fix(workflow): align resume --json envelope and filter expired approvals#1473
Conversation
Two manual-approval inconsistencies surfaced while writing UAT coverage (swamp-uat #470). Issue 1: `workflow resume --json` emitted underscore-prefixed top-level keys (`_status`, `_startedAt`, `_jobs`, …) because the resume command consumed the domain `service.resume()` stream directly and serialized the raw `WorkflowRun` class instance, leaking its private fields. The `run` and `history get` paths instead map domain events through libswamp's `toRunData()`. Promote the internal `mapEvent` to an exported `mapWorkflowExecutionEvent(event, runRepo, verbose?)` and route resume through it, so all three paths emit the identical canonical envelope. Issue 2: `workflow approvals` listed runs whose approval deadline had already lapsed because the `now > suspendedAt + timeout` check lived only inline in `workflow approve`. Extract it into a shared domain helper `evaluateApprovalTimeout()` and apply it in both commands — `approve` still rejects expired gates, and `approvals` now filters them out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Clean, well-structured fix for two real bugs. CI green on Linux and Windows.
Blocking Issues
None.
Suggestions
-
Verbose comments in
approval_timeout.ts(lines 34–46): The multi-paragraph JSDoc onevaluateApprovalTimeoutexplains WHAT it does (clear from the signature) and references callers (workflow approve,workflow approvals). Per CLAUDE.md, prefer no comments or one-line-max, and avoid referencing callers since those references rot. The interface doc onApprovalTimeoutis fine. Consider trimming to just the non-obvious bit: theundefinedreturn semantics. -
Comment in
run.ts(lines 373–380): Similar — the expanded JSDoc onmapWorkflowExecutionEventreferencesrunandresumepaths and explains the private-field leak. The WHY (preventing_statusleaking viaJSON.stringify) is worth keeping; the caller list is not.
Neither blocks merge — the logic, tests, DDD placement, and import boundaries are all correct.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
workflow approvalssilently drops expired entries — when a run is suspended but its approval gate has timed out, the listing omits it without explanation. A user who knows a run is suspended and runsswamp workflow approvalsto find it will see "No workflows awaiting approval" with no indication that an expired gate was filtered. A--verbosedebug line (or a note like "1 run skipped (approval deadline elapsed)") would aid diagnostics, but the current behavior is at least accurate — showing entries thatapprovewould immediately reject would be worse. -
resumeignores--verbosewhen building JSON output —workflow runpassesctx.verbosity === "verbose"tomapWorkflowExecutionEvent;workflow resumepasses nothing (defaults tofalse). Soswamp workflow resume --verbose --jsonproduces the same JSON as without--verbose. This gap pre-dates this PR (before, the raw domain object with_fields was even more broken), so this PR doesn't regress anything — just worth a follow-up to align the two paths fully.
Verdict
PASS — both fixes are correct and improve UX. resume --json now emits the same canonical envelope as run --json (no more _status/_startedAt leakage), and workflow approvals no longer lists gates that approve would immediately reject.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
src/cli/commands/workflow_resume.ts:184—verbosealwaysundefinedin the resume path.mapWorkflowExecutionEvent(event, runRepo)is called without the thirdverboseargument, sotoRunDatanever includes step artifacts in thecompleted/suspendedenvelope. Theresumecommand has no--verboseflag, so this is consistent — but if--verboseis ever added toresume, someone will need to wire it through here. This is not a regression (the old code bypassedtoRunDataentirely), just a gap to be aware of. -
src/cli/commands/workflow_resume.ts:176-185— resume path does not accumulatereport_completed/report_failedevents onto the final run view. TheworkflowRungenerator inrun.ts:548-585collects report events and attaches them to the completed/suspended run view via{ ...mapped, run: { ...mapped.run, reports: reportResults } }. The resume generator does not. Ifservice.resume()yields report events, they'll be emitted individually but not aggregated on the final run view. This is pre-existing behavior (the old code had the same omission), not introduced by this PR, but worth noting since the mapping is now shared.
Low
- TOCTOU between timeout check and approval action in
workflow_approve.ts:102-114. Time passes betweenevaluateApprovalTimeout(step.startedAt, wfStep?.task.data, new Date())andstep.recordApprovalDecision()at line 119. Theoretically, a timeout could lapse in that window. In practice the window is sub-millisecond in a sequential CLI command, and this is the same race the old inline code had. Not a concern in practice.
Verdict
PASS. Clean, well-structured extraction. The evaluateApprovalTimeout helper correctly mirrors the old inline logic (strict > comparison, identical guard conditions for undefined/non-approval/no-timeout), the mapWorkflowExecutionEvent refactor correctly narrows the signature from (event, deps, input) to (event, runRepo, verbose?), the exhaustive switch covers all WorkflowExecutionEvent kinds, and the timeout field's Zod schema (z.number().positive().optional()) ensures !taskData.timeout can never accidentally filter out 0. Test coverage is thorough with 7 cases covering the boundary at exactly-at-deadline. The libswamp barrel export is properly added. No correctness, security, or data integrity issues found.
Summary
Fixes two manual-approval JSON/listing inconsistencies surfaced while writing UAT coverage (swamp-uat #470).
Issue 1 —
workflow resume --jsonleaked_status-prefixed keysrun --jsonandhistory get --jsonmap the domainWorkflowRunthrough libswamp'stoRunData()into a plainWorkflowRunView, exposing canonical keys (status,startedAt,jobs, …). Theresumecommand bypassed libswamp, consumed the domainservice.resume()stream directly, andyielded the rawWorkflowRunclass instance — soJSON.stringifyserialized its private fields (_status,_startedAt,_jobs,_logFile,_tags,_workflowDataArtifacts).Fix: Promote the internal
mapEventto an exportedmapWorkflowExecutionEvent(event, runRepo, verbose?)(lighter signature — only ever needed the run repo + verbose flag), export it fromlibswamp/mod.ts, and route the resume stream through it. All three paths now emit the identical envelope.Issue 2 —
workflow approvalsover-reported timed-out runsThe approval deadline check (
now > suspendedAt + timeout) lived only inline inworkflow approve, soworkflow approvalskept listing expired gates as actionable.Fix: Extract the single source of truth into a domain helper
evaluateApprovalTimeout(startedAt, taskData, now)insrc/domain/workflows/approval_timeout.ts.approverejects expired gates with the same message;approvalsnow skips them (if (timeout?.expired) continue).swamp-uat #470 / PR #241 currently pins the old behavior. Both assertions must be updated in lockstep:
status(+ thetoRunDataenvelope), not_status/_startedAt/…Test Plan
src/domain/workflows/approval_timeout_test.ts(7 cases — expired, in-window, exactly-at-deadline, no timeout, never-started, non-approval task, absent task data).deno check,deno lint,deno fmt, full suite (6464 passed / 0 failed),deno run compile— all green.resume --jsonnow reportsstatus = succeededwith keys[duration, id, jobs, path, status, workflowId, workflowName](no underscores).approverejects withApproval timed out…, andapprovals --jsonreturns{ "approvals": [] }.🤖 Generated with Claude Code