feat: add progressive tree output for workflow run#843
Conversation
039ea00 to
d78173c
Compare
Replaces the flat log output with a two-zone terminal UI for `workflow run` when running in a TTY. The scrollback zone permanently renders completed job blocks with output and reports, while the active zone shows live progress with spinners, step expansion, and output peek windows. Key features: - Tree-structured job/step display with ├─ └─ connectors - Spinner animation and elapsed timers for running jobs - Terminal budget system with 5 degradation tiers (full → one_line) - Active zone capped at 50% terminal height with graceful collapse - Branded separator line between scrollback and active zone - Event batching via microtask coalescing to handle 50+ parallel jobs - Falls back to flat log output for non-TTY (CI, pipes) - New `--log` global flag to force flat log output in TTY Implementation: - Reducer-based state management (pure functions, independently testable) - Ink React components with `<Static>` for scrollback permanence - EventBridge pattern batches rapid events into single React dispatches - Added `jobs` field to `started` event for upfront tree skeleton - Added `jobId`/`stepId` to report events for parallel job association - Upgraded ink 5→6.8, react 18→19 for incremental rendering support Also fixes vault secrets leaking into report data files by capturing pre-vault args for report context. Closes #596 Closes #623 Closes #664 Closes #700
d78173c to
f68a66b
Compare
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
--logis global but only meaningful forworkflow run—--logis registered as a.globalOption()so it appears in the help text of every command (e.g.,swamp vault list --help,swamp model run --help). For every command other thanworkflow run, the flag is silently ignored — there is no interactive tree to suppress. A user who reads the flag inswamp data list --helpand tries it will see no difference and get confused. Consider either: (a) making it a local option on theworkflow runsubcommand, or (b) updating the global help description to hint at its limited scope (e.g.,"Force flat log output instead of interactive tree (workflow run only)"). -
--log/--log-levelvisual proximity — In the rendered help output,--logand--log-levelsit next to each other. They're distinct flags and Cliffy handles them correctly, but the names read as a prefix group. Something like--no-treeor--plainwould be unambiguous. Low-priority since the description is clear. -
Separator line brand text is lowercase
swamp— the separator renders as─swamp──── my-workflow ──. If other branded strings in the CLI use title or consistent casing, align here. Minor cosmetic point.
Verdict
PASS — the new tree output is well-structured, degrades gracefully, falls back correctly in non-TTY environments, and JSON mode is unchanged. The --log escape hatch works as documented. No blocking issues.
There was a problem hiding this comment.
Code Review
This is a well-architected PR that adds a progressive tree output for workflow run. The implementation follows the project's conventions closely.
Blocking Issues
None.
Suggestions
-
useEffectmissing dependency — Inworkflow_run_tree.tsx:161, theuseEffectthat callsonDone(state.failed)has[state.phase, state.failed]as deps but omitsonDoneandexit. SinceonDoneis stable in practice (created once in the renderer), this won't cause bugs, but adding it to the dep array would be more correct for React's rules of hooks. -
WorkflowJobInfo/WorkflowRunJobInfonaming — The domain layer usesWorkflowJobInfo(execution_events.ts) and the libswamp layer usesWorkflowRunJobInfo(run.ts). These are structurally identical. The naming difference is fine for layer separation, but aligning them (e.g., both asWorkflowJobInfo) could reduce cognitive overhead since they carry the same fields. -
Shallow Map clone in
cloneJobs—cloneJobsdoesnew Map(jobs)which is a shallow clone. This works becauseupdateJobalways spreads into a new object, but it's worth noting that any future direct mutation of aJobStateinside the cloned map would break the reducer's immutability contract. A brief comment would help future contributors. -
setTimeoutcleanup race in renderer — Inrenderer.tsx:82, thecompletedhandler usessetTimeout(() => this.cleanup?.(), 100)to allow a final render cycle. This is a common Ink pattern, but if the process exits before the timeout fires, cleanup may not run. Not a practical issue (Ink handles process exit), just noting it.
DDD Assessment
The architecture cleanly follows domain-driven design:
- Domain events (
execution_events.ts) are extended withWorkflowJobInfoas a value object — correct choice for identity-free metadata. - Libswamp layer maps domain events to its own
WorkflowRunEventtype, maintaining the anti-corruption layer between domain and presentation. - State management uses a pure reducer (
treeReducer) — the state types (TreeState,JobState,StepState) are value objects modeling UI state, not domain entities, which is appropriate for the presentation layer. - Import boundaries are respected — all presentation imports come from
libswamp/mod.ts, never from internal paths.
Overall
Clean implementation with strong test coverage (state reducer, budget calculation, event bridge, component rendering). The event batching via microtask coalescing is a smart solution for high-parallelism workflows. The 5-tier degradation system handles terminal size constraints gracefully. LGTM.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Data artifact hints are never generated in the tree renderer (
state.ts:572-579,components/data_hints.tsx,components/scrollback_item.tsx:41)The
ScrollbackItemunion type includes adata_hintsvariant (state.ts:102-106), theDataHintscomponent exists and renders artifact commands (data_hints.tsx:29-42), andScrollbackEntryroutes thedata_hintscase (scrollback_item.tsx:41-47). However, no code path ever creates adata_hintsscrollback item. Thecompletedreducer case (state.ts:572-579) setsfinalRun(which containsdataArtifactson steps) but never extracts artifact names or appends adata_hintsentry to scrollback.The design doc explicitly specifies data hints should appear after workflow-scope reports. The
LogWorkflowRunRenderergenerates these hints inrenderDataArtifactHints(workflow_run.ts:184-213). The tree renderer silently omits them.Breaking example: User runs
swamp workflow run deploywhich produces data artifacts. In log mode they seeswamp data get --workflow deploy ec2-statehints. In tree mode (the new default for TTY), they see nothing — they have to know theswamp datacommands already.Suggested fix: In the
completedcase oftreeReducerSingle, extract artifact names fromevent.run.jobs[*].steps[*].dataArtifactsand append adata_hintsscrollback item when any exist. -
Double unmount race between renderer cleanup and React exit (
renderer.tsx:85-91,workflow_run_tree.tsx:123-129)On completion, two independent cleanup paths fire:
renderer.tsx:90:setTimeout(() => this.cleanup?.(), 100)— callsinstance.unmount()after 100msworkflow_run_tree.tsx:127:setTimeout(() => exit(), 0)— calls Ink'sexit()after 0ms (which also triggers unmount internally)
These race against each other. In normal operation, the component's
exit()fires first (0ms timeout), then the renderer'sunmount()fires 100ms later on an already-unmounted instance. Depending on Ink 6's tolerance for double-unmount, this could produce TTY errors or silent failures.Breaking example: On a fast-completing workflow, both timeouts resolve,
exit()tears down Ink, theninstance.unmount()runs on the dead instance. If Ink 6 throws on double unmount (which it may, given the major version bump), this becomes a crash after apparently successful output.Suggested fix: Guard the cleanup: set
this.cleanup = nullafter calling it, and in theonDonecallback, avoid callingexit()since the renderer owns the lifecycle.
Low
-
O(n²)
indexOfin active zone rendering (components/active_zone.tsx:51,111)visibleJobIds.indexOf(id)is called inside a loop overvisibleJobIds, yielding O(n²) behavior. For the "50+ parallel jobs" scenario mentioned in the PR description, this creates ~2500 lookups per render at 15fps. Not a correctness issue, but easily fixed with a Map or using the loop index directly. -
useElapsedhook returns stale value on first render afterstartedAtchanges (hooks.ts:60-76)When
startedAttransitions from null to a timestamp, the effect runs and callssetElapsed(Date.now() - startedAt). But the hook returns theelapsedstate from before this effect runs (initial value 0). The component needs one more render cycle to pick up the correct elapsed time. At 100ms polling this means up to ~100ms of showing "0" elapsed. Cosmetic only. -
Spinner comment says 80ms but constant is 120ms (
hooks.ts:34,37)The JSDoc says "cycling through braille dot patterns at 80ms intervals" but
SPINNER_INTERVAL_MSis 120. Misleading comment only.
Verdict
PASS — The code is well-structured with clean separation of concerns, proper React immutability patterns, and solid event batching. The missing data hints (#1 Medium) is a functional gap worth tracking but doesn't break existing behavior (it's a new renderer). The double-unmount race (#2 Medium) is unlikely to cause visible issues in practice given Ink's typical resilience. No security, data corruption, or crash-in-production-path issues found.
Summary
workflow runin TTY terminals: a scrollback zone for completed work and a live active zone with spinners, step expansion, and output peek--logglobal flag to force flat log output in TTYCloses #596
Closes #623
Closes #664
Closes #700
Test plan
deno run test)deno checkcleandeno lintcleandeno fmt --checkcleanswamp workflow run <name>in TTY shows tree outputswamp --log workflow run <name>forces flat logswamp workflow run <name> | catfalls back to log (non-TTY)swamp --json workflow run <name>unchanged🤖 Generated with Claude Code