diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 183c6078d..4834252ba 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,7 +2,7 @@ language: "en-US" tone_instructions: >- Focus on type safety, state machine correctness, security policy correctness, - persisted state compatibility, CLI output stability, and design principles + persisted state no-migration, CLI output stability, and design principles from CLAUDE.md. Be direct and specific. Flag action-type mapping violations. reviews: @@ -56,7 +56,11 @@ reviews: - No silent mapping: STOP, COMPLETE, BREAK, DEFER, GOTO, NEXT, CONTINUE, and RETRY must preserve their distinct semantics. - No synthetic IDs: use XState native event system and state graph structure. - Guard functions must express domain conditions only. - - Persisted state compatibility: changes to RunbookState, snapshots, lifecycle, variables, delegation state, or schemaVersion must follow the no-migration rule from CLAUDE.md. + - Persisted state no-migration: this project is pre-release. Do not request + migrations, compatibility shims, fallback reconstruction, or legacy identity + derivation for RunbookState, snapshots, lifecycle, variables, delegation state, + or schemaVersion changes. Flag explicit or implicit migration/adaptation; stale + state should be rejected and require complete, stop, prune, or restart. - path: "packages/core/src/policy/**/*.ts" instructions: | Security-sensitive policy code. Focus on: @@ -200,12 +204,18 @@ reviews: docstrings: mode: "off" # Handled by eslint-plugin-jsdoc in CI custom_checks: - - name: "Persisted state compatibility" + - name: "Persisted state no-migration" mode: "warning" instructions: | - Warn if files that define persisted runbook state, state snapshots, lifecycle fields, variable storage, delegation state, or schemaVersion behavior change and the PR also silently migrates existing .rundown/runs/ data. + This project is pre-release. There is no supported legacy .rundown/runs state. + Do not request migrations, compatibility shims, fallback reconstruction, or legacy runbookRef derivation. + + Warn if files that define persisted runbook state, state snapshots, + lifecycle fields, variable storage, delegation state, or schemaVersion behavior + change and the PR explicitly or implicitly adapts, rewrites, or migrates stale + .rundown/runs/ data. Pass if the PR does not touch persisted state behavior. - Pass if the PR preserves compatibility or detects stale state and requires completion, stop, or prune before restart. + Pass if stale or incompatible state is rejected and the user must complete, stop, prune, or restart. - name: "CLI schema coverage" mode: "warning" instructions: | @@ -246,7 +256,7 @@ code_generation: Generate Jest tests that cover valid syntax, invalid syntax, diagnostics, frontmatter edge cases, code blocks, transitions, FOR clauses, and DELEGATE annotations. - path: "packages/core/**/*.ts" instructions: | - Generate Jest tests that cover state transitions, result/handler/action separation, persisted-state stale detection, policy enforcement, sandbox edge cases, output schema stability, and error paths. + Generate Jest tests that cover state transitions, result/handler/action separation, stale persisted-state rejection requiring complete, stop, prune, or restart, policy enforcement, sandbox edge cases, output schema stability, and error paths. - path: "packages/cli/**/*.ts" instructions: | Generate Jest tests that verify JSON output, --text output, --schema output, command option validation, and user-facing errors. Prefer existing CLI test helpers and snapshots only when the repository already uses them for that behavior. diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 1cb220b59..8cb544ef5 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -150,10 +150,12 @@ rehydrated renderable resumability runbook +runbookref runbooks rundown rundownrc rundowntest +runid runme rustc sandboxing diff --git a/docs/guides/project-integration.md b/docs/guides/project-integration.md index 5e54e5585..de2f5e157 100644 --- a/docs/guides/project-integration.md +++ b/docs/guides/project-integration.md @@ -227,6 +227,6 @@ review/ Guidelines: - Use `#!/usr/bin/env bash` and `set -euo pipefail` - Accept parameters positionally with usage messages -- By default write output to `.rundown/work//` for intermediate artifacts; override via the `WorkPath` template variable (set with `--input WorkPath=...` or config) or the `WORK_PATH` environment variable read by scripts +- By default write intermediate artifacts under the project-shared `.rundown/work` base, using `rdpath --ctx ` or `{{ path "..." }}` when artifacts need workflow isolation - Exit 0 for success (PASS), non-zero for failure (FAIL) - Keep scripts focused — one responsibility per script diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b1c94e9b2..89c00c579 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -302,7 +302,7 @@ rundown status **Output:** ```text File: my-runbook.runbook.md -State: .rundown/runs/wf-2024-01-07-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Action: CONTINUE Result: PASS @@ -652,7 +652,7 @@ Output formatting is implemented in `packages/cli/src/services/output-emitter.ts ```text File: runbook.runbook.md -State: .rundown/runs/wf-xxx.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Action: START At: 1 diff --git a/docs/reference/runtime.md b/docs/reference/runtime.md index d1b51ac85..2b7b724d8 100644 --- a/docs/reference/runtime.md +++ b/docs/reference/runtime.md @@ -216,15 +216,15 @@ The session tracks top-level runs and delegated children separately. ```json { - "defaultStack": ["wf-2026-04-28-parent"], + "defaultStack": ["rd_11111111111111111111111111111111"], "stashedRunbookId": null, "claims": { "rdclm_F3J3n3d_f8fo0a0b1B2c3Q": { "kind": "claim-record", "claimId": "rdclm_F3J3n3d_f8fo0a0b1B2c3Q", - "childRunId": "wf-2026-04-28-child", + "childRunId": "rd_22222222222222222222222222222222", "tokenHash": "sha256:...", - "parentRunId": "wf-2026-04-28-parent", + "parentRunId": "rd_11111111111111111111111111111111", "parentStepId": "1.1", "parentFrameKey": "1|", "parentEntry": 1, @@ -286,8 +286,11 @@ Each run state file stores enough information to resume deterministically. ```json { - "id": "wf-2024-01-07-abc123", - "runbook": "my-runbook.runbook.md", + "id": "rd_4b7f0c2d9e1a4b7f0c2d9e1a4b7f0c2d", + "runbook": { + "source": "project", + "path": ".rundown/runbooks/my-runbook.runbook.md" + }, "runbookPath": ".rundown/runbooks/my-runbook.runbook.md", "title": "My Runbook", "description": "Runbook description", @@ -312,6 +315,9 @@ Each run state file stores enough information to resume deterministically. | Field | Runtime requirement | | --- | --- | +| `id` | Persisted run identifier generated at execution start. | +| `runbook` | Canonical runbook identity object: `{ source, path }`, where `source` is `project`, `plugin`, `bundled`, or `external`. For `project`, `plugin`, and `bundled` sources, `path` is a source-root-relative Markdown path. For `external` sources, `path` is a normalized absolute filesystem path. | +| `runbookPath` | Display/execution file path relative to the current project when possible. | | `step`, `substep` | Current structural position. | | `retryCount` | User-visible retry count across retry sites. | | `variables` | Live variable space, including accumulated outputs. | @@ -367,10 +373,10 @@ remain regular template variables. User-provided variable names MUST match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. -The names `step`, `index`, and `context` are reserved case-insensitively. -Reserved names MUST be rejected in frontmatter `inputs`, frontmatter `required`, -explicit invocation inputs, input files, and config files. Reserved -`RD_INPUT_*` variables are skipped with a warning. +The names `step`, `index`, `context`, `runid`, and `runbookref` are reserved +case-insensitively. Reserved names MUST be rejected in frontmatter `inputs`, +frontmatter `required`, explicit invocation inputs, input files, and config +files. Reserved `RD_INPUT_*` variables are skipped with a warning. ### 8.4 Undefined Variables @@ -403,8 +409,9 @@ dynamic current-frame values but remain reserved for user input. | --- | --- | | `Date`, `DateTime`, `Year`, `Month`, `Day` | Current date/time components. | | `Branch` | Current git branch, or empty outside git. | -| `WorkPath` | Branch-isolated artifact directory; fallback `.rundown/work`; base for `{{ path "..." }}`. | -| `RunId` | Fresh execution identifier for this runbook execution. | +| `WorkPath` | Fixed default artifact base `.rundown/work`; base for `{{ path "..." }}`. | +| `RunbookRef` | Canonical `{ source, path }` identity for the resolved runbook. Injected during runbook preparation. | +| `RunId` | Fresh execution identifier for this runbook execution. Injected only for runnable execution, not for discovery or `rd resolve`. | | `ContextId` | Shared identity across a delegation tree; scopes path helpers into `.rd-/`. | | `Step`, `Index` | Dynamic current step and iteration. | | `context.current.*` | Dynamic current structural context. | @@ -417,6 +424,17 @@ Static built-ins MAY be overridden by higher-precedence sources. Dynamic built-ins MUST NOT be overridden. Plugin runbooks MAY receive upper-snake-case plugin variables such as `CLAUDE_PLUGIN_ROOT`. +The default `WorkPath` value is shared at the project level and does not include +a branch, run, or checkout suffix. Use `ContextId` with `{{ path "..." }}` or +`rdpath --ctx` for workflow isolation inside `.rundown/work/.rd-/`; +run-scoped artifact helpers add `runs//` below that context directory +when a per-run location is required. + +`RunbookRef` is available before template substitution so runbooks can render +their own canonical identity. `RunId` is minted later, when a run is actually +started or claimed, so commands that only resolve variables MUST NOT emit or +persist a synthetic `RunId`. + ### 8.7 Shell Environment @@ -429,6 +447,8 @@ environment filtering. | `RD_WORK_PATH` | `WorkPath` | | `RD_CONTEXT_ID` | `ContextId` | | `RD_RUN_ID` | `RunId` | +| `RD_RUNBOOK_REF` | `RunbookRef.path` | +| `RD_RUNBOOK_SOURCE` | `RunbookRef.source` (`project`, `plugin`, `bundled`, or `external`) | | `RD_OUTPUTS_` | Naked step/substep `OUTPUTS` entry. | Rundown-injected `RD_*` variables use Rundown-wins semantics: user-supplied @@ -441,6 +461,10 @@ resolved static variable map. On resume, the runtime MUST re-apply FOR bounds and template placeholders from this frozen variable state so rendering remains deterministic. +Delegated children inherit the parent's `ContextId` and user variables, but +MUST NOT inherit the parent's `RunId` or `RunbookRef`. Each child receives a +fresh `RunId` and the canonical `RunbookRef` for its own resolved runbook. + ## 9. Security Integration The runtime delegates full policy semantics to diff --git a/docs/reference/security.md b/docs/reference/security.md index e4fce4bf3..78c4aa80e 100644 --- a/docs/reference/security.md +++ b/docs/reference/security.md @@ -187,6 +187,13 @@ Default file access: - Write allow: `{repo}/.claude/**`, `{repo}/.rundown/runs/**`, `{repo}/.rundown/locks/**`, `{repo}/.rundown/contexts/**`, `{repo}/.rundown/session.json`, `{repo}/.rundown/work/**`, `{repo}/node_modules/**`, `{repo}/dist/**`, `{repo}/build/**`, `{repo}/.next/**`, `{tmp}/**` - Write deny: `**/.env`, `**/.env.*`, `**/credentials.json`, `**/*secret*`, `**/*password*`, `{repo}/.rundown/config.yaml` +The default `WorkPath` built-in resolves to the project-shared +`.rundown/work` directory. Rundown does not add branch- or run-derived suffixes +to that base path. Workflows that need separation should use the `ContextId` +scope (`.rundown/work/.rd-/`) or run-scoped artifact paths below that +context; the default policy intentionally grants the full `.rundown/work/**` +tree so those scoped paths remain writable. + Read deny includes SSH keys and certificates (`id_rsa`, `id_ed25519`, `*.pem`, `*.key`) to reduce credential exfiltration risk. Write deny does not include those patterns so key generation workflows can write new keys when otherwise allowed. Allowed environment variables: diff --git a/docs/spec/cli-output.md b/docs/spec/cli-output.md index b68f4ccc2..94cee1e0c 100644 --- a/docs/spec/cli-output.md +++ b/docs/spec/cli-output.md @@ -99,7 +99,7 @@ onboarding plugin New hire setup hr, setup **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Prompt: Yes ## 1. First Step @@ -113,7 +113,7 @@ Step description here. "active": true, "stashed": false, "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "prompted": true, "position": { "current": "1", "total": 3 }, "step": { "name": "1", "description": "First Step" } @@ -148,7 +148,7 @@ Same output shape as active `rd status`, but resolves the delegated child identi **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Action: START @@ -167,7 +167,7 @@ Runbook: COMPLETE { "action": "complete", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "position": { "current": "1", "total": 1 } } ``` @@ -191,9 +191,9 @@ CLAIMED: Claimed rdtk_abcd... -> child.runbook.md "action": "claimed", "token": "rdtk_abcd...", "claim_id": "rdclm_F3J3n3d_f8fo0a0b1B2c3Q", - "run_id": "wf-2026-01-26-child", + "run_id": "rd_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "runbook": "child.runbook.md", - "parent_run_id": "wf-2026-01-26-parent", + "parent_run_id": "rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "parent_step": "1.1" } ``` @@ -211,7 +211,7 @@ The `action` field shows the transition (e.g., "CONTINUE" to next step, "GOTO 3" **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json ─── 2 ────────────────────────────────────────── @@ -229,7 +229,7 @@ Next step description. { "action": "CONTINUE", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "from": { "current": "1", "total": 3 }, "to": { "current": "2", "total": 3 } } @@ -250,7 +250,7 @@ The `action` field shows the transition (e.g., "RETRY (1/3)" for retry, "STOP" f **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Action: RETRY (1/3) At: 1/3 @@ -265,7 +265,7 @@ Step description. { "action": "RETRY (1/3)", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "to": { "current": "1", "total": 3 } } ``` @@ -275,7 +275,7 @@ Step description. **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Runbook: STOP ``` @@ -285,7 +285,7 @@ Runbook: STOP { "action": "STOP", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "stopped": true } ``` @@ -305,7 +305,7 @@ The `action` field is combined (e.g., "GOTO 3"), not a separate `target` field. **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json ─── 3 ────────────────────────────────────────── @@ -323,7 +323,7 @@ Step description. { "action": "GOTO 3", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "from": { "current": "1", "total": 5 }, "to": { "current": "3", "total": 5 } } @@ -340,7 +340,7 @@ Uses `action: "stop"` (command-name action). Stopping sets a non-zero exit code. **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Runbook: STOP ``` @@ -351,7 +351,7 @@ Runbook: STOP "action": "stop", "message": "User requested stop", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json" + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json" } ``` @@ -364,7 +364,7 @@ Runbook: STOP **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Runbook: COMPLETE ``` @@ -375,7 +375,7 @@ Runbook: COMPLETE "action": "complete", "message": "Deployment finished", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json" + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json" } ``` @@ -390,7 +390,7 @@ Uses `action: "stash"` (command-name action). **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Prompt: Yes Step: 1/3 @@ -403,7 +403,7 @@ Runbook: STASHED { "action": "stash", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "prompted": true, "position": { "current": "1", "total": 3 } } @@ -424,7 +424,7 @@ Uses `action: "pop"` (command-name action). **Text:** ```text File: runbooks/deploy.runbook.md -State: .rundown/runs/wf-2026-01-26-abc123.json +State: .rundown/runs/rd_0123456789abcdef0123456789abcdef.json Prompt: Yes Action: PASS @@ -440,7 +440,7 @@ Step description. { "action": "pop", "file": "runbooks/deploy.runbook.md", - "state": ".rundown/runs/wf-2026-01-26-abc123.json", + "state": ".rundown/runs/rd_0123456789abcdef0123456789abcdef.json", "prompted": true, "position": { "current": "2", "total": 3 }, "step": { "name": "2", "description": "Second Step" } diff --git a/docs/spec/language.md b/docs/spec/language.md index c6debcc1c..f8ceb63ee 100644 --- a/docs/spec/language.md +++ b/docs/spec/language.md @@ -59,8 +59,9 @@ Frontmatter is an open YAML object; unknown fields MUST be preserved. | `outputs` | output declaration array | Terminal outputs captured at run completion. | Input and required names MUST match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. `step`, -`index`, and `context` are reserved case-insensitively and MUST NOT appear in -`inputs` or `required`. Missing required variables are resolution errors. +`index`, `context`, `runid`, and `runbookref` are reserved case-insensitively +and MUST NOT appear in `inputs` or `required`. Missing required variables are +resolution errors. ### 3.2 Heading Hierarchy @@ -337,11 +338,11 @@ expand against the current runtime frame. ### 9.1 Variable Names and Precedence -`step`, `index`, and `context` are reserved case-insensitively and MUST NOT be -overridden by user variables. Reserved names are rejected in frontmatter -`inputs`, frontmatter `required`, explicit invocation inputs, input files, and -configuration files. Reserved `RD_INPUT_*` environment variables are skipped -with a warning. +`step`, `index`, `context`, `runid`, and `runbookref` are reserved +case-insensitively and MUST NOT be overridden by user variables. Reserved names +are rejected in frontmatter `inputs`, frontmatter `required`, explicit +invocation inputs, input files, and configuration files. Reserved `RD_INPUT_*` +environment variables are skipped with a warning. Precedence, highest first: @@ -368,8 +369,9 @@ dynamic current-frame values but remain reserved for user input. | --- | --- | | `Date`, `DateTime`, `Year`, `Month`, `Day` | Current date/time components. | | `Branch` | Current git branch, or empty outside git. | -| `WorkPath` | Branch-isolated artifact directory; fallback `.rundown/work`; base for `{{ path "..." }}`. | -| `RunId` | Fresh execution identifier for this runbook execution. | +| `WorkPath` | Fixed default artifact base `.rundown/work`; base for `{{ path "..." }}`. | +| `RunbookRef` | Canonical `{ source, path }` identity for the resolved runbook. Injected before template substitution. | +| `RunId` | Fresh execution identifier for this runbook execution. Injected only when the runbook is started or claimed, not during variable discovery or `rd resolve`. | | `ContextId` | Shared identity across a delegation tree; scopes `{{ path "..." }}` into `.rd-/`. | | `Step`, `Index` | Dynamic current step and iteration. | | `context.current.*` | Dynamic current `step`, `substep`, `index`, and `at`. | @@ -382,11 +384,19 @@ Static built-ins MAY be overridden by higher-precedence sources. Dynamic variables MUST NOT be overridden. Parent context chain addressing is capped at 32 levels. Plugin runbooks MAY receive upper-snake-case plugin variables; `CLAUDE_PLUGIN_ROOT` identifies the plugin installation directory. +Delegated children inherit the parent's `ContextId` and user variables, but MUST +NOT inherit the parent's `RunId` or `RunbookRef`. + +The default `WorkPath` is project-shared and MUST NOT be derived from the git +branch, checkout path, or run id. Isolation for `{{ path "..." }}` is provided by +`ContextId` (`.rd-/`), with run-scoped artifact locations adding the +current `RunId` below that context when needed. ### 9.3 Shell Environment -Executable shell blocks receive `RD_WORK_PATH`, `RD_CONTEXT_ID`, and `RD_RUN_ID` -from `WorkPath`, `ContextId`, and `RunId`. These variables are injected after +Executable shell blocks receive `RD_WORK_PATH`, `RD_CONTEXT_ID`, `RD_RUN_ID`, +`RD_RUNBOOK_REF`, and `RD_RUNBOOK_SOURCE` from `WorkPath`, `ContextId`, `RunId`, +`RunbookRef.path`, and `RunbookRef.source`. These variables are injected after policy environment filtering and use Rundown-wins semantics. The `RD_` prefix is reserved for Rundown-injected variables. diff --git a/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts b/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts index c5ae1f16b..3d084c9dd 100644 --- a/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts +++ b/packages/claude-code-plugin/__tests__/rdpath-find-integration.test.ts @@ -84,7 +84,7 @@ Active step. steps, }; - const state = await manager.create('active.runbook.md', runbook, { + const state = await manager.create({ source: 'project', path: 'active.runbook.md' }, runbook, { runbookPath: 'active.runbook.md', prompted: true, templateVars: vars, @@ -93,14 +93,15 @@ Active step. } async function setupStaleActiveRunbook(cwd: string): Promise { + const runId = 'rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const runsDir = path.join(cwd, '.rundown', 'runs'); await fs.mkdir(runsDir, { recursive: true }); await fs.writeFile( path.join(cwd, '.rundown', 'session.json'), - JSON.stringify({ defaultStack: ['wf-stale-1'] }, null, 2), + JSON.stringify({ defaultStack: [runId] }, null, 2), ); await fs.writeFile( - path.join(runsDir, 'wf-stale-1.json'), + path.join(runsDir, `${runId}.json`), JSON.stringify({ schemaVersion: 1 }, null, 2), ); } diff --git a/packages/claude-code-plugin/src/rdpath.ts b/packages/claude-code-plugin/src/rdpath.ts index 922241ca0..f1cb280e9 100644 --- a/packages/claude-code-plugin/src/rdpath.ts +++ b/packages/claude-code-plugin/src/rdpath.ts @@ -70,7 +70,8 @@ function isRecoverableActiveStateLookupError(error: unknown): boolean { message.includes('schema validation failed') || message.includes('previous schema version') || message.includes('Legacy per-agent session format detected') || - message.includes('Session file contains invalid entries') + message.includes('Legacy session ownership format detected') || + message.includes('Session file contains invalid') ) { return true; } diff --git a/packages/cli/__tests__/commands/claim.test.ts b/packages/cli/__tests__/commands/claim.test.ts index 6ed6f522b..d33db24dc 100644 --- a/packages/cli/__tests__/commands/claim.test.ts +++ b/packages/cli/__tests__/commands/claim.test.ts @@ -12,7 +12,7 @@ import { getCliPath, type TestWorkspace, } from '../helpers/test-utils.js'; -import { writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { join } from 'node:path'; import { ErrorResponseSchema } from '@rundown-org/core'; @@ -466,7 +466,47 @@ Execute with {{Env}} environment. expect(childTemplateVars.ContextId).toBe('ctx-parent'); expect(typeof parentTemplateVars.RunId).toBe('string'); expect(typeof childTemplateVars.RunId).toBe('string'); + expect(childTemplateVars.RunId).toBe(childState?.id); expect(childTemplateVars.RunId).not.toBe(parentTemplateVars.RunId); + expect(childTemplateVars.RunbookRef).toEqual(childState?.runbook); + expect(childTemplateVars.RunbookRef).not.toEqual(parentTemplateVars.RunbookRef); + }); + + it('claims nested plugin child runbooks with source-root-relative RunbookRef', async () => { + const childRel = 'planning/review/review-plan-risk-safety.runbook.md'; + await mkdir(join(workspace.pluginRunbooksDir(), 'planning', 'review'), { recursive: true }); + await writeFile( + join(workspace.pluginRunbooksDir(), childRel), + createRunbook({ + title: 'Risk Safety', + steps: [{ title: 'Child', pass: 'COMPLETE', content: 'Run child.' }], + }), + ); + await writeFile( + join(workspace.cwd, 'parent.runbook.md'), + createRunbook({ + title: 'Parent', + steps: [{ title: 'Delegate', substeps: [{ title: 'Review', content: 'Review.' }] }], + }), + ); + + let result = await runCliInProcess('run --prompted parent.runbook.md --text', workspace); + expect(result.exitCode).toBe(0); + result = await runCliInProcess(`delegate ${childRel} --step 1.1`, workspace); + expect(result.exitCode).toBe(0); + const token = extractToken(result.stdout); + result = await runCliInProcess(`claim ${token}`, workspace); + expect(result.exitCode).toBe(0); + const action = findActionOutput(result.stdout); + const childState = await readRunbookState(workspace, String(action?.run_id)); + + expect(childState).not.toBeNull(); + expect(childState!.runbook).toEqual({ + source: 'plugin', + path: childRel, + }); + expect(childState!.templateVars?.RunbookRef).toEqual(childState!.runbook); + expect(childState!.templateVars?.RunId).toBe(childState!.id); }); }); diff --git a/packages/cli/__tests__/commands/delegate.test.ts b/packages/cli/__tests__/commands/delegate.test.ts index 4715a17d6..39dead9a4 100644 --- a/packages/cli/__tests__/commands/delegate.test.ts +++ b/packages/cli/__tests__/commands/delegate.test.ts @@ -162,9 +162,18 @@ describe('delegate command', () => { const substepStates = state?.substepStates as Array> | undefined; const ss1 = substepStates?.find((ss) => ss.id === '1'); const delegation = ss1?.delegation as Record; - expect(delegation.childRunId).toEqual(expect.stringMatching(/^wf-/)); + expect(delegation.childRunId).toEqual(expect.stringMatching(/^rd_[a-f0-9]{32}$/)); expect(delegation.tokenHash).toEqual(expect.stringMatching(/^sha256:[a-f0-9]{64}$/)); expect(delegation.token).toBeUndefined(); + // Persisted childRunbookRef must be a structured RunbookRef object, not + // just a path string. A regression to path-only persistence would break + // source-aware claim resolution for plugin/bundled/external children. + // Path is source-root-relative: setupDelegation writes the child to + // ${cwd}/runbooks/child.runbook.md and project sourceRoot === cwd. + expect(delegation.childRunbookRef).toEqual({ + source: 'project', + path: 'runbooks/child.runbook.md', + }); const statusResult = await runCliInProcess('status', workspace); expect(statusResult.exitCode).toBe(0); @@ -371,7 +380,7 @@ describe('delegate command', () => { ...state, parentLinkage: { kind: 'delegation', - parentRunId: 'parent-run-id', + parentRunId: `rd_${'9'.repeat(32)}`, parentStepId: '1', tokenHash: `sha256:${'a'.repeat(64)}`, }, diff --git a/packages/cli/__tests__/commands/fail.test.ts b/packages/cli/__tests__/commands/fail.test.ts index 9d3aba508..9244e3d42 100644 --- a/packages/cli/__tests__/commands/fail.test.ts +++ b/packages/cli/__tests__/commands/fail.test.ts @@ -68,7 +68,7 @@ describe('fail command', () => { // After blocking, the runbook is saved but no longer active // Retrieve from all states const states = await getAllStates(workspace); - const state = states.find((s) => s.runbook === 'runbooks/simple.runbook.md'); + const state = states.find((s) => s.runbook.path === 'runbooks/simple.runbook.md'); expect(state?.lifecycle).toBe('stopped'); }); }); @@ -416,7 +416,7 @@ Final step. expect(result.exitCode).toBe(1); const states = await getAllStates(workspace); - const state = states.find((s) => s.runbook === 'runbooks/substep-fail-any.md'); + const state = states.find((s) => s.runbook.path === 'runbooks/substep-fail-any.md'); expect(state?.lifecycle).toBe('stopped'); }); diff --git a/packages/cli/__tests__/commands/ls.test.ts b/packages/cli/__tests__/commands/ls.test.ts index b8185f730..c5d4346c6 100644 --- a/packages/cli/__tests__/commands/ls.test.ts +++ b/packages/cli/__tests__/commands/ls.test.ts @@ -1,6 +1,8 @@ // packages/cli/__tests__/commands/ls.test.ts import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { createTestWorkspace, runCliInProcess } from '../helpers/test-utils.js'; describe('rd ls', () => { @@ -34,4 +36,37 @@ describe('rd ls', () => { expect(result.stdout).toContain('DESCRIPTION'); expect(result.stdout).toContain('simple'); }); + + it('uses persisted source identity when counting active runbook steps', async () => { + await writeFile( + join(workspace.runbooksDir(), 'shadow.runbook.md'), + '# Project Shadow\n\n## 1. Project Only\n\nProject step.\n', + ); + await writeFile( + join(workspace.pluginRunbooksDir(), 'shadow.runbook.md'), + [ + '# Plugin Shadow', + '', + '## 1. Plugin First', + '', + 'First.', + '', + '## 2. Plugin Second', + '', + 'Second.', + '', + '## 3. Plugin Third', + '', + 'Third.', + '', + ].join('\n'), + ); + + await runCliInProcess('run --prompted rundown:shadow --text', workspace); + + const result = await runCliInProcess('ls --text', workspace); + + expect(result.stdout).toContain('1/3'); + expect(result.stdout).not.toContain('1/1'); + }); }); diff --git a/packages/cli/__tests__/commands/output-format.test.ts b/packages/cli/__tests__/commands/output-format.test.ts index fb60abfe5..ca26b3b8c 100644 --- a/packages/cli/__tests__/commands/output-format.test.ts +++ b/packages/cli/__tests__/commands/output-format.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { RUN_ID_PATTERN } from '@rundown-org/core'; import { createTestWorkspace, runCliInProcess, type TestWorkspace } from '../helpers/test-utils.js'; +function expectRunId(text: string): void { + const unanchoredRunIdSource = RUN_ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, ''); + expect(text).toMatch(new RegExp(`\\b${unanchoredRunIdSource}\\b`)); +} + describe('output format integration tests', () => { let workspace: TestWorkspace; @@ -34,7 +40,7 @@ describe('output format integration tests', () => { workspace, ); - expect(result.stdout).toMatch(/wf-\d{4}-\d{2}-\d{2}/); + expectRunId(result.stdout); }); it('shows first step details in action block', async () => { @@ -164,7 +170,7 @@ describe('output format integration tests', () => { const result = await runCliInProcess('status --text', workspace); expect(result.stdout).toContain('State:'); - expect(result.stdout).toMatch(/wf-\d{4}-\d{2}-\d{2}/); + expectRunId(result.stdout); }); it('shows current step block', async () => { @@ -191,7 +197,7 @@ describe('output format integration tests', () => { it('includes runbook ID in output', async () => { const result = await runCliInProcess('stop --text', workspace); - expect(result.stdout).toMatch(/wf-\d{4}-\d{2}-\d{2}/); + expectRunId(result.stdout); }); it('shows confirmation message', async () => { diff --git a/packages/cli/__tests__/commands/pass.test.ts b/packages/cli/__tests__/commands/pass.test.ts index 86df38aba..19347c734 100644 --- a/packages/cli/__tests__/commands/pass.test.ts +++ b/packages/cli/__tests__/commands/pass.test.ts @@ -62,7 +62,7 @@ describe('pass command', () => { await runCliInProcess('pass --text', workspace); const states = await getAllStates(workspace); - const state = states.find((s) => s.runbook === 'runbooks/simple.runbook.md'); + const state = states.find((s) => s.runbook.path === 'runbooks/simple.runbook.md'); expect(state?.lifecycle).toBe('completed'); }); }); @@ -148,7 +148,7 @@ This step stops on pass. await runCliInProcess('pass --text', workspace); const states = await getAllStates(workspace); - const state = states.find((s) => s.runbook === 'runbooks/stop-on-pass.md'); + const state = states.find((s) => s.runbook.path === 'runbooks/stop-on-pass.md'); expect(state?.lifecycle).toBe('stopped'); }); }); @@ -292,7 +292,10 @@ Do child work. const claimId2 = child2Output.claim_id; const anonymousActive = await getActiveState(workspace); - expect(anonymousActive?.runbook).toBe('runbooks/parent.runbook.md'); + expect(anonymousActive?.runbook).toEqual({ + source: 'project', + path: 'runbooks/parent.runbook.md', + }); let status = await runCliInProcess(['status', '--claim-id', claimId1], workspace); expect(JSON.parse(status.stdout).state).toContain(child1Id); @@ -424,7 +427,7 @@ This step stops on pass. await runCliInProcess('pass --text', workspace); const states = await getAllStates(workspace); - const state = states.find((s) => s.runbook === 'runbooks/stop-on-pass.md'); + const state = states.find((s) => s.runbook.path === 'runbooks/stop-on-pass.md'); // lastResult should reflect user's choice (pass), not transition outcome expect(state?.lastResult).toBe('pass'); diff --git a/packages/cli/__tests__/commands/resolve.test.ts b/packages/cli/__tests__/commands/resolve.test.ts index 898928fd7..eb69d8a4c 100644 --- a/packages/cli/__tests__/commands/resolve.test.ts +++ b/packages/cli/__tests__/commands/resolve.test.ts @@ -52,6 +52,26 @@ echo hello expect(output.variables).toHaveProperty('WorkPath'); }); + it('shows RunbookRef without minting RunId', async () => { + const runbookPath = path.join(workspace.cwd, 'parent.runbook.md'); + fs.writeFileSync( + runbookPath, + `## Step 1 +echo hello +`, + ); + + const result = await runCliInProcess('resolve parent.runbook.md', workspace); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.variables.RunbookRef).toEqual({ + source: 'project', + path: 'parent.runbook.md', + }); + expect(payload.variables).not.toHaveProperty('RunId'); + }); + it('resolves declared inputs from CLI — shows resolved variables', async () => { const runbookPath = path.join(workspace.cwd, 'vars.runbook.md'); fs.writeFileSync( diff --git a/packages/cli/__tests__/commands/run-prompted.test.ts b/packages/cli/__tests__/commands/run-prompted.test.ts index f9fea7abd..dacb1112d 100644 --- a/packages/cli/__tests__/commands/run-prompted.test.ts +++ b/packages/cli/__tests__/commands/run-prompted.test.ts @@ -90,7 +90,7 @@ Second step. expect(result.exitCode).toBe(0); const state = await getActiveState(workspace); - expect(state?.runbook).toBe('goto-start.runbook.md'); + expect(state?.runbook).toEqual({ source: 'project', path: 'goto-start.runbook.md' }); expect(state?.step).toBe('2'); }); @@ -129,7 +129,7 @@ Second step. // Child should inherit prompted flag from parent const state = await readRunbookState(workspace, String(childRunId)); expect(state?.prompted).toBe(true); - expect(state?.runbook).toContain('with-commands'); + expect(state?.runbook.path).toContain('with-commands'); }); }); diff --git a/packages/cli/__tests__/commands/run.test.ts b/packages/cli/__tests__/commands/run.test.ts index fdb609015..431edc661 100644 --- a/packages/cli/__tests__/commands/run.test.ts +++ b/packages/cli/__tests__/commands/run.test.ts @@ -49,7 +49,10 @@ describe('start command', () => { const state = await getActiveState(workspace); expect(state).not.toBeNull(); - expect(state?.runbook).toBe('runbooks/simple.runbook.md'); + expect(state?.runbook).toEqual({ + source: 'project', + path: 'runbooks/simple.runbook.md', + }); }); it('initializes step=1 and retryCount=0', async () => { @@ -113,10 +116,11 @@ Plugin task. expect(result.exitCode).toBe(0); const state = await getActiveState(workspace); - expect(state?.runbookRef).toEqual({ + expect(state?.runbook).toEqual({ source: 'plugin', path: 'planning/review/plugin-child.runbook.md', }); + expect(Object.hasOwn(state ?? {}, 'runbookRef')).toBe(false); }); it('stores bundled runbook refs relative to the bundled runbooks root for absolute paths', async () => { @@ -145,10 +149,11 @@ Bundled task. expect(result.exitCode).toBe(0); const state = await getActiveState(workspace); - expect(state?.runbookRef).toEqual({ + expect(state?.runbook).toEqual({ source: 'bundled', path: 'delegation/bundled-child.runbook.md', }); + expect(Object.hasOwn(state ?? {}, 'runbookRef')).toBe(false); }); it('fails if file does not exist', async () => { diff --git a/packages/cli/__tests__/commands/stash-pop.test.ts b/packages/cli/__tests__/commands/stash-pop.test.ts index b074c6e40..4e3eefa57 100644 --- a/packages/cli/__tests__/commands/stash-pop.test.ts +++ b/packages/cli/__tests__/commands/stash-pop.test.ts @@ -69,7 +69,7 @@ describe('stash command', () => { const afterState = await getActiveState(workspace); expect(afterState?.step).toBe(beforeState?.step); - expect(afterState?.runbook).toBe(beforeState?.runbook); + expect(afterState?.runbook).toEqual(beforeState?.runbook); }); it('returns non-zero when another runbook is already stashed', async () => { @@ -450,7 +450,7 @@ describe('pop command', () => { it('outputs error when step not found in runbook', async () => { // Create a state file with a step that doesn't exist in the runbook // runbookSrc must be present for pop to read from stored content - const runbookId = 'wf-2025-01-28-test01'; + const runbookId = `rd_${'3'.repeat(32)}`; const stateFile = join(workspace.statePath(), `${runbookId}.json`); const runbookSrc = `# Test Runbook @@ -463,7 +463,7 @@ rd echo "hello" `; const state = { id: runbookId, - runbook: 'runbooks/simple.runbook.md', + runbook: { source: 'project', path: 'runbooks/simple.runbook.md' }, runbookPath: join(workspace.cwd, 'runbooks', 'simple.runbook.md'), title: 'Test Runbook', step: 'NonExistentStep', // Step that doesn't exist in runbookSrc @@ -490,14 +490,14 @@ rd echo "hello" }); it('restores a claimed stash using captured claim provenance', async () => { - const runbookId = 'wf-2025-01-28-owned01'; - const parentRunId = 'wf-2025-01-28-parent01'; + const runbookId = `rd_${'4'.repeat(32)}`; + const parentRunId = `rd_${'5'.repeat(32)}`; await writeFile( join(workspace.statePath(), `${parentRunId}.json`), JSON.stringify( { id: parentRunId, - runbook: 'parent.runbook.md', + runbook: { source: 'project', path: 'parent.runbook.md' }, runbookPath: 'parent.runbook.md', title: 'Parent Runbook', step: '1', @@ -530,7 +530,7 @@ rd echo "hello" JSON.stringify( { id: runbookId, - runbook: 'owned.runbook.md', + runbook: { source: 'project', path: 'owned.runbook.md' }, runbookPath: 'owned.runbook.md', title: 'Test Runbook', step: '1', diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 7e199d888..411d06384 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -57,7 +57,7 @@ describe('status command', () => { const result = await runCliInProcess('status --text', workspace); expect(result.stdout).toContain('State:'); - expect(result.stdout).toMatch(/wf-\d{4}-\d{2}-\d{2}/); + expect(result.stdout).toMatch(/rd_[a-f0-9]{32}/); }); it('outputs "No active runbook" when none', async () => { diff --git a/packages/cli/__tests__/helpers/brand-helpers.ts b/packages/cli/__tests__/helpers/brand-helpers.ts index 4b596be06..db05bdf11 100644 --- a/packages/cli/__tests__/helpers/brand-helpers.ts +++ b/packages/cli/__tests__/helpers/brand-helpers.ts @@ -10,12 +10,14 @@ import { brandEffectiveVars, brandInitialTemplateVars, brandStoredOutputs, + assertRunId, assertDelegationTokenHash, buildFrameKey, type DelegationTokenHash, type EffectiveVars, type FrameKey, type InitialTemplateVars, + type RunId, type StoredOutputs, type TemplateVarValue, } from '@rundown-org/core'; @@ -49,6 +51,20 @@ export function brandDelegationTokenHashForTest(hash: string): DelegationTokenHa return assertDelegationTokenHash(hash); } +/** + * Test-only producer of {@link RunId}. + * + * Delegates to the production assertion helper so test fixtures use the + * same canonical run-id validation as production code. + * + * @param runId - Candidate persisted run id + * @returns Branded `RunId` + * @throws {Error} If `runId` is not a canonical `rd_<32 lowercase hex>` value + */ +export function brandRunIdForTest(runId: string): RunId { + return assertRunId(runId); +} + /** * Test-only producer of {@link EffectiveVars} for fixture construction. * diff --git a/packages/cli/__tests__/helpers/claim-and-launch.test.ts b/packages/cli/__tests__/helpers/claim-and-launch.test.ts index a993e3e64..0a606aa34 100644 --- a/packages/cli/__tests__/helpers/claim-and-launch.test.ts +++ b/packages/cli/__tests__/helpers/claim-and-launch.test.ts @@ -1,9 +1,24 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import type { RunbookRef, RunbookState, StepDelegation, TokenScanResult } from '@rundown-org/core'; +import type { + ClaimId, + ClaimRecord, + ClaimRunbookResult, + DelegationLinkage, + RunbookRef, + RunId, + RunbookState, + SessionService, + StepDelegation, + TokenScanResult, +} from '@rundown-org/core'; import type { RunPipelineContext } from '../../src/helpers/runbook-pipeline.js'; import type * as VariableDiscoveryModule from '../../src/services/variable-discovery.js'; import { assertVariant } from './assert-variant.js'; -import { brandDelegationTokenHashForTest, brandFrameKeyForTest } from './brand-helpers.js'; +import { + brandDelegationTokenHashForTest, + brandFrameKeyForTest, + brandRunIdForTest, +} from './brand-helpers.js'; import { mockErrorHelpers } from './mock-error-helpers.js'; import { mockFn } from './typed-mocks.js'; @@ -15,6 +30,48 @@ const { isJsonArrayStream: realIsJsonArrayStream } = await import('@rundown-org/ const MOCK_TOKEN_HASH = brandDelegationTokenHashForTest(`sha256:${'a'.repeat(64)}`); const DIFFERENT_TOKEN_HASH = brandDelegationTokenHashForTest(`sha256:${'b'.repeat(64)}`); const ORIGINAL_TOKEN_HASH = brandDelegationTokenHashForTest(`sha256:${'c'.repeat(64)}`); +const TEST_CLAIM_ID = 'rdclm_abcdefghijklmnopqrstu1' as ClaimId; +const RUN_ID = brandRunIdForTest('rd_11111111111111111111111111111111'); +const DIFFERENT_RUN_ID = brandRunIdForTest('rd_22222222222222222222222222222222'); +const EXISTING_CHILD_RUN_ID = brandRunIdForTest('rd_33333333333333333333333333333333'); +const ORPHAN_RUN_ID = brandRunIdForTest('rd_44444444444444444444444444444444'); +const EXISTING_SESSION_CHILD_ID = brandRunIdForTest('rd_55555555555555555555555555555555'); +const NEW_CHILD_ID = brandRunIdForTest('rd_66666666666666666666666666666666'); + +function claimRecord(childRunId: RunId, overrides: Partial = {}): ClaimRecord { + return { + kind: 'claim-record', + claimId: TEST_CLAIM_ID, + childRunId, + tokenHash: MOCK_TOKEN_HASH, + parentRunId: RUN_ID, + parentStepId: '1', + claimedAt: '2026-02-27T10:00:00.000Z', + updatedAt: '2026-02-27T10:00:00.000Z', + ...overrides, + }; +} + +function claimedRunbookResult( + childRunId: RunId, + overrides: Partial = {}, +): ClaimRunbookResult { + return { status: 'claimed', claim: claimRecord(childRunId, overrides) }; +} + +function mockClaimRunbookSuccess(): jest.Mock { + return mockFn().mockImplementation( + async (childRunId: RunId, linkage: DelegationLinkage) => + claimedRunbookResult(childRunId, { + tokenHash: linkage.tokenHash, + parentRunId: linkage.parentRunId, + parentStepId: linkage.parentStepId, + parentStep: linkage.parentStep, + parentFrameKey: linkage.parentFrameKey, + parentEntry: linkage.parentEntry, + }), + ); +} // Mock @rundown-org/core jest.unstable_mockModule('@rundown-org/core', () => ({ @@ -39,6 +96,7 @@ jest.unstable_mockModule('@rundown-org/core', () => ({ RunbookRefSchema: { parse: jest.fn((ref: unknown) => ref), }, + generateRunId: jest.fn(() => `rd_${'a'.repeat(32)}`), DELEGATION_TOKEN_PREFIX: 'rdtk_', DEFAULT_POLICY: { version: 1, @@ -99,6 +157,8 @@ jest.unstable_mockModule('@rundown-org/core', () => ({ ...mockErrorHelpers, })); +const actualResolveRunbook = await import('../../src/helpers/resolve-runbook.js'); + // Mock @rundown-org/parser jest.unstable_mockModule('@rundown-org/parser', () => ({ parseRunbookDocument: jest.fn(), @@ -109,9 +169,23 @@ jest.unstable_mockModule('@rundown-org/parser', () => ({ step.kind === 'substeps' || step.kind === 'for' || step.kind === 'prompted-for', })); -// Mock resolve-runbook +// Mock resolve-runbook discovery while delegating runbook-ref derivation to +// the production implementation by default. Individual tests can still +// override buildRunbookRef for error/mismatch cases. jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => ({ + ...actualResolveRunbook, resolveRunbookFile: jest.fn(), + resolveRunbookRef: jest.fn((_cwd: string, ref: RunbookRef) => + Promise.resolve({ + ok: true, + resolved: { + path: `/tmp/test/${ref.path}`, + source: ref.source, + sourceRoot: '/tmp/test', + }, + }), + ), + buildRunbookRef: jest.fn(actualResolveRunbook.buildRunbookRef), })); // Mock execution service @@ -149,6 +223,20 @@ jest.unstable_mockModule('../../src/services/variable-discovery', () => ({ providedKeys: new Set(), }), RUNTIME_RESERVED_VARIABLES: new Set(['Date', 'DateTime', 'Year', 'Month', 'Day', 'WorkPath']), + BUILTIN_VARIABLES: { + Date: 'Date', + DateTime: 'DateTime', + Year: 'Year', + Month: 'Month', + Day: 'Day', + Branch: 'Branch', + WorkPath: 'WorkPath', + ContextId: 'ContextId', + Step: 'Step', + Index: 'Index', + RunbookRef: 'RunbookRef', + RunId: 'RunId', + }, })); // Mock template-renderer @@ -178,7 +266,9 @@ jest.unstable_mockModule('node:fs/promises', () => ({ // Import after mocking const core = await import('@rundown-org/core'); const parser = await import('@rundown-org/parser'); -const { resolveRunbookFile } = await import('../../src/helpers/resolve-runbook.js'); +const { resolveRunbookFile, resolveRunbookRef, buildRunbookRef } = await import( + '../../src/helpers/resolve-runbook.js' +); const { resolveVariables } = await import('../../src/services/variable-discovery.js'); const { substituteRunbookVariables, resolveForBounds, collectUnresolvedRunbookVariables } = await import('../../src/services/template-renderer.js'); @@ -213,11 +303,9 @@ function makeCtx(overrides: Record = {}): RunPipelineContext { }, actorService: {}, sessionService: { - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), + findClaimForDelegation: + mockFn().mockResolvedValue(null), }, lifecycleService: {}, cwd: '/tmp/test', @@ -280,7 +368,7 @@ function mockScanService(result: FindByTokenResult, orphan?: FindOrphanedChildRe * * `release` accepts an optional `runId` argument in production (the lock * scopes per-parent-run) — the mock signature mirrors that so - * `toHaveBeenCalledWith('run-1')` type-checks. + * `toHaveBeenCalledWith(RUN_ID)` type-checks. */ function mockDelegationLock( acquire: jest.Mock<(...args: unknown[]) => Promise>, @@ -337,6 +425,17 @@ beforeEach(() => { iteration: undefined, frameKey: brandFrameKeyForTest('1'), }); + jest.mocked(resolveRunbookRef).mockImplementation((_cwd: string, ref: RunbookRef) => + Promise.resolve({ + ok: true, + resolved: { + path: `/tmp/test/${ref.path}`, + source: ref.source, + sourceRoot: '/tmp/test', + }, + }), + ); + jest.mocked(buildRunbookRef).mockImplementation(actualResolveRunbook.buildRunbookRef); jest.mocked(validateOutputsDeclarations).mockReturnValue([]); }); @@ -374,7 +473,7 @@ describe('claimAndLaunch', () => { // Mock scan returning a result mockScanService( scanResult({ - parentState: { id: 'run-1', substepStates: [] }, + parentState: { id: RUN_ID, substepStates: [] }, stepId: '1', substepId: '1', delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md' }, @@ -392,7 +491,7 @@ describe('claimAndLaunch', () => { core as unknown as { DelegationLockTimeoutError: new (id: string, lock: string) => Error; } - ).DelegationLockTimeoutError('run-1', '/tmp/test.lock'), + ).DelegationLockTimeoutError(RUN_ID, '/tmp/test.lock'), ); const mockRelease = mockFn<(...args: unknown[]) => Promise>(); mockRelease.mockResolvedValue(undefined); @@ -404,7 +503,7 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'lock-timeout'); - expect(result.parentRunId).toBe('run-1'); + expect(result.parentRunId).toBe(RUN_ID); } }); @@ -412,7 +511,7 @@ describe('claimAndLaunch', () => { const ctx = makeCtx(); const parentState = { - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -421,6 +520,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: '2026-02-28T00:00:00.000Z', contextSnapshot: { vars: {}, ancestors: [] }, @@ -462,7 +562,7 @@ describe('claimAndLaunch', () => { const ctx = makeCtx(); const parentState = { - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -471,7 +571,8 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', - childRunId: 'existing-child-run', + childRunbookRef: { source: 'project', path: 'child.md' }, + childRunId: EXISTING_CHILD_RUN_ID, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, createdAt: '2026-02-27T10:00:00.000Z', @@ -501,8 +602,8 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.childRunId).toBe('existing-child-run'); - expect(result.parentRunId).toBe('run-1'); + expect(result.childRunId).toBe(EXISTING_CHILD_RUN_ID); + expect(result.parentRunId).toBe(RUN_ID); } }); @@ -510,7 +611,7 @@ describe('claimAndLaunch', () => { const ctx = makeCtx(); const parentState = { - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -519,6 +620,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -529,8 +631,8 @@ describe('claimAndLaunch', () => { }; const orphanState = { - id: 'orphan-run-id', - delegation: { parentRunId: 'run-1', parentStepId: '1', tokenHash: MOCK_TOKEN_HASH }, + id: ORPHAN_RUN_ID, + delegation: { parentRunId: RUN_ID, parentStepId: '1', tokenHash: MOCK_TOKEN_HASH }, }; // Mock scan — findByToken returns parent, findOrphanedChild returns orphan @@ -555,28 +657,205 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.childRunId).toBe('orphan-run-id'); - expect(result.parentRunId).toBe('run-1'); + expect(result.childRunId).toBe(ORPHAN_RUN_ID); + expect(result.parentRunId).toBe(RUN_ID); } // Verify update wrote the orphan's childRunId onto the parent delegation const { update: updateMock } = ctx.manager as unknown as { update: jest.Mock }; expect(updateMock).toHaveBeenCalledWith( - 'run-1', + RUN_ID, expect.objectContaining({ substepStates: expect.arrayContaining([ expect.objectContaining({ id: '1', - delegation: expect.objectContaining({ childRunId: 'orphan-run-id' }), + delegation: expect.objectContaining({ childRunId: ORPHAN_RUN_ID }), }), ]), }), ); }); + it('returns existing session claim when delegation has no linked child', async () => { + const parentState = { + id: RUN_ID, + variables: {}, + substepStates: [ + { + id: '1', + status: 'pending', + frameKey: brandFrameKeyForTest('1'), + delegation: { + tokenHash: MOCK_TOKEN_HASH, + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + childRunId: null, + cancelledAt: null, + contextSnapshot: { vars: {}, ancestors: [] }, + createdAt: '2026-02-27T10:00:00.000Z', + }, + }, + ], + }; + const existingChildState = { id: EXISTING_SESSION_CHILD_ID, lifecycle: 'running' }; + const findClaimForDelegation = mockFn< + SessionService['findClaimForDelegation'] + >().mockResolvedValue( + claimRecord(EXISTING_SESSION_CHILD_ID, { + parentRunId: RUN_ID, + parentStepId: '1', + }), + ); + const claimRunbook = mockClaimRunbookSuccess(); + const update = mockFn<() => Promise>().mockResolvedValue(undefined); + const ctx = makeCtx({ + manager: { + load: mockFn<(id: string) => Promise>().mockImplementation(async (id) => + id === EXISTING_SESSION_CHILD_ID ? existingChildState : parentState, + ), + update, + }, + sessionService: { + claimRunbook, + findClaimForDelegation, + }, + }); + + mockScanService( + scanResult({ + parentState, + stepId: '1', + substepId: '1', + delegation: parentState.substepStates[0].delegation, + frameKey: brandFrameKeyForTest('1'), + }), + null, + ); + mockHappyDelegationLock(); + + // cspell:disable-next-line + const result = await claimAndLaunch(ctx, 'rdtk_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH', {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.childRunId).toBe(EXISTING_SESSION_CHILD_ID); + expect(result.loopResult).toBe('waiting'); + } + expect(findClaimForDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }), + ); + expect(claimRunbook).toHaveBeenCalledWith( + EXISTING_SESSION_CHILD_ID, + expect.objectContaining({ + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }), + ); + expect(update).toHaveBeenCalled(); + }); + + it('surfaces linkage-mismatch when existing session claim child linkage diverges', async () => { + const parentState = { + id: RUN_ID, + variables: {}, + substepStates: [ + { + id: '1', + status: 'pending', + frameKey: brandFrameKeyForTest('1'), + delegation: { + tokenHash: MOCK_TOKEN_HASH, + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + childRunId: null, + cancelledAt: null, + contextSnapshot: { vars: {}, ancestors: [] }, + createdAt: '2026-02-27T10:00:00.000Z', + }, + }, + ], + }; + const existingChildState = { id: EXISTING_SESSION_CHILD_ID, lifecycle: 'running' }; + const findClaimForDelegation = mockFn< + SessionService['findClaimForDelegation'] + >().mockResolvedValue( + claimRecord(EXISTING_SESSION_CHILD_ID, { + parentRunId: RUN_ID, + parentStepId: '1', + }), + ); + const incoming: DelegationLinkage = { + kind: 'delegation', + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }; + const persisted: DelegationLinkage = { + kind: 'delegation', + parentRunId: DIFFERENT_RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }; + const claimRunbook = mockFn().mockResolvedValue({ + status: 'linkage-mismatch', + childRunId: EXISTING_SESSION_CHILD_ID, + incoming, + persisted, + }); + const update = mockFn<() => Promise>().mockResolvedValue(undefined); + const ctx = makeCtx({ + manager: { + load: mockFn<(id: string) => Promise>().mockImplementation(async (id) => + id === EXISTING_SESSION_CHILD_ID ? existingChildState : parentState, + ), + update, + }, + sessionService: { + claimRunbook, + findClaimForDelegation, + }, + }); + + mockScanService( + scanResult({ + parentState, + stepId: '1', + substepId: '1', + delegation: parentState.substepStates[0].delegation, + frameKey: brandFrameKeyForTest('1'), + }), + null, + ); + mockHappyDelegationLock(); + + // cspell:disable-next-line + const result = await claimAndLaunch(ctx, 'rdtk_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH', {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + assertVariant(result, 'reason', 'linkage-mismatch'); + expect(result.childRunId).toBe(EXISTING_SESSION_CHILD_ID); + expect(result.parentRunId).toBe(RUN_ID); + } + expect(claimRunbook).toHaveBeenCalledWith( + EXISTING_SESSION_CHILD_ID, + expect.objectContaining({ + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }), + ); + expect(update).not.toHaveBeenCalled(); + }); + it('surfaces child-missing when claimRunbook reports the child state is gone', async () => { const parentState = { - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -585,7 +864,8 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', - childRunId: 'existing-child-run', + childRunbookRef: { source: 'project', path: 'child.md' }, + childRunId: EXISTING_CHILD_RUN_ID, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, createdAt: '2026-02-27T10:00:00.000Z', @@ -594,9 +874,9 @@ describe('claimAndLaunch', () => { ], }; - const mockClaimRunbook = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ + const mockClaimRunbook = mockFn().mockResolvedValue({ status: 'missing-child', - childRunId: 'existing-child-run', + childRunId: EXISTING_CHILD_RUN_ID, }); const ctx = makeCtx({ @@ -623,15 +903,15 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'child-missing'); - expect(result.childRunId).toBe('existing-child-run'); - expect(result.parentRunId).toBe('run-1'); + expect(result.childRunId).toBe(EXISTING_CHILD_RUN_ID); + expect(result.parentRunId).toBe(RUN_ID); } expect(mockClaimRunbook).toHaveBeenCalled(); }); it('surfaces linkage-mismatch when claimRunbook reports persisted linkage divergence', async () => { const parentState = { - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -640,7 +920,8 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', - childRunId: 'existing-child-run', + childRunbookRef: { source: 'project', path: 'child.md' }, + childRunId: EXISTING_CHILD_RUN_ID, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, createdAt: '2026-02-27T10:00:00.000Z', @@ -649,11 +930,23 @@ describe('claimAndLaunch', () => { ], }; - const mockClaimRunbook = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ + const incoming: DelegationLinkage = { + kind: 'delegation', + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: MOCK_TOKEN_HASH, + }; + const persisted: DelegationLinkage = { + kind: 'delegation', + parentRunId: RUN_ID, + parentStepId: '1', + tokenHash: DIFFERENT_TOKEN_HASH, + }; + const mockClaimRunbook = mockFn().mockResolvedValue({ status: 'linkage-mismatch', - childRunId: 'existing-child-run', - incoming: { kind: 'delegation', tokenHash: MOCK_TOKEN_HASH }, - persisted: { kind: 'delegation', tokenHash: DIFFERENT_TOKEN_HASH }, + childRunId: EXISTING_CHILD_RUN_ID, + incoming, + persisted, }); const ctx = makeCtx({ @@ -680,8 +973,8 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'linkage-mismatch'); - expect(result.childRunId).toBe('existing-child-run'); - expect(result.parentRunId).toBe('run-1'); + expect(result.childRunId).toBe(EXISTING_CHILD_RUN_ID); + expect(result.parentRunId).toBe(RUN_ID); } expect(mockClaimRunbook).toHaveBeenCalled(); }); @@ -692,7 +985,7 @@ describe('claimAndLaunch', () => { // Mock scan returning a result mockScanService( scanResult({ - parentState: { id: 'run-1', substepStates: [] }, + parentState: { id: RUN_ID, substepStates: [] }, stepId: '1', substepId: '1', delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md' }, @@ -725,6 +1018,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -775,6 +1069,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -813,7 +1108,7 @@ describe('claimAndLaunch', () => { const ctx = makeCtx(); const parentState = { - id: 'run-1', + id: RUN_ID, substepStates: [ { id: '1', @@ -821,6 +1116,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -845,7 +1141,7 @@ describe('claimAndLaunch', () => { // Mock manager.load returning state without delegation jest.mocked(ctx.manager).load.mockResolvedValue({ - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [{ id: '1', status: 'pending' }], } as unknown as RunbookState); @@ -856,7 +1152,7 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'delegation-removed'); - expect(result.parentRunId).toBe('run-1'); + expect(result.parentRunId).toBe(RUN_ID); expect(result.stepId).toBe('1'); } }); @@ -865,7 +1161,7 @@ describe('claimAndLaunch', () => { const ctx = makeCtx(); const parentState = { - id: 'run-1', + id: RUN_ID, substepStates: [ { id: '1', @@ -873,6 +1169,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: ORIGINAL_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -897,7 +1194,7 @@ describe('claimAndLaunch', () => { // Mock manager.load returning state with different hash jest.mocked(ctx.manager).load.mockResolvedValue({ - id: 'run-1', + id: RUN_ID, variables: {}, substepStates: [ { @@ -906,6 +1203,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: DIFFERENT_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -924,7 +1222,7 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'delegation-removed'); - expect(result.parentRunId).toBe('run-1'); + expect(result.parentRunId).toBe(RUN_ID); expect(result.stepId).toBe('1'); } }); @@ -964,7 +1262,7 @@ describe('claimAndLaunch', () => { it('uses delegation frameKey for linkage, not parent current frame', async () => { // Parent state: delegation on iteration 3 (frameKey '1|3'), parent now on iteration 5 const parentState = { - id: 'run-1', + id: RUN_ID, step: '1', variables: {}, substepStates: [ @@ -975,6 +1273,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -1010,6 +1309,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/tmp/test/child.md', source: 'project', + sourceRoot: '/tmp/test', }); // Cast through unknown: the parser fixture is a minimal stand-in // (real Runbook type carries many more fields than this test reads). @@ -1044,17 +1344,13 @@ describe('claimAndLaunch', () => { .mockReturnValue({ emit: jest.fn() } as unknown as ReturnType); jest.mocked(runExecutionLoop).mockResolvedValue('waiting'); - const mockCreate = mockFn<(...args: unknown[]) => Promise<{ id: string; title: string }>>(); + const mockCreate = mockFn<(...args: unknown[]) => Promise<{ id: RunId; title: string }>>(); mockCreate.mockResolvedValue({ - id: 'new-child-id', + id: NEW_CHILD_ID, title: 'Child', }); - const mockClaimRunbook = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }); + const mockClaimRunbook = mockClaimRunbookSuccess(); const ctx = makeCtx({ manager: { @@ -1072,6 +1368,8 @@ describe('claimAndLaunch', () => { sessionService: { pushRunbook: mockFn<() => Promise>().mockResolvedValue(undefined), claimRunbook: mockClaimRunbook, + findClaimForDelegation: + mockFn().mockResolvedValue(null), }, lifecycleService: { ensureActiveEntry: mockFn< @@ -1096,7 +1394,7 @@ describe('claimAndLaunch', () => { // Critical assertion: delegation linkage should use the delegation's stored // frameKey ('1|3'), NOT the parent's current frame ('1|5') expect(mockCreate).toHaveBeenCalledWith( - 'child.md', + { source: 'project', path: 'child.md' }, expect.anything(), expect.objectContaining({ parentLinkage: expect.objectContaining({ @@ -1111,10 +1409,10 @@ describe('claimAndLaunch', () => { if (result.ok) { // cspell:disable-next-line expect(result.claimId).toBe('rdclm_abcdefghijklmnopqrstu1'); - expect(result.childRunId).toBe('new-child-id'); + expect(result.childRunId).toBe(NEW_CHILD_ID); } expect(mockClaimRunbook).toHaveBeenCalledWith( - 'new-child-id', + NEW_CHILD_ID, expect.objectContaining({ kind: 'delegation', parentFrameKey: '1|3', @@ -1124,7 +1422,7 @@ describe('claimAndLaunch', () => { it('returns LAUNCH_FAILED (RD-816) when manager.create throws and releases the lock', async () => { const parentState = { - id: 'run-1', + id: RUN_ID, step: '1', variables: {}, substepStates: [ @@ -1135,6 +1433,7 @@ describe('claimAndLaunch', () => { delegation: { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, childRunId: null, cancelledAt: null, contextSnapshot: { vars: {}, ancestors: [] }, @@ -1160,6 +1459,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/tmp/test/child.md', source: 'project', + sourceRoot: '/tmp/test', }); // Cast through unknown: minimal parser fixture (see frameKey linkage test). jest.mocked(parser.parseRunbookDocument).mockReturnValue({ @@ -1200,6 +1500,8 @@ describe('claimAndLaunch', () => { }, sessionService: { pushRunbook: mockFn<() => Promise>().mockResolvedValue(undefined), + findClaimForDelegation: + mockFn().mockResolvedValue(null), }, lifecycleService: { ensureActiveEntry: mockFn< @@ -1226,6 +1528,6 @@ describe('claimAndLaunch', () => { expect(result.cause).toContain('disk full'); } // Lock must be released even on init failure - expect(mockRelease).toHaveBeenCalledWith('run-1'); + expect(mockRelease).toHaveBeenCalledWith(RUN_ID); }); }); diff --git a/packages/cli/__tests__/helpers/delegate-inference.test.ts b/packages/cli/__tests__/helpers/delegate-inference.test.ts index 39d9eb25c..434ee5eb8 100644 --- a/packages/cli/__tests__/helpers/delegate-inference.test.ts +++ b/packages/cli/__tests__/helpers/delegate-inference.test.ts @@ -6,7 +6,12 @@ import type { Substep, Transitions, } from '@rundown-org/parser'; -import type { RunbookState, SubstepState, StepDelegation } from '@rundown-org/core'; +import { + RUN_ID_PATTERN, + type RunbookState, + type SubstepState, + type StepDelegation, +} from '@rundown-org/core'; import { inferDelegationTarget, inferRunbookFromStep, @@ -16,6 +21,7 @@ import { brandDelegationTokenHashForTest, brandEffectiveVarsForTest, brandFrameKeyForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from './brand-helpers.js'; @@ -43,10 +49,16 @@ function makeStepWithSubsteps(name: string, substeps: Substep[]): ResolvedStepWi /** Build a minimal RunbookState for testing. */ function makeState(overrides: Partial = {}): RunbookState { + const { + runbook: overrideRunbook, + runbookPath: overrideRunbookPath, + ...stateOverrides + } = overrides; + const runbook = overrideRunbook ?? { source: 'project' as const, path: 'test.runbook.md' }; return { - id: 'test-run-id', - runbook: 'test.runbook.md', - runbookPath: 'test.runbook.md', + id: brandRunIdForTest(`rd_${'a'.repeat(32)}`), + runbook, + runbookPath: overrideRunbookPath ?? runbook.path, step: '1', stepName: 'Step 1', retryCount: 0, @@ -54,7 +66,7 @@ function makeState(overrides: Partial = {}): RunbookState { steps: [], startedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - ...overrides, + ...stateOverrides, }; } @@ -63,6 +75,7 @@ function makeActiveDelegation(): StepDelegation { return { tokenHash: brandDelegationTokenHashForTest(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'child.runbook.md', + childRunbookRef: { source: 'project', path: 'child.runbook.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest(), ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), @@ -87,6 +100,16 @@ function makeStepWithFor( } describe('inferDelegationTarget', () => { + it('builds states with a canonical run id', () => { + expect(RUN_ID_PATTERN.test(makeState().id)).toBe(true); + }); + + it('derives runbookPath from an overridden runbook reference', () => { + const state = makeState({ runbook: { source: 'plugin', path: 'plugin-child.runbook.md' } }); + + expect(state.runbookPath).toBe('plugin-child.runbook.md'); + }); + it('returns first pending substep with runbook reference', () => { const substeps = [ makeSubstep({ id: '1', description: 'First', runbooks: ['child.runbook.md'] }), diff --git a/packages/cli/__tests__/helpers/delegation-completion.test.ts b/packages/cli/__tests__/helpers/delegation-completion.test.ts index a620f8c35..8801cf416 100644 --- a/packages/cli/__tests__/helpers/delegation-completion.test.ts +++ b/packages/cli/__tests__/helpers/delegation-completion.test.ts @@ -4,6 +4,7 @@ import { brandDelegationTokenHashForTest, brandEffectiveVarsForTest, brandFrameKeyForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from './brand-helpers.js'; import { mockFn } from './typed-mocks.js'; @@ -24,6 +25,11 @@ import type { OutputEmitter } from '../../src/services/output-emitter.js'; type SubstepStatePatch = Partial>; +const PARENT_RUN_ID = brandRunIdForTest('rd_11111111111111111111111111111111'); +const CHILD_RUN_ID = brandRunIdForTest('rd_22222222222222222222222222222222'); +const GRANDPARENT_RUN_ID = brandRunIdForTest('rd_33333333333333333333333333333333'); +const OLD_CHILD_RUN_ID = brandRunIdForTest('rd_44444444444444444444444444444444'); + function upsertSubstepStateForTest( substepStates: readonly SubstepState[], substepId: string, @@ -149,10 +155,10 @@ const { handleParentCompletion, extractParentLinkage } = await import( '../../src/helpers/delegation-completion.js' ); -function makeState(id: string, overrides: Partial = {}): RunbookState { +function makeState(id: RunbookState['id'], overrides: Partial = {}): RunbookState { const base: RunbookState = { id, - runbook: 'test.md', + runbook: { source: 'project', path: 'test.md' }, runbookPath: '/tmp/test.md', runbookSrc: '## 1. Step\n- PASS COMPLETE', step: '1', @@ -169,7 +175,7 @@ function makeState(id: string, overrides: Partial = {}): RunbookSt function makeDelegationLinkage(overrides: Partial = {}): DelegationLinkage { return { kind: 'delegation' as const, - parentRunId: 'parent-run-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: brandDelegationTokenHashForTest(`sha256:${'a'.repeat(64)}`), parentStep: '1', @@ -321,7 +327,7 @@ beforeEach(() => { unresolved: 0, status: 'continue', applied: 1, - state: makeState('parent-run-id'), + state: makeState(PARENT_RUN_ID), }; jest.mocked(drainResolvedCompletions).mockResolvedValue(defaultDrainResult); jest.mocked(runExecutionLoop).mockResolvedValue('waiting'); @@ -337,7 +343,7 @@ beforeEach(() => { describe('handleParentCompletion', () => { it('returns not-applicable when child has no delegation linkage', async () => { - const childState = makeState('child-run-id'); + const childState = makeState(CHILD_RUN_ID); const output = makeOutput(); const result = await handleParentCompletion(childState, 'pass', '/test', output); @@ -347,8 +353,8 @@ describe('handleParentCompletion', () => { it('acquires delegation lock on parent run ID', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -362,15 +368,15 @@ describe('handleParentCompletion', () => { await handleParentCompletion(childState, 'pass', '/test', output); - expect(lock.acquire).toHaveBeenCalledWith('parent-run-id'); - expect(lock.release).toHaveBeenCalledWith('parent-run-id'); + expect(lock.acquire).toHaveBeenCalledWith(PARENT_RUN_ID); + expect(lock.release).toHaveBeenCalledWith(PARENT_RUN_ID); }); it('returns not-applicable when parent no longer exists', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); - const states = new Map([['parent-run-id', null]]); + const states = new Map([[PARENT_RUN_ID, null]]); const manager = makeManager(states); const lock = makeLock(); const lifecycleService = makeLifecycleService(); @@ -385,17 +391,17 @@ describe('handleParentCompletion', () => { }); it('does not block inline child when substep has cancelled delegation', async () => { - const childState = makeState('child-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: { kind: 'inline' as const, - parentRunId: 'parent-run-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', parentStep: '1', parentFrameKey: brandFrameKeyForTest('1'), parentEntry: 1, }, }); - const parentState = makeState('parent-run-id', { + const parentState = makeState(PARENT_RUN_ID, { step: '1', activeEntry: 1, activeFrameKey: brandFrameKeyForTest('1'), @@ -407,8 +413,9 @@ describe('handleParentCompletion', () => { delegation: { tokenHash: brandDelegationTokenHashForTest(`sha256:${'b'.repeat(64)}`), childRunbookPath: 'old-child.md', + childRunbookRef: { source: 'project', path: 'old-child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest(), ancestors: [] }, - childRunId: 'old-child-run-id', + childRunId: OLD_CHILD_RUN_ID, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: '2026-01-01T00:00:00.000Z', }, @@ -436,7 +443,7 @@ describe('handleParentCompletion', () => { // Inline child should NOT be blocked by the cancelled delegation expect(result).not.toBe('not-applicable'); expect(lifecycleService.upsertResolvedCompletion).toHaveBeenCalledWith( - 'parent-run-id', + PARENT_RUN_ID, expect.any(String), expect.objectContaining({ agentId: 'inline', @@ -448,8 +455,8 @@ describe('handleParentCompletion', () => { it('skips propagation when delegation was cancelled', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -458,8 +465,9 @@ describe('handleParentCompletion', () => { delegation: { tokenHash: brandDelegationTokenHashForTest(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest(), ancestors: [] }, - childRunId: 'child-run-id', + childRunId: CHILD_RUN_ID, createdAt: '2026-02-27T10:00:00.000Z', cancelledAt: '2026-02-27T10:05:00.000Z', }, @@ -483,8 +491,8 @@ describe('handleParentCompletion', () => { it('records resolved completion on parent', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { step: '1', activeEntry: 1, activeFrameKey: brandFrameKeyForTest('1'), @@ -509,7 +517,7 @@ describe('handleParentCompletion', () => { await handleParentCompletion(childState, 'pass', '/test', output); expect(lifecycleService.upsertResolvedCompletion).toHaveBeenCalledWith( - 'parent-run-id', + PARENT_RUN_ID, '1||1|1', expect.objectContaining({ agentId: 'delegation', @@ -521,8 +529,8 @@ describe('handleParentCompletion', () => { it('drains resolved completions on parent after recording', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -545,7 +553,7 @@ describe('handleParentCompletion', () => { expect(drainResolvedCompletions).toHaveBeenCalledWith( expect.objectContaining({ - runbookId: 'parent-run-id', + runbookId: PARENT_RUN_ID, currentState: parentState, }), ); @@ -553,8 +561,8 @@ describe('handleParentCompletion', () => { it('runs execution loop when completions were applied', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -577,7 +585,7 @@ describe('handleParentCompletion', () => { expect(runExecutionLoop).toHaveBeenCalledWith( manager as unknown as RunbookStateManagerType, - 'parent-run-id', + PARENT_RUN_ID, expect.any(Array), '/test', false, @@ -588,8 +596,8 @@ describe('handleParentCompletion', () => { it('returns stopped when drain results in stopped status', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -614,16 +622,16 @@ describe('handleParentCompletion', () => { it('cascades to grandparent when parent completes', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); const grandparentDelegation = makeDelegationLinkage({ - parentRunId: 'grandparent-run-id', + parentRunId: GRANDPARENT_RUN_ID, parentStepId: '2', }); - const parentState = makeState('parent-run-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], parentLinkage: grandparentDelegation, }); - const grandparentState = makeState('grandparent-run-id', { + const grandparentState = makeState(GRANDPARENT_RUN_ID, { substepStates: [{ id: '2', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -647,13 +655,13 @@ describe('handleParentCompletion', () => { await handleParentCompletion(childState, 'pass', '/test', output); // Should cascade - second acquire should be on grandparent - expect(lock.acquire).toHaveBeenCalledWith('parent-run-id'); - expect(lock.acquire).toHaveBeenCalledWith('grandparent-run-id'); + expect(lock.acquire).toHaveBeenCalledWith(PARENT_RUN_ID); + expect(lock.acquire).toHaveBeenCalledWith(GRANDPARENT_RUN_ID); }); it('respects maximum recursion depth', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); const output = makeOutput(); // Call with depth already at limit @@ -667,8 +675,8 @@ describe('handleParentCompletion', () => { it('passes delegation-specific releaseRunbook:false policy to drain', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -701,8 +709,8 @@ describe('handleParentCompletion', () => { it('explicitly releases parent runbook when drain returns done', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -726,13 +734,13 @@ describe('handleParentCompletion', () => { const sessionInstance = MockSession.mock.results[0]?.value as { releaseRunbook: jest.Mock<(runbookId: string) => Promise>; }; - expect(sessionInstance.releaseRunbook).toHaveBeenCalledWith('parent-run-id'); + expect(sessionInstance.releaseRunbook).toHaveBeenCalledWith(PARENT_RUN_ID); }); it('explicitly releases parent runbook when drain returns stopped', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -756,13 +764,13 @@ describe('handleParentCompletion', () => { const sessionInstance = MockSession.mock.results[0]?.value as { releaseRunbook: jest.Mock<(runbookId: string) => Promise>; }; - expect(sessionInstance.releaseRunbook).toHaveBeenCalledWith('parent-run-id'); + expect(sessionInstance.releaseRunbook).toHaveBeenCalledWith(PARENT_RUN_ID); }); it('uses fail transition config when result is fail', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -789,8 +797,8 @@ describe('handleParentCompletion', () => { it('flushes output after handling', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -816,8 +824,8 @@ describe('handleParentCompletion', () => { it('passes parentFrameKey as frameKeyOverride to drain', async () => { const delegation = makeDelegationLinkage({ parentFrameKey: brandFrameKeyForTest('1', 3) }); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1', 3), status: 'pending' }], }); @@ -847,8 +855,8 @@ describe('handleParentCompletion', () => { it('does not pass frameKeyOverride when parentFrameKey is undefined', async () => { const delegation = makeDelegationLinkage({ parentFrameKey: undefined }); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -875,11 +883,11 @@ describe('handleParentCompletion', () => { it('forwards child finalVars to parent actor via SET_VARIABLES before drain', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation, finalVars: { PlanPath: '/work/plan.json', version: '2.1' }, }); - const parentState = makeState('parent-run-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -904,7 +912,7 @@ describe('handleParentCompletion', () => { const actorInstance = MockActor.mock.results[0]?.value as { sendAndSync: jest.Mock<(...args: unknown[]) => Promise>; }; - expect(actorInstance.sendAndSync).toHaveBeenCalledWith('parent-run-id', expect.any(Array), { + expect(actorInstance.sendAndSync).toHaveBeenCalledWith(PARENT_RUN_ID, expect.any(Array), { type: 'SET_VARIABLES', vars: { PlanPath: '/work/plan.json', version: '2.1' }, }); @@ -912,11 +920,11 @@ describe('handleParentCompletion', () => { it('surfaces a warning to output when SET_VARIABLES fails', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation, finalVars: { PlanPath: '/work/plan.json' }, }); - const parentState = makeState('parent-run-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -969,8 +977,8 @@ describe('handleParentCompletion', () => { it('does not call sendAndSync when child has no finalVars', async () => { const delegation = makeDelegationLinkage(); - const childState = makeState('child-run-id', { parentLinkage: delegation }); - const parentState = makeState('parent-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: delegation }); + const parentState = makeState(PARENT_RUN_ID, { substepStates: [{ id: '1', frameKey: brandFrameKeyForTest('1'), status: 'pending' }], }); @@ -1005,10 +1013,10 @@ describe('handleParentCompletion', () => { describe('inline linkage path', () => { it('extractParentLinkage returns inline linkage from state', () => { - const state = makeState('child-run', { + const state = makeState(CHILD_RUN_ID, { parentLinkage: { kind: 'inline' as const, - parentRunId: 'parent-run', + parentRunId: PARENT_RUN_ID, parentStepId: '1', parentStep: '1', parentFrameKey: brandFrameKeyForTest('1'), @@ -1017,21 +1025,21 @@ describe('inline linkage path', () => { }); const linkage = extractParentLinkage(state); expect(linkage).toBeDefined(); - expect(linkage!.parentRunId).toBe('parent-run'); + expect(linkage!.parentRunId).toBe(PARENT_RUN_ID); }); it('agentId is inline for inline children', async () => { - const childState = makeState('child-run-id', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: { kind: 'inline' as const, - parentRunId: 'parent-run-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', parentStep: '1', parentFrameKey: brandFrameKeyForTest('1'), parentEntry: 1, }, }); - const parentState = makeState('parent-run-id', { + const parentState = makeState(PARENT_RUN_ID, { step: '1', activeEntry: 1, activeFrameKey: brandFrameKeyForTest('1'), @@ -1056,7 +1064,7 @@ describe('inline linkage path', () => { await handleParentCompletion(childState, 'pass', '/test', output); expect(lifecycleService.upsertResolvedCompletion).toHaveBeenCalledWith( - 'parent-run-id', + PARENT_RUN_ID, expect.any(String), expect.objectContaining({ agentId: 'inline', diff --git a/packages/cli/__tests__/helpers/execution-emitter.test.ts b/packages/cli/__tests__/helpers/execution-emitter.test.ts index 0653b3164..cde25ad2e 100644 --- a/packages/cli/__tests__/helpers/execution-emitter.test.ts +++ b/packages/cli/__tests__/helpers/execution-emitter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, jest } from '@jest/globals'; -import { ExecutionEventEmitter, type RunbookState } from '@rundown-org/core'; +import { ExecutionEventEmitter, RunbookRefSchema, type RunbookState } from '@rundown-org/core'; import type { OutputEmitter } from '../../src/services/output-emitter.js'; import { brandStoredOutputsForTest } from './brand-helpers.js'; @@ -10,8 +10,8 @@ type ExecutionEvent = Parameters[0]; describe('createBridgedEmitter', () => { function makeState(overrides: Partial = {}): RunbookState { return { - id: 'wf-test', - runbook: 'test-runbook', + id: 'wf-test' as RunbookState['id'], + runbook: { source: 'project', path: 'test-runbook.runbook.md' }, runbookPath: 'test-runbook.runbook.md', step: '1', stepName: 'Step 1', @@ -41,6 +41,19 @@ describe('createBridgedEmitter', () => { expect(emitter).toBeInstanceOf(ExecutionEventEmitter); }); + it('trusts the runbook ref already validated on state load', () => { + const parseSpy = jest.spyOn(RunbookRefSchema, 'parse'); + const { output } = makeOutput(); + + try { + createBridgedEmitter(makeState(), output as unknown as OutputEmitter); + + expect(parseSpy).not.toHaveBeenCalled(); + } finally { + parseSpy.mockRestore(); + } + }); + it('forwards emitted events to output.executionEvent()', () => { const { output, executionEventFn } = makeOutput(); const emitter = createBridgedEmitter(makeState(), output as unknown as OutputEmitter); @@ -57,31 +70,13 @@ describe('createBridgedEmitter', () => { expect(event.runbookId).toBe('wf-test'); }); - it('adapts state runbook path into a canonical project runbook reference', () => { - const { output, executionEventFn } = makeOutput(); - const state = makeState({ runbook: 'my-book', runbookPath: 'path/to/my-book.runbook.md' }); - const emitter = createBridgedEmitter(state, output as unknown as OutputEmitter); - - emitter.emit('RUNBOOK_STARTED', { - title: 'Test', - prompted: false, - statePath: '.rundown/runs/wf-test.json', - }); - - const event = executionEventFn.mock.calls[0]?.[0]; - expect(event.runbook).toEqual({ source: 'project', path: 'path/to/my-book.runbook.md' }); - }); - - it('uses an explicit canonical runbook reference when provided', () => { + it('emits the persisted project runbook reference', () => { const { output, executionEventFn } = makeOutput(); const state = makeState({ - runbook: 'rundown:write-plan', - runbookPath: '../../plugin/runbooks/planning/write-plan.runbook.md', - }); - const emitter = createBridgedEmitter(state, output as unknown as OutputEmitter, { - source: 'plugin', - path: 'planning/write-plan.runbook.md', + runbook: { source: 'project', path: 'path/to/my-book.runbook.md' }, + runbookPath: 'path/to/my-book.runbook.md', }); + const emitter = createBridgedEmitter(state, output as unknown as OutputEmitter); emitter.emit('RUNBOOK_STARTED', { title: 'Test', @@ -90,15 +85,14 @@ describe('createBridgedEmitter', () => { }); const event = executionEventFn.mock.calls[0]?.[0]; - expect(event.runbook).toEqual({ source: 'plugin', path: 'planning/write-plan.runbook.md' }); + expect(event.runbook).toEqual({ source: 'project', path: 'path/to/my-book.runbook.md' }); }); - it('uses a persisted canonical runbook reference when no explicit reference is provided', () => { + it('uses the persisted canonical runbook identity', () => { const { output, executionEventFn } = makeOutput(); const state = makeState({ - runbook: 'rundown:write-plan', + runbook: { source: 'plugin', path: 'planning/write-plan.runbook.md' }, runbookPath: '../../plugin/runbooks/planning/write-plan.runbook.md', - runbookRef: { source: 'plugin', path: 'planning/write-plan.runbook.md' }, }); const emitter = createBridgedEmitter(state, output as unknown as OutputEmitter); @@ -112,10 +106,10 @@ describe('createBridgedEmitter', () => { expect(event.runbook).toEqual({ source: 'plugin', path: 'planning/write-plan.runbook.md' }); }); - it('falls back to the launch argument when persisted runbook path is not canonical', () => { + it('emits markdown paths without extension rewriting', () => { const { output, executionEventFn } = makeOutput(); const state = makeState({ - runbook: 'runbooks/substep-fail-any.md', + runbook: { source: 'project', path: 'runbooks/substep-fail-any.md' }, runbookPath: '../../var/folders/test/runbooks/substep-fail-any.md', }); const emitter = createBridgedEmitter(state, output as unknown as OutputEmitter); @@ -129,7 +123,7 @@ describe('createBridgedEmitter', () => { const event = executionEventFn.mock.calls[0]?.[0]; expect(event.runbook).toEqual({ source: 'project', - path: 'runbooks/substep-fail-any.runbook.md', + path: 'runbooks/substep-fail-any.md', }); }); }); diff --git a/packages/cli/__tests__/helpers/goto-workflow.test.ts b/packages/cli/__tests__/helpers/goto-workflow.test.ts index 3ee3926db..3bb69cd98 100644 --- a/packages/cli/__tests__/helpers/goto-workflow.test.ts +++ b/packages/cli/__tests__/helpers/goto-workflow.test.ts @@ -10,10 +10,14 @@ import type { } from '@rundown-org/core'; import { assertClaimId } from '@rundown-org/core'; import type { OutputEmitter } from '../../src/services/output-emitter.js'; -import { brandDelegationTokenHashForTest } from './brand-helpers.js'; +import { brandDelegationTokenHashForTest, brandRunIdForTest } from './brand-helpers.js'; import { mockErrorHelpers } from './mock-error-helpers.js'; import { mockFn } from './typed-mocks.js'; +const DEFAULT_RUNBOOK_ID = brandRunIdForTest(`rd_${'6'.repeat(32)}`); +const PARENT_RUNBOOK_ID = brandRunIdForTest(`rd_${'7'.repeat(32)}`); +const CLAIMED_RUNBOOK_ID = brandRunIdForTest(`rd_${'8'.repeat(32)}`); + // Mock @rundown-org/core jest.unstable_mockModule('@rundown-org/core', () => ({ RunbookStateManager: jest.fn(), @@ -373,8 +377,8 @@ describe('executeGoto', () => { // fields; tests exercise only the specific fields each path consults. function makeState(overrides: Partial = {}): RunbookState { return { - id: 'test-id', - runbook: 'test.md', + id: DEFAULT_RUNBOOK_ID, + runbook: { source: 'project', path: 'test.md' }, runbookPath: 'test.md', step: '1', stepName: 'Step 1', @@ -492,13 +496,13 @@ describe('resolveTerminalReleaseModeForRunbook', () => { it('uses stack-pop for default-stack runbooks', async () => { const loadSession = mockFn(); loadSession.mockResolvedValue({ - defaultStack: ['runbook-a'], + defaultStack: [DEFAULT_RUNBOOK_ID], claims: {}, }); const mode = await resolveTerminalReleaseModeForRunbook( { loadSession } as unknown as RunbookStateManager, - 'runbook-a', + DEFAULT_RUNBOOK_ID, ); expect(mode).toBe('stack-pop'); @@ -507,13 +511,13 @@ describe('resolveTerminalReleaseModeForRunbook', () => { it('uses release-runbook for claim-targeted runbooks', async () => { const loadSession = mockFn(); loadSession.mockResolvedValue({ - defaultStack: ['parent-runbook'], + defaultStack: [PARENT_RUNBOOK_ID], claims: { rdclm_abcdefghijklmnopqrstu1: { kind: 'claim-record', claimId: assertClaimId('rdclm_abcdefghijklmnopqrstu1'), - childRunId: 'claimed-runbook', - parentRunId: 'parent-runbook', + childRunId: CLAIMED_RUNBOOK_ID, + parentRunId: PARENT_RUNBOOK_ID, parentStepId: '1.1', tokenHash: brandDelegationTokenHashForTest( 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -526,7 +530,7 @@ describe('resolveTerminalReleaseModeForRunbook', () => { const mode = await resolveTerminalReleaseModeForRunbook( { loadSession } as unknown as RunbookStateManager, - 'claimed-runbook', + CLAIMED_RUNBOOK_ID, ); expect(mode).toBe('release-runbook'); diff --git a/packages/cli/__tests__/helpers/normalize-cli-output.test.ts b/packages/cli/__tests__/helpers/normalize-cli-output.test.ts index c2be10e53..fe48bd8f2 100644 --- a/packages/cli/__tests__/helpers/normalize-cli-output.test.ts +++ b/packages/cli/__tests__/helpers/normalize-cli-output.test.ts @@ -62,15 +62,12 @@ describe('normalizeCliOutput', () => { expect(normalizeCliOutput(input, workspace)).toBe('"runId": ""'); }); - it('masks an 8-char hex RunId template-variable value as ', () => { - // {{RunId}} is a built-in template variable — `randomBytes(4).toString('hex')`. - const input = 'RunId value is abcd1234 done'; - expect(normalizeCliOutput(input, workspace)).toBe('RunId value is done'); + it('masks a concrete RunId template-variable value as ', () => { + const input = 'RunId value is rd_0123456789abcdef0123456789abcdef done'; + expect(normalizeCliOutput(input, workspace)).toBe('RunId value is done'); }); it('masks an 8-char hex ContextId template-variable value as ', () => { - // {{ContextId}} shares the same randomBytes(4)→hex shape as {{RunId}}; - // one rule covers both. const input = 'ContextId value is deadbeef'; expect(normalizeCliOutput(input, workspace)).toBe('ContextId value is '); }); @@ -145,40 +142,26 @@ describe('normalizeCliOutput', () => { expect(normalizeCliOutput(input, workspace)).toBe('loaded from /other-file'); }); - it('replaces wf-YYYY-MM-DD-xxxxxx runbookId values with ', () => { - // cspell:disable-next-line - const input = '"runbookId":"wf-2026-04-22-8gcrrf"'; + it('replaces rd-prefixed runbookId values with ', () => { + const input = '"runbookId":"rd_0123456789abcdef0123456789abcdef"'; expect(normalizeCliOutput(input, workspace)).toBe('"runbookId":""'); }); it('replaces runbookId values embedded in state paths', () => { - const input = '"statePath":".rundown/runs/wf-2026-04-22-zxn59z.json"'; + const input = '"statePath":".rundown/runs/rd_0123456789abcdef0123456789abcdef.json"'; expect(normalizeCliOutput(input, workspace)).toBe( '"statePath":".rundown/runs/.json"', ); }); - it('replaces runbookId with shorter base-36 suffix', () => { - const input = '"runbookId":"wf-2026-04-22-a1"'; - expect(normalizeCliOutput(input, workspace)).toBe('"runbookId":""'); - }); - - describe('runbook ID normalization is bounded to 1-6 base-36 chars', () => { - it('normalizes a 3-char base-36 suffix', () => { - const input = '"runbookId":"wf-2026-04-23-abc"'; - expect(normalizeCliOutput(input, workspace)).toBe('"runbookId":""'); - }); - - it('normalizes a 6-char suffix (upper boundary)', () => { - const input = '"runbookId":"wf-2026-04-23-abcdef"'; - expect(normalizeCliOutput(input, workspace)).toBe('"runbookId":""'); + describe('runbook ID normalization is bounded to 32 lowercase hex chars', () => { + it('does NOT normalize uppercase hex', () => { + const input = '"runbookId":"rd_0123456789abcdef0123456789ABCDEF"'; + expect(normalizeCliOutput(input, workspace)).toBe(input); }); - it('does NOT normalize a 7-char suffix (malformed runbook ID)', () => { - // Suffixes longer than 6 base-36 chars do not match the production - // ID generator; leave them alone so genuine garbage stays visible - // in snapshot diffs rather than getting masked. - const input = '"runbookId":"wf-2026-04-23-abcdefg"'; + it('does NOT normalize malformed lengths', () => { + const input = '"runbookId":"rd_0123456789abcdef0123456789abcde"'; expect(normalizeCliOutput(input, workspace)).toBe(input); }); }); diff --git a/packages/cli/__tests__/helpers/resolve-runbook-plugin-context.test.ts b/packages/cli/__tests__/helpers/resolve-runbook-plugin-context.test.ts new file mode 100644 index 000000000..632ea35c6 --- /dev/null +++ b/packages/cli/__tests__/helpers/resolve-runbook-plugin-context.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, jest } from '@jest/globals'; + +jest.unstable_mockModule('../../src/helpers/plugin-root.js', () => ({ + getPluginRoot: jest.fn(() => null), +})); + +const { resolveRunbookRef } = await import('../../src/helpers/resolve-runbook.js'); + +describe('resolveRunbookRef plugin context', () => { + it('reports missing plugin context separately from a missing plugin file', async () => { + const runbookRef = { source: 'plugin' as const, path: 'planning/write-plan.runbook.md' }; + + const result = await resolveRunbookRef('/workspace', runbookRef); + + expect(result).toEqual({ + ok: false, + reason: 'plugin-context-missing', + runbookRef, + }); + }); +}); diff --git a/packages/cli/__tests__/helpers/resolve-runbook.test.ts b/packages/cli/__tests__/helpers/resolve-runbook.test.ts index d7a1f39a3..35bd670d7 100644 --- a/packages/cli/__tests__/helpers/resolve-runbook.test.ts +++ b/packages/cli/__tests__/helpers/resolve-runbook.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { resolveRunbookFile, parseIdentifier } from '../../src/helpers/resolve-runbook.js'; +import { + resolveRunbookFile, + resolveRunbookRef, + buildRunbookRef, + parseIdentifier, +} from '../../src/helpers/resolve-runbook.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -33,6 +38,7 @@ describe('resolveRunbookFile', () => { expect(result).not.toBeNull(); expect(result!.path).toBe(path.join(claudeDir, 'test.runbook.md')); + expect(result!.sourceRoot).toBe(testDir); }); it('should find runbook in plugin runbooks directory', async () => { @@ -46,6 +52,7 @@ describe('resolveRunbookFile', () => { const result = await resolveRunbookFile(testDir, 'plugin.runbook.md'); expect(result).not.toBeNull(); expect(result!.path).toBe(path.join(pluginDir, 'plugin.runbook.md')); + expect(result!.sourceRoot).toBe(pluginDir); // afterEach restores originalPluginRoot }); @@ -56,6 +63,7 @@ describe('resolveRunbookFile', () => { expect(result).not.toBeNull(); expect(result!.path).toBe(path.join(testDir, 'relative.runbook.md')); + expect(result!.sourceRoot).toBe(testDir); }); it('should return null if runbook not found', async () => { @@ -75,6 +83,7 @@ describe('resolveRunbookFile', () => { expect(result).not.toBeNull(); expect(result!.path).toBe(path.join(claudeDir, 'test.runbook.md')); + expect(result!.sourceRoot).toBe(testDir); }); describe('resolution precedence', () => { @@ -311,6 +320,165 @@ describe('resolveRunbookFile', () => { expect(result).not.toBeNull(); expect(result!.source).toBe('plugin'); }); + + it('returns source-root-relative runbookRef for nested plugin runbooks', async () => { + const pluginRunbooksDir = path.join(testDir, 'plugin/runbooks'); + const runbookRef = 'planning/review/review-plan-risk-safety.runbook.md'; + await fs.mkdir(path.join(pluginRunbooksDir, 'planning', 'review'), { recursive: true }); + await fs.writeFile(path.join(pluginRunbooksDir, runbookRef), '# Risk Safety'); + process.env.CLAUDE_PLUGIN_ROOT = path.join(testDir, 'plugin'); + + const result = await resolveRunbookFile(testDir, runbookRef); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('plugin'); + expect(result!.sourceRoot).toBe(pluginRunbooksDir); + expect(await buildRunbookRef(result!)).toEqual({ source: 'plugin', path: runbookRef }); + }); + + it('keeps project-local runbook refs project-root-relative', async () => { + const projectRunbooksDir = runbooksDir(testDir); + await fs.mkdir(path.join(projectRunbooksDir, 'ops'), { recursive: true }); + await fs.writeFile(path.join(projectRunbooksDir, 'ops/deploy.runbook.md'), '# Deploy'); + + const result = await resolveRunbookFile(testDir, 'ops/deploy.runbook.md'); + + expect(result).not.toBeNull(); + expect(result!.source).toBe('project'); + expect(result!.sourceRoot).toBe(testDir); + expect(await buildRunbookRef(result!)).toEqual({ + source: 'project', + path: '.rundown/runbooks/ops/deploy.runbook.md', + }); + }); + + it('derives cwd-relative refs for absolute project runbook paths under .rundown/runbooks', async () => { + const filePath = path.join(testDir, '.rundown/runbooks/ops/deploy.md'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, '# Deploy'); + + const result = await resolveRunbookFile(testDir, filePath); + + expect(result).toMatchObject({ + source: 'project', + sourceRoot: testDir, + }); + expect(await buildRunbookRef(result!)).toEqual({ + source: 'project', + path: '.rundown/runbooks/ops/deploy.md', + }); + }); + + it('derives cwd-relative refs for absolute direct project file paths', async () => { + const filePath = path.join(testDir, 'scratch/deploy.md'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, '# Deploy'); + + const result = await resolveRunbookFile(testDir, filePath); + + expect(result).toMatchObject({ + source: 'project', + sourceRoot: testDir, + }); + expect(await buildRunbookRef(result!)).toEqual({ + source: 'project', + path: 'scratch/deploy.md', + }); + }); + + it('re-resolves absolute external runbook refs to the original file path', async () => { + const externalDir = await fs.mkdtemp(path.join(path.dirname(testDir), 'external-runbook-')); + try { + const filePath = path.join(externalDir, 'external-child.runbook.md'); + await fs.writeFile(filePath, '# External Child'); + + const resolved = await resolveRunbookFile(testDir, filePath); + + expect(resolved).toMatchObject({ + path: filePath, + source: 'external', + sourceRoot: externalDir, + }); + const runbookRef = await buildRunbookRef(resolved!); + expect(runbookRef).toEqual({ + source: 'external', + path: filePath, + }); + + const rehydrated = await resolveRunbookRef(testDir, runbookRef); + expect(rehydrated.ok).toBe(true); + if (rehydrated.ok) { + expect(rehydrated.resolved).toMatchObject({ + path: filePath, + source: 'external', + sourceRoot: externalDir, + }); + } + } finally { + await fs.rm(externalDir, { recursive: true, force: true }); + } + }); + + it('re-resolves persisted refs to the exact Markdown file path', async () => { + const filePath = path.join(testDir, '.rundown/runbooks/ops/deploy.md'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, '# Deploy'); + + const result = await resolveRunbookRef(testDir, { + source: 'project', + path: '.rundown/runbooks/ops/deploy.md', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.resolved).toMatchObject({ + path: filePath, + source: 'project', + sourceRoot: testDir, + }); + expect(await buildRunbookRef(result.resolved)).toEqual({ + source: 'project', + path: '.rundown/runbooks/ops/deploy.md', + }); + } + }); + + it('reports missing plugin files when plugin context is available', async () => { + process.env.CLAUDE_PLUGIN_ROOT = path.join(testDir, 'plugin'); + const runbookRef = { source: 'plugin' as const, path: 'planning/write-plan.runbook.md' }; + + const result = await resolveRunbookRef(testDir, runbookRef); + + expect(result).toEqual({ + ok: false, + reason: 'file-missing', + runbookRef, + }); + }); + }); +}); + +describe('buildRunbookRef', () => { + it('rejects a symlinked runbook that escapes the source root', async () => { + const realDir = await fs.mkdtemp(path.join(os.tmpdir(), 'real-')); + const sourceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'src-')); + try { + // Real file lives outside sourceRoot + const realFile = path.join(realDir, 'escaped.runbook.md'); + await fs.writeFile(realFile, '# Escaped'); + + // Symlink lives inside sourceRoot but resolves outside + const symlinkInside = path.join(sourceRoot, 'inside.runbook.md'); + await fs.symlink(realFile, symlinkInside); + + // buildRunbookRef should reject because realpath(symlinkInside) is not under sourceRoot + await expect( + buildRunbookRef({ path: symlinkInside, source: 'project', sourceRoot }), + ).rejects.toThrow(/outside/i); + } finally { + await fs.rm(realDir, { recursive: true, force: true }); + await fs.rm(sourceRoot, { recursive: true, force: true }); + } }); }); diff --git a/packages/cli/__tests__/helpers/runbook-loader.test.ts b/packages/cli/__tests__/helpers/runbook-loader.test.ts index 159ba0f4f..97f2ab0fa 100644 --- a/packages/cli/__tests__/helpers/runbook-loader.test.ts +++ b/packages/cli/__tests__/helpers/runbook-loader.test.ts @@ -22,8 +22,8 @@ echo done \`\`\` `; const state: Partial = { - id: 'test-id', - runbook: 'test.runbook.md', + id: 'test-id' as RunbookState['id'], + runbook: { source: 'project', path: 'test.runbook.md' }, runbookSrc, }; @@ -36,8 +36,8 @@ echo done it('should throw when runbookSrc is missing (corrupted state)', () => { const state: Partial = { - id: 'corrupted-id', - runbook: 'test.runbook.md', + id: 'corrupted-id' as RunbookState['id'], + runbook: { source: 'project', path: 'test.runbook.md' }, // runbookSrc is undefined }; @@ -55,8 +55,8 @@ echo done Deploy to {{ env }}. `; const state: Partial = { - id: 'template-id', - runbook: 'template.runbook.md', + id: 'template-id' as RunbookState['id'], + runbook: { source: 'project', path: 'template.runbook.md' }, runbookSrc, templateVars: brandInitialTemplateVarsForTest({ env: 'staging' }), }; @@ -70,8 +70,8 @@ Deploy to {{ env }}. it('should not attempt disk fallback', () => { const state: Partial = { - id: 'missing-src-id', - runbook: 'nonexistent.runbook.md', + id: 'missing-src-id' as RunbookState['id'], + runbook: { source: 'project', path: 'nonexistent.runbook.md' }, // runbookSrc is undefined }; diff --git a/packages/cli/__tests__/helpers/runbook-pipeline.test.ts b/packages/cli/__tests__/helpers/runbook-pipeline.test.ts index 431044737..d097f392a 100644 --- a/packages/cli/__tests__/helpers/runbook-pipeline.test.ts +++ b/packages/cli/__tests__/helpers/runbook-pipeline.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { + ClaimId, + ClaimRecord, + ClaimRunbookResult, + DelegationLinkage, RunbookActorService, RunbookStateManager, SessionService, @@ -8,6 +12,7 @@ import type { DelegationLock, RunbookState, RunbookRef, + RunId, } from '@rundown-org/core'; import type { ParsedForClause, @@ -17,12 +22,16 @@ import type { Transitions, } from '@rundown-org/parser'; import type { OutputEmitter } from '../../src/services/output-emitter.js'; -import type { PreparedRunbook, RunPipelineContext } from '../../src/helpers/runbook-pipeline.js'; +import type { + PreparedRunbook, + RunPipelineContext, + RunnableRunbook, +} from '../../src/helpers/runbook-pipeline.js'; import { assertVariant } from './assert-variant.js'; import { mockErrorHelpers } from './mock-error-helpers.js'; import { makeRunPipelineContext } from './run-pipeline-context-helpers.js'; import { mockFn } from './typed-mocks.js'; -import { brandDelegationTokenHashForTest } from './brand-helpers.js'; +import { brandDelegationTokenHashForTest, brandRunIdForTest } from './brand-helpers.js'; // Capture the real isJsonArrayStream before the mock is registered. // jest.unstable_mockModule does NOT hoist (unlike jest.mock), so this top-level @@ -31,6 +40,47 @@ const { isJsonArrayStream: realIsJsonArrayStream, RunbookRefSchema: realRunbookR await import('@rundown-org/core'); const MOCK_TOKEN_HASH = brandDelegationTokenHashForTest(`sha256:${'a'.repeat(64)}`); +const TEST_CLAIM_ID = 'rdclm_abcdefghijklmnopqrstu1' as ClaimId; +const RUN_ID_PATTERN = /^rd_[a-f0-9]{32}$/; +const MOCK_RUN_ID = brandRunIdForTest(`rd_${'a'.repeat(32)}`); +const PARENT_RUN_ID = brandRunIdForTest(`rd_${'b'.repeat(32)}`); +const DIFFERENT_PARENT_RUN_ID = brandRunIdForTest(`rd_${'c'.repeat(32)}`); +const ORPHAN_RUN_ID = brandRunIdForTest(`rd_${'d'.repeat(32)}`); + +function claimRecord(childRunId: RunId, overrides: Partial = {}): ClaimRecord { + return { + kind: 'claim-record', + claimId: TEST_CLAIM_ID, + childRunId, + tokenHash: MOCK_TOKEN_HASH, + parentRunId: PARENT_RUN_ID, + parentStepId: '1', + claimedAt: '2026-02-27T10:00:00.000Z', + updatedAt: '2026-02-27T10:00:00.000Z', + ...overrides, + }; +} + +function claimedRunbookResult( + childRunId: RunId, + overrides: Partial = {}, +): ClaimRunbookResult { + return { status: 'claimed', claim: claimRecord(childRunId, overrides) }; +} + +function mockClaimRunbookSuccess(): jest.Mock { + return mockFn().mockImplementation( + async (childRunId: RunId, linkage: DelegationLinkage) => + claimedRunbookResult(childRunId, { + tokenHash: linkage.tokenHash, + parentRunId: linkage.parentRunId, + parentStepId: linkage.parentStepId, + parentStep: linkage.parentStep, + parentFrameKey: linkage.parentFrameKey, + parentEntry: linkage.parentEntry, + }), + ); +} // Mock @rundown-org/core jest.unstable_mockModule('@rundown-org/core', () => ({ @@ -55,6 +105,7 @@ jest.unstable_mockModule('@rundown-org/core', () => ({ RunbookRefSchema: { parse: jest.fn((ref: unknown) => realRunbookRefSchema.parse(ref)), }, + generateRunId: jest.fn(() => `rd_${'a'.repeat(32)}`), DELEGATION_TOKEN_PREFIX: 'rdtk_', DEFAULT_POLICY: { version: 1, @@ -116,6 +167,8 @@ jest.unstable_mockModule('@rundown-org/core', () => ({ ...mockErrorHelpers, })); +const actualResolveRunbook = await import('../../src/helpers/resolve-runbook.js'); + // Mock @rundown-org/parser jest.unstable_mockModule('@rundown-org/parser', () => ({ parseRunbookDocument: jest.fn(), @@ -125,10 +178,17 @@ jest.unstable_mockModule('@rundown-org/parser', () => ({ step.kind === 'substeps' || step.kind === 'for' || step.kind === 'prompted-for', })); -// Mock resolve-runbook -jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => ({ - resolveRunbookFile: jest.fn(), -})); +// Mock resolve-runbook discovery while delegating runbook-ref derivation to +// the production implementation by default. Individual tests can still +// override buildRunbookRef for error/mismatch cases. +jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => { + return { + ...actualResolveRunbook, + resolveRunbookFile: jest.fn(), + resolveRunbookRef: jest.fn(), + buildRunbookRef: jest.fn(actualResolveRunbook.buildRunbookRef), + }; +}); // Mock execution service jest.unstable_mockModule('../../src/services/execution', () => ({ @@ -169,6 +229,18 @@ jest.unstable_mockModule('../../src/services/variable-discovery', () => ({ }> >().mockResolvedValue({ vars: {}, warnings: [], providedKeys: new Set() }), RUNTIME_RESERVED_VARIABLES: new Set(['step', 'index', 'context']), + BUILTIN_VARIABLES: { + Date: 'Date', + DateTime: 'DateTime', + Year: 'Year', + Month: 'Month', + Day: 'Day', + Branch: 'Branch', + WorkPath: 'WorkPath', + RunId: 'RunId', + RunbookRef: 'RunbookRef', + ContextId: 'ContextId', + }, isRuntimeReservedVariable: mockFn<(name: string) => boolean>().mockReturnValue(false), })); @@ -200,7 +272,9 @@ jest.unstable_mockModule('node:fs/promises', () => ({ // Import after mocking const core = await import('@rundown-org/core'); const parser = await import('@rundown-org/parser'); -const { resolveRunbookFile } = await import('../../src/helpers/resolve-runbook.js'); +const { resolveRunbookFile, resolveRunbookRef, buildRunbookRef } = await import( + '../../src/helpers/resolve-runbook.js' +); const { runExecutionLoop, buildStepVariables } = await import('../../src/services/execution.js'); const { createBridgedEmitter } = await import('../../src/helpers/execution-emitter.js'); const { FileSourcePolicyError, resolveVariables } = await import( @@ -217,17 +291,22 @@ const { validateOutputsDeclarations } = await import( '../../src/helpers/validate-frontmatter-vars.js' ); const fsPromises = await import('node:fs/promises'); -const { prepareRunbook, startRunbook, buildContextVars, buildTemplateVars } = await import( - '../../src/helpers/runbook-pipeline.js' -); +const { + prepareRunbook, + prepareRunnableRunbook, + loadAndParseResolvedRunbook, + startRunbook, + buildContextVars, + buildTemplateVars, +} = await import('../../src/helpers/runbook-pipeline.js'); const { setHelperRegistry, resetHelperRegistry } = await import( '../../src/services/helper-registry.js' ); -function makeState(id: string, overrides: Record = {}): Record { +function makeState(id: RunId, overrides: Record = {}): Record { return { id, - runbook: 'test.md', + runbook: { source: 'project', path: 'test.md' }, runbookPath: '/tmp/test.md', runbookSrc: '## 1. Step\n- PASS COMPLETE', step: '1', @@ -379,6 +458,7 @@ beforeEach(() => { ).mockResolvedValue('# Test\n\n## 1. Step\n- PASS CONTINUE'); jest.mocked(parser.parseRunbookDocument).mockReturnValue(mockParseResult()); jest.mocked(core.hashDelegationToken).mockReturnValue(MOCK_TOKEN_HASH); + jest.mocked(core.generateRunId).mockReturnValue(MOCK_RUN_ID); jest.mocked(core.reconstituteContextVars).mockReturnValue({}); jest.mocked(core.extractInheritedUserVars).mockReturnValue({}); jest.mocked(core.deriveActiveFrame).mockReturnValue({ @@ -388,6 +468,17 @@ beforeEach(() => { }); jest.mocked(core.isJsonArray).mockImplementation((v: unknown) => Array.isArray(v)); jest.mocked(core.isJsonArrayStream).mockImplementation(realIsJsonArrayStream); + jest.mocked(buildRunbookRef).mockImplementation(actualResolveRunbook.buildRunbookRef); + jest.mocked(resolveRunbookRef).mockImplementation((_cwd: string, ref: RunbookRef) => + Promise.resolve({ + ok: true, + resolved: { + path: `/test/${ref.path}`, + source: ref.source, + sourceRoot: '/test', + }, + }), + ); }); // validateSources was removed in the unified variable model refactoring. @@ -409,6 +500,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/empty.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -427,6 +519,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/good.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -441,10 +534,155 @@ describe('prepareRunbook', () => { } }); + it('prepares resolved runbooks with RunbookRef but without RunId', async () => { + jest.mocked(resolveRunbookFile).mockResolvedValue({ + path: '/test/parent.runbook.md', + source: 'project', + sourceRoot: '/test', + }); + ( + parser.parseRunbookDocument as jest.MockedFunction + ).mockReturnValue(mockParseResult()); + + const result = await prepareRunbook('parent.runbook.md', {}, '/test'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.prepared.mergedVariables.RunbookRef).toEqual({ + source: 'project', + path: 'parent.runbook.md', + }); + expect(result.prepared.mergedVariables).not.toHaveProperty('RunId'); + } + }); + + it('loads resolved runbooks when request identity property order differs', async () => { + const pathFirstRunbookRef: RunbookRef = { + path: 'child.runbook.md', + source: 'project', + }; + const runbookRefSchemaMock = core.RunbookRefSchema as unknown as { + parse: jest.MockedFunction<(ref: unknown) => RunbookRef>; + }; + runbookRefSchemaMock.parse.mockImplementationOnce((ref: unknown) => { + realRunbookRefSchema.parse(ref); + return pathFirstRunbookRef; + }); + + const result = await loadAndParseResolvedRunbook({ + resolved: { + path: '/test/child.runbook.md', + source: 'project', + sourceRoot: '/test', + }, + runbookRef: { source: 'project', path: 'child.runbook.md' }, + displayName: 'child', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.runbookRef).toEqual({ + source: 'project', + path: 'child.runbook.md', + }); + } + }); + + it('does not re-parse the requested runbook ref for an already-resolved runbook', async () => { + const runbookRefSchemaMock = core.RunbookRefSchema as unknown as { + parse: jest.MockedFunction<(ref: unknown) => RunbookRef>; + }; + runbookRefSchemaMock.parse.mockClear(); + + const result = await loadAndParseResolvedRunbook({ + resolved: { + path: '/test/child.runbook.md', + source: 'project', + sourceRoot: '/test', + }, + runbookRef: { source: 'project', path: 'child.runbook.md' }, + displayName: 'child', + }); + + expect(result.ok).toBe(true); + expect(runbookRefSchemaMock.parse).toHaveBeenCalledTimes(1); + expect(runbookRefSchemaMock.parse).toHaveBeenCalledWith({ + source: 'project', + path: 'child.runbook.md', + }); + }); + + it('returns a structured failure when resolved identity does not match request identity', async () => { + const result = await loadAndParseResolvedRunbook({ + resolved: { + path: '/test/child.runbook.md', + source: 'project', + sourceRoot: '/test', + }, + runbookRef: { source: 'project', path: 'parent.runbook.md' }, + displayName: 'child', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe('RUNBOOK_REF_RESOLUTION_ERROR'); + expect(result.error).toContain('project:child.runbook.md'); + expect(result.error).toContain('project:parent.runbook.md'); + expect(result.details).toEqual({ runbook: 'child' }); + } + expect(fsPromises.readFile).not.toHaveBeenCalled(); + }); + + it('prepares runnable runbooks with RunId before substitution', async () => { + jest.mocked(resolveRunbookFile).mockResolvedValue({ + path: '/test/parent.runbook.md', + source: 'project', + sourceRoot: '/test', + }); + ( + parser.parseRunbookDocument as jest.MockedFunction + ).mockReturnValue(mockParseResult()); + + const result = await prepareRunnableRunbook('parent.runbook.md', {}, '/test'); + + expect(result.ok).toBe(true); + if (result.ok) { + const runnableRunId: RunId = result.prepared.runId; + const templateRunId: RunId = result.prepared.mergedVariables.RunId; + + expect(runnableRunId).toMatch(RUN_ID_PATTERN); + expect(templateRunId).toBe(runnableRunId); + expect(result.prepared.mergedVariables.RunbookRef).toEqual({ + source: 'project', + path: 'parent.runbook.md', + }); + } + }); + + it('stores the same runnable template variables used for substitution', async () => { + jest.mocked(resolveRunbookFile).mockResolvedValue({ + path: '/test/parent.runbook.md', + source: 'project', + sourceRoot: '/test', + }); + ( + parser.parseRunbookDocument as jest.MockedFunction + ).mockReturnValue(mockParseResult()); + + const result = await prepareRunnableRunbook('parent.runbook.md', {}, '/test'); + + expect(result.ok).toBe(true); + if (result.ok) { + const substitutedVars = jest.mocked(substituteRunbookVariables).mock.calls[0]?.[1]; + expect(result.prepared.mergedVariables).toBe(substitutedVars); + } + }); + it('adds context.vars aliases to merged template variables', async () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/good.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -471,6 +709,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -499,6 +738,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -527,6 +767,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/good.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -546,6 +787,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/missing-input.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -577,6 +819,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/reserved.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -587,7 +830,7 @@ describe('prepareRunbook', () => { { severity: 'error', message: - 'Frontmatter "inputs[0]" — "context" is a reserved variable name (step, index, context — case-insensitive)', + 'Frontmatter "inputs[0]" — "context" is a reserved variable name (step, index, context, runid, runbookref — case-insensitive)', }, ], }), @@ -626,6 +869,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/needs-var.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -649,6 +893,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/needs-var.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -672,6 +917,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/needs-date.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -700,6 +946,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/sourced.md', source: 'project', + sourceRoot: '/test', }); (parser.isSourced as jest.MockedFunction).mockReturnValue(true); ( @@ -730,6 +977,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/bad-for.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -754,6 +1002,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/for-bounds.md', source: 'project', + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -787,6 +1036,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/good.md', source: 'project', + sourceRoot: '/test', }); jest .mocked(resolveVariables) @@ -813,6 +1063,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/home/user/.claude/extensions/rundown-plugin/not-runbooks/child.runbook.md', source: 'plugin' as const, + sourceRoot: '/home/user/.claude/extensions/rundown-plugin/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -823,7 +1074,8 @@ describe('prepareRunbook', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.code).toBe('RUNBOOK_REF_RESOLUTION_ERROR'); - expect(result.error).toContain('not beneath a runbooks directory'); + expect(result.error).toContain('outside'); + expect(result.error).toContain('/home/user/.claude/extensions/rundown-plugin/runbooks'); expect(result.details).toEqual({ runbook: 'rundown:child' }); } expect(resolveVariables).not.toHaveBeenCalled(); @@ -833,6 +1085,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/home/user/.claude/extensions/rundown-plugin/runbooks/write-plan.runbook.md', source: 'plugin' as const, + sourceRoot: '/home/user/.claude/extensions/rundown-plugin/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -850,10 +1103,36 @@ describe('prepareRunbook', () => { ); }); + it('injects CLAUDE_PLUGIN_ROOT with forward slashes for Windows plugin paths', async () => { + jest.mocked(resolveRunbookFile).mockResolvedValue({ + path: String.raw`C:\Users\agent\.claude\extensions\rundown-plugin\runbooks\write-plan.runbook.md`, + source: 'plugin' as const, + sourceRoot: String.raw`C:\Users\agent\.claude\extensions\rundown-plugin\runbooks`, + }); + jest.mocked(buildRunbookRef).mockResolvedValue({ + source: 'plugin', + path: 'write-plan.runbook.md', + }); + ( + parser.parseRunbookDocument as jest.MockedFunction + ).mockReturnValue(mockParseResult()); + + const result = await prepareRunbook('rundown:write-plan', {}, '/test'); + + expect(result.ok).toBe(true); + expect(substituteRunbookVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + CLAUDE_PLUGIN_ROOT: 'C:/Users/agent/.claude/extensions/rundown-plugin/', + }), + ); + }); + it('does not inject CLAUDE_PLUGIN_ROOT when runbook resolves from project source', async () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/.rundown/runbooks/my-runbook.runbook.md', source: 'project' as const, + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -874,6 +1153,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/home/user/.claude/extensions/rundown-plugin/runbooks/write-plan.runbook.md', source: 'plugin' as const, + sourceRoot: '/home/user/.claude/extensions/rundown-plugin/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -900,6 +1180,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/.rundown/runbooks/ops/deploy.runbook.md', source: 'project' as const, + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -911,7 +1192,7 @@ describe('prepareRunbook', () => { if (result.ok) { expect(result.prepared.runbookRef).toEqual({ source: 'project', - path: 'ops/deploy.runbook.md', + path: '.rundown/runbooks/ops/deploy.runbook.md', }); } }); @@ -920,6 +1201,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/ops/direct.runbook.md', source: 'project' as const, + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -936,10 +1218,11 @@ describe('prepareRunbook', () => { } }); - it('normalizes legacy .md project runbook refs to .runbook.md', async () => { + it('preserves legacy .md project runbook refs', async () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/ops/legacy.md', source: 'project' as const, + sourceRoot: '/test', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -951,7 +1234,7 @@ describe('prepareRunbook', () => { if (result.ok) { expect(result.prepared.runbookRef).toEqual({ source: 'project', - path: 'ops/legacy.runbook.md', + path: 'ops/legacy.md', }); } }); @@ -960,6 +1243,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/home/user/.claude/extensions/rundown-plugin/runbooks/planning/write-plan.runbook.md', source: 'plugin' as const, + sourceRoot: '/home/user/.claude/extensions/rundown-plugin/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -980,6 +1264,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/home/user/.claude/extensions/rundown-plugin/runbooks/team/runbooks/child.runbook.md', source: 'plugin' as const, + sourceRoot: '/home/user/.claude/extensions/rundown-plugin/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -996,6 +1281,34 @@ describe('prepareRunbook', () => { } }); + it('sets RunbookRef from a nested plugin runbook path relative to plugin runbooks root', async () => { + const runbookRel = 'planning/review/review-plan-risk-safety.runbook.md'; + jest.mocked(resolveRunbookFile).mockResolvedValue({ + path: `/plugin/runbooks/${runbookRel}`, + source: 'plugin', + sourceRoot: '/plugin/runbooks', + }); + ( + parser.parseRunbookDocument as jest.MockedFunction + ).mockReturnValue(mockParseResult()); + + const result = await prepareRunbook(runbookRel, {}, '/workspace'); + + expect(result.ok).toBe(true); + expect(substituteRunbookVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + RunbookRef: { source: 'plugin', path: runbookRel }, + }), + ); + expect(substituteRunbookVariables).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ + RunId: expect.anything(), + }), + ); + }); + it('prepares source-root-relative bundled runbook refs', async () => { const originalBundledPath = process.env.BUNDLED_RUNBOOKS_PATH; process.env.BUNDLED_RUNBOOKS_PATH = '/repo/packages/cli/dist/runbooks'; @@ -1004,6 +1317,7 @@ describe('prepareRunbook', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/repo/packages/cli/dist/runbooks/planning/review.runbook.md', source: 'bundled' as const, + sourceRoot: '/repo/packages/cli/dist/runbooks', }); ( parser.parseRunbookDocument as jest.MockedFunction @@ -1031,7 +1345,7 @@ describe('prepareRunbook', () => { describe('startRunbook', () => { it('creates state and runs execution loop', async () => { - const createdState = makeState('new-id') as unknown as RunbookState; + const createdState = makeState(MOCK_RUN_ID) as unknown as RunbookState; const mockCreate = mockFn().mockResolvedValue(createdState); const mockUpdate = mockFn().mockResolvedValue(createdState); const mockLoad = mockFn().mockResolvedValue(createdState); @@ -1062,12 +1376,18 @@ describe('startRunbook', () => { lifecycleService: { ensureActiveEntry: mockEnsureActiveEntry }, }); - const prepared: PreparedRunbook = { + const prepared: RunnableRunbook = { filePath: '/test/runbook.md', + source: 'project', + sourceRoot: '/test', runbookRef: { source: 'project', path: 'runbook.runbook.md' }, + runId: brandRunIdForTest(`rd_${'b'.repeat(32)}`), rawContent: '# Test', runbook: { steps: [makeStep() as PreparedRunbook['runbook']['steps'][number]] }, - mergedVariables: {}, + mergedVariables: { + RunId: brandRunIdForTest(`rd_${'b'.repeat(32)}`), + RunbookRef: { source: 'project', path: 'runbook.runbook.md' }, + }, stats: { steps: 1, substeps: 0 }, frontmatter: null, }; @@ -1079,12 +1399,16 @@ describe('startRunbook', () => { expect(result.loopResult).toBe('done'); } expect(mockCreate).toHaveBeenCalledWith( - 'runbook.md', + prepared.runbookRef, prepared.runbook, expect.objectContaining({ + runId: prepared.runId, runbookPath: 'runbook.md', - runbookRef: prepared.runbookRef, runbookSrc: '# Test', + templateVars: expect.objectContaining({ + RunId: prepared.runId, + RunbookRef: prepared.runbookRef, + }), frontmatterOutputs: [], }), ); @@ -1093,11 +1417,7 @@ describe('startRunbook', () => { const createBridgedEmitterMock = createBridgedEmitter as jest.MockedFunction< (...args: unknown[]) => unknown >; - expect(createBridgedEmitterMock.mock.calls).toContainEqual([ - createdState, - ctx.output, - prepared.runbookRef, - ]); + expect(createBridgedEmitterMock.mock.calls).toContainEqual([createdState, ctx.output]); }); it('seeds frontmatterOutputs from prepared.frontmatter.outputs to manager.create', async () => { @@ -1123,11 +1443,7 @@ describe('startRunbook', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -1135,17 +1451,24 @@ describe('startRunbook', () => { const prepared = { filePath: '/test/runbook.md', + source: 'project', + sourceRoot: '/test', + runbookRef: { source: 'project', path: 'runbook.md' }, + runId: brandRunIdForTest(`rd_${'c'.repeat(32)}`), rawContent: '# Test', runbook: { steps: [makeStep()] }, - mergedVariables: {}, - sources: {}, + mergedVariables: { + RunId: brandRunIdForTest(`rd_${'c'.repeat(32)}`), + RunbookRef: { source: 'project', path: 'runbook.md' }, + }, frontmatter: { outputs: outputDecls }, - } as unknown as PreparedRunbook; + stats: { steps: 1, substeps: 0 }, + } as unknown as RunnableRunbook; await startRunbook(ctx, prepared, { file: 'runbook.md' }); expect(mockCreate).toHaveBeenCalledWith( - 'runbook.md', + prepared.runbookRef, prepared.runbook, expect.objectContaining({ frontmatterOutputs: outputDecls }), ); @@ -1182,11 +1505,7 @@ describe('startRunbook', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -1195,11 +1514,19 @@ describe('startRunbook', () => { const substeps = [{ id: 'a' }, { id: 'b' }]; const prepared = { filePath: '/test/runbook.md', + source: 'project', + sourceRoot: '/test', + runbookRef: { source: 'project', path: 'runbook.md' }, + runId: brandRunIdForTest(`rd_${'d'.repeat(32)}`), rawContent: '# Test', runbook: { steps: [makeStep({ substeps })] }, - mergedVariables: {}, - sources: {}, - } as unknown as PreparedRunbook; + mergedVariables: { + RunId: brandRunIdForTest(`rd_${'d'.repeat(32)}`), + RunbookRef: { source: 'project', path: 'runbook.md' }, + }, + stats: { steps: 1, substeps: 2 }, + frontmatter: null, + } as unknown as RunnableRunbook; const result = await startRunbook(ctx, prepared, { file: 'runbook.md' }); @@ -1219,7 +1546,10 @@ describe('claimAndLaunch', () => { output: {} as unknown as OutputEmitter, manager: {} as unknown as RunbookStateManager, actorService: {} as unknown as RunbookActorService, - sessionService: {} as unknown as SessionService, + sessionService: { + findClaimForDelegation: + mockFn().mockResolvedValue(null), + } as unknown as SessionService, lifecycleService: {} as unknown as ExecutionLifecycleService, cwd: '/test', } satisfies RunPipelineContext; @@ -1245,7 +1575,10 @@ describe('claimAndLaunch', () => { output: {} as unknown as OutputEmitter, manager: {} as unknown as RunbookStateManager, actorService: {} as unknown as RunbookActorService, - sessionService: {} as unknown as SessionService, + sessionService: { + findClaimForDelegation: + mockFn().mockResolvedValue(null), + } as unknown as SessionService, lifecycleService: {} as unknown as ExecutionLifecycleService, cwd: '/test', } satisfies RunPipelineContext; @@ -1264,13 +1597,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: 'existing-child-id', createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1307,11 +1641,7 @@ describe('claimAndLaunch', () => { }) as unknown as jest.MockedObject, ); - const claimSpy = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }); + const claimSpy = mockClaimRunbookSuccess(); const ctx = { output: { status: jest.fn(), flush: jest.fn() } as unknown as OutputEmitter, manager: mockManager as unknown as RunbookStateManager, @@ -1332,17 +1662,83 @@ describe('claimAndLaunch', () => { } }); + it('returns substepId (not stepId) on idempotent claim of a delegated substep', async () => { + const delegation = { + tokenHash: MOCK_TOKEN_HASH, + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + contextSnapshot: { vars: {}, ancestors: [] }, + childRunId: 'existing-child-id', + createdAt: new Date().toISOString(), + cancelledAt: null, + }; + + const parentState = makeState(PARENT_RUN_ID, { + substepStates: [{ id: '1', status: 'pending', delegation }], + }); + + const mockScanner = { + findByToken: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ + parentState, + stepId: '2', // outer step id + substepId: '1', // delegation lives on substep 2.1 + delegation, + }), + }; + jest + .mocked(core.DelegationScanService) + .mockImplementation(() => mockScanner as unknown as jest.MockedObject); + + const mockManager = { + load: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(parentState), + }; + jest + .mocked(core.RunbookStateManager) + .mockImplementation(() => mockManager as unknown as jest.MockedObject); + + jest.mocked(core.DelegationLock).mockImplementation( + () => + ({ + acquire: mockFn<() => Promise>().mockResolvedValue(undefined), + release: mockFn<() => Promise>().mockResolvedValue(undefined), + }) as unknown as jest.MockedObject, + ); + + const claimSpy = mockClaimRunbookSuccess(); + const ctx = { + output: { status: jest.fn(), flush: jest.fn() } as unknown as OutputEmitter, + manager: mockManager as unknown as RunbookStateManager, + actorService: {} as unknown as RunbookActorService, + sessionService: { claimRunbook: claimSpy } as unknown as SessionService, + lifecycleService: {} as unknown as ExecutionLifecycleService, + cwd: '/test', + } satisfies RunPipelineContext; + + const { claimAndLaunch } = await import('../../src/helpers/runbook-pipeline.js'); + // cspell:disable-next-line + const result = await claimAndLaunch(ctx, 'rdtk_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH', {}); + + expect(result.ok).toBe(true); + if (result.ok) { + // ClaimResult.stepId contract: "Step (or substep) ID on the parent that + // holds the delegation". For a delegated substep, that's substepId, not + // the outer stepId. + expect(result.stepId).toBe('1'); + } + }); + it('returns error when delegation was cancelled', async () => { const delegation = { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: new Date().toISOString(), }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1383,7 +1779,10 @@ describe('claimAndLaunch', () => { output: {} as unknown as OutputEmitter, manager: mockManager as unknown as RunbookStateManager, actorService: {} as unknown as RunbookActorService, - sessionService: {} as unknown as SessionService, + sessionService: { + findClaimForDelegation: + mockFn().mockResolvedValue(null), + } as unknown as SessionService, lifecycleService: {} as unknown as ExecutionLifecycleService, cwd: '/test', } satisfies RunPipelineContext; @@ -1403,13 +1802,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1419,9 +1819,9 @@ describe('claimAndLaunch', () => { ], }); - const orphanState = makeState('orphan-id', { + const orphanState = makeState(ORPHAN_RUN_ID, { delegation: { - parentRunId: 'parent-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash, }, @@ -1457,11 +1857,7 @@ describe('claimAndLaunch', () => { }) as unknown as jest.MockedObject, ); - const claimSpy = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }); + const claimSpy = mockClaimRunbookSuccess(); const ctx = { output: { status: jest.fn(), flush: jest.fn() } as unknown as OutputEmitter, manager: mockManager as unknown as RunbookStateManager, @@ -1477,7 +1873,7 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.childRunId).toBe('orphan-id'); + expect(result.childRunId).toBe(ORPHAN_RUN_ID); } expect(mockManager.update).toHaveBeenCalled(); }); @@ -1486,13 +1882,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash: MOCK_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: 'existing-child-id', createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1529,12 +1926,7 @@ describe('claimAndLaunch', () => { }) as unknown as jest.MockedObject, ); - const claimSpy = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - claim: { - claimId: 'rdclm_abcdefghijklmnopqrstu1', - }, - }); + const claimSpy = mockClaimRunbookSuccess(); const mockSessionService = { claimRunbook: claimSpy, } as unknown as SessionService; @@ -1563,13 +1955,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1579,13 +1972,14 @@ describe('claimAndLaunch', () => { ], }); - const orphanState = makeState('orphan-id', { - parentLinkage: { - kind: 'delegation', - parentRunId: 'parent-id', - parentStepId: '1', - tokenHash, - }, + const orphanLinkage: DelegationLinkage = { + kind: 'delegation', + parentRunId: PARENT_RUN_ID, + parentStepId: '1', + tokenHash, + }; + const orphanState = makeState(ORPHAN_RUN_ID, { + parentLinkage: orphanLinkage, }); const mockScanner = { @@ -1618,13 +2012,13 @@ describe('claimAndLaunch', () => { }) as unknown as jest.MockedObject, ); - const claimSpy = mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ + const claimSpy = mockFn().mockResolvedValue({ status: 'linkage-mismatch', - childRunId: 'orphan-id', - persisted: orphanState.parentLinkage, + childRunId: ORPHAN_RUN_ID, + persisted: orphanLinkage, incoming: { kind: 'delegation', - parentRunId: 'different-parent-id', + parentRunId: DIFFERENT_PARENT_RUN_ID, parentStepId: '1', tokenHash, }, @@ -1649,7 +2043,7 @@ describe('claimAndLaunch', () => { expect(result.ok).toBe(false); if (!result.ok) { assertVariant(result, 'reason', 'linkage-mismatch'); - expect(result.childRunId).toBe('orphan-id'); + expect(result.childRunId).toBe(ORPHAN_RUN_ID); } expect(mockManager.update).not.toHaveBeenCalled(); }); @@ -1659,13 +2053,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1691,6 +2086,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); jest.mocked(parser.parseRunbookDocument).mockReturnValue(mockParseResult()); jest.mocked(core.reconstituteContextVars).mockReturnValue({}); @@ -1731,11 +2127,9 @@ describe('claimAndLaunch', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), + findClaimForDelegation: + mockFn().mockResolvedValue(null), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -1751,12 +2145,12 @@ describe('claimAndLaunch', () => { expect(result.loopResult).toBe('waiting'); } expect(mockCreate).toHaveBeenCalledWith( - 'child.md', + { source: 'project', path: 'child.md' }, expect.anything(), expect.objectContaining({ parentLinkage: expect.objectContaining({ kind: 'delegation', - parentRunId: 'parent-id', + parentRunId: PARENT_RUN_ID, tokenHash, }), }), @@ -1768,6 +2162,7 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: { RunId: 'parent-run', @@ -1782,7 +2177,7 @@ describe('claimAndLaunch', () => { cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1808,6 +2203,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); jest.mocked(parser.parseRunbookDocument).mockReturnValue(mockParseResult()); jest.mocked(resolveVariables).mockResolvedValue({ @@ -1849,11 +2245,9 @@ describe('claimAndLaunch', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), + findClaimForDelegation: + mockFn().mockResolvedValue(null), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -1889,6 +2283,7 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: { ContextId: 'ctx-parent', @@ -1903,7 +2298,7 @@ describe('claimAndLaunch', () => { cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -1929,6 +2324,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); jest.mocked(parser.parseRunbookDocument).mockReturnValue(mockParseResult()); jest.mocked(core.extractInheritedUserVars).mockReturnValue({ @@ -1980,11 +2376,9 @@ describe('claimAndLaunch', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), + findClaimForDelegation: + mockFn().mockResolvedValue(null), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -2013,13 +2407,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'missing.md', + childRunbookRef: { source: 'project', path: 'missing.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { substepStates: [ { id: '1', @@ -2042,7 +2437,11 @@ describe('claimAndLaunch', () => { .mocked(core.DelegationScanService) .mockImplementation(() => mockScanner as unknown as jest.MockedObject); - jest.mocked(resolveRunbookFile).mockResolvedValue(null); // File not found + jest.mocked(resolveRunbookRef).mockResolvedValue({ + ok: false, + reason: 'file-missing', + runbookRef: { source: 'project', path: 'missing.md' }, + }); const mockManager = { load: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(parentState), @@ -2064,7 +2463,10 @@ describe('claimAndLaunch', () => { output: {} as unknown as OutputEmitter, manager: mockManager as unknown as RunbookStateManager, actorService: {} as unknown as RunbookActorService, - sessionService: {} as unknown as SessionService, + sessionService: { + findClaimForDelegation: + mockFn().mockResolvedValue(null), + } as unknown as SessionService, lifecycleService: {} as unknown as ExecutionLifecycleService, cwd: '/test', } satisfies RunPipelineContext; @@ -2086,13 +2488,14 @@ describe('claimAndLaunch', () => { const delegation = { tokenHash, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: new Date().toISOString(), cancelledAt: null, }; - const parentState = makeState('parent-id', { + const parentState = makeState(PARENT_RUN_ID, { prompted: true, substepStates: [ { @@ -2119,6 +2522,7 @@ describe('claimAndLaunch', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/child.md', source: 'project', + sourceRoot: '/test', }); jest.mocked(parser.parseRunbookDocument).mockReturnValue(mockParseResult()); jest.mocked(core.reconstituteContextVars).mockReturnValue({}); @@ -2158,11 +2562,9 @@ describe('claimAndLaunch', () => { } as unknown as RunbookActorService, sessionService: { pushRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), - claimRunbook: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue({ - status: 'claimed', - // cspell:disable-next-line - claim: { claimId: 'rdclm_abcdefghijklmnopqrstu1' }, - }), + claimRunbook: mockClaimRunbookSuccess(), + findClaimForDelegation: + mockFn().mockResolvedValue(null), } as unknown as SessionService, lifecycleService: makeLifecycle() as unknown as ExecutionLifecycleService, cwd: '/test', @@ -2173,7 +2575,7 @@ describe('claimAndLaunch', () => { await claimAndLaunch(ctx, 'rdtk_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH', {}); expect(mockCreate).toHaveBeenCalledWith( - 'child.md', + { source: 'project', path: 'child.md' }, expect.anything(), expect.objectContaining({ prompted: true, diff --git a/packages/cli/__tests__/helpers/scenario-workflow.test.ts b/packages/cli/__tests__/helpers/scenario-workflow.test.ts index d2a932068..ca4cdd04a 100644 --- a/packages/cli/__tests__/helpers/scenario-workflow.test.ts +++ b/packages/cli/__tests__/helpers/scenario-workflow.test.ts @@ -151,6 +151,7 @@ describe('loadScenarios', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/runbook.md', source: 'project', + sourceRoot: '/test', }); setReadFileResolved('# No frontmatter'); setExtractFrontmatter(null, '# No frontmatter'); @@ -168,6 +169,7 @@ describe('loadScenarios', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/runbook.md', source: 'project', + sourceRoot: '/test', }); setReadFileResolved('---\nscenarios: bad\n---'); setExtractFrontmatter({ scenarios: 'bad' }, ''); @@ -188,6 +190,7 @@ describe('loadScenarios', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/runbook.md', source: 'project', + sourceRoot: '/test', }); setReadFileResolved('---\nname: test\n---'); setExtractFrontmatter({ name: 'test' }, ''); @@ -213,6 +216,7 @@ describe('loadScenarios', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/runbook.md', source: 'project', + sourceRoot: '/test', }); setReadFileResolved('---\nname: my-runbook\n---'); setExtractFrontmatter({ name: 'my-runbook', scenarios }, ''); @@ -236,6 +240,7 @@ describe('loadScenarios', () => { jest.mocked(resolveRunbookFile).mockResolvedValue({ path: '/test/runbook.md', source: 'project', + sourceRoot: '/test', }); setReadFileResolved('---\nscenarios:\n---'); setExtractFrontmatter({}, ''); diff --git a/packages/cli/__tests__/helpers/status-builder.test.ts b/packages/cli/__tests__/helpers/status-builder.test.ts index 73fea5991..5d818f2a6 100644 --- a/packages/cli/__tests__/helpers/status-builder.test.ts +++ b/packages/cli/__tests__/helpers/status-builder.test.ts @@ -3,6 +3,7 @@ import { mockErrorHelpers } from './mock-error-helpers.js'; import { brandDelegationTokenHashForTest, brandInitialTemplateVarsForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from './brand-helpers.js'; import { mockFn } from './typed-mocks.js'; @@ -11,6 +12,10 @@ import type * as CoreModule from '@rundown-org/core'; import type { BaseStep, ResolvedStep } from '@rundown-org/parser'; import type * as ExecutionModule from '../../src/services/execution.js'; +const PARENT_RUN_ID = brandRunIdForTest(`rd_${'9'.repeat(32)}`); +const SECOND_PARENT_RUN_ID = brandRunIdForTest(`rd_${'a'.repeat(32)}`); +const DEFAULT_RUN_ID = brandRunIdForTest(`rd_${'b'.repeat(32)}`); + // Mock @rundown-org/core jest.unstable_mockModule('@rundown-org/core', () => { const countNumberedSteps = mockFn(); @@ -79,8 +84,8 @@ const { buildInactiveStatus, buildStashedStatus, buildActiveStatus } = await imp function makeState(overrides: Partial = {}): RunbookState { const baseState: RunbookState = { - id: 'test-id', - runbook: 'test.runbook.md', + id: DEFAULT_RUN_ID, + runbook: { source: 'project', path: 'test.runbook.md' }, runbookPath: 'test.runbook.md', step: '1', stepName: 'First Step', @@ -287,7 +292,7 @@ describe('parentLinkage projection', () => { tokenHash: brandDelegationTokenHashForTest( 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', ), - parentRunId: 'parent-run-1', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', parentStep: '1', }, @@ -298,7 +303,7 @@ describe('parentLinkage projection', () => { expect(result.parentLinkage).toEqual({ kind: 'delegation', tokenHash: 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - parentRunId: 'parent-run-1', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', parentStep: '1', }); @@ -308,7 +313,7 @@ describe('parentLinkage projection', () => { const state = makeState({ parentLinkage: { kind: 'inline', - parentRunId: 'parent-run-1', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', }, }); @@ -317,7 +322,7 @@ describe('parentLinkage projection', () => { expect(result.parentLinkage).toEqual({ kind: 'inline', - parentRunId: 'parent-run-1', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', }); expect(result.parentLinkage).not.toHaveProperty('tokenHash'); @@ -331,16 +336,18 @@ describe('parentLinkage projection', () => { expect(result.parentLinkage).toBeUndefined(); }); - it('surfaces parentLinkage in stashed status', () => { + it('surfaces parentLinkage in stashed status and redacts vars for caller-scoped child', () => { const state = makeState({ parentLinkage: { kind: 'delegation', tokenHash: brandDelegationTokenHashForTest( 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', ), - parentRunId: 'parent-run-2', + parentRunId: SECOND_PARENT_RUN_ID, parentStepId: '2.1', }, + templateVars: brandInitialTemplateVarsForTest({ secret: 'inherited-from-parent' }), + variables: brandStoredOutputsForTest({ output_value: 'child-output' }), }); const result = buildStashedStatus(state, '/test'); @@ -348,9 +355,23 @@ describe('parentLinkage projection', () => { expect(result.parentLinkage).toEqual({ kind: 'delegation', tokenHash: 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', - parentRunId: 'parent-run-2', + parentRunId: SECOND_PARENT_RUN_ID, parentStepId: '2.1', }); + // Caller-scoped (parentLinkage set) → vars must be redacted from stashed status. + expect(result.vars).toBeUndefined(); + }); + + it('surfaces vars in stashed status when no parentLinkage is set', () => { + const state = makeState({ + templateVars: brandInitialTemplateVarsForTest({ visible: 'value' }), + variables: brandStoredOutputsForTest(), + }); + + const result = buildStashedStatus(state, '/test'); + + expect(result.parentLinkage).toBeUndefined(); + expect(result.vars).toEqual(expect.objectContaining({ visible: 'value' })); }); }); diff --git a/packages/cli/__tests__/helpers/test-utils.test.ts b/packages/cli/__tests__/helpers/test-utils.test.ts index a070946e5..ce535d0ca 100644 --- a/packages/cli/__tests__/helpers/test-utils.test.ts +++ b/packages/cli/__tests__/helpers/test-utils.test.ts @@ -1,9 +1,11 @@ -import { readdir } from 'node:fs/promises'; +import { readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { describe, it, expect } from '@jest/globals'; import { createRunbook, createTestWorkspace, parseConcatenatedJson, + readRunbookState, runCliInProcess, stripExitArtefact, } from './test-utils.js'; @@ -383,6 +385,111 @@ describe('parseConcatenatedJson', () => { }); }); +describe('readRunbookState', () => { + it('accepts persisted states that reference external runbooks', async () => { + const workspace = await createTestWorkspace({ fixtureDir: 'snapshots' }); + try { + const runId = `rd_${'a'.repeat(32)}`; + const state = { + id: runId, + runbook: { source: 'external', path: '/tmp/external.runbook.md' }, + runbookPath: '/tmp/external.runbook.md', + step: '1', + stepName: 'External step', + retryCount: 0, + variables: {}, + steps: [], + startedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await writeFile(join(workspace.statePath(), `${runId}.json`), JSON.stringify(state)); + + await expect(readRunbookState(workspace, runId)).resolves.toEqual( + expect.objectContaining({ + id: runId, + runbook: { source: 'external', path: '/tmp/external.runbook.md' }, + }), + ); + } finally { + await workspace.cleanup(); + } + }); + + it('rejects persisted states whose ids are not canonical run ids', async () => { + const workspace = await createTestWorkspace({ fixtureDir: 'snapshots' }); + try { + const state = { + id: 'wf_legacy', + runbook: { source: 'project', path: 'legacy.runbook.md' }, + runbookPath: 'legacy.runbook.md', + step: '1', + stepName: 'Legacy step', + retryCount: 0, + variables: {}, + steps: [], + startedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await writeFile(join(workspace.statePath(), 'wf_legacy.json'), JSON.stringify(state)); + + await expect(readRunbookState(workspace, 'wf_legacy')).resolves.toBeNull(); + } finally { + await workspace.cleanup(); + } + }); + + it('returns null when filename id does not match embedded state.id', async () => { + const workspace = await createTestWorkspace({ fixtureDir: 'snapshots' }); + try { + const filenameId = `rd_${'a'.repeat(32)}`; + const embeddedId = `rd_${'b'.repeat(32)}`; + const state = { + id: embeddedId, + runbook: { source: 'project', path: 'x.md' }, + runbookPath: 'x.md', + step: '1', + stepName: 'Step', + retryCount: 0, + variables: {}, + steps: [], + startedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await writeFile(join(workspace.statePath(), `${filenameId}.json`), JSON.stringify(state)); + + await expect(readRunbookState(workspace, filenameId)).resolves.toBeNull(); + } finally { + await workspace.cleanup(); + } + }); + + it('returns the parsed state when filename id matches embedded state.id', async () => { + const workspace = await createTestWorkspace({ fixtureDir: 'snapshots' }); + try { + const id = `rd_${'c'.repeat(32)}`; + const state = { + id, + runbook: { source: 'project', path: 'x.md' }, + runbookPath: 'x.md', + step: '1', + stepName: 'Step', + retryCount: 0, + variables: {}, + steps: [], + startedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await writeFile(join(workspace.statePath(), `${id}.json`), JSON.stringify(state)); + + await expect(readRunbookState(workspace, id)).resolves.toEqual( + expect.objectContaining({ id }), + ); + } finally { + await workspace.cleanup(); + } + }); +}); + describe('createTestWorkspace fixtureDir option', () => { it('copies only the named subdirectory into all three runbook destinations', async () => { const workspace = await createTestWorkspace({ fixtureDir: 'snapshots' }); diff --git a/packages/cli/__tests__/helpers/test-utils.ts b/packages/cli/__tests__/helpers/test-utils.ts index 8a6fa700d..02f06d385 100644 --- a/packages/cli/__tests__/helpers/test-utils.ts +++ b/packages/cli/__tests__/helpers/test-utils.ts @@ -12,6 +12,8 @@ import { setWriter, ConsoleWriter, getErrorMessage, + RUNBOOK_SOURCES, + isRunId, runsDir, sessionPath as _sessionPath, runbooksDir, @@ -498,9 +500,16 @@ function isRunbookState(value: unknown): value is RunbookState { variables?: unknown; steps?: unknown; }; + const runbook = state.runbook; + if (typeof runbook !== 'object' || runbook === null) return false; + const runbookRef = runbook as { source?: unknown; path?: unknown }; + const hasKnownSource = + typeof runbookRef.source === 'string' && + (RUNBOOK_SOURCES as readonly string[]).includes(runbookRef.source); return ( - typeof state.id === 'string' && - typeof state.runbook === 'string' && + isRunId(state.id) && + hasKnownSource && + typeof runbookRef.path === 'string' && typeof state.runbookPath === 'string' && typeof state.step === 'string' && typeof state.stepName === 'string' && @@ -521,7 +530,9 @@ export async function readRunbookState( try { const content = await readFile(join(workspace.statePath(), `${id}.json`), 'utf-8'); const parsed = JSON.parse(content) as unknown; - return isRunbookState(parsed) ? parsed : null; + if (!isRunbookState(parsed)) return null; + if (parsed.id !== id) return null; + return parsed; } catch { return null; } @@ -1072,20 +1083,20 @@ export function buildSubstep(overrides: Partial = {}): Substep { * 3. Delegation tokens (`rdtk_` + alnum, including truncated `rdtk_XXX...YYYY`) → `` * 4. SHA-256 hex digests (e.g. delegation token_hash field) → `` * 5. Full UUIDs → `` - * 6. Runbook state IDs of the form `wf-YYYY-MM-DD-xxxxxx` (base-36 suffix) → `` + * 6. Runbook state IDs of the form `rd_<32 lowercase hex>` → `` * 7. Numeric `"startedAt"` / `"completedAt"` / `"expiresAt"` / etc. epoch ms field values → `` * 8. `"durationMs"` / `"took"` numeric field values → `` * 9. Any 8-char lowercase hex string at word boundaries → `` (see note) * 10. ISO 8601 timestamps → `` * 11. PID banners → `` * - * Rule 9 note: the 8-hex rule is the catch-all for built-in template variables - * `{{RunId}}` and `{{ContextId}}` (both `randomBytes(4).toString('hex')`, see + * Rule 9 note: the 8-hex rule is the catch-all for the built-in `{{ContextId}}` + * template variable (`randomBytes(4).toString('hex')`, see * `packages/cli/src/services/variable-discovery.ts`). It is deliberately * aggressive and will ALSO mask git short SHAs, step-frame hashes, and any - * 8-hex token that happens to appear in user prompt text. Rules 7 and 8 - * (field-scoped epoch ms and duration) run BEFORE this rule so pure-digit - * 8-char values in known fields aren't stolen by the generic hex8 pattern. + * 8-hex token that happens to appear in user prompt text. Rules 6, 7, and 8 + * run BEFORE this rule so concrete run IDs, pure-digit epoch ms values in + * known fields, and durations aren't stolen by the generic hex8 pattern. * When reviewing a snapshot diff, confirm each `` substitution is * legitimate — anything unexpected masked by this rule is a signal, not noise. * @@ -1149,8 +1160,8 @@ export function normalizeCliOutput(output: string, workspace: TestWorkspace): st '', ); - // 6. Runbook state IDs of the form `wf-YYYY-MM-DD-xxxxxx` (base-36 suffix, 1-6 chars) - text = text.replace(/\bwf-\d{4}-\d{2}-\d{2}-[a-z0-9]{1,6}\b/g, ''); + // 6. Runbook state IDs of the form `rd_<32 lowercase hex>`. + text = text.replace(/\brd_[a-f0-9]{32}\b/g, ''); // 7. Numeric epoch ms for known fields (field-scoped so we don't eat arbitrary numbers). // Runs BEFORE rule 9 so an 8-digit epoch ms value isn't stolen by the @@ -1165,13 +1176,13 @@ export function normalizeCliOutput(output: string, workspace: TestWorkspace): st text = text.replace(/"durationMs":\s*\d+/g, '"durationMs": '); text = text.replace(/"took":\s*\d+/g, '"took": '); - // 9. Any 8-char lowercase hex at word boundaries — the catch-all for - // {{RunId}} and {{ContextId}} template variables (both 4 random bytes - // rendered as hex, see variable-discovery.ts). Deliberately aggressive: - // also masks git short SHAs, step-frame hashes, and 8-hex tokens in - // user prompt text. Rules 5, 6, 7, and 8 run first so UUIDs, wf-* ids, - // field-scoped epoch ms, and duration values are preserved. Review - // each `` substitution in snapshot diffs. + // 9. Any 8-char lowercase hex at word boundaries — the catch-all for the + // {{ContextId}} template variable (4 random bytes rendered as hex, see + // variable-discovery.ts). Deliberately aggressive: also masks git short + // SHAs, step-frame hashes, and 8-hex tokens in user prompt text. Rules + // 5, 6, 7, and 8 run first so UUIDs, run IDs, field-scoped epoch ms, and + // duration values are preserved. Review each `` substitution in + // snapshot diffs. text = text.replace(/\b[0-9a-f]{8}\b/g, ''); // 10. ISO 8601 timestamps (with or without fractional seconds, Z or ±HH:MM) diff --git a/packages/cli/__tests__/integration/__snapshots__/scenario-snapshots.test.ts.snap b/packages/cli/__tests__/integration/__snapshots__/scenario-snapshots.test.ts.snap index 892cf326f..eb62afac2 100644 --- a/packages/cli/__tests__/integration/__snapshots__/scenario-snapshots.test.ts.snap +++ b/packages/cli/__tests__/integration/__snapshots__/scenario-snapshots.test.ts.snap @@ -2,12 +2,12 @@ exports[`scenario output snapshots delegation-outputs JSON 1`] = ` "=== command: rd run snapshot-delegation-outputs.runbook.md === -{"type":"runbook_started","title":"Delegation with Outputs","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":2},"stepName":"1","description":"Set message","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":2} -{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":2},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":3} -{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":2},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":4} -{"type":"step_transitioned","action":"CONTINUE","from":"1","at":"2.1","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":5} -{"type":"step_entered","position":{"current":"2","total":2,"substep":"1"},"stepName":"1","description":"Child task","prompt":"Delegated to child runbook.","hasCommand":false,"isSubstep":true,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-delegation-outputs.runbook.md"},"seq":6} +{"type":"runbook_started","title":"Delegation with Outputs","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":2},"stepName":"1","description":"Set message","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":2} +{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":2},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":3} +{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":2},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":4} +{"type":"step_transitioned","action":"CONTINUE","from":"1","at":"2.1","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":5} +{"type":"step_entered","position":{"current":"2","total":2,"substep":"1"},"stepName":"1","description":"Child task","prompt":"Delegated to child runbook.","hasCommand":false,"isSubstep":true,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-delegation-outputs.runbook.md"},"seq":6} === command: rd delegate snapshot-child.runbook.md --step 2.1 === { @@ -21,15 +21,15 @@ exports[`scenario output snapshots delegation-outputs JSON 1`] = ` } === command: rd claim === -{"type":"runbook_started","title":"Snapshot Child","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-child.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Child step","prompt":"The message is: hello from snapshot parent","hasCommand":false,"isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-child.runbook.md"},"seq":2} +{"type":"runbook_started","title":"Snapshot Child","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-child.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Child step","prompt":"The message is: hello from snapshot parent","hasCommand":false,"isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-child.runbook.md"},"seq":2} {"action":"claimed","message":"Claimed -> /.rundown/runbooks/snapshot-child.runbook.md","token":"","claim_id":"","run_id":"","runbook":"/.rundown/runbooks/snapshot-child.runbook.md","parent_run_id":"","parent_step":"2.1","kind":"action"} " `; exports[`scenario output snapshots delegation-outputs text 1`] = ` "=== command: rd run snapshot-delegation-outputs.runbook.md --text === -File: snapshot-delegation-outputs.runbook.md +File: .rundown/runbooks/snapshot-delegation-outputs.runbook.md State: .rundown/runs/.json Action: START @@ -56,7 +56,7 @@ Token: RD_CLAIM_TOKEN= === command: rd claim --text === -File: snapshot-child.runbook.md +File: .rundown/runbooks/snapshot-child.runbook.md State: .rundown/runs/.json Action: START @@ -68,25 +68,25 @@ Claimed -> /.rundown/runbooks/snapshot-child.runbook.md `; exports[`scenario output snapshots multi-step JSON 1`] = ` -"{"type":"runbook_started","title":"Multi Step","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":3},"stepName":"1","description":"First","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":2} -{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":3} -{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":4} -{"type":"step_transitioned","action":"CONTINUE","from":"1","at":"2","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":5} -{"type":"step_entered","position":{"current":"2","total":3},"stepName":"2","description":"Second","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":6} -{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"2","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":7} -{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"2","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":8} -{"type":"step_transitioned","action":"CONTINUE","from":"2","at":"3","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":9} -{"type":"step_entered","position":{"current":"3","total":3},"stepName":"3","description":"Third","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":10} -{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":11} -{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":12} -{"type":"step_transitioned","action":"COMPLETE","from":"3","at":"3","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":13} -{"type":"runbook_completed","finalPosition":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-multi-step.runbook.md"},"seq":14} +"{"type":"runbook_started","title":"Multi Step","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":3},"stepName":"1","description":"First","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":2} +{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":3} +{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":4} +{"type":"step_transitioned","action":"CONTINUE","from":"1","at":"2","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":5} +{"type":"step_entered","position":{"current":"2","total":3},"stepName":"2","description":"Second","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":6} +{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"2","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":7} +{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"2","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":8} +{"type":"step_transitioned","action":"CONTINUE","from":"2","at":"3","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":9} +{"type":"step_entered","position":{"current":"3","total":3},"stepName":"3","description":"Third","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":10} +{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":11} +{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":12} +{"type":"step_transitioned","action":"COMPLETE","from":"3","at":"3","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":13} +{"type":"runbook_completed","finalPosition":{"current":"3","total":3},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-multi-step.runbook.md"},"seq":14} " `; exports[`scenario output snapshots multi-step text 1`] = ` -"File: snapshot-multi-step.runbook.md +"File: .rundown/runbooks/snapshot-multi-step.runbook.md State: .rundown/runs/.json Action: START @@ -130,21 +130,21 @@ Runbook: COMPLETE `; exports[`scenario output snapshots retry JSON 1`] = ` -"{"type":"runbook_started","title":"Retry","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Flaky step","hasCommand":true,"commandCode":"rd echo --result fail --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":2} -{"type":"command_started","command":"rd echo --result fail --result pass","displayCommand":"rd echo --result fail --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":3} -{"type":"command_completed","command":"rd echo --result fail --result pass","success":false,"exitCode":1,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":4} -{"type":"step_transitioned","action":"RETRY","from":"1","at":"1","result":"FAIL","command":"rd echo --result fail --result pass","retryAttempt":1,"retryMax":1,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":5} -{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Flaky step","hasCommand":true,"commandCode":"rd echo --result fail --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":6} -{"type":"command_started","command":"rd echo --result fail --result pass","displayCommand":"rd echo --result fail --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":7} -{"type":"command_completed","command":"rd echo --result fail --result pass","success":true,"exitCode":0,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":8} -{"type":"step_transitioned","action":"COMPLETE","from":"1","at":"1","result":"PASS","command":"rd echo --result fail --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":9} -{"type":"runbook_completed","finalPosition":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-retry.runbook.md"},"seq":10} +"{"type":"runbook_started","title":"Retry","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Flaky step","hasCommand":true,"commandCode":"rd echo --result fail --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":2} +{"type":"command_started","command":"rd echo --result fail --result pass","displayCommand":"rd echo --result fail --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":3} +{"type":"command_completed","command":"rd echo --result fail --result pass","success":false,"exitCode":1,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":4} +{"type":"step_transitioned","action":"RETRY","from":"1","at":"1","result":"FAIL","command":"rd echo --result fail --result pass","retryAttempt":1,"retryMax":1,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":5} +{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Flaky step","hasCommand":true,"commandCode":"rd echo --result fail --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":6} +{"type":"command_started","command":"rd echo --result fail --result pass","displayCommand":"rd echo --result fail --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":7} +{"type":"command_completed","command":"rd echo --result fail --result pass","success":true,"exitCode":0,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":8} +{"type":"step_transitioned","action":"COMPLETE","from":"1","at":"1","result":"PASS","command":"rd echo --result fail --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":9} +{"type":"runbook_completed","finalPosition":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-retry.runbook.md"},"seq":10} " `; exports[`scenario output snapshots retry text 1`] = ` -"File: snapshot-retry.runbook.md +"File: .rundown/runbooks/snapshot-retry.runbook.md State: .rundown/runs/.json Action: START @@ -176,17 +176,17 @@ Runbook: COMPLETE `; exports[`scenario output snapshots simple-complete JSON 1`] = ` -"{"type":"runbook_started","title":"Simple Complete","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Single step","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":2} -{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":3} -{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":4} -{"type":"step_transitioned","action":"COMPLETE","from":"1","at":"1","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":5} -{"type":"runbook_completed","finalPosition":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-complete.runbook.md"},"seq":6} +"{"type":"runbook_started","title":"Simple Complete","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Single step","hasCommand":true,"commandCode":"rd echo --result pass","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":2} +{"type":"command_started","command":"rd echo --result pass","displayCommand":"rd echo --result pass","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":3} +{"type":"command_completed","command":"rd echo --result pass","success":true,"exitCode":0,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":4} +{"type":"step_transitioned","action":"COMPLETE","from":"1","at":"1","result":"PASS","command":"rd echo --result pass","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":5} +{"type":"runbook_completed","finalPosition":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-complete.runbook.md"},"seq":6} " `; exports[`scenario output snapshots simple-complete text 1`] = ` -"File: snapshot-simple-complete.runbook.md +"File: .rundown/runbooks/snapshot-simple-complete.runbook.md State: .rundown/runs/.json Action: START @@ -206,17 +206,17 @@ Runbook: COMPLETE `; exports[`scenario output snapshots simple-stop JSON 1`] = ` -"{"type":"runbook_started","title":"Simple Stop","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":1} -{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Single step","hasCommand":true,"commandCode":"rd echo --result fail","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":2} -{"type":"command_started","command":"rd echo --result fail","displayCommand":"rd echo --result fail","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":3} -{"type":"command_completed","command":"rd echo --result fail","success":false,"exitCode":1,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":4} -{"type":"step_transitioned","action":"STOP","from":"1","at":"1","result":"FAIL","command":"rd echo --result fail","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":5} -{"type":"runbook_stopped","position":{"current":"1","total":1},"reason":"fail_transition","timestamp":"","runbookId":"","runbook":{"source":"project","path":"snapshot-simple-stop.runbook.md"},"seq":6} +"{"type":"runbook_started","title":"Simple Stop","prompted":false,"statePath":".rundown/runs/.json","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":1} +{"type":"step_entered","position":{"current":"1","total":1},"stepName":"1","description":"Single step","hasCommand":true,"commandCode":"rd echo --result fail","commandLang":"bash","isSubstep":false,"prompted":false,"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":2} +{"type":"command_started","command":"rd echo --result fail","displayCommand":"rd echo --result fail","position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":3} +{"type":"command_completed","command":"rd echo --result fail","success":false,"exitCode":1,"position":{"current":"1","total":1},"timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":4} +{"type":"step_transitioned","action":"STOP","from":"1","at":"1","result":"FAIL","command":"rd echo --result fail","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":5} +{"type":"runbook_stopped","position":{"current":"1","total":1},"reason":"fail_transition","timestamp":"","runbookId":"","runbook":{"source":"project","path":".rundown/runbooks/snapshot-simple-stop.runbook.md"},"seq":6} " `; exports[`scenario output snapshots simple-stop text 1`] = ` -"File: snapshot-simple-stop.runbook.md +"File: .rundown/runbooks/snapshot-simple-stop.runbook.md State: .rundown/runs/.json Action: START diff --git a/packages/cli/__tests__/integration/delegate-workflow.test.ts b/packages/cli/__tests__/integration/delegate-workflow.test.ts index 0c0dcc931..64de659a6 100644 --- a/packages/cli/__tests__/integration/delegate-workflow.test.ts +++ b/packages/cli/__tests__/integration/delegate-workflow.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { createTestWorkspace, createRunbook, @@ -405,6 +405,44 @@ describe('DELEGATE backward compatibility — manual rd delegate --step alongsid expect(ss2?.delegation).toBeUndefined(); }); + it('claims an explicit absolute child runbook outside the current project', async () => { + await writeDelegateRunbook(); + const externalDir = await mkdtemp(join(dirname(workspace.cwd), 'rd-external-child-')); + try { + const externalChildPath = join(externalDir, 'external-child.runbook.md'); + await writeFile( + externalChildPath, + createRunbook({ + title: 'External Child', + steps: [{ title: 'Do work', pass: 'COMPLETE', command: 'rd echo --result pass' }], + }), + ); + + const start = await runCliInProcess( + 'run --prompted runbooks/parent.runbook.md --text', + workspace, + ); + expect(start.exitCode).toBe(0); + + const manual = await runCliInProcess( + ['delegate', externalChildPath, '--step', '1.1'], + workspace, + ); + expect(manual.exitCode).toBe(0); + const delegateOutput = JSON.parse(manual.stdout) as Record; + const token = String(delegateOutput.token); + expect(token.startsWith('rdtk_')).toBe(true); + + const claim = await runCliInProcess(['claim', token], workspace); + expect(claim.exitCode).toBe(0); + const claimOutput = findActionOutput(claim.stdout); + expect(claimOutput).not.toBeNull(); + expect(claimOutput!.runbook).toBe(externalChildPath); + } finally { + await rm(externalDir, { recursive: true, force: true }); + } + }); + it('after auto-delegation, rd delegate --step on a remaining substep is rejected with already-delegated error', async () => { // This variant DOES include `- DELEGATE` so auto-delegation fires on // step entry, producing delegation records for both substeps. A diff --git a/packages/cli/__tests__/integration/scenario-runner.test.ts b/packages/cli/__tests__/integration/scenario-runner.test.ts index e0eeef218..856e17f0f 100644 --- a/packages/cli/__tests__/integration/scenario-runner.test.ts +++ b/packages/cli/__tests__/integration/scenario-runner.test.ts @@ -287,12 +287,12 @@ async function executeScenario( const expectedName = filename.split('/').pop()!; const matchingStates = states.filter((s) => { - const runbookPath = s.runbook; + const runbookPath = s.runbook.path; return runbookPath.endsWith(expectedName); }); if (matchingStates.length === 0) { - const allRunbookPaths = states.map((s) => s.runbook).join(', '); + const allRunbookPaths = states.map((s) => s.runbook.path).join(', '); throw new Error(`No state found for runbook ${filename}. Found paths: [${allRunbookPaths}]`); } diff --git a/packages/cli/__tests__/services/execution-helpers.test.ts b/packages/cli/__tests__/services/execution-helpers.test.ts index 59ce3b69d..f219dd5e5 100644 --- a/packages/cli/__tests__/services/execution-helpers.test.ts +++ b/packages/cli/__tests__/services/execution-helpers.test.ts @@ -18,8 +18,8 @@ import type { ResolvedStep, OutputDeclaration, Substep } from '@rundown-org/pars function makeState(step: string, forStack: readonly ForContext[] = []): RunbookState { return { - id: 'test-run', - runbook: 'test.md', + id: 'test-run' as RunbookState['id'], + runbook: { source: 'project', path: 'test.md' }, runbookPath: '/test.md', step, stepName: `Step ${step}`, diff --git a/packages/cli/__tests__/services/execution-loop.test.ts b/packages/cli/__tests__/services/execution-loop.test.ts index eff495ea0..2ab1099ff 100644 --- a/packages/cli/__tests__/services/execution-loop.test.ts +++ b/packages/cli/__tests__/services/execution-loop.test.ts @@ -94,6 +94,7 @@ jest.unstable_mockModule('@rundown-org/core', () => { executeCommand: jest.fn(), executeCommandWithEnv: (jest.fn() as any).mockResolvedValue({ success: true, exitCode: 0 }), executeCommandWithPolicy: jest.fn(), + assertRunId: jest.fn((value: string) => value), evaluatePassCondition: jest.fn(), evaluateFailCondition: jest.fn(), extractLastAction: jest.fn((snapshot: any) => snapshot?.context?.lastAction), @@ -244,6 +245,7 @@ jest.unstable_mockModule('@rundown-org/core', () => { CONFIG_FILE: '.rundown/config.yaml', isJsonValue: jest.fn((v: unknown) => v != null), createJsonArrayStream: jest.fn(), + generateRunId: jest.fn(() => 'rd_0123456789abcdef0123456789abcdef'), createDelegation: jest.fn(), Errors: RealErrors, RundownError: RealRundownError, @@ -272,6 +274,13 @@ jest.unstable_mockModule('../../src/helpers/delegate-inference', () => ({ jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => ({ resolveRunbookFile: (jest.fn() as any).mockResolvedValue(null), + buildRunbookRef: jest.fn((resolved: { source: string; path: string; sourceRoot?: string }) => ({ + source: resolved.source, + path: + resolved.sourceRoot && resolved.path.startsWith(`${resolved.sourceRoot}/`) + ? resolved.path.slice(resolved.sourceRoot.length + 1) + : resolved.path, + })), })); jest.unstable_mockModule('../../src/services/internal-commands', () => ({ @@ -336,7 +345,7 @@ const asSteps = (s: readonly LooseStep[]): ResolvedStepType[] => s as unknown as describe('runExecutionLoop', () => { let mockManager: MockManagerLike; let mockEmitter: MockEmitterLike; - const runbookId = 'test-run-123'; + const runbookId = `rd_${'1'.repeat(32)}`; const steps: LooseStep[] = [ { kind: 'command', @@ -359,6 +368,18 @@ describe('runExecutionLoop', () => { }, }, ]; + const makeLoopState = ( + step = '1', + overrides: Record = {}, + ): Record => ({ + id: runbookId, + runbook: { source: 'project', path: 'test.runbook.md' }, + runbookPath: 'test.runbook.md', + step, + status: 'running', + templateVars: { RunId: runbookId, RunbookRef: { source: 'project', path: 'test.runbook.md' } }, + ...overrides, + }); beforeEach(() => { jest.clearAllMocks(); @@ -370,6 +391,12 @@ describe('runExecutionLoop', () => { mockedPolicyContext.isPolicyEnforced.mockReturnValue(false); mockedPolicyContext.getSandboxOptions.mockReturnValue({ sandbox: true, sandboxStrict: false }); + mockedPolicyContext.getPolicyEvaluator.mockReturnValue({ + setRunbookPath: jest.fn(), + } as unknown as ReturnType); + mockedPolicyContext.getPolicyPrompter.mockReturnValue( + {} as unknown as ReturnType, + ); (core.executeCommand as any).mockReset(); (core.executeCommandWithPolicy as any).mockReset(); @@ -434,11 +461,7 @@ describe('runExecutionLoop', () => { }); it('returns waiting if prompted mode is on', async () => { - mockManager.load.mockResolvedValue({ - id: runbookId, - step: '1', - status: 'running', - }); + mockManager.load.mockResolvedValue(makeLoopState()); const result = await runExecutionLoop( asManager(mockManager), @@ -468,11 +491,7 @@ describe('runExecutionLoop', () => { transitions: { pass: { next: 'COMPLETE' } }, }, ]; - mockManager.load.mockResolvedValue({ - id: runbookId, - step: '1', - status: 'running', - }); + mockManager.load.mockResolvedValue(makeLoopState()); const result = await runExecutionLoop( asManager(mockManager), @@ -488,13 +507,11 @@ describe('runExecutionLoop', () => { it('executes command and advances to next step', async () => { mockManager.load - .mockResolvedValueOnce({ id: runbookId, step: '1', status: 'running' }) - .mockResolvedValueOnce({ id: runbookId, step: '2', status: 'running' }); - - jest.mocked(core.executeCommand).mockResolvedValue({ success: true, exitCode: 0 }); + .mockResolvedValueOnce(makeLoopState('1')) + .mockResolvedValueOnce(makeLoopState('2')); mockActorService.sendAndSync.mockResolvedValue({ - state: { id: runbookId, step: '2', status: 'running' }, + state: makeLoopState('2'), snapshot: { status: 'active', value: '2', @@ -522,22 +539,23 @@ describe('runExecutionLoop', () => { ); expect(result).toBe('waiting'); - expect(core.executeCommand).toHaveBeenCalled(); + expect(core.executeCommandWithEnv).toHaveBeenCalled(); }); - it('injects RD_WORK_PATH, RD_CONTEXT_ID, and RD_RUN_ID together when all three are strings in stepVars', async () => { + it('injects canonical RD_RUN_ID from template vars and persisted runbook identity', async () => { // Downstream tools (e.g. rdpath) treat these env vars as a structurally // paired triple. Asserting them here keeps a regression in the injection // gates at execution.ts (`if (typeof workPath === 'string') ...`) from // silently dropping one half of the pair without any test failing. mockManager.load.mockResolvedValue({ id: runbookId, + runbook: { source: 'project', path: 'test.runbook.md' }, step: '1', status: 'running', templateVars: { WorkPath: '/tmp/work', ContextId: 'ctx-abc', - RunId: 'run-123', + RunId: runbookId, }, }); @@ -550,10 +568,11 @@ describe('runExecutionLoop', () => { step: '1', status: 'done', variables: {}, + runbook: { source: 'project', path: 'test.runbook.md' }, templateVars: { WorkPath: '/tmp/work', ContextId: 'ctx-abc', - RunId: 'run-123', + RunId: runbookId, }, }, snapshot: { @@ -576,11 +595,67 @@ describe('runExecutionLoop', () => { const envArg = jest.mocked(core.executeCommandWithEnv).mock.calls[0][2]; expect(envArg.RD_WORK_PATH).toBe('/tmp/work'); expect(envArg.RD_CONTEXT_ID).toBe('ctx-abc'); - expect(envArg.RD_RUN_ID).toBe('run-123'); + expect(envArg.RD_RUN_ID).toBe(runbookId); + expect(envArg.RD_RUNBOOK_REF).toBe('test.runbook.md'); + expect(envArg.RD_RUNBOOK_SOURCE).toBe('project'); + }); + + it.each([ + { source: 'plugin', path: 'planning/review.runbook.md' }, + { source: 'external', path: '/tmp/review.runbook.md' }, + ])('injects persisted $source runbook identity into RD env', async (runbook) => { + mockManager.load.mockResolvedValue({ + id: runbookId, + runbook, + step: '1', + status: 'running', + templateVars: { + WorkPath: '/tmp/work', + ContextId: 'ctx-abc', + RunId: runbookId, + }, + }); + + jest.mocked(core.executeCommand).mockResolvedValue({ success: true, exitCode: 0 }); + jest.mocked(core.executeCommandWithEnv).mockResolvedValue({ success: true, exitCode: 0 }); + + mockActorService.sendAndSync.mockResolvedValue({ + state: { + id: runbookId, + step: '1', + status: 'done', + variables: {}, + runbook, + templateVars: { + WorkPath: '/tmp/work', + ContextId: 'ctx-abc', + RunId: runbookId, + }, + }, + snapshot: { + status: 'done', + value: 'COMPLETE', + context: { lastAction: { type: 'COMPLETE' } }, + }, + }); + + await runExecutionLoop( + asManager(mockManager), + runbookId, + asSteps([steps[0]]), + '/tmp', + false, + asEmitter(mockEmitter), + ); + + expect(core.executeCommandWithEnv).toHaveBeenCalledTimes(1); + const envArg = jest.mocked(core.executeCommandWithEnv).mock.calls[0][2]; + expect(envArg.RD_RUNBOOK_REF).toBe(runbook.path); + expect(envArg.RD_RUNBOOK_SOURCE).toBe(runbook.source); }); it('handles policy denial', async () => { - mockManager.load.mockResolvedValue({ id: runbookId, step: '1', status: 'running' }); + mockManager.load.mockResolvedValue(makeLoopState()); jest.mocked(policyContext.isPolicyEnforced).mockReturnValue(true); // ExecutionResult requires `exitCode`; this fixture omits it because @@ -610,7 +685,7 @@ describe('runExecutionLoop', () => { }); it('stops with policy-denied when prepareIteration throws ForResolutionError policy-violation', async () => { - mockManager.load.mockResolvedValue({ id: runbookId, step: '1', status: 'running' }); + mockManager.load.mockResolvedValue(makeLoopState()); (core.ForIterationService as unknown as jest.Mock).mockImplementation(() => { const prepareIteration = mockFn<(...args: unknown[]) => Promise<{ status: string }>>(); @@ -656,7 +731,7 @@ describe('runExecutionLoop', () => { }); it('completes the runbook', async () => { - mockManager.load.mockResolvedValue({ id: runbookId, step: '1', status: 'running' }); + mockManager.load.mockResolvedValue(makeLoopState()); jest.mocked(core.executeCommand).mockResolvedValue({ success: true, exitCode: 0 }); mockActorService.sendAndSync.mockResolvedValue({ @@ -690,7 +765,7 @@ describe('runExecutionLoop', () => { message: 'Success', }), ); - expect(mockSessionService.releaseRunbook).toHaveBeenCalledWith('test-run-123'); + expect(mockSessionService.releaseRunbook).toHaveBeenCalledWith(runbookId); expect(mockSessionService.popRunbook).not.toHaveBeenCalled(); }); @@ -699,7 +774,7 @@ describe('runExecutionLoop', () => { // lastAction variant on the returned snapshot. runExecutionLoop should // emit ERROR_OCCURRED with the hook error's code + message before the // terminal RUNBOOK_STOPPED event. - mockManager.load.mockResolvedValue({ id: runbookId, step: '1', status: 'running' }); + mockManager.load.mockResolvedValue(makeLoopState()); jest.mocked(core.executeCommand).mockResolvedValue({ success: false, exitCode: 1 }); mockActorService.sendAndSync.mockResolvedValue({ @@ -1012,28 +1087,15 @@ describe('runExecutionLoop', () => { // orchestrateTransition calls manager.load once more (for the reloaded continue state), // so we need two sequential returns: step 1 (initial load) → step 2 (reload after transition). mockManager.load - .mockResolvedValueOnce({ - id: runbookId, - step: '1', - status: 'running', - templateVars: { ContextId: 'ctx-unit' }, - }) - .mockResolvedValueOnce({ - id: runbookId, - step: '2', - status: 'running', - templateVars: { ContextId: 'ctx-unit' }, - }); + .mockResolvedValueOnce(makeLoopState('1', { templateVars: { ContextId: 'ctx-unit' } })) + .mockResolvedValueOnce(makeLoopState('2', { templateVars: { ContextId: 'ctx-unit' } })); jest.mocked(core.executeCommand).mockResolvedValue({ success: true, exitCode: 0 }); // Non-terminal snapshot (active/CONTINUE) → orchestrateTransition takes the reload path mockActorService.sendAndSync.mockResolvedValue({ state: { - id: runbookId, - step: '2', - status: 'running', - templateVars: { ContextId: 'ctx-unit' }, + ...makeLoopState('2', { templateVars: { ContextId: 'ctx-unit' } }), }, snapshot: { status: 'active', @@ -1105,10 +1167,12 @@ describe('runExecutionLoop', () => { .mockResolvedValueOnce({ path: '/project/.rundown/runbooks/child-a.runbook.md', source: 'project', + sourceRoot: '/project', }) .mockResolvedValueOnce({ path: '/project/.rundown/runbooks/child-b.runbook.md', source: 'project', + sourceRoot: '/project', }); // createDelegation returns a token for each substep. The fixtures use @@ -1286,6 +1350,7 @@ describe('runExecutionLoop', () => { jest.mocked(resolveRunbook.resolveRunbookFile).mockResolvedValue({ path: '/project/.rundown/runbooks/child-a.runbook.md', source: 'project', + sourceRoot: '/project', }); jest.mocked(core.createDelegation).mockReturnValue({ @@ -1376,6 +1441,7 @@ describe('runExecutionLoop', () => { jest.mocked(resolveRunbook.resolveRunbookFile).mockResolvedValue({ path: '/project/.rundown/runbooks/child-a.runbook.md', source: 'project', + sourceRoot: '/project', }); // Real createDelegation enforces the guard — let it run rather than mock diff --git a/packages/cli/__tests__/services/internal-commands.test.ts b/packages/cli/__tests__/services/internal-commands.test.ts index 53dfd13de..fa2d1ead5 100644 --- a/packages/cli/__tests__/services/internal-commands.test.ts +++ b/packages/cli/__tests__/services/internal-commands.test.ts @@ -86,7 +86,7 @@ rd echo test }; // Create runbook state - const state = await manager.create('test.runbook.md', runbook, { + const state = await manager.create({ source: 'project', path: 'test.runbook.md' }, runbook, { runbookPath: 'test.runbook.md', prompted: true, }); diff --git a/packages/cli/__tests__/services/variable-discovery.test.ts b/packages/cli/__tests__/services/variable-discovery.test.ts index b88c6ebdb..f82268244 100644 --- a/packages/cli/__tests__/services/variable-discovery.test.ts +++ b/packages/cli/__tests__/services/variable-discovery.test.ts @@ -5,11 +5,11 @@ import { findConfigFile, parseVarFlag, loadVariablesFromFile, + BUILTIN_VARIABLES, getBuiltinVariables, resolveVariables, routeExtraVars, collectCliFlags, - sanitizeBranchName, setExecFileSyncImpl, } from '../../src/services/variable-discovery.js'; import { execFileSync as nodeExecFileSync } from 'node:child_process'; @@ -34,6 +34,13 @@ import { mockFn } from '../helpers/typed-mocks.js'; type CheckPathFn = PolicyEvaluator['checkPath']; type RequestPermissionFn = PolicyPrompter['requestPermission']; +const RESERVED_IDENTITY_KEY_VARIANTS = [ + BUILTIN_VARIABLES.RunId, + 'runid', + BUILTIN_VARIABLES.RunbookRef, + 'RUNBOOKREF', +] as const; + describe('parseVarFlag', () => { it('should parse key=value format', () => { expect(parseVarFlag('test_command=npm test')).toEqual({ @@ -93,8 +100,9 @@ describe('getBuiltinVariables', () => { expect(builtins).toHaveProperty('Day'); expect(builtins).toHaveProperty('Branch'); expect(builtins).toHaveProperty('WorkPath'); - expect(builtins).toHaveProperty('RunId'); expect(builtins).toHaveProperty('ContextId'); + expect(builtins).not.toHaveProperty('RunId'); + expect(builtins).not.toHaveProperty('RunbookRef'); }); it('should return Date in YYYY-MM-DD format', () => { @@ -140,11 +148,11 @@ describe('getBuiltinVariables', () => { expect(builtins).toHaveProperty('Branch'); }); - it('should include sanitized branch in WorkPath when in git repo', () => { + it('should return fixed WorkPath even when in git repo', () => { setExecFileSyncImpl((() => 'feature/my-branch\n') as unknown as typeof nodeExecFileSync); const builtins = getBuiltinVariables(); - expect(builtins.WorkPath).toBe(`${WORK_DIR}/feature-my-branch`); + expect(builtins.WorkPath).toBe(WORK_DIR); expect(builtins.Branch).toBe('feature/my-branch'); }); @@ -166,17 +174,19 @@ describe('getBuiltinVariables', () => { expect(builtins.Branch).toBe(''); }); - it('should return RunId as alphanumeric string', () => { + it('should not generate RunId during variable discovery', () => { const builtins = getBuiltinVariables(); - expect(builtins).toHaveProperty('RunId'); - expect(builtins.RunId).toMatch(/^[a-f0-9]+$/); - expect(builtins.RunId).toHaveLength(8); + expect(builtins).not.toHaveProperty('RunId'); }); - it('should return unique RunId across calls', () => { - const ids = new Set(Array.from({ length: 100 }, () => getBuiltinVariables().RunId)); - expect(ids.size).toBeGreaterThan(90); + it('registers runtime identity keys without emitting them as discovery builtins', () => { + const builtins = getBuiltinVariables(); + + expect(BUILTIN_VARIABLES.RunId).toBe('RunId'); + expect(BUILTIN_VARIABLES.RunbookRef).toBe('RunbookRef'); + expect(builtins).not.toHaveProperty(BUILTIN_VARIABLES.RunId); + expect(builtins).not.toHaveProperty(BUILTIN_VARIABLES.RunbookRef); }); it('should return ContextId as 8-char alphanumeric string', () => { @@ -497,6 +507,61 @@ describe('resolveVariables', () => { ); }); + it.each( + RESERVED_IDENTITY_KEY_VARIANTS, + )('rejects runtime identity key "%s" from --input', async (name) => { + await expect(resolveVariables({ input: [`${name}=shadow`] }, tmpDir)).rejects.toThrow( + /reserved runtime variable/i, + ); + }); + + it.each( + RESERVED_IDENTITY_KEY_VARIANTS, + )('rejects runtime identity key "%s" from --input-json', async (name) => { + await expect(resolveVariables({ inputJson: [`${name}="shadow"`] }, tmpDir)).rejects.toThrow( + /reserved runtime variable/i, + ); + }); + + it.each( + RESERVED_IDENTITY_KEY_VARIANTS, + )('rejects runtime identity key "%s" from --input-file', async (name) => { + const varFile = path.join(tmpDir, `${name}.yaml`); + await fs.writeFile(varFile, `${name}: shadow\n`); + + await expect(resolveVariables({ inputFile: [varFile] }, tmpDir)).rejects.toThrow( + /reserved runtime variable/i, + ); + }); + + it.each( + RESERVED_IDENTITY_KEY_VARIANTS, + )('ignores runtime identity key "%s" from RD_INPUT_*', async (name) => { + const envKey = `RD_INPUT_${name}`; + const previous = process.env[envKey]; + process.env[envKey] = 'shadow'; + + try { + const result = await resolveVariables({}, tmpDir); + + expect( + Object.keys(result.vars).some((key) => key.toLowerCase() === name.toLowerCase()), + ).toBe(false); + expect( + Array.from(result.providedKeys).some((key) => key.toLowerCase() === name.toLowerCase()), + ).toBe(false); + expect(result.warnings.some((w) => w.includes(envKey) && w.includes('reserved'))).toBe( + true, + ); + } finally { + if (previous === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = previous; + } + } + }); + it('reports all reserved key violations in a single error', async () => { const error = await resolveVariables({ input: ['Step=a', 'Index=b'] }, tmpDir).catch( (e: unknown) => e, @@ -919,13 +984,28 @@ describe('resolveVariables', () => { }); describe('collectEnvBridgeVars (via resolveVariables)', () => { + let originalRdInputEnv = new Map(); + + beforeEach(() => { + originalRdInputEnv = new Map( + Object.entries(process.env).filter(([key]) => key.startsWith('RD_INPUT_')), + ); + }); + afterEach(() => { - // Clean up any RD_INPUT_* env vars set during tests for (const key of Object.keys(process.env)) { - if (key.startsWith('RD_INPUT_')) { + if (key.startsWith('RD_INPUT_') && !originalRdInputEnv.has(key)) { delete process.env[key]; } } + + for (const [key, value] of originalRdInputEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } }); it('collects RD_INPUT_foo=bar as variable foo with value bar', async () => { @@ -1284,61 +1364,3 @@ describe('collectCliFlags', () => { expect(result).toEqual({}); }); }); - -describe('sanitizeBranchName', () => { - it('should return empty string for empty input', () => { - expect(sanitizeBranchName('')).toBe(''); - }); - - it('should pass through simple names unchanged', () => { - expect(sanitizeBranchName('main')).toBe('main'); - expect(sanitizeBranchName('develop')).toBe('develop'); - }); - - it('should replace slashes with hyphens', () => { - expect(sanitizeBranchName('feature/add-login')).toBe('feature-add-login'); - expect(sanitizeBranchName('fix/bug/nested')).toBe('fix-bug-nested'); - }); - - it('should strip invalid characters and append hash when lossy', () => { - const result = sanitizeBranchName('feature@branch!'); - expect(result).toMatch(/^featurebranch-[a-f0-9]{8}$/); - }); - - it('should preserve dots in branch names', () => { - expect(sanitizeBranchName('my..branch')).toBe('my..branch'); - expect(sanitizeBranchName('release/1.2.3')).toBe('release-1.2.3'); - }); - - it('should collapse consecutive hyphens', () => { - expect(sanitizeBranchName('a//b')).toBe('a-b'); - expect(sanitizeBranchName('a---b')).toBe('a-b'); - }); - - it('should trim leading and trailing hyphens', () => { - expect(sanitizeBranchName('-leading')).toBe('leading'); - expect(sanitizeBranchName('trailing-')).toBe('trailing'); - expect(sanitizeBranchName('/scoped/')).toBe('scoped'); - }); - - it('should produce distinct results for branches that previously collided', () => { - const a = sanitizeBranchName('release/1.2'); - const b = sanitizeBranchName('release/12'); - expect(a).not.toBe(b); - expect(a).toBe('release-1.2'); - expect(b).toBe('release-12'); - }); - - it('should return hash-only for non-ASCII-only branches', () => { - const a = sanitizeBranchName('功能'); - const b = sanitizeBranchName('特性'); - expect(a).toMatch(/^[a-f0-9]{8}$/); - expect(b).toMatch(/^[a-f0-9]{8}$/); - expect(a).not.toBe(b); - }); - - it('should produce deterministic output', () => { - expect(sanitizeBranchName('feature@branch')).toBe(sanitizeBranchName('feature@branch')); - expect(sanitizeBranchName('功能')).toBe(sanitizeBranchName('功能')); - }); -}); diff --git a/packages/cli/src/commands/abort.ts b/packages/cli/src/commands/abort.ts index 69b350b2d..95cf240b6 100644 --- a/packages/cli/src/commands/abort.ts +++ b/packages/cli/src/commands/abort.ts @@ -14,6 +14,7 @@ import { buildCompletionKey, buildResolvedCompletion, deriveActiveFrame, + type RunId, type RunbookState, } from '@rundown-org/core'; import { getCwd } from '../helpers/context.js'; @@ -40,7 +41,7 @@ import type { TransitionOrchestrationPolicy } from '../helpers/transition-orches */ async function propagateForceAbort( manager: RunbookStateManager, - parentRunId: string, + parentRunId: RunId, cwd: string, output: OutputEmitter, ): Promise { @@ -173,7 +174,7 @@ export function registerAbortCommand(program: Command): void { let abortResult: ReturnType; let freshParent: RunbookState | null = null; - let childRunId: string | null = null; + let childRunId: RunId | null = null; let childRunbookPath: string = scanResult.delegation.childRunbookPath; /** diff --git a/packages/cli/src/commands/delegate.ts b/packages/cli/src/commands/delegate.ts index 9ba5af146..6e43e38f4 100644 --- a/packages/cli/src/commands/delegate.ts +++ b/packages/cli/src/commands/delegate.ts @@ -14,7 +14,7 @@ import { parseStepIdFromString } from '@rundown-org/parser'; import { getCwd } from '../helpers/context.js'; import { withErrorHandling } from '../helpers/wrapper.js'; import { OutputEmitter } from '../services/output-emitter.js'; -import { resolveRunbookFile } from '../helpers/resolve-runbook.js'; +import { buildRunbookRef, resolveRunbookFile } from '../helpers/resolve-runbook.js'; import { getRunbookFromState } from '../helpers/runbook-loader.js'; import { inferDelegationTarget, inferRunbookFromStep } from '../helpers/delegate-inference.js'; import { @@ -149,6 +149,7 @@ export function registerDelegateCommand(program: Command): void { throw Errors.delegationRunbookNotFound(resolvedRunbook); } const childPath = childResolved.path; + const childRunbookRef = await buildRunbookRef(childResolved); // Parse extra vars through the standard normalization pipeline const rawVars = await collectCliFlags( @@ -201,6 +202,7 @@ export function registerDelegateCommand(program: Command): void { state, stepId: resolvedStepId, childRunbookPath: childPath, + childRunbookRef, extraVars, ancestors: [], frameKey: activeFrameKey, diff --git a/packages/cli/src/commands/ls.ts b/packages/cli/src/commands/ls.ts index 76bb39460..51c3f7db8 100644 --- a/packages/cli/src/commands/ls.ts +++ b/packages/cli/src/commands/ls.ts @@ -85,11 +85,13 @@ export function registerLsCommand(program: Command): void { states.map(async (state) => { const status = getStatus(state, active, stashedId); + const runbookPath = state.runbook.path; const totalSteps = await getStepTotal(cwd, state.runbook); const displayStep = state.step; return { ...state, + runbook: runbookPath, _status: status, _displayStep: `${displayStep}/${String(totalSteps)}`, _step: displayStep, diff --git a/packages/cli/src/commands/prune.ts b/packages/cli/src/commands/prune.ts index e1bdad12a..24625a038 100644 --- a/packages/cli/src/commands/prune.ts +++ b/packages/cli/src/commands/prune.ts @@ -91,13 +91,14 @@ export function registerPruneCommand(program: Command): void { // Stale files (skipped by list() due to schema version mismatch) are invisible to // list() but can still be deleted. Treat them as inactive: prune with // --inactive or --all. - const loadedIds = new Set(states.map((s) => s.id)); + const loadedIds = new Set(states.map((s) => s.id)); const staleIds = allIds.filter((id) => !loadedIds.has(id)); const staleToDelete = (pruneInactive ?? options.all) ? staleIds : []; // Enrich items with status string for display const enrichedItems = toDelete.map((state) => ({ ...state, + runbook: state.runbook.path, _status: getStatus(state, activeState, stashedId), })); diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index b6afb23cb..40f5e7dba 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -29,7 +29,7 @@ import { getCwd } from '../helpers/context.js'; import { OutputEmitter } from '../services/output-emitter.js'; import { parseInputOption, parseInputJsonOption, collect } from '../helpers/option-utils.js'; import { - prepareRunbook, + prepareRunnableRunbook, startRunbook, inferEntryFromState, type RunPipelineContext, @@ -161,7 +161,7 @@ export function registerRunCommand(program: Command): void { }; } - const prepResult = await prepareRunbook(file, inputOpts, cwd, inheritedOptions); + const prepResult = await prepareRunnableRunbook(file, inputOpts, cwd, inheritedOptions); if (!prepResult.ok) { output.error(prepResult.error, prepResult.code, prepResult.details); output.flush(); diff --git a/packages/cli/src/helpers/active-runbook-cleanup.ts b/packages/cli/src/helpers/active-runbook-cleanup.ts index e17a1c1ba..268fcd883 100644 --- a/packages/cli/src/helpers/active-runbook-cleanup.ts +++ b/packages/cli/src/helpers/active-runbook-cleanup.ts @@ -1,5 +1,6 @@ import { type RunbookStateManager, + type RunId, type SessionService, StaleRunbookStateError, isError, @@ -29,7 +30,7 @@ export function isRecoverableActiveStackError(error: Error): boolean { export async function cleanupOrphanedActiveStack( manager: RunbookStateManager, sessionService: SessionService, -): Promise { +): Promise { const session = await manager.loadSession(); const orphanId = session.defaultStack[session.defaultStack.length - 1]; if (!orphanId) { diff --git a/packages/cli/src/helpers/context.ts b/packages/cli/src/helpers/context.ts index 0dd03960e..de2955c05 100644 --- a/packages/cli/src/helpers/context.ts +++ b/packages/cli/src/helpers/context.ts @@ -2,8 +2,8 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { parseRunbook, countNumberedSteps } from '@rundown-org/core'; -import { resolveRunbookFile } from './resolve-runbook.js'; +import { parseRunbook, countNumberedSteps, type RunbookRef } from '@rundown-org/core'; +import { resolveRunbookFile, resolveRunbookRef } from './resolve-runbook.js'; /** * Get current working directory. @@ -18,14 +18,21 @@ export function getCwd(): string { * Named steps (like "RECOVER") are excluded from the count. * * @param cwd - Current working directory - * @param runbookPath - Path to the runbook file + * @param runbook - Path to the runbook file or persisted source-aware runbook identity * @returns Numbered step count or 0 on error */ -export async function getStepTotal(cwd: string, runbookPath: string): Promise { +export async function getStepTotal(cwd: string, runbook: string | RunbookRef): Promise { try { - const resolved = await resolveRunbookFile(cwd, runbookPath); - if (!resolved) return 0; - const content = await fs.readFile(resolved.path, 'utf8'); + let filePath: string | undefined; + if (typeof runbook === 'string') { + filePath = (await resolveRunbookFile(cwd, runbook))?.path; + } else { + const resolved = await resolveRunbookRef(cwd, runbook); + if (!resolved.ok) return 0; + filePath = resolved.resolved.path; + } + if (!filePath) return 0; + const content = await fs.readFile(filePath, 'utf8'); const steps = parseRunbook(content); return countNumberedSteps(steps); } catch { diff --git a/packages/cli/src/helpers/execution-emitter.ts b/packages/cli/src/helpers/execution-emitter.ts index 6311397d0..8949ae3df 100644 --- a/packages/cli/src/helpers/execution-emitter.ts +++ b/packages/cli/src/helpers/execution-emitter.ts @@ -7,12 +7,7 @@ * @module helpers/execution-emitter */ -import { - ExecutionEventEmitter, - RunbookRefSchema, - type RunbookRef, - type RunbookState, -} from '@rundown-org/core'; +import { ExecutionEventEmitter, type RunbookState } from '@rundown-org/core'; import type { OutputEmitter } from '../services/output-emitter.js'; /** @@ -24,7 +19,6 @@ import type { OutputEmitter } from '../services/output-emitter.js'; * * @param runbookState - The runbook state to create the emitter for * @param output - The OutputEmitter to bridge events to - * @param runbookRef - Optional canonical runbook reference derived at preparation time * @returns The ExecutionEventEmitter configured with event bridging * * @example @@ -36,12 +30,8 @@ import type { OutputEmitter } from '../services/output-emitter.js'; export function createBridgedEmitter( runbookState: RunbookState, output: OutputEmitter, - runbookRef?: RunbookRef, ): ExecutionEventEmitter { - const emitter = new ExecutionEventEmitter( - runbookState.id, - resolveRunbookRef(runbookState, runbookRef), - ); + const emitter = new ExecutionEventEmitter(runbookState.id, runbookState.runbook); // Bridge execution events to the unified output system emitter.subscribe((event) => { @@ -50,40 +40,3 @@ export function createBridgedEmitter( return emitter; } - -function resolveRunbookRef(runbookState: RunbookState, runbookRef?: RunbookRef): RunbookRef { - return runbookRef - ? RunbookRefSchema.parse(runbookRef) - : runbookState.runbookRef - ? RunbookRefSchema.parse(runbookState.runbookRef) - : createFallbackProjectRunbookRef(runbookState); -} - -function createFallbackProjectRunbookRef(runbookState: RunbookState): RunbookRef { - for (const candidate of [runbookState.runbookPath, runbookState.runbook]) { - const ref = { - source: 'project' as const, - path: toCanonicalRunbookRefPath(candidate), - }; - const result = RunbookRefSchema.safeParse(ref); - if (result.success) { - return result.data; - } - } - - return RunbookRefSchema.parse({ - source: 'project', - path: toCanonicalRunbookRefPath(runbookState.runbookPath), - }); -} - -function toCanonicalRunbookRefPath(value: string): string { - const normalized = value.startsWith('./') ? value.slice(2) : value; - if (normalized.endsWith('.runbook.md')) { - return normalized; - } - if (normalized.endsWith('.md')) { - return `${normalized.slice(0, -'.md'.length)}.runbook.md`; - } - return normalized; -} diff --git a/packages/cli/src/helpers/goto-workflow.ts b/packages/cli/src/helpers/goto-workflow.ts index ff052a5ea..6afb69749 100644 --- a/packages/cli/src/helpers/goto-workflow.ts +++ b/packages/cli/src/helpers/goto-workflow.ts @@ -21,6 +21,7 @@ import { type StepId, type RunbookState, type ClaimId, + type RunId, } from '@rundown-org/core'; import { runExecutionLoop, type ExecutionTerminalReleaseMode } from '../services/execution.js'; import type { OutputEmitter } from '../services/output-emitter.js'; @@ -83,7 +84,7 @@ export type BuildGotoContextResult = */ export async function resolveTerminalReleaseModeForRunbook( manager: RunbookStateManager, - runbookId: RunbookState['id'], + runbookId: RunId, ): Promise { const session = await manager.loadSession(); const claimed = Object.values(session.claims).some((claim) => claim.childRunId === runbookId); diff --git a/packages/cli/src/helpers/resolve-runbook.ts b/packages/cli/src/helpers/resolve-runbook.ts index 8935eb63b..f4cb6b85c 100644 --- a/packages/cli/src/helpers/resolve-runbook.ts +++ b/packages/cli/src/helpers/resolve-runbook.ts @@ -1,10 +1,17 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { runbooksDir } from '@rundown-org/core'; +import { + runbooksDir, + RunbookRefSchema, + type RunbookRef, + type RunbookSource, +} from '@rundown-org/core'; import { findRunbookByName, findRunbookByNameInSource } from '../services/discovery.js'; import { getBundledRunbooksPath } from './bundled-runbooks.js'; import { getPluginRoot } from './plugin-root.js'; +type DiscoverableRunbookSource = Exclude; + /** * Result of resolving a runbook file, including its source. * @@ -16,9 +23,36 @@ export interface ResolvedRunbook { /** Absolute path to the resolved runbook file */ path: string; /** Source directory where the runbook was found */ - source: 'project' | 'plugin' | 'bundled'; + source: RunbookSource; + /** Source root used to derive persisted source-root-relative identity */ + sourceRoot: string; +} + +/** + * Successful re-resolution of a persisted runbook reference. + */ +export interface ResolveRunbookRefSuccess { + /** Discriminator for successful resolution */ + readonly ok: true; + /** Filesystem path and source metadata for the persisted runbook reference */ + readonly resolved: ResolvedRunbook; } +/** + * Failure while re-resolving a persisted runbook reference. + */ +export interface ResolveRunbookRefFailure { + /** Discriminator for failed resolution */ + readonly ok: false; + /** Whether resolution failed because plugin context was unavailable or the file was missing */ + readonly reason: 'plugin-context-missing' | 'file-missing'; + /** Persisted runbook reference that could not be re-resolved */ + readonly runbookRef: RunbookRef; +} + +/** Result of re-resolving a persisted runbook reference. */ +export type ResolveRunbookRefResult = ResolveRunbookRefSuccess | ResolveRunbookRefFailure; + /** * Parsed runbook identifier with optional namespace. */ @@ -53,7 +87,7 @@ export function parseIdentifier(identifier: string): ParsedIdentifier { * @param namespace - Namespace string * @returns Source type or null if namespace not recognized */ -function namespaceToSource(namespace: string): 'project' | 'plugin' | 'bundled' | null { +function namespaceToSource(namespace: string): DiscoverableRunbookSource | null { if (namespace === 'rundown') { return 'plugin'; } @@ -61,8 +95,8 @@ function namespaceToSource(namespace: string): 'project' | 'plugin' | 'bundled' return null; } -function pathWithin(root: string, target: string): boolean { - const relativePath = path.relative(path.resolve(root), path.resolve(target)); +function isWithinResolvedRoot(root: string, target: string): boolean { + const relativePath = path.relative(root, target); return ( relativePath === '' || (!relativePath.startsWith(`..${path.sep}`) && @@ -71,27 +105,63 @@ function pathWithin(root: string, target: string): boolean { ); } +async function realpathOrResolve(filePath: string): Promise { + try { + return await fs.realpath(filePath); + } catch { + return path.resolve(filePath); + } +} + +async function pathUnderRoot(root: string, target: string): Promise { + const [realRoot, realTarget] = await Promise.all([ + realpathOrResolve(root), + realpathOrResolve(target), + ]); + if (!isWithinResolvedRoot(realRoot, realTarget)) { + return null; + } + return path.join(root, path.relative(realRoot, realTarget)); +} + async function resolveAbsolutePath(cwd: string, filename: string): Promise { + const absoluteFilename = path.resolve(filename); try { - await fs.access(filename); + await fs.access(absoluteFilename); } catch { return null; } - if (pathWithin(runbooksDir(cwd), filename)) { - return { path: filename, source: 'project' }; + const projectRunbookPath = await pathUnderRoot(runbooksDir(cwd), absoluteFilename); + if (projectRunbookPath) { + return { path: projectRunbookPath, source: 'project', sourceRoot: cwd }; } const pluginRoot = getPluginRoot(); - if (pluginRoot && pathWithin(path.join(pluginRoot, 'runbooks'), filename)) { - return { path: filename, source: 'plugin' }; + const pluginRunbooksDir = pluginRoot ? path.join(pluginRoot, 'runbooks') : null; + if (pluginRunbooksDir) { + const pluginRunbookPath = await pathUnderRoot(pluginRunbooksDir, absoluteFilename); + if (pluginRunbookPath) { + return { path: pluginRunbookPath, source: 'plugin', sourceRoot: pluginRunbooksDir }; + } + } + + const bundledRunbooksDir = getBundledRunbooksPath(); + const bundledRunbookPath = await pathUnderRoot(bundledRunbooksDir, absoluteFilename); + if (bundledRunbookPath) { + return { path: bundledRunbookPath, source: 'bundled', sourceRoot: bundledRunbooksDir }; } - if (pathWithin(getBundledRunbooksPath(), filename)) { - return { path: filename, source: 'bundled' }; + const projectPath = await pathUnderRoot(cwd, absoluteFilename); + if (projectPath) { + return { path: projectPath, source: 'project', sourceRoot: cwd }; } - return { path: filename, source: 'project' }; + return { + path: absoluteFilename, + source: 'external', + sourceRoot: path.dirname(absoluteFilename), + }; } /** @@ -115,7 +185,7 @@ async function resolveByPath(cwd: string, filename: string): Promise { + const sourceRoot = sourceRootForDiscovered(cwd, discovered.source); + const normalizedPath = (await pathUnderRoot(sourceRoot, discovered.path)) ?? discovered.path; + return { + path: normalizedPath, + source: discovered.source, + sourceRoot, + }; +} + +function sourceRootForDiscovered(cwd: string, source: DiscoverableRunbookSource): string { + switch (source) { + case 'project': + return cwd; + case 'plugin': { + const pluginRoot = getPluginRoot(); + if (!pluginRoot) { + throw new Error('Plugin runbook discovered without CLAUDE_PLUGIN_ROOT'); + } + return path.join(pluginRoot, 'runbooks'); + } + case 'bundled': + return getBundledRunbooksPath(); + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } +} + +async function toSourceRootRelativePath(filePath: string, sourceRoot: string): Promise { + const safePath = await pathUnderRoot(sourceRoot, filePath); + if (safePath === null) { + throw new Error(`Resolved runbook path is outside ${sourceRoot}: ${filePath}`); + } + const relative = path.relative(sourceRoot, safePath).split(path.sep).join('/'); + if ( + relative.length === 0 || + relative === '..' || + relative.startsWith('../') || + path.isAbsolute(relative) + ) { + throw new Error(`Resolved runbook path is outside ${sourceRoot}: ${filePath}`); + } + return relative; +} + +async function derivePersistedRunbookRef( + filePath: string, + source: RunbookSource, + sourceRoot: string, +): Promise { + if (source === 'external') { + return RunbookRefSchema.parse({ + source, + path: filePath, + }); + } + + return RunbookRefSchema.parse({ + source, + path: await toSourceRootRelativePath(filePath, sourceRoot), + }); +} + +/** + * Build the canonical runtime runbook reference for a resolved runbook file. + * + * @param resolved - Filesystem resolution result carrying path, source, and source root + * @returns Canonical `RunbookRef` derived from the resolved file and validated by `RunbookRefSchema` + * @throws {Error} If the resolved file cannot be represented as a safe source-root-relative Markdown path + */ +export async function buildRunbookRef(resolved: ResolvedRunbook): Promise { + return derivePersistedRunbookRef(resolved.path, resolved.source, resolved.sourceRoot); +} + +/** + * Resolve a persisted runbook reference back to a filesystem path. + * + * @param cwd - Current working directory for project runbooks + * @param runbookRef - Canonical persisted runbook identity + * @returns Typed success with source metadata, or typed failure describing why resolution failed + */ +export async function resolveRunbookRef( + cwd: string, + runbookRef: RunbookRef, +): Promise { + const canonical = runbookRef; + switch (canonical.source) { + case 'external': + try { + // External refs intentionally trust a normalized absolute path only. + // Persistence does not bind file contents, so replacing the file between + // delegation and claim is outside the current integrity model. + await fs.access(canonical.path); + return { + ok: true, + resolved: { + path: canonical.path, + source: canonical.source, + sourceRoot: path.dirname(canonical.path), + }, + }; + } catch { + return { ok: false, reason: 'file-missing', runbookRef: canonical }; + } + case 'project': + return resolveRunbookRefCandidates(canonical, [ + { sourceRoot: cwd, path: path.join(cwd, canonical.path) }, + ]); + case 'plugin': { + const pluginRoot = getPluginRoot(); + if (!pluginRoot) { + return { ok: false, reason: 'plugin-context-missing', runbookRef: canonical }; + } + const pluginRunbooksDir = path.join(pluginRoot, 'runbooks'); + return resolveRunbookRefCandidates(canonical, [ + { sourceRoot: pluginRunbooksDir, path: path.join(pluginRunbooksDir, canonical.path) }, + ]); + } + case 'bundled': { + const bundledRunbooksDir = getBundledRunbooksPath(); + return resolveRunbookRefCandidates(canonical, [ + { sourceRoot: bundledRunbooksDir, path: path.join(bundledRunbooksDir, canonical.path) }, + ]); + } + default: { + const _exhaustive: never = canonical.source; + return _exhaustive; + } + } +} + +async function resolveRunbookRefCandidates( + runbookRef: RunbookRef, + candidates: readonly { readonly sourceRoot: string; readonly path: string }[], +): Promise { + for (const candidate of candidates) { + try { + await fs.access(candidate.path); + return { + ok: true, + resolved: { + path: candidate.path, + source: runbookRef.source, + sourceRoot: candidate.sourceRoot, + }, + }; + } catch { + // Continue to next candidate. + } } + return { ok: false, reason: 'file-missing', runbookRef }; } diff --git a/packages/cli/src/helpers/runbook-loader.ts b/packages/cli/src/helpers/runbook-loader.ts index 558c11c79..1757d6ed4 100644 --- a/packages/cli/src/helpers/runbook-loader.ts +++ b/packages/cli/src/helpers/runbook-loader.ts @@ -56,7 +56,7 @@ export function getRunbookFromState(state: RunbookState, _cwd: string): readonly const errors = diagnostics.filter((d) => d.severity === 'error'); if (errors.length > 0) { throw new Error( - `Runbook ${state.runbook} has structural errors: ${errors[0].message}. ` + + `Runbook ${state.runbook.path} has structural errors: ${errors[0].message}. ` + `Delete state and re-run the runbook.`, ); } @@ -72,7 +72,7 @@ export function getRunbookFromState(state: RunbookState, _cwd: string): readonly // mergeEffectiveVars to include OUTPUTS for descriptions, prompts, and // downstream OUTPUTS expressions. if (state.templateVars) { - const { runbook, diagnostics } = parseRunbookDocument(state.runbookSrc, state.runbook); + const { runbook, diagnostics } = parseRunbookDocument(state.runbookSrc, state.runbook.path); checkDiagnostics(diagnostics); const { runbook: resolved } = resolveForBounds(runbook, state.templateVars); const substituted = substituteRunbookVariables(resolved, state.templateVars); @@ -82,12 +82,12 @@ export function getRunbookFromState(state: RunbookState, _cwd: string): readonly } // Backward compat: old state files have pre-expanded runbookSrc, no templateVars - const { runbook, diagnostics } = parseRunbookDocument(state.runbookSrc, state.runbook); + const { runbook, diagnostics } = parseRunbookDocument(state.runbookSrc, state.runbook.path); checkDiagnostics(diagnostics); if (!areAllStepsResolved(runbook.steps)) { throw new Error( - `Runbook ${state.runbook} has unresolved FOR bounds or runbook references in pre-expanded state. ` + + `Runbook ${state.runbook.path} has unresolved FOR bounds or runbook references in pre-expanded state. ` + `This indicates stale state. Delete and re-run the runbook.`, ); } diff --git a/packages/cli/src/helpers/runbook-pipeline.ts b/packages/cli/src/helpers/runbook-pipeline.ts index 9f83d03ae..618340b61 100644 --- a/packages/cli/src/helpers/runbook-pipeline.ts +++ b/packages/cli/src/helpers/runbook-pipeline.ts @@ -7,7 +7,6 @@ * @module helpers/runbook-pipeline */ -import * as fsSync from 'node:fs'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { @@ -22,12 +21,12 @@ import { type ExecutionEventEmitter, type ResolvedRunbook, type RunbookRef, - RunbookRefSchema, + type RunbookSource, + type RunId, type DelegationLinkage, type ParentLinkage, type ClaimId, RUNS_DIR, - runbooksDir, DelegationScanService, DelegationLock, DelegationLockTimeoutError, @@ -41,6 +40,7 @@ import { type TemplateVarValue, isJsonArray, isJsonArrayStream, + generateRunId, } from '@rundown-org/core'; import { parseRunbookDocument, @@ -53,13 +53,16 @@ import { type RunbookFrontmatter, type Runbook, } from '@rundown-org/parser'; -import { resolveRunbookFile } from './resolve-runbook.js'; +import { buildRunbookRef, resolveRunbookFile, resolveRunbookRef } from './resolve-runbook.js'; import type { ResolvedRunbook as ResolvedRunbookFile } from './resolve-runbook.js'; import { runExecutionLoop } from '../services/execution.js'; import type { OutputEmitter } from '../services/output-emitter.js'; import { createBridgedEmitter } from './execution-emitter.js'; -import { getBundledRunbooksPath } from './bundled-runbooks.js'; -import { FileSourcePolicyError, resolveVariables } from '../services/variable-discovery.js'; +import { + BUILTIN_VARIABLES, + FileSourcePolicyError, + resolveVariables, +} from '../services/variable-discovery.js'; import { substituteRunbookVariables, resolveForBounds, @@ -99,12 +102,31 @@ export interface RunPipelineContext { cwd: string; } +/** Template variables available after runbook resolution but before execution starts. */ +export type PreparedTemplateVariables = Record & { + /** Canonical identity for the resolved runbook. */ + readonly RunbookRef: RunbookRef; +}; + +/** Template variables available to a runnable runbook execution. */ +export type RunnableTemplateVariables = PreparedTemplateVariables & { + /** Fresh execution identifier for this run. */ + readonly RunId: RunId; +}; + /** - * A fully prepared runbook ready for state creation. + * A resolved, validated, template-substituted runbook. + * + * This shape is safe for `rd resolve`: it contains resolver-owned identity + * (`RunbookRef`) but never mints execution-owned identity (`RunId`). */ export interface PreparedRunbook { /** Absolute path to the resolved runbook file */ filePath: string; + /** Source where the runbook was discovered from */ + source: RunbookSource; + /** Source root used to derive persisted runbook identity */ + sourceRoot: string; /** Canonical runbook reference for events and artifact metadata */ runbookRef: RunbookRef; /** Raw markdown content of the runbook file */ @@ -112,13 +134,31 @@ export interface PreparedRunbook { /** Parsed and variable-substituted runbook AST (all FOR bounds resolved) */ runbook: ResolvedRunbook; /** Merged template variables from all sources */ - mergedVariables: Record; + mergedVariables: PreparedTemplateVariables; /** Step and substep counts */ stats: { steps: number; substeps: number }; /** Validated frontmatter, or null if absent/invalid */ frontmatter: RunbookFrontmatter | null; } +/** + * A prepared runbook with execution identity allocated. + * + * Only `rd run`, fresh `rd claim`, and other state-creating paths should + * accept this type. `runId` must be passed into `RunbookStateManager.create()`. + */ +export interface RunnableRunbook extends PreparedRunbook { + readonly runId: RunId; + readonly mergedVariables: RunnableTemplateVariables; +} + +function deriveClaudePluginRoot(sourceRoot: string): string { + const normalized = sourceRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + const lastSlash = normalized.lastIndexOf('/'); + const pluginRoot = lastSlash >= 0 ? normalized.slice(0, lastSlash) : '.'; + return `${pluginRoot}/`; +} + /** Failure produced while initializing a runbook launch. */ export interface RunbookStartFailure { ok: false; @@ -130,7 +170,7 @@ export interface RunbookStartFailure { /** Result of starting a runbook execution loop via {@link startRunbook}. */ export type RunbookStartResult = - | { ok: true; loopResult: 'done' | 'stopped' | 'waiting'; stateId: string } + | { ok: true; loopResult: 'done' | 'stopped' | 'waiting'; stateId: RunId } | RunbookStartFailure; type LaunchSessionActivation = { readonly kind: 'default-stack' } | { readonly kind: 'none' }; @@ -205,11 +245,11 @@ export type ClaimResult = /** Discriminator indicating success. */ ok: true; /** Unique identifier of the launched (or idempotently returned) child run. */ - childRunId: string; + childRunId: RunId; /** Claim id for explicit child targeting. */ claimId: ClaimId; /** Unique identifier of the parent run that owns the delegation. */ - parentRunId: string; + parentRunId: RunId; /** Step (or substep) ID on the parent that holds the delegation. */ stepId: string; /** Terminal state of the child execution loop. */ @@ -248,105 +288,6 @@ export function validateForVariables( } } -/** - * Build a canonical runbook reference from a resolved file and source root. - * - * @param resolved - Resolved runbook file and discovery source - * @param cwd - Project working directory used for project-relative paths - * @returns Validated canonical runbook reference - * @throws {Error} If the resolved file cannot be represented canonically - */ -export function buildRunbookRef(resolved: ResolvedRunbookFile, cwd: string): RunbookRef { - const rootRelativePath = sourceRelativeRunbookPath(resolved, cwd); - return RunbookRefSchema.parse({ - source: resolved.source, - path: toCanonicalRunbookRefPath(toPosixPath(rootRelativePath)), - }); -} - -function sourceRelativeRunbookPath(resolved: ResolvedRunbookFile, cwd: string): string { - switch (resolved.source) { - case 'project': { - const projectRunbooksRelative = pathRelativeWithin(runbooksDir(cwd), resolved.path); - return projectRunbooksRelative ?? pathRelativeRequired(cwd, resolved.path); - } - case 'plugin': { - const pluginRunbooksRoot = findRunbooksAncestor(resolved.path); - if (!pluginRunbooksRoot) { - throw new Error(`Plugin runbook is not beneath a runbooks directory: ${resolved.path}`); - } - return pathRelativeRequired(pluginRunbooksRoot, resolved.path); - } - case 'bundled': - return pathRelativeRequired(getBundledRunbooksPath(), resolved.path); - default: { - const _exhaustive: never = resolved.source; - throw new Error(`Unhandled runbook source: ${String(_exhaustive)}`); - } - } -} - -function pathRelativeRequired(root: string, target: string): string { - const relativePath = pathRelativeWithin(root, target); - if (relativePath === null) { - throw new Error(`Resolved runbook path escapes source root: ${target}`); - } - return relativePath; -} - -function pathRelativeWithin(root: string, target: string): string | null { - const relativePath = path.relative(resolveComparablePath(root), resolveComparablePath(target)); - if (relativePath === '' || escapesRoot(relativePath)) { - return null; - } - return relativePath; -} - -function resolveComparablePath(value: string): string { - try { - return fsSync.realpathSync.native(value); - } catch { - return path.resolve(value); - } -} - -function escapesRoot(relativePath: string): boolean { - return ( - relativePath === '..' || - relativePath.startsWith(`..${path.sep}`) || - path.isAbsolute(relativePath) - ); -} - -function findRunbooksAncestor(filePath: string): string | null { - let current = path.dirname(path.resolve(filePath)); - let outermost: string | null = null; - for (;;) { - if (path.basename(current) === 'runbooks') { - outermost = current; - } - const parent = path.dirname(current); - if (parent === current) { - return outermost; - } - current = parent; - } -} - -function toPosixPath(value: string): string { - return value.split(path.sep).join('/'); -} - -function toCanonicalRunbookRefPath(value: string): string { - if (value.endsWith('.runbook.md')) { - return value; - } - if (value.endsWith('.md')) { - return `${value.slice(0, -'.md'.length)}.runbook.md`; - } - return value; -} - /** * Emit RUNBOOK_STARTED event with metadata. * @param emitter - Event emitter for publishing execution events @@ -412,6 +353,34 @@ export function buildTemplateVars( }; } +function withPreparedVariables( + variables: Record, + runbookRef: RunbookRef, +): PreparedTemplateVariables { + return { + ...variables, + [BUILTIN_VARIABLES.RunbookRef]: runbookRef, + }; +} + +function withRunnableVariables( + variables: PreparedTemplateVariables, + runId: RunId, +): RunnableTemplateVariables { + return { + ...variables, + [BUILTIN_VARIABLES.RunId]: runId, + }; +} + +/** Options that influence runbook preparation for delegation and context inheritance. */ +export interface PrepareRunbookOptions { + /** Context variables inherited from a parent delegation. */ + readonly inheritedContextVars?: Readonly>; + /** User variables inherited from a parent delegation. */ + readonly inheritedUserVars?: Readonly>; +} + /** Success result from {@link prepareRunbook}. */ export interface PrepareSuccess { ok: true; @@ -488,7 +457,11 @@ export interface LoadAndParseSuccess { /** Absolute path to the resolved runbook file */ filePath: string; /** Source where the runbook was discovered from */ - source: 'project' | 'plugin' | 'bundled'; + source: RunbookSource; + /** Source root used to derive persisted runbook identity */ + sourceRoot: string; + /** Canonical runbook reference derived from the resolved file identity. */ + runbookRef: RunbookRef; /** Raw markdown content */ rawContent: string; /** Parsed runbook AST (before variable substitution) */ @@ -509,7 +482,7 @@ export interface LoadAndParseSuccess { export interface LoadAndParseFailure { ok: false; error: string; - code: 'RUNBOOK_NOT_FOUND' | 'PARSE_ERROR'; + code: 'RUNBOOK_NOT_FOUND' | 'PARSE_ERROR' | 'RUNBOOK_REF_RESOLUTION_ERROR'; details: { runbook: string }; } @@ -531,22 +504,96 @@ export type LoadAndParseResult = LoadAndParseSuccess | LoadAndParseFailure; * `diagnostics` array combines parser diagnostics with outputs validation only. * * @param file - Runbook file path or namespace:name - * @param cwd - Current working directory for resolution * @returns Discriminated union: ok with loaded data, or error with message */ +function runbookNotFound(file: string): LoadAndParseFailure { + return { + ok: false, + error: `Runbook not found: ${file}. Try 'rd ls --all' to list available runbooks.`, + code: 'RUNBOOK_NOT_FOUND', + details: { runbook: file }, + }; +} + +/** Request for preparing an already-resolved runbook identity. */ +export interface ResolvedRunbookRequest { + /** Filesystem resolution result. */ + readonly resolved: ResolvedRunbookFile; + /** Canonical persisted runbook identity expected for the resolved file. */ + readonly runbookRef: RunbookRef; + /** User-facing name or path used in diagnostics. */ + readonly displayName: string; +} + +/** + * Resolve, load, and parse a runbook file. + * + * @param file - Runbook file path or namespace:name + * @param cwd - Current working directory for resolution + * @returns Loaded runbook data or a structured load failure + */ export async function loadAndParseRunbook(file: string, cwd: string): Promise { const resolved = await resolveRunbookFile(cwd, file); if (!resolved) { + return runbookNotFound(file); + } + + let runbookRef: RunbookRef; + try { + runbookRef = await buildRunbookRef(resolved); + } catch (error: unknown) { return { ok: false, - error: `Runbook not found: ${file}. Try 'rd ls --all' to list available runbooks.`, - code: 'RUNBOOK_NOT_FOUND', + error: getErrorMessage(error), + code: 'RUNBOOK_REF_RESOLUTION_ERROR', details: { runbook: file }, }; } - const { path: filePath, source } = resolved; + return loadAndParseResolvedRunbook({ + resolved, + runbookRef, + displayName: file, + }); +} + +/** + * Load and parse a runbook from an already-resolved request. + * + * @param request - Resolved runbook request carrying source metadata and identity + * @returns Loaded runbook data or a structured load failure + */ +export async function loadAndParseResolvedRunbook( + request: ResolvedRunbookRequest, +): Promise { + const { resolved, displayName } = request; + const { path: filePath, source, sourceRoot } = resolved; + const runbookRef = request.runbookRef; + + let derivedRunbookRef: RunbookRef; + try { + derivedRunbookRef = await buildRunbookRef({ path: filePath, source, sourceRoot }); + } catch (error: unknown) { + return { + ok: false, + error: getErrorMessage(error), + code: 'RUNBOOK_REF_RESOLUTION_ERROR', + details: { runbook: displayName }, + }; + } + + if ( + derivedRunbookRef.source !== runbookRef.source || + derivedRunbookRef.path !== runbookRef.path + ) { + return { + ok: false, + error: `Resolved runbook identity ${derivedRunbookRef.source}:${derivedRunbookRef.path} does not match requested ${runbookRef.source}:${runbookRef.path}`, + code: 'RUNBOOK_REF_RESOLUTION_ERROR', + details: { runbook: displayName }, + }; + } try { const rawContent = await fs.readFile(filePath, 'utf8'); @@ -571,6 +618,8 @@ export async function loadAndParseRunbook(file: string, cwd: string): Promise>; - inheritedUserVars?: Readonly>; - }, + options?: PrepareRunbookOptions, ): Promise { - // Phase 1-2: Parse + Validate const parsed = await loadAndParseRunbook(file, cwd); + return prepareLoadedRunbook(parsed, file, inputOpts, cwd, { kind: 'prepared' }, options); +} + +/** Success/failure result from preparing a runnable execution. */ +export type RunnablePrepareResult = + | (Omit & { readonly prepared: RunnableRunbook }) + | PrepareFailure; + +/** + * Prepare a runbook for execution, minting a fresh `RunId`. + * + * @param file - Runbook file path or name + * @param inputOpts - Input options from CLI flags + * @param cwd - Current working directory + * @param options - Optional settings including inherited variables from parent runbook + * @returns Runnable preparation result with execution identity on success + */ +export async function prepareRunnableRunbook( + file: string, + inputOpts: InputOptions, + cwd: string, + options?: PrepareRunbookOptions, +): Promise { + const parsed = await loadAndParseRunbook(file, cwd); + return prepareLoadedRunbook( + parsed, + file, + inputOpts, + cwd, + { kind: 'runnable', runId: generateRunId() }, + options, + ); +} + +/** + * Prepare an already-resolved runbook for execution, minting a fresh `RunId`. + * + * @param request - Resolved runbook request carrying source metadata and identity + * @param inputOpts - Input options from CLI flags + * @param cwd - Current working directory + * @param options - Optional settings including inherited variables from parent runbook + * @returns Runnable preparation result with execution identity on success + */ +export async function prepareResolvedRunnableRunbook( + request: ResolvedRunbookRequest, + inputOpts: InputOptions, + cwd: string, + options?: PrepareRunbookOptions, +): Promise { + const parsed = await loadAndParseResolvedRunbook(request); + return prepareLoadedRunbook( + parsed, + request.displayName, + inputOpts, + cwd, + { kind: 'runnable', runId: generateRunId() }, + options, + ); +} + +type PreparedIdentity = { readonly kind: 'prepared' }; +type RunnableIdentity = { readonly kind: 'runnable'; readonly runId: RunId }; + +function prepareLoadedRunbook( + parsed: LoadAndParseResult, + displayName: string, + inputOpts: InputOptions, + cwd: string, + identity: PreparedIdentity, + options?: PrepareRunbookOptions, +): Promise; +function prepareLoadedRunbook( + parsed: LoadAndParseResult, + displayName: string, + inputOpts: InputOptions, + cwd: string, + identity: RunnableIdentity, + options?: PrepareRunbookOptions, +): Promise; +async function prepareLoadedRunbook( + parsed: LoadAndParseResult, + displayName: string, + inputOpts: InputOptions, + cwd: string, + identity: PreparedIdentity | RunnableIdentity, + options?: PrepareRunbookOptions, +): Promise { if (!parsed.ok) return parsed; const { filePath, source, + sourceRoot, + runbookRef, rawContent, runbook: rawRunbook, frontmatter, @@ -626,28 +760,7 @@ export async function prepareRunbook( stats, } = parsed; - // Derive CLAUDE_PLUGIN_ROOT from resolved path when source is plugin - let runbookRef: RunbookRef; - let pluginRoot: string | undefined; - try { - runbookRef = buildRunbookRef({ path: filePath, source }, cwd); - if (source === 'plugin') { - const runbooksSep = `${path.sep}runbooks${path.sep}`; - const runbooksIdx = filePath.indexOf(runbooksSep); - if (runbooksIdx !== -1) { - pluginRoot = filePath.slice(0, runbooksIdx + 1); // include trailing separator - } - } - } catch (error) { - return { - ok: false, - error: getErrorMessage(error), - code: 'RUNBOOK_REF_RESOLUTION_ERROR', - details: { runbook: file }, - stats, - diagnostics, - }; - } + const pluginRoot = source === 'plugin' ? deriveClaudePluginRoot(sourceRoot) : undefined; // Inherited user vars pass through untouched here. Context OUTPUTS are // inherited after variable resolution (stage 3.5 below), once the child's @@ -689,7 +802,7 @@ export async function prepareRunbook( error: error.message, code: error.code, details: { - runbook: file, + runbook: displayName, variable: error.variable, filePath: error.filePath, reason: error.reason, @@ -702,13 +815,23 @@ export async function prepareRunbook( ok: false, error: getErrorMessage(error), code: 'VARIABLE_RESOLUTION_ERROR', - details: { runbook: file }, + details: { runbook: displayName }, variables: {}, stats, diagnostics, }; } - const templateVars = buildTemplateVars(mergedVariables, options); + const baseTemplateVars = buildTemplateVars(mergedVariables, options); + const preparedTemplateVars = withPreparedVariables(baseTemplateVars, runbookRef); + const templateScope = + identity.kind === 'runnable' + ? { + kind: 'runnable' as const, + runId: identity.runId, + vars: withRunnableVariables(preparedTemplateVars, identity.runId), + } + : { kind: 'prepared' as const, vars: preparedTemplateVars }; + const templateVars = templateScope.vars; // Bail early if there are structural errors — don't pass a broken AST to transform passes // This must run before the missing-required check so that malformed `required` entries @@ -720,7 +843,7 @@ export async function prepareRunbook( ok: false, error: earlyErrors[0].message, code: 'VALIDATION_ERROR', - details: { runbook: file }, + details: { runbook: displayName }, variables: templateVars, stats, diagnostics, @@ -753,7 +876,7 @@ export async function prepareRunbook( ok: false, error: `Missing required variable${missing.length > 1 ? 's' : ''}: ${names}. Provide via --input, --input-file, config.yaml, RD_INPUT_* environment variable, or prior runbook OUTPUTS.`, code: 'MISSING_REQUIRED_VARS', - details: { runbook: file, missing }, + details: { runbook: displayName, missing }, variables: templateVars, stats, diagnostics, @@ -773,7 +896,7 @@ export async function prepareRunbook( ok: false, error: getErrorMessage(err), code: 'VALIDATION_ERROR', - details: { runbook: file }, + details: { runbook: displayName }, variables: templateVars, stats, diagnostics, @@ -793,7 +916,7 @@ export async function prepareRunbook( ok: false, error: getErrorMessage(err), code: 'VALIDATION_ERROR', - details: { runbook: file }, + details: { runbook: displayName }, variables: templateVars, stats, diagnostics, @@ -806,7 +929,7 @@ export async function prepareRunbook( ok: false, error: 'Runbook has no steps', code: 'VALIDATION_ERROR', - details: { runbook: file }, + details: { runbook: displayName }, variables: templateVars, stats, diagnostics, @@ -814,17 +937,41 @@ export async function prepareRunbook( }; } + const preparedBaseFields = { + filePath, + source, + sourceRoot, + runbookRef, + rawContent, + runbook, + stats, + frontmatter, + }; + + if (templateScope.kind === 'runnable') { + const prepared: RunnableRunbook = { + ...preparedBaseFields, + runId: templateScope.runId, + mergedVariables: templateScope.vars, + }; + + return { + ok: true, + prepared, + warnings: allWarnings.length > 0 ? allWarnings : undefined, + diagnostics, + unresolved: unresolvedNames, + }; + } + + const prepared: PreparedRunbook = { + ...preparedBaseFields, + mergedVariables: preparedTemplateVars, + }; + return { ok: true, - prepared: { - filePath, - runbookRef, - rawContent, - runbook, - mergedVariables: templateVars, - stats, - frontmatter, - }, + prepared, warnings: allWarnings.length > 0 ? allWarnings : undefined, diagnostics, unresolved: unresolvedNames, @@ -849,13 +996,13 @@ export async function prepareRunbook( */ async function launchRunbook( ctx: RunPipelineContext, - prepared: PreparedRunbook, + prepared: RunnableRunbook, options: { runbookName: string; prompted: boolean; parentLinkage?: ParentLinkage; sessionActivation?: LaunchSessionActivation; - afterInit?: (stateId: string) => Promise; + afterInit?: (stateId: RunId) => Promise; }, ): Promise { const { output, manager, actorService, sessionService, lifecycleService, cwd } = ctx; @@ -867,13 +1014,13 @@ async function launchRunbook( // produce a structured launch failure so callers (notably claimAndLaunch) // can release locks and report cleanly. The loop itself is outside the // try/catch — loop failures still propagate as exceptions. - let stateId: string; + let stateId: RunId; let runbookSteps: ResolvedStep[]; let emitter: ExecutionEventEmitter; try { - const state = await manager.create(options.runbookName, runbook, { + const state = await manager.create(prepared.runbookRef, runbook, { + runId: prepared.runId, runbookPath, - runbookRef: prepared.runbookRef, prompted: options.prompted, parentLinkage: options.parentLinkage, runbookSrc: rawContent, @@ -919,7 +1066,7 @@ async function launchRunbook( await manager.update(state.id, { lastAction: { type: 'START' } }); // Create emitter bridged to unified output - emitter = createBridgedEmitter(state, output, prepared.runbookRef); + emitter = createBridgedEmitter(state, output); // Emit RUNBOOK_STARTED emitRunbookStarted(emitter, state, options.prompted); @@ -966,12 +1113,12 @@ async function launchRunbook( */ export async function startRunbook( ctx: RunPipelineContext, - prepared: PreparedRunbook, + prepared: RunnableRunbook, options: { file: string; prompted?: boolean; parentLinkage?: ParentLinkage; - afterInit?: (stateId: string) => Promise; + afterInit?: (stateId: RunId) => Promise; }, ): Promise { return launchRunbook(ctx, prepared, { @@ -1012,9 +1159,9 @@ export function inferEntryFromState(state: RunbookState, frameKey: FrameKey): nu */ async function updateStepDelegationChildRunId( manager: RunbookStateManager, - runId: string, + runId: RunId, substepId: string, - childRunId: string, + childRunId: RunId, tokenHash?: string, ): Promise { const state = await manager.load(runId); @@ -1039,16 +1186,31 @@ async function updateStepDelegationChildRunId( /** Outcome of {@link claimChildForPipeline}. */ type ClaimChildResult = - | { readonly ok: true; readonly claimId: ClaimId; readonly childRunId: string } + | { readonly ok: true; readonly claimId: ClaimId; readonly childRunId: RunId } | { readonly ok: false; - readonly reason: 'child-missing' | 'linkage-mismatch'; - readonly childRunId: string; + readonly reason: 'child-missing' | 'delegation-resolved' | 'linkage-mismatch'; + readonly childRunId: RunId; }; +/** + * Claim or refresh a delegated child through {@link SessionService.claimRunbook}. + * + * `claimRunbook` owns the idempotent delegation contract: for a matching + * parent linkage it refreshes an existing claim before validating the incoming + * child id, and returns discriminated failures for missing, terminal, or + * linkage-divergent children. The 4b already-linked branch in + * {@link claimAndLaunch} intentionally relies on those source-of-truth + * semantics instead of re-loading the child locally. + * + * @param ctx - Run pipeline context carrying the session service + * @param childRunId - Child run id linked from the delegation or orphan scan + * @param linkage - Fresh linkage rebuilt from token-validated parent state + * @returns Claim id and child id on success, or a mapped failure variant + */ async function claimChildForPipeline( ctx: RunPipelineContext, - childRunId: string, + childRunId: RunId, linkage: DelegationLinkage, ): Promise { const claim = await ctx.sessionService.claimRunbook(childRunId, linkage); @@ -1057,10 +1219,12 @@ async function claimChildForPipeline( return { ok: true, claimId: claim.claim.claimId, - childRunId: (claim.claim as { readonly childRunId?: string }).childRunId ?? childRunId, + childRunId: claim.claim.childRunId, }; case 'missing-child': return { ok: false, reason: 'child-missing', childRunId: claim.childRunId }; + case 'terminal-child': + return { ok: false, reason: 'delegation-resolved', childRunId: claim.childRunId }; case 'linkage-mismatch': return { ok: false, reason: 'linkage-mismatch', childRunId: claim.childRunId }; default: { @@ -1145,9 +1309,9 @@ function emitClaimedOutput( function buildClaimedPayload(args: { readonly truncatedToken: string; readonly claimId: ClaimId; - readonly childRunId: string; + readonly childRunId: RunId; readonly childRunbookPath: string; - readonly parentRunId: string; + readonly parentRunId: RunId; readonly parentStepAt: string | undefined; }): ClaimedOutputPayload { return { @@ -1161,6 +1325,33 @@ function buildClaimedPayload(args: { }; } +function emitClaimedSuccess(args: { + readonly output: OutputEmitter; + readonly truncatedToken: string; + readonly claimId: ClaimId; + readonly childRunId: RunId; + readonly childRunbookPath: string; + readonly parentRunId: RunId; + readonly stepId: string; + readonly parentStepAt: string | undefined; + readonly loopResult: 'done' | 'stopped' | 'waiting'; +}): Extract { + emitClaimedOutput( + args.output, + `Claimed ${args.truncatedToken} -> ${args.childRunbookPath}`, + buildClaimedPayload(args), + ); + + return { + ok: true, + childRunId: args.childRunId, + claimId: args.claimId, + parentRunId: args.parentRunId, + stepId: args.stepId, + loopResult: args.loopResult, + }; +} + /** * Claim a delegation token, reconstitute inherited context, and launch the child runbook. * @@ -1265,27 +1456,6 @@ export async function claimAndLaunch( // 4b. Idempotent return if already claimed if (freshDelegation.childRunId) { - const existingChild = await manager.load(freshDelegation.childRunId); - if (!existingChild) { - // Parent points at a child run that no longer exists on disk. Fail - // closed rather than minting a claim against a missing run. - return { - ok: false, - reason: 'child-missing', - parentRunId: freshParent.id, - stepId, - childRunId: freshDelegation.childRunId, - }; - } - if (existingChild.lifecycle === 'completed' || existingChild.lifecycle === 'stopped') { - return { - ok: false, - reason: 'delegation-resolved', - parentRunId: freshParent.id, - stepId, - childRunId: freshDelegation.childRunId, - }; - } const delegationFrameKey = freshSubstep.frameKey; const freshLinkage: DelegationLinkage = { kind: 'delegation', @@ -1304,28 +1474,17 @@ export async function claimAndLaunch( if (!claimResult.ok) { return claimResultToFailure(claimResult, freshParent.id, stepId); } - const claimId = claimResult.claimId; - emitClaimedOutput( + return emitClaimedSuccess({ output, - `Claimed ${truncatedToken} -> ${freshDelegation.childRunbookPath}`, - buildClaimedPayload({ - truncatedToken, - claimId, - childRunId: freshDelegation.childRunId, - childRunbookPath: freshDelegation.childRunbookPath, - parentRunId: freshParent.id, - parentStepAt: freshDelegation.contextSnapshot.at, - }), - ); - - return { - ok: true, - childRunId: freshDelegation.childRunId, - claimId, + truncatedToken, + childRunId: claimResult.childRunId, + claimId: claimResult.claimId, + childRunbookPath: freshDelegation.childRunbookPath, parentRunId: freshParent.id, - stepId, + stepId: substepId ?? stepId, + parentStepAt: freshDelegation.contextSnapshot.at, loopResult: 'waiting', - }; + }); } // 4c. Check for cancellation @@ -1365,27 +1524,17 @@ export async function claimAndLaunch( adoptedChildRunId, tokenHash, ); - const claimId = claimResult.claimId; - emitClaimedOutput( + return emitClaimedSuccess({ output, - `Claimed ${truncatedToken} -> ${freshDelegation.childRunbookPath}`, - buildClaimedPayload({ - truncatedToken, - claimId, - childRunId: adoptedChildRunId, - childRunbookPath: freshDelegation.childRunbookPath, - parentRunId: freshParent.id, - parentStepAt: freshDelegation.contextSnapshot.at, - }), - ); - return { - ok: true, + truncatedToken, childRunId: adoptedChildRunId, - claimId, + claimId: claimResult.claimId, + childRunbookPath: freshDelegation.childRunbookPath, parentRunId: freshParent.id, - stepId, + stepId: substepId ?? stepId, + parentStepAt: freshDelegation.contextSnapshot.at, loopResult: 'waiting', - }; + }); } // Build delegation linkage for the child. @@ -1402,12 +1551,7 @@ export async function claimAndLaunch( parentEntry: inferEntryFromState(freshParent, delegationFrameKey), }; - const sessionServiceWithOptionalLookup = ctx.sessionService as Partial< - Pick - >; - const existingClaim = sessionServiceWithOptionalLookup.findClaimForDelegation - ? await sessionServiceWithOptionalLookup.findClaimForDelegation(delegationLinkage) - : null; + const existingClaim = await ctx.sessionService.findClaimForDelegation(delegationLinkage); if (existingClaim !== null) { const existingChild = await manager.load(existingClaim.childRunId); if (!existingChild) { @@ -1428,52 +1572,82 @@ export async function claimAndLaunch( childRunId: existingClaim.childRunId, }; } + const claimResult = await claimChildForPipeline( + ctx, + existingClaim.childRunId, + delegationLinkage, + ); + if (!claimResult.ok) { + return claimResultToFailure(claimResult, freshParent.id, stepId); + } await updateStepDelegationChildRunId( manager, freshParent.id, substepId ?? stepId, - existingClaim.childRunId, + claimResult.childRunId, tokenHash, ); - emitClaimedOutput( + return emitClaimedSuccess({ output, - `Claimed ${truncatedToken} -> ${freshDelegation.childRunbookPath}`, - buildClaimedPayload({ - truncatedToken, - claimId: existingClaim.claimId, - childRunId: existingClaim.childRunId, - childRunbookPath: freshDelegation.childRunbookPath, - parentRunId: freshParent.id, - parentStepAt: freshDelegation.contextSnapshot.at, - }), - ); - return { - ok: true, - childRunId: existingClaim.childRunId, - claimId: existingClaim.claimId, + truncatedToken, + childRunId: claimResult.childRunId, + claimId: claimResult.claimId, + childRunbookPath: freshDelegation.childRunbookPath, parentRunId: freshParent.id, - stepId, + stepId: substepId ?? stepId, + parentStepAt: freshDelegation.contextSnapshot.at, loopResult: 'waiting', - }; + }); } // 4e. Reconstitute context vars from frozen snapshot const inheritedContextVars = reconstituteContextVars(freshDelegation.contextSnapshot); const inheritedUserVars = extractInheritedUserVars(freshDelegation.contextSnapshot); - // 4f. Prepare child runbook - const prepResult = await prepareRunbook(freshDelegation.childRunbookPath, inputOpts, cwd, { - inheritedContextVars, - inheritedUserVars, - }); + // 4f. Prepare child runbook from persisted source identity + const childRunbookRef = freshDelegation.childRunbookRef; + const childDisplayPath = freshDelegation.childRunbookPath; + const childResolution = await resolveRunbookRef(cwd, childRunbookRef); + if (!childResolution.ok) { + if (childResolution.reason === 'plugin-context-missing') { + return { + ok: false, + reason: 'prepare-failed', + runbook: childDisplayPath, + code: 'RUNBOOK_REF_RESOLUTION_ERROR', + cause: `Plugin runbook context is unavailable for ${childRunbookRef.source}:${childRunbookRef.path}. Set CLAUDE_PLUGIN_ROOT or install the Rundown Claude Code plugin alongside the CLI.`, + details: { runbook: childDisplayPath }, + }; + } + return { + ok: false, + reason: 'prepare-failed', + runbook: childDisplayPath, + code: 'RUNBOOK_NOT_FOUND', + cause: `Runbook not found: ${childRunbookRef.source}:${childRunbookRef.path}`, + details: { runbook: childDisplayPath }, + }; + } + const childResolved = childResolution.resolved; + + const prepResult = await prepareResolvedRunnableRunbook( + { + resolved: childResolved, + runbookRef: childRunbookRef, + displayName: childDisplayPath, + }, + inputOpts, + cwd, + { inheritedContextVars, inheritedUserVars }, + ); if (!prepResult.ok) { return { ok: false, reason: 'prepare-failed', - runbook: freshDelegation.childRunbookPath, + runbook: childDisplayPath, code: prepResult.code, cause: prepResult.error, - details: prepResult.details, + details: { ...prepResult.details, runbook: childDisplayPath }, }; } @@ -1489,7 +1663,7 @@ export async function claimAndLaunch( const parentPrompted = freshParent.prompted ?? false; // 4g. Launch child runbook - let capturedChildRunId: string | undefined; + let capturedChildRunId: RunId | undefined; let capturedClaimId: ClaimId | undefined; // Captures a write-side claim invariant violation in `afterInit` so we // can surface it as a structured launch-failed result instead of an @@ -1498,7 +1672,7 @@ export async function claimAndLaunch( let invariantViolation: Extract | undefined; const launchResult = await launchRunbook(ctx, prepResult.prepared, { - runbookName: freshDelegation.childRunbookPath, + runbookName: childDisplayPath, prompted: parentPrompted, parentLinkage: delegationLinkage, sessionActivation: { kind: 'none' }, @@ -1528,12 +1702,12 @@ export async function claimAndLaunch( return { ok: false, reason: 'launch-failed', - runbook: freshDelegation.childRunbookPath, + runbook: childDisplayPath, code: ErrorCodes.CLAIM_INVARIANT_VIOLATED.code, cause: `Claim invariant violated for fresh child ${invariantViolation.childRunId}: ${invariantViolation.reason}`, details: { - runbookName: freshDelegation.childRunbookPath, - runbook: freshDelegation.childRunbookPath, + runbookName: childDisplayPath, + runbook: childDisplayPath, }, }; } @@ -1542,10 +1716,10 @@ export async function claimAndLaunch( return { ok: false, reason: 'launch-failed', - runbook: freshDelegation.childRunbookPath, + runbook: childDisplayPath, code: launchResult.code, cause: launchResult.error, - details: { ...launchResult.details, runbook: freshDelegation.childRunbookPath }, + details: { ...launchResult.details, runbook: childDisplayPath }, }; } @@ -1554,12 +1728,12 @@ export async function claimAndLaunch( return { ok: false, reason: 'launch-failed', - runbook: freshDelegation.childRunbookPath, + runbook: childDisplayPath, code: ErrorCodes.LAUNCH_FAILED.code, cause: 'Claim id was not created for delegated child.', details: { - runbookName: freshDelegation.childRunbookPath, - runbook: freshDelegation.childRunbookPath, + runbookName: childDisplayPath, + runbook: childDisplayPath, }, }; } @@ -1570,39 +1744,28 @@ export async function claimAndLaunch( return { ok: false, reason: 'launch-failed', - runbook: freshDelegation.childRunbookPath, + runbook: childDisplayPath, code: ErrorCodes.LAUNCH_FAILED.code, cause: 'Child run id was not captured for delegated child.', details: { - runbookName: freshDelegation.childRunbookPath, - runbook: freshDelegation.childRunbookPath, + runbookName: childDisplayPath, + runbook: childDisplayPath, }, }; } const childRunId = capturedChildRunId; - // Emit claimed output - emitClaimedOutput( + return emitClaimedSuccess({ output, - `Claimed ${truncatedToken} -> ${freshDelegation.childRunbookPath}`, - buildClaimedPayload({ - truncatedToken, - claimId, - childRunId, - childRunbookPath: freshDelegation.childRunbookPath, - parentRunId: freshParent.id, - parentStepAt: freshDelegation.contextSnapshot.at, - }), - ); - - return { - ok: true, + truncatedToken, childRunId, claimId, + childRunbookPath: childDisplayPath, parentRunId: freshParent.id, - stepId, + stepId: substepId ?? stepId, + parentStepAt: freshDelegation.contextSnapshot.at, loopResult: launchResult.loopResult, - }; + }); } finally { // 5. Always release lock await lock.release(parentState.id); diff --git a/packages/cli/src/helpers/transition-orchestrator.ts b/packages/cli/src/helpers/transition-orchestrator.ts index 456e0ee6b..4ac18966a 100644 --- a/packages/cli/src/helpers/transition-orchestrator.ts +++ b/packages/cli/src/helpers/transition-orchestrator.ts @@ -16,6 +16,7 @@ import { type RunbookState, type RunbookStateManager, type RunbookStoppedPayload, + type RunId, type SessionService, type ResolvedStep, type StepPosition, @@ -76,7 +77,7 @@ interface OrchestrateTransitionArgs { /** Event sink for emitting transition lifecycle events. */ sink: TransitionEventSink; /** Unique identifier of the runbook being executed. */ - runbookId: string; + runbookId: RunId; /** All steps in the runbook, used for position calculations. */ steps: ResolvedStep[]; /** The step that was just evaluated. */ @@ -140,7 +141,7 @@ function buildTransitionPositions( async function applyTerminalSideEffects( sessionService: SessionService, policy: TerminalSideEffectsPolicy, - runbookId: RunbookState['id'], + runbookId: RunId, ): Promise { if (policy.releaseRunbook) { await sessionService.releaseRunbook(runbookId); diff --git a/packages/cli/src/helpers/validate-frontmatter-vars.ts b/packages/cli/src/helpers/validate-frontmatter-vars.ts index dc05b0422..b7e116b4c 100644 --- a/packages/cli/src/helpers/validate-frontmatter-vars.ts +++ b/packages/cli/src/helpers/validate-frontmatter-vars.ts @@ -9,7 +9,7 @@ import { * * Returns error diagnostics for: * - Duplicate output names within the outputs array - * - Reserved runtime names (step, index, context — case-insensitive) + * - Reserved runtime names from the parser's shared reserved-name set * * @param outputs - Parsed output declarations from frontmatter, or undefined if absent * @returns Array of validation diagnostics (errors only) diff --git a/packages/cli/src/services/execution.ts b/packages/cli/src/services/execution.ts index 0b8268b0d..ceb889768 100644 --- a/packages/cli/src/services/execution.ts +++ b/packages/cli/src/services/execution.ts @@ -2,6 +2,8 @@ import * as fs from 'node:fs/promises'; import { + assertRunId, + type RunId, buildStepPosition, deriveExecutionAt, buildCompletionKey, @@ -61,7 +63,7 @@ import { import { isInternalRdCommand, executeRdCommandInternal } from './internal-commands.js'; import type { StepVariables } from './execution-vars.js'; import { inferAllDelegateSubsteps } from '../helpers/delegate-inference.js'; -import { resolveRunbookFile } from '../helpers/resolve-runbook.js'; +import { buildRunbookRef, resolveRunbookFile } from '../helpers/resolve-runbook.js'; import { getPolicyEvaluator, getPolicyPrompter, @@ -295,7 +297,7 @@ interface ApplyResultTransitionArgs { sessionService: SessionService; lifecycleService: ExecutionLifecycleService; emitter: ExecutionEventEmitter; - runbookId: string; + runbookId: RunId; steps: ResolvedStep[]; currentState: RunbookState; currentStep: ResolvedStep; @@ -341,7 +343,7 @@ export interface ExecutionLoopOptions { async function applyExecutionTerminalRelease( sessionService: SessionService, - runbookId: string, + runbookId: RunId, mode: ExecutionTerminalReleaseMode, ): Promise { if (mode === 'release-runbook') { @@ -468,7 +470,7 @@ export interface DrainResolvedCompletionsArgs { /** Event emitter for execution progress notifications. */ emitter: ExecutionEventEmitter; /** ID of the runbook being drained. */ - runbookId: string; + runbookId: RunId; /** Parsed step definitions for the runbook. */ steps: ResolvedStep[]; /** Current persisted runbook state. */ @@ -622,7 +624,7 @@ export async function drainResolvedCompletions({ * - In prompted mode (no auto-execution) * * @param manager - Runbook state manager instance - * @param runbookId - ID of the runbook to execute + * @param runbookIdRaw - Unbranded run id; branded to RunId on entry * @param steps - Array of runbook steps * @param cwd - Current working directory for command execution * @param prompted - Whether to run in prompted mode (no auto-execution) @@ -632,13 +634,14 @@ export async function drainResolvedCompletions({ */ export async function runExecutionLoop( manager: RunbookStateManager, - runbookId: string, + runbookIdRaw: string, steps: ResolvedStep[], cwd: string, prompted: boolean, emitter: ExecutionEventEmitter, options: ExecutionLoopOptions = {}, ): Promise<'done' | 'stopped' | 'waiting'> { + const runbookId: RunId = assertRunId(runbookIdRaw); const state = await manager.load(runbookId); if (!state) return 'stopped'; @@ -674,9 +677,8 @@ export async function runExecutionLoop( // Determine the active execution unit: substep if we're at one, otherwise the step. const itemToRender = resolveCurrentExecutionUnit(currentStep, currentState.substep); - // Resolve dynamic values for all data sources (array, file, range). - // The ForIterationService resolves currentValue before each iteration, - // replacing the previous file-only inline resolution. + // Resolve dynamic values for all FOR sources before each iteration. + // ForIterationService owns array, file, and range currentValue hydration. let iterResult: Awaited>; try { iterResult = await iterationService.prepareIteration(runbookId, steps); @@ -876,12 +878,14 @@ export async function runExecutionLoop( if (!childResolved) { throw Errors.delegationRunbookNotFound(target.runbookRef); } + const childRunbookRef = await buildRunbookRef(childResolved); const result = createDelegation( { state: threadedState, stepId: target.stepId, childRunbookPath: childResolved.path, + childRunbookRef, extraVars: undefined, ancestors: [], frameKey, @@ -1001,10 +1005,11 @@ export async function runExecutionLoop( const rdInjected: Record = { ...channels.env }; const workPath = stepVars[BUILTIN_VARIABLES.WorkPath]; const contextId = stepVars[BUILTIN_VARIABLES.ContextId]; - const runId = stepVars[BUILTIN_VARIABLES.RunId]; if (typeof workPath === 'string') rdInjected.RD_WORK_PATH = workPath; if (typeof contextId === 'string') rdInjected.RD_CONTEXT_ID = contextId; - if (typeof runId === 'string') rdInjected.RD_RUN_ID = runId; + rdInjected.RD_RUN_ID = currentState.id; + rdInjected.RD_RUNBOOK_REF = currentState.runbook.path; + rdInjected.RD_RUNBOOK_SOURCE = currentState.runbook.source; // Execute command (unchanged — still routed via internal vs spawn) const extracted = extractDisplayCommand(expandedCommandCode); @@ -1170,7 +1175,7 @@ export function getStepRetryMax(item: Step | ResolvedStep | Substep): number { */ export function buildMetadata(state: RunbookState): RunbookMetadata { return { - file: state.runbook, + file: state.runbook.path, state: `${RUNS_DIR}/${state.id}.json`, prompted: state.prompted ?? undefined, }; diff --git a/packages/cli/src/services/variable-discovery.ts b/packages/cli/src/services/variable-discovery.ts index 3115896e5..a9ede5f26 100644 --- a/packages/cli/src/services/variable-discovery.ts +++ b/packages/cli/src/services/variable-discovery.ts @@ -10,7 +10,7 @@ * @module */ -import { createHash, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { execFileSync as nodeExecFileSync } from 'node:child_process'; @@ -43,36 +43,6 @@ export function setExecFileSyncImpl(fn: typeof nodeExecFileSync): void { execFileSyncImpl = fn; } -/** - * Sanitize a git branch name for use in filesystem paths. - * - * Replaces `/` with `-`, strips characters not in `[a-zA-Z0-9._-]`, - * collapses consecutive hyphens, and trims leading/trailing hyphens. - * - * When sanitization is lossy (characters were stripped), an 8-character SHA-256 - * hash of the original branch name is appended to prevent collisions (e.g. - * `release/1.2` vs `release/12` producing distinct paths). If all characters - * are stripped (e.g. non-ASCII-only names), the hash alone is returned. - * - * @param branch - Raw git branch name - * @returns Sanitized branch name safe for filesystem paths - */ -export function sanitizeBranchName(branch: string): string { - const slashNormalized = branch.replace(/\//g, '-'); - const charStripped = slashNormalized.replace(/[^a-zA-Z0-9._-]/g, ''); - const isLossy = charStripped !== slashNormalized; - const sanitized = charStripped.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, ''); - - if (!sanitized) { - return isLossy ? createHash('sha256').update(branch).digest('hex').slice(0, 8) : ''; - } - if (isLossy) { - const hash = createHash('sha256').update(branch).digest('hex').slice(0, 8); - return `${sanitized}-${hash}`; - } - return sanitized; -} - /** * Detect the current git branch name. * @@ -259,23 +229,21 @@ function normalizeToStringVariables( export const DEFAULT_WORK_PATH = WORK_DIR; /** - * Compute the branch-scoped `WorkPath` value. + * Compute the fixed artifact `WorkPath` value. * - * @param branch - Raw git branch name, or `null` when not in a git repo - * @returns `.rundown/work/` inside git, otherwise `.rundown/work` + * @returns `.rundown/work` */ -function computeWorkPath(branch: string | null): string { - const sanitized = branch ? sanitizeBranchName(branch) : null; - return sanitized ? `${WORK_DIR}/${sanitized}` : WORK_DIR; +function computeWorkPath(): string { + return WORK_DIR; } /** - * Canonical names of rundown built-in template variables. + * Canonical names of Rundown runtime template variables. * - * Single source of truth for the keys produced by {@link getBuiltinVariables}. - * Consumers (e.g. shell-env injection in `execution.ts`) should reference these - * constants rather than hardcoding string literals so a rename here surfaces - * as a typecheck error at every call site. + * Some keys are produced by {@link getBuiltinVariables}; resolver-owned + * identity (`RunbookRef`) is injected during preparation, while execution-owned + * identity (`RunId`) is injected only when creating a `RunnableRunbook`. + * Both are merged after user input so user values cannot override them. */ export const BUILTIN_VARIABLES = { Date: 'Date', @@ -286,14 +254,16 @@ export const BUILTIN_VARIABLES = { Branch: 'Branch', WorkPath: 'WorkPath', RunId: 'RunId', + RunbookRef: 'RunbookRef', ContextId: 'ContextId', } as const; /** * Returns built-in default template variables. * - * These have the lowest precedence and can be overridden by any other source - * (frontmatter, config file, --input-file, or --input flags). + * These discovery defaults have the lowest precedence. `RunbookRef` is + * injected later by preparation and `RunId` is injected later by runnable + * preparation so neither identity can be spoofed by user input. * * @returns Built-in variables with PascalCase names */ @@ -307,8 +277,7 @@ export function getBuiltinVariables(): Record { [BUILTIN_VARIABLES.Month]: String(now.getUTCMonth() + 1).padStart(2, '0'), // MM (01-12, UTC) [BUILTIN_VARIABLES.Day]: String(now.getUTCDate()).padStart(2, '0'), // DD (01-31, UTC) [BUILTIN_VARIABLES.Branch]: branch ?? '', // Raw git branch name (empty when not in git) - [BUILTIN_VARIABLES.WorkPath]: computeWorkPath(branch), // Branch-isolated artifact directory - [BUILTIN_VARIABLES.RunId]: randomBytes(4).toString('hex'), // 8-char hex + [BUILTIN_VARIABLES.WorkPath]: computeWorkPath(), // Fixed artifact directory [BUILTIN_VARIABLES.ContextId]: randomBytes(4).toString('hex'), // 8-char hex }; } @@ -692,7 +661,7 @@ function collectEnvBridgeVars(warnings?: string[]): Record { * earlier ones for the same key during processing in `resolveVariables`. * * ``` - * Layer 0: builtins ← Date, Branch, WorkPath, RunId, ContextId (fresh) + * Layer 0: builtins ← Date, Branch, WorkPath, ContextId (fresh) * Layer 1: discovered ← .rundown/config.yaml (auto-discovered) * Layer 2: inheritedVars ← parent delegation vars (overrides builtins/config) * Layer 3: envBridge ← RD_INPUT_* environment variables @@ -701,7 +670,9 @@ function collectEnvBridgeVars(warnings?: string[]): Record { * * The inherited layer ensures that parent ContextId survives into child * runbooks during delegation, rather than being replaced by a fresh builtin - * or shadowed by project-local config. + * or shadowed by project-local config. Resolver/runtime identity (`RunbookRef` + * and `RunId`) is merged after this discovery stack in the preparation + * pipeline. * * @param options - Variable sources from CLI flags, input-file, and inherited vars * @param options.inputFile - Array of paths to YAML files containing variable definitions (repeatable) @@ -792,7 +763,7 @@ async function enforceFileSourcePolicy( * Resolve variables into the unified template variable map. * * Processes variable layers in precedence order (lowest to highest): - * 1. Built-in defaults (Date, DateTime, Year, Month, Day, Branch, WorkPath, RunId, ContextId) + * 1. Built-in defaults (Date, DateTime, Year, Month, Day, Branch, WorkPath, ContextId) * 2. Auto-discovered .rundown/config.yaml * 2b. Inherited vars from parent delegation * 2c. Environment bridge (RD_INPUT_* env vars) @@ -807,6 +778,9 @@ async function enforceFileSourcePolicy( * - Number → preserved, not stringified * - Other scalar (boolean, null, plain string) → stringified * + * `RunbookRef` and `RunId` are not returned by this function; callers that + * prepare or launch runbooks inject those identity values after user input. + * * @param options - Variable sources from CLI flags, input-file, and inherited vars * @param options.inputFile - Array of paths to YAML files containing variable definitions (repeatable) * @param options.input - Array of key=value flag strings from CLI diff --git a/packages/core/__tests__/helpers/effective-vars.ts b/packages/core/__tests__/helpers/effective-vars.ts index f8ee1124d..104a23911 100644 --- a/packages/core/__tests__/helpers/effective-vars.ts +++ b/packages/core/__tests__/helpers/effective-vars.ts @@ -6,6 +6,7 @@ import { type InitialTemplateVars, type StoredOutputs, } from '../../src/runbook/effective-vars.js'; +import { assertRunId, type RunId } from '../../src/runbook/run-id.js'; import { flattenTemplateVars, type FlattenedTemplateVars, @@ -44,6 +45,20 @@ export function brandStoredOutputsForTest(vars: Readonly> return brandStoredOutputs(vars); } +/** + * Test-only producer of {@link RunId}. + * + * Delegates to the production assertion helper so test fixtures use the + * same canonical run-id validation as production code. + * + * @param runId - Candidate persisted run id + * @returns Branded `RunId` + * @throws {Error} If `runId` is not a canonical `rd_<32 lowercase hex>` value + */ +export function brandRunIdForTest(runId: string): RunId { + return assertRunId(runId); +} + /** * Test-only producer of {@link EffectiveVars} for fixture construction. * diff --git a/packages/core/__tests__/helpers/step-factories.ts b/packages/core/__tests__/helpers/step-factories.ts index f99e24ea1..12f928439 100644 --- a/packages/core/__tests__/helpers/step-factories.ts +++ b/packages/core/__tests__/helpers/step-factories.ts @@ -210,6 +210,7 @@ export function makeStepDelegation(partial: Partial = {}): StepD return { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: makeContextSnapshot(), childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', diff --git a/packages/core/__tests__/runbook/abort-delegation.test.ts b/packages/core/__tests__/runbook/abort-delegation.test.ts index a49b3f146..e7cedc0f4 100644 --- a/packages/core/__tests__/runbook/abort-delegation.test.ts +++ b/packages/core/__tests__/runbook/abort-delegation.test.ts @@ -3,13 +3,22 @@ import { abortDelegation } from '../../src/runbook/delegation-service.js'; import { assertDelegationTokenHash } from '../../src/runbook/delegation-token.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; import type { RunbookState, StepDelegation, SubstepState } from '../../src/runbook/types.js'; -import { brandStoredOutputsForTest, brandEffectiveVarsForTest } from '../helpers/effective-vars.js'; +import { + brandStoredOutputsForTest, + brandEffectiveVarsForTest, + brandRunIdForTest, +} from '../helpers/effective-vars.js'; + +const RUN_ID = brandRunIdForTest(`rd_${'1'.repeat(32)}`); +const CHILD_RUN_ID = brandRunIdForTest(`rd_${'2'.repeat(32)}`); +const OTHER_CHILD_RUN_ID = brandRunIdForTest(`rd_${'3'.repeat(32)}`); /** Helper: create a delegation object. */ function makeDelegation(overrides: Partial = {}): StepDelegation { return { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -21,8 +30,8 @@ function makeDelegation(overrides: Partial = {}): StepDelegation /** Helper: create minimal RunbookState for testing. */ function makeState(substepStates: SubstepState[]): RunbookState { return { - id: 'run-1', - runbook: 'parent.md', + id: RUN_ID, + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Main step', @@ -78,7 +87,7 @@ describe('abortDelegation', () => { }); it('returns needs_force when delegation is claimed without force', () => { - const delegation = makeDelegation({ childRunId: 'child-run-1' }); + const delegation = makeDelegation({ childRunId: CHILD_RUN_ID }); const state = makeState([ { id: '1', frameKey: buildFrameKey('1'), status: 'pending', delegation }, ]); @@ -91,12 +100,12 @@ describe('abortDelegation', () => { expect(result.status).toBe('needs_force'); if (result.status === 'needs_force') { - expect(result.childRunId).toBe('child-run-1'); + expect(result.childRunId).toBe(CHILD_RUN_ID); } }); it('cancels a claimed delegation when force=true', () => { - const delegation = makeDelegation({ childRunId: 'child-run-1' }); + const delegation = makeDelegation({ childRunId: CHILD_RUN_ID }); const state = makeState([ { id: '1', frameKey: buildFrameKey('1'), status: 'pending', delegation }, ]); @@ -134,7 +143,7 @@ describe('abortDelegation', () => { it('only cancels delegation in the targeted frame (cross-frame isolation)', () => { const delegation = makeDelegation(); - const otherDelegation = makeDelegation({ childRunId: 'other-child' }); + const otherDelegation = makeDelegation({ childRunId: OTHER_CHILD_RUN_ID }); const state = makeState([ { id: '1', frameKey: buildFrameKey('1', 1), status: 'pending', delegation }, { id: '1', frameKey: buildFrameKey('1', 2), status: 'pending', delegation: otherDelegation }, @@ -159,7 +168,7 @@ describe('abortDelegation', () => { (ss) => ss.id === '1' && ss.frameKey === buildFrameKey('1', 2), ); expect(other?.delegation?.cancelledAt).toBeNull(); - expect(other?.delegation?.childRunId).toBe('other-child'); + expect(other?.delegation?.childRunId).toBe(OTHER_CHILD_RUN_ID); } }); diff --git a/packages/core/__tests__/runbook/actor-service.test.ts b/packages/core/__tests__/runbook/actor-service.test.ts index 30ebc01a8..cf7acbcf7 100644 --- a/packages/core/__tests__/runbook/actor-service.test.ts +++ b/packages/core/__tests__/runbook/actor-service.test.ts @@ -35,10 +35,14 @@ async function createLifecycleHarness(markdown: string): Promise { }); it('creates and starts actor from persisted state', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = await actorService.createActor(state.id, mockSteps); expect(actor).not.toBeNull(); }); @@ -91,7 +97,9 @@ describe('RunbookActorService', () => { describe('updateFromActor', () => { it('extracts substep ID from flattened machine state (step::N::M)', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1::2', context: { variables: {}, retryCount: 0, substep: '2' }, @@ -103,7 +111,9 @@ describe('RunbookActorService', () => { }); it('extracts step number from simple machine state (step::N)', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::3', context: { variables: {}, retryCount: 0 }, @@ -128,7 +138,9 @@ describe('RunbookActorService', () => { }); it('creates actor and syncs state without sending event', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const result = await actorService.initializeState(state.id, mockSteps); expect(result).not.toBeNull(); expect(result?.step).toBe('1'); @@ -142,7 +154,9 @@ describe('RunbookActorService', () => { }); it('sends event, syncs state, and returns state + snapshot', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const result = await actorService.sendAndSync(state.id, mockSteps, { type: 'PASS' }); expect(result).not.toBeNull(); @@ -159,7 +173,9 @@ describe('RunbookActorService', () => { describe('FOR loop context via actor', () => { it('syncs FOR context fields from actor snapshot', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1', @@ -198,7 +214,9 @@ describe('RunbookActorService', () => { }); it('clears FOR fields when runbook completes', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); // First, set forStack await manager.update(state.id, { @@ -240,7 +258,9 @@ describe('RunbookActorService', () => { describe('implicit ForContext filtering', () => { it('implicit ForContext entries are not persisted', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1::1', context: { @@ -266,7 +286,9 @@ describe('RunbookActorService', () => { }); it('iterationResults not persisted for implicit loops', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1::1', context: { @@ -292,7 +314,9 @@ describe('RunbookActorService', () => { }); it('explicit ForContext entries are persisted normally', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1::1', context: { @@ -319,7 +343,9 @@ describe('RunbookActorService', () => { }); it('iterationResults preserved after explicit FOR loop exits (empty forStack)', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::2', context: { @@ -348,7 +374,9 @@ describe('RunbookActorService', () => { describe('forStack persistence via actor', () => { it('persists forStack with variable source through actor update and reload', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1', @@ -392,7 +420,9 @@ describe('RunbookActorService', () => { }); it('persists forStack with variable source and snapshot through actor update and reload', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const actor = mockActor({ value: 'step::1', @@ -457,7 +487,7 @@ describe('RunbookActorService', () => { }; // Create with templateVars containing arrays - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: templateVars, }); @@ -493,7 +523,7 @@ describe('RunbookActorService', () => { // compileRunbookToMachine(steps) without options — Cause #1 in the handoff. it('seeds compiler context.frontmatterOutputs from RunbookState.frontmatterOutputs', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [{ name: 'SomeVar' }], }); @@ -508,7 +538,7 @@ describe('RunbookActorService', () => { }); it('defaults context.frontmatterOutputs to [] when no outputs declared at run time', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -521,7 +551,7 @@ describe('RunbookActorService', () => { }); it('throws for stale run state missing frontmatterOutputs field', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [], }); @@ -537,7 +567,7 @@ describe('RunbookActorService', () => { }); it('seeds compiler context.templateVars from RunbookState.templateVars (flattened)', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { SomeVar: 'hello', Items: ['a', 'b'] }, }); @@ -564,7 +594,7 @@ describe('RunbookActorService', () => { // state load time; the stream itself is never read. const canonicalTestDir = await realpath(testDir); const stream = createJsonArrayStream(join(canonicalTestDir, 'data.jsonl')); - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { Region: 'us-east-1', @@ -643,7 +673,7 @@ describe('RunbookActorService', () => { // wrote `variables` but never propagated `finalVars` out of the machine. it('persists context.finalVars to RunbookState.finalVars on STOPPED snapshot', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [{ name: 'Result' }], templateVars: { Result: 'failed-value' }, @@ -658,7 +688,7 @@ describe('RunbookActorService', () => { }); it('persists context.finalVars to RunbookState.finalVars on COMPLETE snapshot', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [{ name: 'Result' }], templateVars: { Result: 'passed-value' }, @@ -670,7 +700,7 @@ describe('RunbookActorService', () => { }); it('leaves RunbookState.finalVars undefined when no frontmatter outputs are declared', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [], // No frontmatterOutputs declared → context.finalVars stays {} @@ -681,7 +711,7 @@ describe('RunbookActorService', () => { }); it('leaves RunbookState.finalVars undefined when context.finalVars is empty on COMPLETE', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', frontmatterOutputs: [], // No frontmatterOutputs declared → context.finalVars stays {} }); @@ -702,7 +732,9 @@ describe('RunbookActorService', () => { // the initial state's entry actions on every call — an observable side // effect callers of this method are not signing up for. We assert the // invariant directly by spying on XState's Actor.prototype.start. - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); const xstate = await import('xstate'); const startSpy = jest.spyOn( xstate.Actor.prototype as unknown as { start: () => void }, @@ -725,7 +757,9 @@ describe('RunbookActorService', () => { const frameKey = buildFrameKey('1'); const substepStates: SubstepState[] = [{ id: '1', frameKey, status: 'done', result: 'pass' }]; - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await manager.update(state.id, { substepStates, activeFrameKey: frameKey, @@ -751,7 +785,9 @@ describe('RunbookActorService', () => { { id: '2', frameKey, status: 'pending' }, ]; - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await manager.update(state.id, { substepStates, activeFrameKey: frameKey, @@ -782,7 +818,9 @@ describe('RunbookActorService', () => { const frameKey = buildFrameKey('1'); const substepStates: SubstepState[] = [{ id: '1', frameKey, status: 'pending' }]; - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await manager.update(state.id, { substepStates }); // Snapshot without substepStates in context (e.g. a legacy snapshot path @@ -809,7 +847,9 @@ describe('RunbookActorService', () => { const staleFrameKey = buildFrameKey('1', 1); const newFrameKey = buildFrameKey('1', 2); - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await manager.update(state.id, { activeFrameKey: staleFrameKey }); const actor = mockActor({ @@ -836,7 +876,9 @@ describe('RunbookActorService', () => { // still mirrored — that signals the machine has left an active frame.) const frameKey = buildFrameKey('1'); - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await manager.update(state.id, { activeFrameKey: frameKey }); const actor = mockActor({ diff --git a/packages/core/__tests__/runbook/artifact-manifest-toctou.test.ts b/packages/core/__tests__/runbook/artifact-manifest-toctou.test.ts index cf309f60d..146a0f287 100644 --- a/packages/core/__tests__/runbook/artifact-manifest-toctou.test.ts +++ b/packages/core/__tests__/runbook/artifact-manifest-toctou.test.ts @@ -28,8 +28,8 @@ jest.unstable_mockModule('node:fs', () => ({ const { appendArtifactManifestRecordSync, findArtifactMatches, readArtifactManifest } = await import('../../src/runbook/artifact-manifest.js'); -const RUN_ID = 'wf_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const SECOND_RUN_ID = 'wf_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const RUN_ID = 'rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const SECOND_RUN_ID = 'rd_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; const record = { uri: `rd://artifacts/ctx1/runs/${RUN_ID}/review.json`, diff --git a/packages/core/__tests__/runbook/artifact-manifest.test.ts b/packages/core/__tests__/runbook/artifact-manifest.test.ts index 06c0118f6..1a4fd8203 100644 --- a/packages/core/__tests__/runbook/artifact-manifest.test.ts +++ b/packages/core/__tests__/runbook/artifact-manifest.test.ts @@ -18,9 +18,9 @@ import { } from '../../src/runbook/artifact-manifest.js'; import type { ArtifactPathOptions } from '../../src/runbook/artifact-uri.js'; -const RUN_ID = 'wf_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const SECOND_RUN_ID = 'wf_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const THIRD_RUN_ID = 'wf_cccccccccccccccccccccccccccccccc'; +const RUN_ID = 'rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const SECOND_RUN_ID = 'rd_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const THIRD_RUN_ID = 'rd_cccccccccccccccccccccccccccccccc'; const record = { uri: `rd://artifacts/ctx1/runs/${RUN_ID}/review.json`, @@ -291,14 +291,14 @@ describe('artifact selector resolution', () => { const newer = withRunId(THIRD_RUN_ID, { timestamp: '2026-05-04T03:25:24.000Z', }); - const wrongRunbook = withRunId('wf_dddddddddddddddddddddddddddddddd', { + const wrongRunbook = withRunId('rd_dddddddddddddddddddddddddddddddd', { runbook: { source: 'plugin', path: 'ops/deploy.runbook.md' }, timestamp: '2026-05-04T03:30:24.000Z', }); - const incomplete = withRunId('wf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', { + const incomplete = withRunId('rd_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', { timestamp: '2026-05-04T03:35:24.000Z', }); - const missingFile = withRunId('wf_ffffffffffffffffffffffffffffffff', { + const missingFile = withRunId('rd_ffffffffffffffffffffffffffffffff', { timestamp: '2026-05-04T03:40:24.000Z', }); await writeManifest(cwd, [older, newer, wrongRunbook, incomplete, missingFile]); diff --git a/packages/core/__tests__/runbook/artifact-schema.test.ts b/packages/core/__tests__/runbook/artifact-schema.test.ts index 7c25c2a6e..8cc1040bc 100644 --- a/packages/core/__tests__/runbook/artifact-schema.test.ts +++ b/packages/core/__tests__/runbook/artifact-schema.test.ts @@ -7,7 +7,7 @@ import { } from '../../src/runbook/artifact-schema.js'; import { RUNBOOK_REF_ERROR_TEXT, RunbookRefSchema } from '../../src/runbook/runbook-ref.js'; -const RUN_ID = 'wf_0123456789abcdef0123456789abcdef'; +const RUN_ID = 'rd_0123456789abcdef0123456789abcdef'; const URI = `rd://artifacts/ctx1/runs/${RUN_ID}/review.json`; const VALID_RECORD = { uri: URI, @@ -56,7 +56,7 @@ describe('artifact schemas', () => { it.each([ 'planning/review.runbook.md', { source: 'plugin', path: 'planning/review plan.runbook.md' }, - { source: 'plugin', path: 'planning/review.md' }, + { source: 'plugin', path: 'planning/review' }, { source: 'plugin', path: '../review.runbook.md' }, { source: 'external', path: 'planning/review.runbook.md' }, ])('rejects non-canonical runbook values %#', (runbook) => { @@ -98,7 +98,7 @@ describe('artifact schemas', () => { expect(() => ArtifactRecordSchema.parse({ ...VALID_RECORD, - runId: 'wf_ffffffffffffffffffffffffffffffff', + runId: 'rd_ffffffffffffffffffffffffffffffff', }), ).toThrow(ARTIFACT_ERROR_TEXT.URI_RUN_ID_MISMATCH); expect(() => ArtifactRecordSchema.parse({ ...VALID_RECORD, key: 'other.json' })).toThrow( diff --git a/packages/core/__tests__/runbook/artifact-uri.test.ts b/packages/core/__tests__/runbook/artifact-uri.test.ts index 2c71413c6..9642551bc 100644 --- a/packages/core/__tests__/runbook/artifact-uri.test.ts +++ b/packages/core/__tests__/runbook/artifact-uri.test.ts @@ -10,7 +10,7 @@ import { parseExactArtifactUriParts, } from '../../src/runbook/artifact-uri.js'; -const RUN_ID = 'wf_0123456789abcdef0123456789abcdef'; +const RUN_ID = 'rd_0123456789abcdef0123456789abcdef'; const EXACT_URI = `rd://artifacts/ctx1/runs/${RUN_ID}/review.json`; describe('artifact URI utilities', () => { @@ -99,8 +99,9 @@ describe('artifact URI utilities', () => { }); it.each([ - 'wf_short', - 'wf_0123456789abcdef0123456789ABCDEF', + 'rd_short', + 'rd_0123456789abcdef0123456789ABCDEF', + 'wf_0123456789abcdef0123456789abcdef', 'plain_id', ])('rejects invalid concrete run id %s', (runId) => { expect(() => parseArtifactUri(`rd://artifacts/ctx1/runs/${runId}/review.json`)).toThrow( @@ -125,7 +126,7 @@ describe('artifact URI utilities', () => { expect(artifactUriToPath(EXACT_URI, { cwd: '/repo', workPath: '.rundown/work' })).toBe( path.join( '/repo', - '.rundown/work/.rd-ctx1/runs/wf_0123456789abcdef0123456789abcdef/review.json', + '.rundown/work/.rd-ctx1/runs/rd_0123456789abcdef0123456789abcdef/review.json', ), ); }); diff --git a/packages/core/__tests__/runbook/compiler.test.ts b/packages/core/__tests__/runbook/compiler.test.ts index 965e7e3b2..aa92ef316 100644 --- a/packages/core/__tests__/runbook/compiler.test.ts +++ b/packages/core/__tests__/runbook/compiler.test.ts @@ -12,11 +12,13 @@ import type { RunbookState, } from '../../src/runbook/types.js'; import { createDelegation } from '../../src/runbook/delegation-service.js'; +import type { RunbookRef } from '../../src/runbook/runbook-ref.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; import { createRunbook } from './fixtures.js'; import { brandFlattenedTemplateVarsForTest, brandInitialTemplateVarsForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from '../helpers/effective-vars.js'; @@ -9885,12 +9887,19 @@ echo "processing" describe('parent-aggregation retry with DELEGATE substeps', () => { /** Seed fresh delegations for the named substeps of step `1` (frameKey = buildFrameKey('1')). */ - function seedDelegations(steps: ResolvedStep[], substepIds: string[]): readonly SubstepState[] { + function seedDelegations( + steps: ResolvedStep[], + substepIds: string[], + childRunbookRefForSubstep: (substepId: string) => RunbookRef = (substepId) => ({ + source: 'project', + path: `child-${substepId}.md`, + }), + ): readonly SubstepState[] { const frameKey = buildFrameKey('1'); // Start from a minimal persistent state; createDelegation updates substepStates. let state: RunbookState = { - id: 'test-run', - runbook: 'parent.md', + id: brandRunIdForTest(`rd_${'a'.repeat(32)}`), + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Parent', @@ -9904,11 +9913,13 @@ echo "processing" }; for (const substepId of substepIds) { + const childRunbookRef = childRunbookRefForSubstep(substepId); const result = createDelegation( { state, stepId: `1.${substepId}`, - childRunbookPath: `child-${substepId}.md`, + childRunbookPath: childRunbookRef.path, + childRunbookRef, ancestors: [], frameKey, }, @@ -9933,6 +9944,7 @@ echo "processing" results: Record; seedIds: string[]; trimSteps?: boolean; + childRunbookRefForSubstep?: (substepId: string) => RunbookRef; }): { actor: ReturnType; frameKey: ReturnType } { const substepDefer = { pass: { kind: 'pass' as const, retry: 0, action: { type: 'DEFER' as const } }, @@ -9964,7 +9976,7 @@ echo "processing" // Seed delegations using the full step list — createDelegation validates // the substep exists in the step definitions. - const seededStates = seedDelegations(fullSteps, args.seedIds); + const seededStates = seedDelegations(fullSteps, args.seedIds, args.childRunbookRefForSubstep); // Apply result markers (pass/fail) to the seeded delegations per args.results. // Skip the active substep (the one the dispatched event is supposed to close @@ -10098,6 +10110,26 @@ echo "processing" actor.stop(); }); + it('preserves external child runbook refs when retry reissues delegated substeps', () => { + const externalChildRef: RunbookRef = { + source: 'external', + path: '/tmp/external-child.runbook.md', + }; + const { actor } = buildRetryScenario({ + seedIds: ['1'], + results: { '1': 'fail' }, + childRunbookRefForSubstep: () => externalChildRef, + }); + + actor.send({ type: 'FAIL' }); + + const ctx = actor.getSnapshot().context as RunbookContext; + const post = ctx.substepStates?.find((ss) => ss.id === '1'); + expect(post?.delegation?.childRunbookRef).toEqual(externalChildRef); + + actor.stop(); + }); + /** * Build a scenario where a `RETRY_ERROR` lastAction variant is seeded * directly into the actor's context before it is started. @@ -10497,11 +10529,15 @@ echo "processing" steps: ResolvedStep[], substepId: string, iteration: number, + childRunbookRef: RunbookRef = { + source: 'project', + path: `child-${substepId}-iter${String(iteration)}.md`, + }, ): SubstepState { const frameKey = buildFrameKey('1', iteration); const baseState: RunbookState = { - id: 'test-run', - runbook: 'parent.md', + id: brandRunIdForTest(`rd_${'b'.repeat(32)}`), + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Parent', @@ -10518,7 +10554,8 @@ echo "processing" { state: baseState, stepId: `1.${substepId}`, - childRunbookPath: `child-${substepId}-iter${String(iteration)}.md`, + childRunbookPath: childRunbookRef.path, + childRunbookRef, ancestors: [], frameKey, }, @@ -10565,9 +10602,13 @@ echo "processing" const iter1FrameKey = buildFrameKey('1', 1); const iter2FrameKey = buildFrameKey('1', 2); + const iter1ChildRef: RunbookRef = { + source: 'external', + path: '/tmp/external-iteration-child.runbook.md', + }; // Seed delegations on both iteration frames. - const iter1Seeded = seedIterationDelegation(steps, '1', 1); + const iter1Seeded = seedIterationDelegation(steps, '1', 1, iter1ChildRef); const iter2Seeded = seedIterationDelegation(steps, '1', 2); // Iteration 1: leave pending — the dispatched FAIL below is what closes @@ -10657,6 +10698,7 @@ echo "processing" // Iteration 1's delegation must have a fresh tokenHash (different from seeded). const post1 = ctx.substepStates?.find((ss) => ss.id === '1' && ss.frameKey === iter1FrameKey); expect(post1?.delegation?.tokenHash).not.toBe(iter1Seeded.delegation?.tokenHash); + expect(post1?.delegation?.childRunbookRef).toEqual(iter1ChildRef); actor.stop(); }); diff --git a/packages/core/__tests__/runbook/create-delegation.test.ts b/packages/core/__tests__/runbook/create-delegation.test.ts index 0995653e6..17ab0e212 100644 --- a/packages/core/__tests__/runbook/create-delegation.test.ts +++ b/packages/core/__tests__/runbook/create-delegation.test.ts @@ -7,10 +7,11 @@ import { TOKEN_PREFIX, } from '../../src/runbook/delegation-token.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; -import type { ResolvedStep, AncestorSnapshot } from '../../src/runbook/types.js'; +import type { ResolvedStep, AncestorSnapshot, StepDelegation } from '../../src/runbook/types.js'; import { brandEffectiveVarsForTest, brandInitialTemplateVarsForTest, + brandRunIdForTest, } from '../helpers/effective-vars.js'; import { DEFAULT_TRANSITIONS, @@ -21,6 +22,11 @@ import { makeSteps, } from './delegation-service-fixtures.js'; +const CLAIMED_RUN_ID = brandRunIdForTest(`rd_${'4'.repeat(32)}`); +const COMPLETED_RUN_ID = brandRunIdForTest(`rd_${'5'.repeat(32)}`); +const ANCESTOR_RUN_ID = brandRunIdForTest(`rd_${'6'.repeat(32)}`); +const PARENT_RUN_ID = brandRunIdForTest(`rd_${'7'.repeat(32)}`); + describe('createDelegation', () => { it('succeeds on a step with substeps', () => { const state = makeState(); @@ -29,6 +35,10 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { + source: 'plugin', + path: 'planning/review/review-plan-risk-safety.runbook.md', + }, frameKey: buildFrameKey('1'), }; @@ -39,6 +49,10 @@ describe('createDelegation', () => { expect(result.token).toBeDefined(); expect(result.tokenHash).toBeDefined(); expect(result.delegation).toBeDefined(); + expect(result.delegation.childRunbookRef).toEqual({ + source: 'plugin', + path: 'planning/review/review-plan-risk-safety.runbook.md', + }); expect(result.updatedSubstepStates).toBeDefined(); }); @@ -46,7 +60,13 @@ describe('createDelegation', () => { const state = makeState(); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -62,7 +82,13 @@ describe('createDelegation', () => { const state = makeState(); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -75,7 +101,13 @@ describe('createDelegation', () => { const state = makeState(); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -93,7 +125,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '99.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '99.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -109,7 +147,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -134,7 +178,13 @@ describe('createDelegation', () => { ]; const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -147,9 +197,10 @@ describe('createDelegation', () => { }); it('returns { status: "delegation_exists" } for duplicate active delegation', () => { - const existingDelegation = { + const existingDelegation: StepDelegation = { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'other-child.md', + childRunbookRef: { source: 'project', path: 'other-child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -169,7 +220,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -181,11 +238,12 @@ describe('createDelegation', () => { }); it('allows re-delegation when previous delegation has childRunId set', () => { - const claimedDelegation = { + const claimedDelegation: StepDelegation = { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'other-child.md', + childRunbookRef: { source: 'project', path: 'other-child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, - childRunId: 'run_123', + childRunId: CLAIMED_RUN_ID, createdAt: '2026-02-27T10:00:00.000Z', cancelledAt: null, }; @@ -198,7 +256,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -212,9 +276,10 @@ describe('createDelegation', () => { }); it('allows re-delegation when previous delegation is cancelled', () => { - const cancelledDelegation = { + const cancelledDelegation: StepDelegation = { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'other-child.md', + childRunbookRef: { source: 'project', path: 'other-child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -234,7 +299,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -254,7 +325,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -270,7 +347,7 @@ describe('createDelegation', () => { it('includes provided ancestors in snapshot', () => { const ancestors: readonly AncestorSnapshot[] = [ { - runId: 'grandparent-1', + runId: ANCESTOR_RUN_ID, runbook: 'grandparent.md', step: '1', substep: null, @@ -284,6 +361,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors, frameKey: buildFrameKey('1'), }, @@ -294,7 +372,7 @@ describe('createDelegation', () => { if (result.status !== 'created') return; expect(result.delegation.contextSnapshot.ancestors).toHaveLength(1); - expect(result.delegation.contextSnapshot.ancestors[0].runId).toBe('grandparent-1'); + expect(result.delegation.contextSnapshot.ancestors[0].runId).toBe(ANCESTOR_RUN_ID); }); it('merges extra vars into snapshot vars', () => { @@ -307,6 +385,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { version: '3.0', env: 'override' }, frameKey: buildFrameKey('1'), }, @@ -331,7 +410,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.2', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.2', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -365,7 +450,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.2', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 3) }, + { + state, + stepId: '1.2', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 3), + }, steps, ); @@ -385,7 +476,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.2', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.2', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -401,7 +498,13 @@ describe('createDelegation', () => { const state = makeState(); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -418,7 +521,13 @@ describe('createDelegation', () => { }); const steps = makeSimpleSteps(); const result = createDelegation( - { state, stepId: '1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -437,7 +546,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: 'invalid', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: 'invalid', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -455,7 +570,13 @@ describe('createDelegation', () => { // Parses as step=1, at=2, substep=1. Substep '1' is valid, so 3b passes. // Branch 3c fires because kind !== 'for' and kind !== 'prompted-for'. const result = createDelegation( - { state, stepId: '1.2.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 2) }, + { + state, + stepId: '1.2.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 2), + }, steps, ); @@ -472,7 +593,13 @@ describe('createDelegation', () => { // Parses as step=1, at=2, substep=3. Branch 3b fires because '3' is not in ['1','2']. const result = createDelegation( - { state, stepId: '1.2.3', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.2.3', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -491,7 +618,13 @@ describe('createDelegation', () => { // Parses as step=1, substep=1. Branch 3b fires because step has no substeps at all. const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -510,7 +643,13 @@ describe('createDelegation', () => { // 1.2.1 → step=1, at=2, substep=1; prompted-for is allowed by 3c const delegation = createDelegation( - { state, stepId: '1.2.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 2) }, + { + state, + stepId: '1.2.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 2), + }, steps, ); expect(delegation.status).toBe('created'); @@ -522,7 +661,13 @@ describe('createDelegation', () => { const state = makeState({ forStack: undefined }); const steps = makeForSteps(); const result = createDelegation( - { state, stepId: '1.2.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 2) }, + { + state, + stepId: '1.2.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 2), + }, steps, ); @@ -549,7 +694,13 @@ describe('createDelegation', () => { }); const steps = makeForSteps(); const result = createDelegation( - { state, stepId: '1.3.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 3) }, + { + state, + stepId: '1.3.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 3), + }, steps, ); @@ -565,7 +716,13 @@ describe('createDelegation', () => { const state = makeState({ templateVars: undefined }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -576,11 +733,12 @@ describe('createDelegation', () => { }); it('allows re-delegation after child run completes (childRunId set)', () => { - const completedDelegation = { + const completedDelegation: StepDelegation = { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'old-child.md', + childRunbookRef: { source: 'project', path: 'old-child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, - childRunId: 'completed-run-123', + childRunId: COMPLETED_RUN_ID, createdAt: '2026-02-27T10:00:00.000Z', cancelledAt: null, }; @@ -593,7 +751,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'new-child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'new-child.md', + childRunbookRef: { source: 'project', path: 'new-child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -617,6 +781,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { env: 'production', tier: 'premium' }, frameKey: buildFrameKey('1'), }, @@ -641,7 +806,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.2', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.2', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -666,7 +837,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result1 = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child1.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child1.md', + childRunbookRef: { source: 'project', path: 'child1.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -675,7 +852,13 @@ describe('createDelegation', () => { // Create delegation on different substep const result2 = createDelegation( - { state, stepId: '1.2', childRunbookPath: 'child2.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.2', + childRunbookPath: 'child2.md', + childRunbookRef: { source: 'project', path: 'child2.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -703,7 +886,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -718,7 +907,13 @@ describe('createDelegation', () => { const state = makeState({ forStack: [] }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -732,7 +927,13 @@ describe('createDelegation', () => { const state = makeState({ substep: undefined }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -745,7 +946,7 @@ describe('createDelegation', () => { it('handles ancestors with empty vars', () => { const ancestor: AncestorSnapshot = { - runId: 'anc-1', + runId: ANCESTOR_RUN_ID, runbook: 'ancestor.md', step: '2', substep: null, @@ -758,6 +959,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [ancestor], frameKey: buildFrameKey('1'), }, @@ -776,7 +978,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const before = new Date(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -799,7 +1007,13 @@ describe('createDelegation', () => { }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 2) }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 2), + }, steps, ); @@ -814,9 +1028,10 @@ describe('createDelegation', () => { }); it('allows delegation on iteration 2 when iteration 1 has active delegation', () => { - const delegation1 = { + const delegation1: StepDelegation = { tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -832,7 +1047,13 @@ describe('createDelegation', () => { // Delegate on iteration 2 — should succeed even though iteration 1 has active delegation const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 2) }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 2), + }, steps, ); @@ -853,7 +1074,13 @@ describe('createDelegation', () => { const state = makeState({ substepStates: undefined }); const steps = makeSimpleSteps(); const result = createDelegation( - { state, stepId: '1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1', 3) }, + { + state, + stepId: '1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1', 3), + }, steps, ); @@ -872,6 +1099,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { items: ['a', 'b', 'c'] }, frameKey: buildFrameKey('1'), }, @@ -892,6 +1120,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, frameKey: buildFrameKey('1'), }, steps, @@ -910,6 +1139,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { environment: 'staging', port: 3000 }, ancestors: [], frameKey: buildFrameKey('1'), @@ -930,6 +1160,7 @@ describe('createDelegation', () => { state, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: undefined, ancestors: [], frameKey: buildFrameKey('1'), @@ -951,7 +1182,7 @@ describe('createDelegation', () => { const state = makeState({ parentLinkage: { kind: 'delegation', - parentRunId: 'parent-run-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), }, @@ -959,13 +1190,19 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); expect(result.status).toBe('parent_is_delegated'); if (result.status !== 'parent_is_delegated') return; - expect(result.parentRunId).toBe('parent-run-id'); + expect(result.parentRunId).toBe(PARENT_RUN_ID); expect(result.error.code).toBe('RD-819'); expect(result.error.message).toMatch(/nested delegation forbidden/i); }); @@ -975,7 +1212,13 @@ describe('createDelegation', () => { const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); @@ -990,14 +1233,20 @@ describe('createDelegation', () => { const state = makeState({ parentLinkage: { kind: 'inline', - parentRunId: 'parent-run-id', + parentRunId: PARENT_RUN_ID, parentStepId: '1', }, }); const steps = makeSteps(); const result = createDelegation( - { state, stepId: '1.1', childRunbookPath: 'child.md', frameKey: buildFrameKey('1') }, + { + state, + stepId: '1.1', + childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, + frameKey: buildFrameKey('1'), + }, steps, ); diff --git a/packages/core/__tests__/runbook/delegation-context.test.ts b/packages/core/__tests__/runbook/delegation-context.test.ts index 9dddec204..b800effa7 100644 --- a/packages/core/__tests__/runbook/delegation-context.test.ts +++ b/packages/core/__tests__/runbook/delegation-context.test.ts @@ -1,21 +1,39 @@ import { describe, it, expect } from '@jest/globals'; +import { IDENTITY_OWNED_BUILTINS } from '@rundown-org/parser'; import { buildContextSnapshot, + extractInheritedUserVars, reconstituteContextVars, MAX_ANCESTOR_DEPTH, } from '../../src/runbook/delegation-context.js'; import { mergeEffectiveVars } from '../../src/runbook/effective-vars.js'; -import type { AncestorSnapshot, ContextSnapshot, RunbookState } from '../../src/runbook/types.js'; +import type { + AncestorSnapshot, + ContextSnapshot, + RunbookState, + RunId, +} from '../../src/runbook/types.js'; import { + brandEffectiveVarsForTest, brandInitialTemplateVarsForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from '../helpers/effective-vars.js'; +const RUN_ID = brandRunIdForTest(`rd_${'8'.repeat(32)}`); +const GRANDPARENT_RUN_ID = brandRunIdForTest(`rd_${'9'.repeat(32)}`); +const GREAT_GRANDPARENT_RUN_ID = brandRunIdForTest(`rd_${'a'.repeat(32)}`); +const ANCESTOR_RUN_ID = brandRunIdForTest(`rd_${'b'.repeat(32)}`); + +function ancestorRunId(index: number): RunId { + return brandRunIdForTest(`rd_${index.toString(16).padStart(32, '0')}`); +} + /** Helper: create minimal RunbookState for buildContextSnapshot tests. */ function makeMinimalState(overrides: Partial = {}): RunbookState { return { - id: 'run-1', - runbook: 'parent.md', + id: RUN_ID, + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Main step', @@ -64,7 +82,7 @@ describe('reconstituteContextVars', () => { it('produces grandparent from snapshot.ancestors[0] with offset', () => { const grandparent: AncestorSnapshot = { - runId: 'gp-run', + runId: GRANDPARENT_RUN_ID, runbook: 'grandparent.md', step: '3', substep: '2', @@ -90,14 +108,14 @@ describe('reconstituteContextVars', () => { it('handles 3-level nesting', () => { const grandparent: AncestorSnapshot = { - runId: 'gp-run', + runId: GRANDPARENT_RUN_ID, runbook: 'grandparent.md', step: '2', substep: null, vars: { gp_var: 'gp_value' }, }; const greatGrandparent: AncestorSnapshot = { - runId: 'ggp-run', + runId: GREAT_GRANDPARENT_RUN_ID, runbook: 'great-grandparent.md', step: '1', substep: '3', @@ -184,7 +202,7 @@ describe('reconstituteContextVars', () => { it('emits at and index for ancestors', () => { const ancestor: AncestorSnapshot = { - runId: 'anc-run', + runId: ANCESTOR_RUN_ID, runbook: 'ancestor.md', step: '3', substep: '1', @@ -210,7 +228,7 @@ describe('reconstituteContextVars', () => { it('omits substep when null in ancestor', () => { const ancestor: AncestorSnapshot = { - runId: 'anc-run', + runId: ANCESTOR_RUN_ID, runbook: 'ancestor.md', step: '5', substep: null, @@ -233,7 +251,7 @@ describe('reconstituteContextVars', () => { const ancestors: AncestorSnapshot[] = []; for (let i = 0; i < MAX_ANCESTOR_DEPTH + 1; i++) { ancestors.push({ - runId: `run-${String(i)}`, + runId: ancestorRunId(i), runbook: `runbook-${String(i)}.md`, step: String(i + 1), substep: null, @@ -255,7 +273,7 @@ describe('reconstituteContextVars', () => { const ancestors: AncestorSnapshot[] = []; for (let i = 0; i < MAX_ANCESTOR_DEPTH; i++) { ancestors.push({ - runId: `run-${String(i)}`, + runId: ancestorRunId(i), runbook: `runbook-${String(i)}.md`, step: String(i + 1), substep: null, @@ -277,7 +295,7 @@ describe('reconstituteContextVars', () => { const ancestors: AncestorSnapshot[] = []; for (let i = 0; i < 10; i++) { ancestors.push({ - runId: `run-${String(i)}`, + runId: ancestorRunId(i), runbook: `runbook-${String(i)}.md`, step: String(i + 1), substep: null, @@ -339,7 +357,7 @@ describe('reconstituteContextVars', () => { it('handles ancestors with no vars field', () => { const ancestor: AncestorSnapshot = { - runId: 'anc-run', + runId: ANCESTOR_RUN_ID, runbook: 'ancestor.md', step: '2', substep: null, @@ -383,7 +401,7 @@ describe('reconstituteContextVars', () => { vars: mergeEffectiveVars({ templateVars: { env: 'staging' } }), ancestors: [ { - runId: 'anc', + runId: ANCESTOR_RUN_ID, runbook: 'anc.md', step: '1', substep: null, @@ -466,7 +484,7 @@ describe('reconstituteContextVars', () => { it('preserves JsonObject values in ancestor vars', () => { const ancestor: AncestorSnapshot = { - runId: 'gp-1', + runId: GRANDPARENT_RUN_ID, runbook: 'grandparent.md', step: '1', substep: null, @@ -498,6 +516,45 @@ describe('reconstituteContextVars', () => { }); }); +describe('extractInheritedUserVars', () => { + it('filters runtime identity while preserving user variables and outputs', () => { + const snapshot = { + vars: brandEffectiveVarsForTest({ + RunId: 'rd_parent', + RunbookRef: { source: 'project', path: 'parent.runbook.md' }, + UserInput: 'ok', + OutputValue: 'published', + 'context.parent.vars.UserInput': 'ignored', + }), + ancestors: [], + step: '1', + }; + + expect(extractInheritedUserVars(snapshot)).toEqual( + expect.objectContaining({ UserInput: 'ok', OutputValue: 'published' }), + ); + expect(extractInheritedUserVars(snapshot)).not.toHaveProperty('RunId'); + expect(extractInheritedUserVars(snapshot)).not.toHaveProperty('RunbookRef'); + }); + + it('filters every parser-declared identity-owned built-in', () => { + expect(IDENTITY_OWNED_BUILTINS).toEqual(['RunId', 'RunbookRef']); + + const snapshot = { + vars: brandEffectiveVarsForTest({ + ...Object.fromEntries(IDENTITY_OWNED_BUILTINS.map((key) => [key, `parent-${key}`])), + UserInput: 'ok', + }), + ancestors: [], + step: '1', + }; + + const inherited = extractInheritedUserVars(snapshot); + + expect(inherited).toEqual({ UserInput: 'ok' }); + }); +}); + describe('buildContextSnapshot', () => { it('merges state.variables (step OUTPUTS) into snapshot.vars', () => { const state = makeMinimalState({ diff --git a/packages/core/__tests__/runbook/delegation-propagation.test.ts b/packages/core/__tests__/runbook/delegation-propagation.test.ts index 2828f9a05..7d5ab9037 100644 --- a/packages/core/__tests__/runbook/delegation-propagation.test.ts +++ b/packages/core/__tests__/runbook/delegation-propagation.test.ts @@ -7,13 +7,17 @@ import { } from '../../src/runbook/targeting.js'; import { assertDelegationTokenHash } from '../../src/runbook/delegation-token.js'; import type { DelegationLinkage, RunbookState } from '../../src/runbook/types.js'; -import { brandStoredOutputsForTest } from '../helpers/effective-vars.js'; +import { brandRunIdForTest, brandStoredOutputsForTest } from '../helpers/effective-vars.js'; + +const CHILD_RUN_ID = brandRunIdForTest(`rd_${'1'.repeat(32)}`); +const PARENT_RUN_ID = brandRunIdForTest(`rd_${'2'.repeat(32)}`); +const LOCAL_RUN_ID = brandRunIdForTest(`rd_${'3'.repeat(32)}`); describe('DelegationLinkage extended fields', () => { function makeSchemaState(parentLinkage: Record): Record { return { - id: 'run-child', - runbook: 'child.md', + id: CHILD_RUN_ID, + runbook: { source: 'project', path: 'child.md' }, runbookPath: '/tmp/child.md', runbookSrc: '## 1. Do\n- PASS COMPLETE\n\nDo it.', step: '1', @@ -30,7 +34,7 @@ describe('DelegationLinkage extended fields', () => { it('schema accepts delegation linkage with extended fields', () => { const state = makeSchemaState({ kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: `sha256:${'a'.repeat(64)}`, parentStep: '1', @@ -45,7 +49,7 @@ describe('DelegationLinkage extended fields', () => { it('schema accepts delegation linkage without extended fields', () => { const state = makeSchemaState({ kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: `sha256:${'b'.repeat(64)}`, }); @@ -57,7 +61,7 @@ describe('DelegationLinkage extended fields', () => { it('schema rejects non-positive parentEntry', () => { const state = makeSchemaState({ kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: `sha256:${'c'.repeat(64)}`, parentEntry: 0, @@ -72,12 +76,12 @@ describe('DelegationLinkage type shape', () => { it('returns true when parentLinkage field is present', () => { const linkage: DelegationLinkage = { kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), }; expect(linkage).toBeDefined(); - expect(linkage.parentRunId).toBe('run-parent'); + expect(linkage.parentRunId).toBe(PARENT_RUN_ID); expect(linkage.kind).toBe('delegation'); }); @@ -90,8 +94,8 @@ describe('DelegationLinkage type shape', () => { describe('parentLinkage discriminated union schema', () => { function makeBaseState(overrides: Record = {}): Record { return { - id: 'run-child', - runbook: 'child.md', + id: CHILD_RUN_ID, + runbook: { source: 'project', path: 'child.md' }, runbookPath: '/tmp/child.md', runbookSrc: '## 1. Do\n- PASS COMPLETE\n\nDo it.', step: '1', @@ -109,7 +113,7 @@ describe('parentLinkage discriminated union schema', () => { const state = makeBaseState({ parentLinkage: { kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: `sha256:${'a'.repeat(64)}`, parentStep: '1', @@ -131,7 +135,7 @@ describe('parentLinkage discriminated union schema', () => { const state = makeBaseState({ parentLinkage: { kind: 'inline', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '2', parentStep: '1', parentFrameKey: '1|', @@ -152,7 +156,7 @@ describe('parentLinkage discriminated union schema', () => { const state = makeBaseState({ parentLinkage: { kind: 'bogus', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', }, }); @@ -166,7 +170,7 @@ describe('parentLinkage discriminated union schema', () => { // State with only the old field should have no parentLinkage in the typed result. const state = makeBaseState({ delegation: { - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: `sha256:${'a'.repeat(64)}`, }, @@ -185,7 +189,7 @@ describe('parentLinkage discriminated union schema', () => { const state = makeBaseState({ inlineLinkage: { kind: 'inline', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', }, }); @@ -202,8 +206,8 @@ describe('parentLinkage discriminated union schema', () => { describe('frame identity derivation for propagation', () => { function makeState(overrides: Partial): RunbookState { return { - id: 'run-1', - runbook: 'test.md', + id: LOCAL_RUN_ID, + runbook: { source: 'project', path: 'test.md' }, runbookPath: '/tmp/test.md', runbookSrc: '## 1. Step\n- PASS COMPLETE\n\nTest.', step: '1', @@ -255,7 +259,7 @@ describe('frame identity derivation for propagation', () => { it('uses stored parentFrameKey when available', () => { const linkage: DelegationLinkage = { kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1', tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), parentStep: '1', @@ -274,7 +278,7 @@ describe('frame identity derivation for propagation', () => { it('falls back to deriveActiveFrame when parentFrameKey absent', () => { const linkage: DelegationLinkage = { kind: 'delegation', - parentRunId: 'run-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '2', tokenHash: assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`), // No parentFrameKey, parentEntry — legacy linkage diff --git a/packages/core/__tests__/runbook/delegation-scan.test.ts b/packages/core/__tests__/runbook/delegation-scan.test.ts index fde01e63b..a8b804d24 100644 --- a/packages/core/__tests__/runbook/delegation-scan.test.ts +++ b/packages/core/__tests__/runbook/delegation-scan.test.ts @@ -18,6 +18,27 @@ describe('DelegationScanService', () => { let manager: RunbookStateManager; let scanner: DelegationScanService; + function testRunId(index: number): RunbookState['id'] { + return `rd_${index.toString(16).padStart(32, '0')}` as RunbookState['id']; + } + + const RUN_PARENT_ID = testRunId(1); + const RUN_OTHER_ID = testRunId(2); + const CHILD_RUN_ID = testRunId(3); + const UNRELATED_RUN_ID = testRunId(4); + const CHILD_ONE_ID = testRunId(5); + const CHILD_TWO_ID = testRunId(6); + const RUN_NO_SUBSTEPS_ID = testRunId(7); + const RUN_EMPTY_SUBSTEPS_ID = testRunId(8); + const RUN_NO_DELEGATION_ID = testRunId(9); + const RUN_ONE_ID = testRunId(10); + const RUN_TWO_ID = testRunId(11); + const RUN_TARGET_ID = testRunId(12); + const RUN_NO_DELEGATION_LINKAGE_ID = testRunId(13); + const RUN_CONTEXT_STEP_ID = testRunId(14); + const RUN_LARGE_SUBSTEPS_ID = testRunId(15); + const PARENT_LINK_ID = testRunId(16); + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'delegation-scan-')); manager = new RunbookStateManager(tmpDir); @@ -39,8 +60,8 @@ describe('DelegationScanService', () => { function makeState(id: string, overrides: Partial = {}): RunbookState { return { - id, - runbook: 'parent.md', + id: id as RunbookState['id'], + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Main step', @@ -59,6 +80,7 @@ describe('DelegationScanService', () => { return { tokenHash: hashDelegationToken(token), childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: brandEffectiveVarsForTest({ env: 'staging' }), ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -71,7 +93,7 @@ describe('DelegationScanService', () => { const token = generateDelegationToken(); const delegation = makeDelegation(token); - const state = makeState('run-parent', { + const state = makeState(RUN_PARENT_ID, { substepStates: [ { id: '1', frameKey: buildFrameKey('1'), status: 'pending', delegation }, { id: '2', frameKey: buildFrameKey('1'), status: 'pending' }, @@ -82,7 +104,7 @@ describe('DelegationScanService', () => { const result = await scanner.findByToken(token); expect(result).not.toBeNull(); - expect(result!.parentState.id).toBe('run-parent'); + expect(result!.parentState.id).toBe(RUN_PARENT_ID); expect(result!.substepId).toBe('1'); expect(result!.delegation.tokenHash).toBe(delegation.tokenHash); }); @@ -92,7 +114,7 @@ describe('DelegationScanService', () => { // Write a state with a different token const otherToken = generateDelegationToken(); - const state = makeState('run-other', { + const state = makeState(RUN_OTHER_ID, { substepStates: [ { id: '1', @@ -121,7 +143,7 @@ describe('DelegationScanService', () => { contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [], step: '1' }, }; - const state = makeState('run-parent', { + const state = makeState(RUN_PARENT_ID, { step: '3', substepStates: [{ id: '1', frameKey: buildFrameKey('1'), status: 'pending', delegation }], }); @@ -140,7 +162,7 @@ describe('DelegationScanService', () => { contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [] }, }; - const state = makeState('run-parent', { + const state = makeState(RUN_PARENT_ID, { step: '3', substepStates: [{ id: '1', frameKey: buildFrameKey('1'), status: 'pending', delegation }], }); @@ -160,19 +182,19 @@ describe('DelegationScanService', () => { const linkage: DelegationLinkage = { kind: 'delegation' as const, - parentRunId: 'parent-run', + parentRunId: PARENT_LINK_ID, parentStepId: '1', tokenHash, }; - const childState = makeState('child-run', { + const childState = makeState(CHILD_RUN_ID, { parentLinkage: linkage, }); await writeState(childState); const result = await scanner.findOrphanedChild(tokenHash); expect(result).not.toBeNull(); - expect(result!.id).toBe('child-run'); + expect(result!.id).toBe(CHILD_RUN_ID); }); it('returns null when no orphaned child exists', async () => { @@ -180,7 +202,7 @@ describe('DelegationScanService', () => { const tokenHash = hashDelegationToken(token); // Write an unrelated state - const state = makeState('unrelated-run'); + const state = makeState(UNRELATED_RUN_ID); await writeState(state); const result = await scanner.findOrphanedChild(tokenHash); @@ -193,20 +215,20 @@ describe('DelegationScanService', () => { const linkage1: DelegationLinkage = { kind: 'delegation' as const, - parentRunId: 'parent-run', + parentRunId: PARENT_LINK_ID, parentStepId: '1', tokenHash, }; const linkage2: DelegationLinkage = { kind: 'delegation' as const, - parentRunId: 'parent-run', + parentRunId: PARENT_LINK_ID, parentStepId: '2', tokenHash, }; - const child1 = makeState('child-1', { parentLinkage: linkage1 }); - const child2 = makeState('child-2', { parentLinkage: linkage2 }); + const child1 = makeState(CHILD_ONE_ID, { parentLinkage: linkage1 }); + const child2 = makeState(CHILD_TWO_ID, { parentLinkage: linkage2 }); await writeState(child1); await writeState(child2); @@ -214,14 +236,14 @@ describe('DelegationScanService', () => { const result = await scanner.findOrphanedChild(tokenHash); expect(result).not.toBeNull(); // Should return one of them (first match) - expect(['child-1', 'child-2']).toContain(result!.id); + expect([CHILD_ONE_ID, CHILD_TWO_ID]).toContain(result!.id); }); }); describe('edge cases', () => { it('findByToken handles state with no substepStates', async () => { const token = generateDelegationToken(); - const state = makeState('run-no-substeps', { + const state = makeState(RUN_NO_SUBSTEPS_ID, { substepStates: undefined, }); await writeState(state); @@ -232,7 +254,7 @@ describe('DelegationScanService', () => { it('findByToken handles state with empty substepStates array', async () => { const token = generateDelegationToken(); - const state = makeState('run-empty-substeps', { + const state = makeState(RUN_EMPTY_SUBSTEPS_ID, { substepStates: [], }); await writeState(state); @@ -243,7 +265,7 @@ describe('DelegationScanService', () => { it('findByToken handles substep without delegation field', async () => { const token = generateDelegationToken(); - const state = makeState('run-no-delegation', { + const state = makeState(RUN_NO_DELEGATION_ID, { substepStates: [ { id: '1', frameKey: buildFrameKey('1'), status: 'pending' }, { id: '2', frameKey: buildFrameKey('1'), status: 'pending' }, @@ -259,7 +281,7 @@ describe('DelegationScanService', () => { const token1 = generateDelegationToken(); const token2 = generateDelegationToken(); - const state1 = makeState('run-1', { + const state1 = makeState(RUN_ONE_ID, { substepStates: [ { id: '1', @@ -270,7 +292,7 @@ describe('DelegationScanService', () => { ], }); - const state2 = makeState('run-2', { + const state2 = makeState(RUN_TWO_ID, { substepStates: [ { id: '1', @@ -286,11 +308,11 @@ describe('DelegationScanService', () => { const result1 = await scanner.findByToken(token1); expect(result1).not.toBeNull(); - expect(result1!.parentState.id).toBe('run-1'); + expect(result1!.parentState.id).toBe(RUN_ONE_ID); const result2 = await scanner.findByToken(token2); expect(result2).not.toBeNull(); - expect(result2!.parentState.id).toBe('run-2'); + expect(result2!.parentState.id).toBe(RUN_TWO_ID); }); it('findByToken scans large number of states efficiently', async () => { @@ -299,7 +321,7 @@ describe('DelegationScanService', () => { // Write 50 states without the target token for (let i = 0; i < 50; i++) { const otherToken = generateDelegationToken(); - const state = makeState(`run-${String(i)}`, { + const state = makeState(testRunId(100 + i), { substepStates: [ { id: '1', @@ -313,7 +335,7 @@ describe('DelegationScanService', () => { } // Write one state with the target token - const targetState = makeState('run-target', { + const targetState = makeState(RUN_TARGET_ID, { substepStates: [ { id: '1', @@ -327,12 +349,12 @@ describe('DelegationScanService', () => { const result = await scanner.findByToken(targetToken); expect(result).not.toBeNull(); - expect(result!.parentState.id).toBe('run-target'); + expect(result!.parentState.id).toBe(RUN_TARGET_ID); }); it('findOrphanedChild handles state with no delegation field', async () => { const tokenHash = hashDelegationToken(generateDelegationToken()); - const state = makeState('run-no-delegation-linkage'); + const state = makeState(RUN_NO_DELEGATION_LINKAGE_ID); await writeState(state); const result = await scanner.findOrphanedChild(tokenHash); @@ -346,7 +368,7 @@ describe('DelegationScanService', () => { contextSnapshot: { vars: brandEffectiveVarsForTest({}), ancestors: [], step: '3' }, }; - const state = makeState('run-context-step', { + const state = makeState(RUN_CONTEXT_STEP_ID, { step: '5', substepStates: [ { id: 'a', frameKey: buildFrameKey('5'), status: 'pending' }, @@ -378,7 +400,7 @@ describe('DelegationScanService', () => { // Add delegation to the 50th substep substeps[49] = { ...substeps[49], delegation: makeDelegation(token) }; - const state = makeState('run-large-substeps', { + const state = makeState(RUN_LARGE_SUBSTEPS_ID, { substepStates: substeps, }); await writeState(state); diff --git a/packages/core/__tests__/runbook/delegation-schemas.test.ts b/packages/core/__tests__/runbook/delegation-schemas.test.ts index 507cd08ff..e74782ee9 100644 --- a/packages/core/__tests__/runbook/delegation-schemas.test.ts +++ b/packages/core/__tests__/runbook/delegation-schemas.test.ts @@ -11,10 +11,13 @@ import { import { DelegationStatusEntrySchema, StatusResponseSchema } from '../../src/output/zod-schemas.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; +const PARENT_RUN_ID = `rd_${'1'.repeat(32)}`; +const CHILD_RUN_ID = `rd_${'2'.repeat(32)}`; + describe('AncestorSnapshotSchema', () => { it('accepts valid ancestor snapshot', () => { const result = AncestorSnapshotSchema.safeParse({ - runId: 'run-123', + runId: PARENT_RUN_ID, runbook: 'deploy.md', step: '1', substep: '2', @@ -25,7 +28,7 @@ describe('AncestorSnapshotSchema', () => { it('accepts null substep', () => { const result = AncestorSnapshotSchema.safeParse({ - runId: 'run-123', + runId: PARENT_RUN_ID, runbook: 'deploy.md', step: '1', substep: null, @@ -38,7 +41,7 @@ describe('AncestorSnapshotSchema', () => { describe('AncestorSnapshotSchema structural fields', () => { it('accepts at and index fields', () => { const result = AncestorSnapshotSchema.safeParse({ - runId: 'run-1', + runId: PARENT_RUN_ID, runbook: 'parent.md', step: '2', substep: '1', @@ -51,7 +54,7 @@ describe('AncestorSnapshotSchema structural fields', () => { it('accepts without at and index (backward compat)', () => { const result = AncestorSnapshotSchema.safeParse({ - runId: 'run-1', + runId: PARENT_RUN_ID, runbook: 'parent.md', step: '2', substep: null, @@ -67,7 +70,7 @@ describe('ContextSnapshotSchema', () => { vars: { env: 'staging', version: '1.2.3' }, ancestors: [ { - runId: 'parent-1', + runId: PARENT_RUN_ID, runbook: 'parent.md', step: '1', substep: null, @@ -179,6 +182,7 @@ describe('StepDelegationSchema', () => { const validDelegation = { tokenHash: `sha256:${'a'.repeat(64)}`, childRunbookPath: 'child-runbook.md', + childRunbookRef: { source: 'project', path: 'child-runbook.md' }, contextSnapshot: { vars: { env: 'staging' }, ancestors: [], @@ -212,7 +216,7 @@ describe('StepDelegationSchema', () => { it('accepts non-null childRunId', () => { const result = StepDelegationSchema.safeParse({ ...validDelegation, - childRunId: 'child-run-456', + childRunId: CHILD_RUN_ID, }); expect(result.success).toBe(true); }); @@ -221,7 +225,7 @@ describe('StepDelegationSchema', () => { const result = StepDelegationSchema.safeParse({ ...validDelegation, token: 'rdtk_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', - childRunId: 'child-run-456', + childRunId: CHILD_RUN_ID, }); expect(result.success).toBe(false); }); @@ -280,9 +284,9 @@ describe('ClaimRecordSchema', () => { const validClaim = { kind: 'claim-record', claimId: 'rdclm_abcdefghijklmnopqrstu1', - childRunId: 'wf-2026-05-01-child', + childRunId: CHILD_RUN_ID, tokenHash: `sha256:${'a'.repeat(64)}`, - parentRunId: 'wf-2026-05-01-parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', parentStep: '1', parentFrameKey: '1|', @@ -304,7 +308,7 @@ describe('ClaimRecordSchema', () => { describe('SessionDataSchema claims registry', () => { it('loads sessions without claims using an empty claims registry', () => { - const result = SessionDataSchema.safeParse({ defaultStack: ['parent'] }); + const result = SessionDataSchema.safeParse({ defaultStack: [PARENT_RUN_ID] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.claims).toEqual({}); @@ -313,14 +317,14 @@ describe('SessionDataSchema claims registry', () => { it('rejects claim records whose map key differs from claimId', () => { const result = SessionDataSchema.safeParse({ - defaultStack: ['parent'], + defaultStack: [PARENT_RUN_ID], claims: { rdclm_abcdefghijklmnopqrstu1: { kind: 'claim-record', claimId: 'rdclm_1234567890abcdefghijkl', - childRunId: 'child', + childRunId: CHILD_RUN_ID, tokenHash: `sha256:${'a'.repeat(64)}`, - parentRunId: 'parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', claimedAt: '2026-05-01T00:00:00.000Z', updatedAt: '2026-05-01T00:00:01.000Z', @@ -333,16 +337,16 @@ describe('SessionDataSchema claims registry', () => { it('rejects duplicate claim records for the same childRunId', () => { const base = { kind: 'claim-record', - childRunId: 'child', + childRunId: CHILD_RUN_ID, tokenHash: `sha256:${'b'.repeat(64)}`, - parentRunId: 'parent', + parentRunId: PARENT_RUN_ID, parentStepId: '1.1', claimedAt: '2026-05-01T00:00:00.000Z', updatedAt: '2026-05-01T00:00:01.000Z', }; const result = SessionDataSchema.safeParse({ - defaultStack: ['parent'], + defaultStack: [PARENT_RUN_ID], claims: { rdclm_abcdefghijklmnopqrstu1: { ...base, @@ -411,6 +415,7 @@ describe('SubstepStateSchema backward compatibility', () => { delegation: { tokenHash: `sha256:${'b'.repeat(64)}`, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [] }, childRunId: null, createdAt: '2026-02-27T10:00:00.000Z', @@ -430,11 +435,12 @@ describe('RunbookStateSchema round-trip with delegation', () => { const delegation = { tokenHash: `sha256:${'c'.repeat(64)}`, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: { env: 'prod' }, ancestors: [ { - runId: 'parent-1', + runId: PARENT_RUN_ID, runbook: 'parent.md', step: '1', substep: '2', @@ -442,7 +448,7 @@ describe('RunbookStateSchema round-trip with delegation', () => { }, ], }, - childRunId: 'child-run-789', + childRunId: CHILD_RUN_ID, createdAt: '2026-02-27T10:00:00.000Z', cancelledAt: null, }; @@ -457,7 +463,7 @@ describe('RunbookStateSchema round-trip with delegation', () => { expect(ss?.delegation?.childRunbookPath).toBe('child.md'); expect(ss?.delegation?.contextSnapshot.vars).toEqual({ env: 'prod' }); expect(ss?.delegation?.contextSnapshot.ancestors).toHaveLength(1); - expect(ss?.delegation?.childRunId).toBe('child-run-789'); + expect(ss?.delegation?.childRunId).toBe(CHILD_RUN_ID); } }); }); @@ -578,8 +584,8 @@ describe('StatusResponseSchema with delegations', () => { /** Helper to create a minimal valid RunbookState for schema testing. */ function createMinimalRunbookState(overrides: Record = {}) { return { - id: 'test-id', - runbook: 'test.md', + id: PARENT_RUN_ID, + runbook: { source: 'project', path: 'test.md' }, runbookPath: 'test.md', step: '1', stepName: 'Test step', diff --git a/packages/core/__tests__/runbook/delegation-service-fixtures.ts b/packages/core/__tests__/runbook/delegation-service-fixtures.ts index 58376adcf..42e081cf6 100644 --- a/packages/core/__tests__/runbook/delegation-service-fixtures.ts +++ b/packages/core/__tests__/runbook/delegation-service-fixtures.ts @@ -2,6 +2,7 @@ import { buildFrameKey } from '../../src/runbook/targeting.js'; import type { ResolvedStep, RunbookState, Transitions } from '../../src/runbook/types.js'; import { brandInitialTemplateVarsForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from '../helpers/effective-vars.js'; @@ -94,8 +95,8 @@ function makeTestSubstep(id: string): { /** Helper: create minimal RunbookState for testing. */ export function makeState(overrides: Partial = {}): RunbookState { return { - id: 'run-1', - runbook: 'parent.md', + id: brandRunIdForTest(`rd_${'c'.repeat(32)}`), + runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', step: '1', stepName: 'Main step', diff --git a/packages/core/__tests__/runbook/delegation-service.test.ts b/packages/core/__tests__/runbook/delegation-service.test.ts index 9ea015966..a1fb77ba0 100644 --- a/packages/core/__tests__/runbook/delegation-service.test.ts +++ b/packages/core/__tests__/runbook/delegation-service.test.ts @@ -18,6 +18,7 @@ describe('Result types', () => { delegation: { tokenHash: TEST_TOKEN_HASH, childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: brandEffectiveVars({}), ancestors: [] }, childRunId: null, createdAt: '2026-04-23T00:00:00.000Z', diff --git a/packages/core/__tests__/runbook/execution-lifecycle-service.test.ts b/packages/core/__tests__/runbook/execution-lifecycle-service.test.ts index a6f467cdd..a429c754c 100644 --- a/packages/core/__tests__/runbook/execution-lifecycle-service.test.ts +++ b/packages/core/__tests__/runbook/execution-lifecycle-service.test.ts @@ -35,7 +35,7 @@ describe('ExecutionLifecycleService', () => { describe('ensureActiveEntry', () => { it('initializes entry to 1 on first call', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -47,7 +47,7 @@ describe('ExecutionLifecycleService', () => { }); it('persists initialized entry to disk', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -59,7 +59,7 @@ describe('ExecutionLifecycleService', () => { }); it('increments entry on frame switch', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -82,7 +82,7 @@ describe('ExecutionLifecycleService', () => { }); it('increments entry on GOTO re-entry to same frame', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -101,7 +101,7 @@ describe('ExecutionLifecycleService', () => { }); it('increments entry on RETRY re-entry to same frame', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -120,7 +120,7 @@ describe('ExecutionLifecycleService', () => { }); it('returns without persisting when unchanged', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -139,7 +139,7 @@ describe('ExecutionLifecycleService', () => { }); it('uses provided nextState instead of loading from disk', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -160,7 +160,7 @@ describe('ExecutionLifecycleService', () => { describe('upsertResolvedCompletion', () => { it('stores a new completion', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -180,7 +180,7 @@ describe('ExecutionLifecycleService', () => { }); it('overwrites existing completion', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -211,7 +211,7 @@ describe('ExecutionLifecycleService', () => { }); it('persists completion to disk', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -235,7 +235,7 @@ describe('ExecutionLifecycleService', () => { describe('getResolvedCompletion', () => { it('returns null for missing key', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -251,7 +251,7 @@ describe('ExecutionLifecycleService', () => { describe('consumeResolvedCompletion', () => { it('returns and removes completion', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -276,7 +276,7 @@ describe('ExecutionLifecycleService', () => { }); it('returns null for missing key', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -290,7 +290,7 @@ describe('ExecutionLifecycleService', () => { }); it('persists removal to disk', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -316,7 +316,7 @@ describe('ExecutionLifecycleService', () => { describe('listResolvedCompletions', () => { it('returns completions matching frameKey and entry', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -353,7 +353,7 @@ describe('ExecutionLifecycleService', () => { }); it('filters out non-matching entries', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -384,7 +384,7 @@ describe('ExecutionLifecycleService', () => { }); it('returns empty array when no matches', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -404,7 +404,7 @@ describe('ExecutionLifecycleService', () => { describe('listResolvedCompletions with sentinel', () => { it('includes entry=0 sentinel alongside exact matches', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); @@ -442,7 +442,7 @@ describe('ExecutionLifecycleService', () => { describe('consumeResolvedCompletion with sentinel', () => { it('falls back to sentinel when exact key missing', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); diff --git a/packages/core/__tests__/runbook/for-iteration-service.test.ts b/packages/core/__tests__/runbook/for-iteration-service.test.ts index 3e08fb857..8d3e161a7 100644 --- a/packages/core/__tests__/runbook/for-iteration-service.test.ts +++ b/packages/core/__tests__/runbook/for-iteration-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { RunbookState, ForContext, ResolvedStep } from '../../src/runbook/types.js'; import { brandInitialTemplateVarsForTest, + brandRunIdForTest, brandStoredOutputsForTest, } from '../helpers/effective-vars.js'; import { makeResolvedStepWithFor } from '../helpers/step-factories.js'; @@ -53,8 +54,8 @@ const mockedIsStopped = isRunbookStopped as jest.MockedFunction = {}): RunbookState { return { - id: 'test-123', - runbook: 'test.md', + id: brandRunIdForTest(`rd_${'d'.repeat(32)}`), + runbook: { source: 'project', path: 'test.md' }, runbookPath: '/tmp/test.md', step: '1', stepName: 'Step 1', diff --git a/packages/core/__tests__/runbook/retry-delegation.test.ts b/packages/core/__tests__/runbook/retry-delegation.test.ts index 7a8bd0875..c0383b3ac 100644 --- a/packages/core/__tests__/runbook/retry-delegation.test.ts +++ b/packages/core/__tests__/runbook/retry-delegation.test.ts @@ -13,6 +13,9 @@ import { makeState, makeSteps, } from './delegation-service-fixtures.js'; +import { brandRunIdForTest } from '../helpers/effective-vars.js'; + +const CHILD_RUN_ID = brandRunIdForTest(`rd_${'d'.repeat(32)}`); describe('retryDelegation', () => { it('returns { status: "retried" } with a fresh token on success', () => { @@ -23,6 +26,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -46,6 +50,13 @@ describe('retryDelegation', () => { if (result.status !== 'retried') return; expect(result.tokenHash).not.toBe(initial.tokenHash); expect(result.token.startsWith(TOKEN_PREFIX)).toBe(true); + // childRunbookRef must be preserved verbatim across retry — the operator + // did not pass a new child runbook, so the canonical RunbookRef captured + // at original delegation time must round-trip into the replacement. + expect(result.delegation.childRunbookRef).toEqual({ + source: 'project', + path: 'child.md', + }); const replaced = result.updatedSubstepStates.find((ss) => ss.id === '1'); expect(replaced?.delegation?.tokenHash).toBe(result.tokenHash); @@ -60,6 +71,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { environment: 'staging' }, ancestors: [], frameKey: buildFrameKey('1'), @@ -82,6 +94,13 @@ describe('retryDelegation', () => { expect(result.status).toBe('retried'); if (result.status !== 'retried') return; + // childRunbookRef must be preserved verbatim across retry — the operator + // did not pass a new child runbook, so the canonical RunbookRef captured + // at original delegation time must round-trip into the replacement. + expect(result.delegation.childRunbookRef).toEqual({ + source: 'project', + path: 'child.md', + }); expect(result.updatedSubstepStates.find((ss) => ss.id === '1')?.delegation?.extraVars).toEqual({ environment: 'staging', }); @@ -95,6 +114,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, extraVars: { environment: 'staging', port: 3000 }, ancestors: [], frameKey: buildFrameKey('1'), @@ -118,6 +138,13 @@ describe('retryDelegation', () => { expect(result.status).toBe('retried'); if (result.status !== 'retried') return; + // childRunbookRef must be preserved verbatim across retry — the operator + // did not pass a new child runbook, so the canonical RunbookRef captured + // at original delegation time must round-trip into the replacement. + expect(result.delegation.childRunbookRef).toEqual({ + source: 'project', + path: 'child.md', + }); expect(result.updatedSubstepStates.find((ss) => ss.id === '1')?.delegation?.extraVars).toEqual({ environment: 'production', port: 3000, @@ -132,6 +159,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -146,7 +174,7 @@ describe('retryDelegation', () => { ...ss, status: 'done' as const, result: 'fail' as const, - delegation: { ...ss.delegation, childRunId: 'child-run-1' }, + delegation: { ...ss.delegation, childRunId: CHILD_RUN_ID }, } : ss, ); @@ -187,6 +215,7 @@ describe('retryDelegation', () => { state: { ...baseState, step: '1' }, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -229,6 +258,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -270,6 +300,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -316,6 +347,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1', 2), }, @@ -352,6 +384,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -415,6 +448,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.2.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1', 2), }, @@ -460,6 +494,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, @@ -527,6 +562,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1', 2), }, @@ -576,6 +612,7 @@ describe('retryDelegation', () => { state: baseState, stepId: '1.1', childRunbookPath: 'child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, ancestors: [], frameKey: buildFrameKey('1'), }, diff --git a/packages/core/__tests__/runbook/retry-hook.test.ts b/packages/core/__tests__/runbook/retry-hook.test.ts index 9d5ddc097..7853edcdb 100644 --- a/packages/core/__tests__/runbook/retry-hook.test.ts +++ b/packages/core/__tests__/runbook/retry-hook.test.ts @@ -132,6 +132,7 @@ describe('runRetryHook routing on retryDelegation Result variants', () => { const fixtureDelegation: StepDelegation = { tokenHash: HASH_TEST, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -181,6 +182,7 @@ describe('runRetryHook routing on retryDelegation Result variants', () => { const delegation: StepDelegation = { tokenHash: HASH_NEW, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -311,6 +313,7 @@ describe('runRetryHook routing on retryDelegation Result variants', () => { const staleDelegation: StepDelegation = { tokenHash: HASH_STALE, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -397,6 +400,7 @@ describe('runRetryHook routing on retryDelegation Result variants', () => { const orphanDelegation: StepDelegation = { tokenHash: HASH_ORPHAN, childRunbookPath: 'child-99.md', + childRunbookRef: { source: 'project', path: 'child-99.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -452,6 +456,7 @@ describe('runRetryHook routing on retryDelegation Result variants', () => { const delegation: StepDelegation = { tokenHash: HASH_NO_AT, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, diff --git a/packages/core/__tests__/runbook/retry-single-substep.test.ts b/packages/core/__tests__/runbook/retry-single-substep.test.ts index 289158b77..8b59c2c1a 100644 --- a/packages/core/__tests__/runbook/retry-single-substep.test.ts +++ b/packages/core/__tests__/runbook/retry-single-substep.test.ts @@ -66,6 +66,7 @@ function makeInputs(overrides?: { substepStates?: readonly SubstepState[] }): { const defaultDelegation: StepDelegation = { tokenHash: HASH_TEST, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -134,6 +135,7 @@ describe('retrySingleSubstep', () => { const newDelegation: StepDelegation = { tokenHash: HASH_NEW, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -220,6 +222,7 @@ describe('retrySingleSubstep', () => { const delegationNoAt: StepDelegation = { tokenHash: HASH_NO_AT, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, @@ -276,6 +279,7 @@ describe('retrySingleSubstep', () => { const newDelegation: StepDelegation = { tokenHash: HASH_NEW, childRunbookPath: 'child-1.md', + childRunbookRef: { source: 'project', path: 'child-1.md' }, childRunId: null, createdAt: '2026-01-01T00:00:00.000Z', cancelledAt: null, diff --git a/packages/core/__tests__/runbook/runbook-ref.test.ts b/packages/core/__tests__/runbook/runbook-ref.test.ts index a8299804e..65da588ab 100644 --- a/packages/core/__tests__/runbook/runbook-ref.test.ts +++ b/packages/core/__tests__/runbook/runbook-ref.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from '@jest/globals'; import { RUNBOOK_REF_ERROR_TEXT, RunbookRefSchema } from '../../src/runbook/runbook-ref.js'; describe('RunbookRefSchema', () => { - it('accepts a canonical local-disk runbook reference', () => { + it('accepts source-root-relative Markdown paths without rewriting the extension', () => { + const ref = { + source: 'project', + path: 'ops/deploy.md', + }; + + expect(RunbookRefSchema.parse(ref)).toEqual(ref); + }); + + it('accepts conventional .runbook.md paths', () => { const ref = { source: 'plugin', path: 'planning/review/review-plan-risk-safety.runbook.md', @@ -12,21 +21,28 @@ describe('RunbookRefSchema', () => { }); it.each([ - { source: 'external', path: 'planning/review.runbook.md' }, + { source: 'external', path: 'planning/review.md' }, + { source: 'external', path: '/tmp/foo\0.md' }, + { source: 'external', path: '/tmp/foo\nbar.md' }, + { source: 'external', path: '/tmp/foo\rbar.md' }, + { source: 'external', path: '/tmp/foo\\bar.md' }, + { source: 'external', path: '/tmp/../bar.md' }, + { source: 'external', path: '/tmp/./bar.md' }, + { source: 'external', path: '/tmp//bar.md' }, + { source: 'external', path: '/tmp/bar.txt' }, { source: 'plugin', path: '' }, - { source: 'plugin', path: '/planning/review.runbook.md' }, - { source: 'plugin', path: '../review.runbook.md' }, - { source: 'plugin', path: 'planning//review.runbook.md' }, - { source: 'plugin', path: 'planning/review plan.runbook.md' }, - { source: 'plugin', path: 'planning/review.md' }, - { source: 'project', path: '.rundown/runbooks/planning/review.runbook.md' }, + { source: 'plugin', path: '/planning/review.md' }, + { source: 'plugin', path: '../review.md' }, + { source: 'plugin', path: 'planning//review.md' }, + { source: 'plugin', path: 'planning/review plan.md' }, + { source: 'plugin', path: 'planning/review.txt' }, ])('rejects invalid runbook ref %#', (ref) => { expect(() => RunbookRefSchema.parse(ref)).toThrow(RUNBOOK_REF_ERROR_TEXT.INVALID_RUNBOOK_REF); }); it.each([ - 'planning/review.runbook.md', - { path: 'planning/review.runbook.md' }, + 'planning/review.md', + { path: 'planning/review.md' }, { source: 'plugin' }, ])('rejects structurally invalid runbook refs %#', (ref) => { expect(() => RunbookRefSchema.parse(ref)).toThrow(); diff --git a/packages/core/__tests__/runbook/session-reader.test.ts b/packages/core/__tests__/runbook/session-reader.test.ts index 578ae1dcb..4c4cb2808 100644 --- a/packages/core/__tests__/runbook/session-reader.test.ts +++ b/packages/core/__tests__/runbook/session-reader.test.ts @@ -34,7 +34,7 @@ describe('readActiveRunScope', () => { }); it('returns active WorkPath and ContextId from effective vars', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { WorkPath: '.rundown/work', @@ -50,7 +50,7 @@ describe('readActiveRunScope', () => { }); it('uses stored outputs over template vars', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { WorkPath: '.rundown/work', diff --git a/packages/core/__tests__/runbook/session-service.test.ts b/packages/core/__tests__/runbook/session-service.test.ts index 57a528ff3..d7e2def3c 100644 --- a/packages/core/__tests__/runbook/session-service.test.ts +++ b/packages/core/__tests__/runbook/session-service.test.ts @@ -6,8 +6,9 @@ import { RunbookStateManager } from '../../src/runbook/state.js'; import { SessionService } from '../../src/runbook/session-service.js'; import { assertClaimId } from '../../src/runbook/claim-id.js'; import { assertDelegationTokenHash } from '../../src/runbook/delegation-token.js'; -import type { Step, Runbook } from '../../src/runbook/types.js'; +import type { Step, Runbook, RunId } from '../../src/runbook/types.js'; import { makeBaseStep } from '../helpers/step-factories.js'; +import { brandRunIdForTest } from '../helpers/effective-vars.js'; describe('SessionService', () => { let testDir: string; @@ -32,7 +33,9 @@ describe('SessionService', () => { describe('Runbook stack operations', () => { it('pushRunbook adds to stack', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await sessionService.pushRunbook(state.id); const active = await sessionService.getActive(); @@ -40,8 +43,12 @@ describe('SessionService', () => { }); it('popRunbook removes from stack and returns new top', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); - const child = await manager.create('child.md', mockRunbook, { runbookPath: 'child.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + }); await sessionService.pushRunbook(parent.id); await sessionService.pushRunbook(child.id); @@ -54,9 +61,15 @@ describe('SessionService', () => { }); it('supports arbitrary nesting depth', async () => { - const wf1 = await manager.create('level1.md', mockRunbook, { runbookPath: 'level1.md' }); - const wf2 = await manager.create('level2.md', mockRunbook, { runbookPath: 'level2.md' }); - const wf3 = await manager.create('level3.md', mockRunbook, { runbookPath: 'level3.md' }); + const wf1 = await manager.create({ source: 'project', path: 'level1.md' }, mockRunbook, { + runbookPath: 'level1.md', + }); + const wf2 = await manager.create({ source: 'project', path: 'level2.md' }, mockRunbook, { + runbookPath: 'level2.md', + }); + const wf3 = await manager.create({ source: 'project', path: 'level3.md' }, mockRunbook, { + runbookPath: 'level3.md', + }); await sessionService.pushRunbook(wf1.id); await sessionService.pushRunbook(wf2.id); @@ -74,7 +87,9 @@ describe('SessionService', () => { describe('Stash and pop operations', () => { it('stash saves current runbook and removes from stack', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await sessionService.pushRunbook(state.id); const stashedId = await sessionService.stash(); @@ -85,7 +100,9 @@ describe('SessionService', () => { }); it('unstash restores stashed runbook', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await sessionService.pushRunbook(state.id); await sessionService.stash(); @@ -97,7 +114,9 @@ describe('SessionService', () => { }); it('unstash returns null and clears stash when persisted state is missing', async () => { - const state = await manager.create('temp.md', mockRunbook, { runbookPath: 'temp.md' }); + const state = await manager.create({ source: 'project', path: 'temp.md' }, mockRunbook, { + runbookPath: 'temp.md', + }); await sessionService.pushRunbook(state.id); await sessionService.stash(); @@ -110,8 +129,12 @@ describe('SessionService', () => { }); it('stash refuses to overwrite existing stash', async () => { - const s1 = await manager.create('a.md', mockRunbook, { runbookPath: 'a.md' }); - const s2 = await manager.create('b.md', mockRunbook, { runbookPath: 'b.md' }); + const s1 = await manager.create({ source: 'project', path: 'a.md' }, mockRunbook, { + runbookPath: 'a.md', + }); + const s2 = await manager.create({ source: 'project', path: 'b.md' }, mockRunbook, { + runbookPath: 'b.md', + }); await sessionService.pushRunbook(s1.id); const first = await sessionService.stash(); @@ -128,7 +151,7 @@ describe('SessionService', () => { describe('claim-id runbook targeting', () => { const linkageFor = ( - parentId: string, + parentId: RunId, fill: string, ): Parameters[1] => ({ kind: 'delegation' as const, @@ -151,8 +174,10 @@ describe('SessionService', () => { }; it('registers a delegated child claim without changing the default stack', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); - const child = await manager.create('child.md', mockRunbook, { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkageFor(parent.id, 'a'), }); @@ -174,9 +199,11 @@ describe('SessionService', () => { }); it('reuses the same claim id for the same child', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'b'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -189,6 +216,30 @@ describe('SessionService', () => { expect(second.claim.updatedAt >= first.claim.updatedAt).toBe(true); }); + it('refreshes an existing delegation claim before treating a new child id as claimable', async () => { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const linkage = linkageFor(parent.id, '9'); + const existingChild = await manager.create( + { source: 'project', path: 'existing-child.md' }, + mockRunbook, + { + runbookPath: 'existing-child.md', + parentLinkage: linkage, + }, + ); + const first = assertClaimed(await sessionService.claimRunbook(existingChild.id, linkage)); + + const missingChildId = brandRunIdForTest(`rd_${'f'.repeat(32)}`); + const second = assertClaimed(await sessionService.claimRunbook(missingChildId, linkage)); + + expect(second.claim.claimId).toBe(first.claim.claimId); + expect(second.claim.childRunId).toBe(existingChild.id); + expect(second.claim.claimedAt).toBe(first.claim.claimedAt); + expect(second.claim.updatedAt >= first.claim.updatedAt).toBe(true); + }); + it('returns missing for an unknown claim id', async () => { const resolved = await sessionService.getActiveForClaimId( assertClaimId('rdclm_abcdefghijklmnopqrstu1'), @@ -200,9 +251,11 @@ describe('SessionService', () => { }); it('returns stale for a claim whose child state is missing', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'c'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -218,9 +271,11 @@ describe('SessionService', () => { }); it('returns terminal for a completed claim child', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'd'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -235,10 +290,32 @@ describe('SessionService', () => { } }); + it('returns terminal for a stopped claim child', async () => { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const linkage = linkageFor(parent.id, '7'); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + parentLinkage: linkage, + }); + const claimed = assertClaimed(await sessionService.claimRunbook(child.id, linkage)); + + await manager.update(child.id, { lifecycle: 'stopped' }); + + const resolved = await sessionService.getActiveForClaimId(claimed.claim.claimId); + expect(resolved.status).toBe('terminal'); + if (resolved.status === 'terminal') { + expect(resolved.lifecycle).toBe('stopped'); + } + }); + it('returns unlinked for a child whose delegation linkage no longer matches the claim', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'e'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -259,9 +336,11 @@ describe('SessionService', () => { }); it('returns unlinked when the parent has ended', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'f'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -277,9 +356,11 @@ describe('SessionService', () => { }); it('returns unlinked when the parent state is missing', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '0'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -295,9 +376,11 @@ describe('SessionService', () => { }); it('claimRunbook refuses when the child run state is missing', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '1'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -311,9 +394,11 @@ describe('SessionService', () => { }); it('claimRunbook refuses when persisted child linkage diverges from incoming linkage', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '2'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -329,9 +414,42 @@ describe('SessionService', () => { } }); + it('claimRunbook refuses to refresh an existing child claim with different linkage', async () => { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const originalLinkage = linkageFor(parent.id, '5'); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + parentLinkage: originalLinkage, + }); + const first = assertClaimed(await sessionService.claimRunbook(child.id, originalLinkage)); + + const incomingLinkage = linkageFor(parent.id, '6'); + await manager.update(child.id, { parentLinkage: incomingLinkage }); + + const result = await sessionService.claimRunbook(child.id, incomingLinkage); + + expect(result.status).toBe('linkage-mismatch'); + if (result.status === 'linkage-mismatch') { + expect(result.childRunId).toBe(child.id); + expect(result.incoming).toBe(incomingLinkage); + expect(result.persisted).toEqual({ + kind: 'delegation', + parentRunId: first.claim.parentRunId, + parentStepId: first.claim.parentStepId, + tokenHash: first.claim.tokenHash, + }); + } + }); + it('claimRunbook refuses when child has no parent linkage at all', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); - const child = await manager.create('child.md', mockRunbook, { runbookPath: 'child.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + }); const linkage = linkageFor(parent.id, '4'); const result = await sessionService.claimRunbook(child.id, linkage); @@ -342,9 +460,11 @@ describe('SessionService', () => { }); it('releaseRunbook removes matching claim records', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, 'f'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -358,9 +478,11 @@ describe('SessionService', () => { }); it('stash preserves a claim record and unstashForClaimId restores only the matching child', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '1'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -386,9 +508,11 @@ describe('SessionService', () => { }); it('exposes a stashed claimed child read-only via includeStashed', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '1'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -416,9 +540,11 @@ describe('SessionService', () => { }); it('releaseRunbook clears defaultStack and claim records together when the child completes', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const linkage = linkageFor(parent.id, '1'); - const child = await manager.create('child.md', mockRunbook, { + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { runbookPath: 'child.md', parentLinkage: linkage, }); @@ -447,8 +573,12 @@ describe('SessionService', () => { describe('releaseRunbook default stack cleanup', () => { it('releaseRunbook pops a default-stack child by id', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); - const child = await manager.create('child.md', mockRunbook, { runbookPath: 'child.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + }); await sessionService.pushRunbook(parent.id); await sessionService.pushRunbook(child.id); @@ -462,9 +592,13 @@ describe('SessionService', () => { }); it('releaseRunbook removes a non-top default-stack entry by id', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); - const child = await manager.create('child.md', mockRunbook, { runbookPath: 'child.md' }); - const sibling = await manager.create('sibling.md', mockRunbook, { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); + const child = await manager.create({ source: 'project', path: 'child.md' }, mockRunbook, { + runbookPath: 'child.md', + }); + const sibling = await manager.create({ source: 'project', path: 'sibling.md' }, mockRunbook, { runbookPath: 'sibling.md', }); await sessionService.pushRunbook(parent.id); diff --git a/packages/core/__tests__/runbook/state-schema-version.test.ts b/packages/core/__tests__/runbook/state-schema-version.test.ts index 7d8d9d73f..0def648c9 100644 --- a/packages/core/__tests__/runbook/state-schema-version.test.ts +++ b/packages/core/__tests__/runbook/state-schema-version.test.ts @@ -5,9 +5,12 @@ import * as path from 'node:path'; import { RunbookStateSchema } from '../../src/schemas.js'; import { RunbookStateManager, StaleRunbookStateError } from '../../src/runbook/index.js'; +const BASE_RUN_ID = `rd_${'1'.repeat(32)}`; +const STALE_RUN_ID = `rd_${'2'.repeat(32)}`; + const BASE_SCHEMA_STATE = { - id: 'r1', - runbook: 'x.md', + id: BASE_RUN_ID, + runbook: { source: 'project', path: 'x.md' }, runbookPath: 'x.md', step: '1', stepName: 'x', @@ -89,7 +92,7 @@ describe('RunbookStateManager.load() — stale state enforcement', () => { let manager: RunbookStateManager; const V1_STATE = { - id: 'wf-stale-test', + id: STALE_RUN_ID, runbook: 'x.md', runbookPath: 'x.md', step: '1', @@ -142,7 +145,11 @@ describe('RunbookStateManager.load() — stale state enforcement', () => { it('loads valid v2 state successfully', async () => { const runsDir = path.join(tmpDir, '.rundown', 'runs'); await fs.mkdir(runsDir, { recursive: true }); - const v2State = { ...V1_STATE, schemaVersion: 2 }; + const v2State = { + ...V1_STATE, + runbook: { source: 'project', path: 'x.md' }, + schemaVersion: 2, + }; await fs.writeFile(path.join(runsDir, `${v2State.id}.json`), JSON.stringify(v2State, null, 2)); const result = await manager.load(v2State.id); diff --git a/packages/core/__tests__/runbook/state.test.ts b/packages/core/__tests__/runbook/state.test.ts index 294ea4cf1..455ddab0d 100644 --- a/packages/core/__tests__/runbook/state.test.ts +++ b/packages/core/__tests__/runbook/state.test.ts @@ -3,12 +3,12 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { isError } from '../../src/errors.js'; -import { RunbookStateManager } from '../../src/runbook/state.js'; +import { generateRunId, RunbookStateManager } from '../../src/runbook/state.js'; import { statePath as _statePath } from '../../src/paths.js'; import { SessionService } from '../../src/runbook/session-service.js'; import { ExecutionLifecycleService } from '../../src/runbook/execution-lifecycle-service.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; -import type { Step, Runbook } from '../../src/runbook/types.js'; +import type { Step, Runbook, RunId } from '../../src/runbook/types.js'; import { makeBaseStep, makeSubstep } from '../helpers/step-factories.js'; describe('RunbookStateManager', () => { @@ -34,11 +34,40 @@ describe('RunbookStateManager', () => { await rm(testDir, { recursive: true, force: true }); }); + describe('run id identity', () => { + it('generates canonical branded Rundown run ids', () => { + const runId = generateRunId(); + const branded: RunId = runId; + + expect(branded).toMatch(/^rd_[a-f0-9]{32}$/); + }); + + it('brands RunbookState.id on create/load round trip', async () => { + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); + + const loaded = await manager.load(state.id); + const branded: RunId = loaded!.id; + + expect(branded).toBe(state.id); + expect(branded).toMatch(/^rd_[a-f0-9]{32}$/); + }); + }); + describe('getChildRunbookResult', () => { it('should return pass when child has lifecycle completed', async () => { - const child = await manager.create('child.runbook.md', mockRunbook, { - runbookPath: 'child.runbook.md', - }); + const child = await manager.create( + { source: 'project', path: 'child.runbook.md' }, + mockRunbook, + { + runbookPath: 'child.runbook.md', + }, + ); await manager.update(child.id, { lifecycle: 'completed' }); const result = await lifecycleService.getChildRunbookResult(child.id); @@ -46,9 +75,13 @@ describe('RunbookStateManager', () => { }); it('should return fail when child has lifecycle stopped', async () => { - const child = await manager.create('child.runbook.md', mockRunbook, { - runbookPath: 'child.runbook.md', - }); + const child = await manager.create( + { source: 'project', path: 'child.runbook.md' }, + mockRunbook, + { + runbookPath: 'child.runbook.md', + }, + ); await manager.update(child.id, { lifecycle: 'stopped' }); const result = await lifecycleService.getChildRunbookResult(child.id); @@ -56,9 +89,13 @@ describe('RunbookStateManager', () => { }); it('should return null when child is still active', async () => { - const child = await manager.create('child.runbook.md', mockRunbook, { - runbookPath: 'child.runbook.md', - }); + const child = await manager.create( + { source: 'project', path: 'child.runbook.md' }, + mockRunbook, + { + runbookPath: 'child.runbook.md', + }, + ); await sessionService.pushRunbook(child.id); const result = await lifecycleService.getChildRunbookResult(child.id); @@ -71,9 +108,13 @@ describe('RunbookStateManager', () => { }); it('should return null when child is stashed', async () => { - const child = await manager.create('child.runbook.md', mockRunbook, { - runbookPath: 'child.runbook.md', - }); + const child = await manager.create( + { source: 'project', path: 'child.runbook.md' }, + mockRunbook, + { + runbookPath: 'child.runbook.md', + }, + ); await sessionService.pushRunbook(child.id); await sessionService.stash(); @@ -89,9 +130,13 @@ describe('RunbookStateManager', () => { makeSubstep({ id: '2', description: 'Second reviewer' }), ]; - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); await manager.initializeSubsteps(state.id, substeps, buildFrameKey('1')); const updated = await manager.load(state.id); @@ -110,9 +155,13 @@ describe('RunbookStateManager', () => { makeSubstep({ id: '2', description: 'Second' }), ]; - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); await manager.initializeSubsteps(state.id, substeps, buildFrameKey('1', 1)); const updated = await manager.load(state.id); @@ -128,9 +177,13 @@ describe('RunbookStateManager', () => { it('preserves entries from other frames when frameKey is provided', async () => { const substeps = [makeSubstep({ id: '1', description: 'First' })]; - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); // Initialize iteration 1 await manager.initializeSubsteps(state.id, substeps, buildFrameKey('1', 1)); @@ -156,9 +209,13 @@ describe('RunbookStateManager', () => { it('replaces entries from same frame on re-initialization', async () => { const substeps = [makeSubstep({ id: '1', description: 'First' })]; - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); await manager.initializeSubsteps(state.id, substeps, buildFrameKey('1', 1)); // Re-initialize same frame — should replace, not duplicate @@ -171,9 +228,13 @@ describe('RunbookStateManager', () => { describe('RunbookStateManager substep lifecycle', () => { it('completes substep with result', async () => { - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); await manager.update(state.id, { substepStates: [{ id: '1', frameKey: buildFrameKey('1'), status: 'running' }], }); @@ -190,9 +251,13 @@ describe('RunbookStateManager', () => { }); it('completes substep scoped by frameKey', async () => { - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); // Initialize substeps for two different frames (simulating FOR loop iterations) const frameA = buildFrameKey('1', 1); const frameB = buildFrameKey('1', 2); @@ -225,13 +290,23 @@ describe('RunbookStateManager', () => { }); describe('create with prompted flag', () => { + it('generates canonical rd-prefixed run ids', async () => { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); + + expect(state.id).toMatch(/^rd_[a-f0-9]{32}$/); + }); + it('defaults to auto mode (prompted undefined)', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); expect(state.prompted).toBeUndefined(); }); it('accepts prompted option', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', prompted: true, }); @@ -239,26 +314,33 @@ describe('RunbookStateManager', () => { }); }); - describe('create with runbookRef', () => { - it('persists a canonical runbookRef through create/load round-trip', async () => { + describe('create with runbook identity', () => { + it('persists canonical runbook identity through create/load round-trip', async () => { const runbookRef = { source: 'plugin' as const, path: 'planning/write-plan.runbook.md' }; - const state = await manager.create('rundown:write-plan', mockRunbook, { + const state = await manager.create(runbookRef, mockRunbook, { runbookPath: '../../plugin/runbooks/planning/write-plan.runbook.md', - runbookRef, }); - expect(state.runbookRef).toEqual(runbookRef); + expect(state.runbook).toEqual(runbookRef); + expect(Object.hasOwn(state, 'runbookRef')).toBe(false); const loaded = await manager.load(state.id); - expect(loaded?.runbookRef).toEqual(runbookRef); + expect(loaded?.runbook).toEqual(runbookRef); + expect(Object.hasOwn(loaded ?? {}, 'runbookRef')).toBe(false); }); }); describe('List and delete operations', () => { it('list returns all runbook states', async () => { - await manager.create('one.md', mockRunbook, { runbookPath: 'one.md' }); - await manager.create('two.md', mockRunbook, { runbookPath: 'two.md' }); - await manager.create('three.md', mockRunbook, { runbookPath: 'three.md' }); + await manager.create({ source: 'project', path: 'one.md' }, mockRunbook, { + runbookPath: 'one.md', + }); + await manager.create({ source: 'project', path: 'two.md' }, mockRunbook, { + runbookPath: 'two.md', + }); + await manager.create({ source: 'project', path: 'three.md' }, mockRunbook, { + runbookPath: 'three.md', + }); const states = await manager.list(); @@ -271,7 +353,9 @@ describe('RunbookStateManager', () => { }); it('delete removes runbook state', async () => { - const state = await manager.create('delete.md', mockRunbook, { runbookPath: 'delete.md' }); + const state = await manager.create({ source: 'project', path: 'delete.md' }, mockRunbook, { + runbookPath: 'delete.md', + }); await manager.delete(state.id); @@ -332,7 +416,9 @@ describe('RunbookStateManager', () => { }); it('setLastResult updates last result', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); await lifecycleService.setLastResult(state.id, 'pass'); @@ -345,7 +431,9 @@ describe('RunbookStateManager', () => { }); it('loads legacy targetPath fields and strips them on save', async () => { - const state = await manager.create('legacy.md', mockRunbook, { runbookPath: 'legacy.md' }); + const state = await manager.create({ source: 'project', path: 'legacy.md' }, mockRunbook, { + runbookPath: 'legacy.md', + }); const resolvedKey = '1||1|'; await manager.update(state.id, { @@ -385,7 +473,7 @@ describe('RunbookStateManager', () => { describe('update variables/templateVars semantics', () => { it('replaces templateVars wholesale when updates.templateVars is defined', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { env: 'staging', port: 3000 }, }); @@ -399,7 +487,7 @@ describe('RunbookStateManager', () => { }); it('preserves existing templateVars when updates.templateVars is undefined', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: { env: 'staging', port: 3000 }, }); @@ -410,7 +498,7 @@ describe('RunbookStateManager', () => { }); it('shallow-merges variables when updates.variables is defined', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); await manager.update(state.id, { variables: { A: '1', B: '2' } }); @@ -421,7 +509,7 @@ describe('RunbookStateManager', () => { }); it('preserves existing variables when updates.variables is undefined', async () => { - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); await manager.update(state.id, { variables: { A: '1' } }); @@ -434,7 +522,7 @@ describe('RunbookStateManager', () => { describe('isPrompted', () => { it('returns true when parent has prompted flag', async () => { - const parent = await manager.create('parent.md', mockRunbook, { + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { runbookPath: 'parent.md', prompted: true, }); @@ -444,7 +532,9 @@ describe('RunbookStateManager', () => { }); it('returns false when parent has no prompted flag', async () => { - const parent = await manager.create('parent.md', mockRunbook, { runbookPath: 'parent.md' }); + const parent = await manager.create({ source: 'project', path: 'parent.md' }, mockRunbook, { + runbookPath: 'parent.md', + }); const result = await lifecycleService.isPrompted(parent.id); expect(result).toBe(false); @@ -460,10 +550,14 @@ describe('RunbookStateManager', () => { it('should store runbookSrc when provided to create()', async () => { const runbookSrc = '# Test Runbook\n\n## 1. Step 1\n\nRendered content'; - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - runbookSrc, - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + runbookSrc, + }, + ); expect(state.runbookSrc).toBe(runbookSrc); @@ -473,9 +567,13 @@ describe('RunbookStateManager', () => { }); it('should allow runbookSrc to be undefined', async () => { - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); expect(state.runbookSrc).toBeUndefined(); }); @@ -488,9 +586,13 @@ describe('RunbookStateManager', () => { return; } - const state = await manager.create('test.runbook.md', mockRunbook, { - runbookPath: 'test.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'test.runbook.md' }, + mockRunbook, + { + runbookPath: 'test.runbook.md', + }, + ); const statePath = _statePath(testDir, state.id); const stats = await stat(statePath); @@ -503,7 +605,9 @@ describe('RunbookStateManager', () => { describe('FOR loop context persistence', () => { it('persists FOR fields through round-trip', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); // Update with forStack const updated = await manager.update(state.id, { @@ -554,7 +658,9 @@ describe('RunbookStateManager', () => { describe('Legacy snapshot rejection', () => { it('rejects state with GOTO_NEXT action in lastAction', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); // Manually save legacy state with GOTO_NEXT const fs = await import('node:fs/promises'); @@ -570,7 +676,9 @@ describe('RunbookStateManager', () => { }); it('rejects state with instance field', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); // Manually save legacy state with instance field const fs = await import('node:fs/promises'); @@ -586,7 +694,9 @@ describe('RunbookStateManager', () => { }); it('provides helpful error message for legacy snapshots', async () => { - const state = await manager.create('test.md', mockRunbook, { runbookPath: 'test.md' }); + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + }); // Manually save legacy state with GOTO_NEXT const fs = await import('node:fs/promises'); @@ -618,7 +728,7 @@ describe('RunbookStateManager', () => { env: 'prod', }; - const state = await manager.create('test.md', mockRunbook, { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', templateVars: templateVars, }); @@ -635,9 +745,13 @@ describe('RunbookStateManager', () => { describe('RunbookStateManager.delete — output capture cleanup', () => { it('removes the per-run outputs directory alongside the state JSON', async () => { - const state = await manager.create('demo.runbook.md', mockRunbook, { - runbookPath: '/abs/demo.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'demo.runbook.md' }, + mockRunbook, + { + runbookPath: '/abs/demo.runbook.md', + }, + ); // Simulate captured output files written during a run const outDir = join(testDir, '.rundown', 'runs', state.id, 'outputs', '1'); await (await import('node:fs/promises')).mkdir(outDir, { recursive: true }); @@ -658,9 +772,13 @@ describe('RunbookStateManager', () => { }); it('is a no-op when the outputs directory does not exist', async () => { - const state = await manager.create('demo.runbook.md', mockRunbook, { - runbookPath: '/abs/demo.runbook.md', - }); + const state = await manager.create( + { source: 'project', path: 'demo.runbook.md' }, + mockRunbook, + { + runbookPath: '/abs/demo.runbook.md', + }, + ); // No outputs dir created — delete must still succeed await expect(manager.delete(state.id)).resolves.toBeUndefined(); }); diff --git a/packages/core/__tests__/runbook/types.test.ts b/packages/core/__tests__/runbook/types.test.ts index e9fb999e7..e06d4bcfe 100644 --- a/packages/core/__tests__/runbook/types.test.ts +++ b/packages/core/__tests__/runbook/types.test.ts @@ -7,7 +7,7 @@ import { isJsonObject, } from '../../src/runbook/types.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; -import { brandStoredOutputsForTest } from '../helpers/effective-vars.js'; +import { brandRunIdForTest, brandStoredOutputsForTest } from '../helpers/effective-vars.js'; import { makeSubstep } from '../helpers/step-factories.js'; describe('SubstepState type', () => { @@ -106,8 +106,8 @@ describe('Substep interface', () => { describe('RunbookState runbookSrc field', () => { it('should include runbookSrc field', () => { const state: RunbookState = { - id: 'wf-2026-01-29-abc123', - runbook: 'test.runbook.md', + id: brandRunIdForTest(`rd_${'e'.repeat(32)}`), + runbook: { source: 'project', path: 'test.runbook.md' }, runbookPath: 'test.runbook.md', step: '1', stepName: 'Test step', diff --git a/packages/core/__tests__/schemas.test.ts b/packages/core/__tests__/schemas.test.ts index 73675cd01..43f4296df 100644 --- a/packages/core/__tests__/schemas.test.ts +++ b/packages/core/__tests__/schemas.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from '@jest/globals'; import { parseHookInput, + RunIdSchema, RunbookStateSchema, StepIdSchema, ActionSchema, @@ -9,15 +10,17 @@ import { makeTemplateVarValueSchema, makeRunbookStateSchema, } from '../src/schemas.js'; -import { isJsonArrayStream } from '../src/runbook/types.js'; +import { isJsonArrayStream, type RunId } from '../src/runbook/types.js'; + +const VALID_RUN_ID = `rd_${'a'.repeat(32)}`; /** * Creates a valid runbook state object for testing. * Note: step is now a string ("1", "ErrorHandler", etc.) */ const createValidState = (overrides: Record = {}) => ({ - id: 'test-id', - runbook: 'test.md', + id: VALID_RUN_ID, + runbook: { source: 'project', path: 'test.md' }, runbookPath: 'test.md', step: '1', stepName: 'Test Step', @@ -29,6 +32,15 @@ const createValidState = (overrides: Record = {}) => ({ ...overrides, }); +describe('RunIdSchema', () => { + it('parses canonical rd-prefixed run ids as branded RunId values', () => { + const runId = RunIdSchema.parse(VALID_RUN_ID); + const branded: RunId = runId; + + expect(branded).toBe(VALID_RUN_ID); + }); +}); + describe('parseHookInput', () => { it('parses valid PostToolUse input', () => { const input = JSON.stringify({ @@ -357,22 +369,35 @@ describe('RunbookStateSchema frontmatterOutputs', () => { }); }); -describe('RunbookStateSchema runbookRef', () => { - it('accepts a canonical optional runbookRef', () => { +describe('RunbookStateSchema runbookRef cleanup', () => { + it('rejects the removed runbookRef field', () => { const state = createValidState({ - runbookRef: { source: 'plugin', path: 'planning/write-plan.runbook.md' }, + runbookRef: { source: 'project', path: 'ops/deploy.md' }, }); - expect(RunbookStateSchema.parse(state).runbookRef).toEqual({ - source: 'plugin', - path: 'planning/write-plan.runbook.md', - }); + expect(() => RunbookStateSchema.parse(state)).toThrow(/runbookRef/); }); +}); - it('rejects an invalid optional runbookRef', () => { +describe('RunbookStateSchema runbook identity', () => { + it('accepts persisted state with canonical RunbookRef runbook object', () => { const state = createValidState({ - runbookRef: { source: 'plugin', path: 'planning/write-plan.md' }, + runbook: { source: 'project', path: 'ops/deploy.md' }, + }); + + expect(RunbookStateSchema.parse(state).runbook).toEqual({ + source: 'project', + path: 'ops/deploy.md', }); + }); + + it.each([ + { source: 'project', path: '../deploy.md' }, + { source: 'project', path: '/deploy.md' }, + { source: 'project', path: 'ops\\deploy.md' }, + { source: 'project', path: 'ops/deploy.txt' }, + ])('rejects unsafe persisted runbook identity %#', (runbook) => { + const state = createValidState({ runbook }); expect(() => RunbookStateSchema.parse(state)).toThrow(); }); @@ -719,6 +744,7 @@ describe('makeRunbookStateSchema — SEC1 nested snapshot var protection', () => delegation: { tokenHash: `sha256:${'a'.repeat(64)}`, childRunbookPath: '/project/child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: { items: escaping }, ancestors: [], @@ -746,6 +772,7 @@ describe('makeRunbookStateSchema — SEC1 nested snapshot var protection', () => delegation: { tokenHash: `sha256:${'a'.repeat(64)}`, childRunbookPath: '/project/child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: { items: safe }, ancestors: [], @@ -773,6 +800,7 @@ describe('makeRunbookStateSchema — SEC1 nested snapshot var protection', () => delegation: { tokenHash: `sha256:${'a'.repeat(64)}`, childRunbookPath: '/project/child.md', + childRunbookRef: { source: 'project', path: 'child.md' }, contextSnapshot: { vars: {}, ancestors: [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d36cdba6..17c452afa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export { SessionStateSchema, type ValidatedSessionState, RunbookStateSchema, + RunIdSchema, type ValidatedRunbookState, // Schema-first exports StepIdSchema, diff --git a/packages/core/src/runbook/artifact-errors.ts b/packages/core/src/runbook/artifact-errors.ts index a7a171e6a..59f506c32 100644 --- a/packages/core/src/runbook/artifact-errors.ts +++ b/packages/core/src/runbook/artifact-errors.ts @@ -7,7 +7,7 @@ export const ARTIFACT_ERROR_TEXT = { RECURSIVE_WILDCARD: 'Recursive artifact URI wildcards are not supported in v1', UNRESOLVED_TEMPLATE_MARKER: 'Artifact URI contains unresolved template marker', BARE_BUILTIN_PLACEHOLDER: 'Artifact URI contains bare built-in placeholder', - INVALID_RUN_ID: 'Invalid RunId: expected wf_<32 lowercase hex chars>', + INVALID_RUN_ID: 'Invalid RunId: expected rd_<32 lowercase hex chars>', INVALID_URI_FRAGMENT: 'Artifact URI fragments are not supported', URI_MUST_BE_EXACT: 'uri must be an exact artifact URI', URI_CONTEXT_MISMATCH: 'uri contextId does not match contextId', diff --git a/packages/core/src/runbook/artifact-uri.ts b/packages/core/src/runbook/artifact-uri.ts index 0bcc8be89..505a47a0a 100644 --- a/packages/core/src/runbook/artifact-uri.ts +++ b/packages/core/src/runbook/artifact-uri.ts @@ -3,11 +3,8 @@ import * as path from 'node:path'; import { isNodeErrorCode } from '../errors.js'; import { assertSafeId } from '../paths.js'; import { ARTIFACT_ERROR_TEXT } from './artifact-errors.js'; - -/** - * Concrete run identifier syntax used by artifact producer URIs and metadata. - */ -export const RUN_ID_PATTERN = /^wf_[a-f0-9]{32}$/; +import { RUN_ID_PATTERN } from './run-id.js'; +export { RUN_ID_PATTERN } from './run-id.js'; const TEMPLATE_MARKER_PATTERN = /{{.*}}/; const BARE_BUILTIN_PLACEHOLDERS = new Set(['ContextId', 'RunId']); const SUPPORTED_SELECTOR_QUERY_KEYS = new Set(['status', 'runbook', 'source', 'latest']); @@ -284,7 +281,7 @@ function validateConcreteRunId(runId: string): void { * Validate that a run id names one concrete run. * * @param runId - Candidate run identifier - * @throws {Error} When the run id is not `wf_` plus 32 lowercase hex characters + * @throws {Error} When the run id is not `rd_` plus 32 lowercase hex characters */ export function assertConcreteRunId(runId: string): void { validateConcreteRunId(runId); diff --git a/packages/core/src/runbook/claim-id.ts b/packages/core/src/runbook/claim-id.ts index 12bda8bc3..ff6f9c5f6 100644 --- a/packages/core/src/runbook/claim-id.ts +++ b/packages/core/src/runbook/claim-id.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import type { DelegationTokenHash } from './delegation-token.js'; +import type { RunId } from './run-id.js'; import type { FrameKey } from './targeting.js'; import type { DelegationLinkage, ParentLinkageBase, RunbookState } from './types.js'; @@ -21,7 +22,7 @@ export interface ClaimRecord { /** Stable command-targeting handle returned by rd claim. */ readonly claimId: ClaimId; /** Child runbook state id controlled by this claim. */ - readonly childRunId: RunbookState['id']; + readonly childRunId: RunId; /** Hash of the delegation token that produced this claimed child. */ readonly tokenHash: DelegationTokenHash; /** Parent runbook state id that delegated the child. */ @@ -47,6 +48,9 @@ export interface ClaimRecord { * freshly created or idempotently refreshed record. * - `missing-child` — Transient failure: the child run state file is absent * on disk. May be recoverable by pruning + restarting the parent. + * - `terminal-child` — The child run exists but is already completed or + * stopped, so the delegation should be treated as resolved rather than + * claimed again. * - `linkage-mismatch` — Corruption signal: the child's persisted * `parentLinkage` disagrees with the freshly token-validated `incoming` * linkage on at least one identifying field (`parentRunId`, @@ -58,10 +62,15 @@ export interface ClaimRecord { */ export type ClaimRunbookResult = | { readonly status: 'claimed'; readonly claim: ClaimRecord } - | { readonly status: 'missing-child'; readonly childRunId: RunbookState['id'] } + | { readonly status: 'missing-child'; readonly childRunId: RunId } + | { + readonly status: 'terminal-child'; + readonly childRunId: RunId; + readonly lifecycle: 'completed' | 'stopped'; + } | { readonly status: 'linkage-mismatch'; - readonly childRunId: RunbookState['id']; + readonly childRunId: RunId; readonly incoming: DelegationLinkage; readonly persisted: RunbookState['parentLinkage']; }; @@ -126,7 +135,7 @@ export function generateClaimId(): ClaimId { */ export function createClaimRecord( claimId: ClaimId, - childRunId: RunbookState['id'], + childRunId: RunId, linkage: DelegationLinkage, now: string, ): ClaimRecord { diff --git a/packages/core/src/runbook/delegation-context.ts b/packages/core/src/runbook/delegation-context.ts index b2dc3b27f..61ca19c9c 100644 --- a/packages/core/src/runbook/delegation-context.ts +++ b/packages/core/src/runbook/delegation-context.ts @@ -1,3 +1,4 @@ +import { IDENTITY_OWNED_BUILTINS } from '@rundown-org/parser'; import { mergeEffectiveVars } from './effective-vars.js'; import type { AncestorSnapshot, ContextSnapshot, RunbookState, TemplateVarValue } from './types.js'; import { getActiveForContext, deriveExecutionAt } from './targeting.js'; @@ -5,6 +6,8 @@ import { getActiveForContext, deriveExecutionAt } from './targeting.js'; /** Maximum depth for parent context chain addressing. */ export const MAX_ANCESTOR_DEPTH = 32; +const IDENTITY_OWNED_BUILTIN_SET = new Set(IDENTITY_OWNED_BUILTINS); + /** * Reconstitute inherited context variables from a frozen delegation snapshot. * @@ -162,9 +165,10 @@ export function buildContextSnapshot( /** * Extract parent user-level variables from a context snapshot. * - * Filters out `context.*` namespace keys and `RunId` (which is per-execution), - * returning the remaining user-addressable variables suitable for child - * inheritance. Since `buildContextSnapshot` folds `state.variables` into + * Filters out `context.*` namespace keys, `RunId` (which is per-execution), + * and `RunbookRef` (which belongs to the resolved child), returning the + * remaining user-addressable variables suitable for child inheritance. Since + * `buildContextSnapshot` folds `state.variables` into * `snapshot.vars` via `mergeEffectiveVars`, the returned set intentionally * includes step OUTPUTS (which live in `state.variables`) as well as the * caller-provided `state.templateVars`. Do not re-filter `state.variables` @@ -172,14 +176,14 @@ export function buildContextSnapshot( * flow (SPEC §7). * * @param snapshot - The context snapshot to extract user variables from - * @returns User-defined variables including step OUTPUTS (excludes context.* and RunId) + * @returns User-defined variables including step OUTPUTS (excludes context.*, RunId, and RunbookRef) */ export function extractInheritedUserVars( snapshot: ContextSnapshot, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(snapshot.vars)) { - if (!key.startsWith('context.') && key !== 'RunId') { + if (!key.startsWith('context.') && !IDENTITY_OWNED_BUILTIN_SET.has(key)) { result[key] = value; } } diff --git a/packages/core/src/runbook/delegation-service.ts b/packages/core/src/runbook/delegation-service.ts index 70c62f3e7..6e8832624 100644 --- a/packages/core/src/runbook/delegation-service.ts +++ b/packages/core/src/runbook/delegation-service.ts @@ -12,6 +12,7 @@ import type { SubstepState, TemplateVarValue, } from './types.js'; +import type { RunbookRef } from './runbook-ref.js'; /** * Options for aborting a delegation. @@ -83,6 +84,8 @@ export interface DelegateOptions { readonly stepId: string; /** Path to the child runbook to delegate to. */ readonly childRunbookPath: string; + /** Canonical persisted identity of the child runbook. */ + readonly childRunbookRef: RunbookRef; /** Extra variables to merge into the context snapshot. */ readonly extraVars?: Readonly>; /** Ancestor chain built by the caller. */ @@ -208,7 +211,8 @@ export function createDelegation( options: DelegateOptions, steps: readonly ResolvedStep[], ): CreateDelegationResult { - const { state, stepId, childRunbookPath, extraVars, ancestors, frameKey } = options; + const { state, stepId, childRunbookPath, childRunbookRef, extraVars, ancestors, frameKey } = + options; // 0. Single-level delegation invariant: a claimed (delegated) child runbook // may not issue further delegations. This guard runs before any other @@ -328,6 +332,7 @@ export function createDelegation( token, tokenHash, childRunbookPath, + childRunbookRef, contextSnapshot, childRunId: null, createdAt: new Date().toISOString(), @@ -676,6 +681,7 @@ export function retryDelegation( state: stateAfterAbort, stepId: stepIdForCreate, childRunbookPath: existingDelegation.childRunbookPath, + childRunbookRef: existingDelegation.childRunbookRef, ...(mergedExtraVars ? { extraVars: mergedExtraVars } : {}), ancestors: [], frameKey, diff --git a/packages/core/src/runbook/index.ts b/packages/core/src/runbook/index.ts index e2c25f80d..e8616e496 100644 --- a/packages/core/src/runbook/index.ts +++ b/packages/core/src/runbook/index.ts @@ -11,9 +11,22 @@ export { export * from './step-id.js'; export * from './step-utils.js'; export * from './targeting.js'; +export { RUNBOOK_SOURCES } from './runbook-ref.js'; +export { + assertRunId, + isRunId, + RUN_ID_PATTERN, + RUN_ID_PREFIX, + type RunId, +} from './run-id.js'; export * from './claim-id.js'; export * from './transition-kernel.js'; -export { RunbookStateManager, StaleRunbookStateError, type SessionData } from './state.js'; +export { + generateRunId, + RunbookStateManager, + StaleRunbookStateError, + type SessionData, +} from './state.js'; export { SessionService, type ReleaseRunbookResult } from './session-service.js'; export { readActiveRunScope, type ActiveRunScope } from './session-reader.js'; export { ExecutionLifecycleService } from './execution-lifecycle-service.js'; @@ -106,7 +119,6 @@ export { buildArtifactUri, parseExactArtifactUriParts, parseArtifactUri, - RUN_ID_PATTERN, type ArtifactPathOptions, type ArtifactIdentity, type ArtifactRef, diff --git a/packages/core/src/runbook/run-id.ts b/packages/core/src/runbook/run-id.ts new file mode 100644 index 000000000..13fbbafa6 --- /dev/null +++ b/packages/core/src/runbook/run-id.ts @@ -0,0 +1,34 @@ +export declare const runIdBrand: unique symbol; + +/** Canonical persisted Rundown run identifier. */ +export type RunId = string & { readonly [runIdBrand]: true }; + +/** Prefix for every generated run id. */ +export const RUN_ID_PREFIX = 'rd_'; + +/** Canonical concrete run id pattern. */ +export const RUN_ID_PATTERN = /^rd_[a-f0-9]{32}$/; + +/** + * Return true when value is a canonical Rundown run id. + * + * @param value - Value to test + * @returns Whether the value is a branded run id string + */ +export function isRunId(value: unknown): value is RunId { + return typeof value === 'string' && RUN_ID_PATTERN.test(value); +} + +/** + * Assert and brand a canonical Rundown run id. + * + * @param value - String to validate + * @returns Branded run id + * @throws {Error} If the string is not a canonical run id + */ +export function assertRunId(value: string): RunId { + if (!isRunId(value)) { + throw new Error('Invalid run id: expected rd_<32 lowercase hex chars>'); + } + return value; +} diff --git a/packages/core/src/runbook/runbook-ref.ts b/packages/core/src/runbook/runbook-ref.ts index 4183478aa..27f915962 100644 --- a/packages/core/src/runbook/runbook-ref.ts +++ b/packages/core/src/runbook/runbook-ref.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path'; import { z } from 'zod'; import { assertSafeId } from '../paths.js'; @@ -6,13 +7,13 @@ import { assertSafeId } from '../paths.js'; */ export const RUNBOOK_REF_ERROR_TEXT = { INVALID_RUNBOOK_REF: - 'Invalid runbook: expected { source, path } with a source-root-relative .runbook.md path', + 'Invalid runbook: expected { source, path } with a safe source-root-relative Markdown path or normalized absolute external Markdown path', } as const; /** * Supported source roots for local-disk runbook references. */ -export const RUNBOOK_SOURCES = ['project', 'plugin', 'bundled'] as const; +export const RUNBOOK_SOURCES = ['project', 'plugin', 'bundled', 'external'] as const; /** * Zod schema for a supported runbook source root. @@ -27,17 +28,17 @@ export const RunbookSourceSchema = z.enum(RUNBOOK_SOURCES, { export type RunbookSource = z.infer; /** - * Validate a source-root-relative `.runbook.md` path. + * Validate a source-root-relative Markdown path. * * @param value - Path value to validate * @returns True when the path is canonical for a local runbook reference */ -function isValidRunbookPath(value: string): boolean { +function isValidSourceRootRelativeRunbookPath(value: string): boolean { if ( value.length === 0 || value.startsWith('/') || value.includes('\\') || - !value.endsWith('.runbook.md') + !value.endsWith('.md') ) { return false; } @@ -58,6 +59,29 @@ function isValidRunbookPath(value: string): boolean { return true; } +/** + * Validate a normalized absolute Markdown path for an external runbook. + * + * @param value - Path value to validate + * @returns True when the path can be persisted and rehydrated directly + */ +function isValidExternalRunbookPath(value: string): boolean { + if ( + value.length === 0 || + !path.isAbsolute(value) || + value.includes('\\') || + /[\r\n\0]/.test(value) || + !value.endsWith('.md') || + path.normalize(value) !== value + ) { + return false; + } + + const root = path.parse(value).root; + const segments = value.slice(root.length).split(path.sep); + return !segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..'); +} + /** * Zod schema for canonical local-disk runbook references. */ @@ -76,10 +100,11 @@ export const RunbookRefSchema = z }, ) .superRefine((ref, ctx) => { - if ( - !isValidRunbookPath(ref.path) || - (ref.source === 'project' && ref.path.startsWith('.rundown/runbooks/')) - ) { + const validPath = + ref.source === 'external' + ? isValidExternalRunbookPath(ref.path) + : isValidSourceRootRelativeRunbookPath(ref.path); + if (!validPath) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: RUNBOOK_REF_ERROR_TEXT.INVALID_RUNBOOK_REF, diff --git a/packages/core/src/runbook/session-service.ts b/packages/core/src/runbook/session-service.ts index 4a929c542..471a421e2 100644 --- a/packages/core/src/runbook/session-service.ts +++ b/packages/core/src/runbook/session-service.ts @@ -11,6 +11,7 @@ */ import type { RunbookStateManager } from './state.js'; +import type { RunId } from './run-id.js'; import { SessionLock } from './session-lock.js'; import { createClaimRecord, @@ -24,12 +25,12 @@ import type { DelegationLinkage, RunbookState } from './types.js'; /** Result of removing a runbook from session targeting structures. */ export type ReleaseRunbookResult = - | { readonly status: 'not-found'; readonly runbookId: RunbookState['id'] } + | { readonly status: 'not-found'; readonly runbookId: RunId } | { readonly status: 'released'; - readonly runbookId: RunbookState['id']; + readonly runbookId: RunId; readonly removedFromDefaultStack: boolean; - readonly nextDefaultRunbookId: RunbookState['id'] | null; + readonly nextDefaultRunbookId: RunId | null; }; /** @@ -56,6 +57,18 @@ function linkageMatchesLinkage( ); } +function claimRecordToDelegationLinkage(claim: ClaimRecord): DelegationLinkage { + return { + kind: 'delegation', + parentRunId: claim.parentRunId, + parentStepId: claim.parentStepId, + tokenHash: claim.tokenHash, + ...(claim.parentStep !== undefined ? { parentStep: claim.parentStep } : {}), + ...(claim.parentFrameKey !== undefined ? { parentFrameKey: claim.parentFrameKey } : {}), + ...(claim.parentEntry !== undefined ? { parentEntry: claim.parentEntry } : {}), + }; +} + /** * True when `linkage` is a delegation linkage that matches `claim`'s parent run / step / token hash. * Used to verify a child runbook's parentLinkage genuinely originated from the supplied claim record. @@ -117,7 +130,7 @@ export class SessionService { private findClaimByChildRunId( claims: Record, - childRunId: RunbookState['id'], + childRunId: RunId, ): ClaimRecord | undefined { return Object.values(claims).find((claim) => claim.childRunId === childRunId); } @@ -156,7 +169,7 @@ export class SessionService { * * @param id - The runbook state ID to push */ - async pushRunbook(id: string): Promise { + async pushRunbook(id: RunId): Promise { await this.withLock(async () => { const session = await this.manager.loadSession(); session.defaultStack.push(id); @@ -192,17 +205,46 @@ export class SessionService { * @param linkage - Delegation linkage to record in the claim. Caller must build * this from freshly token-validated parent state. * @returns A claim record on success, or a failure variant when the child is - * missing or its persisted linkage diverges from `linkage`. + * missing, terminal, or its persisted linkage diverges from `linkage`. */ - async claimRunbook( - childRunId: RunbookState['id'], - linkage: DelegationLinkage, - ): Promise { + async claimRunbook(childRunId: RunId, linkage: DelegationLinkage): Promise { return this.withLock(async () => { + const session = await this.manager.loadSession(); + const now = new Date().toISOString(); + const existingForDelegation = this.findClaimByDelegationLinkage(session.claims, linkage); + if (existingForDelegation !== undefined) { + const existingState = await this.manager.load(existingForDelegation.childRunId); + if (!existingState) { + return { status: 'missing-child', childRunId: existingForDelegation.childRunId }; + } + if (existingState.lifecycle === 'completed' || existingState.lifecycle === 'stopped') { + return { + status: 'terminal-child', + childRunId: existingForDelegation.childRunId, + lifecycle: existingState.lifecycle, + }; + } + if (!linkageMatchesLinkage(existingState.parentLinkage, linkage)) { + return { + status: 'linkage-mismatch', + childRunId: existingForDelegation.childRunId, + incoming: linkage, + persisted: existingState.parentLinkage, + }; + } + const refreshed = { ...existingForDelegation, updatedAt: now }; + session.claims[existingForDelegation.claimId] = refreshed; + await this.manager.saveSession(session); + return { status: 'claimed', claim: refreshed }; + } + const childState = await this.manager.load(childRunId); if (!childState) { return { status: 'missing-child', childRunId }; } + if (childState.lifecycle === 'completed' || childState.lifecycle === 'stopped') { + return { status: 'terminal-child', childRunId, lifecycle: childState.lifecycle }; + } if (!linkageMatchesLinkage(childState.parentLinkage, linkage)) { return { status: 'linkage-mismatch', @@ -212,32 +254,19 @@ export class SessionService { }; } - const session = await this.manager.loadSession(); - const now = new Date().toISOString(); const existing = this.findClaimByChildRunId(session.claims, childRunId); if (existing !== undefined) { - const refreshed = { ...existing, updatedAt: now }; - session.claims[existing.claimId] = refreshed; - await this.manager.saveSession(session); - return { status: 'claimed', claim: refreshed }; - } - - const existingForDelegation = this.findClaimByDelegationLinkage(session.claims, linkage); - if (existingForDelegation !== undefined) { - const existingState = await this.manager.load(existingForDelegation.childRunId); - if (!existingState) { - return { status: 'missing-child', childRunId: existingForDelegation.childRunId }; - } - if (!linkageMatchesLinkage(existingState.parentLinkage, linkage)) { + const existingLinkage = claimRecordToDelegationLinkage(existing); + if (!linkageMatchesLinkage(existingLinkage, linkage)) { return { status: 'linkage-mismatch', - childRunId: existingForDelegation.childRunId, + childRunId, incoming: linkage, - persisted: existingState.parentLinkage, + persisted: existingLinkage, }; } - const refreshed = { ...existingForDelegation, updatedAt: now }; - session.claims[existingForDelegation.claimId] = refreshed; + const refreshed = { ...existing, updatedAt: now }; + session.claims[existing.claimId] = refreshed; await this.manager.saveSession(session); return { status: 'claimed', claim: refreshed }; } @@ -309,7 +338,7 @@ export class SessionService { * @param runbookId - Runbook id to release * @returns Structured release result */ - async releaseRunbook(runbookId: RunbookState['id']): Promise { + async releaseRunbook(runbookId: RunId): Promise { return this.withLock(() => this.releaseRunbookLocked(runbookId)); } @@ -319,7 +348,7 @@ export class SessionService { * @param runbookId - Runbook id to release from session targeting structures * @returns Structured release result describing what was removed */ - private async releaseRunbookLocked(runbookId: RunbookState['id']): Promise { + private async releaseRunbookLocked(runbookId: RunId): Promise { const session = await this.manager.loadSession(); const originalDefaultStackLength = session.defaultStack.length; @@ -360,7 +389,7 @@ export class SessionService { * * @returns The new active runbook ID (parent), or null if the stack is empty */ - async popRunbook(): Promise { + async popRunbook(): Promise { return this.withLock(async () => { const session = await this.manager.loadSession(); const topId = session.defaultStack[session.defaultStack.length - 1]; @@ -378,7 +407,7 @@ export class SessionService { * * @returns The stashed runbook ID, or null if no runbook was active or a stash already exists */ - async stash(): Promise { + async stash(): Promise { return this.withLock(async () => { const session = await this.manager.loadSession(); @@ -404,7 +433,7 @@ export class SessionService { * @param runbookId - Runbook id to move into the single session stash slot * @returns The stashed runbook id, or null if no slot is available or the runbook was not targeted */ - async stashRunbook(runbookId: RunbookState['id']): Promise { + async stashRunbook(runbookId: RunId): Promise { return this.withLock(async () => { const session = await this.manager.loadSession(); if (session.stashedRunbookId) return null; @@ -508,7 +537,7 @@ export class SessionService { * * @returns The stashed runbook ID, or null if nothing is stashed */ - async getStashedRunbookId(): Promise { + async getStashedRunbookId(): Promise { const session = await this.manager.loadSession(); return session.stashedRunbookId ?? null; } diff --git a/packages/core/src/runbook/state.ts b/packages/core/src/runbook/state.ts index 0779af72f..56b8318dc 100644 --- a/packages/core/src/runbook/state.ts +++ b/packages/core/src/runbook/state.ts @@ -1,4 +1,5 @@ // src/runbook/state.ts +import { randomBytes } from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { OutputDeclaration } from '@rundown-org/parser'; @@ -19,6 +20,7 @@ import { makeRunbookStateSchema, SessionDataSchema } from '../schemas.js'; import { isNodeError } from '../errors.js'; import { logger } from '../logger.js'; import { brandInitialTemplateVars, brandStoredOutputs } from './effective-vars.js'; +import { assertRunId, RUN_ID_PREFIX, type RunId } from './run-id.js'; import { runsDir as _runsDir, sessionPath as _sessionPath, @@ -65,11 +67,13 @@ export class StaleRunbookStateError extends Error { } } -function generateId(): string { - const now = new Date(); - const date = now.toISOString().slice(0, 10); - const random = Math.random().toString(36).slice(2, 8); - return `wf-${date}-${random}`; +/** + * Generate a concrete Rundown run identifier. + * + * @returns Run ID in canonical `rd_<32 lowercase hex>` form + */ +export function generateRunId(): RunId { + return assertRunId(`${RUN_ID_PREFIX}${randomBytes(16).toString('hex')}`); } /** @@ -79,16 +83,16 @@ function generateId(): string { */ export interface SessionData { /** Active runbook stack for default targeting. */ - defaultStack: string[]; + defaultStack: RunId[]; /** ID of a temporarily stashed runbook, if any. */ - stashedRunbookId?: string; + stashedRunbookId?: RunId; /** Explicit claim-id records for delegated child runbook targeting. */ claims: Record; } interface CreateOptions { readonly runbookPath: string; - readonly runbookRef?: RunbookRef; + readonly runId?: RunId; readonly prompted?: boolean; /** Parent linkage when this run is a child (delegation or inline). */ readonly parentLinkage?: ParentLinkage; @@ -195,26 +199,25 @@ export class RunbookStateManager { /** * Create a new runbook state and persist it to disk. * - * @param runbookFile - Path to the runbook source file + * @param runbookRef - Canonical runbook identity * @param runbook - The parsed runbook definition * @param options - Configuration including agentId, parent runbook info, prompted flag, and templateVars for template variable replacements * @returns The newly created RunbookState */ async create( - runbookFile: string, + runbookRef: RunbookRef, runbook: Runbook | ResolvedRunbook, options: CreateOptions, ): Promise { - const id = generateId(); + const id = options.runId ?? generateRunId(); const now = new Date().toISOString(); const initialStep = runbook.steps[0]; const state: RunbookState = { id, - runbook: runbookFile, + runbook: runbookRef, runbookPath: options.runbookPath, - runbookRef: options.runbookRef, title: runbook.title, description: runbook.description, step: initialStep.name, @@ -245,7 +248,7 @@ export class RunbookStateManager { /** * Load a runbook state from disk by ID. * - * @param id - The runbook state ID (e.g., 'wf-2025-01-12-abc123') + * @param id - The runbook state ID (e.g., 'rd_0123456789abcdef0123456789abcdef') * @returns The loaded RunbookState, or null if file not found * @throws {Error} If the state file exists but fails schema validation (stale state) * @throws {Error} If the runbook state uses deprecated dynamic-step snapshots diff --git a/packages/core/src/runbook/types.ts b/packages/core/src/runbook/types.ts index d59074909..9d41b5de4 100644 --- a/packages/core/src/runbook/types.ts +++ b/packages/core/src/runbook/types.ts @@ -3,6 +3,7 @@ import type { OutputDeclaration } from '@rundown-org/parser'; import type { DelegationTokenHash } from './delegation-token.js'; import type { EffectiveVars, InitialTemplateVars, StoredOutputs } from './effective-vars.js'; import type { RunbookRef } from './runbook-ref.js'; +import type { RunId } from './run-id.js'; import type { FrameKey } from './targeting.js'; // Re-export parser types needed by core package consumers @@ -15,6 +16,7 @@ import type { FrameKey } from './targeting.js'; * @see `@rundown-org/parser` Step */ export type { Step } from '@rundown-org/parser'; +export type { RunId } from './run-id.js'; /** Prompt-only or empty step — no command, no substeps. */ export type { BaseStep } from '@rundown-org/parser'; @@ -439,8 +441,9 @@ export interface StepDelegation { readonly token?: string; readonly tokenHash: DelegationTokenHash; readonly childRunbookPath: string; + readonly childRunbookRef: RunbookRef; readonly contextSnapshot: ContextSnapshot; - readonly childRunId: string | null; + readonly childRunId: RunId | null; readonly createdAt: string; readonly cancelledAt: string | null; /** @@ -482,7 +485,7 @@ export interface ContextSnapshot { /** Single ancestor in the runbook lineage snapshot. */ export interface AncestorSnapshot { - readonly runId: string; + readonly runId: RunId; readonly runbook: string; readonly step: string; readonly substep: string | null; @@ -501,7 +504,7 @@ export interface AncestorSnapshot { * propagate a child's terminal result back to the parent substep. */ export interface ParentLinkageBase { - readonly parentRunId: string; + readonly parentRunId: RunId; readonly parentStepId: string; /** Parent's step name at link time (e.g., "1"). */ readonly parentStep?: string; @@ -708,11 +711,9 @@ export type Lifecycle = 'running' | 'completed' | 'stopped'; * Runbook execution state (persisted) */ export interface RunbookState { - readonly id: string; - readonly runbook: string; // runbook identifier (name or path) + readonly id: RunId; + readonly runbook: RunbookRef; // canonical persisted runbook identity readonly runbookPath: string; // repo-relative resolved file path - /** Canonical runbook reference for events and artifact metadata. */ - readonly runbookRef?: RunbookRef; readonly title?: string; readonly description?: string; readonly step: string; // "1" or "ErrorHandler" diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 8fc5cbc8d..eb4c0ef8c 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -10,6 +10,7 @@ import { CLAIM_ID_PATTERN, type ClaimId, type ClaimRecord } from './runbook/clai import type { FrameKey } from './runbook/targeting.js'; import { createJsonArrayStream } from './runbook/types.js'; import type { JsonValue, TemplateVarValue } from './runbook/types.js'; +import { RUN_ID_PATTERN, type RunId, type runIdBrand } from './runbook/run-id.js'; import { brandEffectiveVars, brandInitialTemplateVars, @@ -21,6 +22,16 @@ import { RunbookRefSchema } from './runbook/runbook-ref.js'; /** Zod schema that parses strings and brands them as {@link FrameKey}. */ const FrameKeySchema = z.string().transform((v) => v as FrameKey); +/** Zod schema that parses strings and brands them as {@link RunId}. */ +export const RunIdSchema = z + .string() + .regex(RUN_ID_PATTERN) + .transform((value) => value as RunId); + +// Keeps the unique-symbol run-id brand nameable in declaration emit for +// exported schemas inferred from RunIdSchema. This is type-only. +type _RunIdBrandForDeclarationEmit = typeof runIdBrand; + /** Zod schema that parses strings and brands them as {@link DelegationTokenHash}. */ export const DelegationTokenHashSchema: z.ZodType = z .string() @@ -253,7 +264,7 @@ export const TemplateVarValueSchema: z.ZodType = z.union([ * Zod schema for a single ancestor in the runbook lineage snapshot. */ export const AncestorSnapshotSchema = z.object({ - runId: z.string(), + runId: RunIdSchema, runbook: z.string(), step: z.string(), substep: z.string().nullable(), @@ -300,8 +311,9 @@ export const StepDelegationSchema = z token: z.string().regex(DELEGATION_TOKEN_PATTERN).optional(), tokenHash: DelegationTokenHashSchema, childRunbookPath: z.string(), + childRunbookRef: RunbookRefSchema, contextSnapshot: ContextSnapshotSchema, - childRunId: z.string().nullable(), + childRunId: RunIdSchema.nullable(), createdAt: z.string(), cancelledAt: z.string().nullable(), extraVars: z.record(z.string(), TemplateVarValueSchema).optional(), @@ -344,9 +356,9 @@ export const ClaimIdSchema = z export const ClaimRecordSchema: z.ZodType = z.object({ kind: z.literal('claim-record'), claimId: ClaimIdSchema, - childRunId: z.string().min(1), + childRunId: RunIdSchema, tokenHash: DelegationTokenHashSchema, - parentRunId: z.string().min(1), + parentRunId: RunIdSchema, parentStepId: z.string().min(1), parentStep: z.string().optional(), parentFrameKey: FrameKeySchema.optional(), @@ -358,8 +370,8 @@ export const ClaimRecordSchema: z.ZodType = /** Zod schema for `.rundown/session.json`. */ export const SessionDataSchema = z .object({ - defaultStack: z.array(z.string()).default([]), - stashedRunbookId: z.string().optional(), + defaultStack: z.array(RunIdSchema).default([]), + stashedRunbookId: RunIdSchema.optional(), claims: z.record(z.string(), ClaimRecordSchema).default({}), }) .superRefine((session, ctx) => { @@ -443,12 +455,24 @@ const ForStackEntrySchema = z.object({ * @see makeRunbookStateSchema for the branded variant. * @see ValidatedRunbookState for the post-parse brand contract. */ -export const RunbookStateSchema = z +const RUNBOOK_REF_REMOVED_MESSAGE = + 'RunbookState.runbookRef is no longer supported; use RunbookState.runbook.'; + +function rejectRemovedRunbookRefField(value: Record, ctx: z.RefinementCtx): void { + if (Object.hasOwn(value, 'runbookRef')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: RUNBOOK_REF_REMOVED_MESSAGE, + path: ['runbookRef'], + }); + } +} + +const RunbookStateObjectSchema = z .object({ - id: z.string(), - runbook: z.string(), + id: RunIdSchema, + runbook: RunbookRefSchema, runbookPath: z.string(), - runbookRef: RunbookRefSchema.optional(), title: z.string().optional(), description: z.string().optional(), step: RunbookStepSchema, // "1" or "ErrorHandler" @@ -474,7 +498,7 @@ export const RunbookStateSchema = z .discriminatedUnion('kind', [ z.object({ kind: z.literal('delegation'), - parentRunId: z.string(), + parentRunId: RunIdSchema, parentStepId: z.string(), tokenHash: DelegationTokenHashSchema, parentStep: z.string().optional(), @@ -483,7 +507,7 @@ export const RunbookStateSchema = z }), z.object({ kind: z.literal('inline'), - parentRunId: z.string(), + parentRunId: RunIdSchema, parentStepId: z.string(), parentStep: z.string().optional(), parentFrameKey: FrameKeySchema.optional(), @@ -549,6 +573,16 @@ export const RunbookStateSchema = z // persisted state files. They are simply ignored in the typed result. .passthrough(); +/** + * Runbook state validation schema. + * + * Rejects the removed `runbookRef` field so callers use the canonical + * `runbook` identity instead. + */ +export const RunbookStateSchema = RunbookStateObjectSchema.superRefine( + rejectRemovedRunbookRefField, +); + /** Validated runbook state. Inferred from {@link RunbookStateSchema}. */ export type ValidatedRunbookState = z.infer; @@ -644,7 +678,7 @@ export function makeTemplateVarValueSchema(projectRoot: string): z.ZodType brandStoredOutputs(v)), substepStates: z.array(SubstepStateSchemaValidated).optional(), - }); + }).superRefine(rejectRemovedRunbookRefField); } diff --git a/packages/parser/__tests__/frontmatter.test.ts b/packages/parser/__tests__/frontmatter.test.ts index 5d7aa2667..7f6d14291 100644 --- a/packages/parser/__tests__/frontmatter.test.ts +++ b/packages/parser/__tests__/frontmatter.test.ts @@ -640,6 +640,20 @@ inputs: expect(diagnostics[0].message).toMatch(/__proto__.*not a valid identifier/i); }); + it.each(['RunId', 'RunbookRef'])('rejects reserved runtime identity "%s" in inputs', (name) => { + const markdown = `--- +inputs: + - ${name} + - environment +--- +# Test`; + const { frontmatter, diagnostics } = extractFrontmatter(markdown); + expect(frontmatter?.inputs).toEqual(['environment']); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain(name); + expect(diagnostics[0].message).toMatch(/reserved/i); + }); + it('treats vars: as unknown passthrough (not a known field)', () => { const markdown = `--- vars: diff --git a/packages/parser/__tests__/outputs-inputs.test.ts b/packages/parser/__tests__/outputs-inputs.test.ts index 0e63bcd09..b701283a8 100644 --- a/packages/parser/__tests__/outputs-inputs.test.ts +++ b/packages/parser/__tests__/outputs-inputs.test.ts @@ -250,6 +250,8 @@ name: no-inputs 'context', 'Step', 'Index', + 'RunId', + 'RunbookRef', ])('rejects reserved name "%s" in frontmatter required', (name) => { const md = `--- name: bad-required @@ -358,6 +360,8 @@ describe('parseRunbookDocument OUTPUTS directive — reserved-name guard', () => 'Context', 'STEP', 'Index', + 'RunId', + 'RunbookRef', ])('throws RunbookSyntaxError when OUTPUTS uses reserved name "%s"', (name) => { const md = `## 1. Step - OUTPUTS diff --git a/packages/parser/src/frontmatter.ts b/packages/parser/src/frontmatter.ts index 68b44728c..3b20cdc5f 100644 --- a/packages/parser/src/frontmatter.ts +++ b/packages/parser/src/frontmatter.ts @@ -1,6 +1,6 @@ import matter from 'gray-matter'; import { z } from 'zod'; -import { isReservedTemplateName } from './reserved.js'; +import { formatReservedTemplateNames, isReservedTemplateName } from './reserved.js'; import type { ValidationDiagnostic } from './validator.js'; import type { OutputDeclaration } from './ast.js'; import { parseFrontmatterOutputDeclaration } from './helpers.js'; @@ -253,7 +253,7 @@ function filterInputDeclarations( if (isReservedTemplateName(entry)) { diagnostics.push({ severity: 'error', - message: `Frontmatter "inputs[${String(index)}]" — "${entry}" is a reserved variable name (step, index, context — case-insensitive)`, + message: `Frontmatter "inputs[${String(index)}]" — "${entry}" is a reserved variable name (${formatReservedTemplateNames()} — case-insensitive)`, }); return; } @@ -310,7 +310,7 @@ function filterIdentifierArray( if (isReservedTemplateName(entry)) { diagnostics.push({ severity: 'error', - message: `Frontmatter "${field}[${String(index)}]" — "${entry}" is a reserved variable name (step, index, context — case-insensitive)`, + message: `Frontmatter "${field}[${String(index)}]" — "${entry}" is a reserved variable name (${formatReservedTemplateNames()} — case-insensitive)`, }); return; } @@ -389,7 +389,7 @@ function filterOutputDeclarationArray( if (isReservedTemplateName(decl.name)) { diagnostics.push({ severity: 'error', - message: `Frontmatter "outputs[${String(index)}]" — "${decl.name}" is a reserved variable name (step, index, context — case-insensitive)`, + message: `Frontmatter "outputs[${String(index)}]" — "${decl.name}" is a reserved variable name (${formatReservedTemplateNames()} — case-insensitive)`, }); return; } diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index a4ee214b7..35fd34408 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -46,7 +46,11 @@ export { isReservedWord, NAMED_IDENTIFIER_PATTERN, } from './step-id.js'; -export { RESERVED_TEMPLATE_NAMES, isReservedTemplateName } from './reserved.js'; +export { + IDENTITY_OWNED_BUILTINS, + RESERVED_TEMPLATE_NAMES, + isReservedTemplateName, +} from './reserved.js'; export type { ParseStepIdOptions } from './step-id.js'; export { extractFrontmatter, diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index a247af088..eb7475e7b 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -28,7 +28,7 @@ import { parseForClause, parseStepOutputDeclaration, } from './helpers.js'; -import { isReservedTemplateName } from './reserved.js'; +import { formatReservedTemplateNames, isReservedTemplateName } from './reserved.js'; import { validateRunbook } from './validator.js'; import type { ValidationDiagnostic } from './validator.js'; import { extractFrontmatter, nameFromFilename } from './frontmatter.js'; @@ -683,7 +683,7 @@ function handleOutputsDirective(node: ListItem, ctx: ActiveStepContext): typeof } if (isReservedTemplateName(decl.name)) { throw new RunbookSyntaxError( - `Invalid OUTPUTS declaration in ${targetLabel}${formatLineNum(item)}: "${decl.name}" is a reserved variable name (step, index, context — case-insensitive)`, + `Invalid OUTPUTS declaration in ${targetLabel}${formatLineNum(item)}: "${decl.name}" is a reserved variable name (${formatReservedTemplateNames()} — case-insensitive)`, ); } if (seen.has(decl.name)) { diff --git a/packages/parser/src/reserved.ts b/packages/parser/src/reserved.ts index 6b3c2caae..d13713756 100644 --- a/packages/parser/src/reserved.ts +++ b/packages/parser/src/reserved.ts @@ -5,7 +5,8 @@ * * These names are owned by runtime context resolution. Allowing them to be * shadowed by user values would corrupt template rendering of `{{step}}`, - * `{{index}}`, and the entire `{{context.*}}` namespace. + * `{{index}}`, runtime identity values, and the entire `{{context.*}}` + * namespace. * * Stored lowercased — comparison is case-insensitive (`Step`, `STEP`, and * `step` are equivalent). @@ -13,16 +14,38 @@ * @module reserved */ +/** + * Canonical runtime identity built-ins that belong to each individual runbook + * execution and must not be inherited from a parent delegation context. + */ +export const IDENTITY_OWNED_BUILTINS = ['RunId', 'RunbookRef'] as const; + +const IDENTITY_OWNED_RESERVED_NAMES = IDENTITY_OWNED_BUILTINS.map((name) => name.toLowerCase()); + /** * Canonical reserved set (lowercased). Re-exported by the CLI as * `RUNTIME_RESERVED_VARIABLES` to keep one source of truth across packages. */ -export const RESERVED_TEMPLATE_NAMES: ReadonlySet = new Set(['step', 'index', 'context']); +export const RESERVED_TEMPLATE_NAMES: ReadonlySet = new Set([ + 'step', + 'index', + 'context', + ...IDENTITY_OWNED_RESERVED_NAMES, +]); + +/** + * Comma-separated reserved-name list for diagnostics. + * + * @returns Human-readable list of runtime-reserved template names + */ +export function formatReservedTemplateNames(): string { + return [...RESERVED_TEMPLATE_NAMES].join(', '); +} /** * Check whether a name collides with a runtime-reserved template variable. * - * Case-insensitive — `Context`, `CONTEXT`, and `context` all return `true`. + * Case-insensitive — `Context`, `RunId`, and `RUNBOOKREF` all return `true`. * * @param name - Identifier to test * @returns `true` if the identifier is reserved