From 79d026e9a4e727bd4556a0144ae9d96539c40888 Mon Sep 17 00:00:00 2001 From: Paulo Date: Wed, 9 Sep 2026 18:30:03 +0200 Subject: [PATCH] Replace live subject activity with the run phase (DRU-514) The subject detail response carries the driving run's sandbox phase while it starts. The shell words it, and Build reads the same map. This removes SubjectActivity, App.get_subject_activity(), the Factory phase map, the label and kind prose the platform shipped, and set_run_phase from the author surface. --- backend/druks/apps/base.py | 24 +++---------- backend/druks/contrib/software_factory/app.py | 14 -------- backend/druks/durable/__init__.py | 3 +- backend/druks/durable/reads.py | 9 ++--- backend/druks/durable/schemas.py | 11 ++---- backend/druks/workflows.py | 3 -- .../software_factory/test_api_work_items.py | 35 ++++++------------- backend/tests/test_author_surface.py | 2 -- backend/tests/test_declared_sandboxes.py | 6 ---- docs/writing-an-app.md | 5 ++- frontend/src/api/types.ts | 13 +++---- .../apps/software_factory/WorkItemPage.tsx | 24 ++++++------- .../apps/software_factory/statusLine.test.ts | 16 +++++---- .../src/apps/software_factory/statusLine.ts | 10 +++--- frontend/src/lib/phase.ts | 10 ++++++ frontend/src/pages/SubjectPage.tsx | 4 ++- 16 files changed, 69 insertions(+), 120 deletions(-) create mode 100644 frontend/src/lib/phase.ts diff --git a/backend/druks/apps/base.py b/backend/druks/apps/base.py index 4c29a05a..62a81f4d 100644 --- a/backend/druks/apps/base.py +++ b/backend/druks/apps/base.py @@ -31,7 +31,6 @@ from druks.agents import Agent from druks.doctor import CheckResult from druks.durable.datastructures import Subject - from druks.durable.schemas import SubjectActivity from druks.ui.page import PageRoute from druks.workflows import Workflow @@ -574,7 +573,7 @@ async def upload(file: UploadFile) -> FileSummary: def _get_subject_routes( cls, subject_class: "type[Subject] | type[StoredSubject]" ) -> "APIRouter": - """The board and one subject (header + status + timeline + activity), each with a + """The board and one subject (header + status + timeline + phase), each with a point-in-time read and a ``/stream`` that pushes the whole snapshot on change. Mounted at ``/api//`` for every subject the app's workflows declare. Every read here is keyed by identity, so an app that @@ -606,15 +605,10 @@ async def board(account_id: str | None) -> SubjectList: ) async def subject_response(subject_id: str) -> SubjectResponse | None: - subject = await subject_class.get_for_subject_id(subject_id) - if subject is None: - return - return await reads.get_subject_response( - subject_type, - subject_id, - summary=subject.get_summary(), - activity=await cls.get_subject_activity(subject), - ) + if subject := await subject_class.get_for_subject_id(subject_id): + return await reads.get_subject_response( + subject_type, subject_id, summary=subject.get_summary() + ) @router.get("", response_model=SubjectList, response_model_by_alias=True) async def list_subjects() -> SubjectList: @@ -684,11 +678,3 @@ async def record_event( payload=payload, app=cls.name, ) - - @classmethod - async def get_subject_activity( - cls, subject: "Subject | StoredSubject" - ) -> "SubjectActivity | None": - """The subject's live sub-phase, if any (e.g. "Provisioning sandbox VM…"). Optional — - override to surface a transient signal the running run pushes.""" - return diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index 9b1f8765..459f4478 100644 --- a/backend/druks/contrib/software_factory/app.py +++ b/backend/druks/contrib/software_factory/app.py @@ -18,20 +18,11 @@ from druks.contrib.software_factory.ticketing.jira import Jira from druks.contrib.software_factory.ticketing.linear import Linear from druks.core import services -from druks.db import StoredSubject from druks.doctor import CheckResult from druks.services import ServiceNotConnectedError -from druks.workflows import SubjectActivity from .services import GithubReviewer -# Only what the timeline can't already show. A running agent has an agent call -# to name it, so the phase that clears provisioning maps to nothing. -_PHASE_META: dict[str, SubjectActivity] = { - "provisioning_vm": SubjectActivity(label="Provisioning sandbox VM…", kind="infra"), - "sandbox_building": SubjectActivity(label="Building sandbox…", kind="infra"), -} - async def check_tracker_identity() -> CheckResult: """Whether the selected tracker's identity is connected. Trackerless is a @@ -196,8 +187,3 @@ async def get_tracker(cls, source: str | None = None) -> Tracker | None: prompt="software_factory/review/review_pull_request.md", contract=ReviewReport, ) - - @classmethod - async def get_subject_activity(cls, subject: StoredSubject) -> SubjectActivity | None: - phase = await subject.get_phase() - return _PHASE_META.get(phase or "") diff --git a/backend/druks/durable/__init__.py b/backend/druks/durable/__init__.py index e9f91a8c..c7fbc7ea 100644 --- a/backend/druks/durable/__init__.py +++ b/backend/druks/durable/__init__.py @@ -2,7 +2,7 @@ from .enums import AgentCallStatus, RunState from .exceptions import FatalError, WorkflowError from .models import AgentCall, Run -from .schemas import AgentCallResponse, SubjectActivity, SubjectSummary +from .schemas import AgentCallResponse, SubjectSummary # The durable-execution engine. Internal — authors never import druks.durable; the # doors are druks.workflows (Workflow, Gate, step + these records) and druks.agents @@ -15,7 +15,6 @@ "FatalError", "Run", "RunState", - "SubjectActivity", "SubjectSummary", "WorkflowError", "get_run_phase", diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index ef584e67..28127869 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -21,7 +21,6 @@ ArtifactDescriptor, ArtifactFile, RunResponse, - SubjectActivity, SubjectResponse, SubjectStatus, SubjectSummary, @@ -100,11 +99,7 @@ async def get_subject_phase(subject_type: str, subject_id: str) -> str | None: async def get_subject_response( - subject_type: str, - subject_id: str, - *, - summary: SubjectSummary, - activity: SubjectActivity | None = None, + subject_type: str, subject_id: str, *, summary: SubjectSummary ) -> SubjectResponse: # list_for_subject is newest-first, so runs[0] is the driving run the status # reads — the same row get_latest_for_subject would return, its calls already @@ -115,7 +110,7 @@ async def get_subject_response( summary=summary, status=await _status(latest), timeline=await _timeline(runs), - activity=activity, + phase=await get_run_phase(latest.id) if latest and latest.is_running else None, ) diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 646718f2..d17c28e5 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -196,19 +196,14 @@ class SubjectList(Schema): rows: list[SubjectRow] = Field(default_factory=list) -class SubjectActivity(Schema): - # The running sub-phase the timeline can't show ("Provisioning sandbox VM…"), supplied - # by the app; ``kind`` groups it for display ("infra" | "agent"). - label: str - kind: str - - class SubjectResponse(Schema): summary: SerializeAsAny[SubjectSummary] status: SubjectStatus # The subject's runs, oldest first, each with its agent calls — the timeline. timeline: list[RunResponse] = Field(default_factory=list) - activity: SubjectActivity | None = None + # The driving run's sandbox phase while it starts ("provisioning_vm"). The + # shell supplies the words. + phase: str | None = None class TranscriptChunk(Schema): diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index 122edeff..a57f7828 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -44,7 +44,6 @@ from druks.durable.schemas import ( AgentCallResponse, RunResponse, - SubjectActivity, SubjectStatus, SubjectSummary, ) @@ -74,13 +73,11 @@ "OperatorReply", "RunResponse", "Subject", - "SubjectActivity", "SubjectStatus", "SubjectSummary", "Workflow", "WorkflowError", "WorkflowEvent", - "set_run_phase", "step", "task", ] diff --git a/backend/tests/software_factory/test_api_work_items.py b/backend/tests/software_factory/test_api_work_items.py index 45038c29..70569ea0 100644 --- a/backend/tests/software_factory/test_api_work_items.py +++ b/backend/tests/software_factory/test_api_work_items.py @@ -4,7 +4,7 @@ import pytest from druks.contrib.software_factory.models import WorkItem from druks.durable.reads import list_subject_timeline -from druks.testing import asgi_client, seed_call +from druks.testing import asgi_client, configure_app_for_test, make_settings, seed_call from fastapi.testclient import TestClient from software_factory.factories import make_test_work_item, seed_build_run @@ -17,16 +17,11 @@ } -def _build_app(tmp_path): - from druks.testing import configure_app_for_test, make_settings - - settings = make_settings(tmp_path) - return configure_app_for_test(settings=settings) - - @pytest.fixture async def client(tmp_path: Path, druks_db): - async with asgi_client(_build_app(tmp_path)) as client: + app = configure_app_for_test(settings=make_settings(tmp_path)) + + async with asgi_client(app) as client: yield client @@ -85,7 +80,7 @@ async def test_subject_list_shows_active_and_excludes_resolved(client: TestClien assert "building" in rows assert "merged one" not in rows assert rows["building"]["status"]["state"] == "running" - assert rows["building"]["summary"]["resolution"] is None + assert not rows["building"]["summary"]["resolution"] async def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, druks_db): @@ -244,11 +239,7 @@ async def test_timeline_shows_every_build_attempt(druks_db): assert any(e.failure == "boom" for e in entries) -async def test_subject_activity_surfaces_running_phase(druks_db, monkeypatch): - # A running build run pushes a transient phase; the detail view's live activity - # surfaces it ("Provisioning sandbox VM…") — finer than the lifecycle status. - from druks.contrib.software_factory import app as software_factory_app - +async def test_detail_carries_the_running_phase(client: TestClient, druks_db, monkeypatch): item = await make_test_work_item(repo="ClawHaven/acme-app", title="x") await seed_build_run(druks_db, work_item_id=item.id, state="running") @@ -256,17 +247,13 @@ async def phase(_run_id): return "provisioning_vm" monkeypatch.setattr("druks.durable.reads.get_run_phase", phase) - activity = await software_factory_app.SoftwareFactory.get_subject_activity(item) - assert activity is not None - assert activity.label == "Provisioning sandbox VM…" - assert activity.kind == "infra" - + detail = (await client.get(f"/api/software_factory/work_item/{item.id}")).json() + assert detail["phase"] == "provisioning_vm" -async def test_subject_activity_none_when_not_running(druks_db): - # A run parked on a gate isn't working — no live sub-phase. - from druks.contrib.software_factory import app as software_factory_app +async def test_detail_has_no_phase_when_not_running(client: TestClient, druks_db): item = await make_test_work_item(repo="ClawHaven/acme-app", title="x") await seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate="review_plan") - assert await software_factory_app.SoftwareFactory.get_subject_activity(item) is None + detail = (await client.get(f"/api/software_factory/work_item/{item.id}")).json() + assert not detail["phase"] diff --git a/backend/tests/test_author_surface.py b/backend/tests/test_author_surface.py index 5f9dc5de..e64436f0 100644 --- a/backend/tests/test_author_surface.py +++ b/backend/tests/test_author_surface.py @@ -27,13 +27,11 @@ "OperatorReply", "RunResponse", "Subject", - "SubjectActivity", "SubjectStatus", "SubjectSummary", "Workflow", "WorkflowError", "WorkflowEvent", - "set_run_phase", "step", "task", }, diff --git a/backend/tests/test_declared_sandboxes.py b/backend/tests/test_declared_sandboxes.py index b48e05d5..cae08dc1 100644 --- a/backend/tests/test_declared_sandboxes.py +++ b/backend/tests/test_declared_sandboxes.py @@ -7,7 +7,6 @@ import druks.agents as agent_module import druks.workflows as workflow_module import pytest -from druks.contrib.software_factory.app import _PHASE_META from druks.sandbox import datastructures, templates from druks.sandbox.client import Client from druks.sandbox.datastructures import Sandbox @@ -60,11 +59,6 @@ class Second: assert declared == {hashlib.sha256(b"setup").hexdigest(): other} -def test_software_factory_maps_the_sandbox_building_phase(): - assert _PHASE_META["sandbox_building"].label == "Building sandbox…" - assert _PHASE_META["sandbox_building"].kind == "infra" - - async def test_prepare_sandbox_templates_requests_each_declaration(monkeypatch): sandbox = Sandbox(setup="sandboxes/setup.sh") object.__setattr__(sandbox, "module", "druks_notes.workflows") diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index 2cb0da24..f88f9315 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -734,9 +734,8 @@ method. Druks serves the same `/api/night_watch/repository` surface for both subject types. This surface contains a board, detail pages, and a live stream. Druks mounts it for each declared subject. Each response contains your summary, run -status, timeline, agent calls, artifacts, and active question. Override -`get_subject_activity()` only to add transient app detail, such as -"Building sandbox VM…". +status, timeline, agent calls, artifacts, active question, and the sandbox +phase while a run starts. Pass the subject instance to each component that requires one. This includes a workflow start, gate answer, or event: diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d5f18e07..d6ef83cf 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -78,13 +78,6 @@ export interface SubjectStatus { accountUsername: string | null } -// The live sub-phase a running run pushes ("Provisioning sandbox VM…", "Working…") — -// finer than the lifecycle status; null unless something is actively running. -export interface SubjectActivity { - label: string - kind: string -} - export interface TokenUsage { inputTokens: number outputTokens: number @@ -160,12 +153,14 @@ export interface SubjectRow { // A subject's full read view: domain summary, status, the platform timeline // (the subject's runs, oldest first, each with its agent calls), and the -// app's optional live activity (the running sub-phase). +// driving run's sandbox phase while it starts. export interface SubjectResponse { summary: S status: SubjectStatus timeline: RunSummary[] - activity?: SubjectActivity | null + // The driving run's sandbox phase while it starts ("provisioning_vm"). The shell + // supplies the words. + phase?: string | null } export interface ArtifactFile { diff --git a/frontend/src/apps/software_factory/WorkItemPage.tsx b/frontend/src/apps/software_factory/WorkItemPage.tsx index 9c18cdaf..386ce795 100644 --- a/frontend/src/apps/software_factory/WorkItemPage.tsx +++ b/frontend/src/apps/software_factory/WorkItemPage.tsx @@ -10,7 +10,6 @@ import type { AgentCallSummary, RunState, RunSummary, - SubjectActivity, SubjectStatus, } from '../../api/types' import { DetailLayout } from '../../components/DetailLayout' @@ -19,6 +18,7 @@ import { CancelRun, RetryRun } from '../../components/RunControls' import { GateControls } from '../../druksui/GateControls' import { RunTranscript } from '../../components/RunTranscript' import { computeElapsed, dur, formatTokenCount, relTime, secondsSince } from '../../lib/format' +import { phaseLine } from '../../lib/phase' import { parkedLine, runSubLine, statusLine } from './statusLine' import { agentCallPath, workItemPath } from './slug' import { useRawLocation } from '../../lib/useRawLocation' @@ -190,7 +190,7 @@ function WorkItemView({ data }: { data: WorkItemDetail }) { /> setSelected(id)} /> @@ -308,12 +308,12 @@ function InfoPanel({ function TimelinePanel({ runs, - activity, + phase, selection, onSelect, }: { runs: RunSummary[] - activity?: SubjectActivity | null + phase?: string | null selection: Selection | null onSelect: (id: string) => void }) { @@ -331,7 +331,7 @@ function TimelinePanel({ @@ -343,12 +343,12 @@ function TimelinePanel({ function RunRow({ run, - activity, + phase, selection, onSelect, }: { run: RunSummary - activity?: SubjectActivity | null + phase?: string | null selection: Selection | null onSelect: (id: string) => void }) { @@ -357,7 +357,7 @@ function RunRow({ // A single call duplicates the run's own row (same label, same ledger) — // fold it into the parent instead of showing both. const collapseCalls = run.agentCalls.length <= 1 - const subtitle = runSubLine(run, activity, collapseCalls) + const subtitle = runSubLine(run, phase, collapseCalls) return (
-
{data.activity?.label ?? 'Starting up…'}
+
{phaseLine(data.phase) ?? 'Starting up…'}
no agent call yet — the transcript begins once the agent starts
diff --git a/frontend/src/apps/software_factory/statusLine.test.ts b/frontend/src/apps/software_factory/statusLine.test.ts index 75063473..db10938a 100644 --- a/frontend/src/apps/software_factory/statusLine.test.ts +++ b/frontend/src/apps/software_factory/statusLine.test.ts @@ -103,15 +103,19 @@ describe('runSubLine', () => { it('leaves the step to its own row when the calls are split out', () => { const many = run({ agentCalls: [call({ status: 'succeeded' }), call({ id: 'c2' })] }) - expect(runSubLine(many, { label: 'Provisioning sandbox VM…', kind: 'infra' }, false)).toBe( - 'Provisioning sandbox VM…', - ) + expect(runSubLine(many, 'provisioning_vm', false)).toBe('Provisioning sandbox VM…') }) it('shows the infra phase while no agent has started', () => { - expect(runSubLine(run(), { label: 'Provisioning sandbox VM…', kind: 'infra' }, true)).toBe( - 'Provisioning sandbox VM…', - ) + expect(runSubLine(run(), 'provisioning_vm', true)).toBe('Provisioning sandbox VM…') + }) + + it('names the sandbox build while its template is still building', () => { + expect(runSubLine(run(), 'sandbox_building', true)).toBe('Building sandbox…') + }) + + it('leaves an unnamed phase to the state', () => { + expect(runSubLine(run(), 'agent_running', true)).toBe('running') }) it('falls back to the state when nothing is running yet', () => { diff --git a/frontend/src/apps/software_factory/statusLine.ts b/frontend/src/apps/software_factory/statusLine.ts index 85e8a9b6..16e5e68b 100644 --- a/frontend/src/apps/software_factory/statusLine.ts +++ b/frontend/src/apps/software_factory/statusLine.ts @@ -1,5 +1,6 @@ import type { PRResolution } from './api' -import type { RunSummary, SubjectActivity, SubjectStatus } from '../../api/types' +import type { RunSummary, SubjectStatus } from '../../api/types' +import { phaseLine } from '../../lib/phase' // Build's status-line copy, composed from the platform's status facts — the backend // ships data; the app owns its own vocabulary. @@ -51,10 +52,10 @@ const STATE_LABEL: Record = { } // What a run row says it is doing. A folded-in step names itself, since its own -// row isn't there to; the activity covers what the timeline can't show at all. +// row isn't there to; the phase covers what the timeline can't show at all. export function runSubLine( run: RunSummary, - activity: SubjectActivity | null | undefined, + phase: string | null | undefined, collapsed: boolean, ): string { if (run.state === 'failed' && run.failure) { @@ -66,7 +67,8 @@ export function runSubLine( if (run.state === 'running') { const step = collapsed && run.agentCalls.find((call) => call.status === 'running') if (step) return step.label - if (activity) return activity.label + const line = phaseLine(phase) + if (line) return line } return STATE_LABEL[run.state] ?? run.state } diff --git a/frontend/src/lib/phase.ts b/frontend/src/lib/phase.ts new file mode 100644 index 00000000..25b5d885 --- /dev/null +++ b/frontend/src/lib/phase.ts @@ -0,0 +1,10 @@ +// The sandbox phases a run pushes before its first agent call, in words. A +// running agent call names itself, so the later phase maps to nothing. +const PHASE_LINES: Record = { + provisioning_vm: 'Provisioning sandbox VM…', + sandbox_building: 'Building sandbox…', +} + +export function phaseLine(phase: string | null | undefined): string | null { + return phase ? (PHASE_LINES[phase] ?? null) : null +} diff --git a/frontend/src/pages/SubjectPage.tsx b/frontend/src/pages/SubjectPage.tsx index ef7b482d..9b5e96ee 100644 --- a/frontend/src/pages/SubjectPage.tsx +++ b/frontend/src/pages/SubjectPage.tsx @@ -15,6 +15,7 @@ import { GateControls } from '../druksui/GateControls' import { RunTranscript } from '../components/RunTranscript' import { StatusGlyph } from '../components/StatusGlyph' import { relTimeFromIso } from '../lib/format' +import { phaseLine } from '../lib/phase' import { summaryEntries } from '../lib/summary' const isActiveRun = (run: RunSummary) => @@ -98,6 +99,7 @@ function SubjectDetail({ const data = query.data! const runs = [...data.timeline].reverse() + const now = phaseLine(data.phase) const crumb = (
@@ -115,7 +117,7 @@ function SubjectDetail({ {value} ))} - {data.activity && {data.activity.label}} + {now && {now}} {selectedRun && !runs.some((run) => run.id === selectedRun) && (

This run does not belong to this subject or is no longer available.