Plan — Issue #178: per-step workflow status (alive vs dead)¶
"workflow each step 的狀態需要能夠看到更詳細的內容, 不然我不知道他是死了還是正在跑"
Builds on #100 (workflows) and the prior workflow-observability work. Grill-locked
(see "Locked decisions" below). Flat integer phases per project convention; backend
via /tdd + 100% coverage gate, frontend via /tdd + vitest. No new auto-kill —
display only, consistent with the earlier rejection of timeout/watchdog/heartbeat.
Problem¶
Each step emits only point events (step_started → silence → step_passed/failed/
skipped/retrying), carrying just phase/name/key (+reason on fail/retry). No
timing, no in-flight progress, no retry count. The prominent workflow UI
(WorkflowPhaseDiagram / WorkflowRunPanel) is phase-level only; per-step
status is scattered in the chat feed. So:
- A long deterministic
sandbox_node(e.g. a 5-min script) is silent until it ends → looks dead. (Agent steps already stream reasoning/tools into the chat.) - There is no per-step status board to watch "which step, how long, how many retries", and it does not survive a reload (per-step state is live-SSE only).
Locked decisions (from grill)¶
- Backend real liveness, not a FE-only timer. A FE timer keeps ticking when the backend is wedged, so it proves only "FE still streaming". We stream real output where it exists.
- Home = a per-step status board in the workflow panel. Each phase in the diagram expands to its steps; the chat feed stays the home for agent reasoning.
- Stream deterministic stdout (
sandbox_node) via the existingsandbox.exec on_outputprimitive → a newstep_outputevent folded into the running step row. Ingest stays as-is (per-file step rows already move; noIngestorsurgery — that within-one-file progress is #162 territory). - Per-step detail split: deterministic rows expand to live stdout; agent rows show status+elapsed and point to the chat. No journal-artifact file inspector.
- Persist per-step state on
WorkflowRun(not ephemeral SSE) so the board survives a reload and shows server-side elapsed ("running 4:32" even when wedged). Consistent with "WorkflowRun holds status, not results" — stdout stays ephemeral. - Collapse loop elements. Same-named loop steps fold into the phase
done/totalcounter; the board persists/shows only distinct-named steps (with duration) + the currently-running element + failed/retrying elements (reuse the existingfailureslist). Keeps the resource bounded and the board uncluttered. - Silent steps (no stdout at all): the only honest signals are server-side elapsed + making the stream connection status visible (connected = backend reachable; dropped = "may have stopped"). No heartbeat — a loop-alive ping can't detect a wedged subprocess (it would falsely read "alive"), and total backend death already shows as an SSE disconnect. We document the ceiling: a silent step's "wedged vs slow" is undecidable without output — which is exactly why we stream stdout where it exists.
Phases¶
P1 — step_output event (schema)¶
workflow/events.py: addStepOutput(phase, name, text, key="")frozen dataclass; add toWorkflowEventunion.api/events.py: fold into theAgentEventunion +to_sse(frozen dataclass with atypefield — no extra wiring).web/src/events.ts: mirrorStepOutput+ add to the union.- Tests: to_sse round-trip; FE type guard.
P2 — stream sandbox stdout¶
workflow/handle.py: widenRunSandboxto accept an optionalon_output(OutputSink = Callable[[bytes], None], matchingsandbox/protocol.py).workflow/steps.pysandbox_node: pass anon_outputsink that emitsStepOutput(phase, name=name or phase, key, text=chunk.decode(...))via the engine_emit(wf, …).api/app.py_wf_run_sandbox: forwardon_outputtosandbox.exec(handle, cmd, on_output=…).workflow/orchestrator.py_on_event: short-circuitStepOutput— publish on the stream and return; do NOT enter_apply_progress(no_patchper chunk).- Tests:
MockSandboxstreaming chunks →sandbox_nodeemitsStepOutputper chunk; orchestrator publishesStepOutputwithout patching the resource.
P3 — persist per-step state on WorkflowRun¶
workflow/run.py: addStepState(phase, name, key="", status="running", attempts=1, reason="", started: int|None, ended: int|None)+steps: list[StepState](additive, default empty → no migration; not indexed — display data).workflow/orchestrator.py_apply_progress: upsert/collapse the step record alongside the existing phase-counter_patch(same write, no extra DB round-trips):StepStarted→ upsert running record, stampstarted(epoch ms).StepRetrying→attempts += 1, setreason.StepPassed/StepSkipped→ ifkey == ""keep aspassed/skipped+ended; ifkey != ""(loop element) drop the record (folds intophase.done).StepFailed→ keep asfailed+ended+reason(failed loop elements also remain in the existingfailureslist).- Time source: a small injectable
now_ms(mirror howdriverstampsstarted/ended) so tests are deterministic. - Tests: a 3-element loop leaves 0 step rows +
done=3; a distinct named step keeps itspassedrow with a duration; a failed element keeps afailedrow; reload (re-_get) returns the persistedsteps.
P4 — step status board (FE)¶
- New board (extend
WorkflowPhaseDiagramor a childWorkflowStepBoard.tsx): each phase row expands to its steps fromrun.steps(poll), overlaid with live SSE: status badge, elapsed (serverstarted→ ticking now / final duration onended), retry count,reason. Deterministic rows expand to live stdout (foldStepOutputinto the running row'sliveOutput, mirroring thetool_loghandling). Agent rows show status + elapsed + a "see conversation" hint. - Collapsed loop view: "{done}/{total} · 1 failed · running {key} ({elapsed})" from the
phase counter + the running step record +
failures. - Move step rendering out of the chat feed (
agentLog.tsstops folding thestep_*events into feed lines) so steps have one home; the chat keeps agent turns (+ optionally a lightphase_entereddivider). - Tests (vitest): board renders pending/running/passed/failed/skipped/retrying; elapsed ticks for a running row and freezes on done; deterministic row shows live stdout; loop collapses to a counter; agentLog no longer emits step feed lines.
P5 — connection status visible (FE)¶
- Surface the SSE stream connection state in the run panel ("Connected — backend
reachable" / "Disconnected — the run may have stopped"), reusing the existing stream
state from
useWorkflow/useAgent. This is the silent-step liveness backstop. - Tests (vitest): connected vs disconnected rendering.
P6 — copy + i18n + wiring¶
- Strings via
useT(zh-TW + en); de-jargon per the UI-copy rule (nosandbox/stdoutinternals in user-facing text — "execution environment" / "output"). Mount the board inWorkflowRunPanel. - Tests (vitest): locale strings resolve; panel mounts the board.
P7 — DoD: full gate + live check¶
- Backend:
coverage … --fail-under=100,ruff check,ruff format --check,ty check. Frontend: vitest +pnpm typecheck+pnpm build. - Live check (LLM/workflow features need a real run, not just fake-LLM tests): run a real workflow against local Ollama and confirm, by eye:
- the board moves step-by-step (a deterministic phase no longer looks frozen);
- a long
sandbox_node's stdout streams live into its row; - reloading mid-run keeps the board + a correct server-side elapsed;
- killing the stream shows the "may have stopped" banner.
Deferred (out of scope, documented)¶
- Within-one-file ingest progress (Ingestor is opaque; #162's index-status territory).
- A journal-artifact file inspector (click a step → its
step_*/<key>.json). - Full stdout replay after a reload for a still-running step (ephemeral; reappears as new chunks stream — the persisted row + elapsed is the reload signal).
- Any new auto-kill / timeout / watchdog / heartbeat (display only; user self-Stops).