Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/druks/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from sqlalchemy import ColumnElement, Index, Select, Text, and_, cast, func, not_, or_, select, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, aliased, mapped_column
from sqlalchemy.types import UserDefinedType

from druks.apps.loader import iter_apps, resolve_workflow_app
Expand Down Expand Up @@ -71,6 +71,7 @@ def get_history(
from druks.durable.enums import WorkflowEvent

owners = [owner.name for owner in iter_apps() if not owner.builtin]
milestone = aliased(cls)
statement = select(cls).where(
cls.app.in_(owners),
or_(
Expand All @@ -85,6 +86,16 @@ def get_history(
),
# A validated gate reply records its result; a routine start records none.
and_(cls.type == WorkflowEvent.RUNNING, cls.payload.has_key("result")),
# A run that recorded no milestone of its own shows that it finished.
and_(
cls.type == WorkflowEvent.FINISHED,
~select(milestone.id)
.where(
milestone.payload["run"] == cls.payload["run"],
milestone.type.not_like("workflow.%"),
)
.exists(),
),
),
)
if app:
Expand Down
14 changes: 13 additions & 1 deletion backend/tests/test_activity_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ async def page(params):

async def test_activity_keeps_decisions_failures_and_stops(druks_db, druks_client):
db_session.registry.set(druks_db)
for kind in ["workflow.running", "workflow.finished", "workflow.step", "workflow.retry"]:
for kind in ["workflow.running", "workflow.step", "workflow.retry"]:
await Event.emit(druks_db, type=kind, app="field_notes", run="gone")
round_facts = {"gate": "review", "input_requested_at": "2026-09-09T01:00:00Z"}
await Event.emit(druks_db, type="workflow.scheduled", app="field_notes", run="gone")
Expand Down Expand Up @@ -185,6 +185,18 @@ async def test_activity_keeps_decisions_failures_and_stops(druks_db, druks_clien
)


async def test_activity_shows_a_finish_only_for_a_run_without_a_milestone(druks_db, druks_client):
db_session.registry.set(druks_db)
await Event.emit(druks_db, type="gist.prepared", app="field_notes", run="announced")
for run in ["announced", "quiet"]:
await Event.emit(druks_db, type="workflow.finished", app="field_notes", run=run)
items = (await druks_client.get("/api/events")).json()["items"]
assert [(item["topic"], item["payload"]["run"]) for item in items] == [
("workflow.finished", "quiet"),
("gist.prepared", "announced"),
]


async def test_destinations_report_what_still_exists(druks_db, druks_client, tmp_path, monkeypatch):
db_session.registry.set(druks_db)
monkeypatch.setenv("DRUKS_DATA_DIR", str(tmp_path))
Expand Down
6 changes: 4 additions & 2 deletions docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -1114,8 +1114,10 @@ Druks records these workflow facts without app calls:
the request time for that round.
- Response received: the concrete gate validated a reply. This does not mean
that the domain accepted the reply's proposal. The workflow decides that.
- Run failed: the run ended with a terminal failure. Routine running and
finished signals remain available to subscribers but do not enter Activity.
- Run finished: the run ended with no announcement and no `to_event()` row of
its own.
- Run failed: the run ended with a terminal failure. Routine running signals
remain available to subscribers but do not enter Activity.
- Run cancelled: an operator cancelled the run through the cancel route. Domain
cleanup that cancels a run records no row and announces no owner outcome.

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ describe('eventLine', () => {
)
})

it('names a finished run', () => {
expect(eventLine(event({ topic: 'workflow.finished', payload: { kind: 'software_factory.profile' } })).label).toBe(
'Profile finished',
)
})

it("words an app's milestone through the app", () => {
const line = eventLine(event({ topic: 'merged', app: 'software_factory' }))

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/feed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CircleDot, CircleX, Clock3, MessageCircleQuestion, MessageSquareReply, Square, type LucideIcon } from 'lucide-react'
import { CircleCheck, CircleDot, CircleX, Clock3, MessageCircleQuestion, MessageSquareReply, Square, type LucideIcon } from 'lucide-react'
import type { EventTopic, FeedItem } from '../api/types'
import { getAppUI, type Tone } from '../apps/registry'
import { zonedParts } from './format'
Expand All @@ -7,6 +7,7 @@ const LIFECYCLE_VERBS: Record<string, string> = {
'workflow.scheduled': 'queued',
'workflow.running': 'response received',
'workflow.parked': 'input requested',
'workflow.finished': 'finished',
'workflow.failed': 'failed',
'workflow.cancelled': 'cancelled',
}
Expand All @@ -15,6 +16,7 @@ const LIFECYCLE_ICONS: Record<string, LucideIcon> = {
'workflow.scheduled': Clock3,
'workflow.running': MessageSquareReply,
'workflow.parked': MessageCircleQuestion,
'workflow.finished': CircleCheck,
'workflow.failed': CircleX,
'workflow.cancelled': Square,
}
Expand Down
Loading