diff --git a/backend/druks/events/models.py b/backend/druks/events/models.py index adc474906..efea7e5fc 100644 --- a/backend/druks/events/models.py +++ b/backend/druks/events/models.py @@ -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 @@ -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_( @@ -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: diff --git a/backend/tests/test_activity_history.py b/backend/tests/test_activity_history.py index 8dc3a91a4..452e0ce18 100644 --- a/backend/tests/test_activity_history.py +++ b/backend/tests/test_activity_history.py @@ -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") @@ -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)) diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index eafaa527e..3df885d34 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -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. diff --git a/frontend/src/lib/feed.test.ts b/frontend/src/lib/feed.test.ts index 533adfcf3..d82fd5400 100644 --- a/frontend/src/lib/feed.test.ts +++ b/frontend/src/lib/feed.test.ts @@ -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' })) diff --git a/frontend/src/lib/feed.ts b/frontend/src/lib/feed.ts index 3b6b10f01..1142f1bba 100644 --- a/frontend/src/lib/feed.ts +++ b/frontend/src/lib/feed.ts @@ -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' @@ -7,6 +7,7 @@ const LIFECYCLE_VERBS: Record = { 'workflow.scheduled': 'queued', 'workflow.running': 'response received', 'workflow.parked': 'input requested', + 'workflow.finished': 'finished', 'workflow.failed': 'failed', 'workflow.cancelled': 'cancelled', } @@ -15,6 +16,7 @@ const LIFECYCLE_ICONS: Record = { 'workflow.scheduled': Clock3, 'workflow.running': MessageSquareReply, 'workflow.parked': MessageCircleQuestion, + 'workflow.finished': CircleCheck, 'workflow.failed': CircleX, 'workflow.cancelled': Square, }