diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3b8ad64dc..33763dbcd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,12 +1,19 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Workflow run targets now require the full 36-character run UUID. Typed prefixes are no longer resolved by any command or workflow-tool action that accepts `runId`, including `status`, `stages`, `stage`, `transcript`, `send`, `pause`, `resume`, `interrupt`, and `quit`, and by `/workflow connect`, `/workflow attach`, and `/workflow resume`. A target that is not a well-formed 8-4-4-4-12 hex UUID — a prefix, a 32-character dashless id, or a same-length non-hex string — is rejected with `Run id must be a full 36-character UUID; got "339e05a4" (8 chars).`, which is deliberately distinct from `Run not found:` so a truncated paste is diagnosable as truncated rather than looking like a stale run. Since every user-facing surface already prints the full id, copy it back verbatim. Because ids are unique and now matched exactly, run-target ambiguity is unreachable and the "Ambiguous run prefix" diagnostic is gone. +- Stage targets are now matched exactly. A `stageId` resolves by exact stage id — a bare UUID at the root, the full `runId:stageId` composite for a stage inside a nested `ctx.workflow(...)` import, or `tool:` for a `ctx.tool` node — or by exact stage or tool name. Prefixes and partial names no longer resolve, so `build` will not select `build-check`. Stage ids are deliberately **not** held to the 36-character rule, because nested and tool identifiers are legitimately longer or differently shaped; nested-stage and tool-node targeting are unchanged. Two stages sharing an exact name still return the existing ambiguity diagnostic. + ### Changed - Removed the four-workflow display cap from the BACKGROUND widget so every qualifying top-level run is rendered. - Moved durable workflow run artifacts—including goal ledgers, Ralph implementation notes, QA evidence video paths, and worktree task outputs—from per-invocation OS temp directories to the run-scoped durable root under the Atomic config directory (`~/.atomic/workflows/runs//`, overridable with `ATOMIC_WORKFLOW_ARTIFACT_DIR`), so they survive OS temp purges and follow state-aware retention. - Toggling tool-output expansion (`ctrl+o`) no longer prints a `Tool output: expanded` / `Tool output: collapsed` status line. The chat re-renders in the new state, which is the same information without the extra line. +- Workflow run identifiers are now shown as full UUIDs across the BACKGROUND widget, status and detail views, run pickers, control messages, and awaiting-input attribution banners. BACKGROUND cards use a two-line identity layout at 80 columns and wider, while narrow chat surfaces wrap full ids without cutting them or breaking their borders. The public `workflow({ action: "status", format: "json" })` payload no longer includes `runIdPrefix`; read `runId` instead, which now carries the full id. The `/workflow connect` run picker now shows five runs at a time and scrolls to additional retained runs with the arrow keys or mouse wheel. The picker's type-to-filter box still narrows by name substring or id prefix, since that selects a row from a list rather than resolving a typed id. + ### Fixed - Quit workflow cards now expire from the BACKGROUND widget after the same recent-run window as finished cards while remaining resumable and discoverable through workflow status; the header count now matches the rendered cards after expiry. diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index e83517156..41090bed5 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -136,7 +136,7 @@ Atomic chooses a complete execution shape, fills inputs from the request, and co ### Monitor and steer a run -Named workflow runs execute in the background. After launch you get a run id; use it to inspect, connect, pause, quit, or resume. +Named workflow runs execute in the background. After launch you get the full run id; user-facing workflow surfaces show that complete UUID. You can still type the full id or a unique short prefix to inspect, connect, pause, quit, or resume a run. Ambiguous prefixes are reported rather than selecting a run arbitrarily. ```text /workflow status # inspect one run's progress @@ -148,6 +148,8 @@ Named workflow runs execute in the background. After launch you get a run id; us /workflow quit # pause gracefully and keep the run resumable ``` +The below-editor `BACKGROUND` panel uses two lines per card at 80 columns and wider: the status glyph and full id are on the first line, and the workflow name plus mode/progress/elapsed metadata are on the second. Below 80 columns it collapses to a count-only line. In chat surfaces, a full id wraps onto continuation lines at narrow widths instead of being cut, and the surrounding border remains intact. + Human-in-the-loop prompts (`ctx.ui.input`, `confirm`, `select`, `editor`) surface in the graph viewer, not as chat modals — connect to the run to answer them. Atomic also posts main-chat lifecycle notices when a run completes, fails, or awaits input. If you answer a workflow prompt in the graph or attached stage chat, the main chat receives a display-only answer summary for audit; it does not wake the model, enter LLM context, or answer later prompts. See [Workflows](/workflows) for the full reference and authoring guide. diff --git a/packages/coding-agent/docs/workflows.md b/packages/coding-agent/docs/workflows.md index e04b70717..da15a0f42 100644 --- a/packages/coding-agent/docs/workflows.md +++ b/packages/coding-agent/docs/workflows.md @@ -113,11 +113,35 @@ List and run it like any other workflow: /workflow key=value ... ``` -Named workflow runs execute in the background. By default, after launch expect a run id and monitor it with `/workflow status `, F2, or `/workflow connect `. A definition with `autoAttach: true` instead opens the graph overlay as soon as an interactive top-level named launch through `/workflow ` or the registered `workflow` tool is accepted. This option does not affect headless launches or nested `ctx.workflow(...)` calls, and existing input-form launch behavior is unchanged. +Named workflow runs execute in the background. By default, after launch expect a full run id and monitor it with `/workflow status `, F2, or `/workflow connect `. A definition with `autoAttach: true` instead opens the graph overlay as soon as an interactive top-level named launch through `/workflow ` or the registered `workflow` tool is accepted. This option does not affect headless launches or nested `ctx.workflow(...)` calls, and existing input-form launch behavior is unchanged. For a request with several implementation items, do not turn list order into one serial workflow by default. Triage dependencies first, then launch independent items as a bounded wave of separate top-level runs; see [Task queues and software factories](#task-queues-and-software-factories). -While a workflow is running, the visible below-editor `BACKGROUND` panel advances its elapsed label every second from the moment the run starts; it does not require opening or switching to the orchestrator. Updates repaint the existing mounted panel in place, paused timers stay frozen, the panel renders every qualifying top-level run, and terminal or quit cards retain their short recent-run expiry. Quit cards remain resumable and discoverable with `/workflow status` after they leave the panel. +While a workflow is running, the visible below-editor `BACKGROUND` panel advances its elapsed label every second from the moment the run starts; it does not require opening or switching to the orchestrator. Updates repaint the existing mounted panel in place, paused timers stay frozen, the panel renders every qualifying top-level run, and terminal or quit cards retain their brief recent-run expiry. Quit cards remain resumable and discoverable with `/workflow status` after they leave the panel. + +### Workflow run identifiers and the BACKGROUND panel + +Workflow run identifiers are shown in full everywhere they are presented to users: the `BACKGROUND` panel, workflow status and detail views, run pickers, control messages, and awaiting-input attribution banners. Input matches that: every command and workflow-tool action that accepts `runId` requires the **full 36-character UUID**, exactly as displayed. Typed prefixes are not accepted, and neither is a 32-character dashless form. A target that is not a well-formed UUID is rejected with `Run id must be a full 36-character UUID; got "339e05a4" (8 chars).`, which is deliberately distinct from `Run not found:` so a truncated paste is diagnosable as truncated rather than looking like a stale run. Because ids are unique and matched exactly, a run target can no longer be ambiguous. + +Stage targeting is exact but not UUID-bound, because stage identifiers are not all bare UUIDs. A `stageId` resolves by exact stage id — a bare UUID at the root, the full `runId:stageId` composite for a stage inside a nested workflow, or `tool:` for a `ctx.tool` node — or by exact stage or tool name. Partial names no longer match, so `build` will not select `build-check`. Two stages that share an exact name are still reported as ambiguous, listing the full matching identifiers. + +At 80 columns and wider, each `BACKGROUND` card uses two rows so the id is not squeezed beside the workflow name: the first row contains the status glyph and full UUID, and the second contains the workflow name followed by its mode, progress, and elapsed/status metadata. The panel renders every qualifying top-level run, so each card is two rows high (plus the existing spacing between cards). Below 80 columns, the panel keeps its collapsed count-only form and does not render an id. + +For chat surfaces such as workflow status, run detail, dispatch confirmation, and the run picker, a full id wraps onto continuation rows when the card is narrower than the id. The renderer never ellipsizes the id and keeps the card border closed at its minimum layout width, while terminals below that floor — including sub-30-column terminals — can hard-clip the box. An awaiting-input attribution banner is titled `AWAITING INPUT` and contains the same two identity rows — `?` plus the full run id, then the workflow name and optional metadata — while the existing prompt question and options remain below it in the normal prompt UI. + +The `/workflow connect` run picker shows five runs at a time; use the arrow keys or mouse wheel to scroll through additional retained runs. + +The rendered card shape at the 80-column breakpoint is: + +```text +│ ● 339e05a4-2289-408e-9076-d1a348f582ae │ +│ stage-output-transcript · chain · 2/3 · 12m │ +│ │ +│ ● d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f │ +│ build-check · chain · 0/2 · 12m │ +``` + +Below the breakpoint the same run set is represented by the collapsed count line, for example ` ▾ 4 background · 2 ● · 1 quit`. ### Or hand-write the TypeScript @@ -749,7 +773,7 @@ Fan out repository research by subsystem, save each branch as an artifact, and s Run open-claude-design to refresh the settings page hierarchy. ``` -If required inputs are missing or ambiguous, Atomic asks for them or opens the inline picker. Named runs execute in the background and return a run id. +If required inputs are missing or ambiguous, Atomic asks for them or opens the inline picker. Named runs execute in the background and return a full run id. ## Writing a Workflow @@ -1333,7 +1357,7 @@ Missing required outputs, schema type mismatches, and non-JSON-serializable retu Pass only workflow definitions to `ctx.workflow(...)`. Import reusable workflows with TypeScript `import` statements first; registry names are only for top-level named runs, not `ctx.workflow(...)` arguments. If a module is missing or does not export a workflow definition, workflow discovery fails when loading that module. Nested child workflows count against `maxDepth` (default `4` total workflow levels). -Atomic hides an import boundary only when the referenced child run is non-empty and reciprocally identifies that parent run and boundary stage. The same rule applies recursively at deeper nesting levels. If no valid child graph can stand in for the boundary—including a failed or skipped boundary, a missing or empty child graph, stale or mismatched ownership metadata, or a recursive link that cannot produce a valid expansion—the graph keeps the boundary summary node instead of flattening an unrelated or invalid child. Running and completed boundaries with valid child graphs are flattened; completed summaries still retain the child workflow name, child run id prefix, and exposed output count for replay/debugging when fallback is required. +Atomic hides an import boundary only when the referenced child run is non-empty and reciprocally identifies that parent run and boundary stage. The same rule applies recursively at deeper nesting levels. If no valid child graph can stand in for the boundary—including a failed or skipped boundary, a missing or empty child graph, stale or mismatched ownership metadata, or a recursive link that cannot produce a valid expansion—the graph keeps the boundary summary node instead of flattening an unrelated or invalid child. Running and completed boundaries with valid child graphs are flattened; completed summaries still retain the child workflow name, full child run id, and exposed output count for replay/debugging when fallback is required. Use `stageName` when the parent needs a more specific label, but keep it concise so the child summary remains readable in the graph. @@ -2794,7 +2818,7 @@ From interactive chat, named workflow launches run in the background so the pare `workflow({ action: "models" })` returns the registry's configured-auth catalog snapshot in registry order. Each entry includes `provider`, `id`, `fullId`, an `isCurrent` marker, and `availableThinkingLevels` derived from the real model's `reasoning` and `thinkingLevelMap` metadata. This is not proof of credentials, entitlements, OAuth freshness, or live provider access, and it exposes no authentication details. -Named launches wait only for **startup admission**, not for workflow completion. Atomic returns `status: "running"` after durable registration, reusable-worktree setup, and other pre-body setup succeed, while the workflow body and stages continue in the background. If setup fails before the workflow body is admitted — for example, `git_worktree_dir` points inside the invoking checkout — the original `workflow` tool call instead returns a structured `status: "failed"` result with the allocated run id and concrete setup error. No background-start claim or orphan run is retained, so the caller can correct the inputs and retry immediately. Failures after admission remain ordinary background lifecycle outcomes reported through status and lifecycle notices. +Named launches wait only for **startup admission**, not for workflow completion. Atomic returns `status: "running"` after durable registration, reusable-worktree setup, and other pre-body setup succeed, while the workflow body and stages continue in the background. If setup fails before the workflow body is admitted — for example, `git_worktree_dir` points inside the invoking checkout — the original `workflow` tool call instead returns a structured `status: "failed"` result with the allocated full run id and concrete setup error. No background-start claim or orphan run is retained, so the caller can correct the inputs and retry immediately. Failures after admission remain ordinary background lifecycle outcomes reported through status and lifecycle notices. A model may launch in the foreground only when the user explicitly requests it or foreground execution is technically required, and it must tell the user before launching. @@ -2841,7 +2865,7 @@ If you copy a HIL workflow example into a headless session, it can pass dispatch /workflow interrupt /workflow quit /workflow resume [stage-id-or-name] [message] -/workflows [workflow-id-or-prefix] +/workflows [full-workflow-uuid] /workflow reload ``` @@ -2891,49 +2915,49 @@ workflow({ action: "status" }) // list every se workflow({ action: "status", statusFilter: "running" }) // filter the run listing by status workflow({ action: "status", statusFilter: "awaiting_input" }) // runs with a pending human prompt workflow({ action: "status", format: "json" }) // structured listing for programmatic use -workflow({ action: "status", runId: "" }) // full detail for one run +workflow({ action: "status", runId: "" }) // full detail for one run -workflow({ action: "stages", runId: "", statusFilter: "all" }) -workflow({ action: "stage", runId: "", stageId: "review" }) +workflow({ action: "stages", runId: "", statusFilter: "all" }) +workflow({ action: "stage", runId: "", stageId: "review" }) // Prefer sessionFile/transcriptPath from stages/stage; quote the exact path, preserve Windows separators, then search/read small ranges. -workflow({ action: "transcript", runId: "", stageId: "review" }) +workflow({ action: "transcript", runId: "", stageId: "review" }) // Omit tail/limit for the default 5-entry preview; pass them for quick recent-context checks. -workflow({ action: "transcript", runId: "", stageId: "review", tail: 40 }) -workflow({ action: "transcript", runId: "", stageId: "review", limit: 20, includeToolOutput: true }) +workflow({ action: "transcript", runId: "", stageId: "review", tail: 40 }) +workflow({ action: "transcript", runId: "", stageId: "review", limit: 20, includeToolOutput: true }) // send is admitted only while the authoritative root workflow is nonterminal. -workflow({ action: "send", runId: "", stageId: "review", text: "please focus on tests" }) -workflow({ action: "send", runId: "", stageId: "approval", promptId: "prompt-1", response: true, delivery: "answer" }) -workflow({ action: "send", runId: "", stageId: "review", message: "continue with tests", delivery: "resume" }) +workflow({ action: "send", runId: "", stageId: "review", text: "please focus on tests" }) +workflow({ action: "send", runId: "", stageId: "approval", promptId: "prompt-1", response: true, delivery: "answer" }) +workflow({ action: "send", runId: "", stageId: "review", message: "continue with tests", delivery: "resume" }) -workflow({ action: "pause", runId: "" }) -workflow({ action: "pause", runId: "", stageId: "review" }) +workflow({ action: "pause", runId: "" }) +workflow({ action: "pause", runId: "", stageId: "review" }) -workflow({ action: "interrupt", runId: "" }) +workflow({ action: "interrupt", runId: "" }) workflow({ action: "interrupt", all: true }) -workflow({ action: "resume", runId: "" }) -workflow({ action: "resume", runId: "", stageId: "review", message: "continue" }) +workflow({ action: "resume", runId: "" }) +workflow({ action: "resume", runId: "", stageId: "review", message: "continue" }) -workflow({ action: "quit", runId: "" }) +workflow({ action: "quit", runId: "" }) workflow({ action: "quit", all: true }) // Abort one in-flight ctx.tool node without pausing the run. -workflow({ action: "quit", runId: "", stageId: "tool:" }) -workflow({ action: "interrupt", runId: "", stageId: "publish-artifact" }) +workflow({ action: "quit", runId: "", stageId: "tool:" }) +workflow({ action: "interrupt", runId: "", stageId: "publish-artifact" }) workflow({ action: "reload", reason: "added team workflow" }) ``` Control behavior: -- `runId` accepts full run ids or unique prefixes for every lifecycle and inspection action, including `status`. The abbreviated IDs printed by status surfaces are valid inputs. Exact IDs take precedence; a prefix shared by multiple runs returns an ambiguity diagnostic with longer matching prefixes instead of selecting the first run. Status lists and run pickers show top-level user-launched workflows; nested child runs are implementation details of the expanded parent graph. -- `status` without `runId` lists every top-level run in the session with a concise per-run summary: run id plus abbreviated prefix, workflow name, run status, started/ended timing with pause-adjusted elapsed time, currently active stages, and awaiting-input details (count plus the stage, prompt id, kind, and message for each pending human prompt). In-flight runs are listed first. The summaries carry the exact identifiers that `pause`/`resume`/`interrupt`/`quit`/`send` accept, so an orchestrating agent can list runs and act on them directly. +- `runId` requires the full 36-character run UUID for every lifecycle and inspection action, including `status`. User-facing status surfaces print that exact value, so pass it back verbatim; typed prefixes are rejected with a distinct `Run id must be a full 36-character UUID` diagnostic rather than resolved. Because ids are matched exactly and are unique, no run target is ambiguous. Status lists and run pickers show top-level user-launched workflows; nested child runs are implementation details of the expanded parent graph. +- `status` without `runId` lists every top-level run in the session with a concise per-run summary: the full run id, workflow name, run status, started/ended timing with pause-adjusted elapsed time, currently active stages, and awaiting-input details (count plus the stage, prompt id, kind, and message for each pending human prompt). In-flight runs are listed first. The summaries carry the exact identifiers that `pause`/`resume`/`interrupt`/`quit`/`send` accept, so an orchestrating agent can list runs and act on them directly. - `statusFilter` narrows the `status` run listing: run statuses (`pending`, `running`, `paused`, `blocked`, `completed`, `failed`, `skipped`, `cancelled`, `killed`) match runs directly, `awaiting_input` selects runs with at least one stage awaiting input or pending human prompt, and `all` (the default) includes everything. - `format: "json"` on data-bearing inspection actions (`status`, `stages`, `stage`, `transcript`) returns the full structured result; the default text output for `status` is the concise per-run summary list. - `status` / `status ` show terminal `ctx.exit(...)` statuses (`completed`, `skipped`, `cancelled`, or `blocked`) and the optional exit reason when one was supplied. - `stages` lists stage summaries, including flattened stages from nested `ctx.workflow(...)` imports and `sessionFile`/`transcriptPath` when a stage has a persisted session. Use `statusFilter: "all"` to include completed, failed, skipped, and pending stages. -- `stage` returns details for one stage by stage id, unique prefix, or stage name, including nested child stages shown in the expanded graph and the persisted `sessionFile` when available. Abbreviated stage IDs printed in graph/control messages use this same unique-prefix resolver; collisions return an ambiguity diagnostic rather than selecting a stage. +- `stage` returns details for one stage by exact stage id or exact stage name, including nested child stages shown in the expanded graph and the persisted `sessionFile` when available. User-facing graph and control messages print full stage IDs; pass one back verbatim, or use the stage's exact name. Prefixes and partial names no longer resolve. Two stages sharing an exact name return an ambiguity diagnostic rather than selecting one. - `transcript` is reference-first with a small preview by default: it returns metadata, transcript paths, and up to 5 recent entries. For targeted lookup, quote the exact `sessionFile`/`transcriptPath` value without changing platform separators (preserve Windows backslashes), search it with `rg` or `grep`, then read only small surrounding ranges. Text results include JSON-escaped `sessionFileJson`/`transcriptPathJson` lines for copy-safe path literals. Pass explicit `tail` or `limit` to override the 5-entry preview; `tail` overrides `limit`; `includeToolOutput` includes captured snapshot tool output in snapshot transcript results. - `send` operates only while the authoritative root workflow is nonterminal; delivery modes are `auto`, `answer`, `prompt`, `steer`, `followUp`, and `resume`. - A terminal root (`completed`, `failed`, `skipped`, `cancelled`, `killed`, or terminal `blocked`) rejects every programmatic send with `status: "failed"`, `code: "WORKFLOW_TERMINAL"`, `delivery: "rejected"`, the requested root run id and terminal status, and guidance to start a new workflow. Proceed inline instead only when the remaining work is small, deterministic, and low risk. @@ -2953,8 +2977,8 @@ Control behavior: - `pause` never accepts a tool node: `ctx.tool` has no turn boundary to stop at, so Atomic rejects it with `Tool nodes cannot be paused; ... Use interrupt or quit to abort it.` instead of a silent no-op. - `interrupt` is resumable: it pauses live work when pausable stages exist and keeps the run in live history/status. - `pause` is useful for pausing a live run or a single live stage without treating it as a destructive abort. -- `resume` can target a stage with `stageId`; the target may be a stage id, unique prefix, or stage name. `message` is forwarded to paused work. For a live interrupted streaming prompt, Atomic preserves the existing prompt loop without duplicating the user message and injects `Continue where you left off. If you believe you are finished with your original task (or a redefined task if the user told you), stop.` when required before normal readiness-gate completion. For a paused stage that was idle waiting for a new stage-chat turn, a non-empty message resumes the stage and starts exactly one fresh prompt containing that message; an empty resume releases the pause without creating a prompt. -- An explicit workflow-tool `resume` target that is absent from the current session store triggers targeted DBOS discovery before Atomic returns `Run not found`. Eligible exact IDs and unique prefixes resume under the original workflow ID; durable prefix collisions return every matching ID. Resource-loading and durable-backend failures remain visible. Ordinary workflow-tool `status` listing stays session-local and does not eagerly hydrate durable history. +- `resume` can target a stage with `stageId`; the target may be an exact stage id or an exact stage name. `message` is forwarded to paused work. For a live interrupted streaming prompt, Atomic preserves the existing prompt loop without duplicating the user message and injects `Continue where you left off. If you believe you are finished with your original task (or a redefined task if the user told you), stop.` when required before normal readiness-gate completion. For a paused stage that was idle waiting for a new stage-chat turn, a non-empty message resumes the stage and starts exactly one fresh prompt containing that message; an empty resume releases the pause without creating a prompt. +- An explicit workflow-tool `resume` target that is absent from the current session store triggers targeted DBOS discovery before Atomic returns `Run not found`. The target must be a full run UUID; an eligible exact ID resumes under the original workflow ID, and a malformed target is rejected before any durable lookup happens. Resource-loading and durable-backend failures remain visible. Ordinary workflow-tool `status` listing stays session-local and does not eagerly hydrate durable history. - Run-level `quit` gracefully pauses in-flight work, marks the run resumable, and leaves it available to `/workflow resume`. A run whose only in-flight work is a `ctx.tool` node is quit like any other: it pauses as resumable instead of reporting that there are no controllable stages. - `reload` refreshes discovered workflow resources in-process; the optional `reason` is echoed in the result. @@ -3036,7 +3060,7 @@ Configure lifecycle behavior with `workflowNotifications.enabled` (default `true Human input is runtime-only: call `ctx.ui.input`, `ctx.ui.confirm`, `ctx.ui.select`, `ctx.ui.editor`, or `ctx.ui.custom` when the workflow needs a decision. No builder-level declaration is required or supported. -Human-in-the-loop prompts from `ctx.ui.input`, `ctx.ui.confirm`, `ctx.ui.select`, `ctx.ui.editor`, and `ctx.ui.custom` appear as awaiting-input nodes in the workflow UI/graph viewer, not as ordinary chat modals. Workflow definitions do not declare HIL; runtime `ctx.ui.*` calls create prompt nodes. If the prompt lives inside an imported child workflow, it still appears in the same expanded parent graph so the user can focus and answer it without switching to a separate child status entry. +Human-in-the-loop prompts from `ctx.ui.input`, `ctx.ui.confirm`, `ctx.ui.select`, `ctx.ui.editor`, and `ctx.ui.custom` appear as awaiting-input nodes in the workflow UI/graph viewer, not as ordinary chat modals. Workflow definitions do not declare HIL; runtime `ctx.ui.*` calls create prompt nodes. If the prompt lives inside an imported child workflow, it still appears in the same expanded parent graph so the user can focus and answer it without switching to a separate child status entry. When the attached stage has a pending prompt, its attribution banner is headed `AWAITING INPUT` and shows the full run id in a two-row identity block; the question and its options continue through the existing prompt UI below the banner. Use `/workflow connect ` (or F2), then press Enter on the focused node or click a graph node to focus and open or attach it for local answers. Custom widget prompts mount inside the attached stage chat and must be completed interactively with the widget's `done(value)` callback. @@ -3098,7 +3122,7 @@ Durable `/workflow resume` preserves completed stage metadata, active-stage elap Each new Atomic process that reopens unfinished work starts from the latest saved baseline, so repeated process-boundary resumes keep stable boundary/child ids, status, graph, and lifecycle duration cumulative without double-counting pauses. A stage paused at ten seconds resumes at ten seconds, and the main-chat dashboard reports prior-session elapsed plus current-session elapsed. Completed inspection uses that same accumulated run timing rather than DBOS record wall-clock age. -Repeated, sibling, sequential, parallel, and multi-level child calls keep independent composed scopes and stable boundary order. The expanded graph routes attach, send, pause, interrupt, and resume through each stage's ordinary owning `{ runId, stageId}`. Exact expanded ids resolve first; local ids, prefixes, and names resolve only when unique, so collisions never select the first match silently. +Repeated, sibling, sequential, parallel, and multi-level child calls keep independent composed scopes and stable boundary order. The expanded graph routes attach, send, pause, interrupt, and resume through each stage's ordinary owning `{ runId, stageId}`. Resolution is exact: an expanded id, a local stage id, or a name must match whole, and colliding names return an ambiguity diagnostic rather than selecting the first match silently. ### `ctx.tool` — durable cached tool execution @@ -3189,16 +3213,16 @@ Fresh completed inspection does not currently persist the workflow's declared ro ```text /workflow resume # Mixed picker: resumable + completed -/workflow resume # Resume unfinished work or open completed detail/chat +/workflow resume # Resume unfinished work or open completed detail/chat /workflows # Alias for the same mixed picker -/workflows # Alias for targeted resume/open +/workflows # Alias for targeted resume/open ``` -Explicit full IDs take precedence, while prefixes resolve across top-level live, resumable durable, and completed targets as one namespace. An exact loadable paused top-level live target resumes directly from in-session state without enumerating the durable completed-history catalog; this keeps explicit live resume responsive even when retained durable history is large and preserves live-over-durable precedence for duplicate IDs. If a stale or concurrent catalog view presents the same failed root as both resumable and read-only history, the resumable durable target wins for exact and prefix routing. Nested child runs remain excluded from this top-level target namespace even when addressed by an exact ID. +Targets resolve across top-level live, resumable durable, and completed entries as one namespace, matched by full UUID only. An exact loadable paused top-level live target resumes directly from in-session state without enumerating the durable completed-history catalog; this keeps explicit live resume responsive even when retained durable history is large and preserves live-over-durable precedence for duplicate IDs. If a stale or concurrent catalog view presents the same failed root as both resumable and read-only history, the resumable durable target wins. Nested child runs remain excluded from this top-level target namespace even when addressed by an exact ID. -The non-interactive `workflow({ action: "resume", runId: "" })` surface uses the same durable resumable-target lookup behavior for explicit targets. If the target is absent locally, Atomic loads workflow resources, queries the authoritative DBOS resumable catalog, and only then reports a missing run. This targeted hydration does not change `workflow({ action: "status" })`: an empty session-local status before explicit resume does not imply that DBOS deleted the workflow. +The non-interactive `workflow({ action: "resume", runId: "" })` surface uses the same durable resumable-target lookup behavior for explicit targets. If the target is absent locally, Atomic loads workflow resources, queries the authoritative DBOS resumable catalog, and only then reports a missing run. This targeted hydration does not change `workflow({ action: "status" })`: an empty session-local status before explicit resume does not imply that DBOS deleted the workflow. -Prefixes and other targets continue through the combined catalog so ambiguity and read-only inspection behavior remain unchanged. Ambiguous prefixes use the existing-style diagnostic. A current completed or non-resumable failed backend row with valid graph checkpoints remains inspectable even if every retained stage conversation is unavailable. Missing, empty, directory, context-empty, or partially malformed transcript paths are stripped from chat attachment while the graph stays read-only and visible. +A target that is not a full UUID is rejected before the combined catalog is consulted, so a truncated id never reaches durable lookup. Read-only inspection behavior is otherwise unchanged. A current completed or non-resumable failed backend row with valid graph checkpoints remains inspectable even if every retained stage conversation is unavailable. Missing, empty, directory, context-empty, or partially malformed transcript paths are stripped from chat attachment while the graph stays read-only and visible. Validation uses the final retained transcript for a repeated stage replay key, so an obsolete superseded checkpoint path does not hide an otherwise valid read-only graph. Reopening inspection refreshes a changed authoritative retained-chat handle. Session-cache-only rows are hidden because the backend is authoritative. Checkpointed non-resumable failed roots appear only in read-only history; cancelled, killed, blocked non-resumable, failed roots without saved progress, and other terminal non-success states are never added. Normal `/resume`, `atomic -r`, and `--continue` behavior for internal workflow stage sessions is unchanged. diff --git a/packages/workflows/src/durable/completed-catalog.ts b/packages/workflows/src/durable/completed-catalog.ts index 9bdeaca70..2ecdb3db8 100644 --- a/packages/workflows/src/durable/completed-catalog.ts +++ b/packages/workflows/src/durable/completed-catalog.ts @@ -31,7 +31,7 @@ import { export type CompletedWorkflowResolution = | { readonly kind: "found"; readonly entry: ResumableWorkflowEntry; readonly snapshot: RunSnapshot } - | { readonly kind: "ambiguous"; readonly matches: readonly ResumableWorkflowEntry[] } + | { readonly kind: "malformed"; readonly message: string } | { readonly kind: "not_found" } | { readonly kind: "stale"; readonly entry: ResumableWorkflowEntry }; @@ -54,13 +54,13 @@ export function listOpenableCompletedWorkflows(backend: DurableWorkflowBackend): } export function resolveCompletedWorkflow( - workflowIdOrPrefix: string, + workflowId: string, backend: DurableWorkflowBackend, openableCatalog: readonly ResumableWorkflowEntry[] = listOpenableCompletedWorkflows(backend), ): CompletedWorkflowResolution { - const resolved = resolveDurableEntry(workflowIdOrPrefix, openableCatalog); + const resolved = resolveDurableEntry(workflowId, openableCatalog); if (resolved !== undefined) { - if ("kind" in resolved) return { kind: "ambiguous", matches: resolved.matches }; + if ("kind" in resolved) return { kind: "malformed", message: resolved.message }; const snapshot = completedWorkflowSnapshot(backend, resolved); if (snapshot === undefined) { return { kind: "stale", entry: resolved }; @@ -68,9 +68,9 @@ export function resolveCompletedWorkflow( return { kind: "found", entry: resolved, snapshot }; } - const authoritative = resolveDurableEntry(workflowIdOrPrefix, listCompletedFromBackend(backend)); + const authoritative = resolveDurableEntry(workflowId, listCompletedFromBackend(backend)); if (authoritative === undefined) return { kind: "not_found" }; - if ("kind" in authoritative) return { kind: "ambiguous", matches: authoritative.matches }; + if ("kind" in authoritative) return { kind: "malformed", message: authoritative.message }; return { kind: "stale", entry: authoritative }; } diff --git a/packages/workflows/src/durable/completed-inspection.ts b/packages/workflows/src/durable/completed-inspection.ts index e58c6b265..7942b2a6c 100644 --- a/packages/workflows/src/durable/completed-inspection.ts +++ b/packages/workflows/src/durable/completed-inspection.ts @@ -23,7 +23,7 @@ export type OpenCompletedDurableResult = readonly name: string; readonly message: string; } - | { readonly ok: false; readonly reason: "not_found" | "ambiguous" | "stale" | "active"; readonly message: string }; + | { readonly ok: false; readonly reason: "not_found" | "malformed" | "stale" | "active"; readonly message: string }; export interface OpenCompletedDurableDeps { readonly durableBackend: DurableWorkflowBackend; @@ -49,22 +49,21 @@ const completedChatRegistrations = new WeakMap `${entry.name} (${entry.workflowId.slice(0, 8)})`).join(", "); - return failure("ambiguous", `Ambiguous completed workflow prefix "${workflowIdOrPrefix}" matches: ${matches}`); + if (resolved.kind === "malformed") { + return failure("malformed", resolved.message); } if (resolved.kind === "stale") { return failure( "stale", - `Completed workflow ${resolved.entry.workflowId.slice(0, 8)} is stale or missing durable checkpoint/session data and cannot be opened.`, + `Completed workflow ${resolved.entry.workflowId} is stale or missing durable checkpoint/session data and cannot be opened.`, ); } @@ -72,7 +71,7 @@ export function openCompletedDurableWorkflow( if (existing !== undefined && existing.status !== "completed") { return failure( "active", - `Workflow ${resolved.snapshot.id.slice(0, 8)} is already active in this session; attach with /workflow connect ${resolved.snapshot.id.slice(0, 8)} instead.`, + `Workflow ${resolved.snapshot.id} is already active in this session; attach with /workflow connect ${resolved.snapshot.id} instead.`, ); } const snapshots = completedWorkflowRunSnapshots(deps.durableBackend, resolved.entry); @@ -90,11 +89,11 @@ export function openCompletedDurableWorkflow( runId: snapshot.id, workflowId: snapshot.id, name: snapshot.name, - message: `Opened completed durable workflow "${snapshot.name}" (${snapshot.id.slice(0, 8)}) for read-only inspection${hasReopenableStage ? " and follow-up chat" : ""}.`, + message: `Opened completed durable workflow "${snapshot.name}" (${snapshot.id}) for read-only inspection${hasReopenableStage ? " and follow-up chat" : ""}.`, }; } -function failure(reason: "not_found" | "ambiguous" | "stale" | "active", message: string): OpenCompletedDurableResult { +function failure(reason: "not_found" | "malformed" | "stale" | "active", message: string): OpenCompletedDurableResult { return { ok: false, reason, message }; } diff --git a/packages/workflows/src/durable/resume-catalog.ts b/packages/workflows/src/durable/resume-catalog.ts index 5fd4ec2c4..6a79c64e2 100644 --- a/packages/workflows/src/durable/resume-catalog.ts +++ b/packages/workflows/src/durable/resume-catalog.ts @@ -10,7 +10,7 @@ export function formatResumableWorkflowList(entries: readonly ResumableWorkflowE if (entries.length === 0) return "No resumable or completed workflows found."; const hasCompleted = entries.some((entry) => entry.status === "completed"); const lines = entries.map((entry, index) => { - const id = entry.workflowId.slice(0, 8); + const id = entry.workflowId; const status = entry.status === "completed" ? "✓ completed" : entry.status.padEnd(8); const checkpoints = `${entry.completedCheckpoints} checkpoint${entry.completedCheckpoints === 1 ? "" : "s"}`; const label = entry.label ? ` "${entry.label}"` : ""; diff --git a/packages/workflows/src/durable/resume-runtime.ts b/packages/workflows/src/durable/resume-runtime.ts index 16fc7e65a..26e011737 100644 --- a/packages/workflows/src/durable/resume-runtime.ts +++ b/packages/workflows/src/durable/resume-runtime.ts @@ -26,6 +26,7 @@ import { } from "../runs/foreground/executor-child-helpers.js"; import { resolveAndValidateInputs } from "../runs/foreground/executor-inputs.js"; import type { RunOpts } from "../runs/foreground/executor-types.js"; +import { isFullRunId, malformedRunIdMessage } from "../shared/run-id.js"; import type { RunSnapshot } from "../shared/store-types.js"; import type { WorkflowDefinition, WorkflowInputValues } from "../shared/types.js"; import type { WorkflowRegistry } from "../workflows/registry.js"; @@ -63,7 +64,7 @@ export interface ResumeDurableDeps { /** Hydrate current DBOS metadata and checkpoints before synchronous replay reads. */ export async function prepareDurableResume( - workflowIdOrPrefix: string | undefined, + workflowId: string | undefined, deps: ResumeDurableDeps, ): Promise { const backend = deps.durableBackend ?? getDurableBackend(); @@ -71,8 +72,8 @@ export async function prepareDurableResume( const catalog = backend.listResumableWorkflows(); // If a specific target was requested, hydrate that workflow too (it might // be resumable but not yet in the resumable filter — e.g. recently failed). - if (workflowIdOrPrefix !== undefined) { - const resolved = resolveDurableEntry(workflowIdOrPrefix, catalog); + if (workflowId !== undefined) { + const resolved = resolveDurableEntry(workflowId, catalog); if (resolved !== undefined && !("kind" in resolved)) { await backend.hydrateWorkflow(resolved.workflowId); } @@ -80,30 +81,32 @@ export async function prepareDurableResume( return backend.listResumableWorkflows(); } -/** Resolve a current DBOS catalog entry by full id or unique prefix. */ +/** + * Resolve a current DBOS catalog entry by its full run id. + * + * The durable `workflowId` is the live run id, so it takes the same strict + * contract: a full UUID or nothing. A malformed target is reported separately + * from an absent one. + */ export function resolveDurableEntry( - workflowIdOrPrefix: string, + workflowId: string, catalog: readonly ResumableWorkflowEntry[], -): ResumableWorkflowEntry | { kind: "ambiguous"; matches: readonly ResumableWorkflowEntry[] } | undefined { - const exact = catalog.find((entry) => entry.workflowId === workflowIdOrPrefix); - if (exact !== undefined) return exact; - const prefixMatches = catalog.filter((entry) => entry.workflowId.startsWith(workflowIdOrPrefix)); - if (prefixMatches.length === 0) return undefined; - if (prefixMatches.length === 1) return prefixMatches[0]; - return { kind: "ambiguous", matches: prefixMatches }; +): ResumableWorkflowEntry | { kind: "malformed"; message: string } | undefined { + if (!isFullRunId(workflowId)) return { kind: "malformed", message: malformedRunIdMessage(workflowId) }; + return catalog.find((entry) => entry.workflowId === workflowId); } /** Resume by DBOS workflow id and replay current persisted checkpoints. */ export async function resumeDurableWorkflow( - workflowIdOrPrefix: string, + workflowId: string, deps: ResumeDurableDeps, catalog?: readonly ResumableWorkflowEntry[], ): Promise { const backend = deps.durableBackend ?? getDurableBackend(); const resolvedCatalog = catalog ?? backend.listResumableWorkflows(); - const resolved = resolveDurableEntry(workflowIdOrPrefix, resolvedCatalog); + const resolved = resolveDurableEntry(workflowId, resolvedCatalog); if (resolved === undefined) { - const direct = backend.getWorkflow(workflowIdOrPrefix); + const direct = backend.getWorkflow(workflowId); if (direct !== undefined && direct.status === "running") { if (hasActiveLiveRun(deps.baseRunOpts.store, direct.workflowId)) { return alreadyRunningResult(direct.name, direct.workflowId, deps.baseRunOpts.store); @@ -112,31 +115,27 @@ export async function resumeDurableWorkflow( return foreignRunningResult(direct.name, direct.workflowId); } } - if (!backend.isWorkflowLoadable(workflowIdOrPrefix)) { + if (!backend.isWorkflowLoadable(workflowId)) { return { ok: false, reason: "not_registered", - message: `Workflow ${workflowIdOrPrefix.slice(0, 8)} has no valid current DBOS state.`, + message: `Workflow ${workflowId} has no valid current DBOS state.`, }; } return { ok: false, reason: "not_registered", - message: `No resumable workflow found for id/prefix: ${workflowIdOrPrefix}`, + message: `No resumable workflow found for id: ${workflowId}`, }; } if ("kind" in resolved) { - return { - ok: false, - reason: "not_registered", - message: `Ambiguous workflow prefix "${workflowIdOrPrefix}" matches: ${resolved.matches.map((m) => `${m.name} (${m.workflowId.slice(0, 8)})`).join(", ")}`, - }; + return { ok: false, reason: "not_registered", message: resolved.message }; } if (!backend.isWorkflowLoadable(resolved.workflowId)) { return { ok: false, reason: "not_registered", - message: `Workflow ${resolved.workflowId.slice(0, 8)} has no valid current DBOS state.`, + message: `Workflow ${resolved.workflowId} has no valid current DBOS state.`, }; } // Revalidate the authoritative DBOS handle before resume. A running handle @@ -147,7 +146,7 @@ export async function resumeDurableWorkflow( return { ok: false, reason: "stale", - message: `Workflow ${resolved.workflowId.slice(0, 8)} has no current DBOS checkpoint state; re-run the workflow to start fresh.`, + message: `Workflow ${resolved.workflowId} has no current DBOS checkpoint state; re-run the workflow to start fresh.`, }; } @@ -163,7 +162,7 @@ export async function resumeDurableWorkflow( return { ok: false, reason: "not_resumable", - message: `Workflow ${resolved.workflowId.slice(0, 8)} is ${handle.status}, not resumable.`, + message: `Workflow ${resolved.workflowId} is ${handle.status}, not resumable.`, }; } @@ -200,7 +199,7 @@ export async function resumeDurableWorkflow( return { ok: false, reason: "stale", - message: `Workflow ${resolved.workflowId.slice(0, 8)} changed while resume was pending; refresh the workflow list and try again.`, + message: `Workflow ${resolved.workflowId} changed while resume was pending; refresh the workflow list and try again.`, }; } @@ -223,7 +222,7 @@ export async function resumeDurableWorkflow( return { ok: false, reason: "startup_failed", - message: `Failed to resume durable workflow ${resolved.workflowId.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to resume durable workflow ${resolved.workflowId}: ${error instanceof Error ? error.message : String(error)}`, }; } const { accepted } = launch; @@ -233,7 +232,7 @@ export async function resumeDurableWorkflow( const error = workflowStartupFailureMessage( admission, snapshot?.error, - `Workflow ${resolved.workflowId.slice(0, 8)} ended before startup admission`, + `Workflow ${resolved.workflowId} ended before startup admission`, ); deps.baseRunOpts.store?.removeRun(accepted.runId); backend.setWorkflowStatus(resolved.workflowId, handle.status, handle.pendingPrompts, handle.resumable); @@ -241,7 +240,7 @@ export async function resumeDurableWorkflow( return { ok: false, reason: "startup_failed", - message: `Failed to resume durable workflow ${resolved.workflowId.slice(0, 8)}: ${error}`, + message: `Failed to resume durable workflow ${resolved.workflowId}: ${error}`, }; } @@ -250,7 +249,7 @@ export async function resumeDurableWorkflow( runId: accepted.runId, workflowId: resolved.workflowId, name: handle.name, - message: `Resuming durable workflow "${handle.name}" (${resolved.workflowId.slice(0, 8)}) — completed checkpoints will be replayed.`, + message: `Resuming durable workflow "${handle.name}" (${resolved.workflowId}) — completed checkpoints will be replayed.`, }; } @@ -271,7 +270,7 @@ function foreignRunningResult(name: string, workflowId: string): ResumeDurableRe ok: false, reason: "not_resumable", message: - `Workflow "${name}" (${workflowId.slice(0, 8)}) is actively running in another Atomic session. ` + + `Workflow "${name}" (${workflowId}) is actively running in another Atomic session. ` + "Control it from that session; it becomes resumable here only after that session pauses, quits, or crashes.", }; } @@ -281,9 +280,9 @@ function alreadyRunningResult(name: string, workflowId: string, store: RunOpts[" return { ok: false, reason: "not_resumable", - message: `Workflow "${name}" (${workflowId.slice(0, 8)}) is already running${ + message: `Workflow "${name}" (${workflowId}) is already running${ here ? " in this session" : " in another session" - }. See agents working and chat with or steer each stage using \`/workflow connect ${workflowId.slice(0, 8)}\`; use \`/workflow quit ${workflowId.slice(0, 8)}\` to pause the run for later resume.`, + }. See agents working and chat with or steer each stage using \`/workflow connect ${workflowId}\`; use \`/workflow quit ${workflowId}\` to pause the run for later resume.`, }; } @@ -332,12 +331,12 @@ export async function prepareTargetedDurableResumable( export async function prepareRuntimeDurableResumable( getBackend: () => DurableWorkflowBackend, - workflowIdOrPrefix?: string, + workflowId?: string, ): Promise { const backend = getBackend(); await backend.hydrateResumableWorkflows(); - if (workflowIdOrPrefix !== undefined) { - const resolved = resolveDurableEntry(workflowIdOrPrefix, backend.listResumableWorkflows()); + if (workflowId !== undefined) { + const resolved = resolveDurableEntry(workflowId, backend.listResumableWorkflows()); if (resolved !== undefined && !("kind" in resolved)) await backend.hydrateWorkflow(resolved.workflowId); } return backend.listResumableWorkflows(); diff --git a/packages/workflows/src/extension/extension-runtime-state.ts b/packages/workflows/src/extension/extension-runtime-state.ts index 3237ecc4e..9d730b542 100644 --- a/packages/workflows/src/extension/extension-runtime-state.ts +++ b/packages/workflows/src/extension/extension-runtime-state.ts @@ -152,14 +152,14 @@ export function createWorkflowExtensionRuntimeState( resumeFailedRun(sourceRunId, stageId, options) { return runtimeRef.current.resumeFailedRun(sourceRunId, stageId, options); }, - resumeDurableWorkflow(workflowIdOrPrefix, options) { - return runtimeRef.current.resumeDurableWorkflow(workflowIdOrPrefix, options); + resumeDurableWorkflow(workflowId, options) { + return runtimeRef.current.resumeDurableWorkflow(workflowId, options); }, listDurableResumable() { return runtimeRef.current.listDurableResumable(); }, - prepareDurableResumable(workflowIdOrPrefix) { - return runtimeRef.current.prepareDurableResumable(workflowIdOrPrefix); + prepareDurableResumable(workflowId) { + return runtimeRef.current.prepareDurableResumable(workflowId); }, prepareDurableResumableForIds(workflowIds) { const targeted = runtimeRef.current.prepareDurableResumableForIds; @@ -169,16 +169,16 @@ export function createWorkflowExtensionRuntimeState( prepareCompletedDurable() { return runtimeRef.current.prepareCompletedDurable?.() ?? Promise.resolve([]); }, - openCompletedDurableWorkflow(workflowIdOrPrefix, catalog) { + openCompletedDurableWorkflow(workflowId, catalog) { const open = runtimeRef.current.openCompletedDurableWorkflow; if (open === undefined) { return { ok: false, reason: "not_found", - message: `No completed durable workflow found for id/prefix: ${workflowIdOrPrefix}`, + message: `No completed durable workflow found for id: ${workflowId}`, }; } - return open(workflowIdOrPrefix, catalog); + return open(workflowId, catalog); }, }; diff --git a/packages/workflows/src/extension/render-result.ts b/packages/workflows/src/extension/render-result.ts index f0e89bf73..00815f7ae 100644 --- a/packages/workflows/src/extension/render-result.ts +++ b/packages/workflows/src/extension/render-result.ts @@ -442,9 +442,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts const r = result as StageListResult; if (r.error) return renderNotice("WORKFLOW STAGES", `${r.runId || "(none)"}: ${r.error}`, opts, themed); const counts = r.stages - .map( - (s) => `${s.name} (${s.id.slice(0, 12)}): ${s.status}${s.skippedReason ? ` — ${s.skippedReason}` : ""}`, - ) + .map((s) => `${s.name} (${s.id}): ${s.status}${s.skippedReason ? ` — ${s.skippedReason}` : ""}`) .join("; "); return renderNotice("WORKFLOW STAGES", `${r.runId}: ${r.filter} — ${counts || "no stages"}`, opts, themed); } @@ -456,7 +454,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts const extra = r.stage.error ? ` — ${r.stage.error}` : r.stage.result ? ` — ${r.stage.result}` : ""; return renderNotice( "WORKFLOW STAGE", - `${r.runId}: ${r.stage.name} (${r.stage.id.slice(0, 12)}) ${r.stage.status}${extra}`, + `${r.runId}: ${r.stage.name} (${r.stage.id}) ${r.stage.status}${extra}`, opts, themed, ); @@ -468,7 +466,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts const suffix = r.truncated ? " (truncated)" : ""; return renderNotice( "WORKFLOW TRANSCRIPT", - `${r.runId}/${r.stageId.slice(0, 12)} ${r.source}: ${text}${suffix}`, + `${r.runId}/${r.stageId} ${r.source}: ${text}${suffix}`, opts, themed, ); @@ -476,12 +474,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts case "send": { const r = result as SendResult; - return renderNotice( - "WORKFLOW SEND", - `${r.runId}/${r.stageId.slice(0, 12)} ${r.delivery}: ${r.message}`, - opts, - themed, - ); + return renderNotice("WORKFLOW SEND", `${r.runId}/${r.stageId} ${r.delivery}: ${r.message}`, opts, themed); } case "pause": { diff --git a/packages/workflows/src/extension/runtime-durable-resume.ts b/packages/workflows/src/extension/runtime-durable-resume.ts index a338d0328..19f9f6871 100644 --- a/packages/workflows/src/extension/runtime-durable-resume.ts +++ b/packages/workflows/src/extension/runtime-durable-resume.ts @@ -25,17 +25,17 @@ import { discoverWorkflows } from "./discovery.js"; export interface DurableResumeRuntime { resumeDurableWorkflow( - workflowIdOrPrefix: string, + workflowId: string, options?: { readonly policy?: WorkflowExecutionPolicy }, ): Promise; listDurableResumable(): readonly ResumableWorkflowEntry[]; - prepareDurableResumable(workflowIdOrPrefix?: string): Promise; + prepareDurableResumable(workflowId?: string): Promise; prepareDurableCatalog?(): Promise; /** Hydrate a bounded set of known DBOS workflow ids. */ prepareDurableResumableForIds?(workflowIds: readonly string[]): Promise; prepareCompletedDurable?(): Promise; openCompletedDurableWorkflow?( - workflowIdOrPrefix: string, + workflowId: string, catalog?: readonly ResumableWorkflowEntry[], ): OpenCompletedDurableResult; } @@ -60,18 +60,18 @@ export function createDurableResumeRuntime(deps: DurableResumeRuntimeDeps): Dura const ids = deps.store .runs() .map((run) => run.id) - .filter((id) => target === undefined || id === target || id.startsWith(target)); + .filter((id) => target === undefined || id === target); for (const id of ids) await backend.hydrateWorkflow(id); }; let preparedCatalog: readonly ResumableWorkflowEntry[] = []; return { - async resumeDurableWorkflow(workflowIdOrPrefix, options): Promise { + async resumeDurableWorkflow(workflowId, options): Promise { await deps.ensureReady(); const backend = getDurableBackend(); if (preparedCatalog.length === 0) { - preparedCatalog = await prepareRuntimeDurableResumable(() => backend, workflowIdOrPrefix); + preparedCatalog = await prepareRuntimeDurableResumable(() => backend, workflowId); } - const resolved = resolveCatalogEntry(workflowIdOrPrefix, preparedCatalog); + const resolved = resolveCatalogEntry(workflowId, preparedCatalog); if (resolved !== undefined) await backend.hydrateWorkflow(resolved.workflowId); const adapterDeps: ResumeDurableDeps = { registry: deps.registry, @@ -81,17 +81,17 @@ export function createDurableResumeRuntime(deps: DurableResumeRuntimeDeps): Dura (await discoverWorkflows({ cwd: cwd ?? deps.runtimeCwd })).registry.get(name), ...(deps.jobs !== undefined ? { jobs: deps.jobs } : {}), }; - return await resumeDurableWorkflowAdapter(workflowIdOrPrefix, adapterDeps, preparedCatalog); + return await resumeDurableWorkflowAdapter(workflowId, adapterDeps, preparedCatalog); }, listDurableResumable(): readonly ResumableWorkflowEntry[] { return getDurableBackend().listResumableWorkflows(); }, - async prepareDurableResumable(workflowIdOrPrefix) { + async prepareDurableResumable(workflowId) { await deps.ensureReady(); const backend = getDurableBackend(); try { - await hydrateStoredWorkflowCandidates(backend, workflowIdOrPrefix); - preparedCatalog = await prepareRuntimeDurableResumable(() => backend, workflowIdOrPrefix); + await hydrateStoredWorkflowCandidates(backend, workflowId); + preparedCatalog = await prepareRuntimeDurableResumable(() => backend, workflowId); return preparedCatalog; } finally { purgeSuppressedWorkflowRuns(backend, deps.store); @@ -131,12 +131,12 @@ export function createDurableResumeRuntime(deps: DurableResumeRuntimeDeps): Dura purgeSuppressedWorkflowRuns(backend, deps.store); } }, - openCompletedDurableWorkflow(workflowIdOrPrefix, catalog) { + openCompletedDurableWorkflow(workflowId, catalog) { const backend = getDurableBackend(); - const entry = resolveCatalogEntry(workflowIdOrPrefix, catalog ?? []); - const handle = backend.getWorkflow(entry?.workflowId ?? workflowIdOrPrefix); + const entry = resolveCatalogEntry(workflowId, catalog ?? []); + const handle = backend.getWorkflow(entry?.workflowId ?? workflowId); return openCompletedSnapshot( - workflowIdOrPrefix, + workflowId, { durableBackend: backend, store: deps.store, @@ -152,11 +152,8 @@ export function createDurableResumeRuntime(deps: DurableResumeRuntimeDeps): Dura } function resolveCatalogEntry( - workflowIdOrPrefix: string, + workflowId: string, catalog: readonly ResumableWorkflowEntry[], ): ResumableWorkflowEntry | undefined { - const exact = catalog.find((entry) => entry.workflowId === workflowIdOrPrefix); - if (exact !== undefined) return exact; - const matches = catalog.filter((entry) => entry.workflowId.startsWith(workflowIdOrPrefix)); - return matches.length === 1 ? matches[0] : undefined; + return catalog.find((entry) => entry.workflowId === workflowId); } diff --git a/packages/workflows/src/extension/runtime.ts b/packages/workflows/src/extension/runtime.ts index ed80927da..acacae5ee 100644 --- a/packages/workflows/src/extension/runtime.ts +++ b/packages/workflows/src/extension/runtime.ts @@ -172,11 +172,11 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi } function matchesResumeStageIdentifier(stage: RunSnapshot["stages"][number], identifier: string): boolean { - return stage.id === identifier || stage.name === identifier || stage.id.startsWith(identifier); + return stage.id === identifier || stage.name === identifier; } function stageLabel(stage: RunSnapshot["stages"][number]): string { - return `${stage.name} (${stage.id.slice(0, 12)})`; + return `${stage.name} (${stage.id})`; } function resolveUniqueResumeStage( @@ -261,7 +261,7 @@ export function createExtensionRuntime(opts: ExtensionRuntimeOpts = {}): Extensi }; } const stageMessage = (verb: string, runId: string): string => - `${verb} workflow "${def.name}" from run ${source.id.slice(0, 8)} at stage ${resolvedStage.stageId.slice(0, 8)} (run ${runId.slice(0, 8)}).`; + `${verb} workflow "${def.name}" from run ${source.id} at stage ${resolvedStage.stageId} (run ${runId}).`; const launchContinuation = () => launchDetachedUntilStartup(def, sourceInputs, { ...runOptions(options?.policy), diff --git a/packages/workflows/src/extension/workflow-command-completions.ts b/packages/workflows/src/extension/workflow-command-completions.ts index bfd388ac9..7520a6bb7 100644 --- a/packages/workflows/src/extension/workflow-command-completions.ts +++ b/packages/workflows/src/extension/workflow-command-completions.ts @@ -43,7 +43,7 @@ function workflowNameItems(runtime: ExtensionRuntime): PiArgumentCompletion[] { function runIdItems(): PiArgumentCompletion[] { return topLevelWorkflowRuns(store.runs()).map((run) => ({ value: `${run.id} `, - label: run.id.slice(0, 8), + label: run.id, description: `${run.name} — ${run.status}`, })); } diff --git a/packages/workflows/src/extension/workflow-command-registration.ts b/packages/workflows/src/extension/workflow-command-registration.ts index 9c69cbcdb..dc44ef0b5 100644 --- a/packages/workflows/src/extension/workflow-command-registration.ts +++ b/packages/workflows/src/extension/workflow-command-registration.ts @@ -35,7 +35,7 @@ import { import { workflowPolicyFromContext } from "./workflow-policy.js"; import { normalizeWorkflowReloadReport, type WorkflowReloadReport } from "./workflow-reload-report.js"; import { handleRunControlCommand, type WorkflowRunControlDeps } from "./workflow-run-control-command.js"; -import { overlaySurfaceFromContext, reloadFailureMessage, resolveRunIdPrefix } from "./workflow-targets.js"; +import { overlaySurfaceFromContext, reloadFailureMessage, resolveRunId } from "./workflow-targets.js"; export interface WorkflowSlashCommandDeps { runtimeProxy: ExtensionRuntime; @@ -150,13 +150,9 @@ async function workflowSlashHandler( if (subcommand === "status") { const target = parts[1]; if (target && !target.startsWith("--")) { - const resolved = resolveRunIdPrefix(target); + const resolved = resolveRunId(target); + if (resolved.kind === "malformed") return fail(resolved.message); if (resolved.kind === "not_found") return fail(`Run not found: ${target}`); - if (resolved.kind === "ambiguous") { - return fail( - `Ambiguous run prefix "${target}" matches: ${resolved.matches.map((id) => id.slice(0, 12)).join(", ")}`, - ); - } const inspected = inspectRun(resolved.runId); if (!inspected.ok) return fail(`Run not found: ${target}`); emitChatSurface(pi, { kind: "detail", detail: inspected.detail }); diff --git a/packages/workflows/src/extension/workflow-durable-resume-command.ts b/packages/workflows/src/extension/workflow-durable-resume-command.ts index 78f700224..6594901bf 100644 --- a/packages/workflows/src/extension/workflow-durable-resume-command.ts +++ b/packages/workflows/src/extension/workflow-durable-resume-command.ts @@ -5,6 +5,7 @@ import { formatResumableWorkflowList } from "../durable/resume-catalog.js"; import { isWorkflowRunResumable } from "../durable/resume-eligibility.js"; import { type DurableWorkflowDeleteOutcome, deleteDurableWorkflowIfSafe } from "../durable/retention-policy.js"; import type { ResumableWorkflowEntry } from "../durable/types.js"; +import { isFullRunId, malformedRunIdMessage } from "../shared/run-id.js"; import { store } from "../shared/store.js"; import type { RunSnapshot } from "../shared/store-types.js"; import { workflowRunResumeCandidate } from "../shared/workflow-artifacts.js"; @@ -39,7 +40,7 @@ export interface WorkflowResumeTarget { export type WorkflowResumeTargetResolution = | WorkflowResumeTarget - | { readonly kind: "ambiguous"; readonly matches: readonly WorkflowResumeTarget[] } + | { readonly kind: "malformed"; readonly message: string } | { readonly kind: "not_found" }; export async function prepareWorkflowResumeCatalog( @@ -104,8 +105,8 @@ export async function handleDurableResume( // directory a second time for the same command invocation. catalog.completed, ); - if (resolved.kind === "ambiguous") { - fail(`Ambiguous workflow prefix "${target}" matches: ${formatMatches(resolved.matches)}`); + if (resolved.kind === "malformed") { + fail(resolved.message); return true; } if (resolved.kind === "completed") { @@ -196,12 +197,9 @@ export function resolveWorkflowResumeTarget( name: run.name, }); } + if (!isFullRunId(target)) return { kind: "malformed", message: malformedRunIdMessage(target) }; const exact = targets.get(target); - if (exact !== undefined) return exact; - const matches = [...targets.values()].filter((candidate) => candidate.workflowId.startsWith(target)); - if (matches.length === 0) return { kind: "not_found" }; - if (matches.length === 1) return matches[0]!; - return { kind: "ambiguous", matches }; + return exact ?? { kind: "not_found" }; } function isExplicitResumeCandidate(run: RunSnapshot): boolean { @@ -250,14 +248,14 @@ function openCompletedTarget( function openCompleted( runtime: ExtensionRuntime, - workflowIdOrPrefix: string, + workflowId: string, catalog: readonly ResumableWorkflowEntry[], beforeRestoreCompleted?: (snapshots: readonly RunSnapshot[]) => void, ) { return ( - runtime.openCompletedDurableWorkflow?.(workflowIdOrPrefix, catalog) ?? + runtime.openCompletedDurableWorkflow?.(workflowId, catalog) ?? openCompletedDurableWorkflow( - workflowIdOrPrefix, + workflowId, { durableBackend: getDurableBackend(), store, @@ -267,7 +265,3 @@ function openCompleted( ) ); } - -function formatMatches(entries: readonly WorkflowResumeTarget[]): string { - return entries.map((entry) => `${entry.name} (${entry.workflowId.slice(0, 8)})`).join(", "); -} diff --git a/packages/workflows/src/extension/workflow-run-control-command.ts b/packages/workflows/src/extension/workflow-run-control-command.ts index 640de40f8..fbb6b0c3a 100644 --- a/packages/workflows/src/extension/workflow-run-control-command.ts +++ b/packages/workflows/src/extension/workflow-run-control-command.ts @@ -30,7 +30,7 @@ import { resumePickerLiveUpdateOptions, } from "./workflow-resume-picker-rows.js"; import { classifyDurableResumeShadow, reconcileDurableResumeShadow } from "./workflow-resume-shadow.js"; -import { overlaySurfaceFromContext, resolveRunIdPrefix, resolveStageTarget } from "./workflow-targets.js"; +import { overlaySurfaceFromContext, resolveRunId, resolveStageTarget } from "./workflow-targets.js"; export type { WorkflowRunControlDeps } from "./workflow-durable-resume-command.js"; @@ -58,10 +58,10 @@ export async function handleRunControlCommand( const theme = deriveGraphTheme({}); const failHeadlessAttachCommand = (targetAction: "connect" | "attach", runId: string, stageId?: string): boolean => { if (policy.allowInputPicker) return false; - const displayTarget = stageId ? `${runId.slice(0, 8)} stage ${stageId.slice(0, 8)}` : runId.slice(0, 8); + const displayTarget = stageId ? `${runId} stage ${stageId}` : runId; fail( `/workflow ${targetAction} requires an interactive UI surface and cannot attach in non-interactive mode. ` + - `Target: ${displayTarget}. Use /workflow status ${runId.slice(0, 8)} or the workflow tool's status/stages/transcript actions for non-interactive inspection.`, + `Target: ${displayTarget}. Use /workflow status ${runId} or the workflow tool's status/stages/transcript actions for non-interactive inspection.`, ); return true; }; @@ -84,20 +84,18 @@ export async function handleRunControlCommand( } return true; } - const resolved = resolveRunIdPrefix(target); - if (resolved.kind === "not_found") { - fail(`Run not found: ${target}\n\n${renderSessionList(store.runs(), { theme, includeAll: true })}`); + const resolved = resolveRunId(target); + if (resolved.kind === "malformed") { + fail(resolved.message); return true; } - if (resolved.kind === "ambiguous") { - fail(`Ambiguous run prefix "${target}" matches: ${resolved.matches.map((id) => id.slice(0, 12)).join(", ")}`); + if (resolved.kind === "not_found") { + fail(`Run not found: ${target}\n\n${renderSessionList(store.runs(), { theme, includeAll: true })}`); return true; } if (failHeadlessAttachCommand("connect", resolved.runId)) return true; if (policy.allowInputPicker) deps.overlay.open(resolved.runId, overlaySurfaceFromContext(ctx)); - print( - `Connected to ${resolved.runId.slice(0, 8)}. h hide · ctrl+x leave graph · return to main chat · esc close.`, - ); + print(`Connected to ${resolved.runId}. h hide · ctrl+x leave graph · return to main chat · esc close.`); return true; } @@ -125,7 +123,7 @@ export async function handleRunControlCommand( } if (action === "interrupt" && !yes && confirmationPrompt) { const title = `Interrupt all ${inFlight.length} in-flight workflow runs?`; - const body = `Pauses: ${inFlight.map((run) => `${run.name} (${run.id.slice(0, 8)})`).join(", ")}`; + const body = `Pauses: ${inFlight.map((run) => `${run.name} (${run.id})`).join(", ")}`; if (!(await confirmationPrompt(title, body))) { print("Cancelled."); return true; @@ -157,29 +155,27 @@ export async function handleRunControlCommand( } return true; } - const resolved = resolveRunIdPrefix(target!); - if (resolved.kind === "not_found") { - fail(`Run not found: ${target}`); + const resolved = resolveRunId(target!); + if (resolved.kind === "malformed") { + fail(resolved.message); return true; } - if (resolved.kind === "ambiguous") { - fail( - `Ambiguous run prefix "${target}" matches multiple runs: ${resolved.matches.map((id) => id.slice(0, 12)).join(", ")}`, - ); + if (resolved.kind === "not_found") { + fail(`Run not found: ${target}`); return true; } const run = store.runs().find((candidate) => candidate.id === resolved.runId); if (action === "quit") { if (run?.endedAt !== undefined) { - print(`Run ${resolved.runId.slice(0, 8)} already ended.`); + print(`Run ${resolved.runId} already ended.`); return true; } try { const result = await quitRun(resolved.runId); - if (result.ok) print(`Run ${result.runId.slice(0, 8)} quit and can be resumed with /workflow resume.`); - else if (result.reason === "already_ended") print(`Run ${result.runId.slice(0, 8)} already ended.`); + if (result.ok) print(`Run ${result.runId} quit and can be resumed with /workflow resume.`); + else if (result.reason === "already_ended") print(`Run ${result.runId} already ended.`); else if (result.reason === "no_active_stages") { - fail(`No controllable stages on run ${result.runId.slice(0, 8)}; the run remains active.`); + fail(`No controllable stages on run ${result.runId}; the run remains active.`); } else fail(`Run not found: ${target}`); } catch (error) { fail(`Failed to quit run ${resolved.runId}: ${error instanceof Error ? error.message : String(error)}`); @@ -188,17 +184,17 @@ export async function handleRunControlCommand( } if (!yes && run && run.endedAt === undefined && confirmationPrompt) { const confirmed = await confirmationPrompt( - `Interrupt workflow run ${run.name} (${run.id.slice(0, 8)})?`, + `Interrupt workflow run ${run.name} (${run.id})?`, "Pauses live work so it can be resumed later.", ); if (!confirmed) { - print(`Cancelled. Run ${resolved.runId.slice(0, 8)} is still active.`); + print(`Cancelled. Run ${resolved.runId} is still active.`); return true; } } try { const result = await interruptRun(resolved.runId); - if (result.ok) print(`Run ${result.runId.slice(0, 8)} interrupted and can be resumed.`); + if (result.ok) print(`Run ${result.runId} interrupted and can be resumed.`); else fail( result.reason === "not_found" @@ -206,8 +202,8 @@ export async function handleRunControlCommand( : result.reason === "already_ended" ? `Run already ended: ${target}` : result.reason === "stage_not_found" - ? `Stage not found for run ${resolved.runId.slice(0, 8)}.` - : `No active stages to interrupt on run ${resolved.runId.slice(0, 8)}.`, + ? `Stage not found for run ${resolved.runId}.` + : `No active stages to interrupt on run ${resolved.runId}.`, ); } catch (error) { fail(`Failed to interrupt run ${resolved.runId}: ${error instanceof Error ? error.message : String(error)}`); @@ -227,7 +223,7 @@ export async function handleRunControlCommand( fail( active.length === 0 ? "No active runs to pause." - : `Picker requires an interactive UI surface. Active runs:\n${active.map((r) => ` ${r.id.slice(0, 8)} ${r.name}`).join("\n")}\n\nUsage: /workflow pause [stageId]`, + : `Picker requires an interactive UI surface. Active runs:\n${active.map((r) => ` ${r.id} ${r.name}`).join("\n")}\n\nUsage: /workflow pause [stageId]`, ); } else if (action === "attach") { fail( @@ -272,7 +268,7 @@ export async function handleRunControlCommand( }); } if (picked.result.kind === "live") { - const resolved = resolveRunIdPrefix(picked.result.runId); + const resolved = resolveRunId(picked.result.runId); if (resolved.kind !== "exact") { fail(`Run not found: ${picked.result.runId}`); return true; @@ -302,7 +298,7 @@ export async function handleRunControlCommand( if (result.ok && policy.allowInputPicker) deps.overlay.open(result.runId, overlaySurfaceFromContext(ctx)); result.ok - ? print(result.message ?? `Resumed ${result.runId.slice(0, 8)}`) + ? print(result.message ?? `Resumed ${result.runId}`) : fail(`Run not found: ${picked.result.runId}`); } } catch (error) { @@ -319,7 +315,7 @@ export async function handleRunControlCommand( runId = picked.runId; } else if (action === "resume") { const backend = getDurableBackend(); - const localResolution = resolveRunIdPrefix(target); + const localResolution = resolveRunId(target); const localBeforePreparation = localResolution.kind === "exact" ? store.runs().find((run) => run.id === localResolution.runId) : undefined; const exactBeforePreparation = localBeforePreparation?.id === target ? localBeforePreparation : undefined; @@ -352,7 +348,7 @@ export async function handleRunControlCommand( !hasPendingDurableResumeTransition(exactBeforePreparation.id); if (exactIsActivelyRunning) { fail( - `Workflow ${exactBeforePreparation.id.slice(0, 8)} is already running in this session. Attach with \`/workflow connect ${exactBeforePreparation.id.slice(0, 8)}\` instead of resuming.`, + `Workflow ${exactBeforePreparation.id} is already running in this session. Attach with \`/workflow connect ${exactBeforePreparation.id}\` instead of resuming.`, ); return true; } @@ -393,10 +389,8 @@ export async function handleRunControlCommand( durable, backend.listCompletedWorkflows(), ); - if (combined.kind === "ambiguous") { - fail( - `Ambiguous workflow prefix "${target}" matches: ${combined.matches.map((match) => `${match.name} (${match.workflowId.slice(0, 8)})`).join(", ")}`, - ); + if (combined.kind === "malformed") { + fail(combined.message); return true; } if (combined.kind === "completed" || combined.kind === "durable") { @@ -412,15 +406,13 @@ export async function handleRunControlCommand( } } } else { - const resolved = resolveRunIdPrefix(target); - if (resolved.kind === "not_found") { - fail(`Run not found: ${target}`); + const resolved = resolveRunId(target); + if (resolved.kind === "malformed") { + fail(resolved.message); return true; } - if (resolved.kind === "ambiguous") { - fail( - `Ambiguous run prefix "${target}" matches: ${resolved.matches.map((id) => id.slice(0, 12)).join(", ")}`, - ); + if (resolved.kind === "not_found") { + fail(`Run not found: ${target}`); return true; } runId = resolved.runId; @@ -437,8 +429,8 @@ export async function handleRunControlCommand( if (policy.allowInputPicker) deps.overlay.open(runId, overlaySurfaceFromContext(ctx), stageId, stageRunId); print( stageId - ? `Attached to ${runId.slice(0, 8)} stage ${stageId.slice(0, 8)}. ctrl+x return to graph · esc close.` - : `Attached to ${runId.slice(0, 8)}. ↵ chat · ctrl+x leave graph · return to main chat.`, + ? `Attached to ${runId} stage ${stageId}. ctrl+x return to graph · esc close.` + : `Attached to ${runId}. ↵ chat · ctrl+x leave graph · return to main chat.`, ); return true; } @@ -455,11 +447,11 @@ export async function handleRunControlCommand( if (!result.ok) { fail( result.reason === "not_found" - ? `Run not found: ${stageRunId.slice(0, 8)}` + ? `Run not found: ${stageRunId}` : result.reason === "already_ended" - ? `Run ${stageRunId.slice(0, 8)} already ended.` + ? `Run ${stageRunId} already ended.` : result.reason === "no_active_stages" - ? `No pausable stages on run ${stageRunId.slice(0, 8)}.` + ? `No pausable stages on run ${stageRunId}.` : `Stage not found: ${stageTarget ?? "(unknown)"}`, ); return true; @@ -467,8 +459,8 @@ export async function handleRunControlCommand( if (policy.allowInputPicker) deps.overlay.open(runId, overlaySurfaceFromContext(ctx), stageId, stageRunId); print( result.paused.length === 0 - ? `No stages were paused on run ${stageRunId.slice(0, 8)}.` - : `Paused ${result.paused.length} stage(s) on run ${stageRunId.slice(0, 8)}: ${result.paused.map((stage) => stage.name).join(", ")}`, + ? `No stages were paused on run ${stageRunId}.` + : `Paused ${result.paused.length} stage(s) on run ${stageRunId}: ${result.paused.map((stage) => stage.name).join(", ")}`, ); } catch (error) { fail(`Failed to pause run ${stageRunId}: ${error instanceof Error ? error.message : String(error)}`); @@ -497,7 +489,7 @@ export async function handleRunControlCommand( !hasPendingDurableResumeTransition(stageRunId) ) { fail( - `Workflow ${stageRunId.slice(0, 8)} is already running in this session. Attach with \`/workflow connect ${stageRunId.slice(0, 8)}\` instead of resuming.`, + `Workflow ${stageRunId} is already running in this session. Attach with \`/workflow connect ${stageRunId}\` instead of resuming.`, ); return true; } @@ -521,7 +513,7 @@ export async function handleRunControlCommand( return true; } if (!result.ok) { - fail(`Run not found: ${stageRunId.slice(0, 8)}`); + fail(`Run not found: ${stageRunId}`); return true; } if (result.mode === "partial") { @@ -546,11 +538,11 @@ export async function handleRunControlCommand( const runLevelResumed = hadPausedRunState && !hadPausedStageState && stageId === undefined && result.snapshot.status === "running"; if (result.message !== undefined) print(result.message); - else if (runLevelResumed) print(`Resumed run ${stageRunId.slice(0, 8)}.`); - else fail(`No paused stages on run ${stageRunId.slice(0, 8)}.`); + else if (runLevelResumed) print(`Resumed run ${stageRunId}.`); + else fail(`No paused stages on run ${stageRunId}.`); } else { print( - `Resumed ${result.resumed.length} stage(s) on run ${stageRunId.slice(0, 8)}${message ? ` with message: "${message}"` : ""}.`, + `Resumed ${result.resumed.length} stage(s) on run ${stageRunId}${message ? ` with message: "${message}"` : ""}.`, ); } return true; diff --git a/packages/workflows/src/extension/workflow-schema.ts b/packages/workflows/src/extension/workflow-schema.ts index 94a64638b..35e81d341 100644 --- a/packages/workflows/src/extension/workflow-schema.ts +++ b/packages/workflows/src/extension/workflow-schema.ts @@ -41,7 +41,7 @@ export const WorkflowParametersSchema = Type.Object( runId: Type.Optional( Type.String({ description: - "Run identifier or unique prefix for status/stages/stage/transcript/send/pause/resume/interrupt/quit. Omit runId with action 'status' to list all session runs and their statuses. Use '--all' or all:true for supported bulk run-control actions.", + "Full 36-character run UUID for status/stages/stage/transcript/send/pause/resume/interrupt/quit. Prefixes are not accepted; pass the id exactly as displayed. Omit runId with action 'status' to list all session runs and their statuses. Use '--all' or all:true for supported bulk run-control actions.", }), ), all: Type.Optional( @@ -53,7 +53,7 @@ export const WorkflowParametersSchema = Type.Object( stageId: Type.Optional( Type.String({ description: - "Stage id, unique prefix, or stage name for stage-scoped inspection, transcript, send, pause, or resume. For interrupt and quit it may also name an in-flight ctx.tool node by its tool: id or tool name, which aborts that single call.", + "Exact stage id or exact stage name for stage-scoped inspection, transcript, send, pause, or resume. Prefixes and partial names are not accepted. A nested stage id is the full 'runId:stageId' composite. For interrupt and quit it may also name an in-flight ctx.tool node by its exact tool: id or tool name, which aborts that single call.", }), ), message: Type.Optional( diff --git a/packages/workflows/src/extension/workflow-status-summary.ts b/packages/workflows/src/extension/workflow-status-summary.ts index 898ba6af9..db6ce017b 100644 --- a/packages/workflows/src/extension/workflow-status-summary.ts +++ b/packages/workflows/src/extension/workflow-status-summary.ts @@ -33,8 +33,6 @@ import { elapsedRunMs } from "../shared/timing.js"; */ export type WorkflowRunStatusFilter = StageStatus | RunStatus | "all"; -const RUN_ID_PREFIX_LEN = 8; - /** A currently active (running or awaiting-input) stage within a run. */ export interface WorkflowStatusActiveStage { /** Expanded-graph stage id; valid for stage-scoped send/pause/resume. */ @@ -81,8 +79,6 @@ export interface WorkflowStatusToolNode { */ export interface WorkflowRunStatusSummary { readonly runId: string; - /** Abbreviated run id as printed by status surfaces; a valid prefix input. */ - readonly runIdPrefix: string; /** Workflow/run name. */ readonly name: string; readonly status: RunStatus; @@ -152,7 +148,6 @@ export function summarizeRunSnapshot(run: RunSnapshot, now = Date.now()): Workfl const awaitingInput = awaitingInputEntries(run); return { runId: run.id, - runIdPrefix: run.id.slice(0, RUN_ID_PREFIX_LEN), name: run.name, status: effectiveRunStatus(run), startedAt: run.startedAt, diff --git a/packages/workflows/src/extension/workflow-targets.ts b/packages/workflows/src/extension/workflow-targets.ts index 9615a4dac..0675c3124 100644 --- a/packages/workflows/src/extension/workflow-targets.ts +++ b/packages/workflows/src/extension/workflow-targets.ts @@ -5,6 +5,7 @@ import { expandWorkflowGraph, stageMatchesExpandedIdentifier, } from "../shared/expanded-workflow-graph.js"; +import { isFullRunId, malformedRunIdMessage, RUN_ID_LENGTH } from "../shared/run-id.js"; import { topLevelWorkflowRuns } from "../shared/run-visibility.js"; import { store } from "../shared/store.js"; import type { RunStatus } from "../shared/store-types.js"; @@ -13,7 +14,7 @@ import type { PiExecuteContext, WorkflowToolArgs } from "./public-types.js"; import type { PiUISurface } from "./wiring.js"; export function formatAlreadyEndedRetainedMessage(runId: string): string { - return `Run ${runId.slice(0, 8)} already ended; retained for inspection.`; + return `Run ${runId} already ended; retained for inspection.`; } export function stageFailureMessage(runId: string, resultReason: string, action: "pause" | "interrupt"): string { @@ -78,25 +79,24 @@ export function isRunStatus(value: string): value is RunStatus { } } +export { isFullRunId, malformedRunIdMessage, RUN_ID_LENGTH }; + export type RunIdResolution = | { kind: "exact"; runId: string } - | { kind: "ambiguous"; matches: string[] } + | { kind: "malformed"; message: string } | { kind: "not_found" }; -export function resolveRunIdPrefix(target: string): RunIdResolution { - const runs = store.runs(); - const exact = runs.find((r) => r.id === target); +export function resolveRunId(target: string): RunIdResolution { + if (!isFullRunId(target)) return { kind: "malformed", message: malformedRunIdMessage(target) }; + const exact = store.runs().find((r) => r.id === target); if (exact) return { kind: "exact", runId: exact.id }; - const prefixed = runs.filter((r) => r.id.startsWith(target)); - if (prefixed.length === 0) return { kind: "not_found" }; - if (prefixed.length === 1) return { kind: "exact", runId: prefixed[0]!.id }; - return { kind: "ambiguous", matches: prefixed.map((r) => r.id) }; + return { kind: "not_found" }; } export type ToolRunTarget = | { kind: "all" } | { kind: "run"; runId: string } - | { kind: "ambiguous"; target: string; matches: string[] } + | { kind: "malformed"; target: string; message: string } | { kind: "not_found"; target: string; message: string }; export function resolveToolRunTarget(args: WorkflowToolArgs, emptyMessage: string): ToolRunTarget { @@ -104,9 +104,9 @@ export function resolveToolRunTarget(args: WorkflowToolArgs, emptyMessage: strin if (args.all === true || rawTarget === "--all") return { kind: "all" }; const target = rawTarget || store.activeRunId() || ""; if (!target) return { kind: "not_found", target: rawTarget, message: emptyMessage }; - const resolved = resolveRunIdPrefix(target); + const resolved = resolveRunId(target); if (resolved.kind === "exact") return { kind: "run", runId: resolved.runId }; - if (resolved.kind === "ambiguous") return { kind: "ambiguous", target, matches: resolved.matches }; + if (resolved.kind === "malformed") return { kind: "malformed", target, message: resolved.message }; return { kind: "not_found", target, message: `Run not found: ${target}` }; } @@ -126,7 +126,7 @@ export function resolveStageTarget(runId: string, stageTarget?: string): ToolSta if (exactNames.length === 1) return resolvedStageTarget(exactNames[0]!); if (exactNames.length > 1) return ambiguousStageTarget(target, exactNames); const matches = graph.stages.filter((stage) => stageMatchesExpandedIdentifier(stage, target)); - if (matches.length === 0) return { ok: false, message: `Stage not found in run ${runId.slice(0, 8)}: ${target}` }; + if (matches.length === 0) return { ok: false, message: `Stage not found in run ${runId}: ${target}` }; if (matches.length > 1) return ambiguousStageTarget(target, matches); return resolvedStageTarget(matches[0]!); } @@ -182,7 +182,7 @@ export function resolveControlNodeTarget(runId: string, stageTarget?: string): C message: `Ambiguous stage identifier "${target}" matches: ${matches.map(expandedStageLabel).join(", ")}`, }; } - return { ok: false, message: `Stage not found in run ${runId.slice(0, 8)}: ${target}` }; + return { ok: false, message: `Stage not found in run ${runId}: ${target}` }; } function resolvedControlNodeTarget(node: ExpandedWorkflowStage): ControlNodeTarget { @@ -196,10 +196,6 @@ export function toolNodePauseRejectionMessage(name: string, nodeId: string): str return `Tool nodes cannot be paused; ctx.tool ${name} (${nodeId}) has no turn boundary. Use interrupt or quit to abort it.`; } -export function ambiguousRunMessage(target: string, matches: readonly string[]): string { - return `Ambiguous run prefix "${target}" matches: ${matches.map((id) => id.slice(0, 12)).join(", ")}`; -} - export function overlaySurfaceFromContext(ctx?: { ui?: PiUISurface }): OverlayPiSurface | undefined { return typeof ctx?.ui?.custom === "function" ? { ui: ctx.ui } : undefined; } diff --git a/packages/workflows/src/extension/workflow-tool-content.ts b/packages/workflows/src/extension/workflow-tool-content.ts index 85cc9d8f2..969c7e9b4 100644 --- a/packages/workflows/src/extension/workflow-tool-content.ts +++ b/packages/workflows/src/extension/workflow-tool-content.ts @@ -81,7 +81,7 @@ function renderStatusToolContent(result: Extract match.workflowId); - return { - action: "resume", - runId: target, - status: "noop", - message: `Ambiguous run prefix "${target}" matches: ${matches.join(", ")}`, - }; + if (resolved.kind === "malformed") { + return { action: "resume", runId: target, status: "noop", message: resolved.message }; } if (resolved.kind === "durable") return resumePreparedDurableTarget(resolved.workflowId, deps); if (resolved.kind === "live") { @@ -460,9 +435,8 @@ export async function workflowResumeAction( const target = resolveToolRunTarget(args, "No active run to resume."); if (target.kind === "all") return { action: "resume", runId: "--all", status: "noop", message: "Resume does not support --all." }; - if (target.kind === "ambiguous") { - const liveMatches = store.runs().filter((run) => target.matches.includes(run.id)); - return resolveExplicitDurableTarget(target.target, args, deps, liveMatches); + if (target.kind === "malformed") { + return { action: "resume", runId: target.target, status: "noop", message: target.message }; } if (target.kind === "not_found") { const explicitTarget = args.runId?.trim(); @@ -471,11 +445,8 @@ export async function workflowResumeAction( } return { action: "resume", runId: target.target, status: "noop", message: target.message }; } - const explicitTarget = args.runId?.trim(); - if (explicitTarget !== undefined && explicitTarget.length > 0 && explicitTarget !== target.runId) { - const liveMatches = store.runs().filter((run) => run.id.startsWith(explicitTarget)); - return resolveExplicitDurableTarget(explicitTarget, args, deps, liveMatches); - } + // An explicit target now resolves only by exact id, so it can never disagree + // with the resolved run; the old re-resolution branch here is unreachable. const backend = getDurableBackend(); const exact = store.runs().find((run) => run.id === target.runId); const shadow = exact === undefined ? "not_shadow" : classifyDurableResumeShadow(exact, store, { backend }); @@ -546,9 +517,9 @@ export async function workflowResumeAction( (isPaused ? result.resumed.length === 0 ? runLevelResumed - ? `Resumed run ${result.runId.slice(0, 8)}.` - : `No paused stages on run ${result.runId.slice(0, 8)}.` - : `Resumed ${result.resumed.length} stage(s) on run ${result.runId.slice(0, 8)}${args.message ? ` with message: "${args.message}"` : ""}.` + ? `Resumed run ${result.runId}.` + : `No paused stages on run ${result.runId}.` + : `Resumed ${result.resumed.length} stage(s) on run ${result.runId}${args.message ? ` with message: "${args.message}"` : ""}.` : `Snapshot available: run ${result.runId} (${result.snapshot.name}) — status: ${result.snapshot.status}, stages: ${result.snapshot.stages.length}`); const status = result.mode === "partial" ? "partial" : noPausedProgress ? "noop" : "ok"; return { action: "resume", runId: result.runId, status, message }; diff --git a/packages/workflows/src/extension/workflow-tool-inspection.ts b/packages/workflows/src/extension/workflow-tool-inspection.ts index 305bf1f7c..82a776006 100644 --- a/packages/workflows/src/extension/workflow-tool-inspection.ts +++ b/packages/workflows/src/extension/workflow-tool-inspection.ts @@ -12,7 +12,7 @@ import { summarizeStage, transcriptEntryFromMessage, } from "./workflow-stage-results.js"; -import { ambiguousRunMessage, resolveToolRunTarget, resolveToolStageTarget } from "./workflow-targets.js"; +import { resolveToolRunTarget, resolveToolStageTarget } from "./workflow-targets.js"; export function workflowStagesResult(args: WorkflowToolArgs): WorkflowToolResult { const target = resolveToolRunTarget(args, "No active run to inspect."); @@ -26,16 +26,7 @@ export function workflowStagesResult(args: WorkflowToolArgs): WorkflowToolResult error: "Stage listing requires a single run.", }; } - if (target.kind === "ambiguous") { - return { - action: "stages", - runId: target.target, - filter, - stages: [], - error: ambiguousRunMessage(target.target, target.matches), - }; - } - if (target.kind === "not_found") { + if (target.kind === "malformed" || target.kind === "not_found") { return { action: "stages", runId: target.target, @@ -56,10 +47,7 @@ export function workflowStageResult(args: WorkflowToolArgs): WorkflowToolResult if (target.kind === "all") { return { action: "stage", runId: "--all", error: "Stage inspection requires a single run." }; } - if (target.kind === "ambiguous") { - return { action: "stage", runId: target.target, error: ambiguousRunMessage(target.target, target.matches) }; - } - if (target.kind === "not_found") { + if (target.kind === "malformed" || target.kind === "not_found") { return { action: "stage", runId: target.target, error: target.message }; } const stage = resolveToolStageTarget(target.runId, args.stageId); @@ -67,7 +55,7 @@ export function workflowStageResult(args: WorkflowToolArgs): WorkflowToolResult return { action: "stage", runId: target.runId, - error: stage.ok ? "Stage id, prefix, or name is required." : stage.message, + error: stage.ok ? "Stage id or name is required." : stage.message, }; } const stageRunId = stage.runId ?? target.runId; @@ -78,7 +66,7 @@ export function workflowStageResult(args: WorkflowToolArgs): WorkflowToolResult : { action: "stage", runId: stageRunId, - error: `Stage not found in run ${stageRunId.slice(0, 8)}: ${stage.stageId}`, + error: `Stage not found in run ${stageRunId}: ${stage.stageId}`, }; } @@ -94,17 +82,7 @@ export function workflowTranscriptResult(args: WorkflowToolArgs): WorkflowToolRe truncated: false, }; } - if (target.kind === "ambiguous") { - return { - action: "transcript", - runId: target.target, - stageId: "", - source: "error", - entries: [{ role: "notice", text: ambiguousRunMessage(target.target, target.matches) }], - truncated: false, - }; - } - if (target.kind === "not_found") { + if (target.kind === "malformed" || target.kind === "not_found") { return { action: "transcript", runId: target.target, @@ -121,7 +99,7 @@ export function workflowTranscriptResult(args: WorkflowToolArgs): WorkflowToolRe runId: target.runId, stageId: "", source: "error", - entries: [{ role: "notice", text: stage.ok ? "Stage id, prefix, or name is required." : stage.message }], + entries: [{ role: "notice", text: stage.ok ? "Stage id or name is required." : stage.message }], truncated: false, }; } diff --git a/packages/workflows/src/extension/workflow-tool-send.ts b/packages/workflows/src/extension/workflow-tool-send.ts index 4256ab869..c6597a5f9 100644 --- a/packages/workflows/src/extension/workflow-tool-send.ts +++ b/packages/workflows/src/extension/workflow-tool-send.ts @@ -12,7 +12,7 @@ import { isTerminalRunStatus } from "../shared/store-internal.js"; import { reciprocalWorkflowRootRunId } from "../shared/workflow-run-ownership.js"; import type { WorkflowToolArgs } from "./public-types.js"; import type { WorkflowToolResult } from "./render-result.js"; -import { ambiguousRunMessage, resolveToolRunTarget, resolveToolStageTarget } from "./workflow-targets.js"; +import { resolveToolRunTarget, resolveToolStageTarget } from "./workflow-targets.js"; /** * Optional dependencies enabling `workflow send` to revive an eligible @@ -120,16 +120,7 @@ export async function workflowSendAction( if (target.kind === "all") { return workflowSendResult("--all", "", requestedDelivery, "noop", "Send requires a single run."); } - if (target.kind === "ambiguous") { - return workflowSendResult( - target.target, - "", - requestedDelivery, - "noop", - ambiguousRunMessage(target.target, target.matches), - ); - } - if (target.kind === "not_found") { + if (target.kind === "malformed" || target.kind === "not_found") { return workflowSendResult(target.target, "", requestedDelivery, "noop", target.message); } const runs = store.runs(); @@ -146,7 +137,7 @@ export async function workflowSendAction( "", requestedDelivery, "noop", - stage.ok ? "Stage id, prefix, or name is required." : stage.message, + stage.ok ? "Stage id or name is required." : stage.message, ); } const resolvedStageId = stage.stageId; diff --git a/packages/workflows/src/extension/workflow-tool.ts b/packages/workflows/src/extension/workflow-tool.ts index 17013b5d5..bd17fd3ce 100644 --- a/packages/workflows/src/extension/workflow-tool.ts +++ b/packages/workflows/src/extension/workflow-tool.ts @@ -8,12 +8,7 @@ import { formatWorkflowResourceLoadWarning } from "./workflow-command-surfaces.j import { workflowPolicyFromContext } from "./workflow-policy.js"; import type { WorkflowReloadReport } from "./workflow-reload-report.js"; import { buildWorkflowStatusListing } from "./workflow-status-summary.js"; -import { - ambiguousRunMessage, - isWorkflowStageToolContext, - resolveRunIdPrefix, - topLevelExpandedSnapshots, -} from "./workflow-targets.js"; +import { isWorkflowStageToolContext, resolveRunId, topLevelExpandedSnapshots } from "./workflow-targets.js"; import { workflowGetResult } from "./workflow-tool-content.js"; import { workflowInterruptAction, @@ -84,13 +79,9 @@ export function makeExecuteWorkflowTool( case "status": { const target = args.runId; if (target !== undefined) { - const resolved = resolveRunIdPrefix(target); - if (resolved.kind === "ambiguous") { - return { - action: "statusDetail", - runId: target, - error: ambiguousRunMessage(target, resolved.matches), - }; + const resolved = resolveRunId(target); + if (resolved.kind === "malformed") { + return { action: "statusDetail", runId: target, error: resolved.message }; } if (resolved.kind === "not_found") { return { action: "statusDetail", runId: target, error: `run not found: ${target}` }; diff --git a/packages/workflows/src/runs/background/quit.ts b/packages/workflows/src/runs/background/quit.ts index 29a0da757..b83c61566 100644 --- a/packages/workflows/src/runs/background/quit.ts +++ b/packages/workflows/src/runs/background/quit.ts @@ -247,7 +247,7 @@ function controllableAdmissionBoundaries( */ async function closeToolAdmission(boundaries: readonly ToolAdmissionBoundary[], runId: string): Promise { if (boundaries.length === 0) return; - const reason = new WorkflowGracefulQuitError(runId, `run ${runId.slice(0, 8)}`); + const reason = new WorkflowGracefulQuitError(runId, `run ${runId}`); await Promise.all(boundaries.map((boundary) => boundary.closeForQuit(reason))); } diff --git a/packages/workflows/src/runs/background/run-inspect.ts b/packages/workflows/src/runs/background/run-inspect.ts index 2daaaad92..f954934e9 100644 --- a/packages/workflows/src/runs/background/run-inspect.ts +++ b/packages/workflows/src/runs/background/run-inspect.ts @@ -62,18 +62,18 @@ export type InspectRunResult = | { ok: false; runId: string; reason: "not_found" }; /** - * Look up a single run by id (full UUID or unique prefix) and return a - * normalised {@link RunDetail} for the per-run text/TUI surfaces. + * Look up a single run by its exact id and return a normalised + * {@link RunDetail} for the per-run text/TUI surfaces. * - * Returns ok:false "not_found" when no run matches, "ambiguous" when a - * prefix matches multiple. Read-only: does not mutate the store. + * Exact match only. Callers reach this with an id already resolved at the input + * boundary, where shape is validated; a second, looser prefix match here would + * only reintroduce the truncated targeting the resolvers now reject. + * + * Read-only: does not mutate the store. */ export function inspectRun(runId: string, opts?: { store?: Store }): InspectRunResult { const activeStore = opts?.store ?? defaultStore; - const runs = activeStore.runs(); - - const exact = runs.find((r) => r.id === runId); - const candidate = exact ?? (runs.length > 0 ? runs.find((r) => r.id.startsWith(runId)) : undefined); + const candidate = activeStore.runs().find((r) => r.id === runId); if (!candidate) { return { ok: false, runId, reason: "not_found" }; diff --git a/packages/workflows/src/shared/expanded-workflow-graph.ts b/packages/workflows/src/shared/expanded-workflow-graph.ts index 8004b2afc..67f80fa65 100644 --- a/packages/workflows/src/shared/expanded-workflow-graph.ts +++ b/packages/workflows/src/shared/expanded-workflow-graph.ts @@ -262,24 +262,22 @@ export function expandedStageTarget( return graph.targets.get(virtualStageIdValue); } +/** + * Exact-match only. A stage id is never truncated to address a stage: at the + * root that id is a bare UUID, nested it is the `runId:nodeId` composite built + * by `virtualNodeId`, and a tool node carries `tool:`. Names match + * whole, so `build` never selects `build-check`. + */ export function stageMatchesExpandedIdentifier(stage: ExpandedWorkflowStage, target: string): boolean { const graphTarget = stage.workflowGraphTarget; return ( - stage.id === target || - stage.name === target || - stage.id.startsWith(target) || - graphTarget.stageId === target || - graphTarget.stageId.startsWith(target) || - graphTarget.runId === target || - graphTarget.runId.startsWith(target) + stage.id === target || stage.name === target || graphTarget.stageId === target || graphTarget.runId === target ); } export function expandedStageLabel(stage: ExpandedWorkflowStage): string { const target = stage.workflowGraphTarget; if (stage.nodeKind === "tool") return `${stage.name} (tool)`; - const runPrefix = target.runId.slice(0, 8); - const stagePrefix = target.stageId.slice(0, 8); const depthPrefix = target.depth > 0 ? `${childAliasFor(stage) ?? target.runName}:` : ""; - return `${depthPrefix}${stage.name} (${runPrefix}/${stagePrefix})`; + return `${depthPrefix}${stage.name} (${target.runId}/${target.stageId})`; } diff --git a/packages/workflows/src/shared/persistence-session-entries.ts b/packages/workflows/src/shared/persistence-session-entries.ts index 6bc08fb40..30154c1df 100644 --- a/packages/workflows/src/shared/persistence-session-entries.ts +++ b/packages/workflows/src/shared/persistence-session-entries.ts @@ -139,7 +139,7 @@ export interface RunBlockedPayload { /** * Appends a `workflow.run.start` entry and labels it for /tree filtering. - * Label format: `wf::` (first 8 chars of runId). + * Label format: `wf::`. */ export function appendRunStart(api: PersistenceAPI, payload: RunStartPayload): void { if (typeof api.appendEntry !== "function") return; @@ -156,8 +156,7 @@ export function appendRunStart(api: PersistenceAPI, payload: RunStartPayload): v ts: payload.ts, }); if (entryId && typeof api.setLabel === "function") { - const shortId = payload.runId.slice(0, 8); - api.setLabel(entryId, `wf:${payload.name}:${shortId}`); + api.setLabel(entryId, `wf:${payload.name}:${payload.runId}`); } } diff --git a/packages/workflows/src/shared/run-id.ts b/packages/workflows/src/shared/run-id.ts new file mode 100644 index 000000000..51f26e959 --- /dev/null +++ b/packages/workflows/src/shared/run-id.ts @@ -0,0 +1,36 @@ +/** + * The run identifier contract. + * + * A run id is a bare `crypto.randomUUID()` value (see `extension/dispatcher.ts`), + * and the durable DBOS `workflowId` is that same value. Every user-facing surface + * renders it in full, so every resolver accepts it only in full: there is no + * unique-prefix fallback and no truncated form that addresses a run. + * + * This lives in `shared/` because both the extension resolvers and the durable + * catalog need it, and `durable/` must not import from `extension/`. + */ + +/** Canonical rendered length of a run id, dashes included. */ +export const RUN_ID_LENGTH = 36; + +const RUN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * True only for a full 8-4-4-4-12 hex UUID. + * + * Rejects a prefix, a 32-character dashless form, and a 36-character string that + * is the right length but not hex — a transposed or truncated paste should fail + * loudly here rather than silently miss during lookup. + */ +export function isFullRunId(value: string): boolean { + return RUN_ID_PATTERN.test(value); +} + +/** + * Reported instead of "not found" so a truncated paste is diagnosable as + * truncated. A well-formed id that no run happens to carry is a different + * failure and keeps the not-found message. + */ +export function malformedRunIdMessage(target: string): string { + return `Run id must be a full ${RUN_ID_LENGTH}-character UUID; got "${target}" (${target.length} chars).`; +} diff --git a/packages/workflows/src/tui/dispatch-confirm.ts b/packages/workflows/src/tui/dispatch-confirm.ts index acd437300..b57acb6df 100644 --- a/packages/workflows/src/tui/dispatch-confirm.ts +++ b/packages/workflows/src/tui/dispatch-confirm.ts @@ -5,10 +5,8 @@ * Visual contract: * - One rounded `DISPATCHED` panel. * - One status-coloured rounded run card: - * title: runId8 · workflowName · ● running + * title: full runId · workflowName · ● running * body: compact `k=v · k=v · +N more` input summary when present - * - One compact next-step hint: - * ▸ /workflow connect see agents working · chat with and steer each stage * * What we deliberately do NOT emit (was in the legacy 7-row layout): * - the `✓ submitted · /workflow ` echo line — pi already shows @@ -16,8 +14,8 @@ * - the `[ DISPATCHED ]` band — a single-card surface doesn't need a * band wrapper (bands frame multi-card surfaces like BACKGROUND / * WORKFLOWS); - * - the `run id` muted caption beside the tag — the bg-pill chip with - * an 8-char hex string visually communicates "identifier"; + * - the `run id` muted caption — the bare identifier row already + * communicates identity without restoring legacy chrome; * - the `status starting…` body row — the `● running` badge on row 1 * occupies the same semantic slot; * - the second hint row `▸ /workflow status` — that is a separate intent @@ -34,13 +32,13 @@ */ import type { WorkflowInputValues } from "../shared/types.js"; -import { chatWidth, ELLIPSIS, renderHintRows, renderRoundedBox } from "./chat-surface.js"; +import { chatWidth, ELLIPSIS, renderRoundedBox } from "./chat-surface.js"; import { hexToAnsi, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; +import { wrapIdentifierLines } from "./run-identity-rows.js"; import { truncateToWidth, visibleWidth } from "./text-helpers.js"; const INLINE_INPUT_LIMIT = 3; -const SHORT_ID_LEN = 8; /** * Below this many cells, inline inputs on row 1 are unreadable. We wrap @@ -51,9 +49,9 @@ const SHORT_ID_LEN = 8; const MIN_INLINE_INPUT_BUDGET = 16; export interface RenderDispatchConfirmOpts { - /** Registered workflow name (rendered bold beside the run-id tag on row 1). */ + /** Registered workflow name rendered beside the full run-id body row. */ workflowName: string; - /** Real run UUID; the renderer surfaces the first 8 chars in the tag. */ + /** Real run UUID; every rendered occurrence keeps the complete value. */ runId: string; /** Inputs merged from CLI tokens + picker output. */ inputs: Readonly; @@ -72,8 +70,7 @@ export function renderDispatchConfirm(opts: RenderDispatchConfirmOpts): string { const width = effectiveWidth(opts.width); const theme = opts.theme; const accent = theme?.warning ?? "#000000"; - const tag = shortRunId(opts.runId); - + const tag = opts.runId; // Status badge — anchored to the right of row 1, in the running hue. // Mirrors the `● running` glyph used by every other live-run surface. const trailing = theme ? { text: "● running", fg: theme.warning } : { text: "● running" }; @@ -117,34 +114,53 @@ export function renderDispatchConfirm(opts: RenderDispatchConfirmOpts): string { // the full body interior (width - body chrome) as the wider canvas. if (hasInputs && titleSuffix === undefined) { const BODY_PREFIX_W = 4; // " " — see renderTaggedCard body prefix - const bodyBudget = Math.max(0, width - BODY_PREFIX_W - 1); + const bodyBudget = Math.max(0, width - 2 - BODY_PREFIX_W); const overflowSeg = renderInputsSegment(opts.inputs, bodyBudget, theme); if (overflowSeg) bodyRows.push(overflowSeg.rendered); } const inputRows = bodyRows.length > 0 ? bodyRows.map((row) => ` ${row} `) : [` ${titleSuffix ?? "started in background"} `]; - const titleLine = ` ● ${tag} ${opts.workflowName} ${trailing.text} `; + const identifierRows = renderIdentifierRows(opts.runId, width - 2, theme); + const titleLine = ` ● ${opts.workflowName} ${trailing.text} `; - const hints = renderHintRows( - [{ command: `/workflow connect ${tag}`, hint: "see agents working · chat with and steer each stage" }], - theme, - ) - .split("\n") - .map((line) => ` ${line} `); + const hints = renderDispatchHintRows(opts.runId, width - 4, theme).map((line) => ` ${line} `); return renderRoundedBox({ title: "DISPATCHED", - bodyLines: [titleLine, ...inputRows, "", ...hints], + bodyLines: [...identifierRows, titleLine, ...inputRows, "", ...hints], accent, theme, width, }); } -/** First 8 chars of the run UUID — the canonical short form. */ -function shortRunId(runId: string): string { - return runId.length > SHORT_ID_LEN ? runId.slice(0, SHORT_ID_LEN) : runId; +function renderIdentifierRows(id: string, width: number, theme?: GraphTheme): string[] { + const rows = wrapIdentifierLines(id, width, " ", " "); + if (!theme) return rows.map((row) => `${row.prefix}${row.chunk}`); + const accent = hexToAnsi(theme.accent); + return rows.map((row) => `${row.prefix}${accent}${row.chunk}${RESET}`); +} + +function renderDispatchHintRows(id: string, width: number, theme?: GraphTheme): string[] { + const budget = Math.max(1, width); + const prefix = "▸ /workflow connect "; + const continuation = " "; + const rows = wrapIdentifierLines(id, budget, prefix, continuation); + const suffix = " see agents working · chat with and steer each stage"; + const last = rows[rows.length - 1]!; + if (visibleWidth(`${last.prefix}${last.chunk}${suffix}`) <= budget) { + last.chunk += suffix; + } else { + rows.push({ + prefix: continuation, + chunk: truncateToWidth(suffix.trimStart(), Math.max(1, budget - visibleWidth(continuation)), ELLIPSIS), + }); + } + if (!theme) return rows.map((row) => `${row.prefix}${row.chunk}`); + const dim = hexToAnsi(theme.dim); + const accent = hexToAnsi(theme.accent); + return rows.map((row) => `${dim}${row.prefix}${RESET}${accent}${row.chunk}${RESET}`); } interface InputsSegment { diff --git a/packages/workflows/src/tui/graph-view-render.ts b/packages/workflows/src/tui/graph-view-render.ts index 3891fbe12..50f4b5bc3 100644 --- a/packages/workflows/src/tui/graph-view-render.ts +++ b/packages/workflows/src/tui/graph-view-render.ts @@ -146,21 +146,25 @@ export abstract class GraphViewRenderer extends GraphViewGraphRenderer { // surface that interacts with the prompt. When the stage switcher is // open it owns the body/input, so hide the prompt card until it closes. if (!this.promptState || this.switcherOpen) return; - + const run = this._getCurrentRun(); const cardWidth = Math.min(72, Math.max(40, frameWidth - 6)); const cardLines = renderPromptCard({ state: this.promptState, theme: this.graphTheme, width: cardWidth, cursorOn: ((Date.now() / 530) | 0) % 2 === 0, + identity: run ? { runId: run.id, name: run.name } : undefined, + maxRows: bodyTarget, }); const bodyStart = 3; - const bodyEnd = 3 + bodyTarget; + const bodyEnd = bodyStart + bodyTarget; const slot = Math.max(bodyStart, bodyStart + Math.floor((bodyTarget - cardLines.length) / 2)); + // The card renderer owns row budgeting, but keep this composition boundary + // defensive: a complete card fits wholly in the body or is not painted. + if (cardLines.length > bodyTarget || slot + cardLines.length > bodyEnd) return; const leftPad = Math.max(0, Math.floor((frameWidth - cardWidth) / 2)); for (let i = 0; i < cardLines.length; i++) { const lineIdx = slot + i; - if (lineIdx >= bodyEnd) break; const base = lines[lineIdx] ?? this._blankRow(frameWidth); lines[lineIdx] = this._overlayCard(base, cardLines[i]!, leftPad, frameWidth); } diff --git a/packages/workflows/src/tui/node-card.ts b/packages/workflows/src/tui/node-card.ts index 9abf9c39b..17fa0ee3d 100644 --- a/packages/workflows/src/tui/node-card.ts +++ b/packages/workflows/src/tui/node-card.ts @@ -30,6 +30,7 @@ import { elapsedStageMs } from "../shared/timing.js"; import { BOLD, hexBg, hexToAnsi, lerpColor, paint, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; import { NODE_H, NODE_W } from "./layout.js"; +import { wrapIdentifierLines } from "./run-identity-rows.js"; import { fmtDuration, statusIcon } from "./status-helpers.js"; import { truncateToWidth, visibleWidth } from "./text-helpers.js"; @@ -131,28 +132,25 @@ function metaText(stage: StageSnapshot): string { return stage.fastMode === true ? `${dependencyText} · fast` : dependencyText; } -function shortRunId(runId: string): string { - return runId.length <= 8 ? runId : runId.slice(0, 8); -} - -function workflowChildSummaryText(stage: StageSnapshot): string { +function workflowChildRunRows(stage: StageSnapshot, width: number): string[] { const child = stage.workflowChild ?? stage.workflowChildRun; - if (child === undefined) return durationText(stage); - return `↳ ${child.workflow}`; + if (child === undefined) return []; + return wrapIdentifierLines(child.runId, Math.max(1, width), "run ", "").map((row) => `${row.prefix}${row.chunk}`); } -function workflowChildMetaText(stage: StageSnapshot): string { +function workflowChildMetaText(stage: StageSnapshot): string | undefined { const completed = stage.workflowChild; if (completed !== undefined) { const outputCount = Object.keys(completed.outputs).length; - const outputs = outputCount === 1 ? "1 out" : `${outputCount} outs`; - return `run ${shortRunId(completed.runId)} · ${outputs}`; + return outputCount === 1 ? "1 out" : `${outputCount} outs`; } + if (stage.workflowChildRun !== undefined) return "live"; + return undefined; +} - const live = stage.workflowChildRun; - if (live !== undefined) return `run ${shortRunId(live.runId)} · live`; - - return metaText(stage); +function joinCompactStatusMeta(status: string, meta: string, width: number): string { + const candidates = [`${status} · ${meta}`, `${status} ·${meta}`, `${status}· ${meta}`, `${status}·${meta}`]; + return candidates.find((candidate) => visibleWidth(candidate) <= width) ?? meta; } function statusLabel(status: StageStatus): string { @@ -210,22 +208,23 @@ function buildTitleSlot( focused: boolean, theme: GraphTheme, cardBg: string, + compact = false, ): { slot: string; visibleWidth: number } { - const maxName = Math.max(2, innerWidth - 4); + const maxName = Math.max(2, compact ? innerWidth - 1 : innerWidth - 4); const safeName = truncate(name, maxName); if (focused) { // Flanking spaces sit on the accent tab so the pill reads as a // single coloured run. Use `paint` to combine bg + fg + bold + // RESET in one ANSI sequence, then re-prime the card stratum so // the dashes outside the slot stay on the body bg. - const tabText = ` ${safeName} `; + const tabText = compact ? safeName : ` ${safeName} `; const styled = `${paint(tabText, theme.surface, { bg: theme.accent, bold: true, })}${cardBg}`; return { slot: styled, visibleWidth: visibleWidth(tabText) }; } - const titleRaw = ` ${safeName} `; + const titleRaw = compact ? safeName : ` ${safeName} `; const styled = `${BOLD}${titleRaw}${RESET}${cardBg}`; return { slot: styled, visibleWidth: visibleWidth(titleRaw) }; } @@ -251,14 +250,16 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string const bg = hexBg(theme.bg); const innerWidth = Math.max(2, width - 2); - // Title sits inside the top border. Focus adds an accent-coloured - // pill to the name slot without adding an extra glyph. + // Child workflow boundaries use the compact title path so their workflow + // identity remains visible without changing the fixed card geometry. + const child = stage.workflowChild ?? stage.workflowChildRun; const { slot: titleSlot, visibleWidth: titleVisibleWidth } = buildTitleSlot( - stage.name, + child === undefined ? stage.name : `↳ ${child.workflow}`, innerWidth, focused, theme, bg, + child !== undefined, ); const titleStart = Math.max(1, Math.floor((innerWidth - titleVisibleWidth) / 2)); const titleEnd = titleStart + titleVisibleWidth; @@ -267,17 +268,12 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string const top = `${bg}${bc}╭${topMiddle}╮${RESET}`; const bottom = `${bg}${bc}╰${"─".repeat(innerWidth)}╯${RESET}`; - // Interior — compact status + duration. Child workflow boundary - // stages otherwise look like empty completed nodes, so use the first - // body row for the child workflow identity and the final row for a - // terse child-run summary. This keeps the graph dense while making - // the boundary explain what actually ran. const bodyText = stage.nodeKind === "tool" ? (stage.error ?? stage.result ?? "durable tool") : stage.status === "blocked" ? blockedBadgeText(stage, opts.stages, innerWidth) - : workflowChildSummaryText(stage); + : durationText(stage); const bodyHex = durationColor(stage.status, theme); const statusText = `${statusIcon(stage.status)} ${stage.toolStatus ?? statusLabel(stage.status)}`; const statusLine = @@ -292,10 +288,26 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string bold: stage.status === "blocked", }) + `${bg}${bc}│${RESET}`; - const metaLine = - `${bg}${bc}│${RESET}` + - centreColored(workflowChildMetaText(stage), innerWidth, theme.dim, bg) + - `${bg}${bc}│${RESET}`; + + const contentRows = Math.max(0, height - 2); + const metaLine = `${bg}${bc}│${RESET}${centreColored(metaText(stage), innerWidth, theme.dim, bg)}${bg}${bc}│${RESET}`; + const childRunLines = workflowChildRunRows(stage, innerWidth).map( + (row) => `${bg}${bc}│${RESET}${centreColored(row, innerWidth, theme.dim, bg)}${bg}${bc}│${RESET}`, + ); + const queuedCount = queuedBadgeCount(opts.queuedMessageCount); + const childMeta = workflowChildMetaText(stage); + const childSummary = + childMeta === undefined + ? undefined + : joinCompactStatusMeta(statusText, queuedCount > 0 ? queuedBadgeText(queuedCount) : childMeta, innerWidth); + const childSummaryLine = + childSummary === undefined + ? undefined + : `${bg}${bc}│${RESET}` + + centreColored(childSummary, innerWidth, bodyHex, bg, { + bold: stage.status === "running" || stage.status === "awaiting_input", + }) + + `${bg}${bc}│${RESET}`; const interior: string[] = stage.status === "awaiting_input" @@ -308,29 +320,25 @@ export function renderNodeCard(stage: StageSnapshot, opts: NodeCardOpts): string centreColored("↵ enter to respond", innerWidth, theme.dim, bg) + `${bg}${bc}│${RESET}`, ] - : [durLine, statusLine, metaLine]; + : childSummaryLine === undefined + ? [durLine, statusLine, metaLine] + : [...childRunLines, childSummaryLine]; // A queued steer/follow-up is invisible once the user leaves the stage chat, // so it claims one existing body row rather than competing for space inside a - // line that would truncate. Card geometry is unchanged: the row is replaced, - // not added. Pick the least useful row: the dim metadata row on an ordinary - // card, and the redundant "waiting for response" row on an awaiting-input - // card, which keeps its status row and its `↵ enter to respond` action hint. - const queuedCount = queuedBadgeCount(opts.queuedMessageCount); - const preferredBadgeRow = stage.status === "awaiting_input" ? 1 : interior.length - 1; + // line that would truncate. Child boundaries pack it beside status except when + // the awaiting-input interior leaves its redundant response row available. + const preferredBadgeRow = + stage.status === "awaiting_input" ? 1 : childSummaryLine === undefined ? interior.length - 1 : -1; // Pad / clip to exactly `height` lines. - const contentRows = Math.max(0, height - 2); while (interior.length < contentRows) { interior.push(`${bg}${bc}│${RESET}${bg}${" ".repeat(innerWidth)}${bg}${bc}│${RESET}`); } - if (interior.length > contentRows) { - interior.length = contentRows; - } + if (interior.length > contentRows) interior.length = contentRows; - if (queuedCount > 0 && interior.length > 0) { - const badgeRow = - preferredBadgeRow >= 0 && preferredBadgeRow < interior.length ? preferredBadgeRow : interior.length - 1; + if (queuedCount > 0 && interior.length > 0 && preferredBadgeRow >= 0) { + const badgeRow = preferredBadgeRow < interior.length ? preferredBadgeRow : interior.length - 1; interior[badgeRow] = `${bg}${bc}│${RESET}` + centreColored(queuedBadgeText(queuedCount), innerWidth, theme.info, bg, { bold: true }) + diff --git a/packages/workflows/src/tui/prompt-card-render.ts b/packages/workflows/src/tui/prompt-card-render.ts index 18e0ab590..f7e4944e5 100644 --- a/packages/workflows/src/tui/prompt-card-render.ts +++ b/packages/workflows/src/tui/prompt-card-render.ts @@ -6,41 +6,273 @@ import type { GraphTheme } from "./graph-theme.js"; import { createPromptSelectList } from "./prompt-card-select.js"; import type { PromptCardState } from "./prompt-card-state.js"; import { graphemeParts } from "./prompt-card-text.js"; +import { renderRunIdentityRows } from "./run-identity-rows.js"; +import { statusColor, statusIcon } from "./status-helpers.js"; + +export interface PromptCardIdentity { + readonly runId: string; + readonly name: string; + readonly meta?: string; +} export interface PromptCardRenderOpts { readonly state: PromptCardState; readonly theme: GraphTheme; readonly width: number; readonly cursorOn: boolean; + /** Optional run attribution; absent preserves the legacy prompt card. */ + readonly identity?: PromptCardIdentity; + /** Maximum rows to emit. Small budgets use a reduced, closed prompt surface. */ + readonly maxRows?: number; + /** Wrapped question-row offset used by scrollable attached prompt surfaces. */ + readonly messageOffset?: number; +} + +export interface PromptCardLayout { + readonly lines: string[]; + readonly totalQuestionRows: number; + readonly visibleQuestionRows: number; +} + +export function renderPromptIdentityBanner(identity: PromptCardIdentity, theme: GraphTheme, width: number): string[] { + const innerWidth = Math.max(20, width - 2); + const borderColor = theme.border; + const bg = ""; + const identityRows = renderRunIdentityRows({ + runId: identity.runId, + name: identity.name, + meta: identity.meta, + glyph: statusIcon("awaiting_input"), + glyphColor: statusColor("awaiting_input", theme), + theme, + width: innerWidth, + idIndent: 1, + idGap: 1, + nameIndent: 4, + }); + return [ + makeBorderTop(borderColor, " AWAITING INPUT ", theme, innerWidth, bg), + ...identityRows.map((row) => makePaddedRow(bg, borderColor, innerWidth, row)), + makeBorderBottom(borderColor, innerWidth, bg), + ]; +} + +export function renderPromptRunIdBanner(identity: PromptCardIdentity, theme: GraphTheme, width: number): string[] { + const banner = renderPromptIdentityBanner(identity, theme, width); + // The canonical identity renderer appends the workflow-name row last, after + // every wrapped run-id row. Remove only that row for the middle ladder rung. + return [...banner.slice(0, -2), banner.at(-1)!]; } +const STANDARD_PROMPT_FIXED_ROWS = 5; +const NORMAL_SPACING_ROWS = 3; + /** - * Render the prompt card as a list of width-safe ANSI lines, suitable to - * paint over the graph body inside the overlay. + * Render the prompt surface as an optional attribution banner followed by + * the existing prompt UI. The banner contains identity only; the prompt + * question, response field, and hints remain in their existing box. */ export function renderPromptCard(opts: PromptCardRenderOpts): string[] { + return renderPromptCardLayout(opts).lines; +} + +/** Render a card and report its scrollable question window in question-row units. */ +export function renderPromptCardLayout(opts: PromptCardRenderOpts): PromptCardLayout { const { state, theme, width } = opts; const innerWidth = Math.max(20, width - 2); const borderColor = theme.border; const bg = ""; + const maxRows = opts.maxRows === undefined ? undefined : Math.max(0, Math.floor(opts.maxRows)); + const messageOffset = Math.max(0, Math.floor(opts.messageOffset ?? 0)); + const unattributed = renderPromptBodyBlock( + state, + theme, + innerWidth, + opts.cursorOn, + borderColor, + bg, + maxRows, + messageOffset, + ); + if (opts.identity === undefined) return unattributed; + + const banners = [ + renderPromptIdentityBanner(opts.identity, theme, width), + renderPromptRunIdBanner(opts.identity, theme, width), + ]; + for (const banner of banners) { + const promptBudget = maxRows === undefined ? undefined : Math.max(0, maxRows - banner.length); + const attributedPrompt = renderPromptBodyBlock( + state, + theme, + innerWidth, + opts.cursorOn, + borderColor, + bg, + promptBudget, + messageOffset, + ); + const minimumQuestionRows = attributedPrompt.totalQuestionRows > 0 ? 1 : 0; + const bannerFits = + maxRows === undefined || + (banner.length + minimumCompletePromptRows(state) <= maxRows && + attributedPrompt.visibleQuestionRows >= minimumQuestionRows && + attributedPrompt.visibleQuestionRows >= unattributed.visibleQuestionRows); + if (!bannerFits) continue; + return { + lines: [ + ...banner, + ...attributedPrompt.lines.map((line, index) => + index === 0 ? makeBorderTop(borderColor, "", theme, innerWidth, bg) : line, + ), + ], + totalQuestionRows: attributedPrompt.totalQuestionRows, + visibleQuestionRows: attributedPrompt.visibleQuestionRows, + }; + } + return unattributed; +} + +function renderPromptBodyBlock( + state: PromptCardState, + theme: GraphTheme, + innerWidth: number, + cursorOn: boolean, + borderColor: string, + bg: string, + maxRows: number | undefined, + messageOffset: number, +): PromptCardLayout { + const messageRows = wrapText(state.prompt.message, innerWidth - 4); + const totalQuestionRows = messageRows.length; + if (maxRows !== undefined && maxRows === 0) { + return { lines: [], totalQuestionRows, visibleQuestionRows: 0 }; + } + if (maxRows !== undefined && maxRows < minimumCompletePromptRows(state)) { + return renderReducedPromptBlock(state, theme, innerWidth, borderColor, bg, maxRows, messageRows, messageOffset); + } + + const desiredResponseRows = responseRowCount(state); + const minimumResponseRows = minimumResponseRowCount(state); + const normallySpaced = + maxRows === undefined || + maxRows >= STANDARD_PROMPT_FIXED_ROWS + NORMAL_SPACING_ROWS + desiredResponseRows + totalQuestionRows; + let messageCount = totalQuestionRows; + let responseCount = desiredResponseRows; + if (maxRows !== undefined) { + const fixedRows = STANDARD_PROMPT_FIXED_ROWS + (normallySpaced ? NORMAL_SPACING_ROWS : 0); + const variableBudget = maxRows - fixedRows; + messageCount = totalQuestionRows > 0 ? 1 : 0; + responseCount = minimumResponseRows; + let remaining = variableBudget - messageCount - responseCount; + const responseExtra = Math.min(remaining, Math.max(0, desiredResponseRows - responseCount)); + responseCount += responseExtra; + remaining -= responseExtra; + messageCount += Math.min(remaining, Math.max(0, totalQuestionRows - messageCount)); + } + + const safeMessageOffset = Math.min(messageOffset, Math.max(0, totalQuestionRows - messageCount)); const lines: string[] = []; lines.push(makeBorderTop(borderColor, " AWAITING INPUT ", theme, innerWidth, bg)); - lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); - for (const messageLine of wrapText(state.prompt.message, innerWidth - 4)) { + if (normallySpaced) lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); + for (const messageLine of messageRows.slice(safeMessageOffset, safeMessageOffset + messageCount)) { lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${paint(messageLine, theme.text)}`)); } - lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); + if (normallySpaced) lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); - const fieldLines = renderResponseFieldBox(state, theme, innerWidth - 4, opts.cursorOn); - for (const fl of fieldLines) { - lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${fl}`)); + const fieldLines = renderResponseFieldBox(state, theme, innerWidth - 4, cursorOn, responseCount); + for (const fieldLine of fieldLines) { + lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${fieldLine}`)); } - lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); + if (normallySpaced) lines.push(makePaddedRow(bg, borderColor, innerWidth, "")); lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${renderHints(state.prompt.kind, theme)}`)); lines.push(makeBorderBottom(borderColor, innerWidth, bg)); - return lines; + return { lines, totalQuestionRows, visibleQuestionRows: messageCount }; +} + +function minimumCompletePromptRows(state: PromptCardState): number { + return STANDARD_PROMPT_FIXED_ROWS + minimumResponseRowCount(state) + 1; +} + +function renderReducedPromptBlock( + state: PromptCardState, + theme: GraphTheme, + innerWidth: number, + borderColor: string, + bg: string, + maxRows: number, + messageRows: readonly string[], + messageOffset: number, +): PromptCardLayout { + const totalQuestionRows = messageRows.length; + const compactHint = compactSurfaceHint(state.prompt.kind); + if (maxRows === 1) { + return { + lines: [paint(`AWAITING INPUT · ${compactHint}`, theme.textMuted, { bold: true })], + totalQuestionRows, + visibleQuestionRows: 0, + }; + } + if (maxRows === 2) { + return { + lines: [ + makeBorderTop(borderColor, ` AWAITING INPUT · ${compactHint} `, theme, innerWidth, bg), + makeBorderBottom(borderColor, innerWidth, bg), + ], + totalQuestionRows, + visibleQuestionRows: 0, + }; + } + + const availableBodyRows = maxRows - 2; + const supportingRows = availableBodyRows >= 3 ? 2 : Math.max(0, availableBodyRows - 1); + const messageCount = Math.min(totalQuestionRows, Math.max(1, availableBodyRows - supportingRows)); + const safeMessageOffset = Math.min(messageOffset, Math.max(0, totalQuestionRows - messageCount)); + const title = maxRows < 5 ? ` AWAITING INPUT · ${compactHint} ` : " AWAITING INPUT "; + const lines = [makeBorderTop(borderColor, title, theme, innerWidth, bg)]; + for (const messageLine of messageRows.slice(safeMessageOffset, safeMessageOffset + messageCount)) { + lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${paint(messageLine, theme.text)}`)); + } + if (availableBodyRows >= 2) { + lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${renderReducedResponse(state, theme)}`)); + } + if (availableBodyRows >= 3) { + lines.push(makePaddedRow(bg, borderColor, innerWidth, ` ${renderHints(state.prompt.kind, theme)}`)); + } + lines.push(makeBorderBottom(borderColor, innerWidth, bg)); + return { lines, totalQuestionRows, visibleQuestionRows: messageCount }; +} + +function compactSurfaceHint(kind: PendingPrompt["kind"]): string { + switch (kind) { + case "confirm": + return "y Yes"; + case "select": + return "↑↓ Choose"; + case "custom": + return "enter"; + default: + return "enter Submit"; + } +} + +function renderReducedResponse(state: PromptCardState, theme: GraphTheme): string { + switch (state.prompt.kind) { + case "confirm": + return paint("response yes / no", theme.textMuted, { bold: true }); + case "select": + return paint(`response ${state.prompt.choices?.[state.selectedIndex] ?? "(no choices)"}`, theme.textMuted, { + bold: true, + }); + case "input": + return paint(`response ❯ ${state.rawText}`, theme.textMuted, { bold: true }); + case "editor": + return paint("response editor", theme.textMuted, { bold: true }); + case "custom": + return paint("response", theme.textMuted, { bold: true }); + } } function makeBorderTop(color: string, label: string, theme: GraphTheme, innerWidth: number, bg: string): string { @@ -67,11 +299,30 @@ function wrapText(text: string, width: number): string[] { return wrapTextWithAnsi(text, width); } +function responseRowCount(state: PromptCardState): number { + switch (state.prompt.kind) { + case "select": { + const choiceCount = state.prompt.choices?.length ?? 0; + const visibleChoices = Math.max(1, Math.min(5, choiceCount)); + return visibleChoices + (choiceCount > visibleChoices ? 1 : 0); + } + case "editor": + return 6; + default: + return 1; + } +} + +function minimumResponseRowCount(state: PromptCardState): number { + return state.prompt.kind === "select" && (state.prompt.choices?.length ?? 0) > 1 ? 2 : 1; +} + function renderResponseFieldBox( state: PromptCardState, theme: GraphTheme, usable: number, cursorOn: boolean, + maxContentRows: number, ): string[] { const boxWidth = Math.max(4, usable); const contentWidth = Math.max(1, boxWidth - 2); @@ -80,7 +331,7 @@ function renderResponseFieldBox( const labelText = paint(label, theme.textMuted, { bold: true }); const labelW = visibleWidth(labelText); const topFill = Math.max(0, boxWidth - labelW - 2); - const rows = renderResponseField(state, theme, contentWidth, cursorOn); + const rows = renderResponseField(state, theme, contentWidth, cursorOn, maxContentRows); return [ paint("╭", borderColor) + labelText + paint(`${"─".repeat(topFill)}╮`, borderColor), ...rows.map((row) => makeFieldRow(row, contentWidth, borderColor)), @@ -94,16 +345,22 @@ function makeFieldRow(content: string, width: number, borderColor: string): stri return paint("│", borderColor) + padded + paint("│", borderColor); } -function renderResponseField(state: PromptCardState, theme: GraphTheme, usable: number, cursorOn: boolean): string[] { +function renderResponseField( + state: PromptCardState, + theme: GraphTheme, + usable: number, + cursorOn: boolean, + maxRows: number, +): string[] { switch (state.prompt.kind) { case "confirm": return [renderConfirmRow(state, theme, usable)]; case "select": - return renderSelectRows(state, theme, usable); + return renderSelectRows(state, theme, usable, maxRows); case "input": return [renderInputRow(state, theme, usable, cursorOn)]; case "editor": - return renderEditorRows(state, theme, usable, cursorOn); + return renderEditorRows(state, theme, usable, cursorOn, maxRows); case "custom": return [padToUsable("", usable)]; } @@ -123,12 +380,14 @@ function renderConfirmRow(state: PromptCardState, theme: GraphTheme, usable: num return padToUsable(row, usable); } -function renderSelectRows(state: PromptCardState, theme: GraphTheme, usable: number): string[] { +function renderSelectRows(state: PromptCardState, theme: GraphTheme, usable: number, maxRows: number): string[] { const choices = state.prompt.choices ?? []; if (choices.length === 0) { return [padToUsable(paint("(no choices)", theme.dim), usable)]; } - const maxVisible = Math.min(5, choices.length); + const rowBudget = Math.max(1, Math.floor(maxRows)); + const scrolls = choices.length > Math.min(5, rowBudget); + const maxVisible = Math.max(1, Math.min(5, choices.length, rowBudget - (scrolls ? 1 : 0))); const list = createPromptSelectList(state, theme, maxVisible); return list.render(usable).map((line) => padToUsable(line, usable)); } @@ -141,8 +400,19 @@ function renderInputRow(state: PromptCardState, theme: GraphTheme, usable: numbe return padToUsable(paint("❯ ", theme.accent) + withCursor, usable); } -function renderEditorRows(state: PromptCardState, theme: GraphTheme, usable: number, cursorOn: boolean): string[] { - const ROWS = 5; +function renderEditorRows( + state: PromptCardState, + theme: GraphTheme, + usable: number, + cursorOn: boolean, + maxRows: number, +): string[] { + const rowBudget = Math.max(1, Math.floor(maxRows)); + if (rowBudget === 1 && state.editorSubmitFocused) { + return [padToUsable(renderEditorSubmitAction(true, theme), usable)]; + } + const editorRows = rowBudget === 1 ? 1 : rowBudget - 1; + const includeSubmit = rowBudget > 1; const allLines = state.rawText.split("\n"); // Find the line + column the caret currently sits on. let acc = 0; @@ -159,10 +429,10 @@ function renderEditorRows(state: PromptCardState, theme: GraphTheme, usable: num caretLine = i + 1; caretCol = 0; } - const start = Math.max(0, Math.min(caretLine - Math.floor(ROWS / 2), allLines.length - ROWS)); + const start = Math.max(0, Math.min(caretLine - Math.floor(editorRows / 2), allLines.length - editorRows)); const safeStart = Math.max(0, start); const rows: string[] = []; - for (let i = 0; i < ROWS; i++) { + for (let i = 0; i < editorRows; i++) { const lineIdx = safeStart + i; const lineText = allLines[lineIdx] ?? ""; const isCaretLine = !state.editorSubmitFocused && lineIdx === caretLine; @@ -174,7 +444,7 @@ function renderEditorRows(state: PromptCardState, theme: GraphTheme, usable: num const prefix = paint(isCaretLine ? "❯ " : " ", isCaretLine ? theme.accent : theme.dim); rows.push(padToUsable(prefix + withCursor, usable)); } - rows.push(padToUsable(renderEditorSubmitAction(state.editorSubmitFocused, theme), usable)); + if (includeSubmit) rows.push(padToUsable(renderEditorSubmitAction(state.editorSubmitFocused, theme), usable)); return rows; } diff --git a/packages/workflows/src/tui/prompt-card.ts b/packages/workflows/src/tui/prompt-card.ts index 3965d9725..affaa552b 100644 --- a/packages/workflows/src/tui/prompt-card.ts +++ b/packages/workflows/src/tui/prompt-card.ts @@ -6,7 +6,7 @@ */ export { handlePromptCardInput, isPromptEscapeInput } from "./prompt-card-input.js"; -export type { PromptCardRenderOpts } from "./prompt-card-render.js"; +export type { PromptCardIdentity, PromptCardRenderOpts } from "./prompt-card-render.js"; export { renderPromptCard } from "./prompt-card-render.js"; export type { PromptCardAction, PromptCardState } from "./prompt-card-state.js"; export { createPromptCardState, defaultResponseFor } from "./prompt-card-state.js"; diff --git a/packages/workflows/src/tui/run-detail.ts b/packages/workflows/src/tui/run-detail.ts index d95dc3617..b9bc8648a 100644 --- a/packages/workflows/src/tui/run-detail.ts +++ b/packages/workflows/src/tui/run-detail.ts @@ -24,10 +24,10 @@ import type { FlatBandBadge } from "./chat-surface.js"; import { renderRoundedBox } from "./chat-surface.js"; import { BOLD, hexToAnsi, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; +import { wrapIdentifierLines } from "./run-identity-rows.js"; import { fmtDuration, statusColor, statusIcon } from "./status-helpers.js"; import { truncateToWidth, visibleWidth } from "./text-helpers.js"; -const SHORT_ID_LEN = 6; const STAGE_NAME_COL = 14; const KEY_COL = 14; @@ -56,10 +56,11 @@ export function renderRunDetail(detail: RunDetail, opts: RenderRunDetailOpts = { function renderPlain(detail: RunDetail, now: number, width: number): string { const out: string[] = []; - - const sid = shortId(detail.runId); const stateBadge = stateLabel(detail); + out.push(...renderIdentifierRows(detail.runId, width - 2)); + out.push(""); + for (const [k, v] of summaryRows(detail, now)) { if (v === undefined) continue; const value = truncateToWidth(v, Math.max(1, width - 4 - KEY_COL), "…"); @@ -89,18 +90,10 @@ function renderPlain(detail: RunDetail, now: number, width: number): string { out.push(""); } - if (detail.endedAt === undefined) { - const hint = - detail.status === "paused" - ? ` ▸ workflow resume id=${sid} continue workflow ` - : ` ▸ workflow interrupt id=${sid} cancel `; - out.push(truncateToWidth(hint, width - 2, "…")); - } else { - out.push(truncateToWidth(` ▸ workflow resume id=${sid} reopen graph `, width - 2, "…")); - } + out.push(...renderDetailHintRows(detail, width - 2)); return renderRoundedBox({ - title: `RUN ${sid} ${detail.name} ${stateBadge}`, + title: `RUN ${detail.name} ${stateBadge}`, bodyLines: out, width, }); @@ -115,9 +108,10 @@ function renderThemed(detail: RunDetail, now: number, theme: GraphTheme, width: const muted = hexToAnsi(theme.textMuted); const dim = hexToAnsi(theme.dim); const text = hexToAnsi(theme.text); - const accent = hexToAnsi(theme.accent); - const sid = shortId(detail.runId); + out.push(...renderIdentifierRows(detail.runId, width - 2, theme)); + out.push(""); + const badges = stateBadges(detail, theme); for (const [k, v] of summaryRows(detail, now)) { @@ -151,25 +145,11 @@ function renderThemed(detail: RunDetail, now: number, theme: GraphTheme, width: out.push(""); } - if (detail.endedAt === undefined) { - const hint = - detail.status === "paused" - ? ` ${dim}▸${RESET} ${accent}workflow resume id=${sid}${RESET}${dim} continue workflow${RESET} ` - : ` ${dim}▸${RESET} ${accent}workflow interrupt id=${sid}${RESET}${dim} cancel${RESET} `; - out.push(truncateToWidth(hint, width - 2, "…")); - } else { - out.push( - truncateToWidth( - ` ${dim}▸${RESET} ${accent}workflow resume id=${sid}${RESET}${dim} reopen graph${RESET} `, - width - 2, - "…", - ), - ); - } + out.push(...renderDetailHintRows(detail, width - 2, theme)); const badgeText = badges.length > 0 ? ` ${badges.map((b) => b.text).join(" ")}` : ""; return renderRoundedBox({ - title: `RUN ${sid} ${detail.name}${badgeText}`, + title: `RUN ${detail.name}${badgeText}`, bodyLines: out, accent: theme.accent, theme, @@ -389,16 +369,53 @@ function statePlain(detail: RunDetail): string { // Tiny formatters // --------------------------------------------------------------------------- -function shortId(id: string): string { - return id.length > SHORT_ID_LEN ? id.slice(0, SHORT_ID_LEN) : id; -} - function pad(s: string, n: number): string { const width = visibleWidth(s); if (width >= n) return s; return s + " ".repeat(n - width); } +function renderIdentifierRows(id: string, width: number, theme?: GraphTheme): string[] { + const rows = wrapIdentifierLines(id, width, " run id ", " "); + if (!theme) return rows.map((row) => `${row.prefix}${row.chunk}`); + const muted = hexToAnsi(theme.textMuted); + const accent = hexToAnsi(theme.accent); + return rows.map((row, index) => { + const prefix = index === 0 ? `${muted}${row.prefix}${RESET}` : row.prefix; + return `${prefix}${accent}${row.chunk}${RESET}`; + }); +} + +function renderDetailHintRows(detail: RunDetail, width: number, theme?: GraphTheme): string[] { + const prefix = + detail.endedAt === undefined + ? detail.status === "paused" + ? " ▸ workflow resume id=" + : " ▸ workflow interrupt id=" + : " ▸ workflow resume id="; + const suffix = + detail.endedAt === undefined + ? detail.status === "paused" + ? " continue workflow " + : " cancel " + : " reopen graph "; + const continuation = " "; + const rows = wrapIdentifierLines(detail.runId, width, prefix, continuation); + const last = rows[rows.length - 1]!; + if (visibleWidth(`${last.prefix}${last.chunk}${suffix}`) <= width) { + last.chunk += suffix; + } else { + rows.push({ + prefix: continuation, + chunk: truncateToWidth(suffix.trimStart(), Math.max(1, width - visibleWidth(continuation)), "…"), + }); + } + if (!theme) return rows.map((row) => `${row.prefix}${row.chunk}`); + const dim = hexToAnsi(theme.dim); + const accent = hexToAnsi(theme.accent); + return rows.map((row) => `${dim}${row.prefix}${RESET}${accent}${row.chunk}${RESET}`); +} + function formatTime(ms: number): string { const d = new Date(ms); const hh = String(d.getUTCHours()).padStart(2, "0"); diff --git a/packages/workflows/src/tui/run-identity-rows.ts b/packages/workflows/src/tui/run-identity-rows.ts new file mode 100644 index 000000000..c4b4c476e --- /dev/null +++ b/packages/workflows/src/tui/run-identity-rows.ts @@ -0,0 +1,97 @@ +/** Shared two-row run identity rendering for workflow cards and attribution banners. */ + +import { BOLD, hexToAnsi, RESET } from "./color-utils.js"; +import type { GraphTheme } from "./graph-theme.js"; +import { visibleWidth } from "./text-helpers.js"; + +export interface RunIdentityRowsOpts { + /** Full run UUID; this value is never shortened. */ + readonly runId: string; + /** Workflow display name shown on the second row. */ + readonly name: string; + /** Optional metadata appended to the workflow name. */ + readonly meta?: string; + /** Status glyph shown on the first row. */ + readonly glyph: string; + /** Hex colour for the status glyph in themed output. */ + readonly glyphColor?: string; + /** Hex colour for metadata in themed output. */ + readonly metaColor?: string; + /** Omit for plain output. */ + readonly theme?: GraphTheme; + /** Visible width available to the identity rows. Omit to disable wrapping. */ + readonly width?: number; + /** Leading spaces before the glyph. Defaults to the widget's three cells. */ + readonly idIndent?: number; + /** Spaces between the glyph and the full run id. Defaults to two cells. */ + readonly idGap?: number; + /** Leading spaces before the workflow name. Defaults to five cells. */ + readonly nameIndent?: number; +} + +export interface IdentifierLine { + prefix: string; + chunk: string; +} + +/** + * Hard-wrap an identifier without changing any of its characters. Prefixes + * count against each row's visible-cell budget, but are not part of the id. + */ +export function wrapIdentifierLines( + id: string, + width: number, + firstPrefix: string, + continuationPrefix: string, +): IdentifierLine[] { + const rows: IdentifierLine[] = []; + let remaining = id; + let first = true; + while (remaining.length > 0 || rows.length === 0) { + const prefix = first ? firstPrefix : continuationPrefix; + const budget = Math.max(1, width - visibleWidth(prefix)); + let chunk = ""; + for (const character of remaining) { + if (chunk.length > 0 && visibleWidth(`${chunk}${character}`) > budget) break; + chunk += character; + } + if (chunk.length === 0) chunk = remaining[0] ?? ""; + rows.push({ prefix, chunk }); + remaining = remaining.slice(chunk.length); + first = false; + } + return rows; +} + +/** + * Render the canonical identity shape shared by the background widget and + * awaiting-input attribution banner. The caller supplies the status glyph so + * surfaces can preserve their existing run-state semantics. + */ +export function renderRunIdentityRows(opts: RunIdentityRowsOpts): string[] { + const idIndent = Math.max(0, opts.idIndent ?? 3); + const idGap = Math.max(0, opts.idGap ?? 2); + const nameIndent = Math.max(0, opts.nameIndent ?? 5); + const idPrefix = `${" ".repeat(idIndent)}${opts.glyph}${" ".repeat(idGap)}`; + const continuationPrefix = " ".repeat(Math.max(0, idIndent + idGap - 1)); + const availableWidth = opts.width === undefined ? Number.POSITIVE_INFINITY : Math.max(1, opts.width); + const chunks = wrapIdentifierLines(opts.runId, availableWidth, idPrefix, continuationPrefix); + const themed = opts.theme !== undefined; + const glyph = themed ? `${hexToAnsi(opts.glyphColor ?? opts.theme.text)}${opts.glyph}${RESET}` : opts.glyph; + const id = themed ? (text: string) => `${hexToAnsi(opts.theme!.accent)}${text}${RESET}` : (text: string) => text; + const name = themed ? `${hexToAnsi(opts.theme.text)}${BOLD}${opts.name}${RESET}` : opts.name; + const metaColor = opts.metaColor ?? opts.theme?.dim; + const meta = + themed && opts.meta !== undefined && opts.meta.length > 0 + ? ` · ${hexToAnsi(metaColor ?? opts.theme.textMuted)}${opts.meta}${RESET}` + : opts.meta !== undefined && opts.meta.length > 0 + ? ` · ${opts.meta}` + : ""; + + const rows = chunks.map((chunk, index) => { + if (index > 0) return `${chunk.prefix}${id(chunk.chunk)}`; + return `${" ".repeat(idIndent)}${glyph}${" ".repeat(idGap)}${id(chunk.chunk)}`; + }); + rows.push(`${" ".repeat(nameIndent)}${name}${meta}`); + return rows; +} diff --git a/packages/workflows/src/tui/session-picker.ts b/packages/workflows/src/tui/session-picker.ts index ef74e48c1..11e74b25b 100644 --- a/packages/workflows/src/tui/session-picker.ts +++ b/packages/workflows/src/tui/session-picker.ts @@ -26,6 +26,7 @@ import { elapsedRunMs } from "../shared/timing.js"; import { workflowRunResumeCandidate } from "../shared/workflow-artifacts.js"; import { BOLD, hexBg, hexToAnsi, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; +import { type IdentifierLine, wrapIdentifierLines } from "./run-identity-rows.js"; import { fmtDuration, statusColor, statusIcon } from "./status-helpers.js"; import { Key, matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js"; @@ -100,6 +101,9 @@ export function selectRunsForPicker( resumeCandidateLookup?: ResumeCandidateLookup, ): PickerRow[] { const q = query.trim().toLowerCase(); + // Incremental search, not id resolution. The user narrows a list and then + // selects a row, and the selected run's full id is what gets acted on, so + // prefix matching here never targets a run by a truncated id. const matches = (r: RunSnapshot): boolean => { if (!q) return true; return r.name.toLowerCase().includes(q) || r.id.startsWith(q); @@ -155,7 +159,8 @@ function renderHeader(width: number, theme: GraphTheme): string { const inner = Math.max(4, width - 2); const border = hexToAnsi(theme.border); const accent = hexToAnsi(theme.accent); - const padded = ` ${TITLE} `; + const title = truncateToWidth(TITLE, Math.max(1, inner - 2), "…"); + const padded = ` ${title} `; const padLen = Math.max(0, inner - visibleWidth(padded)); const left = Math.min(2, padLen); const right = padLen - left; @@ -250,45 +255,60 @@ function stageProgress(run: RunSnapshot): string { return `${done}/${total} stages`; } -function renderRunRow(row: PickerRow, isSelected: boolean, inner: number, theme: GraphTheme, now: number): string { +function renderRunRow(row: PickerRow, isSelected: boolean, inner: number, theme: GraphTheme, now: number): string[] { const border = hexToAnsi(theme.border); + const panelBg = hexBg(theme.bg); const run = row.run; const icon = statusIcon(run.status); - const idShort = run.id.slice(0, 8); + const iconColor = hexToAnsi(statusColor(run.status, theme)); + const dim = hexToAnsi(theme.dim); + const text = hexToAnsi(theme.text); + const muted = hexToAnsi(theme.textMuted); + + // The full identifier owns its own row. It is never ellipsized; narrow + // overlays wrap it into continuation rows so the box remains intact. + const idRows = wrapIdentifierLines(run.id, inner, ` ${icon} `, " "); + const renderIdRow = ({ prefix, chunk }: IdentifierLine, index: number): string => { + if (isSelected) { + return `${border}│${RESET}${hexBg(theme.accent)}${hexToAnsi(theme.backgroundElement)}${BOLD}${padTo( + `${prefix}${chunk}`, + inner, + )}${RESET}${border}│${RESET}`; + } + const content = + index === 0 + ? ` ${iconColor}${icon}${RESET}${panelBg} ${dim}${chunk}${RESET}${panelBg}` + : ` ${dim}${chunk}${RESET}${panelBg}`; + return `${border}│${RESET}${panelBg}${padTo(content, inner)}${border}│${RESET}`; + }; + const elapsed = fmtElapsed(run, now); const progress = stageProgress(run); - - // Layout columns: glyph(1) idShort(8) name(flex) elapsed(R) progress(R). - // Name budgeting is done by visible cell width so wide workflow names - // cannot push the elapsed/progress columns through the right border. const elapsedCol = elapsed.padStart(8, " "); const progressCol = progress.padStart(10, " "); const rightPlain = `${elapsedCol} ${progressCol} `; - const namePrefixW = visibleWidth(` ${icon} ${idShort} `); - const nameBudget = Math.max(1, inner - namePrefixW - visibleWidth(rightPlain) - 1); + const rightBudget = Math.max(1, inner - 5); + const rightVisible = truncateToWidth(rightPlain, rightBudget, "…"); + const right = + visibleWidth(rightVisible) < visibleWidth(rightPlain) + ? `${dim}${rightVisible}${RESET}${panelBg}` + : `${muted}${elapsedCol}${RESET}${panelBg} ${dim}${progressCol}${RESET}${panelBg} `; + const nameBudget = Math.max(1, inner - 3 - visibleWidth(rightVisible) - 1); const name = truncateToWidth(run.name, nameBudget, "…"); - if (isSelected) { - const pillBg = hexBg(theme.accent); - const pillFg = hexToAnsi(theme.backgroundElement); - const left = ` ${icon} ${idShort} ${name}`; - const right = rightPlain; - const gap = Math.max(1, inner - visibleWidth(left) - visibleWidth(right)); - const content = `${left}${" ".repeat(gap)}${right}`; - return `${border}│${RESET}${pillBg}${pillFg}${BOLD}${padTo(content, inner)}${RESET}${border}│${RESET}`; - } - - const panelBg = hexBg(theme.bg); - const iconColor = hexToAnsi(statusColor(run.status, theme)); - const dim = hexToAnsi(theme.dim); - const text = hexToAnsi(theme.text); - const muted = hexToAnsi(theme.textMuted); - - const left = ` ${iconColor}${icon}${RESET}${panelBg} ${dim}${idShort}${RESET}${panelBg} ${text}${name}${RESET}${panelBg}`; - const right = `${muted}${elapsedCol}${RESET}${panelBg} ${dim}${progressCol}${RESET}${panelBg} `; - const gap = Math.max(1, inner - visibleWidth(left) - visibleWidth(right)); - const content = `${left}${" ".repeat(gap)}${right}`; - return `${border}│${RESET}${panelBg}${padTo(content, inner)}${RESET}${border}│${RESET}`; + const nameRow = isSelected + ? `${border}│${RESET}${hexBg(theme.accent)}${hexToAnsi(theme.backgroundElement)}${BOLD}${padTo( + ` ${name}${" ".repeat(Math.max(1, inner - 3 - visibleWidth(name) - visibleWidth(rightVisible)))}${rightVisible}`, + inner, + )}${RESET}${border}│${RESET}` + : `${border}│${RESET}${panelBg}${padTo( + ` ${text}${name}${RESET}${panelBg}${" ".repeat( + Math.max(1, inner - 3 - visibleWidth(name) - visibleWidth(right)), + )}${right}`, + inner, + )}${border}│${RESET}`; + + return [...idRows.map(renderIdRow), nameRow]; } function renderEmptyState(inner: number, theme: GraphTheme): string { @@ -300,12 +320,12 @@ function renderEmptyState(inner: number, theme: GraphTheme): string { return `${border}│${RESET}${panelBg}${padTo(content, inner)}${RESET}${border}│${RESET}`; } -const VIEWPORT = 10; +const VIEWPORT = 5; export function renderSessionPicker(opts: SessionPickerRenderOpts): string[] { const { width, theme, rows, state } = opts; const now = opts.now ?? Date.now(); - const inner = Math.max(40, width - 2); + const inner = Math.max(4, width - 2); const lines: string[] = []; lines.push(renderHeader(width, theme)); @@ -334,7 +354,7 @@ export function renderSessionPicker(opts: SessionPickerRenderOpts): string[] { prevBucket = row.bucket; } const absIndex = Math.max(0, start) + i; - lines.push(renderRunRow(row, absIndex === sel, inner, theme, now)); + lines.push(...renderRunRow(row, absIndex === sel, inner, theme, now)); } lines.push(renderBlankRow(inner, theme)); lines.push(renderBottomBorder(width, theme)); diff --git a/packages/workflows/src/tui/stage-chat-view-archive-history.ts b/packages/workflows/src/tui/stage-chat-view-archive-history.ts index 823a46a15..9cb618adb 100644 --- a/packages/workflows/src/tui/stage-chat-view-archive-history.ts +++ b/packages/workflows/src/tui/stage-chat-view-archive-history.ts @@ -2,7 +2,12 @@ import { Box, Text } from "@earendil-works/pi-tui"; import type { PendingPrompt, StageSnapshot } from "../shared/store-types.js"; import { renderRoundedBoxLines } from "./chat-surface.js"; import { hexToAnsi, RESET } from "./color-utils.js"; -import { renderPromptCard } from "./prompt-card.js"; +import { + type PromptCardLayout, + renderPromptCardLayout, + renderPromptIdentityBanner, + renderPromptRunIdBanner, +} from "./prompt-card-render.js"; import { bannerLines, embedOrchestratorReturnHintInWidget } from "./stage-chat-view-footer-status.js"; import { blankLine, @@ -205,24 +210,46 @@ export function renderBlockedBody( } export function renderPromptBody(ctx: StageChatViewContext, width: number, budget: number): string[] { - const primitiveLines = renderPrimitivePromptBody(ctx, width); - if (primitiveLines) { - return fitPromptBodyLines(ctx, embedOrchestratorReturnHintInWidget(ctx, primitiveLines, width), width, budget); + const primitiveLayout = renderPrimitivePromptBody(ctx, width, budget); + if (primitiveLayout) { + return fitPromptBodyLines( + ctx, + embedPromptReturnHint(ctx, primitiveLayout.lines, width), + width, + budget, + Math.max(0, primitiveLayout.totalQuestionRows - primitiveLayout.visibleQuestionRows), + true, + primitiveLayout.visibleQuestionRows, + ); } const state = ctx.promptState; - const lines = state - ? renderPromptCard({ - state, - theme: ctx.theme, - width, - cursorOn: ctx.focused, - }) - : []; - return fitPromptBodyLines(ctx, embedOrchestratorReturnHintInWidget(ctx, lines, width), width, budget); + if (!state) return fitPromptBodyLines(ctx, [], width, budget); + const layout = renderPromptCardLayout({ + state, + theme: ctx.theme, + width, + cursorOn: ctx.focused, + identity: { runId: ctx.runId, name: ctx.workflowName }, + maxRows: budget, + messageOffset: ctx.promptScrollOffset, + }); + return fitPromptBodyLines( + ctx, + embedPromptReturnHint(ctx, layout.lines, width), + width, + budget, + Math.max(0, layout.totalQuestionRows - layout.visibleQuestionRows), + true, + layout.visibleQuestionRows, + ); +} + +function embedPromptReturnHint(ctx: StageChatViewContext, lines: readonly string[], width: number): string[] { + return lines.length < 3 ? [...lines] : embedOrchestratorReturnHintInWidget(ctx, lines, width); } -function renderPrimitivePromptBody(ctx: StageChatViewContext, width: number): string[] | null { +function renderPrimitivePromptBody(ctx: StageChatViewContext, width: number, budget: number): PromptCardLayout | null { const state = ctx.promptState; const editor = ctx.promptEditor; if (!state || !editor) return null; @@ -230,19 +257,151 @@ function renderPrimitivePromptBody(ctx: StageChatViewContext, width: number): st setEditorBorderColor(editor, (text) => hexToAnsi(ctx.theme.accent) + text + RESET); const innerWidth = Math.max(2, width - 2); - const bodyLines: string[] = []; - const messageBox = new Box(2, 1); - messageBox.addChild(new Text(paint(state.prompt.message, ctx.theme.text), 0, 0)); - bodyLines.push(...messageBox.render(innerWidth)); - bodyLines.push(...new Text(paint("response", ctx.theme.textMuted, { bold: true }), 2, 0).render(innerWidth)); - for (const line of editor.render(Math.max(20, innerWidth - 4))) { - bodyLines.push(` ${line}`); + const messageLines = new Text(paint(state.prompt.message, ctx.theme.text), 2, 0).render(innerWidth); + const responseLines = new Text(paint("response", ctx.theme.textMuted, { bold: true }), 2, 0).render(innerWidth); + const editorLines = editor.render(Math.max(20, innerWidth - 4)).map((line) => ` ${line}`); + const hintLines = new Text(renderHintsForPrompt(state.prompt.kind, ctx.theme), 2, 0).render(innerWidth); + const identity = { runId: ctx.runId, name: ctx.workflowName }; + const unattributed = renderPrimitivePromptBlockLayout( + ctx, + width, + budget, + "AWAITING INPUT", + messageLines, + responseLines, + editorLines, + hintLines, + ); + const minimumPromptRows = 2 + editorLines.length + hintLines.length + (messageLines.length > 0 ? 1 : 0); + // Same degradation ladder as the standard prompt surface: two identity rows, + // then the run id alone, then no banner. The middle rung matters most here, + // because the editor and hint rows leave the least room for attribution. + const banners = [ + renderPromptIdentityBanner(identity, ctx.theme, width), + renderPromptRunIdBanner(identity, ctx.theme, width), + ]; + for (const banner of banners) { + const promptBudget = Math.max(0, budget - banner.length); + const attributed = renderPrimitivePromptBlockLayout( + ctx, + width, + promptBudget, + "", + messageLines, + responseLines, + editorLines, + hintLines, + ); + if ( + banner.length + minimumPromptRows > budget || + attributed.visibleQuestionRows === 0 || + attributed.visibleQuestionRows < unattributed.visibleQuestionRows + ) { + continue; + } + return { + lines: [...banner, ...attributed.lines], + totalQuestionRows: attributed.totalQuestionRows, + visibleQuestionRows: attributed.visibleQuestionRows, + }; } - bodyLines.push(""); - bodyLines.push(...new Text(renderHintsForPrompt(state.prompt.kind, ctx.theme), 2, 0).render(innerWidth)); + return unattributed; +} + +function renderPrimitivePromptBlockLayout( + ctx: StageChatViewContext, + width: number, + maxRows: number, + title: string, + messageLines: readonly string[], + responseLines: readonly string[], + editorLines: readonly string[], + hintLines: readonly string[], +): PromptCardLayout { + const totalQuestionRows = messageLines.length; + if (maxRows === 0) return { lines: [], totalQuestionRows, visibleQuestionRows: 0 }; + const fixedRows = 2 + editorLines.length + hintLines.length; + if (maxRows < fixedRows + (totalQuestionRows > 0 ? 1 : 0)) { + return renderReducedPrimitivePrompt(ctx, width, maxRows, messageLines, title); + } + + let remaining = maxRows - fixedRows; + let messageCount = totalQuestionRows > 0 ? 1 : 0; + remaining -= messageCount; + const includeResponse = remaining >= responseLines.length; + if (includeResponse) remaining -= responseLines.length; + const extraMessageRows = Math.min(remaining, Math.max(0, totalQuestionRows - messageCount)); + messageCount += extraMessageRows; + remaining -= extraMessageRows; + const includeBlank = remaining > 0; + const safeOffset = Math.min(ctx.promptScrollOffset, Math.max(0, totalQuestionRows - messageCount)); + const bodyLines = [ + ...messageLines.slice(safeOffset, safeOffset + messageCount), + ...(includeResponse ? responseLines : []), + ...editorLines, + ...(includeBlank ? [""] : []), + ...hintLines, + ]; + return { + lines: renderPrimitivePromptBlock(ctx, width, title, bodyLines), + totalQuestionRows, + visibleQuestionRows: messageCount, + }; +} +function renderReducedPrimitivePrompt( + ctx: StageChatViewContext, + width: number, + maxRows: number, + messageLines: readonly string[], + title: string, +): PromptCardLayout { + const totalQuestionRows = messageLines.length; + const compactTitle = "AWAITING INPUT"; + if (maxRows === 1) { + return { + lines: [paint(`${compactTitle} · enter Submit`, ctx.theme.textMuted, { bold: true })], + totalQuestionRows, + visibleQuestionRows: 0, + }; + } + if (maxRows === 2) { + const closed = renderPrimitivePromptBlock(ctx, width, compactTitle, []); + return { + lines: [closed[0]!, closed.at(-1)!], + totalQuestionRows, + visibleQuestionRows: 0, + }; + } + + const availableBodyRows = maxRows - 2; + const supportingRows = availableBodyRows >= 3 ? 2 : Math.max(0, availableBodyRows - 1); + const messageCount = Math.min(totalQuestionRows, Math.max(1, availableBodyRows - supportingRows)); + const safeOffset = Math.min(ctx.promptScrollOffset, Math.max(0, totalQuestionRows - messageCount)); + const responseLine = paint( + availableBodyRows >= 3 ? " response" : " response · enter Submit", + ctx.theme.textMuted, + { bold: true }, + ); + const hintLine = renderHintsForPrompt(ctx.promptState?.prompt.kind ?? "input", ctx.theme); + return { + lines: renderPrimitivePromptBlock(ctx, width, maxRows < 5 ? compactTitle : title || "AWAITING INPUT", [ + ...messageLines.slice(safeOffset, safeOffset + messageCount), + ...(availableBodyRows >= 2 ? [responseLine] : []), + ...(availableBodyRows >= 3 ? [hintLine] : []), + ]), + totalQuestionRows, + visibleQuestionRows: messageCount, + }; +} +function renderPrimitivePromptBlock( + ctx: StageChatViewContext, + width: number, + title: string, + bodyLines: readonly string[], +): string[] { return renderRoundedBoxLines({ - title: "AWAITING INPUT", + title, bodyLines, width, theme: ctx.theme, @@ -255,10 +414,15 @@ export function fitPromptBodyLines( lines: readonly string[], width: number, budget: number, + maxScroll = Math.max(0, lines.length - budget), + scrollApplied = false, + visibleRows = Math.min(lines.length, budget), ): string[] { - ctx.promptMaxScroll = Math.max(0, lines.length - budget); + ctx.promptMaxScroll = Math.max(0, maxScroll); + ctx.promptVisibleRows = Math.max(0, visibleRows); ctx.promptScrollOffset = Math.max(0, Math.min(ctx.promptScrollOffset, ctx.promptMaxScroll)); - const framed = lines.slice(ctx.promptScrollOffset, ctx.promptScrollOffset + budget); + const start = scrollApplied ? 0 : ctx.promptScrollOffset; + const framed = lines.slice(start, start + budget); while (framed.length < budget) framed.push(blankLine(width)); return framed; } diff --git a/packages/workflows/src/tui/stage-chat-view-footer-status.ts b/packages/workflows/src/tui/stage-chat-view-footer-status.ts index ccc3b661a..bba4abb37 100644 --- a/packages/workflows/src/tui/stage-chat-view-footer-status.ts +++ b/packages/workflows/src/tui/stage-chat-view-footer-status.ts @@ -2,6 +2,7 @@ import type { AgentSession } from "@bastani/atomic"; import { Box, Text } from "@earendil-works/pi-tui"; import type { StageSnapshot } from "../shared/store-types.js"; import { hexToAnsi, RESET } from "./color-utils.js"; +import { wrapIdentifierLines } from "./run-identity-rows.js"; import { bgFn, blendBg, @@ -17,30 +18,63 @@ import { truncateToWidth, visibleWidth } from "./text-helpers.js"; export function renderHeader(ctx: StageChatViewContext, width: number, stage: StageSnapshot | undefined): string[] { const t = ctx.theme; const stageName = stage?.name ?? "stage"; - const left = + const sid = ctx.handle?.sessionId ?? stage?.sessionId; + const prefixWidth = visibleWidth(" STAGE "); + const separatorWidth = visibleWidth(" / "); + const meta = sid ? `session ${sid}` : ""; + const rightWidth = meta ? visibleWidth(meta) + 1 : 0; + const singleRowNameBudget = width - prefixWidth - separatorWidth - rightWidth - (meta ? 1 : 0); + + const fullNameWidth = visibleWidth(ctx.workflowName) + visibleWidth(stageName); + if (!sid || singleRowNameBudget >= fullNameWidth) { + const names = fitHeaderNames(ctx.workflowName, stageName, Math.max(2, singleRowNameBudget)); + const left = renderHeaderLeft(ctx, names.workflow, names.stage); + const right = meta ? `${paint(meta, t.dim)} ` : ""; + const gap = Math.max(0, width - visibleWidth(left) - visibleWidth(right)); + return [left + " ".repeat(gap) + right]; + } + + const names = fitHeaderNames(ctx.workflowName, stageName, Math.max(2, width - prefixWidth - separatorWidth)); + const left = renderHeaderLeft(ctx, names.workflow, names.stage); + const lines = [left + " ".repeat(Math.max(0, width - visibleWidth(left)))]; + if (visibleWidth(meta) + 1 <= width) { + lines.push(`${" ".repeat(Math.max(0, width - visibleWidth(meta) - 1))}${paint(meta, t.dim)} `); + return lines; + } + for (const row of wrapIdentifierLines(sid, width, " ", " ")) { + const value = `${row.prefix}${paint(row.chunk, t.dim)}`; + lines.push(value + " ".repeat(Math.max(0, width - visibleWidth(value)))); + } + return lines; +} + +function renderHeaderLeft(ctx: StageChatViewContext, workflowName: string, stageName: string): string { + const t = ctx.theme; + return ( paint(" ", t.mauve, { bold: true }) + paint("STAGE", t.textMuted, { bold: true }) + " " + - paint(ctx.workflowName, t.textMuted) + + paint(workflowName, t.textMuted) + paint(" / ", t.dim) + - paint(stageName, t.text, { bold: true }); - const meta = headerMeta(ctx, stage); - const right = meta ? `${paint(meta, t.dim)} ` : ""; - const leftW = visibleWidth(ctx.workflowName) + visibleWidth(stageName) + visibleWidth(" STAGE / ") + 1; - const rightW = visibleWidth(meta) + (meta ? 1 : 0); - const gap = Math.max(1, width - leftW - rightW); - return [left + " ".repeat(gap) + right]; + paint(stageName, t.text, { bold: true }) + ); } -function headerMeta(ctx: StageChatViewContext, stage: StageSnapshot | undefined): string { - const parts: string[] = []; - const sid = ctx.handle?.sessionId ?? stage?.sessionId; - if (sid) parts.push(`session ${shortenId(sid)}`); - return parts.join(" · "); -} - -function shortenId(id: string): string { - return id.length > 10 ? id.slice(0, 8) : id; +function fitHeaderNames(workflowName: string, stageName: string, budget: number): { workflow: string; stage: string } { + const available = Math.max(2, budget); + const workflowWidth = visibleWidth(workflowName); + const stageWidth = visibleWidth(stageName); + let workflowBudget = Math.min(workflowWidth, Math.max(1, Math.ceil(available / 2))); + let stageBudget = Math.min(stageWidth, Math.max(1, available - workflowBudget)); + let remaining = available - workflowBudget - stageBudget; + const workflowExtra = Math.min(remaining, Math.max(0, workflowWidth - workflowBudget)); + workflowBudget += workflowExtra; + remaining -= workflowExtra; + stageBudget += Math.min(remaining, Math.max(0, stageWidth - stageBudget)); + return { + workflow: truncateToWidth(workflowName, workflowBudget, "…"), + stage: truncateToWidth(stageName, stageBudget, "…"), + }; } export function sepRule(ctx: StageChatViewContext, width: number): string { diff --git a/packages/workflows/src/tui/stage-chat-view-input.ts b/packages/workflows/src/tui/stage-chat-view-input.ts index c7ff52d47..d7b2f6404 100644 --- a/packages/workflows/src/tui/stage-chat-view-input.ts +++ b/packages/workflows/src/tui/stage-chat-view-input.ts @@ -160,8 +160,8 @@ function handlePromptInput(ctx: StageChatViewContext, data: string): void { function handlePromptScrollInput(ctx: StageChatViewContext, data: string, includeKeyboard = true): boolean { const mouse = parseTerminalMouseInput(data); const wheelDirection = mouse ? terminalMouseWheelDirection(mouse) : null; - const wheelDeltaRows = - wheelDirection === "up" ? -PROMPT_SCROLL_STEP_ROWS : wheelDirection === "down" ? PROMPT_SCROLL_STEP_ROWS : 0; + const wheelStep = promptScrollStep(ctx, PROMPT_SCROLL_STEP_ROWS); + const wheelDeltaRows = wheelDirection === "up" ? -wheelStep : wheelDirection === "down" ? wheelStep : 0; if (wheelDeltaRows !== 0) { scrollPromptBy(ctx, wheelDeltaRows); return true; @@ -169,11 +169,11 @@ function handlePromptScrollInput(ctx: StageChatViewContext, data: string, includ if (mouse) return true; if (!includeKeyboard) return false; if (matchesKey(data, "pageUp")) { - scrollPromptBy(ctx, -promptPageSize(ctx)); + scrollPromptBy(ctx, -promptScrollStep(ctx, promptPageSize(ctx))); return true; } if (matchesKey(data, "pageDown")) { - scrollPromptBy(ctx, promptPageSize(ctx)); + scrollPromptBy(ctx, promptScrollStep(ctx, promptPageSize(ctx))); return true; } if (!ctx.promptEditor && matchesKey(data, "home")) { @@ -189,6 +189,10 @@ function handlePromptScrollInput(ctx: StageChatViewContext, data: string, includ return false; } +function promptScrollStep(ctx: StageChatViewContext, requestedRows: number): number { + return Math.max(1, Math.min(requestedRows, Math.max(1, ctx.promptVisibleRows))); +} + function scrollPromptBy(ctx: StageChatViewContext, deltaRows: number): void { ctx.promptScrollOffset = Math.max(0, Math.min(ctx.promptMaxScroll, ctx.promptScrollOffset + deltaRows)); ctx.requestRender?.(); diff --git a/packages/workflows/src/tui/stage-chat-view-state.ts b/packages/workflows/src/tui/stage-chat-view-state.ts index 8a5c22ab0..44e7d5d94 100644 --- a/packages/workflows/src/tui/stage-chat-view-state.ts +++ b/packages/workflows/src/tui/stage-chat-view-state.ts @@ -66,6 +66,7 @@ export function initializeStageChatView(ctx: StageChatViewContext, opts: StageCh ctx.promptEditorSubmitFromEnter = false; ctx.promptScrollOffset = 0; ctx.promptMaxScroll = 0; + ctx.promptVisibleRows = 0; ctx.localPaused = false; ctx.mouseScrollCaptureEnabled = true; ctx.lastObservedStageStatus = undefined; @@ -374,6 +375,7 @@ export function syncPromptState(ctx: StageChatViewContext, prompt: PendingPrompt function resetPromptScroll(ctx: StageChatViewContext): void { ctx.promptScrollOffset = 0; ctx.promptMaxScroll = 0; + ctx.promptVisibleRows = 0; } function promptSeedText(ctx: StageChatViewContext, prompt: PendingPrompt): string { diff --git a/packages/workflows/src/tui/stage-chat-view-types.ts b/packages/workflows/src/tui/stage-chat-view-types.ts index ec252927c..f6751e4c1 100644 --- a/packages/workflows/src/tui/stage-chat-view-types.ts +++ b/packages/workflows/src/tui/stage-chat-view-types.ts @@ -15,7 +15,8 @@ import type { PromptCardState } from "./prompt-card.js"; export const VIEW_LINE_COUNT = 32; export const PROMPT_SCROLL_STEP_ROWS = 4; -export const HEADER_ROWS = 1; +/** Header rows reserved by prompt paging for the normal UUID continuation shape. */ +export const HEADER_ROWS = 2; export const SEP_ROWS = 1; export const STAGE_CHAT_MOUSE_SCROLL_TOGGLE_LABEL = "ctrl+t"; @@ -148,6 +149,7 @@ export interface StageChatViewContext { promptEditorSubmitFromEnter: boolean; promptScrollOffset: number; promptMaxScroll: number; + promptVisibleRows: number; localPaused: boolean; mouseScrollCaptureEnabled: boolean; lastObservedStageStatus: StageStatus | undefined; diff --git a/packages/workflows/src/tui/stage-chat-view.ts b/packages/workflows/src/tui/stage-chat-view.ts index 693e17e35..fb9156dc2 100644 --- a/packages/workflows/src/tui/stage-chat-view.ts +++ b/packages/workflows/src/tui/stage-chat-view.ts @@ -53,7 +53,6 @@ import { } from "./stage-chat-view-state.js"; import { transcriptDebugEntries } from "./stage-chat-view-transcript.js"; import { - HEADER_ROWS, SEP_ROWS, type StageChatViewContext, type StageChatViewOpts, @@ -99,6 +98,7 @@ export class StageChatView implements Component, Focusable { private promptEditorSubmitFromEnter!: StageChatViewContext["promptEditorSubmitFromEnter"]; private promptScrollOffset!: StageChatViewContext["promptScrollOffset"]; private promptMaxScroll!: StageChatViewContext["promptMaxScroll"]; + private promptVisibleRows!: StageChatViewContext["promptVisibleRows"]; private localPaused!: StageChatViewContext["localPaused"]; private mouseScrollCaptureEnabled!: StageChatViewContext["mouseScrollCaptureEnabled"]; private seenNoticeIds!: StageChatViewContext["seenNoticeIds"]; @@ -141,7 +141,7 @@ export class StageChatView implements Component, Focusable { const totalRows = viewLineCount(ctx); const plan = planStageChatFrame({ viewportRows: totalRows, - headerRows: HEADER_ROWS, + headerRows: headerLines.length, separatorRows: SEP_ROWS, pendingRows: pendingLines.length, workingRows: workingLines.length, @@ -234,6 +234,7 @@ export class StageChatView implements Component, Focusable { void this.promptEditorSubmitFromEnter; void this.promptScrollOffset; void this.promptMaxScroll; + void this.promptVisibleRows; void this.mouseScrollCaptureEnabled; void this.seenNoticeIds; void this._unsubscribeStore; diff --git a/packages/workflows/src/tui/status-list.ts b/packages/workflows/src/tui/status-list.ts index 0db162013..797e3130b 100644 --- a/packages/workflows/src/tui/status-list.ts +++ b/packages/workflows/src/tui/status-list.ts @@ -4,13 +4,8 @@ * Visual contract (DESIGN.md §5): * - One rounded `BACKGROUND` panel with subtitle and count badges. * - One rounded card per run (replaces the indented per-stage rows): - * title: runId · workflow · state badge - * row 1: mode · progress strip · meta - * - Status colour is carried by the card border and state badge semantics, - * never by decorative body text. - * - One trailing hint row pointing at `/workflow status ` for the - * most-recently-active run; full per-stage detail moves into - * `/workflow status ` ({@link renderRunDetail}). + * title: full runId · workflow · state badge + * rows: status glyph + full runId, then workflow identity and meta * * Plain mode (theme omitted) preserves the rounded panel/card shape without * ANSI escapes, with ASCII bracket cells `[✓][●][○][✗]`. @@ -30,14 +25,13 @@ import { effectiveRunStatus } from "../shared/returned-run-status.js"; import type { RunSnapshot, StageSnapshot, StageStatus } from "../shared/store-types.js"; import { elapsedRunMs, elapsedStageMs } from "../shared/timing.js"; import type { FlatBandBadge } from "./chat-surface.js"; -import { chatWidth, ELLIPSIS, progressStrip, renderHintRows, renderRoundedBox } from "./chat-surface.js"; +import { chatWidth, ELLIPSIS, progressStrip, renderRoundedBox } from "./chat-surface.js"; import { BOLD, hexToAnsi, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; +import { wrapIdentifierLines } from "./run-identity-rows.js"; import { fmtDuration } from "./status-helpers.js"; import { truncateToWidth, visibleWidth } from "./text-helpers.js"; -const SHORT_ID_LEN = 6; -const MIN_TITLE_BUDGET = 12; const STAGE_LABEL_BUDGET = 24; export interface RenderStatusListOpts { @@ -83,17 +77,10 @@ export function renderStatusList(runs: readonly RunSnapshot[], opts: RenderStatu body.push(...renderRunEntry(sorted[i]!, now, cardWidth, opts.theme)); } } - if (opts.showDetailHint !== false && sorted.length > 0) { - const sid = shortId(sorted[0]!.id); body.push(""); - body.push( - ...renderHintRows([{ command: `/workflow status ${sid}`, hint: "drill into a run" }], opts.theme) - .split("\n") - .map((line) => ` ${line} `), - ); + body.push(...renderStatusHintRows(sorted[0]!.id, opts.theme, width).map((line) => ` ${line} `)); } - const badgeText = badges && badges.length > 0 ? ` ${badges.map((b) => b.text).join(" ")}` : ""; return renderRoundedBox({ title: `BACKGROUND ${subtitle}${badgeText}`, @@ -108,43 +95,50 @@ export function renderStatusList(runs: readonly RunSnapshot[], opts: RenderStatu // --------------------------------------------------------------------------- function renderRunEntry(run: RunSnapshot, now: number, width: number, theme?: GraphTheme): string[] { - const sid = shortId(run.id); - const trailing = runTrailing(run, theme); - const mode = run.stages.length > 1 ? "chain " : "single"; const bodyWidth = effectiveWidth(width); const interior = Math.max(8, bodyWidth - 4); + const glyph = statusIconForRun(run); + const glyphFg = theme ? hexToAnsi(runAccent(run, theme)) : ""; + const accent = theme ? hexToAnsi(theme.accent) : ""; + const text = theme ? hexToAnsi(theme.text) : ""; + const muted = theme ? hexToAnsi(theme.textMuted) : ""; + const dim = theme ? hexToAnsi(theme.dim) : ""; + const reset = theme ? RESET : ""; + + // The identifier owns its row and is never sent through truncateToWidth. + // At narrow widths, continuation rows preserve every identifier character + // while keeping each row inside the rounded panel's interior. + const idRows = wrapIdentifierLines(run.id, interior, ` ${glyph} `, " "); + const identityRows = idRows.map(({ prefix, chunk }, index) => { + if (!theme) return `${prefix}${chunk}`; + if (index === 0) return ` ${glyphFg}${glyph}${RESET} ${accent}${chunk}${RESET}`; + return ` ${accent}${chunk}${RESET}`; + }); + + const trailing = runTrailing(run, theme); + const trailingText = truncateToWidth(trailing?.text ?? "", Math.max(0, interior - 1), ELLIPSIS); + const nameBudget = Math.max(1, interior - 3 - visibleWidth(trailingText) - (trailingText ? 2 : 0)); + const name = truncateToWidth(run.name, nameBudget, ELLIPSIS); + const nameSeg = theme ? `${text}${BOLD}${name}${RESET}` : name; + const trailingSeg = + theme && trailingText ? `${hexToAnsi(trailing?.fg ?? theme.dim)}${trailingText}${RESET}` : trailingText; + const identity = ` ${nameSeg}${trailingSeg ? ` ${trailingSeg}` : ""}`; + + const mode = run.stages.length > 1 ? "chain " : "single"; const rawMeta = runCardMeta(run, now); const modeW = mode.length + 4; const maxMetaW = Math.max(0, interior - modeW - 3); const meta = truncateToWidth(rawMeta, maxMetaW, ELLIPSIS); const metaW = visibleWidth(meta); const stripBudget = Math.max(0, interior - modeW - metaW - 2); - const strip = progressStrip(stageCells(run), stripBudget, theme); const usedLeftW = modeW + visibleWidth(strip); const gap = Math.max(metaW > 0 ? 1 : 0, interior - usedLeftW - metaW); - - const glyph = statusIconForRun(run); - const glyphFg = theme ? hexToAnsi(runAccent(run, theme)) : ""; - const accent = theme ? hexToAnsi(theme.accent) : ""; - const text = theme ? hexToAnsi(theme.text) : ""; - const muted = theme ? hexToAnsi(theme.textMuted) : ""; - const dim = theme ? hexToAnsi(theme.dim) : ""; - const reset = theme ? RESET : ""; - - const name = truncateToWidth( - run.name, - Math.max(MIN_TITLE_BUDGET, interior - visibleWidth(sid) - visibleWidth(trailing?.text ?? "") - 8), - ELLIPSIS, - ); - const line1 = theme - ? ` ${glyphFg}${glyph}${RESET} ${accent}${sid}${RESET} ${text}${BOLD}${name}${RESET} ${glyphFg}${trailing?.text ?? ""}${RESET} ` - : ` ${glyph} ${sid} ${name} ${trailing?.text ?? ""} `; const modeSeg = theme ? `${muted}${mode}${reset}` : mode; const metaSeg = theme ? `${dim}${meta}${reset}` : meta; - const line2 = ` ${modeSeg} ${strip}${" ".repeat(gap)}${metaSeg} `; + const metaLine = ` ${modeSeg} ${strip}${" ".repeat(gap)}${metaSeg} `; - return [line1, line2]; + return [...identityRows, identity, metaLine]; } function runAccent(run: RunSnapshot, theme?: GraphTheme): string { @@ -403,8 +397,38 @@ function sortRuns(runs: readonly RunSnapshot[]): RunSnapshot[] { return [...[...active].sort(byStart), ...[...ended].sort(byStart)]; } -function shortId(id: string): string { - return id.length > SHORT_ID_LEN ? id.slice(0, SHORT_ID_LEN) : id; +function renderStatusHintRows(id: string, theme: GraphTheme | undefined, width: number): string[] { + const budget = Math.max(1, width - 4); + const prefix = "▸ /workflow status "; + const continuation = " "; + const rows = wrapIdentifierLines(id, budget, prefix, continuation); + const identifierRowCount = rows.length; + const suffix = " drill into a run"; + const last = rows[rows.length - 1]!; + if (visibleWidth(`${last.prefix}${last.chunk}${suffix}`) <= budget) { + last.chunk += suffix; + } else { + rows.push({ + prefix: continuation, + chunk: truncateToWidth(suffix.trimStart(), Math.max(1, budget - visibleWidth(continuation)), ELLIPSIS), + }); + } + if (!theme) return rows.map((row) => `${row.prefix}${row.chunk}`); + const dim = hexToAnsi(theme.dim); + const accent = hexToAnsi(theme.accent); + return rows.map((row, index) => { + const carriesIdentifier = index < identifierRowCount; + const carriesSuffix = carriesIdentifier && index === identifierRowCount - 1 && row.chunk.endsWith(suffix); + const idChunk = carriesSuffix ? row.chunk.slice(0, -suffix.length) : row.chunk; + const suffixText = carriesSuffix ? suffix : ""; + if (index === 0) { + return `${dim}▸${RESET} ${accent}/workflow status ${idChunk}${RESET}${suffixText ? `${dim}${suffixText}${RESET}` : ""}`; + } + if (carriesIdentifier) { + return `${row.prefix}${accent}${idChunk}${RESET}${suffixText ? `${dim}${suffixText}${RESET}` : ""}`; + } + return `${row.prefix}${dim}${row.chunk}${RESET}`; + }); } function emptyStateLine(theme?: GraphTheme): string { @@ -418,7 +442,6 @@ function statusIconForRun(run: RunSnapshot): string { case "completed": return "✓"; case "skipped": - return "⊘"; case "cancelled": return "⊘"; case "blocked": @@ -437,4 +460,3 @@ function statusIconForRun(run: RunSnapshot): string { } // Re-export for callers that need to inspect width budgeting. -export { MIN_TITLE_BUDGET }; diff --git a/packages/workflows/src/tui/widget.ts b/packages/workflows/src/tui/widget.ts index 1cf636a6b..b077ecd43 100644 --- a/packages/workflows/src/tui/widget.ts +++ b/packages/workflows/src/tui/widget.ts @@ -5,9 +5,8 @@ * - One transparent rounded `BACKGROUND` panel with `N runs` and status * badges (`✓ n ● n ○ n ✗ n`) in the title. * - One compact rounded card per run: - * title: ` ` - * row 1: `` - * - Blank line between cards; trailing blank trimmed. + * title: ` ` + * row 1: ` · ` * - Collapsed single-line form below 80 cells: * `▾ N background · X ●` in dim+warning. * @@ -28,9 +27,10 @@ import type { RunSnapshot, StoreSnapshot } from "../shared/store-types.js"; import { elapsedRunMs } from "../shared/timing.js"; import type { FlatBandBadge } from "./chat-surface.js"; import { renderRoundedBoxLines } from "./chat-surface.js"; -import { BOLD, hexToAnsi, RESET } from "./color-utils.js"; +import { hexToAnsi, RESET } from "./color-utils.js"; import type { GraphTheme } from "./graph-theme.js"; import { deriveGraphTheme } from "./graph-theme.js"; +import { renderRunIdentityRows } from "./run-identity-rows.js"; import { statusIcon } from "./status-helpers.js"; import type { PiTheme } from "./store-widget-installer.js"; @@ -38,7 +38,6 @@ import type { PiTheme } from "./store-widget-installer.js"; // Tunables // --------------------------------------------------------------------------- -const SHORT_ID_LEN = 6; export const RECENT_ENDED_WINDOW_MS = 30_000; const COLLAPSED_BREAKPOINT_COLS = 80; @@ -176,10 +175,6 @@ function selectDisplayRuns(snap: StoreSnapshot, now: number): RunSnapshot[] { // Per-run derived strings // --------------------------------------------------------------------------- -function shortId(run: RunSnapshot): string { - return run.id.length > SHORT_ID_LEN ? run.id.slice(0, SHORT_ID_LEN) : run.id; -} - function statusGlyph(run: RunSnapshot): string { if (isQuitRun(run)) return "○"; switch (effectiveRunStatus(run)) { @@ -313,29 +308,28 @@ function formatTitleBadges(badges: readonly FlatBandBadge[], theme: GraphTheme, // --------------------------------------------------------------------------- function themedRunLines(run: RunSnapshot, now: number, theme: GraphTheme): string[] { - const dim = hexToAnsi(theme.dim); - const text = hexToAnsi(theme.text); - const accent = hexToAnsi(theme.accent); - const muted = hexToAnsi(theme.textMuted); - const glyphFg = hexToAnsi(statusFg(run, theme)); - - const glyph = statusGlyph(run); - const sid = shortId(run); - const name = run.name; - - const line1 = ` ${glyphFg}${glyph}${RESET} ${accent}${sid}${RESET} ${text}${BOLD}${name}${RESET}`; const meta = metaLine(run, now); // Render the meta line in muted while running so the elapsed-time // gradient stays readable; dim it once the run has terminated. - const metaFg = effectiveRunStatus(run) === "running" ? muted : dim; - const line2 = ` ${metaFg}${meta}${RESET}`; - return [line1, line2]; + const metaColor = effectiveRunStatus(run) === "running" ? theme.textMuted : theme.dim; + return renderRunIdentityRows({ + runId: run.id, + name: run.name, + meta, + glyph: statusGlyph(run), + glyphColor: statusFg(run, theme), + metaColor, + theme, + }); } function plainRunLines(run: RunSnapshot, now: number): string[] { - const line1 = ` ${statusGlyph(run)} ${shortId(run)} ${run.name}`; - const line2 = ` ${metaLine(run, now)}`; - return [line1, line2]; + return renderRunIdentityRows({ + runId: run.id, + name: run.name, + meta: metaLine(run, now), + glyph: statusGlyph(run), + }); } // --------------------------------------------------------------------------- diff --git a/test/helpers/run-id.ts b/test/helpers/run-id.ts new file mode 100644 index 000000000..33f2bd7dd --- /dev/null +++ b/test/helpers/run-id.ts @@ -0,0 +1,23 @@ +import { createHash } from "node:crypto"; + +/** + * Deterministic run/stage id for fixtures. + * + * Resolvers now accept only a full 36-character UUID, so a fixture id like + * `"run-1"` is rejected as malformed before any lookup happens. Deriving the id + * from a seed keeps each fixture's intent readable at the call site + * (`testRunId("terminal-send-race")`) while producing a value the resolver + * accepts, and keeps it stable across runs so failures stay reproducible and + * snapshots stay comparable. + * + * Distinct seeds give distinct ids; the same seed always gives the same id, so + * a parent and child fixture can be related on purpose rather than by accident. + */ +export function testRunId(seed: string): string { + const hex = createHash("sha256").update(seed).digest("hex"); + // Pin the version and variant nibbles so the value is a well-formed v4-shaped + // UUID rather than merely 32 hex characters wearing dashes. + const version = `4${hex.slice(13, 16)}`; + const variant = `${((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}`; + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${version}-${variant}-${hex.slice(20, 32)}`; +} diff --git a/test/integration/mock-extension-api-rendering.test.ts b/test/integration/mock-extension-api-rendering.test.ts index f1731dfd6..4410840e2 100644 --- a/test/integration/mock-extension-api-rendering.test.ts +++ b/test/integration/mock-extension-api-rendering.test.ts @@ -420,19 +420,22 @@ describe("renderResult — all action branches", () => { test("action='run' background dispatch reuses the slash-command dispatch card", () => { const width = 64; - const out = renderResult( - { - action: "run", - name: "fan-out-and-synthesize", - runId: "abcdef123456", - status: "running", - message: "started", - }, - { width, runInputs: { prompt: "map the repo" } }, - ); + const result = { + action: "run", + name: "fan-out-and-synthesize", + runId: "abcdef123456", + status: "running", + message: "started", + } as const; + const out = renderResult(result, { width, runInputs: { prompt: "map the repo" } }); assert.match(out, /fan-out-and-synthesize/); assert.match(out, /prompt/); - assert.match(out, /\/workflow connect abcdef12/); + // The connect hint styles its dim prefix and accent id as separate spans, + // so the styled string carries escape codes between "connect " and the id. + // Assert the hint against the plain render; the styled one is still what + // the width check below measures. + const plain = renderResult(result, { width, runInputs: { prompt: "map the repo" }, plain: true }); + assert.match(plain, /\/workflow connect abcdef123456/); for (const line of out.split("\n")) { assert.ok( visibleWidth(line) <= width, diff --git a/test/integration/mock-extension-api-tool-registration.test.ts b/test/integration/mock-extension-api-tool-registration.test.ts index f0c2ce013..0ec18eccd 100644 --- a/test/integration/mock-extension-api-tool-registration.test.ts +++ b/test/integration/mock-extension-api-tool-registration.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import type { Api, Model } from "@earendil-works/pi-ai/compat"; import { beforeEach, describe, test } from "vitest"; import type { WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; +import { testRunId } from "../helpers/run-id.js"; import type { WorkflowToolArgs } from "./mock-extension-api-helpers.js"; import { EXPECTED_WORKFLOW_DESCRIPTION_TOKENS, @@ -264,35 +265,38 @@ describe("MockExtensionAPI — tool registration", () => { test("tool execute rejects unknown actions", async () => { const execute = mock.tools[0]!.opts.execute; + const unknownRunId = testRunId("run-123"); await assert.rejects( - () => runTool(execute, { runId: "run-123", action: "archive" } as unknown as WorkflowToolArgs), + () => runTool(execute, { runId: unknownRunId, action: "archive" } as unknown as WorkflowToolArgs), /unknown action "archive"/, ); }); test("tool execute returns interrupt result for canonical action='interrupt'", async () => { const execute = mock.tools[0]!.opts.execute; - const result = await runTool(execute, { runId: "run-123", action: "interrupt" }); + const unknownRunId = testRunId("run-123"); + const result = await runTool(execute, { runId: unknownRunId, action: "interrupt" }); assert.equal(result.action, "interrupt"); const r = result as { action: "interrupt"; runId: string; status: string; message: string }; - assert.equal(r.runId, "run-123"); + assert.equal(r.runId, unknownRunId); assert.equal(r.status, "noop"); assert.ok(r.message.includes("Run not found")); }); test("tool execute returns quit result for canonical action='quit'", async () => { const execute = mock.tools[0]!.opts.execute; - const result = await runTool(execute, { runId: "run-123", action: "quit" }); + const unknownRunId = testRunId("run-123"); + const result = await runTool(execute, { runId: unknownRunId, action: "quit" }); assert.equal(result.action, "quit"); const r = result as { action: "quit"; runId: string; status: string; message: string }; - assert.equal(r.runId, "run-123"); + assert.equal(r.runId, unknownRunId); assert.equal(r.status, "noop"); assert.ok(r.message.includes("Run not found")); }); test("tool execute returns resume stub for action='resume'", async () => { const execute = mock.tools[0]!.opts.execute; - const result = await runTool(execute, { runId: "run-456", inputs: {}, action: "resume" }); + const result = await runTool(execute, { runId: testRunId("run-456"), inputs: {}, action: "resume" }); assert.equal(result.action, "resume"); }); diff --git a/test/integration/overlay-entrypoints-commands.test.ts b/test/integration/overlay-entrypoints-commands.test.ts index 4fe7ca8a5..2e594e861 100644 --- a/test/integration/overlay-entrypoints-commands.test.ts +++ b/test/integration/overlay-entrypoints-commands.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { testRunId } from "../helpers/run-id.js"; import { attachHostCustomUiState, buildGraphOverlayAdapter, @@ -106,8 +107,9 @@ describe("/workflow resume — overlay integration", () => { const wfCmd = commands.workflow!; const { ctx } = buildPrintCtx(); + const unknownRunId = testRunId("no-such-run"); - void wfCmd.options.handler("resume no-such-run", ctx); + void wfCmd.options.handler(`resume ${unknownRunId}`, ctx); assert.equal(customCalls.length, 0); }); @@ -271,7 +273,7 @@ describe("/workflow resume — overlay integration", () => { test("resume with known authoritative completed runId calls overlay.open", async () => { singletonStore.clear(); - const runId = `test-resume-run-${Date.now()}`; + const runId = testRunId(`test-resume-run-${Date.now()}`); const backend = new InMemoryDurableBackend(); const cleanup = registerInspectableCompleted(backend, runId, "test-wf"); setDurableBackend(backend); @@ -302,7 +304,7 @@ describe("/workflow resume — overlay integration", () => { }); test("resume of an actively-running run is refused (use /workflow connect)", async () => { - const runId = `test-active-run-${Date.now()}`; + const runId = testRunId(`test-active-run-${Date.now()}`); singletonStore.recordRunStart({ id: runId, @@ -386,7 +388,8 @@ describe("/workflow pause — top-level command", () => { factory(pi); const wfCmd = commands.workflow!; const { ctx, messages } = buildPrintCtx(); - await wfCmd.options.handler("pause no-such-run", ctx); + const unknownRunId = testRunId("no-such-run"); + await wfCmd.options.handler(`pause ${unknownRunId}`, ctx); const joined = messages.join("\n"); assert.match(joined, /Run not found/); }); @@ -397,7 +400,7 @@ describe("/workflow resume — paused vs non-paused branching", () => { afterEach(() => setDurableBackend(undefined)); test("resume refuses a completed local snapshot without authoritative data", async () => { singletonStore.clear(); - const runId = `test-non-paused-${Date.now()}`; + const runId = testRunId(`test-non-paused-${Date.now()}`); singletonStore.recordRunStart({ id: runId, name: "snap-only-wf", @@ -430,7 +433,7 @@ describe("/workflow attach — top-level command", () => { afterEach(() => setDurableBackend(undefined)); test("attach opens the overlay", async () => { singletonStore.clear(); - const runId = `test-attach-${Date.now()}`; + const runId = testRunId(`test-attach-${Date.now()}`); singletonStore.recordRunStart({ id: runId, name: "attach-wf", @@ -454,7 +457,8 @@ describe("/workflow attach — top-level command", () => { factory(pi); const wfCmd = commands.workflow!; const { ctx, messages } = buildPrintCtx(); - await wfCmd.options.handler("attach not-a-run", ctx); + const unknownRunId = testRunId("not-a-run"); + await wfCmd.options.handler(`attach ${unknownRunId}`, ctx); assert.match(messages.join("\n"), /Run not found/); assert.equal(customCalls.length, 0); }); @@ -466,7 +470,7 @@ describe("/workflow attach — top-level command", () => { const wfCmd = commands.workflow!; const { ctx } = buildPrintCtx(); // Unknown id — hermetic durable backend has no matching record. - await wfCmd.options.handler("resume not-a-durable-wf", ctx); + await wfCmd.options.handler(`resume ${testRunId("not-a-durable-wf")}`, ctx); assert.equal(customCalls.length, 0); }); diff --git a/test/integration/overlay-resume-regressions.test.ts b/test/integration/overlay-resume-regressions.test.ts index dcf0e4f83..ba7e1074a 100644 --- a/test/integration/overlay-resume-regressions.test.ts +++ b/test/integration/overlay-resume-regressions.test.ts @@ -5,6 +5,7 @@ import { setDurableBackend } from "../../packages/workflows/src/durable/factory. import type { ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import type { WorkflowExecutionPolicy } from "../../packages/workflows/src/shared/types.js"; +import { testRunId } from "../helpers/run-id.js"; import { buildMockPi, buildPrintCtxWithRealCustom, @@ -26,10 +27,11 @@ describe("/workflow resume — durable regression coverage", () => { test("durable resume forwards non-interactive command policy", async () => { let capturedPolicy: WorkflowExecutionPolicy | undefined; + const workflowId = testRunId("durable-policy-run"); const runtime = { prepareDurableResumable: async () => [ { - workflowId: "durable-policy-run", + workflowId, name: "policy-wf", status: "paused" as const, completedCheckpoints: 0, @@ -47,7 +49,7 @@ describe("/workflow resume — durable regression coverage", () => { await handleRunControlCommand( "resume", - ["durable-policy-run"], + [workflowId], { hasUI: false, ui: { notify: () => undefined } }, { info: (message) => messages.push(message), @@ -119,11 +121,12 @@ describe("/workflow resume — durable regression coverage", () => { }); test("targeted stale running durable resume does not print stale catalog", async () => { + const workflowId = testRunId("stale-running-id"); const runtime = { registry: { has: () => false }, prepareDurableResumable: async () => [ { - workflowId: "stale-running-id", + workflowId, name: "stale-running-wf", status: "running" as const, completedCheckpoints: 0, @@ -142,7 +145,7 @@ describe("/workflow resume — durable regression coverage", () => { await handleRunControlCommand( "resume", - ["stale-running-id"], + [workflowId], { hasUI: false, ui: { notify: () => undefined } }, { info: (message) => messages.push(message), @@ -251,8 +254,9 @@ describe("/workflow resume — durable regression coverage", () => { }); test("no-arg durable picker resolves selection before dispose", async () => { const backend = new InMemoryDurableBackend(); + const workflowId = testRunId("durable-select-race"); backend.registerWorkflow({ - workflowId: "durable-select-race", + workflowId, name: "missing-selection-def", inputs: {}, createdAt: Date.now(), @@ -277,7 +281,7 @@ describe("/workflow resume — durable regression coverage", () => { test("combined picker resolves live selection before dispose", async () => { const now = Date.now(); - const liveRunId = `live-select-${now}`; + const liveRunId = testRunId(`live-select-${now}`); singletonStore.recordRunStart({ id: liveRunId, name: "live-select-wf", @@ -289,7 +293,7 @@ describe("/workflow resume — durable regression coverage", () => { singletonStore.recordRunPaused(liveRunId, now + 2); const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ - workflowId: "durable-select-alongside", + workflowId: testRunId("durable-select-alongside"), name: "durable-select", inputs: {}, createdAt: now, @@ -313,7 +317,7 @@ describe("/workflow resume — durable regression coverage", () => { test("combined picker resumes failed live runs through continuation path", async () => { const now = Date.now(); - const failedRunId = `failed-live-${now}`; + const failedRunId = testRunId(`failed-live-${now}`); singletonStore.recordRunStart({ id: failedRunId, name: "missing-continuation-wf", @@ -330,7 +334,7 @@ describe("/workflow resume — durable regression coverage", () => { }); const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ - workflowId: "durable-with-failed-live", + workflowId: testRunId("durable-with-failed-live"), name: "durable-select", inputs: {}, createdAt: now - 2, diff --git a/test/integration/workflow-id-targeting.test.ts b/test/integration/workflow-id-targeting.test.ts index 0b5268a57..c87bdac6c 100644 --- a/test/integration/workflow-id-targeting.test.ts +++ b/test/integration/workflow-id-targeting.test.ts @@ -21,7 +21,7 @@ const config: WorkflowRuntimeConfig = { beforeEach(() => store.clear()); afterEach(() => store.clear()); -test("displayed Workflow run and stage ID prefixes are actionable through the public tool", async () => { +test("displayed Workflow run and stage IDs remain full while typed prefixes stay actionable through the public tool", async () => { const fixture = workflow({ name: "actionable-id-proof", description: "Deterministic workflow identifier proof.", @@ -49,14 +49,27 @@ test("displayed Workflow run and stage ID prefixes are actionable through the pu const execute = makeExecuteWorkflowTool(runtime, () => undefined); const listed = await execute({ action: "status" }, {} as never); const rendered = renderResult(listed, { plain: true }); - const runMatch = rendered.match(new RegExp(`\\b(${completed.runId.slice(0, 6)})\\b`)); - assert.ok(runMatch, "status should render the actionable run ID prefix"); - const displayedRunId = runMatch[1]!; + assert.ok(rendered.includes(completed.runId), `status should render the full run ID; output:\n${rendered}`); + // The contract is a round trip: the id the UI prints is exactly the id the + // tool accepts back. Nothing shorter is a valid target any more. + const displayedRunId = completed.runId; const status = await execute({ action: "status", runId: displayedRunId }, {} as never); assert.equal(status.action, "statusDetail"); assert.equal(status.runId, completed.runId); + const truncatedRun = await execute({ action: "status", runId: completed.runId.slice(0, 6) }, {} as never); + assert.equal(truncatedRun.action, "statusDetail"); + assert.ok( + "error" in truncatedRun, + "a truncated run id must fail rather than return run detail for the run it prefixes", + ); + assert.match( + "error" in truncatedRun ? truncatedRun.error : "", + /must be a full 36-character UUID/, + "a truncated run id must be rejected as malformed rather than resolved by prefix", + ); + const stages = await execute( { action: "stages", @@ -67,10 +80,10 @@ test("displayed Workflow run and stage ID prefixes are actionable through the pu ); assert.equal(stages.action, "stages"); const renderedStages = renderResult(stages, { plain: true, width: 200 }); - const stageMatch = renderedStages.match(/deterministic-stage \(([^)]+)\)/); - assert.ok(stageMatch, "stages should render the actionable stage ID prefix"); + const stageMatch = renderedStages.match(/deterministic-stage [(]([^)]+)[)]/); + assert.ok(stageMatch, "stages should render the full stage ID"); const displayedStageId = stageMatch[1]!; - assert.equal(displayedStageId, stageId.slice(0, 12)); + assert.equal(displayedStageId, stageId); const stage = await execute( { action: "stage", @@ -82,4 +95,19 @@ test("displayed Workflow run and stage ID prefixes are actionable through the pu assert.equal(stage.action, "stage"); assert.equal(stage.runId, completed.runId); assert.equal(stage.action === "stage" ? stage.stage?.id : undefined, stageId); + + const truncatedStage = await execute( + { + action: "stage", + runId: displayedRunId, + stageId: stageId.slice(0, 12), + }, + {} as never, + ); + assert.equal(truncatedStage.action, "stage"); + assert.equal( + truncatedStage.action === "stage" ? truncatedStage.stage : undefined, + undefined, + "a truncated stage id must not resolve to the stage it prefixes", + ); }); diff --git a/test/integration/workflow-tool-node-quit-cli.test.ts b/test/integration/workflow-tool-node-quit-cli.test.ts index 3fdb62ed5..9507b5af5 100644 --- a/test/integration/workflow-tool-node-quit-cli.test.ts +++ b/test/integration/workflow-tool-node-quit-cli.test.ts @@ -413,7 +413,7 @@ describe("issue #2078 — quitting an in-flight ctx.tool through the real CLI", ); assert.equal( evidence.quitNotifications.some((message) => - message.includes(`Run ${evidence.runId.slice(0, 8)} quit and can be resumed with /workflow resume.`), + message.includes(`Run ${evidence.runId} quit and can be resumed with /workflow resume.`), ), true, `quit notifications were: ${JSON.stringify(evidence.quitNotifications)}`, diff --git a/test/unit/background-quit.test.ts b/test/unit/background-quit.test.ts index c7fb48469..75a786683 100644 --- a/test/unit/background-quit.test.ts +++ b/test/unit/background-quit.test.ts @@ -994,7 +994,7 @@ describe("graceful workflow quit of in-flight ctx.tool nodes", () => { const store = createStore(); const registry = createStageControlRegistry(); const toolControls = createToolControlRegistry(); - const runId = "quit-tool-post-close"; + const runId = "c3d4e5f6-a1b2-4c3d-9e0f-1a2b3c4d5e6f"; const entered = Promise.withResolvers(); let secondCallStarted = false; let secondCallRejection: unknown; @@ -1044,6 +1044,7 @@ describe("graceful workflow quit of in-flight ctx.tool nodes", () => { assert.equal(secondCallStarted, false, "a post-close ctx.tool callback must never run"); assert.equal(secondCallRejection instanceof Error, true); assert.match(String(secondCallRejection), /suspended by workflow quit/); + assert.ok(String(secondCallRejection).includes(runId)); assert.deepEqual( store .runs() diff --git a/test/unit/dispatch-confirm-render.test.ts b/test/unit/dispatch-confirm-render.test.ts index c3505c742..6f2597599 100644 --- a/test/unit/dispatch-confirm-render.test.ts +++ b/test/unit/dispatch-confirm-render.test.ts @@ -2,17 +2,12 @@ * Unit tests for the `/workflow …` dispatch confirmation * (`src/tui/dispatch-confirm.ts`). * - * Visual contract — ui/dispatch-mockup.html §1 (compact two-row shape): - * - One rounded dispatched panel: rounded run card with 8-char runId + workflow name + - * inline inputs (`k=v · k=v · +N more`) + right-aligned `● running` - * badge. + * Visual contract — ui/dispatch-mockup.html §1 (full-id identity rows): + * - One rounded dispatched panel with the full UUID on its own identity row. + * - Workflow name and running status follow on the next row. + * - Inputs render in the body and the connect hint keeps the complete UUID. * - Inputs wrap to a second body row only when row 1's interior cannot * hold them; the body row uses the same overflow rules. - * - Runs keep one hint row: `▸ /workflow connect see agents working · chat with and steer each stage`. - * - * Explicitly removed (was in the legacy 7-row layout): the `✓ submitted` - * echo, the `[ DISPATCHED ]` band, the `run id` muted caption beside the - * tag, and the `status starting…` body row. * * cross-ref: src/tui/dispatch-confirm.ts · src/tui/chat-surface.ts * · ui/dispatch-mockup.html @@ -22,6 +17,8 @@ import assert from "node:assert/strict"; import { describe, test } from "vitest"; import { renderDispatchConfirm } from "../../packages/workflows/src/tui/dispatch-confirm.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; +import { statusIcon } from "../../packages/workflows/src/tui/status-helpers.js"; +import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; const ANSI_RE = /\x1b\[[0-9;]*m/g; const stripAnsi = (s: string) => s.replace(ANSI_RE, ""); @@ -37,8 +34,8 @@ describe("renderDispatchConfirm — themed", () => { }); const plain = stripAnsi(out); - // Identity: short runId in the tag + workflow name on the same row. - assert.match(plain, /0391c9c1/); + // Identity keeps the complete run id and workflow name. + assert.match(plain, /0391c9c1-aaaa-bbbb-cccc-dddddddddddd/); assert.match(plain, /fan-out-and-synthesize/); // Status badge in the trailing slot. @@ -48,20 +45,23 @@ describe("renderDispatchConfirm — themed", () => { assert.match(plain, /prompt="map the codebase"/); assert.match(plain, /max_branches=4/); - // Guidance preserves the actionable short id and explains the graph in + // Guidance preserves the complete actionable id and explains the graph in // plain language without attach/detach jargon. - assert.match(plain, /▸ \/workflow connect 0391c9c1\s+see agents working/); + assert.match(plain, /▸ \/workflow connect 0391c9c1-aaaa-bbbb-cccc-dddddddddddd\s+see agents working/); assert.match(plain, /chat with and steer each stage/); assert.doesNotMatch(plain, /attach|detach/i); // Legacy chrome MUST be gone. assert.doesNotMatch(plain, /✓ submitted/); assert.doesNotMatch(plain, /\[ DISPATCHED \]/); - assert.doesNotMatch(plain, /\brun id\b/); assert.doesNotMatch(plain, /\bstarting…/); assert.doesNotMatch(plain, /▸ \/workflow status/); assert.match(plain, /^╭ DISPATCHED /); - assert.match(plain, /● {2}0391c9c1 {2}fan-out-and-synthesize {2}● running/); + assert.doesNotMatch(plain, /\brun id\b/); + assert.match( + plain, + new RegExp(`${statusIcon("running")} {2}fan-out-and-synthesize {2}${statusIcon("running")} running`), + ); // Themed mode emits ANSI escapes. assert.match(out, /\x1b\[/); @@ -77,12 +77,12 @@ describe("renderDispatchConfirm — themed", () => { }); const lines = stripAnsi(out).split("\n"); assert.match(lines[0]!, /DISPATCHED/); - assert.match(lines[1]!, /be3181c1/); - assert.match(lines[1]!, /fan-out-and-synthesize/); - assert.match(lines[2]!, /prompt="explore the codebase"/); - assert.match(lines[2]!, /max_branches=4/); + assert.match(lines[1]!, /be3181c1-aaaa-bbbb-cccc-dddddddddddd/); + assert.match(lines[2]!, /fan-out-and-synthesize/); + assert.match(lines[3]!, /prompt="explore the codebase"/); + assert.match(lines[3]!, /max_branches=4/); assert.match(lines.join("\n"), /● running/); - assert.match(lines.join("\n"), /▸ \/workflow connect be3181c1/); + assert.match(lines.join("\n"), /▸ \/workflow connect be3181c1-aaaa-bbbb-cccc-dddddddddddd/); }); test("narrow terminal → rounded card remains width-safe", () => { @@ -94,10 +94,48 @@ describe("renderDispatchConfirm — themed", () => { width: 60, }); const lines = stripAnsi(out).split("\n"); - assert.match(lines.join("\n"), /be3181c1/); + assert.match(lines.join("\n"), /be3181c1-aaaa-bbbb-cccc-dddddddddddd/); assert.match(lines.join("\n"), /● running/); assert.match(lines.join("\n"), /prompt=/); - assert.match(lines.join("\n"), /▸ \/workflow connect be3181c1/); + assert.match(lines.join("\n"), /▸ \/workflow connect be3181c1-aaaa-bbbb-cccc-dddddddddddd/); + }); + + test("every terminal width preserves the complete connect id and rounded-box edges", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + for (let width = 20; width <= 120; width += 1) { + const lines = stripAnsi( + renderDispatchConfirm({ + workflowName: "publish-release", + runId, + inputs: {}, + theme: deriveGraphTheme({}), + width, + }), + ).split("\n"); + const expectedWidth = Math.max(32, width); + for (const [index, line] of lines.entries()) { + assert.equal(visibleWidth(line), expectedWidth, `width ${width}, row ${index}: ${line}`); + if (index === 0) assert.match(line, /^╭.*╮$/, `width ${width}: top edge`); + else if (index === lines.length - 1) assert.match(line, /^╰.*╯$/, `width ${width}: bottom edge`); + else assert.match(line, /^│.*│$/, `width ${width}, row ${index}: side edges`); + } + + const hintStart = lines.findIndex((line) => line.includes("/workflow connect")); + assert.notEqual(hintStart, -1, `width ${width}: connect hint missing`); + let reassembledId = ""; + for (const boxedLine of lines.slice(hintStart, -1)) { + let content = boxedLine.slice(1, -1).trim(); + if (content.startsWith("▸ /workflow connect ")) content = content.slice("▸ /workflow connect ".length); + else if (content.startsWith("see agents")) break; + const suffixAt = content.indexOf(" see agents"); + if (suffixAt >= 0) content = content.slice(0, suffixAt); + content = content.trim(); + assert.doesNotMatch(content, /…/, `width ${width}: identifier chunk was ellipsized`); + reassembledId += content; + if (suffixAt >= 0) break; + } + assert.equal(reassembledId, runId, `width ${width}: connect id changed across wrapped rows`); + } }); test("zero inputs renders a single identity row + hint, with no body row", () => { @@ -109,10 +147,10 @@ describe("renderDispatchConfirm — themed", () => { width: 100, }); const plain = stripAnsi(out); - assert.match(plain, /5b91ee54/); + assert.match(plain, /5b91ee54-aaaa-bbbb-cccc-dddddddddddd/); assert.match(plain, /primer/); assert.match(plain, /● running/); - assert.match(plain, /▸ \/workflow connect 5b91ee54/); + assert.match(plain, /▸ \/workflow connect 5b91ee54-aaaa-bbbb-cccc-dddddddddddd/); // No legacy `(none)` placeholder. assert.doesNotMatch(stripAnsi(out), /\(none\)/); }); @@ -162,25 +200,26 @@ describe("renderDispatchConfirm — plain", () => { }); assert.doesNotMatch(out, /\x1b\[/); - // Plain identity row carries run id, workflow name, and status. - assert.match(out, /● {2}abc12345 {2}tournament {2}● running/); + // Plain identity rows carry the full run id, workflow name, and status. + assert.match(out, /abc12345-aaaa-bbbb-cccc-dddddddddddd/); + assert.match(out, new RegExp(`${statusIcon("running")} {2}tournament {2}${statusIcon("running")} running`)); assert.match(out, /● running/); // Inputs present (inline on wide terminal). assert.match(out, /prompt="hello"/); // All workflows explain how to see agents and chat with/steer stages. - assert.match(out, /▸ \/workflow connect abc12345\s+see agents working/); + assert.match(out, /▸ \/workflow connect abc12345-aaaa-bbbb-cccc-dddddddddddd\s+see agents working/); assert.match(out, /chat with and steer each stage/); assert.doesNotMatch(out, /attach|detach/i); - assert.doesNotMatch(out, /▸ \/workflow status abc12345/); + assert.doesNotMatch(out, /▸ \/workflow status/); assert.doesNotMatch(out, /Ask here anytime for status or to steer this run\./); // Legacy chrome MUST be gone in plain mode too. assert.doesNotMatch(out, /✓ submitted/); assert.doesNotMatch(out, /\[ DISPATCHED \]/); - assert.doesNotMatch(out, /\brun id\b/); assert.doesNotMatch(out, /\bstarting…/); + assert.doesNotMatch(out, /\brun id\b/); }); test("zero inputs in plain mode renders just identity row + hint", () => { @@ -190,8 +229,8 @@ describe("renderDispatchConfirm — plain", () => { inputs: {}, width: 100, }); - assert.match(out, /● {2}5b91ee54 {2}primer {2}● running/); - assert.match(out, /● running/); - assert.match(out, /▸ \/workflow connect 5b91ee54/); + assert.match(out, /5b91ee54-aaaa-bbbb-cccc-dddddddddddd/); + assert.match(out, new RegExp(`${statusIcon("running")} {2}primer {2}${statusIcon("running")} running`)); + assert.match(out, /▸ \/workflow connect 5b91ee54-aaaa-bbbb-cccc-dddddddddddd/); }); }); diff --git a/test/unit/durable-cached-descendant-guard.test.ts b/test/unit/durable-cached-descendant-guard.test.ts index d0ce59570..8a112242d 100644 --- a/test/unit/durable-cached-descendant-guard.test.ts +++ b/test/unit/durable-cached-descendant-guard.test.ts @@ -16,6 +16,7 @@ import { createStore, store as workflowStore } from "../../packages/workflows/sr import type { RunSnapshot, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; import { sleep } from "../helpers/runtime.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; @@ -240,7 +241,7 @@ function markHandledStageSkipped(sdk: ReturnType): void { } test.sequential("cached completed boundary republishes a child that handled a failed stage and completed downstream", async () => { - const rootId = "cached-descendant-valid-root"; + const rootId = testRunId("cached-descendant-valid-root"); const fixture = await prepareFixture(rootId); const fresh = new DbosDurableBackend(fixture.persisted, { executorId: "cached-descendant-valid-fresh" }); await fresh.hydrateWorkflow(rootId); @@ -314,7 +315,7 @@ test.sequential("cached completed boundary republishes a child that handled a fa }); test.sequential("cached completed boundary accepts a fully timed skipped child stage without rerunning", async () => { - const rootId = "cached-descendant-skipped-root"; + const rootId = testRunId("cached-descendant-skipped-root"); const fixture = await prepareFixture(rootId); markHandledStageSkipped(fixture.persisted); const fresh = new DbosDurableBackend(fixture.persisted, { executorId: "cached-descendant-skipped-fresh" }); @@ -354,7 +355,7 @@ test.sequential("cached completed boundary accepts a fully timed skipped child s }); test.sequential("cached completed boundary rejects an active descendant before publication or later dispatch", async () => { - const rootId = "cached-descendant-malformed-root"; + const rootId = testRunId("cached-descendant-malformed-root"); const fixture = await prepareFixture(rootId); const malformed = copySdk(fixture.persisted); removeDeepTerminal(malformed); diff --git a/test/unit/durable-completed-catalog-review.test.ts b/test/unit/durable-completed-catalog-review.test.ts index 8c6ccd562..2b0f81f3f 100644 --- a/test/unit/durable-completed-catalog-review.test.ts +++ b/test/unit/durable-completed-catalog-review.test.ts @@ -13,6 +13,7 @@ import { RUN_TIMING_CHECKPOINT_NAME } from "../../packages/workflows/src/durable import type { DurableCheckpoint, DurableStageRunTopology } from "../../packages/workflows/src/durable/types.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk, seedMockCheckpoint, seedMockWorkflow } from "./durable-dbos-backend-helpers.js"; function registerCompleted(backend: InMemoryDurableBackend, workflowId: string): void { @@ -88,7 +89,7 @@ function reconstructed( describe("completed catalog reviewer regressions", () => { test("keeps a public workflow-run-timing tool visible while excluding the internal timing checkpoint", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "public-run-timing-name"; + const workflowId = testRunId("public-run-timing-name"); registerCompleted(backend, workflowId); const argsHash = durableHash({ name: RUN_TIMING_CHECKPOINT_NAME, args: {}, ordinal: 1 }); backend.recordCheckpoint(tool(workflowId, `tool:${argsHash}`, RUN_TIMING_CHECKPOINT_NAME, argsHash, 20)); @@ -126,8 +127,8 @@ describe("completed catalog reviewer regressions", () => { test("retains a scoped child tool whose public name matches the run-timing sentinel", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "scoped-name-root"; - const childRunId = "scoped-name-child"; + const workflowId = testRunId("scoped-name-root"); + const childRunId = testRunId("scoped-name-child"); registerCompleted(backend, workflowId); const rootRun = { runId: workflowId, runName: workflowId } as const; const childRun: DurableStageRunTopology = { @@ -177,7 +178,7 @@ describe("completed catalog reviewer regressions", () => { test("reconstructs legacy stage-tool-stage nodes in checkpoint sequence", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "legacy-mixed-sequence"; + const workflowId = testRunId("legacy-mixed-sequence"); registerCompleted(backend, workflowId); backend.recordCheckpoint(stage(workflowId, "before", "before", 10)); backend.recordCheckpoint(tool(workflowId, "middle", "middle", "middle-hash", 20)); @@ -190,7 +191,7 @@ describe("completed catalog reviewer regressions", () => { test("uses first logical checkpoint position across equal timestamps and repeated records", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "legacy-equal-time"; + const workflowId = testRunId("legacy-equal-time"); registerCompleted(backend, workflowId); backend.recordCheckpoint( stage(workflowId, "before-session", "before", 10, { @@ -218,7 +219,7 @@ describe("completed catalog reviewer regressions", () => { test("preserves legacy mixed checkpoint sequence after DBOS hydration", async () => { const sdk = createMockSdk(); - const workflowId = "legacy-dbos-order"; + const workflowId = testRunId("legacy-dbos-order"); const checkpoints = [ stage(workflowId, "before", "before", 10), tool(workflowId, "middle", "middle", "middle-hash", 10), @@ -246,7 +247,7 @@ describe("completed catalog reviewer regressions", () => { test("persisted topology order overrides contradictory checkpoint sequence", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "topology-over-sequence"; + const workflowId = testRunId("topology-over-sequence"); registerCompleted(backend, workflowId); const run = { runId: workflowId, runName: workflowId } as const; backend.recordCheckpoint( @@ -274,8 +275,8 @@ describe("completed catalog reviewer regressions", () => { test("preserves nested topology-aware order and boundary fan-in", () => { const backend = new InMemoryDurableBackend(); - const workflowId = "nested-topology-order"; - const childRunId = "nested-topology-child"; + const workflowId = testRunId("nested-topology-order"); + const childRunId = testRunId("nested-topology-child"); registerCompleted(backend, workflowId); const rootRun = { runId: workflowId, runName: workflowId } as const; const childRun: DurableStageRunTopology = { diff --git a/test/unit/durable-completed-catalog.test.ts b/test/unit/durable-completed-catalog.test.ts index 5b72b219b..a1a8c43e1 100644 --- a/test/unit/durable-completed-catalog.test.ts +++ b/test/unit/durable-completed-catalog.test.ts @@ -11,8 +11,12 @@ import { listOpenableCompletedWorkflows, resolveCompletedWorkflow, } from "../../packages/workflows/src/durable/completed-catalog.js"; -import { listResumableFromBackend } from "../../packages/workflows/src/durable/resume-catalog.js"; +import { + formatResumableWorkflowList, + listResumableFromBackend, +} from "../../packages/workflows/src/durable/resume-catalog.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; +import { testRunId } from "../helpers/run-id.js"; let tempDir = ""; @@ -54,17 +58,17 @@ describe("completed durable catalog", () => { test("keeps completed listing distinct from resumability predicates", () => { const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ - workflowId: "paused", + workflowId: testRunId("paused"), name: "paused-flow", inputs: {}, createdAt: 1, status: "paused", completedCheckpoints: 1, }); - registerCompleted(backend, "completed"); + registerCompleted(backend, testRunId("completed")); backend.recordCheckpoint({ kind: "tool", - workflowId: "completed", + workflowId: testRunId("completed"), checkpointId: "tool:1", name: "read", argsHash: "hash", @@ -74,22 +78,37 @@ describe("completed durable catalog", () => { assert.deepEqual( listResumableFromBackend(backend).map((entry) => entry.workflowId), - ["paused"], + [testRunId("paused")], ); assert.deepEqual( listCompletedFromBackend(backend).map((entry) => entry.workflowId), - ["completed"], + [testRunId("completed")], ); }); + test("formats full workflow ids in resume target lists", () => { + const workflowId = "bb22cc33-44dd-55ee-66ff-778899001122"; + const rendered = formatResumableWorkflowList([ + { + workflowId, + name: "resumable-flow", + status: "paused", + completedCheckpoints: 1, + pendingPrompts: 0, + createdAt: 1, + updatedAt: 2, + }, + ]); + assert.ok(rendered.includes(workflowId)); + }); test("keeps completed graphs inspectable while stripping stale retained chat", () => { const backend = new InMemoryDurableBackend(); const transcript = join(tempDir, "stage.jsonl"); writeSessionTranscript(transcript, "valid-session"); - registerCompleted(backend, "valid-completed"); + registerCompleted(backend, testRunId("valid-completed")); backend.recordCheckpoint({ kind: "stage", - workflowId: "valid-completed", + workflowId: testRunId("valid-completed"), checkpointId: "stage:1", name: "summarize", replayKey: "stage:summarize:1", @@ -98,10 +117,10 @@ describe("completed durable catalog", () => { model: "provider/model", completedAt: 20, }); - registerCompleted(backend, "stale-completed"); + registerCompleted(backend, testRunId("stale-completed")); backend.recordCheckpoint({ kind: "stage", - workflowId: "stale-completed", + workflowId: testRunId("stale-completed"), checkpointId: "stage:1", name: "missing", replayKey: "stage:missing:1", @@ -111,41 +130,44 @@ describe("completed durable catalog", () => { assert.deepEqual( new Set(listOpenableCompletedWorkflows(backend).map((entry) => entry.workflowId)), - new Set(["valid-completed", "stale-completed"]), + new Set([testRunId("valid-completed"), testRunId("stale-completed")]), ); const entries = listCompletedFromBackend(backend); const snapshot = completedWorkflowSnapshot( backend, - entries.find((entry) => entry.workflowId === "valid-completed")!, + entries.find((entry) => entry.workflowId === testRunId("valid-completed"))!, ); assert.equal(snapshot?.status, "completed"); assert.equal(snapshot?.stages[0]?.result, "finished"); assert.equal(snapshot?.stages[0]?.model, "provider/model"); const stale = completedWorkflowSnapshot( backend, - entries.find((entry) => entry.workflowId === "stale-completed")!, + entries.find((entry) => entry.workflowId === testRunId("stale-completed"))!, ); assert.equal(stale?.stages[0]?.sessionFile, undefined); - assert.equal(resolveCompletedWorkflow("stale", backend).kind, "found"); + assert.equal(resolveCompletedWorkflow(testRunId("stale-completed"), backend).kind, "found"); }); test("keeps completed graphs open without retained chat and opens tool-only runs read-only", () => { const backend = new InMemoryDurableBackend(); const cases = [ - { id: "no-session", sessionFile: undefined }, - { id: "empty-session", sessionFile: join(tempDir, "empty.jsonl") }, - { id: "malformed-session", sessionFile: join(tempDir, "malformed.jsonl") }, - { id: "directory-session", sessionFile: join(tempDir, "directory.jsonl") }, - { id: "header-only", sessionFile: join(tempDir, "header-only.jsonl") }, - { id: "invalid-message", sessionFile: join(tempDir, "invalid-message.jsonl") }, + { id: testRunId("no-session"), sessionFile: undefined }, + { id: testRunId("empty-session"), sessionFile: join(tempDir, "empty.jsonl") }, + { id: testRunId("malformed-session"), sessionFile: join(tempDir, "malformed.jsonl") }, + { id: testRunId("directory-session"), sessionFile: join(tempDir, "directory.jsonl") }, + { id: testRunId("header-only"), sessionFile: join(tempDir, "header-only.jsonl") }, + { id: testRunId("invalid-message"), sessionFile: join(tempDir, "invalid-message.jsonl") }, ] as const; writeFileSync(cases[1].sessionFile, ""); writeFileSync(cases[2].sessionFile, "not-json\n"); mkdirSync(cases[3].sessionFile); - writeFileSync(cases[4].sessionFile, `${JSON.stringify({ type: "session", id: "header-only" })}\n`); + writeFileSync(cases[4].sessionFile, `${JSON.stringify({ type: "session", id: testRunId("header-only") })}\n`); writeFileSync( cases[5].sessionFile, - [JSON.stringify({ type: "session", id: "invalid-message" }), JSON.stringify({ type: "message" })].join("\n"), + [ + JSON.stringify({ type: "session", id: testRunId("invalid-message") }), + JSON.stringify({ type: "message" }), + ].join("\n"), ); for (const item of cases) { registerCompleted(backend, item.id); @@ -159,10 +181,10 @@ describe("completed durable catalog", () => { completedAt: 20, }); } - registerCompleted(backend, "tool-only"); + registerCompleted(backend, testRunId("tool-only")); backend.recordCheckpoint({ kind: "tool", - workflowId: "tool-only", + workflowId: testRunId("tool-only"), checkpointId: "tool:1", name: "read", argsHash: "hash", @@ -171,9 +193,9 @@ describe("completed durable catalog", () => { }); const ids = listOpenableCompletedWorkflows(backend).map((entry) => entry.workflowId); - assert.deepEqual(new Set(ids), new Set([...cases.map((item) => item.id), "tool-only"])); + assert.deepEqual(new Set(ids), new Set([...cases.map((item) => item.id), testRunId("tool-only")])); for (const item of cases) assert.equal(resolveCompletedWorkflow(item.id, backend).kind, "found"); - const toolOnly = resolveCompletedWorkflow("tool-only", backend); + const toolOnly = resolveCompletedWorkflow(testRunId("tool-only"), backend); assert.equal(toolOnly.kind, "found"); if (toolOnly.kind === "found") { assert.equal(toolOnly.snapshot.stages.length, 0); @@ -192,10 +214,10 @@ describe("completed durable catalog", () => { const backend = new InMemoryDurableBackend(); const validTranscript = join(tempDir, "retained.jsonl"); writeSessionTranscript(validTranscript, "retained-session"); - registerCompleted(backend, "merged-stage"); + registerCompleted(backend, testRunId("merged-stage")); backend.recordCheckpoint({ kind: "stage", - workflowId: "merged-stage", + workflowId: testRunId("merged-stage"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -205,7 +227,7 @@ describe("completed durable catalog", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "merged-stage", + workflowId: testRunId("merged-stage"), checkpointId: "stage:2", name: "final", replayKey: "stage:final:1", @@ -217,7 +239,7 @@ describe("completed durable catalog", () => { assert.deepEqual( listOpenableCompletedWorkflows(backend).map((entry) => entry.workflowId), - ["merged-stage"], + [testRunId("merged-stage")], ); assert.equal( completedWorkflowSnapshot(backend, listCompletedFromBackend(backend)[0]!)?.stages[0]?.sessionFile, @@ -229,10 +251,10 @@ describe("completed durable catalog", () => { const backend = new InMemoryDurableBackend(); const validTranscript = join(tempDir, "usable.jsonl"); writeSessionTranscript(validTranscript, "usable-session"); - registerCompleted(backend, "partially-retained"); + registerCompleted(backend, testRunId("partially-retained")); backend.recordCheckpoint({ kind: "stage", - workflowId: "partially-retained", + workflowId: testRunId("partially-retained"), checkpointId: "stage:1", name: "retained", replayKey: "stage:retained:1", @@ -241,7 +263,7 @@ describe("completed durable catalog", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "partially-retained", + workflowId: testRunId("partially-retained"), checkpointId: "stage:2", name: "stale", replayKey: "stage:stale:1", @@ -252,7 +274,7 @@ describe("completed durable catalog", () => { const snapshot = completedWorkflowSnapshot(backend, listCompletedFromBackend(backend)[0]!); assert.deepEqual( listOpenableCompletedWorkflows(backend).map((item) => item.workflowId), - ["partially-retained"], + [testRunId("partially-retained")], ); assert.equal(snapshot?.stages[0]?.sessionFile, validTranscript); assert.equal(snapshot?.stages[1]?.sessionFile, undefined); @@ -264,7 +286,7 @@ describe("completed durable catalog", () => { writeFileSync( malformed, [ - JSON.stringify({ type: "session", id: "partially-malformed" }), + JSON.stringify({ type: "session", id: testRunId("partially-malformed") }), JSON.stringify({ type: "message", id: "valid", @@ -275,16 +297,16 @@ describe("completed durable catalog", () => { ].join("\n"), ); const emptyContent = [ - { id: "blank-string", content: " " }, - { id: "empty-array", content: [] }, - { id: "empty-object", content: {} }, - { id: "empty-block", content: [{}] }, - { id: "blank-text-block", content: [{ type: "text", text: "" }] }, + { id: testRunId("blank-string"), content: " " }, + { id: testRunId("empty-array"), content: [] }, + { id: testRunId("empty-object"), content: {} }, + { id: testRunId("empty-block"), content: [{}] }, + { id: testRunId("blank-text-block"), content: [{ type: "text", text: "" }] }, ] as const; - registerCompleted(backend, "partially-malformed"); + registerCompleted(backend, testRunId("partially-malformed")); backend.recordCheckpoint({ kind: "stage", - workflowId: "partially-malformed", + workflowId: testRunId("partially-malformed"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -320,7 +342,7 @@ describe("completed durable catalog", () => { const entries = listOpenableCompletedWorkflows(backend); assert.deepEqual( new Set(entries.map((entry) => entry.workflowId)), - new Set(["partially-malformed", ...emptyContent.map((item) => item.id)]), + new Set([testRunId("partially-malformed"), ...emptyContent.map((item) => item.id)]), ); for (const entry of entries) assert.equal(completedWorkflowSnapshot(backend, entry)?.stages[0]?.sessionFile, undefined); @@ -332,7 +354,7 @@ describe("completed durable catalog", () => { writeFileSync( path, [ - JSON.stringify({ type: "session", id: "structured-context" }), + JSON.stringify({ type: "session", id: testRunId("structured-context") }), JSON.stringify({ type: "message", id: "structured-context-message", @@ -341,10 +363,10 @@ describe("completed durable catalog", () => { }), ].join("\n"), ); - registerCompleted(backend, "structured-context"); + registerCompleted(backend, testRunId("structured-context")); backend.recordCheckpoint({ kind: "stage", - workflowId: "structured-context", + workflowId: testRunId("structured-context"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -354,7 +376,7 @@ describe("completed durable catalog", () => { assert.deepEqual( listOpenableCompletedWorkflows(backend).map((item) => item.workflowId), - ["structured-context"], + [testRunId("structured-context")], ); }); @@ -362,8 +384,8 @@ describe("completed durable catalog", () => { const backend = new InMemoryDurableBackend(); const transcript = join(tempDir, "nested-child.jsonl"); writeSessionTranscript(transcript, "nested-child-session"); - const runId = "completed-nested"; - const childRunId = "completed-nested-child"; + const runId = testRunId("completed-nested"); + const childRunId = testRunId("completed-nested-child"); backend.registerWorkflow({ workflowId: runId, name: "nested-root", @@ -435,7 +457,7 @@ describe("completed durable catalog", () => { version: 1 as const, stageId: "orphan", parentIds: [], - run: { ...childRun, runId: "old-child-run" }, + run: { ...childRun, runId: testRunId("old-child-run") }, }, }, { @@ -481,10 +503,10 @@ describe("completed durable catalog", () => { test("hides duplicate boundary-start records instead of inventing a child link", () => { const backend = new InMemoryDurableBackend(); - registerCompleted(backend, "duplicate-boundary"); - const rootRun = { runId: "duplicate-boundary", runName: "completed-flow" } as const; + registerCompleted(backend, testRunId("duplicate-boundary")); + const rootRun = { runId: testRunId("duplicate-boundary"), runName: "completed-flow" } as const; const child = { - runId: "duplicate-child", + runId: testRunId("duplicate-child"), runName: "child", parentRunId: "duplicate-boundary", parentStageId: "boundary", @@ -500,7 +522,7 @@ describe("completed durable catalog", () => { for (const checkpointId of ["boundary-start:a", "boundary-start:b"]) { backend.recordCheckpoint({ kind: "stage", - workflowId: "duplicate-boundary", + workflowId: testRunId("duplicate-boundary"), checkpointId, name: "workflow:child", replayKey: "workflow:child:1", @@ -518,20 +540,20 @@ describe("completed durable catalog", () => { } assert.equal(listOpenableCompletedWorkflows(backend).length, 0); - assert.equal(resolveCompletedWorkflow("duplicate-boundary", backend).kind, "stale"); + assert.equal(resolveCompletedWorkflow(testRunId("duplicate-boundary"), backend).kind, "stale"); assert.equal(completedWorkflowSnapshot(backend, listCompletedFromBackend(backend)[0]!), undefined); }); test("hides cyclic and duplicated stage topology", () => { const backend = new InMemoryDurableBackend(); - registerCompleted(backend, "cyclic"); + registerCompleted(backend, testRunId("cyclic")); for (const [stageId, parentIds] of [ ["a", ["b"]], ["b", ["a"]], ] as const) { backend.recordCheckpoint({ kind: "stage", - workflowId: "cyclic", + workflowId: testRunId("cyclic"), checkpointId: `stage:${stageId}`, name: stageId, replayKey: `stage:${stageId}:1`, @@ -541,15 +563,15 @@ describe("completed durable catalog", () => { version: 1, stageId, parentIds: [...parentIds], - run: { runId: "cyclic", runName: "completed-flow" }, + run: { runId: testRunId("cyclic"), runName: "completed-flow" }, }, }); } - registerCompleted(backend, "duplicate-stage-id"); + registerCompleted(backend, testRunId("duplicate-stage-id")); for (const replayKey of ["stage:one:1", "stage:two:1"]) { backend.recordCheckpoint({ kind: "stage", - workflowId: "duplicate-stage-id", + workflowId: testRunId("duplicate-stage-id"), checkpointId: replayKey, name: "shared", replayKey, @@ -559,22 +581,22 @@ describe("completed durable catalog", () => { version: 1, stageId: "shared-source-id", parentIds: [], - run: { runId: "duplicate-stage-id", runName: "completed-flow" }, + run: { runId: testRunId("duplicate-stage-id"), runName: "completed-flow" }, }, }); } assert.equal(listOpenableCompletedWorkflows(backend).length, 0); - assert.equal(resolveCompletedWorkflow("cyclic", backend).kind, "stale"); - assert.equal(resolveCompletedWorkflow("duplicate-stage-id", backend).kind, "stale"); + assert.equal(resolveCompletedWorkflow(testRunId("cyclic"), backend).kind, "stale"); + assert.equal(resolveCompletedWorkflow(testRunId("duplicate-stage-id"), backend).kind, "stale"); }); test("hides a boundary terminal that disagrees with its start identity", () => { const backend = new InMemoryDurableBackend(); - registerCompleted(backend, "mismatched-terminal"); - const rootRun = { runId: "mismatched-terminal", runName: "completed-flow" } as const; + registerCompleted(backend, testRunId("mismatched-terminal")); + const rootRun = { runId: testRunId("mismatched-terminal"), runName: "completed-flow" } as const; const child = { - runId: "child-from-start", + runId: testRunId("child-from-start"), runName: "child", parentRunId: "mismatched-terminal", parentStageId: "boundary", @@ -582,7 +604,7 @@ describe("completed durable catalog", () => { } as const; backend.recordCheckpoint({ kind: "stage", - workflowId: "mismatched-terminal", + workflowId: testRunId("mismatched-terminal"), checkpointId: "boundary-start:workflow:child:1", name: "workflow:child", replayKey: "workflow:child:1", @@ -607,13 +629,19 @@ describe("completed durable catalog", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "mismatched-terminal", + workflowId: testRunId("mismatched-terminal"), checkpointId: "boundary-terminal:workflow:child:1:completed", name: "workflow:child", replayKey: "workflow:child:1", completedAt: 21, endedAt: 21, - output: { workflow: "child", runId: "unrelated-child", status: "completed", exited: false, outputs: {} }, + output: { + workflow: "child", + runId: testRunId("unrelated-child"), + status: "completed", + exited: false, + outputs: {}, + }, topology: { version: 1, stageId: "boundary", @@ -628,18 +656,18 @@ describe("completed durable catalog", () => { alias: "child", workflow: "child", status: "completed", - child: { ...child, runId: "unrelated-child" }, + child: { ...child, runId: testRunId("unrelated-child") }, }, }, }); assert.equal(listOpenableCompletedWorkflows(backend).length, 0); - assert.equal(resolveCompletedWorkflow("mismatched-terminal", backend).kind, "stale"); + assert.equal(resolveCompletedWorkflow(testRunId("mismatched-terminal"), backend).kind, "stale"); }); test("marks a completed root stale when a current boundary start has no terminal", () => { const backend = new InMemoryDurableBackend(); - const runId = "unterminated-completed-root"; - const childRunId = "unterminated-child"; + const runId = testRunId("unterminated-completed-root"); + const childRunId = testRunId("unterminated-child"); const replayKey = "workflow:child:1"; registerCompleted(backend, runId); const rootRun = { runId, runName: "completed-flow" } as const; diff --git a/test/unit/durable-dbos-multi-session.test.ts b/test/unit/durable-dbos-multi-session.test.ts index dde6e6ae6..212c6ac3c 100644 --- a/test/unit/durable-dbos-multi-session.test.ts +++ b/test/unit/durable-dbos-multi-session.test.ts @@ -21,6 +21,7 @@ import { import { resumeDurableWorkflow } from "../../packages/workflows/src/durable/resume-runtime.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; +import { testRunId } from "../helpers/run-id.js"; interface SharedDbosState { readonly workflows: Map; @@ -142,7 +143,7 @@ describe("cross-process hydration of ordinary run checkpoints", () => { const state: SharedDbosState = { workflows: new Map(), steps: new Map() }; const writer = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-a" }); writer.registerWorkflow({ - workflowId: "wf-completed-run", + workflowId: testRunId("wf-completed-run"), name: "multi-session-flow", inputs: {}, createdAt: 1, @@ -152,7 +153,7 @@ describe("cross-process hydration of ordinary run checkpoints", () => { // WITHOUT topology; the durable record must still be current-format. await writer.recordCheckpointAsync({ kind: "stage", - workflowId: "wf-completed-run", + workflowId: testRunId("wf-completed-run"), checkpointId: "stage-session:stage:task:echo:1:h123", name: "echo", replayKey: "stage:task:echo:1", @@ -162,28 +163,28 @@ describe("cross-process hydration of ordinary run checkpoints", () => { }); await writer.recordCheckpointAsync({ kind: "stage", - workflowId: "wf-completed-run", + workflowId: testRunId("wf-completed-run"), checkpointId: "task:stage:task:echo:1", name: "echo", replayKey: "stage:task:echo:1", output: { text: "pong" }, completedAt: 11, }); - writer.setWorkflowStatus("wf-completed-run", "completed"); + writer.setWorkflowStatus(testRunId("wf-completed-run"), "completed"); await writer.flush(); const fresh = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-b" }); await fresh.hydrateResumableWorkflows(); - const handle = fresh.getWorkflow("wf-completed-run"); + const handle = fresh.getWorkflow(testRunId("wf-completed-run")); assert.equal(handle?.status, "completed"); - assert.equal(fresh.isWorkflowLoadable("wf-completed-run"), true); - assert.equal(fresh.listCheckpoints("wf-completed-run").length, 2); + assert.equal(fresh.isWorkflowLoadable(testRunId("wf-completed-run")), true); + assert.equal(fresh.listCheckpoints(testRunId("wf-completed-run")).length, 2); assert.deepEqual( fresh.listCompletedWorkflows().map((entry) => entry.workflowId), - ["wf-completed-run"], + [testRunId("wf-completed-run")], ); - assert.equal(fresh.getStageOutput("wf-completed-run", "stage:task:echo:1") !== undefined, true); + assert.equal(fresh.getStageOutput(testRunId("wf-completed-run"), "stage:task:echo:1") !== undefined, true); }); }); @@ -192,7 +193,7 @@ describe("running workflows are never resume targets", () => { const state: SharedDbosState = { workflows: new Map(), steps: new Map() }; const owner = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-a" }); owner.registerWorkflow({ - workflowId: "wf-own-running", + workflowId: testRunId("wf-own-running"), name: "multi-session-flow", inputs: {}, createdAt: 1, @@ -202,41 +203,41 @@ describe("running workflows are never resume targets", () => { await owner.flush(); assert.deepEqual(owner.listResumableWorkflows(), []); - assert.equal(owner.getWorkflow("wf-own-running")?.status, "running"); + assert.equal(owner.getWorkflow(testRunId("wf-own-running"))?.status, "running"); }); }); describe("shared-database visibility across sessions", () => { test("hides another session's live running workflow from resume, surfaces it once its heartbeat ages out", async () => { const state: SharedDbosState = { workflows: new Map(), steps: new Map() }; - state.workflows.set("wf-live-elsewhere", { - workflowId: "wf-live-elsewhere", + state.workflows.set(testRunId("wf-live-elsewhere"), { + workflowId: testRunId("wf-live-elsewhere"), name: "multi-session-flow", status: "PENDING", createdAt: 1_000, }); state.steps.set( - "wf-live-elsewhere:checkpoint:__atomic_metadata:9000000000001:seed", - seededMetadata("wf-live-elsewhere", { ownerExecutorId: "atomic-other-1", updatedAt: Date.now() }), + `${testRunId("wf-live-elsewhere")}:checkpoint:__atomic_metadata:9000000000001:seed`, + seededMetadata(testRunId("wf-live-elsewhere"), { ownerExecutorId: "atomic-other-1", updatedAt: Date.now() }), ); const observer = new DbosDurableBackend(createSharedSdk(state)); await observer.hydrateResumableWorkflows(); assert.deepEqual(observer.listResumableWorkflows(), []); - assert.equal(observer.getWorkflow("wf-live-elsewhere")?.ownerExecutorId, "atomic-other-1"); + assert.equal(observer.getWorkflow(testRunId("wf-live-elsewhere"))?.ownerExecutorId, "atomic-other-1"); // The owning session crashes: nothing rewrites metadata and the recorded // heartbeat simply ages beyond the liveness window. const crashed: SharedDbosState = { workflows: new Map(), steps: new Map() }; - crashed.workflows.set("wf-crashed-elsewhere", { - workflowId: "wf-crashed-elsewhere", + crashed.workflows.set(testRunId("wf-crashed-elsewhere"), { + workflowId: testRunId("wf-crashed-elsewhere"), name: "multi-session-flow", status: "PENDING", createdAt: 1_000, }); crashed.steps.set( - "wf-crashed-elsewhere:checkpoint:__atomic_metadata:9000000000001:seed", - seededMetadata("wf-crashed-elsewhere", { - workflowId: "wf-crashed-elsewhere", + `${testRunId("wf-crashed-elsewhere")}:checkpoint:__atomic_metadata:9000000000001:seed`, + seededMetadata(testRunId("wf-crashed-elsewhere"), { + workflowId: testRunId("wf-crashed-elsewhere"), ownerExecutorId: "atomic-other-1", updatedAt: Date.now() - FOREIGN_LIVE_WORKFLOW_WINDOW_MS - 1, }), @@ -245,14 +246,14 @@ describe("shared-database visibility across sessions", () => { await recoverer.hydrateResumableWorkflows(); assert.deepEqual( recoverer.listResumableWorkflows().map((entry) => entry.workflowId), - ["wf-crashed-elsewhere"], + [testRunId("wf-crashed-elsewhere")], ); }); test("resume refuses a workflow actively running in another session with an actionable message", async () => { const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ - workflowId: "wf-foreign-live", + workflowId: testRunId("wf-foreign-live"), name: "multi-session-flow", inputs: {}, createdAt: 1, @@ -261,7 +262,7 @@ describe("shared-database visibility across sessions", () => { ownerExecutorId: "atomic-other-1", }); - const result = await resumeDurableWorkflow("wf-foreign-live", { + const result = await resumeDurableWorkflow(testRunId("wf-foreign-live"), { registry: { register: () => { throw new Error("unused"); @@ -291,7 +292,7 @@ describe("cross-process resume claim", () => { const state: SharedDbosState = { workflows: new Map(), steps: new Map() }; const seeder = new DbosDurableBackend(createSharedSdk(state)); seeder.registerWorkflow({ - workflowId: "wf-contended", + workflowId: testRunId("wf-contended"), name: "multi-session-flow", inputs: {}, createdAt: 1, @@ -306,12 +307,12 @@ describe("cross-process resume claim", () => { const state = await pausedWorkflowState(); const sessionA = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-a" }); const sessionB = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-b" }); - await sessionA.hydrateWorkflow("wf-contended"); - await sessionB.hydrateWorkflow("wf-contended"); + await sessionA.hydrateWorkflow(testRunId("wf-contended")); + await sessionB.hydrateWorkflow(testRunId("wf-contended")); const outcomes = await Promise.all([ - sessionA.transitionWorkflowStatus("wf-contended", ["paused"], "running"), - sessionB.transitionWorkflowStatus("wf-contended", ["paused"], "running"), + sessionA.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), + sessionB.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), ]); assert.deepEqual([...outcomes].sort(), [false, true]); @@ -320,12 +321,12 @@ describe("cross-process resume claim", () => { const state = await pausedWorkflowState(); const sessionA = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-shared" }); const sessionB = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-shared" }); - await sessionA.hydrateWorkflow("wf-contended"); - await sessionB.hydrateWorkflow("wf-contended"); + await sessionA.hydrateWorkflow(testRunId("wf-contended")); + await sessionB.hydrateWorkflow(testRunId("wf-contended")); const outcomes = await Promise.all([ - sessionA.transitionWorkflowStatus("wf-contended", ["paused"], "running"), - sessionB.transitionWorkflowStatus("wf-contended", ["paused"], "running"), + sessionA.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), + sessionB.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), ]); assert.deepEqual([...outcomes].sort(), [false, true]); @@ -334,12 +335,12 @@ describe("cross-process resume claim", () => { const state = await pausedWorkflowState(); const runner = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-runner" }); const blocker = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-blocker" }); - await runner.hydrateWorkflow("wf-contended"); - await blocker.hydrateWorkflow("wf-contended"); + await runner.hydrateWorkflow(testRunId("wf-contended")); + await blocker.hydrateWorkflow(testRunId("wf-contended")); const outcomes = await Promise.all([ - runner.transitionWorkflowStatus("wf-contended", ["paused"], "running"), - blocker.transitionWorkflowStatus("wf-contended", ["paused"], "blocked"), + runner.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), + blocker.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "blocked"), ]); assert.deepEqual([...outcomes].sort(), [false, true]); @@ -349,35 +350,35 @@ describe("cross-process resume claim", () => { const state = await pausedWorkflowState(); const winner = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-a" }); const loser = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-b" }); - await winner.hydrateWorkflow("wf-contended"); - await loser.hydrateWorkflow("wf-contended"); + await winner.hydrateWorkflow(testRunId("wf-contended")); + await loser.hydrateWorkflow(testRunId("wf-contended")); - assert.equal(await winner.transitionWorkflowStatus("wf-contended", ["paused"], "running"), true); - assert.equal(await loser.transitionWorkflowStatus("wf-contended", ["paused"], "running"), false); - assert.equal(loser.getWorkflow("wf-contended")?.status, "running"); + assert.equal(await winner.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), true); + assert.equal(await loser.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), false); + assert.equal(loser.getWorkflow(testRunId("wf-contended"))?.status, "running"); }); test("a crashed winner cannot wedge the generation: its claim is valid metadata", async () => { const state = await pausedWorkflowState(); // Session A wins the claim but "crashes" before dispatching its write. const crasher = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-a" }); - await crasher.hydrateWorkflow("wf-contended"); - assert.equal(await crasher.transitionWorkflowStatus("wf-contended", ["paused"], "running"), true); + await crasher.hydrateWorkflow(testRunId("wf-contended")); + assert.equal(await crasher.transitionWorkflowStatus(testRunId("wf-contended"), ["paused"], "running"), true); // A fresh session hydrates the claim as authoritative running metadata // owned by the dead executor — recoverable via heartbeat staleness, and // the workflow remains loadable rather than suppressed. const fresh = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-session-b" }); - await fresh.hydrateWorkflow("wf-contended"); - assert.equal(fresh.getWorkflow("wf-contended")?.status, "running"); - assert.equal(fresh.getWorkflow("wf-contended")?.ownerExecutorId, "atomic-session-a"); - assert.equal(fresh.isWorkflowLoadable("wf-contended"), true); + await fresh.hydrateWorkflow(testRunId("wf-contended")); + assert.equal(fresh.getWorkflow(testRunId("wf-contended"))?.status, "running"); + assert.equal(fresh.getWorkflow(testRunId("wf-contended"))?.ownerExecutorId, "atomic-session-a"); + assert.equal(fresh.isWorkflowLoadable(testRunId("wf-contended")), true); }); test("a non-resumable paused reservation rolls back to blocked across hydration", async () => { const state: SharedDbosState = { workflows: new Map(), steps: new Map() }; const seeder = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-seeder" }); seeder.registerWorkflow({ - workflowId: "wf-reservation", + workflowId: testRunId("wf-reservation"), name: "multi-session-flow", inputs: {}, createdAt: 1, @@ -387,30 +388,30 @@ describe("cross-process resume claim", () => { }); await seeder.flush(); const claimant = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-claimant" }); - await claimant.hydrateWorkflow("wf-reservation"); + await claimant.hydrateWorkflow(testRunId("wf-reservation")); assert.equal( - await claimant.transitionWorkflowStatus("wf-reservation", ["blocked"], "paused", undefined, false), + await claimant.transitionWorkflowStatus(testRunId("wf-reservation"), ["blocked"], "paused", undefined, false), true, ); assert.equal(claimant.listResumableWorkflows().length, 0); const reserved = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-observer" }); - await reserved.hydrateWorkflow("wf-reservation"); - assert.equal(reserved.getWorkflow("wf-reservation")?.status, "paused"); - assert.equal(reserved.getWorkflow("wf-reservation")?.resumable, false); + await reserved.hydrateWorkflow(testRunId("wf-reservation")); + assert.equal(reserved.getWorkflow(testRunId("wf-reservation"))?.status, "paused"); + assert.equal(reserved.getWorkflow(testRunId("wf-reservation"))?.resumable, false); assert.equal(reserved.listResumableWorkflows().length, 0); assert.equal( - await claimant.transitionWorkflowStatus("wf-reservation", ["paused"], "blocked", undefined, true), + await claimant.transitionWorkflowStatus(testRunId("wf-reservation"), ["paused"], "blocked", undefined, true), true, ); const fresh = new DbosDurableBackend(createSharedSdk(state), { executorId: "atomic-fresh" }); - await fresh.hydrateWorkflow("wf-reservation"); - assert.equal(fresh.getWorkflow("wf-reservation")?.status, "blocked"); - assert.equal(fresh.getWorkflow("wf-reservation")?.resumable, true); + await fresh.hydrateWorkflow(testRunId("wf-reservation")); + assert.equal(fresh.getWorkflow(testRunId("wf-reservation"))?.status, "blocked"); + assert.equal(fresh.getWorkflow(testRunId("wf-reservation"))?.resumable, true); assert.deepEqual( fresh.listResumableWorkflows().map((run) => run.workflowId), - ["wf-reservation"], + [testRunId("wf-reservation")], ); }); }); diff --git a/test/unit/durable-nested-completed-chat.test.ts b/test/unit/durable-nested-completed-chat.test.ts index d079e1a33..36e5e2697 100644 --- a/test/unit/durable-nested-completed-chat.test.ts +++ b/test/unit/durable-nested-completed-chat.test.ts @@ -10,6 +10,7 @@ import type { DurableBoundaryChildTopology, DurableStageTopology } from "../../p import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; @@ -134,13 +135,13 @@ test("fresh DBOS root-only catalog opens nested post-mortem chat detached from e const tempDir = mkdtempSync(join(tmpdir(), "atomic-nested-chat-")); try { const fixture: ChatFixture = { - rootId: "completed-chat-root", - childId: "completed-chat-child", + rootId: testRunId("completed-chat-root"), + childId: testRunId("completed-chat-child"), boundaryId: "completed-chat-boundary", sessionFile: createTranscript(tempDir, "nested-chat"), output: { workflow: "chat-child", - runId: "completed-chat-child", + runId: testRunId("completed-chat-child"), status: "completed", exited: false, outputs: {}, @@ -217,13 +218,13 @@ test("fresh DBOS hides malformed completed child output and registers no chat at const tempDir = mkdtempSync(join(tmpdir(), "atomic-malformed-chat-")); try { const fixture: ChatFixture = { - rootId: "malformed-chat-root", - childId: "malformed-chat-child", + rootId: testRunId("malformed-chat-root"), + childId: testRunId("malformed-chat-child"), boundaryId: "malformed-chat-boundary", sessionFile: createTranscript(tempDir, "malformed-chat"), output: { workflow: "chat-child", - runId: "malformed-chat-child", + runId: testRunId("malformed-chat-child"), status: "completed", outputs: {}, }, @@ -262,8 +263,7 @@ test("fresh DBOS hides malformed completed child output and registers no chat at assert.deepEqual(opened, { ok: false, reason: "stale", - message: - "Completed workflow malforme is stale or missing durable checkpoint/session data and cannot be opened.", + message: `Completed workflow ${fixture.rootId} is stale or missing durable checkpoint/session data and cannot be opened.`, }); assert.deepEqual(freshStore.runs(), []); assert.equal(registry.get(fixture.childId, "retained-child-stage"), undefined); diff --git a/test/unit/durable-occurrence-upgrade.test.ts b/test/unit/durable-occurrence-upgrade.test.ts index cb5e2d40d..626a36939 100644 --- a/test/unit/durable-occurrence-upgrade.test.ts +++ b/test/unit/durable-occurrence-upgrade.test.ts @@ -21,10 +21,11 @@ import { createStore, store as workflowStore } from "../../packages/workflows/sr import type { StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; import { sleep } from "../helpers/runtime.js"; import { createMockSdk, seedMockCheckpoint, seedMockWorkflow } from "./durable-dbos-backend-helpers.js"; -const ROOT_ID = "prompt-occurrence-upgrade-root"; +const ROOT_ID = testRunId("prompt-occurrence-upgrade-root"); const SELECT_MESSAGE = " Preserve this prompt exactly?\nSecond line. "; const SELECT_OPTIONS = [" first ", "second\nvalue", "third "] as const; const SELECT_RESPONSE = SELECT_OPTIONS[1]; @@ -272,7 +273,7 @@ test.sequential("fresh DBOS replay coalesces an omitted occurrenceKey prompt wit }); test("fresh DBOS active root rejects an occurrence bound to different replay keys before prompt replay", async () => { - const rootId = "prompt-occurrence-active-poison"; + const rootId = testRunId("prompt-occurrence-active-poison"); const sdk = createMockSdk(); seedMockWorkflow(sdk, { workflowId: rootId, name: rootId, status: "PENDING", createdAt: 1 }); for (const [index, replayKey] of ["prompt:input:first", "prompt:input:SECOND-DIFFERENT"].entries()) { diff --git a/test/unit/durable-pending-nested-ui-resume.test.ts b/test/unit/durable-pending-nested-ui-resume.test.ts index 9cf33b947..50d395c75 100644 --- a/test/unit/durable-pending-nested-ui-resume.test.ts +++ b/test/unit/durable-pending-nested-ui-resume.test.ts @@ -20,10 +20,11 @@ import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expande import { createStore, store as workflowStore } from "../../packages/workflows/src/shared/store.js"; import type { StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; import { sleep } from "../helpers/runtime.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; -const ROOT_ID = "pending-nested-ui-root"; +const ROOT_ID = testRunId("pending-nested-ui-root"); afterEach(() => { setDurableBackend(undefined); diff --git a/test/unit/durable-repeated-prompt-resume.test.ts b/test/unit/durable-repeated-prompt-resume.test.ts index d18105d68..cd8d0e567 100644 --- a/test/unit/durable-repeated-prompt-resume.test.ts +++ b/test/unit/durable-repeated-prompt-resume.test.ts @@ -20,6 +20,7 @@ import { createStore, store as workflowStore } from "../../packages/workflows/sr import type { StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import type { WorkflowDefinition } from "../../packages/workflows/src/shared/types.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; import { sleep } from "../helpers/runtime.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; @@ -210,7 +211,7 @@ async function answerFresh(rootId: string, childRunId: string, stage: StageSnaps } test.sequential("fresh nested resume keeps sequential identical same-callsite prompt occurrences distinct", async () => { - const rootId = "repeated-sequential-root"; + const rootId = testRunId("repeated-sequential-root"); const sdk = createMockSdk(); const writer = new DbosDurableBackend(sdk, { executorId: "repeated-sequential-writer" }); const writerStore = createStore(); @@ -352,7 +353,7 @@ test.sequential("fresh nested resume keeps sequential identical same-callsite pr }); test.sequential("fresh nested resume restores and independently answers two concurrent identical prompts", async () => { - const rootId = "repeated-concurrent-root"; + const rootId = testRunId("repeated-concurrent-root"); const sdk = createMockSdk(); const writer = new DbosDurableBackend(sdk, { executorId: "repeated-concurrent-writer" }); const writerStore = createStore(); diff --git a/test/unit/durable-resume-flush-retry.test.ts b/test/unit/durable-resume-flush-retry.test.ts index e5c2da119..e3d17eded 100644 --- a/test/unit/durable-resume-flush-retry.test.ts +++ b/test/unit/durable-resume-flush-retry.test.ts @@ -14,6 +14,7 @@ import { stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore, type Store, store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; class FlushTrackingBackend extends InMemoryDurableBackend { flushCalls = 0; @@ -26,7 +27,7 @@ class FlushTrackingBackend extends InMemoryDurableBackend { async flush(): Promise { this.flushCalls += 1; if (this.flushCalls <= this.failures) throw new Error("transient durable flush failure"); - this.durableStatus = this.getWorkflow("flush-retry")?.status ?? "missing"; + this.durableStatus = this.getWorkflow(testRunId("flush-retry"))?.status ?? "missing"; } } @@ -86,7 +87,7 @@ afterEach(() => { describe("durable-running asynchronous flush retry", () => { delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; test("primitive retries a transient failed flush after local state is already running", async () => { - const runId = "flush-retry"; + const runId = testRunId("flush-retry"); const backend = new FlushTrackingBackend(1); const activeStore = createStore(); const registry = createStageControlRegistry(); @@ -105,7 +106,7 @@ describe("durable-running asynchronous flush retry", () => { }); test("primitive keeps reporting partial while the durable flush continues failing", async () => { - const runId = "flush-retry"; + const runId = testRunId("flush-retry"); const backend = new FlushTrackingBackend(Number.POSITIVE_INFINITY); const activeStore = createStore(); const registry = createStageControlRegistry(); @@ -122,7 +123,7 @@ describe("durable-running asynchronous flush retry", () => { }); test.sequential("workflow tool retries a pending durable transition instead of reporting snapshot success", async () => { - const runId = "flush-retry"; + const runId = testRunId("flush-retry"); const backend = new FlushTrackingBackend(1); setDurableBackend(backend); seedPausedRun(runId, backend, store, stageControlRegistry); @@ -143,7 +144,7 @@ describe("durable-running asynchronous flush retry", () => { }); test.sequential("slash resume retries a pending durable transition instead of saying already running", async () => { - const runId = "flush-retry"; + const runId = testRunId("flush-retry"); const backend = new FlushTrackingBackend(1); setDurableBackend(backend); seedPausedRun(runId, backend, store, stageControlRegistry); diff --git a/test/unit/durable-resume-runtime.test.ts b/test/unit/durable-resume-runtime.test.ts index 861f503e9..2b468fa85 100644 --- a/test/unit/durable-resume-runtime.test.ts +++ b/test/unit/durable-resume-runtime.test.ts @@ -26,6 +26,7 @@ import { createJobTracker } from "../../packages/workflows/src/runs/background/j import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowDefinition } from "../../packages/workflows/src/shared/types.js"; import type { WorkflowRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; function makeEntry(workflowId: string, name: string, status: ResumableWorkflowEntry["status"]): ResumableWorkflowEntry { return { @@ -41,30 +42,43 @@ function makeEntry(workflowId: string, name: string, status: ResumableWorkflowEn describe("resolveDurableEntry", () => { const catalog: readonly ResumableWorkflowEntry[] = [ - makeEntry("wf-aaa-001", "alpha", "running"), - makeEntry("wf-bbb-002", "beta", "paused"), + makeEntry(testRunId("wf-aaa-001"), "alpha", "running"), + makeEntry(testRunId("wf-bbb-002"), "beta", "paused"), ]; - test("exact id match", async () => { - const r = resolveDurableEntry("wf-aaa-001", catalog); + test("exact full id match", async () => { + const fullId = testRunId("wf-aaa-001"); + const r = resolveDurableEntry(fullId, catalog); assert.ok(r && !("kind" in r)); - assert.equal(r!.workflowId, "wf-aaa-001"); + assert.equal(r!.workflowId, fullId); }); - test("unique prefix match", async () => { - const r = resolveDurableEntry("wf-aaa", catalog); - assert.ok(r && !("kind" in r)); - assert.equal(r!.workflowId, "wf-aaa-001"); + test("rejects a unique prefix and accepts the full id", async () => { + const fullId = testRunId("wf-aaa-001"); + const malformed = resolveDurableEntry(fullId.slice(0, 8), catalog); + assert.ok(malformed && "kind" in malformed && malformed.kind === "malformed"); + assert.match(malformed.message, /full 36-character UUID/); + const exact = resolveDurableEntry(fullId, catalog); + assert.ok(exact && !("kind" in exact)); + assert.equal(exact.workflowId, fullId); }); - test("ambiguous prefix", async () => { - const r = resolveDurableEntry("wf-", catalog); - assert.ok(r && "kind" in r && r.kind === "ambiguous"); - assert.equal(r.matches.length, 2); + test("rejects a shared prefix while full ids remain independently addressable", async () => { + const firstId = testRunId("ambiguous-first"); + const secondId = `${firstId.slice(0, 8)}-${testRunId("ambiguous-second").slice(9)}`; + const sharedCatalog = [makeEntry(firstId, "alpha", "running"), makeEntry(secondId, "beta", "paused")]; + const malformed = resolveDurableEntry(firstId.slice(0, 8), sharedCatalog); + assert.ok(malformed && "kind" in malformed && malformed.kind === "malformed"); + const first = resolveDurableEntry(firstId, sharedCatalog); + const second = resolveDurableEntry(secondId, sharedCatalog); + assert.ok(first && !("kind" in first)); + assert.ok(second && !("kind" in second)); + assert.equal(first.workflowId, firstId); + assert.equal(second.workflowId, secondId); }); test("no match returns undefined", async () => { - assert.equal(resolveDurableEntry("wf-zzz", catalog), undefined); + assert.equal(resolveDurableEntry(testRunId("wf-zzz"), catalog), undefined); }); }); @@ -117,20 +131,22 @@ describe("resumeDurableWorkflow", () => { } test("returns not_registered when id is unknown", async () => { - const result = await resumeDurableWorkflow("wf-does-not-exist", deps()); + const unknownId = testRunId("wf-does-not-exist"); + const result = await resumeDurableWorkflow(unknownId, deps()); assert.equal(result.ok, false); assert.equal(result.reason, "not_registered"); }); - test("resolves a unique prefix before exact-id loadability checks", async () => { + test("rejects a unique prefix before exact-id loadability checks", async () => { class ExactOnlyLoadableBackend extends InMemoryDurableBackend { override isWorkflowLoadable(workflowId: string): boolean { return this.getWorkflow(workflowId) !== undefined; } } const exactBackend = new ExactOnlyLoadableBackend(); + const fullId = testRunId("wf-prefix-current"); exactBackend.registerWorkflow({ - workflowId: "wf-prefix-current", + workflowId: fullId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, @@ -138,15 +154,18 @@ describe("resumeDurableWorkflow", () => { completedCheckpoints: 1, }); - const result = await resumeDurableWorkflow("wf-prefix", { ...deps(), durableBackend: exactBackend }); + const result = await resumeDurableWorkflow(fullId.slice(0, 8), { ...deps(), durableBackend: exactBackend }); - assert.equal(result.ok, true); - if (result.ok) assert.equal(result.workflowId, "wf-prefix-current"); + assert.equal(result.ok, false); + assert.equal(result.reason, "not_registered"); + assert.match(result.message, /full 36-character UUID/); }); - test("returns ambiguous when prefix matches multiple", async () => { + test("rejects a shared prefix while full workflow ids remain independently addressable", async () => { + const firstId = testRunId("ambiguous-first"); + const secondId = `${firstId.slice(0, 8)}-${testRunId("ambiguous-second").slice(9)}`; backend.registerWorkflow({ - workflowId: "wf-x-1", + workflowId: firstId, name: "resumable-pipeline", inputs: { topic: "a" }, createdAt: 1, @@ -154,22 +173,29 @@ describe("resumeDurableWorkflow", () => { completedCheckpoints: 1, }); backend.registerWorkflow({ - workflowId: "wf-x-2", + workflowId: secondId, name: "resumable-pipeline", inputs: { topic: "b" }, createdAt: 1, status: "paused", completedCheckpoints: 1, }); - const result = await resumeDurableWorkflow("wf-x", deps()); + const result = await resumeDurableWorkflow(firstId.slice(0, 8), deps()); assert.equal(result.ok, false); assert.equal(result.reason, "not_registered"); - assert.match(result.message, /Ambiguous/); + assert.match(result.message, /full 36-character UUID/); + const first = resolveDurableEntry(firstId, backend.listResumableWorkflows()); + const second = resolveDurableEntry(secondId, backend.listResumableWorkflows()); + assert.ok(first && !("kind" in first)); + assert.ok(second && !("kind" in second)); + assert.equal(first.workflowId, firstId); + assert.equal(second.workflowId, secondId); }); test("returns not_resumable when status is completed", async () => { + const workflowId = testRunId("wf-done-1"); backend.registerWorkflow({ - workflowId: "wf-done-1", + workflowId, name: "resumable-pipeline", inputs: { topic: "a" }, createdAt: 1, @@ -177,44 +203,47 @@ describe("resumeDurableWorkflow", () => { }); // Pass an explicit catalog containing the completed entry (the backend's // resumable list would filter it out) to exercise the not_resumable branch. - const catalog = [makeEntry("wf-done-1", "resumable-pipeline", "completed")]; - const result = await resumeDurableWorkflow("wf-done-1", deps(), catalog); + const catalog = [makeEntry(workflowId, "resumable-pipeline", "completed")]; + const result = await resumeDurableWorkflow(workflowId, deps(), catalog); assert.equal(result.ok, false); assert.equal(result.reason, "not_resumable"); }); test("rejects when authoritative backend state is ineligible despite stale catalog progress", async () => { + const workflowId = testRunId("wf-zero-progress"); backend.registerWorkflow({ - workflowId: "wf-zero-progress", + workflowId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, status: "paused", }); - const staleCatalog = [makeEntry("wf-zero-progress", "resumable-pipeline", "paused")]; + const staleCatalog = [makeEntry(workflowId, "resumable-pipeline", "paused")]; - const result = await resumeDurableWorkflow("wf-zero-progress", deps(), staleCatalog); + const result = await resumeDurableWorkflow(workflowId, deps(), staleCatalog); assert.equal(result.ok, false); assert.equal(result.reason, "not_resumable"); - assert.equal(backend.getWorkflow("wf-zero-progress")?.status, "paused"); + assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); }); test("returns workflow_not_found when definition is missing", async () => { + const workflowId = testRunId("wf-ghost-1"); backend.registerWorkflow({ - workflowId: "wf-ghost-1", + workflowId, name: "missing-workflow", inputs: {}, createdAt: 1, status: "paused", completedCheckpoints: 1, }); - const result = await resumeDurableWorkflow("wf-ghost-1", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, false); assert.equal(result.reason, "workflow_not_found"); }); test("rediscovers an on-the-fly project workflow from its persisted invocation cwd", async () => { + const workflowId = testRunId("wf-reloaded-project"); const definition = makeDef(); const conflicting = workflow({ name: definition.name, @@ -224,7 +253,7 @@ describe("resumeDurableWorkflow", () => { run: async () => ({ wrong: true }), }) as unknown as WorkflowDefinition; backend.registerWorkflow({ - workflowId: "wf-reloaded-project", + workflowId, name: definition.name, inputs: { topic: "fresh" }, createdAt: 1, @@ -233,7 +262,7 @@ describe("resumeDurableWorkflow", () => { invocationCwd: "/persisted/project", }); let resolvedCwd: string | undefined; - const result = await resumeDurableWorkflow("wf-reloaded-project", { + const result = await resumeDurableWorkflow(workflowId, { ...deps(), registry: makeRegistryWith(conflicting), resolveDefinition: async (name, cwd) => { @@ -243,60 +272,78 @@ describe("resumeDurableWorkflow", () => { }); assert.equal(result.ok, true); assert.equal(resolvedCwd, "/persisted/project"); - await jobs.get("wf-reloaded-project")?.promise; + await jobs.get(workflowId)?.promise; }); test("returns invalid_inputs when cached inputs fail schema validation", async () => { + const workflowId = testRunId("wf-bad-in-1"); backend.registerWorkflow({ - workflowId: "wf-bad-in-1", + workflowId, name: "resumable-pipeline", inputs: {}, createdAt: 1, status: "paused", completedCheckpoints: 1, }); - const result = await resumeDurableWorkflow("wf-bad-in-1", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, false); assert.equal(result.reason, "invalid_inputs"); }); test("successfully re-dispatches with the ORIGINAL workflow id", async () => { + const workflowId = testRunId("wf-resume-target"); backend.registerWorkflow({ - workflowId: "wf-resume-target", + workflowId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, status: "failed", }); - const result = await resumeDurableWorkflow("wf-resume-target", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, true); if (result.ok) { - assert.equal(result.workflowId, "wf-resume-target"); - assert.equal(result.runId, "wf-resume-target"); // runId == original workflowId for replay + assert.equal(result.workflowId, workflowId); + assert.equal(result.runId, workflowId); // runId == original workflowId for replay assert.match(result.message, /Resuming durable workflow/); } // Backend status flipped back to running. - assert.equal(backend.getWorkflow("wf-resume-target")!.status, "running"); + assert.equal(backend.getWorkflow(workflowId)!.status, "running"); + }); + test("includes the full workflow id in a successful resume message", async () => { + const workflowId = "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f"; + backend.registerWorkflow({ + workflowId, + name: "resumable-pipeline", + inputs: { topic: "data" }, + createdAt: 1, + status: "failed", + }); + const result = await resumeDurableWorkflow(workflowId, deps()); + assert.equal(result.ok, true); + if (result.ok) assert.ok(result.message.includes(workflowId)); + await jobs.get(workflowId)?.promise; }); test("resume succeeds when the backend has durable checkpoint state for the workflow", async () => { + const workflowId = testRunId("wf-has-state"); backend.registerWorkflow({ - workflowId: "wf-has-state", + workflowId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, status: "paused", completedCheckpoints: 1, }); - const result = await resumeDurableWorkflow("wf-has-state", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, true); - if (result.ok) assert.equal(result.runId, "wf-has-state"); - assert.equal(backend.getWorkflow("wf-has-state")?.status, "running"); + if (result.ok) assert.equal(result.runId, workflowId); + assert.equal(backend.getWorkflow(workflowId)?.status, "running"); }); test("resume refuses only when a running handle has an active live run in this session", async () => { + const workflowId = testRunId("wf-active"); backend.registerWorkflow({ - workflowId: "wf-active", + workflowId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, @@ -305,20 +352,20 @@ describe("resumeDurableWorkflow", () => { }); // No live run → crash recovery: resume is allowed even though the durable // handle says `running`. - let result = await resumeDurableWorkflow("wf-active", deps()); + let result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, true); // With an active live run in this session, resume is refused. - backend.setWorkflowStatus("wf-active", "running"); + backend.setWorkflowStatus(workflowId, "running"); store.recordRunStart({ - id: "wf-active", + id: workflowId, name: "resumable-pipeline", inputs: {}, status: "running", stages: [], startedAt: 1, }); - result = await resumeDurableWorkflow("wf-active", deps()); + result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, false); if (!result.ok) { assert.equal(result.reason, "not_resumable"); @@ -327,12 +374,14 @@ describe("resumeDurableWorkflow", () => { assert.match(result.message, /\/workflow quit/); assert.doesNotMatch(result.message, /\/workflow kill/); } - store.removeRun("wf-active"); + store.removeRun(workflowId); }); test("running and paused workflows are both resumable at the catalog level", async () => { + const runningId = testRunId("wf-running"); + const pausedId = testRunId("wf-paused"); backend.registerWorkflow({ - workflowId: "wf-running", + workflowId: runningId, name: "resumable-pipeline", inputs: {}, createdAt: 1, @@ -340,7 +389,7 @@ describe("resumeDurableWorkflow", () => { completedCheckpoints: 1, }); backend.registerWorkflow({ - workflowId: "wf-paused", + workflowId: pausedId, name: "resumable-pipeline", inputs: {}, createdAt: 1, @@ -348,13 +397,14 @@ describe("resumeDurableWorkflow", () => { completedCheckpoints: 1, }); const ids = backend.listResumableWorkflows().map((e) => e.workflowId); - assert.ok(ids.includes("wf-running"), "running is resumable (crash recovery)"); - assert.ok(ids.includes("wf-paused")); + assert.ok(ids.includes(runningId), "running is resumable (crash recovery)"); + assert.ok(ids.includes(pausedId)); }); test("resume removes stale quit store shadow before reusing workflow id", async () => { + const workflowId = testRunId("wf-shadow"); backend.registerWorkflow({ - workflowId: "wf-shadow", + workflowId, name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, @@ -362,7 +412,7 @@ describe("resumeDurableWorkflow", () => { completedCheckpoints: 1, }); store.recordRunStart({ - id: "wf-shadow", + id: workflowId, name: "stale-shadow", inputs: {}, status: "paused", @@ -372,28 +422,29 @@ describe("resumeDurableWorkflow", () => { resumable: true, }); - const result = await resumeDurableWorkflow("wf-shadow", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, true); - const matching = store.runs().filter((run) => run.id === "wf-shadow"); + const matching = store.runs().filter((run) => run.id === workflowId); assert.equal(matching.length, 1); assert.equal(matching[0]?.name, "resumable-pipeline"); }); test("successful resume result includes runId for overlay connection (issue #1498)", async () => { + const workflowId = testRunId("wf-overlay-connect"); backend.registerWorkflow({ - workflowId: "wf-overlay-connect", + workflowId, name: "resumable-pipeline", inputs: { topic: "overlay" }, createdAt: 1, status: "failed", }); - const result = await resumeDurableWorkflow("wf-overlay-connect", deps()); + const result = await resumeDurableWorkflow(workflowId, deps()); assert.equal(result.ok, true); if (result.ok) { // runId is the original workflow id so the overlay connects to the // re-dispatched run. - assert.equal(result.runId, "wf-overlay-connect"); - assert.equal(result.workflowId, "wf-overlay-connect"); + assert.equal(result.runId, workflowId); + assert.equal(result.workflowId, workflowId); } }); @@ -404,7 +455,7 @@ describe("resumeDurableWorkflow", () => { async hydrateResumableWorkflows(): Promise { this.hydrated = true; this.registerWorkflow({ - workflowId: "wf-hydrated", + workflowId: testRunId("wf-hydrated"), name: "resumable-pipeline", inputs: { topic: "data" }, createdAt: 1, @@ -417,7 +468,8 @@ describe("resumeDurableWorkflow", () => { const catalog = await prepareRuntimeDurableResumable(() => hydrating); assert.equal(hydrating.hydrated, true); assert.equal(catalog.length, 1); - const result = await resumeDurableWorkflow("wf-hydrated", { ...deps(), durableBackend: hydrating }); + const workflowId = testRunId("wf-hydrated"); + const result = await resumeDurableWorkflow(workflowId, { ...deps(), durableBackend: hydrating }); assert.equal(result.ok, true); }); }); @@ -429,7 +481,7 @@ describe("durable resume eligibility", () => { // relies on ordinary durable progress from its completed calls. assert.equal( isDurableWorkflowResumable({ - workflowId: "zero-progress", + workflowId: testRunId("zero-progress"), status: "paused", completedCheckpoints: 0, pendingPrompts: 0, @@ -442,7 +494,7 @@ describe("durable resume eligibility", () => { test("a paused workflow with checkpoint progress is resumable unless refused", () => { assert.equal( isDurableWorkflowResumable({ - workflowId: "quit-with-progress", + workflowId: testRunId("quit-with-progress"), status: "paused", completedCheckpoints: 1, pendingPrompts: 0, @@ -452,7 +504,7 @@ describe("durable resume eligibility", () => { ); assert.equal( isDurableWorkflowResumable({ - workflowId: "refused", + workflowId: testRunId("refused"), status: "paused", completedCheckpoints: 1, pendingPrompts: 0, @@ -465,7 +517,7 @@ describe("durable resume eligibility", () => { test("a nested child workflow is never a resume target", () => { assert.equal( isDurableWorkflowResumable({ - workflowId: "child", + workflowId: testRunId("child"), rootWorkflowId: "root", status: "paused", completedCheckpoints: 1, diff --git a/test/unit/durable-resume-worktree.test.ts b/test/unit/durable-resume-worktree.test.ts index c25587c9c..2ff2a2fb4 100644 --- a/test/unit/durable-resume-worktree.test.ts +++ b/test/unit/durable-resume-worktree.test.ts @@ -15,6 +15,7 @@ import { runGitChecked } from "../../packages/workflows/src/runs/shared/worktree import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowDefinition } from "../../packages/workflows/src/shared/types.js"; import type { WorkflowRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; function createGitRepo(name: string): { readonly root: string; readonly repo: string } { const root = realpathSync.native(mkdtempSync(join(tmpdir(), name))); @@ -90,7 +91,7 @@ describe("durable resume with reusable git worktrees", () => { }, }) as unknown as WorkflowDefinition; backend.registerWorkflow({ - workflowId: "wf-worktree-resume", + workflowId: testRunId("wf-worktree-resume"), name: "worktree-resume-pipeline", inputs: { git_worktree_dir: "../resume-reused-wt" }, createdAt: 1, @@ -106,16 +107,16 @@ describe("durable resume with reusable git worktrees", () => { adapters: { complete: { complete: async (text: string) => text } }, }; - const result = await resumeDurableWorkflow("wf-worktree-resume", { ...depsFor(def), baseRunOpts }); + const result = await resumeDurableWorkflow(testRunId("wf-worktree-resume"), { ...depsFor(def), baseRunOpts }); assert.equal(result.ok, true); - await jobs.get("wf-worktree-resume")?.promise; - const run = store.runs().find((snapshot) => snapshot.id === "wf-worktree-resume"); + await jobs.get(testRunId("wf-worktree-resume"))?.promise; + const run = store.runs().find((snapshot) => snapshot.id === testRunId("wf-worktree-resume")); assert.equal(run?.status, "completed"); assert.equal(run?.result?.observed_cwd, expectedWorkflowCwd); assert.equal(existsSync(expectedWorktree), true); assert.equal(existsSync(wrongWorktree), false); - const handle = backend.getWorkflow("wf-worktree-resume"); + const handle = backend.getWorkflow(testRunId("wf-worktree-resume")); assert.equal(handle?.invocationCwd, sourceCwd); assert.equal(handle?.workflowCwd, expectedWorkflowCwd); assert.equal(handle?.gitWorktreeRoot, expectedWorktree); @@ -143,7 +144,7 @@ describe("durable resume with reusable git worktrees", () => { }, }) as unknown as WorkflowDefinition; backend.registerWorkflow({ - workflowId: "wf-worktree-partial-fails", + workflowId: testRunId("wf-worktree-partial-fails"), name: "worktree-partial-fails", inputs: { git_worktree_dir: "../partial-wt" }, createdAt: 1, @@ -160,7 +161,7 @@ describe("durable resume with reusable git worktrees", () => { adapters: { complete: { complete: async (text: string) => text } }, }; - const result = await resumeDurableWorkflow("wf-worktree-partial-fails", { + const result = await resumeDurableWorkflow(testRunId("wf-worktree-partial-fails"), { ...depsFor(def), baseRunOpts, jobs, @@ -172,20 +173,24 @@ describe("durable resume with reusable git worktrees", () => { assert.match(result.message, /already exists but is not a Git worktree/); } assert.equal( - store.runs().some((snapshot) => snapshot.id === "wf-worktree-partial-fails"), + store.runs().some((snapshot) => snapshot.id === testRunId("wf-worktree-partial-fails")), false, ); - assert.equal(jobs.has("wf-worktree-partial-fails"), false); - assert.equal(cancellation.abort("wf-worktree-partial-fails"), false); - assert.equal(backend.getWorkflow("wf-worktree-partial-fails")?.status, "failed"); - assert.equal(backend.getWorkflow("wf-worktree-partial-fails")?.resumable, true); + assert.equal(jobs.has(testRunId("wf-worktree-partial-fails")), false); + assert.equal(cancellation.abort(testRunId("wf-worktree-partial-fails")), false); + assert.equal(backend.getWorkflow(testRunId("wf-worktree-partial-fails"))?.status, "failed"); + assert.equal(backend.getWorkflow(testRunId("wf-worktree-partial-fails"))?.resumable, true); rmSync(join(primary.root, "partial-wt"), { recursive: true, force: true }); - const retry = await resumeDurableWorkflow("wf-worktree-partial-fails", { ...depsFor(def), baseRunOpts, jobs }); + const retry = await resumeDurableWorkflow(testRunId("wf-worktree-partial-fails"), { + ...depsFor(def), + baseRunOpts, + jobs, + }); assert.equal(retry.ok, true); - await jobs.get("wf-worktree-partial-fails")?.promise; + await jobs.get(testRunId("wf-worktree-partial-fails"))?.promise; assert.equal( - store.runs().find((snapshot) => snapshot.id === "wf-worktree-partial-fails")?.status, + store.runs().find((snapshot) => snapshot.id === testRunId("wf-worktree-partial-fails"))?.status, "completed", ); } finally { diff --git a/test/unit/durable-round1-completed-validation.test.ts b/test/unit/durable-round1-completed-validation.test.ts index ce6a4ea35..b751d495f 100644 --- a/test/unit/durable-round1-completed-validation.test.ts +++ b/test/unit/durable-round1-completed-validation.test.ts @@ -7,13 +7,14 @@ import { resolveCompletedWorkflow, } from "../../packages/workflows/src/durable/completed-catalog.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; +import { testRunId } from "../helpers/run-id.js"; function seedCompletedBoundary( backend: InMemoryDurableBackend, runId: string, output: WorkflowSerializableValue, + childRunId = testRunId(`${runId}-child`), ): void { - const childRunId = `${runId}-child`; const replayKey = "workflow:child:1"; const rootRun = { runId, runName: "completed-parent" } as const; const child = { @@ -105,25 +106,31 @@ function seedCompletedBoundary( test("completed catalog uses the shared strict WorkflowChildResult parser", () => { const valid = new InMemoryDurableBackend(); - seedCompletedBoundary(valid, "valid-completed", { - workflow: "child", - runId: "valid-completed-child", - status: "completed", - exited: false, - outputs: {}, - }); + const validRootId = testRunId("valid-completed"); + seedCompletedBoundary( + valid, + validRootId, + { + workflow: "child", + runId: testRunId("valid-completed-child"), + status: "completed", + exited: false, + outputs: {}, + }, + testRunId("valid-completed-child"), + ); assert.equal(listOpenableCompletedWorkflows(valid).length, 1); assert.equal(completedWorkflowRunSnapshots(valid, valid.listCompletedWorkflows()[0]!).length, 2); for (const [index, output] of ( [ - { workflow: "child", runId: "malformed-0-child", status: "completed", outputs: {} }, - { workflow: "child", runId: "malformed-1-child", status: "failed", exited: false, outputs: {} }, - { workflow: "child", runId: "malformed-2-child", status: "completed", exited: false, outputs: [] }, + { workflow: "child", runId: testRunId("malformed-0-child"), status: "completed", outputs: {} }, + { workflow: "child", runId: testRunId("malformed-1-child"), status: "failed", exited: false, outputs: {} }, + { workflow: "child", runId: testRunId("malformed-2-child"), status: "completed", exited: false, outputs: [] }, ] satisfies readonly WorkflowSerializableValue[] ).entries()) { const backend = new InMemoryDurableBackend(); - const runId = `malformed-${index}`; + const runId = testRunId(`malformed-${index}`); seedCompletedBoundary(backend, runId, output); assert.deepEqual(listOpenableCompletedWorkflows(backend), [], runId); assert.equal(resolveCompletedWorkflow(runId, backend).kind, "stale", runId); diff --git a/test/unit/durable-round2-fresh-dbos-lifecycle.test.ts b/test/unit/durable-round2-fresh-dbos-lifecycle.test.ts index c0167d2e3..1f7944b89 100644 --- a/test/unit/durable-round2-fresh-dbos-lifecycle.test.ts +++ b/test/unit/durable-round2-fresh-dbos-lifecycle.test.ts @@ -14,6 +14,7 @@ import { run } from "../../packages/workflows/src/engine/run.js"; import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; function copySdk(source: ReturnType): ReturnType { @@ -35,8 +36,8 @@ function terminals(backend: DbosDurableBackend, runId: string): DurableStageChec test("failed, skipped, and exited child lifecycle survives fresh DBOS catalog hydration exactly once", async () => { const sdk = createMockSdk(); const writer = new DbosDurableBackend(sdk, { executorId: "round2-lifecycle-writer" }); - const completedRootId = "round2-lifecycle-completed"; - const skippedRootId = "round2-lifecycle-skipped"; + const completedRootId = testRunId("round2-lifecycle-completed"); + const skippedRootId = testRunId("round2-lifecycle-skipped"); let stageStarts = 0; let stageEnds = 0; diff --git a/test/unit/durable-round2-fresh-dbos-routing.test.ts b/test/unit/durable-round2-fresh-dbos-routing.test.ts index 2469c59eb..111113f30 100644 --- a/test/unit/durable-round2-fresh-dbos-routing.test.ts +++ b/test/unit/durable-round2-fresh-dbos-routing.test.ts @@ -11,11 +11,12 @@ import { workflowPauseAction } from "../../packages/workflows/src/extension/work import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; -const ROOT_ID = "round2-routing-root"; -const CHILD_IDS = ["round2-routing-child-a", "round2-routing-child-b"] as const; +const ROOT_ID = testRunId("round2-routing-root"); +const CHILD_IDS = [testRunId("round2-routing-child-a"), testRunId("round2-routing-child-b")] as const; const LOCAL_STAGE_ID = "shared-local-id"; const LOCAL_STAGE_NAME = "shared-name"; diff --git a/test/unit/durable-round3-boundary-lifecycle.test.ts b/test/unit/durable-round3-boundary-lifecycle.test.ts index e055fa751..ca47e0ccb 100644 --- a/test/unit/durable-round3-boundary-lifecycle.test.ts +++ b/test/unit/durable-round3-boundary-lifecycle.test.ts @@ -15,10 +15,11 @@ import type { import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk, seedMockCheckpoint, seedMockWorkflow } from "./durable-dbos-backend-helpers.js"; -const ROOT = "round3-lifecycle-root"; -const CHILD = "round3-lifecycle-child"; +const ROOT = testRunId("round3-lifecycle-root"); +const CHILD = testRunId("round3-lifecycle-child"); const KEY = "workflow:child:1"; const BOUNDARY_ID = "round3-lifecycle-boundary"; diff --git a/test/unit/durable-round3-completed-catalog.test.ts b/test/unit/durable-round3-completed-catalog.test.ts index 9c5266ced..7ec419e9c 100644 --- a/test/unit/durable-round3-completed-catalog.test.ts +++ b/test/unit/durable-round3-completed-catalog.test.ts @@ -10,6 +10,7 @@ import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-ba import type { DurableStageRunTopology, DurableStageTopology } from "../../packages/workflows/src/durable/types.js"; import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; import { createMockSdk, seedMockCheckpoint, seedMockWorkflow } from "./durable-dbos-backend-helpers.js"; function rootRun(rootId: string): DurableStageRunTopology { @@ -152,10 +153,10 @@ async function assertHidden(name: string, rootId: string, sdk: ReturnType { - const rootId = "round3-catalog-poison"; + const rootId = testRunId("round3-catalog-poison"); const sdk = completedSdk(rootId); const key = "workflow:catalog-child:1"; - seedBoundary({ sdk, rootId, key, boundaryId: "boundary", childId: "owned-child", order: 0 }); + seedBoundary({ sdk, rootId, key, boundaryId: "boundary", childId: testRunId("owned-child"), order: 0 }); seedMockCheckpoint(sdk, rootId, { kind: "stage", workflowId: rootId, @@ -165,7 +166,7 @@ test("fresh DBOS completed catalog rejects same-key output poisoning before merg completedAt: 9, output: { workflow: "catalog-child", - runId: "poison-child", + runId: testRunId("poison-child"), status: "completed", exited: false, outputs: { value: "poison" }, @@ -183,7 +184,7 @@ test("fresh DBOS completed catalog rejects same-key output poisoning before merg }); test("fresh DBOS completed catalog hides legacy child output without a reciprocal child group", async () => { - const rootId = "round3-catalog-missing-legacy-child"; + const rootId = testRunId("round3-catalog-missing-legacy-child"); const sdk = completedSdk(rootId); seedMockCheckpoint(sdk, rootId, { kind: "stage", @@ -194,7 +195,7 @@ test("fresh DBOS completed catalog hides legacy child output without a reciproca completedAt: 2, output: { workflow: "catalog-child", - runId: "missing-child", + runId: testRunId("missing-child"), status: "completed", exited: false, outputs: { value: "verbatim" }, @@ -212,7 +213,7 @@ test("fresh DBOS completed catalog hides legacy child output without a reciproca }); test("fresh DBOS completed catalog rejects one prompt occurrence bound to different replay keys", async () => { - const rootId = "round3-catalog-prompt-occurrence-poison"; + const rootId = testRunId("round3-catalog-prompt-occurrence-poison"); const sdk = completedSdk(rootId); for (const [index, replayKey] of ["prompt:input:first", "prompt:input:SECOND-DIFFERENT"].entries()) { seedMockCheckpoint(sdk, rootId, { @@ -239,7 +240,7 @@ test("fresh DBOS completed catalog rejects one prompt occurrence bound to differ }); test("fresh DBOS completed catalog rejects replay-key stage identity drift", async () => { - const rootId = "round3-catalog-stage-alias"; + const rootId = testRunId("round3-catalog-stage-alias"); const sdk = completedSdk(rootId); const replayKey = "stage:work:1"; seedMockCheckpoint(sdk, rootId, { @@ -283,26 +284,26 @@ test("fresh DBOS completed catalog rejects replay-key stage identity drift", asy }); test("fresh DBOS completed catalog rejects empty and aliased owning-run boundary ids", async () => { - const emptyRoot = "round3-catalog-empty-boundary"; + const emptyRoot = testRunId("round3-catalog-empty-boundary"); const emptySdk = completedSdk(emptyRoot); seedBoundary({ sdk: emptySdk, rootId: emptyRoot, key: "workflow:catalog-child:1", boundaryId: "", - childId: "empty-child", + childId: testRunId("empty-child"), order: 0, }); await assertHidden("empty-boundary", emptyRoot, emptySdk); - const duplicateRoot = "round3-catalog-duplicate-boundary"; + const duplicateRoot = testRunId("round3-catalog-duplicate-boundary"); const duplicateSdk = completedSdk(duplicateRoot); seedBoundary({ sdk: duplicateSdk, rootId: duplicateRoot, key: "workflow:catalog-child:1", boundaryId: "aliased-boundary", - childId: "child-one", + childId: testRunId("child-one"), order: 0, }); seedBoundary({ @@ -310,7 +311,7 @@ test("fresh DBOS completed catalog rejects empty and aliased owning-run boundary rootId: duplicateRoot, key: "workflow:catalog-child:2", boundaryId: "aliased-boundary", - childId: "child-two", + childId: testRunId("child-two"), order: 1, }); await assertHidden("duplicate-boundary", duplicateRoot, duplicateSdk); diff --git a/test/unit/graph-view-prompt-missing-run.test.ts b/test/unit/graph-view-prompt-missing-run.test.ts new file mode 100644 index 000000000..21625bcc0 --- /dev/null +++ b/test/unit/graph-view-prompt-missing-run.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; +import { + defaultTheme, + makePendingPrompt, + makeRunPromptSnap, + makeStage, + makeStore, + visibleText, +} from "./overlay-graph-helpers.js"; + +class MissingPromptAttributionGraphView extends GraphView { + private renderLookups = 0; + + protected override _getCurrentRun(): RunSnapshot | null { + this.renderLookups += 1; + if (this.renderLookups >= 3) return null; + return super._getCurrentRun(); + } +} + +test("pending graph prompt renders when its optional run attribution lookup misses", () => { + const view = new MissingPromptAttributionGraphView({ + mode: "overlay", + runId: "run-1", + store: makeStore(makeRunPromptSnap([makeStage("prompt-owner")], makePendingPrompt())), + graphTheme: defaultTheme, + }); + + const rendered = visibleText(view.render(96)); + + assert.match(rendered, /AWAITING INPUT/); + assert.match(rendered, /Continue\?/); + view.dispose(); +}); diff --git a/test/unit/graph-view-prompt-overlay-height.test.ts b/test/unit/graph-view-prompt-overlay-height.test.ts new file mode 100644 index 000000000..66d7926c8 --- /dev/null +++ b/test/unit/graph-view-prompt-overlay-height.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; +import type { PendingPrompt, RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; +import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; +import { ANSI_RE, defaultTheme, makePendingPrompt, makeRunPromptSnap, makeStore } from "./overlay-graph-helpers.js"; + +const RUN_ID = "339e05a4-2289-408e-9076-d1a348f582ae"; + +function renderPromptOverlay(viewportRows: number, prompt = makePendingPrompt({ message: "Continue?" })): string[] { + const snapshot = makeRunPromptSnap([], prompt); + const run: RunSnapshot = { ...snapshot.runs[0]!, id: RUN_ID, name: "build-check" }; + const view = new GraphView({ + mode: "overlay", + runId: RUN_ID, + store: makeStore({ ...snapshot, runs: [run] }), + graphTheme: defaultTheme, + getViewportRows: () => viewportRows, + }); + const lines = view.render(96); + view.dispose(); + return lines; +} + +function stripAnsi(line: string): string { + return line.replace(ANSI_RE, ""); +} + +function promptIdentityBanner(plain: readonly string[]): string[] { + const bannerStart = plain.findIndex((line) => line.includes("╭ AWAITING INPUT ")); + if (bannerStart < 0) return []; + const left = plain[bannerStart]!.indexOf("╭ AWAITING INPUT "); + const card = plain.slice(bannerStart).map((line) => line.slice(left, left + 72)); + const bannerEnd = card.findIndex((line, index) => index > 0 && line.startsWith("╰")); + return bannerEnd < 0 ? [] : card.slice(0, bannerEnd + 1); +} + +function assertRoundedBoxesClosed(plain: string[], context: string): void { + const openBoxes: Array<{ left: number; width: number }> = []; + for (const line of plain) { + for (let column = 0; column < line.length; column++) { + if (line[column] === "╭") { + const right = line.indexOf("╮", column + 1); + assert.ok(right > column, `${context} has an open top border`); + openBoxes.push({ left: column, width: right - column }); + } + if (line[column] === "╰") { + const openBox = openBoxes.pop(); + const right = line.indexOf("╯", column + 1); + assert.ok(openBox, `${context} closes a box before one is open`); + assert.equal(column, openBox.left, `${context} shifts a box's bottom border`); + assert.equal(right - column, openBox.width, `${context} changes a box's bottom width`); + } + } + } + assert.equal(openBoxes.length, 0, `${context} leaves a box unclosed\n${plain.join("\n")}`); +} + +function assertGraphFooterAtBodyBoundary(plain: string[], viewportRows: number, context: string): void { + const marginRows = viewportRows >= 9 ? 1 : 0; + const footerStart = viewportRows - marginRows - 3; + assert.match(plain[footerStart]!, /╭─+╮/, `${context} is missing the GRAPH footer top border`); + assert.match(plain[footerStart + 1]!, /│ GRAPH │/, `${context} is missing the GRAPH footer label`); + assert.match(plain[footerStart + 2]!, /╰─+╯/, `${context} is missing the GRAPH footer bottom border`); + for (const line of plain.slice(footerStart + 3)) { + assert.equal(line.trim(), "", `${context} painted past the panel body/status region`); + } +} + +function promptCases(): Array<{ label: string; prompt: PendingPrompt }> { + const cases: Array<{ label: string; prompt: PendingPrompt }> = [ + { label: "input", prompt: makePendingPrompt({ kind: "input", message: "Continue?" }) }, + { label: "confirm", prompt: makePendingPrompt({ kind: "confirm", message: "Continue?" }) }, + { + label: "editor", + prompt: makePendingPrompt({ kind: "editor", message: "Explain the decision", initial: "first\nsecond" }), + }, + ]; + for (const choiceCount of [2, 3, 5, 6, 8]) { + const choices = Array.from({ length: choiceCount }, (_, index) => + choiceCount === 8 && index === 0 + ? "A deliberately long option label that must remain width-safe while the select list scrolls" + : `choice-${index + 1}`, + ); + cases.push({ + label: `select choices=${choiceCount}${choiceCount === 8 ? " long-label" : ""}`, + prompt: makePendingPrompt({ kind: "select", message: "Choose one", choices }), + }); + } + return cases; +} + +describe("GraphView prompt overlay height budget", () => { + test("keeps every prompt box closed and answer hints visible while sweeping short viewport heights", () => { + for (const viewportRows of Array.from({ length: 17 }, (_, index) => index + 14)) { + const lines = renderPromptOverlay(viewportRows); + const plain = lines.map(stripAnsi); + const openBoxes: Array<{ left: number; width: number }> = []; + for (const line of plain) { + for (let column = 0; column < line.length; column++) { + if (line[column] === "╭") { + const right = line.indexOf("╮", column + 1); + assert.ok(right > column, `viewportRows=${viewportRows} has an open top border`); + openBoxes.push({ left: column, width: right - column }); + } + if (line[column] === "╰") { + const openBox = openBoxes.pop(); + const right = line.indexOf("╯", column + 1); + assert.ok(openBox, `viewportRows=${viewportRows} closes a box before one is open`); + assert.equal(column, openBox.left, `viewportRows=${viewportRows} shifts a box's bottom border`); + assert.equal( + right - column, + openBox.width, + `viewportRows=${viewportRows} changes a box's bottom width`, + ); + } + } + } + + assert.equal(openBoxes.length, 0, `viewportRows=${viewportRows} leaves a box unclosed\n${plain.join("\n")}`); + assert.match(plain.join("\n"), /enter Submit · ctrl\+c Skip/, `viewportRows=${viewportRows}`); + assert.equal(lines.length, viewportRows); + for (const line of lines) assert.equal(visibleWidth(line), 96, `viewportRows=${viewportRows}`); + } + }); + + test("keeps the graph footer and every prompt box closed for every prompt kind across viewport heights", () => { + for (const { label, prompt } of promptCases()) { + for (let viewportRows = 8; viewportRows <= 40; viewportRows++) { + const context = `${label} viewportRows=${viewportRows}`; + const lines = renderPromptOverlay(viewportRows, prompt); + const plain = lines.map(stripAnsi); + + assertRoundedBoxesClosed(plain, context); + assertGraphFooterAtBodyBoundary(plain, viewportRows, context); + assert.equal(lines.length, viewportRows, context); + for (const line of lines) assert.equal(visibleWidth(line), 96, context); + } + } + }); + + test("renders a reduced complete prompt below the full-control height", () => { + const rows13 = renderPromptOverlay(13).map(stripAnsi).join("\n"); + const rows14 = renderPromptOverlay(14).map(stripAnsi).join("\n"); + + assert.match(rows13, /AWAITING INPUT/); + assert.match(rows13, /Continue\?/); + assert.match(rows13, /enter Submit · ctrl\+c Skip/); + assert.match(rows14, /AWAITING INPUT/); + assert.match(rows14, /enter Submit · ctrl\+c Skip/); + }); + + test("omits supplementary attribution before sacrificing the complete prompt UI", () => { + const rows21 = renderPromptOverlay(17).map(stripAnsi).join("\n"); + const rows22 = renderPromptOverlay(19).map(stripAnsi).join("\n"); + + assert.doesNotMatch(rows21, new RegExp(RUN_ID)); + assert.match(rows21, /Continue\?/); + assert.match(rows21, /enter Submit · ctrl\+c Skip/); + assert.match(rows21, /╰─+╯/); + assert.match(rows22, new RegExp(RUN_ID)); + const selectPrompt = makePendingPrompt({ + kind: "select", + message: "OVERLAY-SELECT-QUESTION", + choices: ["stable", "beta", "nightly"], + }); + for (const viewportRows of [21, 22]) { + const overlay = renderPromptOverlay(viewportRows, selectPrompt).map(stripAnsi).join("\n"); + assert.match(overlay, /OVERLAY-SELECT-QUESTION/, `viewportRows=${viewportRows}`); + assert.match(overlay, new RegExp(RUN_ID), `viewportRows=${viewportRows}`); + } + }); + + test("uses the full-id-only banner between full attribution and banner yield", () => { + const yielded = renderPromptOverlay(17).map(stripAnsi); + const oneRow = renderPromptOverlay(18).map(stripAnsi); + const twoRows = renderPromptOverlay(19).map(stripAnsi); + const oneRowBanner = promptIdentityBanner(oneRow); + const twoRowBanner = promptIdentityBanner(twoRows); + + assert.doesNotMatch(yielded.join("\n"), new RegExp(RUN_ID)); + assert.ok(oneRowBanner.join("\n").includes(RUN_ID), "one-row banner must carry the complete run id"); + assert.doesNotMatch(oneRowBanner.join("\n"), /build-check/); + assert.equal(oneRowBanner.length, 3, "one-row banner has only top, full-id, and bottom rows"); + assert.ok(twoRowBanner.join("\n").includes(RUN_ID)); + assert.match(twoRowBanner.join("\n"), /build-check/); + assert.equal(twoRowBanner.length, 4, "two-row banner shape must remain unchanged"); + }); +}); diff --git a/test/unit/nested-workflow-target-routing.test.ts b/test/unit/nested-workflow-target-routing.test.ts index 5697af4b9..47dc4077c 100644 --- a/test/unit/nested-workflow-target-routing.test.ts +++ b/test/unit/nested-workflow-target-routing.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import { + resolveControlNodeTarget, resolveStageTarget, topLevelExpandedSnapshots, } from "../../packages/workflows/src/extension/workflow-targets.js"; @@ -28,6 +29,9 @@ import { createStore, store } from "../../packages/workflows/src/shared/store.js import type { RunSnapshot, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import { testRunId } from "../helpers/run-id.js"; + +const fixtureRunId = (seed: string): string => testRunId(seed); function stage(id: string, name = id): StageSnapshot { return { id, name, status: "running", parentIds: [], toolEvents: [], attachable: true }; @@ -45,37 +49,37 @@ function run(overrides: Partial & Pick { setDurableBackend(undefined); store.clear(); }); - describe("nested workflow stage target routing", () => { test("duplicate child-local stage IDs are ambiguous instead of first-matched", () => { - const result = resolveStageTarget("root-run", "shared"); + const result = resolveStageTarget(fixtureRunId("root-run"), "shared"); assert.equal(result.ok, false); if (result.ok) return; assert.equal( result.message, - 'Ambiguous stage identifier "shared" matches: worker:duplicate name (child-le/shared), worker:duplicate name (child-ri/shared)', + `Ambiguous stage identifier "shared" matches: worker:duplicate name (${fixtureRunId("child-left")}/shared), worker:duplicate name (${fixtureRunId("child-right")}/shared)`, ); }); test("exact virtual IDs and unique local IDs retain the owning child run", () => { - assert.deepEqual(resolveStageTarget("root-run", "child-right:shared"), { + assert.deepEqual(resolveStageTarget(fixtureRunId("root-run"), `${fixtureRunId("child-right")}:shared`), { ok: true, - runId: "child-right", + runId: fixtureRunId("child-right"), stageId: "shared", }); - assert.deepEqual(resolveStageTarget("root-run", "left-only"), { + assert.deepEqual(resolveStageTarget(fixtureRunId("root-run"), "left-only"), { ok: true, - runId: "child-left", + runId: fixtureRunId("child-left"), stageId: "left-only", }); }); - test("repeated names and ambiguous prefixes retain the existing ambiguity path", () => { - const byName = resolveStageTarget("root-run", "repeated name"); + test("exact stage and tool ids and names resolve without prefix fallback", () => { + const toolRunId = fixtureRunId("tool-targets-root"); + store.recordRunStart( + run({ + id: toolRunId, + name: "tool-targets", + stages: [stage("stage-id", "build-check")], + toolNodes: [ + { + kind: "tool", + id: "tool:args-hash", + name: "format-files", + argsHash: "args-hash", + ordinal: 0, + parentIds: [], + status: "running", + attachable: false, + }, + ], + }), + ); + + assert.deepEqual(resolveStageTarget(toolRunId, "stage-id"), { + ok: true, + runId: toolRunId, + stageId: "stage-id", + }); + assert.deepEqual(resolveStageTarget(toolRunId, "build-check"), { + ok: true, + runId: toolRunId, + stageId: "stage-id", + }); + assert.deepEqual(resolveControlNodeTarget(toolRunId, "tool:args-hash"), { + ok: true, + kind: "tool", + runId: toolRunId, + nodeId: "tool:args-hash", + name: "format-files", + }); + assert.deepEqual(resolveControlNodeTarget(toolRunId, "format-files"), { + ok: true, + kind: "tool", + runId: toolRunId, + nodeId: "tool:args-hash", + name: "format-files", + }); + assert.deepEqual(resolveStageTarget(toolRunId, "build"), { + ok: false, + message: `Stage not found in run ${toolRunId}: build`, + }); + }); + + test("repeated names remain ambiguous while partial names are rejected", () => { + const byName = resolveStageTarget(fixtureRunId("root-run"), "repeated name"); assert.equal(byName.ok, false); if (!byName.ok) { - assert.match(byName.message, /^Ambiguous stage identifier "repeated name" matches: /); - assert.match(byName.message, /child-le\/left-on/); - assert.match(byName.message, /child-ri\/right-on/); + assert.equal( + byName.message, + `Ambiguous stage identifier "repeated name" matches: worker:repeated name (${fixtureRunId("child-left")}/left-only), worker:repeated name (${fixtureRunId("child-right")}/right-only)`, + ); } - const byPrefix = resolveStageTarget("root-run", "child-r"); - assert.equal(byPrefix.ok, false); - if (!byPrefix.ok) { - assert.match(byPrefix.message, /^Ambiguous stage identifier "child-r" matches: /); - assert.match(byPrefix.message, /child-ri\/shared/); - assert.match(byPrefix.message, /child-ri\/right-on/); - } + const byPartialName = resolveStageTarget(fixtureRunId("root-run"), "duplicate"); + assert.deepEqual(byPartialName, { + ok: false, + message: `Stage not found in run ${fixtureRunId("root-run")}: duplicate`, + }); }); test("attach detach restores the exact sibling owner when local stage IDs collide", () => { @@ -187,23 +241,29 @@ describe("nested workflow stage target routing", () => { const pane = new WorkflowAttachPane({ store: localStore, graphTheme: deriveGraphTheme({}), - runId: "root-run", - initialAttachRunId: "child-right", + runId: fixtureRunId("root-run"), + initialAttachRunId: fixtureRunId("child-right"), initialAttachStageId: "shared", onClose: () => {}, }); - assert.equal(localStore.runs().find((candidate) => candidate.id === "child-right")?.stages[0]?.attached, true); + assert.equal( + localStore.runs().find((candidate) => candidate.id === fixtureRunId("child-right"))?.stages[0]?.attached, + true, + ); pane.handleInput(Key.ctrl("x")); assert.equal(pane._mode, "graph"); pane.handleInput(Key.enter); assert.equal(pane._mode, "stage-chat"); assert.equal( - localStore.runs().find((candidate) => candidate.id === "child-left")?.stages[0]?.attached, + localStore.runs().find((candidate) => candidate.id === fixtureRunId("child-left"))?.stages[0]?.attached, undefined, ); - assert.equal(localStore.runs().find((candidate) => candidate.id === "child-right")?.stages[0]?.attached, true); + assert.equal( + localStore.runs().find((candidate) => candidate.id === fixtureRunId("child-right"))?.stages[0]?.attached, + true, + ); pane.dispose(); }); @@ -213,7 +273,7 @@ describe("nested workflow stage target routing", () => { const pane = new WorkflowAttachPane({ store: localStore, graphTheme: deriveGraphTheme({}), - runId: "root-run", + runId: fixtureRunId("root-run"), initialAttachStageId: "shared", onClose: () => {}, }); @@ -229,11 +289,11 @@ describe("nested workflow stage target routing", () => { ); assert.equal( - localStore.runs().find((candidate) => candidate.id === "child-left")?.stages[0]?.attached, + localStore.runs().find((candidate) => candidate.id === fixtureRunId("child-left"))?.stages[0]?.attached, undefined, ); assert.equal( - localStore.runs().find((candidate) => candidate.id === "child-right")?.stages[0]?.attached, + localStore.runs().find((candidate) => candidate.id === fixtureRunId("child-right"))?.stages[0]?.attached, undefined, ); pane.dispose(); @@ -440,20 +500,20 @@ describe("nested workflow stage target routing", () => { test("public controls and inspection route an exact virtual ID to the true child owner", async () => { const leftCalls: HandleCalls = { pauses: 0, resumes: [], prompts: [] }; const rightCalls: HandleCalls = { pauses: 0, resumes: [], prompts: [] }; - stageControlRegistry.register(liveHandle("child-left", "shared", leftCalls)); - stageControlRegistry.register(liveHandle("child-right", "shared", rightCalls)); - const target = { runId: "root-run", stageId: "child-right:shared" }; + stageControlRegistry.register(liveHandle(fixtureRunId("child-left"), "shared", leftCalls)); + stageControlRegistry.register(liveHandle(fixtureRunId("child-right"), "shared", rightCalls)); + const target = { runId: fixtureRunId("root-run"), stageId: `${fixtureRunId("child-right")}:shared` }; const inspected = workflowStageResult({ action: "stage", ...target }); assert.equal(inspected.action, "stage"); if (inspected.action !== "stage") return; - assert.equal(inspected.runId, "child-right"); + assert.equal(inspected.runId, fixtureRunId("child-right")); assert.equal(inspected.stage?.id, "shared"); const transcript = workflowTranscriptResult({ action: "transcript", ...target }); assert.equal(transcript.action, "transcript"); if (transcript.action !== "transcript") return; - assert.equal(transcript.runId, "child-right"); + assert.equal(transcript.runId, fixtureRunId("child-right")); assert.equal(transcript.stageId, "shared"); assert.equal(transcript.source, "live"); @@ -461,7 +521,7 @@ describe("nested workflow stage target routing", () => { assert.deepEqual( { runId: sent.runId, stageId: sent.stageId, status: sent.status }, { - runId: "child-right", + runId: fixtureRunId("child-right"), stageId: "shared", status: "ok", }, @@ -471,14 +531,14 @@ describe("nested workflow stage target routing", () => { const paused = await workflowPauseAction({ action: "pause", ...target }); assert.equal(paused.action, "pause"); - assert.equal("runId" in paused ? paused.runId : undefined, "child-right"); + assert.equal("runId" in paused ? paused.runId : undefined, fixtureRunId("child-right")); assert.equal(leftCalls.pauses, 0); assert.equal(rightCalls.pauses, 1); const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ - workflowId: "root-run", - rootWorkflowId: "root-run", + workflowId: fixtureRunId("root-run"), + rootWorkflowId: fixtureRunId("root-run"), name: "root", inputs: {}, createdAt: 1, @@ -496,20 +556,19 @@ describe("nested workflow stage target routing", () => { }, ); assert.equal(resumed.action, "resume"); - assert.equal("runId" in resumed ? resumed.runId : undefined, "child-right"); + assert.equal("runId" in resumed ? resumed.runId : undefined, fixtureRunId("child-right")); assert.deepEqual(rightCalls.resumes, ["continue right"]); const interrupted = await workflowInterruptAction({ action: "interrupt", ...target }); assert.equal(interrupted.action, "interrupt"); - assert.equal("runId" in interrupted ? interrupted.runId : undefined, "child-right"); + assert.equal("runId" in interrupted ? interrupted.runId : undefined, fixtureRunId("child-right")); assert.equal(leftCalls.pauses, 0); assert.equal(rightCalls.pauses, 2); }); test("every public control surface reports ambiguity instead of routing duplicate local IDs", async () => { - const target = { runId: "root-run", stageId: "shared" }; - const expected = - 'Ambiguous stage identifier "shared" matches: worker:duplicate name (child-le/shared), worker:duplicate name (child-ri/shared)'; + const target = { runId: fixtureRunId("root-run"), stageId: "shared" }; + const expected = `Ambiguous stage identifier "shared" matches: worker:duplicate name (${fixtureRunId("child-left")}/shared), worker:duplicate name (${fixtureRunId("child-right")}/shared)`; const pause = await workflowPauseAction({ action: "pause", ...target }); const interrupt = await workflowInterruptAction({ action: "interrupt", ...target }); const resume = await workflowResumeAction( @@ -535,16 +594,16 @@ describe("nested workflow stage target routing", () => { }); test("post-mortem revival dependencies are resolved for the nested stage owner", async () => { - const nestedStage = store.runs().find((run) => run.id === "child-right")?.stages[0]; + const nestedStage = store.runs().find((run) => run.id === fixtureRunId("child-right"))?.stages[0]; assert.ok(nestedStage); - store.recordStageEnd("child-right", { ...nestedStage, status: "completed", result: "done" }); + store.recordStageEnd(fixtureRunId("child-right"), { ...nestedStage, status: "completed", result: "done" }); let dependencyRunId: string | undefined; const result = await workflowSendAction( { action: "send", - runId: "root-run", - stageId: "child-right:shared", + runId: fixtureRunId("root-run"), + stageId: `${fixtureRunId("child-right")}:shared`, text: "continue retained chat", }, { @@ -565,8 +624,8 @@ describe("nested workflow stage target routing", () => { }, ); - assert.equal(dependencyRunId, "child-right"); - assert.equal(result.runId, "child-right"); + assert.equal(dependencyRunId, fixtureRunId("child-right")); + assert.equal(result.runId, fixtureRunId("child-right")); assert.equal(result.stageId, "shared"); assert.equal(result.status, "noop"); }); @@ -574,18 +633,18 @@ describe("nested workflow stage target routing", () => { test("top-level expanded listings exclude both child and grandchild implementation runs", () => { store.recordRunStart( run({ - id: "grandchild-run", + id: fixtureRunId("grandchild-run"), name: "grandchild", - parentRunId: "child-right", + parentRunId: fixtureRunId("child-right"), parentStageId: "shared", - rootRunId: "root-run", + rootRunId: fixtureRunId("root-run"), stages: [stage("grandchild-stage")], }), ); assert.deepEqual( topLevelExpandedSnapshots().map((snapshot) => snapshot.id), - ["root-run"], + [fixtureRunId("root-run")], ); }); }); diff --git a/test/unit/node-card.test.ts b/test/unit/node-card.test.ts index 9edc7c327..2a611329d 100644 --- a/test/unit/node-card.test.ts +++ b/test/unit/node-card.test.ts @@ -22,6 +22,8 @@ import { hexBg, hexToAnsi } from "../../packages/workflows/src/tui/color-utils.j import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; import { NODE_H, NODE_W } from "../../packages/workflows/src/tui/layout.js"; import { renderNodeCard } from "../../packages/workflows/src/tui/node-card.js"; +import { statusIcon } from "../../packages/workflows/src/tui/status-helpers.js"; +import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; const ANSI_RE = /\x1b\[[0-9;]*m/g; const stripAnsi = (s: string) => s.replace(ANSI_RE, ""); @@ -35,6 +37,8 @@ function makeStage(opts: Partial = {}): StageSnapshot { status: opts.status ?? ("pending" as StageStatus), parentIds: opts.parentIds ?? [], topologyState: opts.topologyState, + nodeKind: opts.nodeKind, + toolStatus: opts.toolStatus, toolEvents: opts.toolEvents ?? [], durationMs: opts.durationMs, pausedDurationMs: opts.pausedDurationMs, @@ -45,6 +49,7 @@ function makeStage(opts: Partial = {}): StageSnapshot { blockedByStageId: opts.blockedByStageId, model: opts.model, workflowChild: opts.workflowChild, + workflowChildRun: opts.workflowChildRun, fastMode: opts.fastMode, }; } @@ -93,6 +98,33 @@ describe("renderNodeCard — queued-message badge", () => { assert.doesNotMatch(badged, /1 dep/); }); + test("keeps child identity, status, full UUID, and queued badge at the real card geometry", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const lines = renderNodeCard( + makeStage({ + status: "running", + workflowChildRun: { alias: "child", workflow: "publish-child", runId }, + }), + { theme, queuedMessageCount: 2 }, + ); + const plainLines = lines.map(stripAnsi); + const rendered = plainLines.join("\n"); + const renderedRunId = plainLines + .slice(1, -1) + .map((line) => line.slice(1, -1).trim()) + .filter((line) => /^(?:run )?[0-9a-f-]+$/.test(line)) + .join("") + .replace(/^run /, ""); + + assert.equal(lines.length, NODE_H); + for (const line of plainLines) assert.equal(line.length, NODE_W); + assert.match(rendered, /publish-child/); + assert.match(rendered, new RegExp(`${statusIcon("running")} running`)); + assert.match(rendered, /✉ 2 queued/); + assert.equal(renderedRunId, runId); + assert.doesNotMatch(rendered, /…/); + }); + test("renders a single queued message count verbatim", () => { const lines = renderNodeCard(makeStage({ status: "running", startedAt: 1 }), { theme, @@ -118,6 +150,52 @@ describe("renderNodeCard — queued-message badge", () => { assert.doesNotMatch(rendered, /waiting for response/); }); + test("keeps queued badges on awaiting-input child boundaries at the real card geometry", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const childBoundaries: Array<{ label: string; stage: Partial }> = [ + { + label: "live child", + stage: { workflowChildRun: { alias: "child", workflow: "publish-child", runId } }, + }, + { + label: "completed child", + stage: { + workflowChild: { + alias: "child", + workflow: "publish-child", + runId, + status: "completed", + outputs: {}, + }, + }, + }, + ]; + + for (const { label, stage } of childBoundaries) { + for (const queuedMessageCount of [1, 2, 12, 100]) { + const lines = renderNodeCard(makeStage({ ...stage, status: "awaiting_input" }), { + theme, + width: 24, + height: 5, + queuedMessageCount, + }); + const plainLines = lines.map(stripAnsi); + const rendered = plainLines.join("\n"); + const context = `${label} queuedMessageCount=${queuedMessageCount}`; + const badgeLine = plainLines.find((line) => line.includes(`${queuedMessageCount} queued`)); + const badgeText = badgeLine?.slice(1, -1).trim(); + + assert.equal(lines.length, 5, context); + for (const line of plainLines) assert.equal(visibleWidth(line), 24, context); + assert.notEqual(badgeText, undefined, context); + assert.match(badgeText!, new RegExp(`^\\S ${queuedMessageCount} queued$`), context); + assert.doesNotMatch(badgeText!, /…/, context); + assert.match(rendered, new RegExp(`${statusIcon("awaiting_input")} awaiting input`), context); + assert.match(rendered, /↵ enter to respond/, context); + } + } + }); + test("omits the badge for zero, negative, and absent counts", () => { for (const queuedMessageCount of [undefined, 0, -1, Number.NaN]) { const lines = renderNodeCard(makeStage({ status: "running", startedAt: 1 }), { @@ -129,6 +207,61 @@ describe("renderNodeCard — queued-message badge", () => { for (const line of lines) assert.equal(stripAnsi(line).length, NODE_W); } }); + test("keeps every child-run queued badge whole for long status labels at the real geometry", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const statuses: Array<{ + label: string; + stage: Partial; + expectedStatusText: string; + }> = [ + { + label: "cancelled", + stage: { status: "skipped", nodeKind: "tool", toolStatus: "cancelled" }, + expectedStatusText: `${statusIcon("cancelled")} cancelled`, + }, + { + label: "complete", + stage: { + status: "completed", + durationMs: 1, + workflowChild: { + alias: "child", + workflow: "publish-child", + runId, + status: "completed", + outputs: { artifact: "ready" }, + }, + }, + expectedStatusText: `${statusIcon("completed")} complete`, + }, + { + label: "running", + stage: { status: "running", startedAt: 1 }, + expectedStatusText: `${statusIcon("running")} running`, + }, + ]; + + for (const { label, stage, expectedStatusText } of statuses) { + for (const queuedMessageCount of [1, 12, 100]) { + const lines = renderNodeCard( + makeStage({ + ...stage, + workflowChildRun: { alias: "child", workflow: "publish-child", runId }, + }), + { theme, width: 24, height: 5, queuedMessageCount }, + ); + const plainLines = lines.map(stripAnsi); + const rendered = plainLines.join("\n"); + const context = `${label} queuedMessageCount=${queuedMessageCount}`; + + assert.equal(lines.length, 5, context); + for (const line of plainLines) assert.equal(line.length, 24, context); + assert.match(rendered, new RegExp(`${queuedMessageCount} queued`), context); + assert.doesNotMatch(rendered, /…/, context); + if (queuedMessageCount === 1) assert.match(rendered, new RegExp(expectedStatusText), context); + } + } + }); }); describe("renderNodeCard — focused tab marker", () => { @@ -343,10 +476,77 @@ describe("renderNodeCard — metadata line", () => { const rendered = stripAnsi(lines.join("\n")); assert.match(rendered, /↳ pr1135-import-child/); assert.match(rendered, /✓ complete/); - assert.match(rendered, /run run_1234 · 1 out/); + // The child run id is never shortened: at node width it wraps across rows, + // so reassemble the identity rows before asserting the complete value. + const interior = lines.slice(1, -1).map((line) => stripAnsi(line).replaceAll("│", "").trim()); + assert.ok(interior.slice(0, 2).join("").includes("run run_1234567890abcdef")); + assert.match(rendered, /· 1 out/); assert.doesNotMatch(stripAnsi(lines[1]!), /0ms|—/); }); + test("keeps full child-run UUIDs and intact suffix tokens within fixed geometry", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const cases: Array<{ label: string; stage: StageSnapshot; suffix: string }> = [ + { + label: "live", + stage: makeStage({ + status: "running", + workflowChildRun: { alias: "child", workflow: "publish-child", runId }, + }), + suffix: "· live", + }, + { + label: "one output", + stage: makeStage({ + status: "completed", + workflowChild: { + alias: "child", + workflow: "publish-child", + runId, + status: "completed", + outputs: { artifact: "ready" }, + }, + }), + suffix: "· 1 out", + }, + { + label: "three outputs", + stage: makeStage({ + status: "completed", + workflowChild: { + alias: "child", + workflow: "publish-child", + runId, + status: "completed", + outputs: { artifact: "ready", checksum: "ok", notes: "done" }, + }, + }), + suffix: "· 3 outs", + }, + ]; + + for (const { label, stage, suffix } of cases) { + const lines = renderNodeCard(stage, { theme }); + const interior = lines + .slice(1, -1) + .map((line) => stripAnsi(line).replaceAll("│", "").trim()) + .join(""); + const rendered = stripAnsi(lines.join("\n")); + assert.equal(lines.length, NODE_H, label); + assert.ok(interior.includes(runId), `${label}: ${interior}`); + assert.ok(interior.endsWith(suffix), `${label}: ${interior}`); + assert.match(rendered, /publish-child/, label); + assert.match( + rendered, + new RegExp( + stage.status === "running" ? `${statusIcon("running")} running` : `${statusIcon("completed")} complete`, + ), + label, + ); + assert.doesNotMatch(rendered, /…/, label); + } + }); + test("stages show a visible fast marker without mutating model metadata", () => { const lines = renderNodeCard(makeStage({ status: "completed", model: "openai/gpt-5.1-codex", fastMode: true }), { theme, diff --git a/test/unit/persistence-session-entries.test.ts b/test/unit/persistence-session-entries.test.ts index 1d00176ec..874f52c86 100644 --- a/test/unit/persistence-session-entries.test.ts +++ b/test/unit/persistence-session-entries.test.ts @@ -78,12 +78,17 @@ describe("appendRunStart", () => { assert.equal((p.inputs as Record).x, 1); }); - test("calls setLabel with wf:: format", () => { + test("calls setLabel with wf:: format", () => { const api = makeMockApi(); - appendRunStart(api, { runId: "abcdefgh-1234", name: "my-workflow", inputs: {}, ts: 1 }); + appendRunStart(api, { + runId: "339e05a4-2289-408e-9076-d1a348f582ae", + name: "my-workflow", + inputs: {}, + ts: 1, + }); assert.equal(api._labels.size, 1); const label = [...api._labels.values()][0]; - assert.equal(label, "wf:my-workflow:abcdefgh"); + assert.equal(label, "wf:my-workflow:339e05a4-2289-408e-9076-d1a348f582ae"); }); test("includes continuation metadata when provided", () => { diff --git a/test/unit/post-commit-lifecycle-state-repair.test.ts b/test/unit/post-commit-lifecycle-state-repair.test.ts index d3238bbdd..691f484d0 100644 --- a/test/unit/post-commit-lifecycle-state-repair.test.ts +++ b/test/unit/post-commit-lifecycle-state-repair.test.ts @@ -19,9 +19,12 @@ import { } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore, store as singletonStore } from "../../packages/workflows/src/shared/store.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; import { sleep } from "../helpers/runtime.js"; import { buildCtx, installSlashDispatchTestHooks, registerWorkflowCommand } from "./slash-dispatch-utils.js"; +const fixtureRunId = (seed: string): string => testRunId(seed); + function handle(input: { readonly runId: string; readonly stageId: string; @@ -159,7 +162,7 @@ describe("post-commit quit and nested resume coherence", () => { setDurableBackend(backend); const targetStore = createStore(); const registry = createStageControlRegistry(); - const runId = "quit-store-coherence"; + const runId = fixtureRunId("quit-store-coherence"); let controlStatus: StageControlStatus = "running"; targetStore.recordRunStart({ id: runId, name: runId, inputs: {}, status: "running", stages: [], startedAt: 1 }); targetStore.recordStageStart(runId, { @@ -200,13 +203,15 @@ describe("post-commit quit and nested resume coherence", () => { setDurableBackend(backend); const targetStore = createStore(); const registry = createStageControlRegistry(); - seedNested(targetStore, backend, "root-primitive", "child-primitive", ["child-stage"]); + const rootId = fixtureRunId("root-primitive"); + const childId = fixtureRunId("child-primitive"); + seedNested(targetStore, backend, rootId, childId, ["child-stage"]); let calls = 0; registry.register( handle({ - runId: "child-primitive", + runId: childId, stageId: "child-stage", - status: () => targetStore.runs().find((run) => run.id === "child-primitive")?.stages[0]?.status ?? "paused", + status: () => targetStore.runs().find((run) => run.id === childId)?.stages[0]?.status ?? "paused", resume: async () => { calls += 1; return undefined; @@ -214,7 +219,7 @@ describe("post-commit quit and nested resume coherence", () => { }), ); - const result = await resumeRun("child-primitive", { + const result = await resumeRun(childId, { store: targetStore, stageControlRegistry: registry, stageId: "child-stage", @@ -222,23 +227,23 @@ describe("post-commit quit and nested resume coherence", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.runId, "child-primitive"); + assert.equal(result.runId, childId); assert.deepEqual( result.resumed.map((stage) => stage.id), ["child-stage"], ); assert.equal(calls, 1); - assert.equal(targetStore.runs().find((run) => run.id === "child-primitive")?.status, "running"); - assert.equal(targetStore.runs().find((run) => run.id === "child-primitive")?.stages[0]?.status, "running"); - assert.equal(targetStore.runs().find((run) => run.id === "root-primitive")?.status, "running"); - assert.equal(backend.getWorkflow("root-primitive")?.status, "running"); + assert.equal(targetStore.runs().find((run) => run.id === childId)?.status, "running"); + assert.equal(targetStore.runs().find((run) => run.id === childId)?.stages[0]?.status, "running"); + assert.equal(targetStore.runs().find((run) => run.id === rootId)?.status, "running"); + assert.equal(backend.getWorkflow(rootId)?.status, "running"); }); test.sequential("slash-targeted nested progress then tool root retry does not duplicate resumed descendants", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const rootId = "root-reverse-surface-retry"; - const childId = "child-reverse-surface-retry"; + const rootId = fixtureRunId("root-reverse-surface-retry"); + const childId = fixtureRunId("child-reverse-surface-retry"); seedNested(singletonStore, backend, rootId, childId, ["first", "second"]); const calls = new Map(); for (const stageId of ["first", "second"]) { @@ -290,8 +295,8 @@ describe("post-commit quit and nested resume coherence", () => { test.sequential("tool-targeted nested progress then slash root retry resumes only the remaining descendant", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const rootId = "root-surface-retry"; - const childId = "child-surface-retry"; + const rootId = fixtureRunId("root-surface-retry"); + const childId = fixtureRunId("child-surface-retry"); seedNested(singletonStore, backend, rootId, childId, ["first-child-stage", "second-child-stage"]); const calls = new Map(); for (const stageId of ["first-child-stage", "second-child-stage"]) { @@ -347,7 +352,7 @@ describe("post-commit quit and nested resume coherence", () => { assert.match(messages.join("\n"), /Resumed 1 stage/); }); test.sequential("slash resume reports synthetic prompt acknowledgement as info while the root completes", async () => { - const runId = "resume-terminal-synthetic-slash"; + const runId = fixtureRunId("resume-terminal-synthetic-slash"); const backend = await startAnsweredQuitSyntheticPrompt(runId); const { workflowCmd } = await registerWorkflowCommand(); const infos: string[] = []; @@ -371,7 +376,7 @@ describe("post-commit quit and nested resume coherence", () => { }); test.sequential("tool resume reports synthetic prompt acknowledgement as ok while the root completes", async () => { - const runId = "resume-terminal-synthetic-tool"; + const runId = fixtureRunId("resume-terminal-synthetic-tool"); const backend = await startAnsweredQuitSyntheticPrompt(runId); const result = await toolHandler()({ action: "resume", runId }, {} as never); @@ -391,7 +396,7 @@ describe("post-commit quit and nested resume coherence", () => { test.sequential("slash resume reports terminal completion acknowledged by a paused control", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "resume-terminal-slash"; + const runId = fixtureRunId("resume-terminal-slash"); const stageId = "awaiting-answer"; let controlStatus: StageControlStatus = "paused"; singletonStore.recordRunStart({ @@ -475,7 +480,7 @@ describe("post-commit quit and nested resume coherence", () => { test.sequential("tool resume reports async terminal completion as successful progress", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "resume-terminal-tool"; + const runId = fixtureRunId("resume-terminal-tool"); const stageId = "awaiting-answer"; let controlStatus: StageControlStatus = "paused"; singletonStore.recordRunStart({ @@ -550,7 +555,7 @@ describe("post-commit quit and nested resume coherence", () => { test.sequential("tool resume retains noop for a pre-existing paused snapshot with no paused control", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "resume-no-paused-control"; + const runId = fixtureRunId("resume-no-paused-control"); const stageId = "stale-paused-stage"; singletonStore.recordRunStart({ id: runId, @@ -609,13 +614,13 @@ describe("post-commit quit and nested resume coherence", () => { }; await workflowCmd.options.handler(`resume ${runId}`, ctx); assert.deepEqual(infos, []); - assert.deepEqual(errors, [`No paused stages on run ${runId.slice(0, 8)}.`]); + assert.deepEqual(errors, [`No paused stages on run ${runId}.`]); }); test.sequential("slash quit, held synthetic prompt answer, and resume reports one truthful acknowledgment", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "resume-synthetic-prompt-command"; + const runId = fixtureRunId("resume-synthetic-prompt-command"); const definition = workflow({ name: "resume-synthetic-prompt-command", description: "", diff --git a/test/unit/prompt-card.test.ts b/test/unit/prompt-card.test.ts index 4b5ff0b0b..b93d32fbc 100644 --- a/test/unit/prompt-card.test.ts +++ b/test/unit/prompt-card.test.ts @@ -11,6 +11,7 @@ import assert from "node:assert/strict"; import { describe, test } from "vitest"; import type { PendingPrompt } from "../../packages/workflows/src/shared/store-types.ts"; +import { hexToAnsi } from "../../packages/workflows/src/tui/color-utils.ts"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.ts"; import { createPromptCardState, @@ -18,6 +19,8 @@ import { handlePromptCardInput, renderPromptCard, } from "../../packages/workflows/src/tui/prompt-card.ts"; +import { renderPromptCardLayout } from "../../packages/workflows/src/tui/prompt-card-render.ts"; +import { statusColor, statusIcon } from "../../packages/workflows/src/tui/status-helpers.ts"; import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.ts"; import { makeFakeKeybindings } from "../support/fake-keybindings.ts"; @@ -244,6 +247,31 @@ describe("renderPromptCard", () => { } }); + test("never emits more complete rows than the caller's budget for any prompt kind", () => { + const kinds: PendingPrompt["kind"][] = ["input", "confirm", "select", "editor"]; + for (const kind of kinds) { + for (const choiceCount of [2, 3, 5, 8]) { + const choices = Array.from({ length: choiceCount }, (_, index) => `choice-${index + 1}`); + const state = createPromptCardState( + makePrompt({ + kind, + choices: kind === "select" ? choices : undefined, + initial: kind === "editor" ? "first\nsecond\nthird" : undefined, + }), + ); + for (let width = 40; width <= 100; width += 2) { + for (let maxRows = 1; maxRows <= 30; maxRows++) { + const lines = renderPromptCard({ state, theme, width, cursorOn: false, maxRows }); + assert.ok( + lines.length <= maxRows, + `kind=${kind} choices=${choiceCount} width=${width} maxRows=${maxRows} emitted=${lines.length}`, + ); + } + } + } + } + }); + test("includes the prompt message in the rendered text", () => { const state = createPromptCardState(makePrompt({ message: "UNIQUE-MARKER-XYZ" })); const lines = renderPromptCard({ state, theme, width: 60, cursorOn: false }); @@ -251,6 +279,127 @@ describe("renderPromptCard", () => { assert.ok(joined.includes("UNIQUE-MARKER-XYZ"), "message text must appear in output"); }); + test("renders an identity-only awaiting-input attribution banner", () => { + const runId = "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f"; + const question = "Ship this change?"; + const state = createPromptCardState(makePrompt({ message: question })); + const lines = renderPromptCard({ + state, + theme, + width: 80, + cursorOn: false, + identity: { runId, name: "build-check" }, + }); + const plain = lines.map(stripAnsi); + const bannerEnd = plain.findIndex((line) => line.startsWith("╰")); + assert.ok(bannerEnd >= 0, "attribution banner has a bottom border"); + const banner = plain.slice(0, bannerEnd + 1).join("\n"); + + assert.match(banner, /^╭ AWAITING INPUT /); + assert.ok(banner.includes(runId), "banner keeps the complete run id"); + assert.ok(banner.includes("build-check"), "banner keeps the workflow name"); + assert.doesNotMatch(banner, /Ship this change\?/); + assert.equal(plain.filter((line) => line.startsWith("╭ AWAITING INPUT ")).length, 1); + assert.ok( + lines[1]?.includes(`${hexToAnsi(statusColor("awaiting_input", theme))}${statusIcon("awaiting_input")}`), + ); + assert.ok(plain.join("\n").includes(question), "the existing prompt UI still renders the question below"); + }); + + test("keeps select questions visible as budgets grow and attribution appears", () => { + const questionMarkers = Array.from({ length: 4 }, (_, index) => `MONOTONIC-QUESTION-${index + 1}`); + const state = createPromptCardState( + makePrompt({ kind: "select", message: questionMarkers.join("\n"), choices: ["stable", "beta", "nightly"] }), + ); + let previousVisibleMarkers = new Set(); + for (let maxRows = 8; maxRows <= 24; maxRows += 1) { + const rendered = renderPromptCard({ + state, + theme, + width: 72, + cursorOn: false, + identity: { runId: "339e05a4-2289-408e-9076-d1a348f582ae", name: "build-check" }, + maxRows, + }) + .map(stripAnsi) + .join("\n"); + const visibleMarkers = new Set(questionMarkers.filter((marker) => rendered.includes(marker))); + const bannerIsVisible = rendered.includes("339e05a4-2289-408e-9076-d1a348f582ae"); + if (bannerIsVisible) { + assert.ok(visibleMarkers.size > 0, `maxRows=${maxRows} lets attribution starve the question`); + } + for (const marker of previousVisibleMarkers) { + assert.ok(visibleMarkers.has(marker), `increasing maxRows to ${maxRows} removes ${marker}`); + } + previousVisibleMarkers = visibleMarkers; + } + }); + + test("keeps attributed select and confirm question content visible and monotonic from 8 through 24 rows", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const workflowName = "middle-rung-workflow"; + const questionMarkers = Array.from({ length: 4 }, (_, index) => `LADDER-QUESTION-${index + 1}`); + const middleRungKinds = new Set(); + for (const kind of ["confirm", "select"] as const) { + const state = createPromptCardState( + makePrompt({ + kind, + message: questionMarkers.join("\n"), + choices: kind === "select" ? ["stable", "beta", "nightly"] : undefined, + }), + ); + let previousVisibleMarkers = new Set(); + for (let maxRows = 8; maxRows <= 24; maxRows += 1) { + const layout = renderPromptCardLayout({ + state, + theme, + width: 72, + cursorOn: false, + identity: { runId, name: workflowName }, + maxRows, + }); + const rendered = layout.lines.map(stripAnsi).join("\n"); + const visibleMarkers = new Set(questionMarkers.filter((marker) => rendered.includes(marker))); + const bannerIsVisible = rendered.includes(runId); + if (bannerIsVisible) { + assert.ok(layout.visibleQuestionRows >= 1, `${kind} maxRows=${maxRows} renders a zero-row question`); + if (!rendered.includes(workflowName)) middleRungKinds.add(kind); + } + for (const marker of previousVisibleMarkers) { + assert.ok(visibleMarkers.has(marker), `${kind} increasing maxRows to ${maxRows} removes ${marker}`); + } + previousVisibleMarkers = visibleMarkers; + } + } + assert.deepEqual([...middleRungKinds], ["confirm", "select"], "both prompt kinds must use the one-row rung"); + }); + + test("wraps the attribution id without breaking borders at narrow widths", () => { + const runId = "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f"; + const state = createPromptCardState(makePrompt({ message: "UNIQUE-PROMPT" })); + for (const width of [80, 40, 30, 20]) { + const lines = renderPromptCard({ + state, + theme, + width, + cursorOn: false, + identity: { runId, name: "build-check" }, + }); + const plain = lines.map(stripAnsi); + const expectedWidth = Math.max(22, width); + for (const line of plain) assert.equal(visibleWidth(line), expectedWidth); + const banner = plain.slice(0, plain.findIndex((line) => line.startsWith("╰")) + 1); + const idStart = banner.findIndex((line) => line.includes(runId.slice(0, 8))); + const nameRow = banner.findIndex((line, index) => index > idStart && line.includes("build-check")); + const renderedId = banner + .slice(idStart, nameRow) + .join("") + .replace(/[^0-9a-f-]/gi, ""); + assert.equal(renderedId, runId, `full id is retained at width ${width}`); + assert.ok(plain.every((line) => line.startsWith("╭") || line.startsWith("╰") || line.startsWith("│"))); + } + }); + test("response field uses rounded border chrome", () => { const state = createPromptCardState(makePrompt({ kind: "input" })); const lines = renderPromptCard({ state, theme, width: 60, cursorOn: false }); diff --git a/test/unit/run-detail-render.test.ts b/test/unit/run-detail-render.test.ts index a01982dbf..3420cebe5 100644 --- a/test/unit/run-detail-render.test.ts +++ b/test/unit/run-detail-render.test.ts @@ -88,12 +88,12 @@ describe("inspectRun", () => { } }); - test("resolves a short prefix to a single matching run", () => { + test("does not resolve a short prefix to a longer matching run", () => { const store = createStore(); store.recordRunStart(makeRun({ id: "abc123full-uuid", name: "wf" })); const result = inspectRun("abc123", { store }); - assert.equal(result.ok, true); - if (result.ok) assert.equal(result.detail.runId, "abc123full-uuid"); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.reason, "not_found"); }); test("derives mode=chain when stage count > 1", () => { @@ -132,8 +132,10 @@ describe("renderRunDetail — themed", () => { const out = renderRunDetail(detail, { theme: deriveGraphTheme({}), now }); const plain = stripAnsi(out); - // Rounded panel header carries short id, workflow name, and status. - assert.match(plain, /RUN abc123/); + // The full run id is in the identity row, while the title keeps the + // workflow name and state badge. + assert.match(plain, /RUN refactor-auth/); + assert.match(plain, /abc123uuid/); assert.match(plain, /refactor-auth/); assert.match(plain, /● running/); @@ -144,11 +146,9 @@ describe("renderRunDetail — themed", () => { assert.match(plain, /● planner/); assert.match(plain, /○ worker/); - // Active run gets the interrupt action hint (shortId crops to 6 chars). - assert.match(plain, /workflow interrupt\s+id=abc123/); + // Active run keeps the complete id in the interrupt action hint. + assert.match(plain, /workflow interrupt\s+id=abc123uuid/); assert.doesNotMatch(plain, /workflow resume/); - // Pill label uses the short id too. - assert.match(plain, /RUN abc123/); }); test("paused run renders paused badges, summary state, and resume hint", () => { @@ -167,11 +167,12 @@ describe("renderRunDetail — themed", () => { const out = renderRunDetail(detail, { theme: deriveGraphTheme({}), now, width: 100 }); const plain = stripAnsi(out); - assert.match(plain, /RUN pause1/); + assert.match(plain, /RUN tournament/); + assert.match(plain, /pause123uuid/); assert.match(plain, /tournament/); assert.match(plain, /❚❚ paused/); assert.match(plain, /state\s+❚❚ paused/); - assert.match(plain, /workflow resume\s+id=pause1/); + assert.match(plain, /workflow resume\s+id=pause123uuid/); assert.match(plain, /continue workflow/); assert.doesNotMatch(plain, /workflow interrupt/); assert.doesNotMatch(plain, /○ pending/); @@ -179,9 +180,10 @@ describe("renderRunDetail — themed", () => { test("ended run swaps the action hint to resume and reports duration", () => { const now = 1_000_000; + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; const detail = detailFromRun( makeRun({ - id: "donerunid", + id: runId, name: "scan-deps", status: "completed", startedAt: now - 60_000, @@ -193,8 +195,7 @@ describe("renderRunDetail — themed", () => { const out = renderRunDetail(detail, { theme: deriveGraphTheme({}), now }); const plain = stripAnsi(out); assert.match(plain, /✓ completed/); - // shortId() crops the pill label and the action hint to 6 chars. - assert.match(plain, /workflow resume\s+id=doneru/); + assert.ok(plain.includes(`id=${runId}`)); assert.match(plain, /started\s+00:15:40/); assert.match(plain, /ended\s+00:16:32/); assert.doesNotMatch(plain, /\([^)]*ago\)/); @@ -280,12 +281,12 @@ describe("renderRunDetail — themed", () => { describe("renderRunDetail — plain", () => { test("plain mode (no theme) is ANSI-free and includes rounded panel chrome", () => { - // shortId() truncates run ids to 6 chars for the pill label. + // The full id is rendered in the body rather than shortened in the title. const detail = detailFromRun(makeRun({ id: "scratch01" })); const out = renderRunDetail(detail); assert.doesNotMatch(out, /\x1b\[/); - assert.match(out, /╭ RUN scratc/); - assert.match(out, /refactor-auth/); + assert.match(out, /╭ RUN refactor-auth/); + assert.match(out, /run id\s+scratch01/); assert.match(out, /╰─+╯/); }); }); diff --git a/test/unit/runtime-07.test.ts b/test/unit/runtime-07.test.ts index 20b9a48ab..f169595bf 100644 --- a/test/unit/runtime-07.test.ts +++ b/test/unit/runtime-07.test.ts @@ -21,6 +21,7 @@ import type { WorkflowToolResult } from "../../packages/workflows/src/extension/ import { renderResult } from "../../packages/workflows/src/extension/render-result.js"; import type { StageAdapters, StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; import type { createStore } from "../../packages/workflows/src/shared/store.js"; +import { statusIcon } from "../../packages/workflows/src/tui/status-helpers.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; // --------------------------------------------------------------------------- @@ -109,18 +110,20 @@ function _fakeStageSession(): StageSessionRuntime { describe("renderResult — run variant", () => { test("running run renders a dispatch confirmation card", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; const out = renderResult({ action: "run", name: "hello-world", - runId: "abc-123", + runId, status: "running", - message: 'Workflow "hello-world" started in background (runId: abc-123).', + message: `Workflow "hello-world" started in background (runId: ${runId}).`, stages: [], }); - assert.ok(out.includes("abc-123")); - assert.ok(out.includes("hello-world")); - assert.ok(out.includes("● running")); - assert.ok(out.includes("/workflow connect abc-123")); + const plain = out.replace(/\u001b\[[0-9;]*m/g, ""); + assert.ok(plain.includes(runId)); + assert.ok(plain.includes("hello-world")); + assert.ok(plain.includes(`${statusIcon("running")} running`)); + assert.ok(plain.includes(`/workflow connect ${runId}`)); }); test("failed run shows error", () => { diff --git a/test/unit/session-list.test.ts b/test/unit/session-list.test.ts index 70b22aeca..00e9c8236 100644 --- a/test/unit/session-list.test.ts +++ b/test/unit/session-list.test.ts @@ -20,13 +20,15 @@ function makeRun(over: Partial): RunSnapshot { error: over.error, }; } -test("session list renders the band-header chrome with both runs and a detail hint", () => { +test("session list renders full run ids with the band-header chrome and a detail hint", () => { const theme = deriveGraphTheme({}); const now = 100_000; + const activeRunId = "11111111-2222-4333-8444-555555555555"; + const completedRunId = "22222222-3333-4444-8555-666666666666"; const runs = [ - makeRun({ id: "11111111-...", name: "tournament", status: "running", startedAt: now - 30_000 }), + makeRun({ id: activeRunId, name: "tournament", status: "running", startedAt: now - 30_000 }), makeRun({ - id: "22222222-...", + id: completedRunId, name: "research", status: "completed", startedAt: now - 60_000, @@ -39,17 +41,16 @@ test("session list renders the band-header chrome with both runs and a detail hi // Outline-pill band header (DESIGN.md §5). assert.match(out, /BACKGROUND/); assert.match(out, /2 runs/); - // Both runs are listed with bolded names. + // Both runs are listed with bolded names and complete identifiers. assert.match(out, /tournament/); assert.match(out, /research/); - // Short-id (6 chars) leads each entry. - assert.match(out, /111111/); - assert.match(out, /222222/); + assert.ok(out.includes(activeRunId)); + assert.ok(out.includes(completedRunId)); // Status count badges per band-header contract. assert.match(out, /● 1/); assert.match(out, /✓ 1/); - // Trailing hint nudges drill-down via the rich detail surface. - assert.match(out, /\/workflow status \w+/); + // Trailing hint nudges drill-down via the rich detail surface using the complete identifier. + assert.ok(out.includes(`/workflow status ${activeRunId}`)); }); test("session list includeAll:true includes old retained terminal runs", () => { diff --git a/test/unit/session-picker.test.ts b/test/unit/session-picker.test.ts index 9e40856d1..1c69abe59 100644 --- a/test/unit/session-picker.test.ts +++ b/test/unit/session-picker.test.ts @@ -308,7 +308,7 @@ test("renderSessionPicker emits header, sections, and footer hints", () => { assert.match(joined, /Connect to workflow run/); assert.match(joined, /ACTIVE/); assert.match(joined, /TERMINAL/); - assert.match(joined, /aaaa1111/); + assert.match(joined, /aaaa1111-0000-0000-0000-000000000000/); assert.match(joined, /tournament/); assert.match(joined, /Navigate/); assert.match(joined, /Connect/); @@ -343,6 +343,47 @@ test("renderSessionPicker clamps long and wide workflow names to the panel width assert.match(lines.join("\n"), /…/); }); +test("keeps full ids and intact borders at narrow widths", () => { + const theme = deriveGraphTheme({}); + const state = createSessionPickerState(); + const runId = "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f"; + const row = { run: makeRun({ id: runId, name: "build-check" }), bucket: "active" as const }; + for (const width of [80, 79, 60, 40, 30, 20]) { + const plain = renderSessionPicker({ width, theme, rows: [row], state }).map((line) => + line.replace(/\u001b\[[0-9;]*m/g, ""), + ); + const bordered = plain.filter((line) => line.startsWith("╭") || line.startsWith("│") || line.startsWith("╰")); + assert.ok(bordered.length > 0); + const borderWidth = visibleWidth(bordered[0]!); + for (const line of bordered) assert.equal(visibleWidth(line), borderWidth); + const idStart = plain.findIndex((line) => line.includes(runId.slice(0, 8))); + let consumed = 0; + for (const line of plain.slice(idStart)) { + const fragment = line.replace(/[^0-9a-f-]/gi, ""); + if (fragment.length === 0) continue; + if (!runId.startsWith(fragment, consumed)) break; + consumed += fragment.length; + if (consumed === runId.length) break; + } + assert.equal(consumed, runId.length, `full id is retained at width ${width}`); + assert.doesNotMatch(plain.join("\n"), /d4e5f6a1-.*…/); + } +}); + +test("limits the run viewport so full-id rows keep the overlay height compact", () => { + const theme = deriveGraphTheme({}); + const state = createSessionPickerState(); + const rows = Array.from({ length: 12 }, (_, index) => ({ + run: makeRun({ + id: `${String(index).padStart(8, "0")}-0000-0000-0000-${String(index).padStart(12, "0")}`, + name: `workflow-${index}`, + }), + bucket: "active" as const, + })); + + assert.equal(renderSessionPicker({ width: 80, theme, rows, state }).length, 18); + assert.equal(renderSessionPicker({ width: 40, theme, rows, state }).length, 23); +}); test("renderSessionPicker emits a clean ╰────╯ bottom border with hints on a separate row below", () => { // Regression gate: previously the hints text was embedded inside the // bottom-corner row (`╰── ↑↓ Navigate · … ╯`), producing the broken diff --git a/test/unit/slash-dispatch-headless-basic.ts b/test/unit/slash-dispatch-headless-basic.ts index e95db79be..d75cacaad 100644 --- a/test/unit/slash-dispatch-headless-basic.ts +++ b/test/unit/slash-dispatch-headless-basic.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ChatSurfacePayload, ExtensionAPI, PiCommandContext, PiCommandOptions } from "./slash-dispatch-utils.js"; import { addFactoryStubs, @@ -20,6 +21,7 @@ import { } from "./slash-dispatch-utils.js"; installSlashDispatchTestHooks(); +const MISSING_RUN_ID = testRunId("definitely-missing"); describe("/workflow command in non-interactive (-p) mode (#1156 regressions)", () => { async function registerWorkflowCommand(): Promise<{ @@ -200,7 +202,7 @@ export default workflow({ test.sequential("/workflow status emits printable list and detail content when no UI is available", async () => { const { handler, sent } = await registerWorkflowCommand(); - const runId = `headless-printable-status-${Date.now()}`; + const runId = testRunId(`headless-printable-status-${Date.now()}`); recordTerminalRun(runId, "completed", { name: "headless-printable-workflow", }); @@ -296,8 +298,8 @@ export default workflow({ const { handler } = await registerWorkflowCommand(); await assertRejectsHeadlessCommand( - () => handler("status definitely-missing", headlessNoOpCtx()), - /Run not found: definitely-missing/, + () => handler(`status ${MISSING_RUN_ID}`, headlessNoOpCtx()), + new RegExp(`Run not found: ${MISSING_RUN_ID}`), ); }); @@ -305,8 +307,8 @@ export default workflow({ const { handler } = await registerWorkflowCommand(); await assertRejectsHeadlessCommand( - () => handler("connect definitely-missing", headlessNoOpCtx()), - /Run not found: definitely-missing/, + () => handler(`connect ${MISSING_RUN_ID}`, headlessNoOpCtx()), + new RegExp(`Run not found: ${MISSING_RUN_ID}`), ); }); @@ -321,7 +323,7 @@ export default workflow({ test.sequential("/workflow connect rejects visibly in headless mode", async () => { const { handler } = await registerWorkflowCommand(); - const runId = `headless-connect-valid-${Date.now()}`; + const runId = testRunId(`headless-connect-valid-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); await assertRejectsHeadlessCommand( @@ -332,7 +334,7 @@ export default workflow({ test.sequential("/workflow attach rejects visibly in headless mode", async () => { const { handler } = await registerWorkflowCommand(); - const runId = `headless-attach-valid-${Date.now()}`; + const runId = testRunId(`headless-attach-valid-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); await assertRejectsHeadlessCommand( @@ -343,7 +345,7 @@ export default workflow({ test.sequential("/workflow attach rejects visibly in headless mode", async () => { const { handler } = await registerWorkflowCommand(); - const runId = `headless-attach-stage-${Date.now()}`; + const runId = testRunId(`headless-attach-stage-${Date.now()}`); const stageId = "stage-headless-attach"; store.recordRunStart({ ...makeInflightRun(runId), diff --git a/test/unit/slash-dispatch-headless-control.ts b/test/unit/slash-dispatch-headless-control.ts index 8b70f9a01..7db8980d9 100644 --- a/test/unit/slash-dispatch-headless-control.ts +++ b/test/unit/slash-dispatch-headless-control.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { testRunId } from "../helpers/run-id.js"; import type { ChatSurfacePayload, ExtensionAPI, PiCommandContext, PiCommandOptions } from "./slash-dispatch-utils.js"; import { addFactoryStubs, @@ -22,6 +23,7 @@ import { } from "./slash-dispatch-utils.js"; installSlashDispatchTestHooks(); +const MISSING_RUN_ID = testRunId("definitely-missing"); beforeEach(() => setDurableBackend(new InMemoryDurableBackend())); afterEach(() => setDurableBackend(undefined)); @@ -153,8 +155,8 @@ describe("/workflow command in non-interactive (-p) mode (#1156 regressions)", ( const { handler } = await registerWorkflowCommand(); await assertRejectsHeadlessCommand( - () => handler("quit definitely-missing", headlessNoOpCtx()), - /Run not found: definitely-missing/, + () => handler(`quit ${MISSING_RUN_ID}`, headlessNoOpCtx()), + new RegExp(`Run not found: ${MISSING_RUN_ID}`), ); }); @@ -166,7 +168,9 @@ describe("/workflow command in non-interactive (-p) mode (#1156 regressions)", ( ["resume", "resume", /Resumed 1 stage\(s\)/], ])("/workflow %s emits displayable success output in headless mode", async (_label, action, expected) => { const { handler, sent } = await registerWorkflowCommand(); - const runId = `headless-success-${action}-${Date.now()}`; + const runId = testRunId( + `339e05a4-2289-408e-9076-d1a348f582${action === "interrupt" ? "01" : action === "quit" ? "02" : action === "pause" ? "03" : "04"}`, + ); const stageId = `stage-${action}`; if (action !== "reload") { @@ -193,11 +197,12 @@ describe("/workflow command in non-interactive (-p) mode (#1156 regressions)", ( assert.ok(outputs.length > 0, `expected /workflow ${action} to emit command output`); const content = outputs.map((message) => message.content ?? "").join("\n"); assert.match(content, expected); + if (action !== "reload") assert.ok(content.includes(runId), `expected full run id ${runId} in ${action} output`); }); test.sequential("/workflow interrupt --all emits displayable success output in headless mode", async () => { const { handler, sent } = await registerWorkflowCommand(); - const runId = `headless-interrupt-all-${Date.now()}`; + const runId = testRunId(`headless-interrupt-all-${Date.now()}`); const stageId = "stage-interrupt-all"; store.recordRunStart({ ...makeInflightRun(runId), @@ -224,7 +229,7 @@ describe("/workflow command in non-interactive (-p) mode (#1156 regressions)", ( test.sequential("/workflow quit --all emits displayable resumable success output in headless mode", async () => { const { handler, sent } = await registerWorkflowCommand(); - const runId = `headless-quit-all-${Date.now()}`; + const runId = testRunId(`headless-quit-all-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); diff --git a/test/unit/slash-dispatch-interrupt.ts b/test/unit/slash-dispatch-interrupt.ts index 5d473778c..09976286d 100644 --- a/test/unit/slash-dispatch-interrupt.ts +++ b/test/unit/slash-dispatch-interrupt.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { PiCommandContext } from "./slash-dispatch-utils.js"; import { addFactoryStubs, @@ -26,7 +27,12 @@ describe("/workflow run-control chat commands", () => { test.sequential.each([["completed"], ["failed"], ["killed"]] as const)( "top-level /workflow quit leaves %s terminal runs unchanged", async (status) => { - const runId = `slash-quit-${status}-${Date.now()}`; + const runId = + status === "completed" + ? "a1b2c3d4-3333-4333-8333-333333333333" + : status === "failed" + ? "a1b2c3d4-4444-4444-8444-444444444444" + : "a1b2c3d4-5555-4555-8555-555555555555"; recordTerminalRun(runId, status); const { workflowCmd } = await registerWorkflowCommand(); @@ -42,7 +48,7 @@ describe("/workflow run-control chat commands", () => { await workflowCmd.options.handler(`quit ${runId}`, ctx); const joined = msgs.join("\n"); - assert.match(joined, /already ended/i); + assert.ok(joined.includes(`Run ${runId} already ended.`)); assert.doesNotMatch(joined, /Run not found/); assert.equal(store.runs().find((r) => r.id === runId)?.status, status); }, @@ -85,7 +91,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("top-level /workflow quit pauses and preserves resumability without confirmation", async () => { - const runId = `quit-chat-${Date.now()}`; + const runId = testRunId(`quit-chat-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); const controller = new AbortController(); @@ -132,7 +138,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("top-level /workflow quit without a controllable stage reports that the run remains active", async () => { - const runId = `quit-no-control-${Date.now()}`; + const runId = testRunId(`quit-no-control-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { workflowCmd } = await registerWorkflowCommand(); const { ctx, messages } = buildCtx(); @@ -186,7 +192,7 @@ describe("/workflow run-control chat commands", () => { ])( "top-level /workflow quit %s rejects yes compatibility as an ordinary unsupported target", async (argsTemplate, unsupportedToken) => { - const runId = `quit-unsupported-confirmation-${unsupportedToken}-${Date.now()}`; + const runId = testRunId(`quit-unsupported-confirmation-${unsupportedToken}-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); const { workflowCmd } = await registerWorkflowCommand(); @@ -197,12 +203,12 @@ describe("/workflow run-control chat commands", () => { const run = store.runs().find((candidate) => candidate.id === runId); assert.equal(run?.status, "running"); assert.equal(run?.exitReason, undefined); - assert.match(messages.join("\n"), new RegExp(`Run not found: ${unsupportedToken}`)); + assert.match(messages.join("\n"), /Run id must be a full 36-character UUID/); }, ); test.sequential("top-level /workflow quit without an id defaults to the active run", async () => { - const runId = `quit-active-${Date.now()}`; + const runId = testRunId(`quit-active-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); const { workflowCmd } = await registerWorkflowCommand(); @@ -221,7 +227,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("removed /workflow kill is not a compatibility alias for quit", async () => { - const runId = `removed-kill-${Date.now()}`; + const runId = testRunId(`removed-kill-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { workflowCmd } = await registerWorkflowCommand(); const { ctx, messages } = buildCtx(); @@ -239,7 +245,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("top-level /workflow interrupt defaults to the active run", async () => { - const runId = `interrupt-active-${Date.now()}`; + const runId = testRunId(`interrupt-active-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { pi, commands } = buildMockPi(); @@ -270,7 +276,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("top-level /workflow interrupt reports no active stages without confirmation", async () => { - const runId = `interrupt-chat-${Date.now()}`; + const runId = testRunId(`interrupt-chat-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { pi, commands } = buildMockPi(); @@ -306,7 +312,7 @@ describe("/workflow run-control chat commands", () => { }); test.sequential("top-level /workflow reload stays available while workflows are in flight", async () => { - const runId = `reload-slash-inflight-${Date.now()}`; + const runId = testRunId(`reload-slash-inflight-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { pi, commands } = buildMockPi(); diff --git a/test/unit/slash-dispatch-resume.ts b/test/unit/slash-dispatch-resume.ts index 299f76bb8..289575283 100644 --- a/test/unit/slash-dispatch-resume.ts +++ b/test/unit/slash-dispatch-resume.ts @@ -5,6 +5,7 @@ import { openCompletedDurableWorkflow } from "../../packages/workflows/src/durab // InMemoryDurableBackend is already bound at line 46. Bun tolerated the repeated // binding; every standards-conforming parser rejects it, and vitest's did. import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { testRunId } from "../helpers/run-id.js"; import type { PiCommandContext, PiCustomOverlayFactoryTui, @@ -28,7 +29,7 @@ installSlashDispatchTestHooks(); describe("/workflow resume — active run is refused", () => { test.sequential("resuming an already-running run refuses and points at /workflow connect", async () => { - const runId = `resume-slash-overlay-${Date.now()}`; + const runId = testRunId(`resume-slash-overlay-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const openCalls: Array<{ overlay: boolean }> = []; @@ -73,7 +74,7 @@ describe("/workflow resume — active run is refused", () => { }); test.sequential("active run resume output does NOT include 'still active — no resume needed'", async () => { - const runId = `resume-nomsg-${Date.now()}`; + const runId = testRunId(`resume-nomsg-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const { pi, commands } = buildMockPi(); @@ -143,7 +144,7 @@ describe("/workflow resume — exact live fast path", () => { const factoryModule = await import("../../packages/workflows/src/extension/index.js"); factoryModule.default(pi); const handler = commands.find((command) => command.name === "workflow")!.options.handler; - const runId = `headless-fast-resume-${Date.now()}`; + const runId = testRunId(`headless-fast-resume-${Date.now()}`); const stageId = "stage-fast-resume"; backend.registerWorkflow({ workflowId: runId, @@ -199,7 +200,7 @@ describe("/workflow resume — exact live fast path", () => { const factoryModule = await import("../../packages/workflows/src/extension/index.js"); factoryModule.default(pi); const handler = commands.find((command) => command.name === "workflow")!.options.handler; - const runId = `nested-paused-resume-${Date.now()}`; + const runId = testRunId(`nested-paused-resume-${Date.now()}`); const stageId = "stage-nested-paused"; store.recordRunStart({ ...makeInflightRun(runId), @@ -220,7 +221,7 @@ describe("/workflow resume — exact live fast path", () => { await assert.rejects( handler(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }), - /No resumable workflow found for id\/prefix/, + /No resumable workflow found for id:/, ); assert.ok(backend.completedCatalogCalls > 0, "nested child must continue through the top-level resolver"); @@ -259,9 +260,9 @@ describe("/workflow attach ", () => { factoryModule.default(pi); const handler = commands.find((command) => command.name === "workflow")!.options.handler; - const rootRunId = `attach-root-${Date.now()}`; - const childOneRunId = `attach-child-one-${Date.now()}`; - const childTwoRunId = `attach-child-two-${Date.now()}`; + const rootRunId = testRunId(`attach-root-${Date.now()}`); + const childOneRunId = testRunId(`attach-child-one-${Date.now()}`); + const childTwoRunId = testRunId(`attach-child-two-${Date.now()}`); const nestedStageId = "review"; const boundary = (id: string, childRunId: string) => ({ id, @@ -317,8 +318,8 @@ describe("/workflow attach ", () => { }); test.sequential("routes /workflow attach through an actually hydrated durable child owner", async () => { - const rootRunId = `attach-hydrated-root-${Date.now()}`; - const childRunId = `attach-hydrated-child-${Date.now()}`; + const rootRunId = testRunId(`attach-hydrated-root-${Date.now()}`); + const childRunId = testRunId(`attach-hydrated-child-${Date.now()}`); const boundaryId = "hydrated-boundary"; const stageId = "hydrated-review"; const replayKey = "workflow:hydrated-child:1"; @@ -447,7 +448,7 @@ describe("/workflow attach ", () => { assert.equal(overlayOpens, 1); assert.equal(attachedOwner, childRunId); - assert.match(notifications.join("\n"), /Attached to .* stage hydrated\./); + assert.match(notifications.join("\n"), /Attached to .* stage hydrated-review\./); }); }); diff --git a/test/unit/slash-dispatch-tool-continuation-blocked.ts b/test/unit/slash-dispatch-tool-continuation-blocked.ts index b48bd2131..e8d21f9af 100644 --- a/test/unit/slash-dispatch-tool-continuation-blocked.ts +++ b/test/unit/slash-dispatch-tool-continuation-blocked.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { describe, test } from "vitest"; import { getDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime, SessionEntry, WorkflowPersistencePort } from "./slash-dispatch-utils.js"; import { assert, @@ -61,7 +62,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test("makeExecuteWorkflowTool resume starts linked continuation for active blocked recoverable workflow", async () => { - const sourceRunId = `resume-tool-blocked-${Date.now()}`; + const sourceRunId = testRunId(`resume-tool-blocked-${Date.now()}`); const def = workflow({ name: "tool-resume-blocked-wf", description: "", @@ -197,7 +198,7 @@ describe("tool run-control actions", () => { assert.equal(durableBackend.getWorkflow(sourceRunId)?.status, "blocked"); }); test("active blocked continuation atomically claims its durable source", async () => { - const sourceRunId = `resume-claim-${Date.now()}`; + const sourceRunId = testRunId(`resume-claim-${Date.now()}`); const def = workflow({ name: "claim-blocked-wf", description: "", @@ -268,7 +269,7 @@ describe("tool run-control actions", () => { }); test("makeExecuteWorkflowTool resume finalizes restored blocked source run", async () => { - const sourceRunId = `resume-tool-restored-blocked-${Date.now()}`; + const sourceRunId = testRunId(`resume-tool-restored-blocked-${Date.now()}`); const def = workflow({ name: "tool-resume-restored-blocked-wf", description: "", diff --git a/test/unit/slash-dispatch-tool-continuation-failed.ts b/test/unit/slash-dispatch-tool-continuation-failed.ts index ebfa389d3..ab74ac3f7 100644 --- a/test/unit/slash-dispatch-tool-continuation-failed.ts +++ b/test/unit/slash-dispatch-tool-continuation-failed.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -59,7 +60,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test("makeExecuteWorkflowTool resume starts linked continuation for failed resumable workflow", async () => { - const sourceRunId = `resume-tool-source-${Date.now()}`; + const sourceRunId = testRunId(`resume-tool-source-${Date.now()}`); const def = workflow({ name: "tool-resume-wf", description: "", diff --git a/test/unit/slash-dispatch-tool-control.ts b/test/unit/slash-dispatch-tool-control.ts index 0ebc3deba..37b91efe7 100644 --- a/test/unit/slash-dispatch-tool-control.ts +++ b/test/unit/slash-dispatch-tool-control.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { describe, test } from "vitest"; import { resumeRun } from "../../packages/workflows/src/runs/background/status.js"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -81,7 +82,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool quit without runId pauses the active run resumably", async () => { - const runId = `quit-tool-active-${Date.now()}`; + const runId = testRunId(`quit-tool-active-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); const controller = new AbortController(); @@ -108,7 +109,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool quit reports a live run with no controllable stage as unchanged", async () => { - const runId = `quit-tool-no-control-${Date.now()}`; + const runId = testRunId(`quit-tool-no-control-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const result = await makeToolHandler()({ action: "quit", runId }, {} as never); @@ -119,14 +120,20 @@ describe("tool run-control actions", () => { assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); }); - test.sequential("makeExecuteWorkflowTool quit supports unique run id prefixes", async () => { - const runId = `quit-tool-prefix-${Date.now()}`; + test.sequential("makeExecuteWorkflowTool rejects run id prefixes but accepts full ids", async () => { + const runId = testRunId(`quit-tool-prefix-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage"); const handler = makeToolHandler(); - const result = await handler({ action: "quit", runId: runId.slice(0, 12) }, {} as never); + const prefixResult = await handler({ action: "quit", runId: runId.slice(0, 12) }, {} as never); + assert.equal(prefixResult.action, "quit"); + const prefix = prefixResult as { action: string; status: string; message: string }; + assert.equal(prefix.status, "noop"); + assert.match(prefix.message, /Run id must be a full 36-character UUID/); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + const result = await handler({ action: "quit", runId }, {} as never); assert.equal(result.action, "quit"); const r = result as { action: string; status: string; runId: string }; assert.equal(r.status, "paused"); @@ -202,7 +209,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool quit all reports rejected pause details", async () => { - const runId = `quit-tool-rejected-${Date.now()}`; + const runId = testRunId(`quit-tool-rejected-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); registerTestStageHandle(runId, "quit-stage", "running", { pause: async () => { @@ -221,7 +228,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool rejected resume returns noop and keeps the run paused", async () => { - const runId = `resume-tool-rejected-${Date.now()}`; + const runId = testRunId(`resume-tool-rejected-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordRunPaused(runId); registerTestStageHandle(runId, "resume-stage", "paused", { @@ -257,7 +264,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool rejects all run-control with stageId", async () => { - const runId = `pause-tool-all-stage-${Date.now()}`; + const runId = testRunId(`pause-tool-all-stage-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const handler = makeToolHandler(); @@ -277,7 +284,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool interrupt without runId defaults to the active run", async () => { - const runId = `interrupt-tool-active-${Date.now()}`; + const runId = testRunId(`interrupt-tool-active-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const handler = makeToolHandler(); @@ -297,7 +304,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool pause reports pause wording for inactive stages", async () => { - const runId = `pause-tool-inactive-stage-${Date.now()}`; + const runId = testRunId(`pause-tool-inactive-stage-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-paused-1", @@ -329,7 +336,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool lists and inspects workflow stages", async () => { - const runId = `stage-tool-list-${Date.now()}`; + const runId = testRunId(`stage-tool-list-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-running-1", diff --git a/test/unit/slash-dispatch-tool-delivery.ts b/test/unit/slash-dispatch-tool-delivery.ts index f63851d3a..1700b91bb 100644 --- a/test/unit/slash-dispatch-tool-delivery.ts +++ b/test/unit/slash-dispatch-tool-delivery.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -58,7 +59,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool sends explicit prompt delivery to live handles", async () => { - const runId = `stage-tool-send-prompt-live-${Date.now()}`; + const runId = testRunId(`stage-tool-send-prompt-live-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-live", @@ -99,7 +100,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool sends explicit steer delivery to live handles", async () => { - const runId = `stage-tool-send-steer-live-${Date.now()}`; + const runId = testRunId(`stage-tool-send-steer-live-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-steer-live", @@ -142,7 +143,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool promptId mismatch does not fall through to live followUp", async () => { - const runId = `stage-tool-send-prompt-mismatch-${Date.now()}`; + const runId = testRunId(`stage-tool-send-prompt-mismatch-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-mismatch", @@ -194,7 +195,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool treats explicit empty text prompt payload as an answer", async () => { - const runId = `stage-tool-send-empty-${Date.now()}`; + const runId = testRunId(`stage-tool-send-empty-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-empty", @@ -231,7 +232,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool treats explicit empty response prompt payload as an answer", async () => { - const runId = `stage-tool-send-empty-response-${Date.now()}`; + const runId = testRunId(`stage-tool-send-empty-response-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-empty-response", @@ -276,7 +277,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool ignores explicit undefined prompt payloads", async () => { - const runId = `stage-tool-send-undefined-${Date.now()}`; + const runId = testRunId(`stage-tool-send-undefined-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-undefined", diff --git a/test/unit/slash-dispatch-tool-finalize.ts b/test/unit/slash-dispatch-tool-finalize.ts index 82919c722..8df4e1536 100644 --- a/test/unit/slash-dispatch-tool-finalize.ts +++ b/test/unit/slash-dispatch-tool-finalize.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -57,7 +58,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool resume surfaces workflow_not_found for failed resumable run without registry definition", async () => { - const runId = `resume-tool-failed-${Date.now()}`; + const runId = testRunId(`resume-tool-failed-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-a", diff --git a/test/unit/slash-dispatch-tool-lifecycle.ts b/test/unit/slash-dispatch-tool-lifecycle.ts index d681a2c88..54f38f46e 100644 --- a/test/unit/slash-dispatch-tool-lifecycle.ts +++ b/test/unit/slash-dispatch-tool-lifecycle.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -120,7 +121,7 @@ describe("tool run-control actions", () => { }); test.sequential("registered workflow tool content defaults to path-only transcripts and supports explicit previews", async () => { - const runId = `tool-content-transcript-${Date.now()}`; + const runId = testRunId(`tool-content-transcript-${Date.now()}`); const longText = `start-${"x".repeat(180)}-sentinel-end`; const toolOutput = `tool-output-${"y".repeat(120)}-sentinel-end`; const sessionFile = "C:\\Users\\atomic runner\\tool-content.jsonl"; diff --git a/test/unit/slash-dispatch-tool-prompts.ts b/test/unit/slash-dispatch-tool-prompts.ts index 82c837e8e..504f15003 100644 --- a/test/unit/slash-dispatch-tool-prompts.ts +++ b/test/unit/slash-dispatch-tool-prompts.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -60,7 +61,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool answers stage pending prompts", async () => { - const runId = `stage-tool-send-${Date.now()}`; + const runId = testRunId(`stage-tool-send-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-1", @@ -98,7 +99,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool refuses workflow send answers for custom prompt nodes", async () => { - const runId = `stage-tool-send-custom-${Date.now()}`; + const runId = testRunId(`stage-tool-send-custom-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-custom-prompt", @@ -144,7 +145,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool tags brokered prompt answers as workflow-tool sourced", async () => { - const runId = `stage-tool-send-broker-${Date.now()}`; + const runId = testRunId(`stage-tool-send-broker-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-broker-prompt", @@ -200,7 +201,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool leaves pending prompts untouched when payload is omitted", async () => { - const runId = `stage-tool-send-omitted-${Date.now()}`; + const runId = testRunId(`stage-tool-send-omitted-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-omitted", @@ -237,7 +238,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool delivery answer without a pending prompt does not fall through to live followUp", async () => { - const runId = `stage-tool-send-answer-no-prompt-${Date.now()}`; + const runId = testRunId(`stage-tool-send-answer-no-prompt-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-no-prompt", @@ -278,7 +279,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool auto delivery without a targeted prompt starts an idle live prompt", async () => { - const runId = `stage-tool-send-auto-live-${Date.now()}`; + const runId = testRunId(`stage-tool-send-auto-live-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-auto-live", diff --git a/test/unit/slash-dispatch-tool-reload-resume.ts b/test/unit/slash-dispatch-tool-reload-resume.ts index fff34a0a0..5b4922bb2 100644 --- a/test/unit/slash-dispatch-tool-reload-resume.ts +++ b/test/unit/slash-dispatch-tool-reload-resume.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime, PiToolOpts, WorkflowToolArgs } from "./slash-dispatch-utils.js"; import { addFactoryStubs, @@ -163,7 +164,7 @@ describe("tool run-control actions", () => { const handler = makeExecuteWorkflowTool(runtime, () => { reloads += 1; }); - const runId = `reload-inflight-${Date.now()}`; + const runId = testRunId(`reload-inflight-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const result = await handler({ action: "reload", reason: "test" }, {} as never); @@ -199,29 +200,36 @@ describe("tool run-control actions", () => { assert.match(reload.message, /Reload failed: bad workflow config/); }); - test.sequential("makeExecuteWorkflowTool returns ambiguous run-prefix messages", async () => { - store.recordRunStart(makeInflightRun("ambiguous-run-a")); - store.recordRunStart(makeInflightRun("ambiguous-run-b")); + test.sequential("makeExecuteWorkflowTool rejects run prefixes while full ids remain independently addressable", async () => { + const anchor = testRunId("ambiguous-run"); + const firstId = `${anchor.slice(0, 24)}111111111111`; + const secondId = `${anchor.slice(0, 24)}222222222222`; + store.recordRunStart(makeInflightRun(firstId)); + store.recordRunStart(makeInflightRun(secondId)); const handler = makeToolHandler(); - const result = await handler({ action: "quit", runId: "ambiguous-run" }, {} as never); - - assert.equal(result.action, "quit"); - const r = result as { action: string; status: string; message: string }; - assert.equal(r.status, "noop"); - assert.match(r.message, /Ambiguous run prefix/); - assert.equal( - store.runs().some((run) => run.id === "ambiguous-run-a"), - true, - ); - assert.equal( - store.runs().some((run) => run.id === "ambiguous-run-b"), - true, - ); + const malformed = await handler({ action: "quit", runId: anchor.slice(0, 12) }, {} as never); + assert.equal(malformed.action, "quit"); + const malformedResult = malformed as { action: string; status: string; message: string }; + assert.equal(malformedResult.status, "noop"); + assert.match(malformedResult.message, /Run id must be a full 36-character UUID/); + + for (const fullId of [firstId, secondId]) { + const result = await handler({ action: "quit", runId: fullId }, {} as never); + assert.equal(result.action, "quit"); + const r = result as { action: string; status: string; runId: string; message: string }; + assert.equal(r.status, "noop"); + assert.equal(r.runId, fullId); + assert.match(r.message, /no controllable stages.*remains active/i); + assert.equal( + store.runs().some((run) => run.id === fullId), + true, + ); + } }); - test.sequential("makeExecuteWorkflowTool resume accepts run prefixes, stage names, and messages", async () => { - const runId = `resume-tool-stage-${Date.now()}`; + test.sequential("makeExecuteWorkflowTool resume accepts full run ids, exact stage names, and messages", async () => { + const runId = testRunId(`resume-tool-stage-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-abc123", @@ -235,7 +243,7 @@ describe("tool run-control actions", () => { const result = await handler( { action: "resume", - runId: runId.slice(0, 12), + runId, stageId: "review-stage", message: "continue please", }, @@ -255,7 +263,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool resume against in-flight run returns status:'ok'", async () => { - const runId = `resume-tool-ok-${Date.now()}`; + const runId = testRunId(`resume-tool-ok-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); const handler = makeToolHandler(); @@ -268,20 +276,20 @@ describe("tool run-control actions", () => { assert.equal(r.runId, runId); }); - test.sequential("makeExecuteWorkflowTool resume rejects ambiguous stage prefixes", async () => { - const runId = `resume-tool-ambiguous-stage-${Date.now()}`; + test.sequential("makeExecuteWorkflowTool rejects partial stage names but preserves exact-name ambiguity", async () => { + const runId = testRunId(`resume-tool-ambiguous-stage-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); for (const stageId of ["ambiguous-stage-aaa", "ambiguous-stage-bbb"]) { store.recordStageStart(runId, { id: stageId, - name: stageId, + name: "ambiguous-stage", status: "failed", parentIds: [], toolEvents: [], }); store.recordStageEnd(runId, { id: stageId, - name: stageId, + name: "ambiguous-stage", status: "failed", parentIds: [], toolEvents: [], @@ -294,15 +302,16 @@ describe("tool run-control actions", () => { }); const handler = makeToolHandler(); - const result = await handler({ action: "resume", runId, stageId: "ambiguous-stage" }, {} as never); + const partial = await handler({ action: "resume", runId, stageId: "ambiguous-stag" }, {} as never); + assert.equal(partial.action, "resume"); + const partialResult = partial as { action: string; status: string; runId: string; message: string }; + assert.equal(partialResult.status, "noop"); + assert.equal(partialResult.runId, runId); + assert.match(partialResult.message, /Stage not found/); - assert.equal(result.action, "resume"); - const r = result as { - action: string; - status: string; - runId: string; - message: string; - }; + const exactName = await handler({ action: "resume", runId, stageId: "ambiguous-stage" }, {} as never); + assert.equal(exactName.action, "resume"); + const r = exactName as { action: string; status: string; runId: string; message: string }; assert.equal(r.status, "noop"); assert.equal(r.runId, runId); assert.match(r.message, /Ambiguous stage identifier/); diff --git a/test/unit/slash-dispatch-tool-snapshots.ts b/test/unit/slash-dispatch-tool-snapshots.ts index b96497b0e..829bbf846 100644 --- a/test/unit/slash-dispatch-tool-snapshots.ts +++ b/test/unit/slash-dispatch-tool-snapshots.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -57,7 +58,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool returns no truncation marker for tail zero", async () => { - const runId = `stage-tool-transcript-tail-zero-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-tail-zero-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-tail-zero-1", @@ -98,7 +99,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool returns final snapshot error after timestamped tools", async () => { - const runId = `stage-tool-transcript-error-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-error-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-error-1", @@ -133,7 +134,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool keeps terminal snapshot entries after tools for tied timestamps", async () => { - const runId = `stage-tool-transcript-tie-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-tie-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-tie-1", @@ -175,7 +176,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool preserves empty final snapshot result after tools", async () => { - const runId = `stage-tool-transcript-empty-result-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-empty-result-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-empty-result-1", @@ -216,7 +217,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool preserves empty final snapshot error after tools", async () => { - const runId = `stage-tool-transcript-empty-error-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-empty-error-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-empty-error-1", diff --git a/test/unit/slash-dispatch-tool-stages.ts b/test/unit/slash-dispatch-tool-stages.ts index d6a5345d2..520606230 100644 --- a/test/unit/slash-dispatch-tool-stages.ts +++ b/test/unit/slash-dispatch-tool-stages.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -58,7 +59,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool stages clones pending prompts", async () => { - const runId = `stage-tool-prompt-clone-${Date.now()}`; + const runId = testRunId(`stage-tool-prompt-clone-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-prompt-clone", @@ -109,7 +110,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool stages supports all stage status filters", async () => { - const runId = `stage-tool-status-filters-${Date.now()}`; + const runId = testRunId(`stage-tool-status-filters-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); for (const status of [ "pending", @@ -160,44 +161,59 @@ describe("tool run-control actions", () => { assert.match(missingStages.error ?? "", /No active run to inspect/); assert.match(renderResult(missing, { plain: true }), /No active run to inspect/); - store.recordRunStart(makeInflightRun("stages-ambiguous-run-a")); - store.recordRunStart(makeInflightRun("stages-ambiguous-run-b")); - const ambiguous = await handler({ action: "stages", runId: "stages-ambiguous-run" }, {} as never); - assert.equal(ambiguous.action, "stages"); - const ambiguousStages = ambiguous as { - action: string; - runId: string; - error?: string; - stages: unknown[]; - }; - assert.equal(ambiguousStages.runId, "stages-ambiguous-run"); - assert.deepEqual(ambiguousStages.stages, []); - assert.match(ambiguousStages.error ?? "", /Ambiguous run prefix/); - assert.match(renderResult(ambiguous, { plain: true }), /Ambiguous run prefix/); + const firstId = testRunId("stages-ambiguous-run-a"); + const secondId = testRunId("stages-ambiguous-run-b"); + store.recordRunStart(makeInflightRun(firstId)); + store.recordRunStart(makeInflightRun(secondId)); + const prefix = firstId.slice(0, 12); + const malformed = await handler({ action: "stages", runId: prefix }, {} as never); + assert.equal(malformed.action, "stages"); + const malformedStages = malformed as { action: string; runId: string; error?: string; stages: unknown[] }; + assert.equal(malformedStages.runId, prefix); + assert.deepEqual(malformedStages.stages, []); + assert.match(malformedStages.error ?? "", /Run id must be a full 36-character UUID/); + assert.match(renderResult(malformed, { plain: true }), /Run id must be a full 36-character UUID/); + assert.equal( + store.runs().some((run) => run.id === firstId), + true, + ); + assert.equal( + store.runs().some((run) => run.id === secondId), + true, + ); }); - test.sequential("workflow status rejects an ambiguous run ID exactly as abbreviated in its list", async () => { - const firstId = "abc123-first-full-run-id"; - const secondId = "abc123-second-full-run-id"; + test.sequential("workflow status rejects run prefixes while full ids remain independently addressable", async () => { + const anchor = testRunId("status-shared-prefix"); + const firstId = `${anchor.slice(0, 24)}111111111111`; + const secondId = `${anchor.slice(0, 24)}222222222222`; store.recordRunStart(makeInflightRun(firstId)); store.recordRunStart(makeInflightRun(secondId)); const handler = makeToolHandler(); const listed = await handler({ action: "status" }, {} as never); const rendered = renderResult(listed, { plain: true }); - assert.match(rendered, /abc123/); - - const detail = await handler({ action: "status", runId: "abc123" }, {} as never); - assert.equal(detail.action, "statusDetail"); - const statusDetail = detail as { action: string; runId: string; error?: string }; - assert.equal(statusDetail.runId, "abc123"); - assert.match(statusDetail.error ?? "", /Ambiguous run prefix/); - assert.match(statusDetail.error ?? "", new RegExp(firstId.slice(0, 12))); - assert.match(statusDetail.error ?? "", new RegExp(secondId.slice(0, 12))); + assert.ok(rendered.includes(firstId)); + assert.ok(rendered.includes(secondId)); + + const prefix = anchor.slice(0, 8); + const malformed = await handler({ action: "status", runId: prefix }, {} as never); + assert.equal(malformed.action, "statusDetail"); + const malformedDetail = malformed as { action: string; runId: string; error?: string }; + assert.equal(malformedDetail.runId, prefix); + assert.match(malformedDetail.error ?? "", /Run id must be a full 36-character UUID/); + + for (const fullId of [firstId, secondId]) { + const detail = await handler({ action: "status", runId: fullId }, {} as never); + assert.equal(detail.action, "statusDetail"); + const statusDetail = detail as { action: string; runId: string; error?: string }; + assert.equal(statusDetail.runId, fullId); + assert.equal(statusDetail.error, undefined); + } }); test.sequential("makeExecuteWorkflowTool returns chronologically final snapshot result after tools", async () => { - const runId = `stage-tool-transcript-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-1", diff --git a/test/unit/slash-dispatch-tool-transcripts.ts b/test/unit/slash-dispatch-tool-transcripts.ts index 86d6a6324..326c56621 100644 --- a/test/unit/slash-dispatch-tool-transcripts.ts +++ b/test/unit/slash-dispatch-tool-transcripts.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { describe, test } from "vitest"; +import { testRunId } from "../helpers/run-id.js"; import type { ExtensionRuntime } from "./slash-dispatch-utils.js"; import { assert, @@ -60,7 +61,7 @@ describe("tool run-control actions", () => { assert.match((result as { error?: string }).error ?? "", /workflows cannot invoke workflows/); } test.sequential("makeExecuteWorkflowTool applies limit and lets tail override limit", async () => { - const runId = `stage-tool-transcript-limit-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-limit-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-limit-1", @@ -121,7 +122,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool falls back to bounded preview when transcript path is unavailable", async () => { - const runId = `stage-tool-transcript-no-path-${Date.now()}`; + const runId = testRunId(`stage-tool-transcript-no-path-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-transcript-no-path-1", @@ -179,7 +180,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool labels empty live handles as live transcript source", async () => { - const runId = `stage-tool-live-empty-handle-${Date.now()}`; + const runId = testRunId(`stage-tool-live-empty-handle-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-live-empty-handle-1", @@ -217,7 +218,10 @@ describe("tool run-control actions", () => { test.sequential("makeExecuteWorkflowTool uses error transcript source for target errors", async () => { const handler = makeToolHandler(); - const result = await handler({ action: "transcript", runId: "missing-run", stageId: "stage" }, {} as never); + const result = await handler( + { action: "transcript", runId: testRunId("missing-run"), stageId: "stage" }, + {} as never, + ); assert.equal(result.action, "transcript"); const transcript = result as { @@ -230,7 +234,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool preserves empty live transcript text blocks", async () => { - const runId = `stage-tool-live-empty-block-${Date.now()}`; + const runId = testRunId(`stage-tool-live-empty-block-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-live-empty-block-1", @@ -270,7 +274,7 @@ describe("tool run-control actions", () => { }); test.sequential("makeExecuteWorkflowTool omits text for live non-text content blocks", async () => { - const runId = `stage-tool-live-non-text-${Date.now()}`; + const runId = testRunId(`stage-tool-live-non-text-${Date.now()}`); store.recordRunStart(makeInflightRun(runId)); store.recordStageStart(runId, { id: "stage-live-non-text-1", diff --git a/test/unit/stage-chat-header.test.ts b/test/unit/stage-chat-header.test.ts new file mode 100644 index 000000000..5f33babb2 --- /dev/null +++ b/test/unit/stage-chat-header.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; +import { StageChatView } from "../../packages/workflows/src/tui/stage-chat-view.js"; +import { makeHandle, stripAnsi } from "./stage-chat-view-helpers.js"; + +const SESSION_ID = "339e05a4-2289-408e-9076-d1a348f582ae"; + +function renderStageHeader(workflowName: string, stageName: string, width: number): string[] { + const store = createStore(); + store.recordRunStart({ + id: "run-1", + name: workflowName, + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart("run-1", { + id: "stage-a", + name: stageName, + status: "running", + parentIds: [], + toolEvents: [], + }); + const { handle } = makeHandle(); + Object.defineProperty(handle, "sessionId", { value: SESSION_ID }); + const viewportRows = 12; + const view = new StageChatView({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageId: "stage-a", + workflowName, + handle, + getViewportRows: () => viewportRows, + onDetach: () => {}, + onClose: () => {}, + }); + const rendered = view.render(width); + view.dispose(); + assert.equal(rendered.length, viewportRows, `width ${width}: frame row count`); + const separatorIndex = rendered.findIndex((line) => /^─+$/.test(stripAnsi(line))); + assert.ok(separatorIndex > 0, `width ${width}: header separator missing`); + return rendered.slice(0, separatorIndex); +} + +test("stage chat header preserves full names and session id monotonically from 40 through 120 columns", () => { + for (const [workflowName, stageName] of [ + ["publish-release", "implement"], + ["wf", "s"], + ] as const) { + let previousRowCount = Number.POSITIVE_INFINITY; + for (let width = 40; width <= 120; width++) { + const header = renderStageHeader(workflowName, stageName, width); + const plain = header.map(stripAnsi); + const joined = plain.join("\n"); + for (const [row, line] of plain.entries()) { + assert.ok(line.length <= width, `${workflowName}/${stageName}, width ${width}, row ${row}: ${line}`); + } + assert.match(joined, new RegExp(workflowName), `${workflowName}/${stageName}, width ${width}: workflow`); + assert.match(joined, new RegExp(stageName), `${workflowName}/${stageName}, width ${width}: stage`); + assert.ok(joined.includes(SESSION_ID), `${workflowName}/${stageName}, width ${width}: session`); + assert.doesNotMatch(joined, /…/, `${workflowName}/${stageName}, width ${width}: ellipsis`); + assert.ok( + header.length <= previousRowCount, + `${workflowName}/${stageName}, width ${width}: row count ${previousRowCount} -> ${header.length}`, + ); + previousRowCount = header.length; + } + } +}); diff --git a/test/unit/stage-chat-view-primitive-prompt-identity.test.ts b/test/unit/stage-chat-view-primitive-prompt-identity.test.ts new file mode 100644 index 000000000..79c5ca92d --- /dev/null +++ b/test/unit/stage-chat-view-primitive-prompt-identity.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { PendingPrompt } from "../../packages/workflows/src/shared/store-types.js"; +import { + createStore, + deriveGraphTheme, + FakePromptEditor, + makeFakeKeybindings, + makeHandle, + makePendingPrompt, + StageChatView, + setupRun, + stripAnsi, +} from "./stage-chat-view-helpers.js"; + +const RUN_ID = "339e05a4-2289-408e-9076-d1a348f582ae"; +const WORKFLOW_NAME = "primitive-attribution"; + +for (const kind of ["input", "editor"] as const satisfies readonly PendingPrompt["kind"][]) { + test(`attached-stage ${kind} prompt renders the full run identity above the primitive editor`, () => { + const store = createStore(); + setupRun(store, RUN_ID, "stage-a"); + const question = kind === "input" ? "What value should the workflow use?" : "Explain the workflow decision."; + const prompt = makePendingPrompt({ kind, message: question, initial: kind === "editor" ? "initial draft" : "" }); + assert.equal(store.recordStagePendingPrompt(RUN_ID, "stage-a", prompt), true); + const { handle } = makeHandle(); + const view = new StageChatView({ + store, + graphTheme: deriveGraphTheme({}), + runId: RUN_ID, + stageId: "stage-a", + workflowName: WORKFLOW_NAME, + handle, + onDetach: () => {}, + onClose: () => {}, + piTui: { + requestRender: () => {}, + terminal: { rows: 32, columns: 100 }, + } as never, + piTheme: {}, + piKeybindings: makeFakeKeybindings(), + piEditorFactory: () => new FakePromptEditor(), + }); + + const lines = view.render(100).map((line) => stripAnsi(line)); + view.dispose(); + const bannerStarts = lines + .map((line, index) => (/^╭ AWAITING INPUT ─*╮$/.test(line) ? index : -1)) + .filter((index) => index >= 0); + assert.equal(bannerStarts.length, 1, `${kind} must render exactly one AWAITING INPUT title`); + const bannerStart = bannerStarts[0]!; + const bannerEnd = lines.findIndex((line, index) => index > bannerStart && /^╰─+╯$/.test(line)); + assert.ok(bannerEnd > bannerStart, `${kind} attribution banner must have a bottom border`); + const banner = lines.slice(bannerStart, bannerEnd + 1).join("\n"); + const rendered = lines.join("\n"); + + assert.ok(banner.includes(RUN_ID), `${kind} attribution banner is missing the full run id:\n${rendered}`); + assert.ok( + banner.includes(WORKFLOW_NAME), + `${kind} attribution banner is missing the workflow name:\n${rendered}`, + ); + assert.equal(bannerEnd - bannerStart + 1, 4, `${kind} attribution banner must contain exactly two body rows`); + assert.doesNotMatch(banner, new RegExp(question.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + const questionRow = lines.findIndex((line) => line.includes(question)); + const editorRow = lines.findIndex((line) => line.includes("fake-pi-editor:")); + assert.ok(questionRow > bannerEnd, `${kind} question must remain below the attribution banner`); + assert.ok(editorRow > bannerEnd, `${kind} primitive editor must remain below the attribution banner`); + }); +} + +test("primitive prompt row budgets emit only complete attribution and editor boxes", () => { + for (const kind of ["input", "editor"] as const satisfies readonly PendingPrompt["kind"][]) { + for (let viewportRows = 1; viewportRows <= 24; viewportRows += 1) { + const store = createStore(); + setupRun(store, RUN_ID, "stage-a"); + const prompt = makePendingPrompt({ kind, message: "Budgeted question", initial: "draft" }); + assert.equal(store.recordStagePendingPrompt(RUN_ID, "stage-a", prompt), true); + const { handle } = makeHandle(); + const view = new StageChatView({ + store, + graphTheme: deriveGraphTheme({}), + runId: RUN_ID, + stageId: "stage-a", + workflowName: WORKFLOW_NAME, + handle, + onDetach: () => {}, + onClose: () => {}, + piTui: { + requestRender: () => {}, + terminal: { rows: viewportRows, columns: 100 }, + } as never, + piTheme: {}, + piKeybindings: makeFakeKeybindings(), + piEditorFactory: () => new FakePromptEditor(), + getViewportRows: () => viewportRows, + }); + + const lines = view.render(100).map((line) => stripAnsi(line)); + view.dispose(); + let boxOpen = false; + for (const line of lines) { + if (line.startsWith("╭")) { + assert.equal(boxOpen, false, `${kind} rows=${viewportRows} opens a box before closing the previous one`); + assert.match(line, /^╭(?: AWAITING INPUT )?─+╮$/); + boxOpen = true; + } + if (line.startsWith("╰")) { + assert.equal(boxOpen, true, `${kind} rows=${viewportRows} closes a box before opening one`); + assert.match(line, /^╰─+╯$/); + boxOpen = false; + } + } + assert.equal(boxOpen, false, `${kind} rows=${viewportRows} leaves a prompt box unclosed`); + + const rendered = lines.join("\n"); + if (rendered.includes(RUN_ID.slice(0, 8))) { + assert.ok(rendered.includes(RUN_ID), `${kind} rows=${viewportRows} has a partial run id`); + assert.ok(rendered.includes(WORKFLOW_NAME), `${kind} rows=${viewportRows} has a partial workflow identity`); + } + if (viewportRows === 14) { + assert.ok(rendered.includes(RUN_ID), `${kind} must shrink prompt spacing before omitting attribution`); + assert.ok(rendered.includes("Budgeted question"), `${kind} question must remain below a compact banner`); + assert.ok(rendered.includes("fake-pi-editor:"), `${kind} editor must remain below a compact banner`); + } + // The primitive path degrades through the same three rungs as the standard + // prompt surface. Row 10 is the middle rung, which this path was missing: + // the run id survives alone once both identity rows no longer fit. + if (viewportRows === 10) { + assert.ok(rendered.includes(RUN_ID), `${kind} must keep the run id on the middle rung`); + assert.match(rendered, /╭ AWAITING INPUT /, `${kind} must retain the complete interactive prompt box`); + assert.ok(rendered.includes("Budgeted question"), `${kind} question must survive banner degradation`); + assert.ok(rendered.includes("fake-pi-editor:"), `${kind} editor must survive banner degradation`); + } + if (viewportRows === 9) { + assert.doesNotMatch( + rendered, + new RegExp(RUN_ID), + `${kind} must drop attribution entirely below the middle rung`, + ); + assert.ok(rendered.includes("Budgeted question"), `${kind} question must survive attribution omission`); + assert.ok(rendered.includes("fake-pi-editor:"), `${kind} editor must survive attribution omission`); + } + } + } +}); diff --git a/test/unit/stage-chat-view-prompt-height.test.ts b/test/unit/stage-chat-view-prompt-height.test.ts new file mode 100644 index 000000000..b71394d96 --- /dev/null +++ b/test/unit/stage-chat-view-prompt-height.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { PendingPrompt } from "../../packages/workflows/src/shared/store-types.js"; +import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; +import { + createStore, + deriveGraphTheme, + FakePromptEditor, + makeFakeKeybindings, + makeHandle, + makePendingPrompt, + StageChatView, + setupRun, + stripAnsi, +} from "./stage-chat-view-helpers.js"; + +const RUN_ID = "339e05a4-2289-408e-9076-d1a348f582ae"; +const WORKFLOW_NAME = "budgeted-prompt"; +const TERMINAL_COLUMNS = [30, 40, 60, 80, 100, 120] as const; +const REACHABILITY_COLUMNS = [40, 72, 100] as const; +const ORIGIN_MAIN_PROMPT_SURFACE_VIEWPORT_FLOOR = 3; +const QUESTION_MARKERS = Array.from( + { length: 12 }, + (_, index) => `QUESTION-MARKER-${String(index + 1).padStart(2, "0")}`, +); +const LONG_QUESTION = QUESTION_MARKERS.map((marker) => `${marker} review detail`).join("\n"); +const PROMPTS: ReadonlyArray<{ label: string; prompt: PendingPrompt; hintText: string }> = [ + { + label: "input", + prompt: makePendingPrompt({ kind: "input", message: "Enter a release label" }), + hintText: "Submit", + }, + { + label: "confirm", + prompt: makePendingPrompt({ kind: "confirm", message: "Continue the workflow?" }), + hintText: "y Yes", + }, + { + label: "select", + prompt: makePendingPrompt({ + kind: "select", + message: "Choose a release channel", + choices: ["stable", "beta", "nightly", "canary", "manual"], + }), + hintText: "Choos", + }, + { + label: "editor", + prompt: makePendingPrompt({ kind: "editor", message: "Explain the release decision", initial: "draft" }), + hintText: "Submit", + }, + { + label: "custom", + prompt: makePendingPrompt({ kind: "custom", message: "Provide a custom response" }), + hintText: "enter", + }, +]; + +function assertRoundedBoxesClosed(lines: readonly string[], context: string): void { + const openBoxes: Array<{ left: number; width: number }> = []; + for (const line of lines) { + for (let column = 0; column < line.length; column += 1) { + if (line[column] === "╭") { + const right = line.indexOf("╮", column + 1); + assert.ok(right > column, `${context} has an incomplete top border`); + openBoxes.push({ left: column, width: right - column }); + } + if (line[column] === "╰") { + const openBox = openBoxes.pop(); + const right = line.indexOf("╯", column + 1); + assert.ok(openBox, `${context} closes a box before opening one`); + assert.equal(column, openBox.left, `${context} shifts a box's bottom border`); + assert.equal(right - column, openBox.width, `${context} changes a box's bottom width`); + } + } + } + assert.equal(openBoxes.length, 0, `${context} leaves a prompt box unclosed\n${lines.join("\n")}`); +} + +function makePromptView( + prompt: PendingPrompt, + terminalColumns: number, + viewportRows: number, + usePrimitiveEditor: boolean, +): StageChatView { + const store = createStore(); + setupRun(store, RUN_ID, "stage-a"); + assert.equal(store.recordStagePendingPrompt(RUN_ID, "stage-a", prompt), true); + const { handle } = makeHandle(); + return new StageChatView({ + store, + graphTheme: deriveGraphTheme({}), + runId: RUN_ID, + stageId: "stage-a", + workflowName: WORKFLOW_NAME, + handle, + onDetach: () => {}, + onClose: () => {}, + piTui: { + requestRender: () => {}, + terminal: { rows: viewportRows, columns: terminalColumns }, + } as never, + piTheme: {}, + ...(usePrimitiveEditor + ? { piKeybindings: makeFakeKeybindings(), piEditorFactory: () => new FakePromptEditor() } + : {}), + getViewportRows: () => viewportRows, + }); +} + +function promptIdentityBanner(plain: readonly string[]): string[] { + const bannerStart = plain.findIndex((line) => /^╭ AWAITING INPUT ─*╮$/.test(line)); + if (bannerStart < 0) return []; + const bannerEnd = plain.findIndex((line, index) => index > bannerStart && /^╰─+╯$/.test(line)); + return bannerEnd < 0 ? [] : plain.slice(bannerStart, bannerEnd + 1); +} + +test("makes every long prompt question row reachable without blank windows", () => { + for (const kind of ["input", "confirm", "select", "editor", "custom"] as const) { + for (const terminalColumns of REACHABILITY_COLUMNS) { + for (let viewportRows = 8; viewportRows <= 40; viewportRows += 1) { + const prompt = makePendingPrompt({ + kind, + message: LONG_QUESTION, + choices: kind === "select" ? ["stable", "beta", "nightly"] : undefined, + initial: kind === "editor" ? "draft" : undefined, + }); + const view = makePromptView(prompt, terminalColumns, viewportRows, kind === "input" || kind === "editor"); + const seen = new Set(); + let previousRender: string | undefined; + const context = `${kind} columns=${terminalColumns} viewportRows=${viewportRows}`; + + for (let scroll = 0; scroll <= QUESTION_MARKERS.length + 4; scroll += 1) { + const plain = view.render(Math.max(40, terminalColumns)).map(stripAnsi); + const rendered = plain.join("\n"); + assertRoundedBoxesClosed(plain, `${context} scroll=${scroll}`); + const visibleMarkers = QUESTION_MARKERS.filter((marker) => rendered.includes(marker)); + assert.ok(visibleMarkers.length > 0, `${context} scroll=${scroll} renders a blank question window`); + for (const marker of visibleMarkers) seen.add(marker); + if (rendered === previousRender) break; + previousRender = rendered; + assert.equal(view.handleInput("\x1b[<65;1;1M"), true); + } + + view.dispose(); + assert.deepEqual([...seen].sort(), QUESTION_MARKERS, `${context} leaves question rows unreachable`); + } + } + } +}); + +test("reaches the last long confirm question row at several heights including the one-row banner floor", () => { + for (const viewportRows of [12, 16, 20, 22, 23]) { + const prompt = makePendingPrompt({ kind: "confirm", message: LONG_QUESTION }); + const view = makePromptView(prompt, 80, viewportRows, false); + const initial = view.render(80).map(stripAnsi); + const banner = promptIdentityBanner(initial); + const context = `confirm columns=80 viewportRows=${viewportRows}`; + + if (viewportRows === 23) { + assert.ok(banner.join("\n").includes(RUN_ID), `${context} must retain the complete run id`); + assert.doesNotMatch(banner.join("\n"), new RegExp(WORKFLOW_NAME)); + assert.equal(banner.length, 3, `${context} must use the one-row identity rung`); + } + if (viewportRows === 22) assert.doesNotMatch(initial.join("\n"), new RegExp(RUN_ID)); + + assert.equal(view.handleInput("end"), true); + const bottom = view.render(80).map(stripAnsi); + view.dispose(); + assertRoundedBoxesClosed(bottom, context); + assert.ok(bottom.join("\n").includes(QUESTION_MARKERS.at(-1)!), `${context} cannot reach the last question row`); + } +}); + +test("matches origin/main's prompt surface floor across kinds, widths, and viewport heights", () => { + for (const { label, prompt } of PROMPTS) { + for (const terminalColumns of TERMINAL_COLUMNS) { + for (let viewportRows = 1; viewportRows <= 40; viewportRows += 1) { + const view = makePromptView(prompt, terminalColumns, viewportRows, false); + const rendered = view.render(Math.max(40, terminalColumns)).map(stripAnsi).join("\n"); + view.dispose(); + if (viewportRows >= ORIGIN_MAIN_PROMPT_SURFACE_VIEWPORT_FLOOR) { + assert.match( + rendered, + /AWAITING INPUT/, + `${label} columns=${terminalColumns} viewportRows=${viewportRows} drops the prompt surface`, + ); + } + } + } + } +}); + +test("attached-stage prompts keep complete boxes and answer hints across viewport sizes", () => { + for (const { label, prompt, hintText } of PROMPTS.filter(({ label }) => !["input", "editor"].includes(label))) { + for (const terminalColumns of TERMINAL_COLUMNS) { + for (let viewportRows = 1; viewportRows <= 40; viewportRows += 1) { + const view = makePromptView(prompt, terminalColumns, viewportRows, false); + const renderWidth = Math.max(40, terminalColumns); + const rendered = view.render(renderWidth); + view.dispose(); + const plain = rendered.map(stripAnsi); + const context = `${label} columns=${terminalColumns} viewportRows=${viewportRows}`; + + assertRoundedBoxesClosed(plain, context); + if (plain.some((line) => line.includes("AWAITING INPUT"))) { + assert.ok( + plain.some((line) => line.includes(hintText)), + `${context} drops the answer-hints row`, + ); + } + for (const line of rendered) { + assert.ok( + visibleWidth(line) <= renderWidth, + `${context} exceeds width ${renderWidth}: ${stripAnsi(line)}`, + ); + } + } + } + } +}); diff --git a/test/unit/status-list-render.test.ts b/test/unit/status-list-render.test.ts index ba9049c3b..3e3af569f 100644 --- a/test/unit/status-list-render.test.ts +++ b/test/unit/status-list-render.test.ts @@ -4,8 +4,8 @@ * Visual contract from ui/mockups.html §2: * - one rounded `BACKGROUND` panel * - one two-row card per run (replaces the indented per-stage rows) - * - per-card row 1: tag (short id) + bold workflow name + state badge - * - per-card row 2: mode + progress strip + meta + * - per-card row 1: status glyph + full run id + * - per-card row 2: workflow name + state badge, followed by meta rows * - trailing hint pointing at `/workflow status ` * * Plain mode preserves rounded panel/card shape without ANSI escapes. @@ -16,7 +16,10 @@ import assert from "node:assert/strict"; import { describe, test } from "vitest"; import type { RunSnapshot, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { chatWidth } from "../../packages/workflows/src/tui/chat-surface.js"; +import { hexToAnsi, RESET } from "../../packages/workflows/src/tui/color-utils.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; +import { statusIcon } from "../../packages/workflows/src/tui/status-helpers.js"; import { renderStatusList } from "../../packages/workflows/src/tui/status-list.js"; import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; @@ -121,25 +124,26 @@ describe("renderStatusList — populated", () => { assert.match(plain, /● 2/, "two active runs"); assert.match(plain, /✓ 1/, "one completed run"); - // One card per run — tag + bold workflow + state badge. - assert.match(plain, /abc123\s+refactor-auth/); - assert.match(plain, /def456\s+doc-update/); - assert.match(plain, /ghi789\s+scan-deps/); - assert.match(plain, /● running/); - assert.match(plain, /✓ completed/); + // Each identity row carries the complete run id; the workflow identity is + // deliberately on the following row so the UUID is never shortened. + assert.ok(plain.includes("abc123uuid")); + assert.ok(plain.includes("def456uuid")); + assert.ok(plain.includes("ghi789uuid")); + assert.match(plain, new RegExp(`refactor-auth\\s+${statusIcon("running")} running`)); + assert.match(plain, new RegExp(`doc-update\\s+${statusIcon("running")} running`)); + assert.match(plain, new RegExp(`scan-deps\\s+${statusIcon("completed")} completed`)); - // Row 2 — mode + progress strip + meta. + // Row 3 — mode + progress strip + meta. assert.match(plain, /chain\s+\[✓\]\[●\]\[○\]/); assert.match(plain, /1\/3/, "chain progress fraction renders in meta"); assert.match(plain, /single\s+\[●\]/); assert.match(plain, /single\s+\[✓\]/); - // Run entries are compact rows, not per-stage expansion in list view. - const runRows = plain.split("\n").filter((l) => /[●✓✗⊘○]\s+[a-z0-9]{6}\s+/.test(l)); - assert.equal(runRows.length, 3, "3 runs × 1 identity row"); + const idRows = ["abc123uuid", "def456uuid", "ghi789uuid"].filter((id) => plain.includes(id)); + assert.equal(idRows.length, 3, "3 runs × 1 complete identity row"); // Trailing hint references the most-recently-active run (def456, 42s ago). - assert.match(plain, /▸ \/workflow status def456/); + assert.match(plain, /▸ \/workflow status def456uuid/); assert.match(plain, /drill into a run/); }); @@ -157,9 +161,10 @@ describe("renderStatusList — populated", () => { ], { now, width: 100 }, ); - assert.match(out, /↑ 1 blocked/); - assert.match(out, /↑\s+blk123\s+auth-blocked\s+↑ blocked/); + + assert.match(out, new RegExp(`${statusIcon("blocked")}\\s+blk123uuid`)); + assert.match(out, new RegExp(`auth-blocked\\s+${statusIcon("blocked")} blocked`)); assert.doesNotMatch(out, /✓ 1/); assert.doesNotMatch(out, /✓ completed/); }); @@ -182,9 +187,10 @@ describe("renderStatusList — populated", () => { ], { now, width: 100 }, ); - assert.match(out, /↑ 1 blocked/); - assert.match(out, /↑\s+old123\s+adversarial-verification\s+↑ blocked/); + + assert.match(out, new RegExp(`${statusIcon("blocked")}\\s+old123uuid`)); + assert.match(out, new RegExp(`adversarial-verification\\s+${statusIcon("blocked")} blocked`)); assert.doesNotMatch(out, /✓ 1/); assert.doesNotMatch(out, /✓ completed/); }); @@ -217,9 +223,10 @@ describe("renderStatusList — populated", () => { ], { now, width: 100 }, ); - assert.match(out, /↑ 1 blocked/); - assert.match(out, /↑\s+auth00\s+adversarial-verification\s+↑ blocked/); + + assert.match(out, new RegExp(`${statusIcon("blocked")}\\s+auth00uuid`)); + assert.match(out, new RegExp(`adversarial-verification\\s+${statusIcon("blocked")} blocked`)); assert.doesNotMatch(out, /✓ 1/); assert.doesNotMatch(out, /✓ completed/); }); @@ -252,7 +259,8 @@ describe("renderStatusList — populated", () => { ); assert.match(out, /✓ 1/); - assert.match(out, /✓\s+okauth\s+tournament\s+✓ completed/); + assert.match(out, /okauthuuid/); + assert.match(out, new RegExp(`tournament\\s+${statusIcon("completed")} completed`)); assert.doesNotMatch(out, /↑ 1 blocked/); assert.doesNotMatch(out, /↑ blocked/); }); @@ -307,11 +315,12 @@ describe("renderStatusList — populated", () => { const runningRow = plain.split("\n").find((line) => line.includes("active-wf")); assert.ok(runningRow, "running run row is present"); - assert.match(runningRow, /● running/); + assert.match(runningRow, new RegExp(`active-wf\\s+${statusIcon("running")} running`)); const pausedRow = plain.split("\n").find((line) => line.includes("paused-wf")); assert.ok(pausedRow, "paused run row is present"); - assert.match(pausedRow, /❚❚\s+pause2\s+paused-wf\s+❚❚ paused/); + assert.match(plain, /pause222uuid/); + assert.match(pausedRow, new RegExp(`paused-wf\\s+${statusIcon("paused")} paused`)); assert.doesNotMatch(pausedRow, /○ pending/); assert.doesNotMatch(pausedRow, /● running/); }); @@ -332,7 +341,8 @@ describe("renderStatusList — populated", () => { assert.doesNotMatch(out, /\x1b\[/); assert.match(out, /^╭ BACKGROUND {2}1 run /); assert.match(out, /❚❚ 1 paused/); - assert.match(out, /❚❚\s+pause3\s+paused-plain\s+❚❚ paused/); + assert.match(out, /pause333uuid/); + assert.match(out, new RegExp(`paused-plain\\s+${statusIcon("paused")} paused`)); assert.match(out, /single\s+\[❚❚\]/, "paused progress cell uses the paused glyph, not the pending cell"); assert.doesNotMatch(out, /○ pending/); assert.doesNotMatch(out, /● 1(?:\s+running)?/); @@ -468,4 +478,44 @@ describe("renderStatusList — populated", () => { } assert.match(stripAnsi(out), /…/); }); + test("wraps complete ids and preserves borders at every narrow width", () => { + const runId = "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f"; + const run = makeRun({ id: runId, name: "narrow-status", status: "running" }); + for (const width of [80, 79, 60, 40, 30, 20]) { + const plain = stripAnsi(renderStatusList([run], { theme: deriveGraphTheme({}), width })); + const lines = plain.split("\n"); + const expectedWidth = chatWidth(width); + for (const line of lines) assert.equal(visibleWidth(line), expectedWidth); + const idStart = lines.findIndex((line) => line.includes(runId.slice(0, 8))); + const nameRow = lines.findIndex((line, index) => index > idStart && line.includes("narrow-status")); + const renderedId = lines + .slice(idStart, nameRow) + .join("") + .replace(/[^0-9a-f-]/gi, ""); + assert.equal(renderedId, runId, `full id is retained at width ${width}`); + assert.doesNotMatch(plain, /d4e5f6a1-.*…/); + assert.ok(lines[0]?.startsWith("╭")); + assert.ok(lines.at(-1)?.startsWith("╰")); + } + }); + + test("themes every wrapped run-id row in the workflow status hint", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const run = makeRun({ id: runId, name: "narrow-hint", status: "running" }); + const theme = deriveGraphTheme({}); + const lines = renderStatusList([run], { width: 44, theme }).split("\n"); + const hintLines = lines.filter((line) => { + const plain = stripAnsi(line); + return plain.includes("/workflow status") || plain.includes("76-d1a348f582ae"); + }); + + assert.equal(hintLines.length, 2, "the full hint id wraps across two rows"); + for (const line of hintLines) { + assert.ok( + line.includes(hexToAnsi(theme.accent)), + `wrapped id row is not accent-coloured: ${JSON.stringify(line)}`, + ); + assert.ok(line.includes(RESET), `wrapped id row has no colour reset: ${JSON.stringify(line)}`); + } + }); }); diff --git a/test/unit/widget-rendering.test.ts b/test/unit/widget-rendering.test.ts index c13444089..d61623ac3 100644 --- a/test/unit/widget-rendering.test.ts +++ b/test/unit/widget-rendering.test.ts @@ -4,10 +4,8 @@ * Visual contract: * - One transparent rounded `BACKGROUND` panel with `N runs` subtitle and * status-icon count badges in the title. - * - Two-line entry per run (status glyph + short id + bold name on - * line 1; dim mode · progress · duration on line 2). - * - Blank line between entries, no trailing blank. - * - Collapsed single-line form below 80 cells. + * - Two-line entry per run (status glyph + full id on line 1; workflow name + * and dim mode · progress · duration on line 2). * - Hides entirely (returns []) when no active or recently-ended runs. * * cross-ref: src/tui/widget.ts · orchestrator-panel-ui.png · DESIGN.md §5 @@ -20,6 +18,7 @@ import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot, StageSnapshot, StoreSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { hexToAnsi } from "../../packages/workflows/src/tui/color-utils.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; +import { statusColor, statusIcon } from "../../packages/workflows/src/tui/status-helpers.js"; import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; import { buildThemedWidgetLines, @@ -120,15 +119,16 @@ describe("renderWidgetLines — hidden states", () => { describe("renderWidgetLines — standard form", () => { test("single active run → rounded panel + 2-line entry (4 lines total)", () => { - const snap = makeSnap([makeRun("abc123uuid", "my-wf", "running")]); + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const snap = makeSnap([makeRun(runId, "my-wf", "running")]); const lines = renderWidgetLines(snap, 120).map(stripAnsi); // top border + 2 content rows + bottom border = 4 total assert.equal(lines.length, 4); assert.ok(lines[0]!.includes("BACKGROUND"), "header should include BACKGROUND label"); assert.ok(lines[0]!.includes("1 run"), "header should include 1 run subtitle"); - assert.ok(lines[1]!.includes("abc123"), "line 1 should include short id"); - assert.ok(lines[1]!.includes("my-wf"), "line 1 should include workflow name"); - assert.ok(lines[2]!.includes("single"), "line 2 should describe mode"); + assert.ok(lines[1]!.includes(runId), "line 1 should include the complete run id"); + assert.ok(!lines[1]!.includes("my-wf"), "line 1 should contain only status and id"); + assert.ok(lines[2]!.includes("my-wf · single"), "line 2 should join the workflow name and meta"); }); test("quit run renders resumable quit badge and note", () => { @@ -213,7 +213,7 @@ describe("renderWidgetLines — standard form", () => { assert.match(lines[0] ?? "", /↑ 1 blocked/u); assert.doesNotMatch(lines[0] ?? "", /running/u); - assert.match(text, /↑ {2}blocke {2}recoverable-auth/u); + assert.match(text, /recoverable-auth · blocked · resumable via \/workflow resume/u); assert.match(text, /blocked · resumable via \/workflow resume/u); assert.equal(nextWidgetRefreshDelayMs(snapshot), undefined); }); @@ -531,3 +531,70 @@ describe("buildThemedWidgetLines — themed path", () => { ); }); }); + +describe("run identity rows", () => { + test("keep complete ids and two-line identity for running, awaiting, quit, and terminal states", () => { + const theme = deriveGraphTheme({}); + const now = Date.now(); + const ids = { + running: "339e05a4-2289-408e-9076-d1a348f582ae", + awaiting: "d4e5f6a1-77b2-4c31-9e0a-2f1c8b4d6e5f", + quit: "aa11bb22-33cc-44dd-55ee-66ff77889900", + completed: "bb22cc33-44dd-55ee-66ff-778899001122", + failed: "cc33dd44-55ee-66ff-7788-990011223344", + }; + const awaiting = makeRun(ids.awaiting, "build-check", "running", [makeStage("s1", "ask", "awaiting_input")]); + const quit: RunSnapshot = { ...makeRun(ids.quit, "release-docs", "paused"), exitReason: "quit" }; + const completed = makeRun(ids.completed, "publish-release", "completed", [], now - 10_000, now); + const failed = makeRun(ids.failed, "verify-release", "failed", [], now - 10_000, now); + const running = makeRun(ids.running, "stage-output-transcript", "running", [], now - 10_000); + const lines = renderWidgetLines(makeSnap([running, awaiting, quit, completed, failed]), 120).map(stripAnsi); + const joined = lines.join("\n"); + + const cases = [ + { id: ids.running, name: "stage-output-transcript", glyph: statusIcon("running") }, + // Provisional pin: today's card keeps the running glyph; the objective example shows `?`, + // but changing awaiting-input attribution belongs to PR #2135. + { id: ids.awaiting, name: "build-check", glyph: statusIcon("running") }, + { id: ids.quit, name: "release-docs", glyph: statusIcon("pending") }, + { id: ids.completed, name: "publish-release", glyph: statusIcon("completed") }, + { id: ids.failed, name: "verify-release", glyph: statusIcon("failed") }, + ] as const; + for (const entry of cases) { + assert.ok(joined.includes(entry.id), `full id ${entry.id} is rendered`); + const idLine = lines.find((line) => line.includes(entry.id)); + assert.ok(idLine?.includes(entry.glyph), `${entry.name} uses ${entry.glyph}`); + } + for (const name of [ + "stage-output-transcript", + "build-check", + "release-docs", + "publish-release", + "verify-release", + ]) { + assert.ok( + lines.some((line) => line.includes(`${name} ·`)), + `${name} has a name/meta identity row`, + ); + } + + const themed = buildThemedWidgetLines(makeSnap([running]), NULL_PI_THEME, 120); + assert.ok(themed[1]?.includes(hexToAnsi(statusColor("running", theme)))); + assert.ok(themed[1]?.includes(statusIcon("running"))); + }); + + test("keeps every widget border line at the collapsed breakpoint", () => { + const runId = "339e05a4-2289-408e-9076-d1a348f582ae"; + const snap = makeSnap([makeRun(runId, "narrow-run", "running")]); + for (const width of [80, 79, 60, 40, 30, 20]) { + const lines = renderWidgetLines(snap, width).map(stripAnsi); + if (width >= 80) { + for (const line of lines) assert.equal(visibleWidth(line), width); + assert.ok(lines.join("\n").includes(runId)); + } else { + assert.equal(lines.length, 1, `width ${width} uses collapsed count-only form`); + assert.ok(!lines.join("\n").includes("339e05a4"), "collapsed widget intentionally omits ids"); + } + } + }); +}); diff --git a/test/unit/workflow-auto-restore-quit-resume.test.ts b/test/unit/workflow-auto-restore-quit-resume.test.ts index a959a2ab8..c6419e74d 100644 --- a/test/unit/workflow-auto-restore-quit-resume.test.ts +++ b/test/unit/workflow-auto-restore-quit-resume.test.ts @@ -18,6 +18,7 @@ import { } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { restoreOnSessionStart } from "../../packages/workflows/src/shared/persistence-restore.js"; import { createStore, store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; function seedRestoredShadow( backend: InMemoryDurableBackend, @@ -178,7 +179,7 @@ describe("gracefully quit durable workflow session restore", () => { test.sequential.each(["paused", "running"] as const)( "slash resume recovers authoritative durable %s shadow with the original workflow id", async (durableStatus) => { - const workflowId = `slash-restored-${durableStatus}`; + const workflowId = testRunId(`slash-restored-${durableStatus}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedRestoredShadow(backend, workflowId, durableStatus); @@ -193,7 +194,7 @@ describe("gracefully quit durable workflow session restore", () => { await handleRunControlCommand( "resume", - [workflowId.slice(0, 12)], + [workflowId], { hasUI: false, ui: { notify: () => undefined } }, { info: (message) => info.push(message), error: (message) => errors.push(message) }, { @@ -220,7 +221,7 @@ describe("gracefully quit durable workflow session restore", () => { test.sequential.each(["paused", "running"] as const)( "workflow tool resume recovers authoritative durable %s shadow with the original workflow id", async (durableStatus) => { - const workflowId = `tool-restored-${durableStatus}`; + const workflowId = testRunId(`tool-restored-${durableStatus}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedRestoredShadow(backend, workflowId, durableStatus); @@ -250,9 +251,9 @@ describe("gracefully quit durable workflow session restore", () => { test.sequential.each([ ["exact id", (workflowId: string) => workflowId], - ["unique prefix", (workflowId: string) => workflowId.slice(0, 18)], - ] as const)("workflow tool resume discovers a durable-only target by %s", async (_label, selectTarget) => { - const workflowId = `tool-durable-only-${Date.now()}`; + ["short prefix rejected", (workflowId: string) => workflowId.slice(0, 18)], + ] as const)("workflow tool resume requires an exact durable-only id", async (_label, selectTarget) => { + const workflowId = testRunId(`tool-durable-only-${Date.now()}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedDurableOnly(backend, workflowId); @@ -264,9 +265,16 @@ describe("gracefully quit durable workflow session restore", () => { () => undefined, ); - const result = await execute({ action: "resume", runId: selectTarget(workflowId) }, {} as never); + const target = selectTarget(workflowId); + const result = await execute({ action: "resume", runId: target }, {} as never); assert.equal(result.action, "resume"); + if (target !== workflowId) { + assert.equal(result.status, "noop"); + assert.match(result.message, /Run id must be a full 36-character UUID/); + assert.equal(jobTracker.has(workflowId), false); + return; + } assert.equal(result.runId, workflowId); assert.equal(result.status, "running"); assert.match(result.message, /Resuming durable workflow/); @@ -277,9 +285,9 @@ describe("gracefully quit durable workflow session restore", () => { await resumedJob.promise; }); - test.sequential("workflow tool reports every ambiguous durable-only prefix match", async () => { + test.sequential("workflow tool rejects a shared durable-only prefix", async () => { const prefix = `tool-durable-ambiguous-${Date.now()}`; - const workflowIds = [`${prefix}-alpha`, `${prefix}-beta`]; + const workflowIds = [testRunId(`${prefix}-alpha`), testRunId(`${prefix}-beta`)]; const backend = new InMemoryDurableBackend(); setDurableBackend(backend); for (const workflowId of workflowIds) seedDurableOnly(backend, workflowId); @@ -294,18 +302,17 @@ describe("gracefully quit durable workflow session restore", () => { assert.equal(result.action, "resume"); assert.equal(result.status, "noop"); - assert.match(result.message, /Ambiguous run prefix/); + assert.match(result.message, /Run id must be a full 36-character UUID/); for (const workflowId of workflowIds) { - assert.ok(result.message.includes(workflowId)); assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); assert.equal(jobTracker.has(workflowId), false); } }); - test.sequential("workflow tool ambiguity includes local and durable-only prefix matches", async () => { + test.sequential("workflow tool rejects a prefix shared by local and durable-only runs", async () => { const prefix = `tool-mixed-ambiguous-${Date.now()}`; - const localIds = [`${prefix}-local`]; - const durableId = `${prefix}-durable`; + const localIds = [testRunId(`${prefix}-local`)]; + const durableId = testRunId(`${prefix}-durable`); for (const id of localIds) { store.recordRunStart({ id, @@ -331,15 +338,14 @@ describe("gracefully quit durable workflow session restore", () => { assert.equal(result.action, "resume"); assert.equal(result.status, "noop"); - assert.match(result.message, /Ambiguous run prefix/); - for (const id of [...localIds, durableId]) assert.ok(result.message.includes(id)); + assert.match(result.message, /Run id must be a full 36-character UUID/); assert.equal(jobTracker.has(durableId), false); }); - test.sequential("combined resolution keeps a sole eligible live prefix target", async () => { + test.sequential("combined resolution accepts an exact eligible live target", async () => { const prefix = `tool-live-filter-${Date.now()}`; - const liveId = `${prefix}-paused`; - const terminalId = `${prefix}-terminal`; + const liveId = testRunId(`${prefix}-paused`); + const terminalId = testRunId(`${prefix}-terminal`); store.recordRunStart({ id: liveId, name: "restored-workflow", @@ -377,7 +383,7 @@ describe("gracefully quit durable workflow session restore", () => { () => undefined, ); - const result = await execute({ action: "resume", runId: prefix }, {} as never); + const result = await execute({ action: "resume", runId: liveId }, {} as never); assert.equal(result.action, "resume"); assert.equal(result.runId, liveId); @@ -387,7 +393,7 @@ describe("gracefully quit durable workflow session restore", () => { }); test.sequential("workflow tool refuses a durable-only zero-progress target without local synthesis", async () => { - const workflowId = `tool-durable-ineligible-${Date.now()}`; + const workflowId = testRunId(`tool-durable-ineligible-${Date.now()}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedDurableOnly(backend, workflowId, false); @@ -408,7 +414,7 @@ describe("gracefully quit durable workflow session restore", () => { }); test.sequential("workflow tool surfaces resource loading failure before durable-only lookup", async () => { - const workflowId = `tool-durable-loader-failure-${Date.now()}`; + const workflowId = testRunId(`tool-durable-loader-failure-${Date.now()}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedDurableOnly(backend, workflowId); @@ -436,7 +442,7 @@ describe("gracefully quit durable workflow session restore", () => { ["paused", "control"], ["running", "control"], ] as const)("durable %s snapshot is not a resume shadow while a live %s exists", (durableStatus, liveKind) => { - const workflowId = `live-${durableStatus}-${liveKind}`; + const workflowId = testRunId(`live-${durableStatus}-${liveKind}`); const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ workflowId, @@ -477,7 +483,7 @@ describe("gracefully quit durable workflow session restore", () => { test.sequential.each(["paused", "running"] as const)( "zero-progress durable %s orphan stays unmodified and tool resume is a noop", async (durableStatus) => { - const workflowId = `zero-tool-${durableStatus}`; + const workflowId = testRunId(`zero-tool-${durableStatus}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); @@ -491,7 +497,7 @@ describe("gracefully quit durable workflow session restore", () => { () => undefined, () => undefined, ); - const result = await execute({ action: "resume", runId: workflowId.slice(0, 12) }, {} as never); + const result = await execute({ action: "resume", runId: workflowId }, {} as never); assert.equal(result.action, "resume"); assert.equal(result.runId, workflowId); @@ -509,7 +515,7 @@ describe("gracefully quit durable workflow session restore", () => { test.sequential.each(["paused", "running"] as const)( "zero-progress durable %s orphan stays unmodified through slash resume", async (durableStatus) => { - const workflowId = `zero-slash-${durableStatus}`; + const workflowId = testRunId(`zero-slash-${durableStatus}`); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); @@ -521,7 +527,7 @@ describe("gracefully quit durable workflow session restore", () => { await handleRunControlCommand( "resume", - [workflowId.slice(0, 12)], + [workflowId], { hasUI: false, ui: { notify: () => undefined } }, { info: (message) => info.push(message), error: (message) => errors.push(message) }, { diff --git a/test/unit/workflow-completed-inspection-lifecycle-state.test.ts b/test/unit/workflow-completed-inspection-lifecycle-state.test.ts index 2390d3fdd..0975ab4e2 100644 --- a/test/unit/workflow-completed-inspection-lifecycle-state.test.ts +++ b/test/unit/workflow-completed-inspection-lifecycle-state.test.ts @@ -9,6 +9,7 @@ import { type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; interface Admission { readonly content?: string; @@ -74,7 +75,7 @@ describe("completed inspection lifecycle delivery state", () => { const state = createWorkflowLifecycleNotificationState(); const send = Promise.withResolvers(); const admissions: Admission[] = []; - seedCompletedTool(backend, "pending-live", "historical replacement"); + seedCompletedTool(backend, testRunId("pending-live"), "historical replacement"); const unsubscribe = installWorkflowLifecycleNotifications({ store, state, @@ -86,17 +87,17 @@ describe("completed inspection lifecycle delivery state", () => { }, }); store.recordRunStart({ - id: "pending-live", + id: testRunId("pending-live"), name: "original live", inputs: {}, status: "running", stages: [], startedAt: 1, }); - store.recordRunEnd("pending-live", "completed", {}); + store.recordRunEnd(testRunId("pending-live"), "completed", {}); assert.equal(admissions.length, 1); - const opened = openCompletedDurableWorkflow("pending-live", { + const opened = openCompletedDurableWorkflow(testRunId("pending-live"), { durableBackend: backend, store, beforeRestore(snapshots) { @@ -122,7 +123,7 @@ describe("completed inspection lifecycle delivery state", () => { const state = createWorkflowLifecycleNotificationState(); const admissions: Admission[] = []; let attempt = 0; - seedCompletedTool(backend, "retry-live", "historical replacement"); + seedCompletedTool(backend, testRunId("retry-live"), "historical replacement"); const unsubscribe = installWorkflowLifecycleNotifications({ store, state, @@ -136,14 +137,14 @@ describe("completed inspection lifecycle delivery state", () => { }); try { store.recordRunStart({ - id: "retry-live", + id: testRunId("retry-live"), name: "original live", inputs: {}, status: "running", stages: [], startedAt: 1, }); - store.recordRunEnd("retry-live", "completed", {}); + store.recordRunEnd(testRunId("retry-live"), "completed", {}); await flushMicrotasks(); assert.equal(admissions.length, 1); assert.equal(timers.activeCount(), 1); @@ -151,7 +152,7 @@ describe("completed inspection lifecycle delivery state", () => { const originalContent = admissions[0]?.content; assert.equal(originalDetails?.workflowName, "original live"); - const opened = openCompletedDurableWorkflow("retry-live", { + const opened = openCompletedDurableWorkflow(testRunId("retry-live"), { durableBackend: backend, store, beforeRestore(snapshots) { diff --git a/test/unit/workflow-completed-inspection.test.ts b/test/unit/workflow-completed-inspection.test.ts index f0d09e0cc..1b01ea3ca 100644 --- a/test/unit/workflow-completed-inspection.test.ts +++ b/test/unit/workflow-completed-inspection.test.ts @@ -17,6 +17,7 @@ import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; import { computeLayout, NODE_H, NODE_W } from "../../packages/workflows/src/tui/layout.js"; +import { testRunId } from "../helpers/run-id.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; import { defaultTheme, visibleText } from "./overlay-graph-helpers.js"; @@ -99,7 +100,7 @@ describe("completed workflow inspection", () => { const store = createStore(); const lifecycle = lifecycleRestoration(store); const registry = createStageControlRegistry(); - const sessionFile = retainedSession("completed-inspection"); + const sessionFile = retainedSession(testRunId("completed-inspection")); const promptCalls: string[] = []; const session: StageSessionRuntime = { ...mockSession(), @@ -109,7 +110,7 @@ describe("completed workflow inspection", () => { }, }; backend.registerWorkflow({ - workflowId: "completed-inspection", + workflowId: testRunId("completed-inspection"), name: "completed-flow", inputs: { topic: "done" }, createdAt: 1, @@ -118,7 +119,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "completed-inspection", + workflowId: testRunId("completed-inspection"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -130,7 +131,7 @@ describe("completed workflow inspection", () => { let sessionCreates = 0; let restoredMessageCount = 0; - const opened = openCompletedDurableWorkflow("completed-ins", { + const opened = openCompletedDurableWorkflow(testRunId("completed-inspection"), { durableBackend: backend, store, beforeRestore: lifecycle.beforeRestore, @@ -150,16 +151,16 @@ describe("completed workflow inspection", () => { assert.equal(opened.ok, true); assert.equal(store.runs()[0]?.status, "completed"); assert.equal(store.runs()[0]?.stages[0]?.attachable, false); - assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); - const handle = registry.get("completed-inspection", "final-source"); + assert.equal(backend.getWorkflow(testRunId("completed-inspection"))?.status, "completed"); + const handle = registry.get(testRunId("completed-inspection"), "final-source"); assert.ok(handle); - assert.deepEqual(registry.run("completed-inspection").stages(), []); + assert.deepEqual(registry.run(testRunId("completed-inspection")).stages(), []); const attached: string[] = []; - const graph = expandWorkflowGraph(store.snapshot(), "completed-inspection"); + const graph = expandWorkflowGraph(store.snapshot(), testRunId("completed-inspection")); const view = new GraphView({ mode: "overlay", - runId: "completed-inspection", + runId: testRunId("completed-inspection"), store, graphTheme: defaultTheme, getViewportRows: () => 32, @@ -175,9 +176,9 @@ describe("completed workflow inspection", () => { for (const char of "final") view.handleInput(char); view.handleInput("\r"); assert.deepEqual(attached, [ - "completed-inspection/final-source", - "completed-inspection/final-source", - "completed-inspection/final-source", + `${testRunId("completed-inspection")}/final-source`, + `${testRunId("completed-inspection")}/final-source`, + `${testRunId("completed-inspection")}/final-source`, ]); view.dispose(); await handle.prompt("What should I do next?"); @@ -185,17 +186,44 @@ describe("completed workflow inspection", () => { assert.equal(restoredMessageCount, 1); assert.deepEqual(promptCalls, ["What should I do next?"]); assert.equal(store.runs()[0]?.status, "completed"); - assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); + assert.equal(backend.getWorkflow(testRunId("completed-inspection"))?.status, "completed"); assert.deepEqual(lifecycle.sent, []); lifecycle.unsubscribe(); }); + test("includes the full workflow id in a completed inspection message", () => { + const workflowId = testRunId("full-workflow-id"); + const backend = new InMemoryDurableBackend(); + const store = createStore(); + backend.registerWorkflow({ + workflowId, + name: "completed-flow", + inputs: {}, + createdAt: 1, + updatedAt: 3, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId, + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + output: "done", + completedAt: 2, + topology: completedTopology("final-source"), + }); + + const opened = openCompletedDurableWorkflow(workflowId, { durableBackend: backend, store }); + assert.equal(opened.ok, true); + if (opened.ok) assert.ok(opened.message.includes(workflowId)); + }); test("refuses to replace an active run with the same id", () => { const backend = new InMemoryDurableBackend(); const store = createStore(); - const sessionFile = retainedSession("same-id"); + const sessionFile = retainedSession(testRunId("same-id")); backend.registerWorkflow({ - workflowId: "same-id", + workflowId: testRunId("same-id"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -203,7 +231,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "same-id", + workflowId: testRunId("same-id"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -211,9 +239,16 @@ describe("completed workflow inspection", () => { completedAt: 2, topology: completedTopology("final-source"), }); - store.recordRunStart({ id: "same-id", name: "active", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: testRunId("same-id"), + name: "active", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); - const opened = openCompletedDurableWorkflow("same-id", { durableBackend: backend, store }); + const opened = openCompletedDurableWorkflow(testRunId("same-id"), { durableBackend: backend, store }); assert.equal(opened.ok, false); if (!opened.ok) assert.equal(opened.reason, "active"); assert.equal(store.runs()[0]?.status, "running"); @@ -222,9 +257,9 @@ describe("completed workflow inspection", () => { test("replaces a retained completed snapshot with authoritative durable detail", () => { const backend = new InMemoryDurableBackend(); const store = createStore(); - const sessionFile = retainedSession("authoritative"); + const sessionFile = retainedSession(testRunId("authoritative")); backend.registerWorkflow({ - workflowId: "authoritative", + workflowId: testRunId("authoritative"), name: "durable-name", inputs: {}, createdAt: 1, @@ -232,7 +267,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "authoritative", + workflowId: testRunId("authoritative"), checkpointId: "stage:1", name: "durable-stage", replayKey: "stage:durable:1", @@ -242,7 +277,7 @@ describe("completed workflow inspection", () => { topology: completedTopology("durable-source"), }); store.recordRunStart({ - id: "authoritative", + id: testRunId("authoritative"), name: "stale-local-name", inputs: {}, status: "completed", @@ -252,7 +287,7 @@ describe("completed workflow inspection", () => { resumable: false, }); - const opened = openCompletedDurableWorkflow("authoritative", { durableBackend: backend, store }); + const opened = openCompletedDurableWorkflow(testRunId("authoritative"), { durableBackend: backend, store }); assert.equal(opened.ok, true); assert.equal(store.runs()[0]?.name, "durable-name"); @@ -268,7 +303,7 @@ describe("completed workflow inspection", () => { const firstSessionFile = retainedSession("first-authoritative"); const secondSessionFile = retainedSession("second-authoritative"); backend.registerWorkflow({ - workflowId: "refresh-chat", + workflowId: testRunId("refresh-chat"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -276,7 +311,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "refresh-chat", + workflowId: testRunId("refresh-chat"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -298,12 +333,12 @@ describe("completed workflow inspection", () => { }, }; - assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); - const firstHandle = registry.get("refresh-chat", "final-source"); + assert.equal(openCompletedDurableWorkflow(testRunId("refresh-chat"), deps).ok, true); + const firstHandle = registry.get(testRunId("refresh-chat"), "final-source"); assert.equal(firstHandle?.sessionFile, firstSessionFile); backend.recordCheckpoint({ kind: "stage", - workflowId: "refresh-chat", + workflowId: testRunId("refresh-chat"), checkpointId: "stage:2", name: "final", replayKey: "stage:final:1", @@ -312,9 +347,9 @@ describe("completed workflow inspection", () => { topology: completedTopology("final-source"), }); - assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); + assert.equal(openCompletedDurableWorkflow(testRunId("refresh-chat"), deps).ok, true); assert.equal(firstHandle?.isDisposed, true); - assert.equal(registry.get("refresh-chat", "final-source")?.sessionFile, secondSessionFile); + assert.equal(registry.get(testRunId("refresh-chat"), "final-source")?.sessionFile, secondSessionFile); assert.deepEqual(lifecycle.sent, []); lifecycle.unsubscribe(); }); @@ -326,7 +361,7 @@ describe("completed workflow inspection", () => { const invalidatedSessionFile = retainedSession("invalidated-stage"); const retainedSessionFile = retainedSession("still-retained-stage"); backend.registerWorkflow({ - workflowId: "invalidate-chat", + workflowId: testRunId("invalidate-chat"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -334,7 +369,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "invalidate-chat", + workflowId: testRunId("invalidate-chat"), checkpointId: "stage:1", name: "first", replayKey: "stage:first:1", @@ -344,7 +379,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "invalidate-chat", + workflowId: testRunId("invalidate-chat"), checkpointId: "stage:2", name: "second", replayKey: "stage:second:1", @@ -365,24 +400,24 @@ describe("completed workflow inspection", () => { }, }; - assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); - const invalidatedHandle = registry.get("invalidate-chat", "first-source"); + assert.equal(openCompletedDurableWorkflow(testRunId("invalidate-chat"), deps).ok, true); + const invalidatedHandle = registry.get(testRunId("invalidate-chat"), "first-source"); assert.ok(invalidatedHandle); rmSync(invalidatedSessionFile); - assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); + assert.equal(openCompletedDurableWorkflow(testRunId("invalidate-chat"), deps).ok, true); assert.equal(invalidatedHandle.isDisposed, true); - assert.equal(registry.get("invalidate-chat", "first-source"), undefined); - assert.equal(registry.get("invalidate-chat", "second-source")?.sessionFile, retainedSessionFile); + assert.equal(registry.get(testRunId("invalidate-chat"), "first-source"), undefined); + assert.equal(registry.get(testRunId("invalidate-chat"), "second-source")?.sessionFile, retainedSessionFile); }); test("opens a retained internal stage transcript without exposing it in ordinary history", async () => { const backend = new InMemoryDurableBackend(); const store = createStore(); - const internalSessionFile = retainedSession("internal-completed", true); + const internalSessionFile = retainedSession(testRunId("internal-completed"), true); retainedSession("regular-history"); backend.registerWorkflow({ - workflowId: "internal-completed", + workflowId: testRunId("internal-completed"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -390,7 +425,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "stage", - workflowId: "internal-completed", + workflowId: testRunId("internal-completed"), checkpointId: "stage:1", name: "final", replayKey: "stage:final:1", @@ -399,7 +434,10 @@ describe("completed workflow inspection", () => { topology: completedTopology("final-source"), }); - assert.equal(openCompletedDurableWorkflow("internal-completed", { durableBackend: backend, store }).ok, true); + assert.equal( + openCompletedDurableWorkflow(testRunId("internal-completed"), { durableBackend: backend, store }).ok, + true, + ); assert.equal(store.runs()[0]?.stages[0]?.sessionFile, internalSessionFile); assert.deepEqual( (await SessionManager.list(tempDir, tempDir)).map((session) => session.id), @@ -412,7 +450,7 @@ describe("completed workflow inspection", () => { const store = createStore(); const registry = createStageControlRegistry(); backend.registerWorkflow({ - workflowId: "completed-tool-only", + workflowId: testRunId("completed-tool-only"), name: "tool-only", inputs: {}, createdAt: 1, @@ -420,7 +458,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "tool", - workflowId: "completed-tool-only", + workflowId: testRunId("completed-tool-only"), checkpointId: "tool:publish", name: "publish", argsHash: "publish-hash", @@ -428,7 +466,7 @@ describe("completed workflow inspection", () => { completedAt: 2, }); - const opened = openCompletedDurableWorkflow("completed-tool", { + const opened = openCompletedDurableWorkflow(testRunId("completed-tool-only"), { durableBackend: backend, store, stageControlRegistry: registry, @@ -445,7 +483,7 @@ describe("completed workflow inspection", () => { if (!opened.ok) return; assert.match(opened.message, /read-only inspection/); assert.doesNotMatch(opened.message, /follow-up chat/); - assert.deepEqual(registry.forRun("completed-tool-only"), []); + assert.deepEqual(registry.forRun(testRunId("completed-tool-only")), []); assert.deepEqual( store.runs()[0]?.toolNodes?.map((tool) => tool.name), ["publish"], @@ -456,8 +494,8 @@ describe("completed workflow inspection", () => { const backend = new InMemoryDurableBackend(); const store = createStore(); const lifecycle = lifecycleRestoration(store); - const runId = "silent-nested-root"; - const childRunId = "silent-nested-child"; + const runId = testRunId("silent-nested-root"); + const childRunId = testRunId("silent-nested-child"); backend.registerWorkflow({ workflowId: runId, name: "nested root", @@ -506,7 +544,7 @@ describe("completed workflow inspection", () => { }, }); - const opened = openCompletedDurableWorkflow("silent-nested", { + const opened = openCompletedDurableWorkflow(testRunId("silent-nested-root"), { durableBackend: backend, store, beforeRestore: lifecycle.beforeRestore, @@ -538,7 +576,7 @@ describe("completed workflow inspection", () => { }, }); backend.registerWorkflow({ - workflowId: "silent-tool-only", + workflowId: testRunId("silent-tool-only"), name: "silent tool", inputs: {}, createdAt: 1, @@ -547,7 +585,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "tool", - workflowId: "silent-tool-only", + workflowId: testRunId("silent-tool-only"), checkpointId: "tool:publish", name: "publish", argsHash: "publish-hash", @@ -555,7 +593,7 @@ describe("completed workflow inspection", () => { completedAt: 2, }); - const opened = openCompletedDurableWorkflow("silent-tool", { + const opened = openCompletedDurableWorkflow(testRunId("silent-tool-only"), { durableBackend: backend, store, beforeRestore(snapshots) { @@ -587,17 +625,17 @@ describe("completed workflow inspection", () => { }, }); store.recordRunStart({ - id: "live-then-inspect", + id: testRunId("live-then-inspect"), name: "live tool", inputs: {}, status: "running", stages: [], startedAt: 1, }); - store.recordRunEnd("live-then-inspect", "completed", {}); + store.recordRunEnd(testRunId("live-then-inspect"), "completed", {}); assert.equal(sent.length, 1); backend.registerWorkflow({ - workflowId: "live-then-inspect", + workflowId: testRunId("live-then-inspect"), name: "live tool", inputs: {}, createdAt: 1, @@ -606,7 +644,7 @@ describe("completed workflow inspection", () => { }); backend.recordCheckpoint({ kind: "tool", - workflowId: "live-then-inspect", + workflowId: testRunId("live-then-inspect"), checkpointId: "tool:done", name: "done", argsHash: "done-hash", @@ -614,7 +652,7 @@ describe("completed workflow inspection", () => { completedAt: 2, }); - const opened = openCompletedDurableWorkflow("live-then", { + const opened = openCompletedDurableWorkflow(testRunId("live-then-inspect"), { durableBackend: backend, store, beforeRestore(snapshots) { diff --git a/test/unit/workflow-lazy-startup-continuation.test.ts b/test/unit/workflow-lazy-startup-continuation.test.ts index 0fa971ac0..34be558a2 100644 --- a/test/unit/workflow-lazy-startup-continuation.test.ts +++ b/test/unit/workflow-lazy-startup-continuation.test.ts @@ -19,6 +19,7 @@ import type { SessionEntry } from "../../packages/workflows/src/shared/persisten import { store } from "../../packages/workflows/src/shared/store.js"; import type { ChatSurfacePayload } from "../../packages/workflows/src/tui/chat-surface-message.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; +import { testRunId } from "../helpers/run-id.js"; interface SentMessage { customType?: string; @@ -152,7 +153,10 @@ describe("workflow lazy-startup continuation fixes", () => { }); const sessionStart = handlers.get("session_start"); assert.ok(sessionStart); - await sessionStart({}, { sessionManager: { getEntries: () => [inFlightEntry("persist-off-run")] } }); + await sessionStart( + {}, + { sessionManager: { getEntries: () => [inFlightEntry(testRunId("persist-off-run"))] } }, + ); assert.equal(store.runs().length, 0); assert.equal(resourceCalls, 0); } finally { @@ -174,10 +178,10 @@ describe("workflow lazy-startup continuation fixes", () => { const { handlers } = registerFactory({ disableAsyncDiscovery: true }); await handlers.get("session_start")?.( {}, - { sessionManager: { getEntries: () => [inFlightEntry("auto-run")] } }, + { sessionManager: { getEntries: () => [inFlightEntry(testRunId("auto-run"))] } }, ); assert.equal( - store.runs().some((run) => run.id === "auto-run"), + store.runs().some((run) => run.id === testRunId("auto-run")), false, ); } finally { @@ -277,7 +281,7 @@ describe("workflow lazy-startup continuation fixes", () => { throw new Error("discovery failed"); }, }); - const runId = "paused-slash-resume-source"; + const runId = testRunId("paused-slash-resume-source"); store.recordRunStart({ id: runId, name: "paused workflow", @@ -298,7 +302,7 @@ describe("workflow lazy-startup continuation fixes", () => { test("workflow tool paused resume bypasses workflow discovery", async () => { let ensureCalls = 0; - const runId = "paused-tool-resume-source"; + const runId = testRunId("paused-tool-resume-source"); store.recordRunStart({ id: runId, name: "paused tool workflow", @@ -349,7 +353,7 @@ describe("workflow lazy-startup continuation fixes", () => { return [{ path: workflowPath, enabled: true }]; }, }); - const sourceRunId = "lazy-slash-resume-source"; + const sourceRunId = testRunId("lazy-slash-resume-source"); store.recordRunStart({ id: sourceRunId, name: "slash-resume-lazy", @@ -408,7 +412,7 @@ describe("workflow lazy-startup continuation fixes", () => { outputs: { value: Type.Optional(Type.String()) }, run: async (ctx) => ({ value: await ctx.stage("retry").prompt("retry") }), }); - const sourceRunId = "lazy-tool-resume-source"; + const sourceRunId = testRunId("lazy-tool-resume-source"); store.recordRunStart({ id: sourceRunId, name: def.name, diff --git a/test/unit/workflow-lazy-startup-review-followup.test.ts b/test/unit/workflow-lazy-startup-review-followup.test.ts index b43e8bdcc..b23df93f7 100644 --- a/test/unit/workflow-lazy-startup-review-followup.test.ts +++ b/test/unit/workflow-lazy-startup-review-followup.test.ts @@ -12,6 +12,7 @@ import { } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; const previousWorkflowStageSubagentGuard = process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; @@ -89,7 +90,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { throw new Error("discovery failed"); }, }); - const runId = "picker-live-resume-source"; + const runId = testRunId("picker-live-resume-source"); store.recordRunStart({ id: runId, name: "picker workflow", @@ -169,7 +170,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { throw new Error("discovery failed"); }, }); - const runId = "quit-paused-resume-source"; + const runId = testRunId("quit-paused-resume-source"); store.recordRunStart({ id: runId, name: "quit paused workflow", @@ -198,7 +199,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { return []; }, }); - const runId = "dbos-like-current-live"; + const runId = testRunId("dbos-like-current-live"); store.recordRunStart({ id: runId, name: "current live", @@ -228,7 +229,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { return []; }, }); - const runId = "dbos-like-unclassified-restored"; + const runId = testRunId("dbos-like-unclassified-restored"); store.recordRunStart({ id: runId, name: "restored", @@ -247,7 +248,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { }); test("/workflow resume routes quit durable shadows through durable resume", async () => { - const runId = "quit-shadow-durable-resume"; + const runId = testRunId("quit-shadow-durable-resume"); store.recordRunStart({ id: runId, name: "quit shadow workflow", @@ -323,7 +324,13 @@ describe("workflow lazy-startup review follow-up fixes", () => { return { dispatch: async (): Promise => canSeeLazyWorkflow - ? { action: "run", name: "lazy model run", runId: "model-run", status: "running", stages: [] } + ? { + action: "run", + name: "lazy model run", + runId: testRunId("model-run"), + status: "running", + stages: [], + } : { action: "run", name: "lazy model run", @@ -350,13 +357,13 @@ describe("workflow lazy-startup review follow-up fixes", () => { assert.equal(ensureCalls, 1); assert.equal(result.action, "run"); assert.equal(result.status, "running"); - assert.equal(result.runId, "model-run"); + assert.equal(result.runId, testRunId("model-run")); }); test("workflow tool failed resume re-resolves runtime after lazy discovery", async () => { const backend = new HydrationCapableBackend(); setDurableBackend(backend); - const sourceRunId = "lazy-tool-model-resume-source"; + const sourceRunId = testRunId("lazy-tool-model-resume-source"); store.recordRunStart({ id: sourceRunId, name: "lazy model resume", @@ -389,7 +396,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { return { resumeFailedRun: () => canResume - ? { ok: true, runId: "continued-run", message: "Resuming failed workflow" } + ? { ok: true, runId: testRunId("continued-run"), message: "Resuming failed workflow" } : { ok: false, reason: "workflow_not_found", message: "workflow_not_found: lazy model resume" }, } as unknown as ExtensionRuntime; }; @@ -410,7 +417,7 @@ describe("workflow lazy-startup review follow-up fixes", () => { assert.equal(backend.hydrateCalls, 0); assert.equal(result.action, "resume"); assert.equal(result.status, "running"); - assert.equal(result.runId, "continued-run"); + assert.equal(result.runId, testRunId("continued-run")); assert.match(result.message ?? "", /Resuming failed workflow/); }); }); diff --git a/test/unit/workflow-reload-rediscovery.test.ts b/test/unit/workflow-reload-rediscovery.test.ts index b63c412ba..2b03ceaed 100644 --- a/test/unit/workflow-reload-rediscovery.test.ts +++ b/test/unit/workflow-reload-rediscovery.test.ts @@ -18,6 +18,7 @@ import { cancellationRegistry } from "../../packages/workflows/src/runs/backgrou import { killAllRuns } from "../../packages/workflows/src/runs/background/status.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner-types.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; const originalCwd = process.cwd(); const originalAgentDir = process.env.ATOMIC_CODING_AGENT_DIR; @@ -436,7 +437,7 @@ describe("workflow reload rediscovery matrix", () => { }); const durableBackend = new InMemoryDurableBackend(); durableBackend.registerWorkflow({ - workflowId: "durable-reload-retained", + workflowId: testRunId("durable-reload-retained"), name: "reload-inflight", inputs: { message: "resume" }, createdAt: 1, @@ -448,7 +449,7 @@ describe("workflow reload rediscovery matrix", () => { await harness.execute({ action: "reload" }); assert.deepEqual(durableBackend.listResumableWorkflows(), durableBefore); const resumeMessageStart = harness.messages.length; - await harness.commands.get("workflow")?.handler?.("resume durable-reload-retained", { + await harness.commands.get("workflow")?.handler?.(`resume ${testRunId("durable-reload-retained")}`, { hasUI: false, ui: { notify: () => undefined }, }); @@ -461,7 +462,7 @@ describe("workflow reload rediscovery matrix", () => { await writeWorkflow(workflowPath, { name: "reload-inflight", description: "new metadata", prompt: "new prompt" }); const reloaded = reloadResult(await harness.execute({ action: "reload" })); assert.equal(reloaded.status, "ok"); - assert.equal(durableBackend.isWorkflowLoadable("durable-reload-retained"), true); + assert.equal(durableBackend.isWorkflowLoadable(testRunId("durable-reload-retained")), true); const current = await harness.execute({ action: "get", workflow: "reload-inflight" }); assert.equal(current.action, "get"); assert.equal(current.details?.output?.description, "new metadata"); diff --git a/test/unit/workflow-resume-partial-surfaces.test.ts b/test/unit/workflow-resume-partial-surfaces.test.ts index cda931498..26a710258 100644 --- a/test/unit/workflow-resume-partial-surfaces.test.ts +++ b/test/unit/workflow-resume-partial-surfaces.test.ts @@ -12,6 +12,7 @@ import { stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; function seedPartialRun(runId: string): InMemoryDurableBackend { const backend = new InMemoryDurableBackend(); @@ -90,7 +91,7 @@ afterEach(() => { describe("partial resume command surfaces", () => { test.sequential("workflow tool preserves result identity and reports partial failure", async () => { - const runId = "tool-partial-resume"; + const runId = testRunId("tool-partial-resume"); const backend = seedPartialRun(runId); const runtime = createExtensionRuntime({ definitions: [], store }); const execute = makeExecuteWorkflowTool( @@ -123,7 +124,7 @@ describe("partial resume command surfaces", () => { super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); } } - const runId = "tool-durable-resume-failure"; + const runId = testRunId("tool-durable-resume-failure"); const backend = new ThrowRunningBackend(); setDurableBackend(backend); store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); @@ -175,7 +176,7 @@ describe("partial resume command surfaces", () => { super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); } } - const runId = "tool-durable-resume-retry"; + const runId = testRunId("tool-durable-resume-retry"); const backend = new TransientRunningBackend(); setDurableBackend(backend); store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); @@ -220,7 +221,7 @@ describe("partial resume command surfaces", () => { }); test.sequential("no-target slash selector reports resume rejection through the reporter", async () => { - const runId = "slash-picker-resume-failure"; + const runId = testRunId("slash-picker-resume-failure"); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); store.recordRunStart({ id: runId, name: "picker", inputs: {}, status: "running", stages: [], startedAt: 1 }); @@ -272,7 +273,7 @@ describe("partial resume command surfaces", () => { }); test.sequential("slash resume reports the same partial failure instead of success or noop", async () => { - const runId = "slash-partial-resume"; + const runId = testRunId("slash-partial-resume"); const backend = seedPartialRun(runId); const runtime = createExtensionRuntime({ definitions: [], store }); const info: string[] = []; diff --git a/test/unit/workflow-run-control-completed-resume.test.ts b/test/unit/workflow-run-control-completed-resume.test.ts index 9e52192d0..fd1e0ce22 100644 --- a/test/unit/workflow-run-control-completed-resume.test.ts +++ b/test/unit/workflow-run-control-completed-resume.test.ts @@ -20,6 +20,7 @@ import { collectResumePickerLiveRuns } from "../../packages/workflows/src/extens import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { store } from "../../packages/workflows/src/shared/store.js"; import { ENV_WORKFLOW_ARTIFACT_DIR } from "../../packages/workflows/src/shared/workflow-artifacts.js"; +import { testRunId } from "../helpers/run-id.js"; let tempDir = ""; @@ -119,10 +120,10 @@ async function resume( } describe("/workflow resume completed target", () => { - test("opens a unique completed id prefix without invoking durable resume dispatch", async () => { + test("opens an exact completed id without invoking durable resume dispatch", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompleted(backend, "completed-command-target"); + registerCompleted(backend, testRunId("completed-command-target")); const baseRuntime = createExtensionRuntime({ store }); let resumeCalls = 0; const runtime: ExtensionRuntime = { @@ -134,19 +135,19 @@ describe("/workflow resume completed target", () => { }; const opened: string[] = []; - const result = await resume("completed-command", runtime, opened); + const result = await resume(testRunId("completed-command-target"), runtime, opened); assert.equal(resumeCalls, 0); - assert.deepEqual(opened, ["completed-command-target"]); + assert.deepEqual(opened, [testRunId("completed-command-target")]); assert.match(result.messages.join("\n"), /read-only inspection and follow-up chat/); - assert.equal(store.runs().find((run) => run.id === "completed-command-target")?.status, "completed"); - assert.equal(backend.getWorkflow("completed-command-target")?.status, "completed"); + assert.equal(store.runs().find((run) => run.id === testRunId("completed-command-target"))?.status, "completed"); + assert.equal(backend.getWorkflow(testRunId("completed-command-target"))?.status, "completed"); }); test("keeps the direct completed fallback lifecycle-silent when the runtime omits the open adapter", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompletedTool(backend, "completed-direct-fallback"); + registerCompletedTool(backend, testRunId("completed-direct-fallback")); const lifecycleState = createWorkflowLifecycleNotificationState(); const sends: Array<{ options: object | undefined }> = []; const unsubscribe = installWorkflowLifecycleNotifications({ @@ -165,7 +166,7 @@ describe("/workflow resume completed target", () => { const deps = commandDeps(runtime, opened); deps.beforeRestoreCompleted = (snapshots) => { assert.equal( - store.runs().some((run) => run.id === "completed-direct-fallback"), + store.runs().some((run) => run.id === testRunId("completed-direct-fallback")), false, "lifecycle state must be seeded before the historical snapshot is inserted", ); @@ -178,57 +179,60 @@ describe("/workflow resume completed target", () => { const errors: string[] = []; await handleRunControlCommand( "resume", - ["completed-direct-fallback"], + [testRunId("completed-direct-fallback")], { hasUI: true, ui: { notify: () => undefined } }, { info: (message) => messages.push(message), error: (message) => errors.push(message) }, deps, ); assert.deepEqual(errors, []); - assert.deepEqual(opened, ["completed-direct-fallback"]); - assert.deepEqual(restored, [["completed-direct-fallback"]]); + assert.deepEqual(opened, [testRunId("completed-direct-fallback")]); + assert.deepEqual(restored, [[testRunId("completed-direct-fallback")]]); assert.match(messages.join("\n"), /read-only inspection/); - assert.equal(store.runs().find((run) => run.id === "completed-direct-fallback")?.toolNodes?.[0]?.name, "done"); + assert.equal( + store.runs().find((run) => run.id === testRunId("completed-direct-fallback"))?.toolNodes?.[0]?.name, + "done", + ); assert.deepEqual(sends, [], "historical command fallback must emit no lifecycle steer/card"); } finally { unsubscribe(); } }); - test("opens an exact completed id and reports completed-prefix ambiguity", async () => { + test("opens an exact completed id and rejects a completed-id prefix", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompleted(backend, "completed-exact-alpha"); - registerCompleted(backend, "completed-exact-beta"); + registerCompleted(backend, testRunId("completed-exact-alpha")); + registerCompleted(backend, testRunId("completed-exact-beta")); const runtime = createExtensionRuntime({ store }); const opened: string[] = []; - const exact = await resume("completed-exact-alpha", runtime, opened); + const exact = await resume(testRunId("completed-exact-alpha"), runtime, opened); store.clear(); - const ambiguous = await resume("completed-exact-", runtime); + const malformed = await resume("completed-exact-", runtime); - assert.deepEqual(opened, ["completed-exact-alpha"]); + assert.deepEqual(opened, [testRunId("completed-exact-alpha")]); assert.match(exact.messages.join("\n"), /Opened completed durable workflow/); - assert.match(ambiguous.errors.join("\n"), /Ambiguous workflow prefix/); - assert.match(ambiguous.errors.join("\n"), /completed-exact-alpha-flow/); - assert.match(ambiguous.errors.join("\n"), /completed-exact-beta-flow/); + assert.match(malformed.errors.join("\n"), /Run id must be a full 36-character UUID/); }); test("reports a clear missing target without dispatching completed inspection", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompleted(backend, "known-completed"); + registerCompleted(backend, testRunId("known-completed")); - const result = await resume("missing-workflow", createExtensionRuntime({ store })); + const missingTarget = testRunId("missing-workflow"); + const result = await resume(missingTarget, createExtensionRuntime({ store })); - assert.match(result.errors.join("\n"), /No resumable workflow found for id\/prefix: missing-workflow/); + assert.match(result.errors.join("\n"), new RegExp(`No resumable workflow found for id: ${missingTarget}`)); }); test("explicit resume of a non-resumable run keeps the explanatory error", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); + const runId = testRunId("zero-progress-explicit"); backend.registerWorkflow({ - workflowId: "zero-progress-explicit", + workflowId: runId, name: "zero-progress-flow", inputs: {}, createdAt: 1, @@ -236,7 +240,7 @@ describe("/workflow resume completed target", () => { resumable: true, }); store.recordRunStart({ - id: "zero-progress-explicit", + id: runId, name: "zero-progress-flow", inputs: {}, status: "paused", @@ -245,7 +249,7 @@ describe("/workflow resume completed target", () => { resumable: true, }); - const result = await resume("zero-progress-explicit", createExtensionRuntime({ store })); + const result = await resume(runId, createExtensionRuntime({ store })); assert.match( result.errors.join("\n"), @@ -257,7 +261,7 @@ describe("/workflow resume completed target", () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ - workflowId: "stale-completed-target", + workflowId: testRunId("stale-completed-target"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -274,7 +278,7 @@ describe("/workflow resume completed target", () => { }, }; - const result = await resume("stale-completed", runtime); + const result = await resume(testRunId("stale-completed-target"), runtime); assert.equal(resumeCalls, 0); assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); @@ -284,7 +288,7 @@ describe("/workflow resume completed target", () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ - workflowId: "retained-stale", + workflowId: testRunId("retained-stale"), name: "completed-flow", inputs: {}, createdAt: 1, @@ -292,7 +296,7 @@ describe("/workflow resume completed target", () => { completedCheckpoints: 1, }); store.recordRunStart({ - id: "retained-stale", + id: testRunId("retained-stale"), name: "completed-flow", inputs: {}, status: "completed", @@ -312,19 +316,19 @@ describe("/workflow resume completed target", () => { }; const opened: string[] = []; - const result = await resume("retained-stale", runtime, opened); + const result = await resume(testRunId("retained-stale"), runtime, opened); assert.equal(resumeCalls, 0); assert.deepEqual(opened, []); assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); }); - test("reports ambiguity across live and completed workflow prefixes", async () => { + test("rejects a shared run-id prefix across live and completed workflows", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompleted(backend, "shared-completed"); + registerCompleted(backend, testRunId("shared-completed")); store.recordRunStart({ - id: "shared-live", + id: testRunId("shared-live"), name: "live-flow", inputs: {}, status: "paused", @@ -334,17 +338,15 @@ describe("/workflow resume completed target", () => { }); const result = await resume("shared-", createExtensionRuntime({ store })); - assert.match(result.errors.join("\n"), /Ambiguous workflow prefix/); - assert.match(result.errors.join("\n"), /live-flow/); - assert.match(result.errors.join("\n"), /shared-completed-flow/); + assert.match(result.errors.join("\n"), /Run id must be a full 36-character UUID/); }); - test("excludes cancelled, killed, and non-resumable failed locals from prefix resolution", async () => { + test("excludes cancelled, killed, and non-resumable failed locals from exact-id resolution", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - registerCompleted(backend, "excluded-completed"); + registerCompleted(backend, testRunId("excluded-completed")); store.recordRunStart({ - id: "excluded-cancelled", + id: testRunId("excluded-cancelled"), name: "cancelled", inputs: {}, status: "cancelled", @@ -354,7 +356,7 @@ describe("/workflow resume completed target", () => { resumable: false, }); store.recordRunStart({ - id: "excluded-killed", + id: testRunId("excluded-killed"), name: "killed", inputs: {}, status: "killed", @@ -364,7 +366,7 @@ describe("/workflow resume completed target", () => { resumable: false, }); store.recordRunStart({ - id: "excluded-failed", + id: testRunId("excluded-failed"), name: "failed", inputs: {}, status: "failed", @@ -375,17 +377,17 @@ describe("/workflow resume completed target", () => { }); const opened: string[] = []; - const result = await resume("excluded-", createExtensionRuntime({ store }), opened); + const result = await resume(testRunId("excluded-completed"), createExtensionRuntime({ store }), opened); assert.equal(result.errors.length, 0); - assert.deepEqual(opened, ["excluded-completed"]); + assert.deepEqual(opened, [testRunId("excluded-completed")]); }); test("keeps quit shadows on the durable resume path", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ - workflowId: "quit-shadow", + workflowId: testRunId("quit-shadow"), name: "quit-flow", inputs: {}, createdAt: 1, @@ -393,7 +395,7 @@ describe("/workflow resume completed target", () => { completedCheckpoints: 1, }); store.recordRunStart({ - id: "quit-shadow", + id: testRunId("quit-shadow"), name: "quit-flow", inputs: {}, status: "running", @@ -411,15 +413,15 @@ describe("/workflow resume completed target", () => { durableResumeCalls += 1; return Promise.resolve({ ok: true, - runId: "quit-shadow", - workflowId: "quit-shadow", + runId: testRunId("quit-shadow"), + workflowId: testRunId("quit-shadow"), name: "quit-flow", message: "resumed quit shadow", }); }, }; - const result = await resume("quit-shadow", runtime); + const result = await resume(testRunId("quit-shadow"), runtime); assert.equal(durableResumeCalls, 1); assert.match(result.messages.join("\n"), /resumed quit shadow/); @@ -429,7 +431,7 @@ describe("/workflow resume completed target", () => { test(`keeps durable ${status} targets on the durable resume path`, async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const id = `durable-${status}`; + const id = testRunId(`durable-${status}`); const entry = { workflowId: id, name: `${status}-flow`, @@ -461,7 +463,7 @@ describe("/workflow resume completed target", () => { test("routes checkpointed resumable failures through durable resume instead of completed inspection", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const id = "failed-resumable-command-target"; + const id = testRunId("failed-resumable-command-target"); backend.registerWorkflow({ workflowId: id, name: "failed-resumable-flow", @@ -518,10 +520,10 @@ describe("/workflow resume completed target", () => { assert.equal(resolveWorkflowResumeTarget(id, [], catalog.resumable, catalog.completed).kind, "durable"); assert.equal( resolveWorkflowResumeTarget("failed-resumable-command", [], catalog.resumable, catalog.completed).kind, - "durable", + "malformed", ); - const result = await resume("failed-resumable-command", runtime); + const result = await resume(testRunId("failed-resumable-command-target"), runtime); assert.equal(resumeCalls, 1); assert.equal(completedOpenCalls, 0); @@ -538,9 +540,9 @@ describe("/workflow resume completed target", () => { return listCompletedWorkflows(); }; setDurableBackend(backend); - registerCompleted(backend, "exact-live-other-completed"); + registerCompleted(backend, testRunId("exact-live-other-completed")); store.recordRunStart({ - id: "exact-live", + id: testRunId("exact-live"), name: "live-flow", inputs: {}, status: "paused", @@ -550,11 +552,11 @@ describe("/workflow resume completed target", () => { }); const opened: string[] = []; - const result = await resume("exact-live", createExtensionRuntime({ store }), opened); + const result = await resume(testRunId("exact-live"), createExtensionRuntime({ store }), opened); assert.equal(result.errors.length, 0); - assert.equal(store.runs().find((run) => run.id === "exact-live")?.status, "running"); - assert.match(result.messages.join("\n"), /Resumed run exact-li/); + assert.equal(store.runs().find((run) => run.id === testRunId("exact-live"))?.status, "running"); + assert.match(result.messages.join("\n"), new RegExp(`Resumed run ${testRunId("exact-live")}`)); assert.equal(completedCatalogReads, 0, "an exact live run must bypass durable completed-catalog enumeration"); }); @@ -562,7 +564,7 @@ describe("/workflow resume completed target", () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ - workflowId: "picker-active-block", + workflowId: testRunId("picker-active-block"), name: "picker-flow", inputs: {}, createdAt: 1, @@ -570,7 +572,7 @@ describe("/workflow resume completed target", () => { resumable: true, }); store.recordRunStart({ - id: "picker-active-block", + id: testRunId("picker-active-block"), name: "picker-flow", inputs: {}, status: "running", @@ -586,16 +588,16 @@ describe("/workflow resume completed target", () => { assert.deepEqual( source.liveRuns.map((run) => run.id), - ["picker-active-block"], + [testRunId("picker-active-block")], ); - assert.equal(source.suppressedLiveIds.has("picker-active-block"), false); + assert.equal(source.suppressedLiveIds.has(testRunId("picker-active-block")), false); }); test("resume picker omits a paused snapshot with no durable checkpoint", () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ - workflowId: "picker-no-checkpoint", + workflowId: testRunId("picker-no-checkpoint"), name: "picker-flow", inputs: {}, createdAt: 1, @@ -603,7 +605,7 @@ describe("/workflow resume completed target", () => { resumable: true, }); store.recordRunStart({ - id: "picker-no-checkpoint", + id: testRunId("picker-no-checkpoint"), name: "picker-flow", inputs: {}, status: "paused", @@ -615,7 +617,7 @@ describe("/workflow resume completed target", () => { const source = collectResumePickerLiveRuns(store); assert.equal( - source.liveRuns.some((run) => run.id === "picker-no-checkpoint"), + source.liveRuns.some((run) => run.id === testRunId("picker-no-checkpoint")), false, ); }); @@ -623,7 +625,7 @@ describe("/workflow resume completed target", () => { test("suppresses a durable duplicate for a paused snapshot whose artifact is missing", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "picker-missing-artifact"; + const runId = testRunId("picker-missing-artifact"); backend.registerWorkflow({ workflowId: runId, name: "picker-flow", @@ -682,7 +684,7 @@ describe("/workflow resume completed target", () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); store.recordRunStart({ - id: "failed-live", + id: testRunId("failed-live"), name: "failed-flow", inputs: {}, status: "failed", @@ -692,7 +694,7 @@ describe("/workflow resume completed target", () => { resumable: true, }); store.recordRunStart({ - id: "running-live", + id: testRunId("running-live"), name: "running-flow", inputs: {}, status: "running", @@ -700,7 +702,7 @@ describe("/workflow resume completed target", () => { startedAt: 1, }); store.recordRunStart({ - id: "active-blocked-live", + id: testRunId("active-blocked-live"), name: "blocked-flow", inputs: {}, status: "running", @@ -719,17 +721,17 @@ describe("/workflow resume completed target", () => { failedResumeCalls += 1; return { ok: true, - runId: "continued-run", - sourceRunId: "failed-live", + runId: testRunId("continued-run"), + sourceRunId: testRunId("failed-live"), resumeFromStageId: "failed-stage", message: "continued failed workflow", }; }, }; - const failedResult = await resume("failed-live", runtime); - const blockedResult = await resume("active-blocked-live", runtime); - const runningResult = await resume("running-live", runtime); + const failedResult = await resume(testRunId("failed-live"), runtime); + const blockedResult = await resume(testRunId("active-blocked-live"), runtime); + const runningResult = await resume(testRunId("running-live"), runtime); assert.equal(failedResumeCalls, 2); assert.match(failedResult.messages.join("\n"), /continued failed workflow/); diff --git a/test/unit/workflow-status-listing.test.ts b/test/unit/workflow-status-listing.test.ts index 32f86007a..682c2ae13 100644 --- a/test/unit/workflow-status-listing.test.ts +++ b/test/unit/workflow-status-listing.test.ts @@ -87,7 +87,7 @@ describe("workflow tool status run listing", () => { // In-flight run sorts before the ended run despite starting later. const active = result.runs[0]!; assert.equal(active.runId, activeId); - assert.equal(active.runIdPrefix, activeId.slice(0, 8)); + assert.equal("runIdPrefix" in active, false); assert.equal(active.name, "release-docs"); assert.equal(active.status, "running"); assert.equal(active.endedAt, undefined); @@ -174,10 +174,10 @@ describe("workflow tool status run listing", () => { assert.match(text, /action: status/); assert.match(text, /filter: all/); assert.match(text, /runs: 1 \(1 in flight\)/); - // Concise summary line: [n] . + // Concise summary line includes the full run id so it remains actionable without truncation. const summaryLine = text.split("\n").find((line) => line.startsWith("[1]")); assert.notEqual(summaryLine, undefined); - assert.match(summaryLine!, new RegExp(activeId.slice(0, 8))); + assert.ok(summaryLine!.includes(activeId)); assert.match(summaryLine!, /release-docs/); assert.match(summaryLine!, /running/); assert.match(summaryLine!, /awaiting input \(1\): approve/); diff --git a/test/unit/workflow-tool-durable-replay.test.ts b/test/unit/workflow-tool-durable-replay.test.ts index f618c0945..3cb85877a 100644 --- a/test/unit/workflow-tool-durable-replay.test.ts +++ b/test/unit/workflow-tool-durable-replay.test.ts @@ -11,6 +11,7 @@ import { quitRun } from "../../packages/workflows/src/runs/background/quit.js"; import { runDetached } from "../../packages/workflows/src/runs/background/runner.js"; import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; class FailingHydrationBackend extends InMemoryDurableBackend { override async hydrateResumableWorkflows(): Promise { @@ -47,7 +48,7 @@ afterEach(async () => { describe("workflow tool durable-only checkpoint replay", () => { test.sequential("resume keeps the original id and does not repeat a completed ctx.tool side effect", async () => { - const workflowId = "tool-durable-replay-original-id"; + const workflowId = testRunId("tool-durable-replay-original-id"); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); backend.registerWorkflow({ @@ -131,7 +132,7 @@ describe("workflow tool durable-only checkpoint replay", () => { () => undefined, () => undefined, ); - const target = "durable-only-unknown-id"; + const target = testRunId("durable-only-unknown-id"); const status = await execute({ action: "status" }, {} as never); assert.equal(status.action, "status"); @@ -148,7 +149,7 @@ describe("workflow tool durable-only checkpoint replay", () => { const backend = new FailingHydrationBackend(); setDurableBackend(backend); const definition = workflow({ - name: "tool-durable-failure", + name: testRunId("tool-durable-failure"), description: "", inputs: {}, outputs: {}, @@ -160,7 +161,7 @@ describe("workflow tool durable-only checkpoint replay", () => { () => undefined, ); - const failed = await execute({ action: "resume", runId: "durable-failure" }, {} as never); + const failed = await execute({ action: "resume", runId: testRunId("durable-failure") }, {} as never); assert.equal(failed.action, "resume"); assert.equal(failed.status, "noop"); assert.match(failed.message, /durable hydration exploded/); @@ -176,7 +177,7 @@ describe("workflow tool durable-only checkpoint replay", () => { }); test.sequential("quit with an in-flight tool re-runs only that call at the same node identity", async () => { - const workflowId = "tool-durable-replay-quit-in-flight"; + const workflowId = testRunId("tool-durable-replay-quit-in-flight"); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); const args = { path: "artifact.txt" }; @@ -293,7 +294,7 @@ describe("workflow tool durable-only checkpoint replay", () => { }); test.sequential("resume supersedes an abandoned detached job and isolates its late callback", async () => { - const workflowId = "tool-durable-replay-abandoned-relaunch"; + const workflowId = testRunId("tool-durable-replay-abandoned-relaunch"); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); const doneArgs = { path: "already-done.txt" }; @@ -410,7 +411,7 @@ describe("workflow tool durable-only checkpoint replay", () => { }); test.sequential("return-mode callback that fulfills after abort writes one inspection-only record", async () => { - const workflowId = "tool-durable-late-return-cancellation"; + const workflowId = testRunId("tool-durable-late-return-cancellation"); const backend = new InMemoryDurableBackend(); setDurableBackend(backend); const args = { path: "late-return.txt" }; diff --git a/test/unit/workflow-tool-graph.test.ts b/test/unit/workflow-tool-graph.test.ts index 9842a9d98..40c55247e 100644 --- a/test/unit/workflow-tool-graph.test.ts +++ b/test/unit/workflow-tool-graph.test.ts @@ -23,6 +23,7 @@ import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expande import { createStore, store as singletonStore } from "../../packages/workflows/src/shared/store.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; import { renderNodeCard } from "../../packages/workflows/src/tui/node-card.js"; +import { testRunId } from "../helpers/run-id.js"; describe("ctx.tool workflow graph execution", () => { test("tool-only workflow completes with its declared output and one durable side effect", async () => { @@ -186,11 +187,11 @@ describe("ctx.tool workflow graph execution", () => { }); test("replays a legacy tool checkpoint without rerunning and reconstructs cached topology", async () => { - const workflowId = "legacy-tool-only"; + const workflowId = testRunId("legacy-tool-only"); const backend = new InMemoryDurableBackend(); backend.registerWorkflow({ workflowId, - name: "legacy-tool-only", + name: testRunId("legacy-tool-only"), inputs: {}, createdAt: 1, status: "paused", @@ -209,7 +210,7 @@ describe("ctx.tool workflow graph execution", () => { }); let calls = 0; const definition = workflow({ - name: "legacy-tool-only", + name: testRunId("legacy-tool-only"), description: "", inputs: {}, outputs: { raw: Type.Boolean() }, @@ -360,14 +361,14 @@ describe("ctx.tool node cancellation controls", () => { test("quit with a tool node id aborts only that node and leaves siblings running", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "tool-node-targeted-quit"; + const runId = testRunId("tool-node-targeted-quit"); const enteredTarget = Promise.withResolvers(); const enteredSibling = Promise.withResolvers(); const releaseSibling = Promise.withResolvers(); let siblingSignal: AbortSignal | undefined; let siblingCompleted = false; const definition = workflow({ - name: "tool-node-targeted-quit", + name: testRunId("tool-node-targeted-quit"), description: "", inputs: {}, outputs: {}, @@ -439,10 +440,10 @@ describe("ctx.tool node cancellation controls", () => { test("an uncaught awaited cancellation reports the node cancelled and the run's real status", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "tool-node-uncaught-abort"; + const runId = testRunId("tool-node-uncaught-abort"); const entered = Promise.withResolvers(); const definition = workflow({ - name: "tool-node-uncaught-abort", + name: testRunId("tool-node-uncaught-abort"), description: "", inputs: {}, outputs: {}, @@ -497,7 +498,7 @@ describe("ctx.tool node cancellation controls", () => { test("interrupt resolves a unique tool name, rejects an ambiguous one, and refuses pause", async () => { const backend = new InMemoryDurableBackend(); setDurableBackend(backend); - const runId = "tool-node-name-routing"; + const runId = testRunId("tool-node-name-routing"); const entered: Array> = []; const enteredResolvers = [ Promise.withResolvers(), @@ -507,7 +508,7 @@ describe("ctx.tool node cancellation controls", () => { for (const resolver of enteredResolvers) entered.push(resolver.promise); const releaseDuplicates = Promise.withResolvers(); const definition = workflow({ - name: "tool-node-name-routing", + name: testRunId("tool-node-name-routing"), description: "", inputs: {}, outputs: {}, @@ -576,7 +577,7 @@ describe("ctx.tool node cancellation controls", () => { const toolControls = createToolControlRegistry(); const entered = Promise.withResolvers(); const definition = workflow({ - name: "tool-cancelled-rendering", + name: testRunId("tool-cancelled-rendering"), description: "", inputs: {}, outputs: {}, @@ -598,12 +599,20 @@ describe("ctx.tool node cancellation controls", () => { const pending = run( definition, {}, - { runId: "tool-cancelled-rendering", store, durableBackend: backend, toolControlRegistry: toolControls }, + { + runId: testRunId("tool-cancelled-rendering"), + store, + durableBackend: backend, + toolControlRegistry: toolControls, + }, ); await entered.promise; const liveNode = store.runs()[0]?.toolNodes?.find((node) => node.name === "aborted-call"); assert.ok(liveNode); - await abortToolNode("tool-cancelled-rendering", liveNode.id, { store, toolControlRegistry: toolControls }); + await abortToolNode(testRunId("tool-cancelled-rendering"), liveNode.id, { + store, + toolControlRegistry: toolControls, + }); const result = await pending; const snapshot = store.runs().find((candidate) => candidate.id === result.runId)!; diff --git a/test/unit/workflow-tool-interactions.test.ts b/test/unit/workflow-tool-interactions.test.ts index 10cfd891c..fa9fd98c4 100644 --- a/test/unit/workflow-tool-interactions.test.ts +++ b/test/unit/workflow-tool-interactions.test.ts @@ -15,11 +15,12 @@ import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expande import { createStore, store } from "../../packages/workflows/src/shared/store.js"; import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; import { computeLayout, NODE_H, NODE_W } from "../../packages/workflows/src/tui/layout.js"; +import { testRunId } from "../helpers/run-id.js"; import { defaultTheme } from "./overlay-graph-helpers.js"; function recordToolOnly(target = createStore(), status: "running" | "completed" = "running") { target.recordRunStart({ - id: "tool-interaction-run", + id: testRunId("tool-interaction-run"), name: "tool interaction", inputs: {}, status, @@ -70,11 +71,11 @@ afterEach(() => { describe("non-attachable tool interactions", () => { test("keyboard, direct mouse, and switcher activation never attach a tool", () => { const localStore = recordToolOnly(); - const graph = expandWorkflowGraph(localStore.snapshot(), "tool-interaction-run"); + const graph = expandWorkflowGraph(localStore.snapshot(), testRunId("tool-interaction-run")); const attached: string[] = []; const view = new GraphView({ mode: "overlay", - runId: "tool-interaction-run", + runId: testRunId("tool-interaction-run"), store: localStore, graphTheme: defaultTheme, getViewportRows: () => 32, @@ -94,7 +95,7 @@ describe("non-attachable tool interactions", () => { test("keyboard, mouse, and switcher activation open a retained completed stage", () => { const localStore = createStore(); localStore.recordRunStart({ - id: "postmortem-run", + id: testRunId("postmortem-run"), name: "postmortem", inputs: {}, status: "completed", @@ -112,11 +113,11 @@ describe("non-attachable tool interactions", () => { }, ], }); - const graph = expandWorkflowGraph(localStore.snapshot(), "postmortem-run"); + const graph = expandWorkflowGraph(localStore.snapshot(), testRunId("postmortem-run")); const attached: string[] = []; const view = new GraphView({ mode: "overlay", - runId: "postmortem-run", + runId: testRunId("postmortem-run"), store: localStore, graphTheme: defaultTheme, getViewportRows: () => 32, @@ -130,9 +131,9 @@ describe("non-attachable tool interactions", () => { for (const char of "retained-stage") view.handleInput(char); view.handleInput("\r"); assert.deepEqual(attached, [ - "postmortem-run/retained-stage", - "postmortem-run/retained-stage", - "postmortem-run/retained-stage", + `${testRunId("postmortem-run")}/retained-stage`, + `${testRunId("postmortem-run")}/retained-stage`, + `${testRunId("postmortem-run")}/retained-stage`, ]); view.dispose(); }); @@ -140,13 +141,13 @@ describe("non-attachable tool interactions", () => { test("terminal sends reject before textual tool targeting can create a handle", async () => { recordToolOnly(store, "completed"); for (const target of ["tool:publish", "publish-api"]) { - const resolved = resolveStageTarget("tool-interaction-run", target); + const resolved = resolveStageTarget(testRunId("tool-interaction-run"), target); assert.equal(resolved.ok, false, `${target} must not resolve as a stage`); } let postMortemCreates = 0; const sent = await workflowSendAction( - { action: "send", runId: "tool-interaction-run", stageId: "tool:publish", text: "chat" }, + { action: "send", runId: testRunId("tool-interaction-run"), stageId: "tool:publish", text: "chat" }, { resolvePostMortemDeps: () => { postMortemCreates += 1; @@ -156,14 +157,14 @@ describe("non-attachable tool interactions", () => { ); const paused = await workflowPauseAction({ action: "pause", - runId: "tool-interaction-run", + runId: testRunId("tool-interaction-run"), stageId: "tool:publish", }); let overlayOpens = 0; const commandErrors: string[] = []; await handleRunControlCommand( "attach", - ["tool-interaction-run", "tool:publish"], + [testRunId("tool-interaction-run"), "tool:publish"], {}, { info() {}, @@ -184,7 +185,7 @@ describe("non-attachable tool interactions", () => { ); const interrupted = await workflowInterruptAction({ action: "interrupt", - runId: "tool-interaction-run", + runId: testRunId("tool-interaction-run"), stageId: "tool:publish", }); @@ -196,14 +197,17 @@ describe("non-attachable tool interactions", () => { assert.equal(sent.delivery, "rejected"); assert.equal(paused.status, "noop"); assert.equal(interrupted.status, "noop"); - assert.match(sent.message, /workflow tool-interaction-run has terminated with status completed/); + assert.match( + sent.message, + new RegExp(`workflow ${testRunId("tool-interaction-run")} has terminated with status completed`), + ); // Tool nodes are abort-only control targets: pause rejects them explicitly and // interrupt reports that this settled node has nothing in flight to abort. assert.match(paused.message, /Tool nodes cannot be paused/); assert.match(interrupted.message, /Tool node tool:publish is not running/); assert.equal(postMortemCreates, 0); const resumed = await workflowResumeAction( - { action: "resume", runId: "tool-interaction-run", stageId: "tool:publish" }, + { action: "resume", runId: testRunId("tool-interaction-run"), stageId: "tool:publish" }, { getRuntime: () => ({ prepareDurableResumable: async () => [] }), policy: {}, @@ -213,6 +217,6 @@ describe("non-attachable tool interactions", () => { assert.equal(resumed.status, "noop"); assert.match(commandErrors.join("\n"), /Stage not found/); assert.equal(overlayOpens, 0); - assert.deepEqual(stageControlRegistry.forRun("tool-interaction-run"), []); + assert.deepEqual(stageControlRegistry.forRun(testRunId("tool-interaction-run")), []); }); }); diff --git a/test/unit/workflow-tool-send-idle-routing.test.ts b/test/unit/workflow-tool-send-idle-routing.test.ts index b80f73d1c..19ffb27f6 100644 --- a/test/unit/workflow-tool-send-idle-routing.test.ts +++ b/test/unit/workflow-tool-send-idle-routing.test.ts @@ -6,6 +6,7 @@ import { stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; const runIds = new Set(); @@ -83,7 +84,7 @@ function liveHandle(input: { describe("workflow send — idle-aware live-stage routing", () => { for (const delivery of ["auto", "followUp"] as const) { test(`idle ${delivery} starts a prompt and reports the actual action`, async () => { - const runId = `idle-${delivery}`; + const runId = testRunId(`idle-${delivery}`); const calls: string[] = []; liveHandle({ runId, streaming: false, calls }); @@ -107,7 +108,7 @@ describe("workflow send — idle-aware live-stage routing", () => { } test("explicit idle prompt preserves its established response string", async () => { - const runId = "explicit-idle-prompt"; + const runId = testRunId("explicit-idle-prompt"); const calls: string[] = []; liveHandle({ runId, streaming: false, calls }); @@ -125,7 +126,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("paused root resume preserves its established response string", async () => { - const runId = "ordinary-resume"; + const runId = testRunId("ordinary-resume"); const calls: string[] = []; liveHandle({ runId, streaming: false, calls, status: "paused", runStatus: "paused" }); assert.equal(store.runs().find((run) => run.id === runId)?.status, "paused"); @@ -144,7 +145,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("resume against a running stage is a truthful noop", async () => { - const runId = "running-resume-noop"; + const runId = testRunId("running-resume-noop"); const calls: string[] = []; liveHandle({ runId, streaming: false, calls }); @@ -162,7 +163,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("explicit sends cannot bypass a paused stage", async () => { - const runId = "paused-follow-up-noop"; + const runId = testRunId("paused-follow-up-noop"); const calls: string[] = []; liveHandle({ runId, streaming: false, calls, status: "paused" }); @@ -180,7 +181,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("streaming followUp queues without starting a concurrent prompt", async () => { - const runId = "streaming-follow-up"; + const runId = testRunId("streaming-follow-up"); const calls: string[] = []; liveHandle({ runId, streaming: true, calls }); @@ -197,7 +198,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("streaming steer steers without starting a concurrent prompt", async () => { - const runId = "streaming-steer"; + const runId = testRunId("streaming-steer"); const calls: string[] = []; liveHandle({ runId, streaming: true, calls }); @@ -214,7 +215,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("streaming auto selects steering, matching a manually typed message", async () => { - const runId = "streaming-auto"; + const runId = testRunId("streaming-auto"); const calls: string[] = []; liveHandle({ runId, streaming: true, calls }); @@ -230,7 +231,7 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("sequential sends preserve submission order within each queue", async () => { - const runId = "streaming-order"; + const runId = testRunId("streaming-order"); const calls: string[] = []; liveHandle({ runId, streaming: true, calls }); @@ -255,8 +256,8 @@ describe("workflow send — idle-aware live-stage routing", () => { }); test("expanded root target sends to the hydrated child owner", async () => { - const rootId = "hydrated-routing-root"; - const childId = "hydrated-routing-child"; + const rootId = testRunId("hydrated-routing-root"); + const childId = testRunId("hydrated-routing-child"); const calls: string[] = []; runIds.add(rootId); runIds.add(childId); diff --git a/test/unit/workflow-tool-send-postmortem.test.ts b/test/unit/workflow-tool-send-postmortem.test.ts index d4e2fd5f4..217e49913 100644 --- a/test/unit/workflow-tool-send-postmortem.test.ts +++ b/test/unit/workflow-tool-send-postmortem.test.ts @@ -1,3 +1,4 @@ +import { testRunId } from "../helpers/run-id.js"; /** * Regression coverage for issue #2009's terminal workflow-send boundary. * @@ -24,8 +25,8 @@ import { store } from "../../packages/workflows/src/shared/store.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; let tempDir = ""; -const RUN_ID = "postmortem-send-run"; -const CHILD_RUN_ID = "postmortem-send-child"; +const RUN_ID = testRunId("postmortem-send-run"); +const CHILD_RUN_ID = testRunId("postmortem-send-child"); const TERMINAL_ROOT_STATUSES = ["completed", "failed", "skipped", "cancelled", "killed", "blocked"] as const; interface RevivalCounter { diff --git a/test/unit/workflow-tool-send-terminal-race.test.ts b/test/unit/workflow-tool-send-terminal-race.test.ts index 61867821f..aba76afec 100644 --- a/test/unit/workflow-tool-send-terminal-race.test.ts +++ b/test/unit/workflow-tool-send-terminal-race.test.ts @@ -9,9 +9,10 @@ import type { PostMortemStageChatDeps } from "../../packages/workflows/src/runs/ import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import type { StageAdapters } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; import { store } from "../../packages/workflows/src/shared/store.js"; +import { testRunId } from "../helpers/run-id.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; -const RUN_ID = "terminal-send-race"; +const RUN_ID = testRunId("terminal-send-race"); let tempDir = ""; beforeEach(() => {