diff --git a/.changeset/collect-emits-a-complete-step-entered.md b/.changeset/collect-emits-a-complete-step-entered.md new file mode 100644 index 000000000..208502ae1 --- /dev/null +++ b/.changeset/collect-emits-a-complete-step-entered.md @@ -0,0 +1,81 @@ +--- +'@rundown-org/core': minor +--- + +# `rundown collect` emits the same `STEP_ENTERED` as `rundown run` + +Entering substep `1.1` via `rundown run` produced a `STEP_ENTERED` carrying its +description and prompt. Entering the same substep of the same runbook via the +RETRY re-entry `rundown collect` drives produced one carrying neither. Two +functions built the payload's `StepEntryMetadata` and they disagreed: the CLI +execution loop rendered every field, and the collection service hand-built ids, +position, name and flags with every rendered field absent. All four are optional +on the type, which is what let the disagreement compile. + +The collect path now enters through the same core seam the loop does. The +hand-built entry is gone, along with the Stryker equivalence annotations that +existed only because half the fields it built were never observed. + +Three assertions flip with it, each pinned by #816 against the old behaviour: + +- The rendered fields. `description` and `prompt` are present on the collect + payload, end to end, and equal to the run payload's for the same unit. +- `prompted`. The collect path read `!!state.prompted` alone; it now composes + the persisted flag with the step kind, so a prompted-FOR step reports `true` + on both paths. +- `substepId`. It came off the raw cursor while `isSubstep` came off the + resolved unit, so a cursor naming no live substep produced a populated + `substepId` beside `isSubstep: false`. Both answer one question and both now + come off the resolved unit — which matters beyond tidiness, because the + frontier seams gate credential disclosure on `isSubstep`. + +**The fenced frontier seam sheds its `entry` parameter**, as its unfenced twin +already had. `prepareReEntryFrontierConsume` derives the substep question from +the state it holds and returns the projected bearers rather than an entry, so +there is no longer any route by which a caller can hand either seam an entry +that disagrees with the run. + +**Two guards are deleted rather than left unreachable.** +`deriveStepEnteredEffect` refused an entry whose `stepId` / `substepId` +disagreed with the snapshot. Those existed because the entry was a parameter; +with one producer that reads the cursor and the snapshot off the same +`RunbookState`, the mismatch is unrepresentable. +`RunbookActorService.observeExecutionUnitEntry` goes with them — its last caller +was the collect path — and `StepEntryMetadata` becomes a local passed between +two core functions rather than a parameter of anything. + +**A new failure surface, named.** A collect that has committed can now fail to +RENDER the entry its bearers ride on — typically a `--helpers` helper raising — +where before it emitted a thinner event that needed no rendering. Nothing +recovers the bearers (the consume is durable, so a retry answers the idempotent +no-op), so the collect still rejects rather than reporting a phantom success +with an empty observation list. It rejects with a code of its own, +`DELEGATION_FRONTIER_DISCLOSURE_FAILED` (RD-833), instead of escaping bare as +RD-999 "Unknown error" — an envelope that cannot carry this condition's +recovery, which is "fix the helper, then re-delegate". A render refusal that is +`InvalidRunbookStateError` keeps its own class, so the CLI's RD-309 arm still +prints finish/stop/prune for a run that cannot describe itself. + +Bearer-disclosure ordering is unchanged and still asserted: the commit lands +before the entry is derived, so a refused transaction consumes nothing and +discloses nothing. + +**The persisted-snapshot guards are now typed too, and RD-833 depends on it.** +`assertFreshSnapshotValue` and `compileMachineFromState` refuse an unreadable +`snapshot.value`, a transient parent-entry state, a cursor naming a step the +runbook no longer declares, and a missing `frontmatterOutputs` — all one run's +corrupt persisted state, and every message already spelled RD-309's remediation +("Prune invalid runbook state and restart execution"). They threw a bare +`Error`, which reached the CLI as RD-999. They now raise +`InvalidRunbookStateError` with a typed reason, which is what keeps them off +RD-833: without it, a collect whose committed target carried an unparseable +`stateValue` would have told the operator to fix a helper and re-issue +delegations when the real recovery is prune/restart. Every other caller of those +guards gains the RD-309 envelope with them. + +One narrowing worth knowing. The artifact-path projection used to fall back to +`WORK_DIR` when a run carried no `WorkPath`, while the render context the same +entry expands helper paths against refused a missing `WorkPath` outright — so +the fallback could only ever produce an entry whose artifact paths and helper +paths named different roots. There is one read now, and a run with no `WorkPath` +is refused as corrupt persisted state on both paths. diff --git a/.changeset/core-entry-seam.md b/.changeset/core-entry-seam.md new file mode 100644 index 000000000..817b531b1 --- /dev/null +++ b/.changeset/core-entry-seam.md @@ -0,0 +1,174 @@ +--- +'@rundown-org/core': minor +'@rundown-org/cli': minor +--- + +# Enter an execution unit through core, not by rendering it in the CLI + +The CLI execution loop used to render the unit it was about to enter — merge +effective variables, build the step frame, pick which expander applied to which +field, assemble a `StepEntryMetadata` — and then read its own rendered command +back out to decide what to do next. `expandedCommandCode === undefined` was the +loop's control-flow signal for "nothing to run". Rendering precedence is a +language-level concern the spec owns, so it belongs behind the machine (#799); +`undefined`-as-signal is a missing type. + +`RunbookActorService.enterExecutionUnit({ state, steps })` now does all three — +render, observe, classify — and returns `ExecutionUnitEntry`, a three-arm union: + +- **`awaiting`** — nothing for this process to run. One arm for the three + conditions the loop used to spell out itself: a prompted run, a prompted-FOR + step, and a unit that declares no command. +- **`runnable`** — carries a `RenderedUnitCommand`: the expanded code, its + display projection, and the `RD_*` environment for the child process. +- **`inline-launch`** — carries the one-shot intent the machine prepared. + +The loop sends state and steps and reads back a classified entry. It renders +nothing, and it derives exactly one fact for itself — whether the cursor is on a +substep — because the missing-deriver authority precondition has to answer that +before any entry exists. + +**The command is one value, and that is the point.** The string announced in +`STEP_ENTERED.commandCode` and the string handed to `EXECUTE_COMMAND` come from +one expansion, so a non-deterministic `--helpers` helper cannot make a runbook +run something other than what it announced. That property used to be held by +statement ordering in the loop; it is now held by construction. + +The comment that ordering carried was wrong and is not carried forward. It +claimed artifact-producing helpers "append a manifest row per call, so a second +expansion would duplicate the entries". `expandLoopVariablesForCommand` is +synchronous and reduces to `substituteText`, which imports neither `fs` nor the +manifest module; the manifest append is idempotent by identity anyway. The real +constraint is helper determinism, which is what the docs now say. + +`RenderedUnitCommand` is nominally branded — tier 1 of the doctrine in +`effective-vars.ts`, a `declare const` `unique symbol`, minted only inside +`deriveExecutionUnitEntry`. Tier 1 is right here because the record is consumed +by typed functions and never round-trips through JSON: `EXECUTE_COMMAND` targets +`__execute-command`, whose `invoke.input` reads the event with no `assign`, so a +rendered command never reaches persisted context. + +The brand is load-bearing rather than decorative, and two things keep it that +way. `RenderedUnitCommand` is **not** re-exported from `@rundown-org/core`, so +outside core the type cannot be named — and a type that cannot be named cannot +be asserted to, aliased, or reached through a namespace import. Inside core, +where a relative import puts the name back in scope, a type-aware ESLint rule +(`local/no-rendered-unit-command-cast`, +`eslint-rules/no-rendered-unit-command-cast.mjs`) bans every assertion that can +mint one. Tier 1 means the assertion IS the mint, so the set of assertion +SYNTAXES is the whole surface — but a selector that matches syntax has to +enumerate every spelling, and an import rename (`RenderedUnitCommand as Local`) +produces a spelling no enumeration anticipates. The rule instead resolves the +asserted-to TYPE through the checker and walks its symbol, base types, and +union/intersection members, so a rename, an alias two hops away, or an interface +that inherits the brand all resolve to the same declared symbol and get caught +the same as a direct cast. `scripts/__tests__/eslint-brand-cast-guard.test.mjs` +lints one committed fixture per laundering route through the real config, +because a bug in the rule's type resolution matches nothing and otherwise reads +as passing. The fixtures are real `.ts` files under +`packages/core/__tests__/fixtures/brand-cast/` — a type-aware rule needs ESLint +and the checker to be reading the same bytes, which `lintText` against a +borrowed `filePath` does not guarantee, and a file this test writes and sweeps +is visible to everything else that reads the working tree while it exists. +Separately, the CLI, MCP and plugin `src/**` may no longer import +`buildStepVariables`, `expandLoopVariables`, `expandLoopVariablesForCommand`, or +`deriveExecutionUnitEntry` from core at all. + +**The entry seam's internals came off the public barrel** on the same reasoning. +`deriveStepEnteredEffect` used to carry two cursor-mismatch guards, refusing an +entry whose `stepId` / `substepId` disagreed with the snapshot; they are deleted +because the entry now has exactly ONE producer, which reads the cursor and the +snapshot off the same `RunbookState`. That argument only holds while a front end +cannot reach the deriver with a hand-built entry, and a wildcard +`export * from './execution-observation.js'` was putting the deriver, +`StepEntryMetadata` and `StepEntryObservationInput` on `@rundown-org/core` +without any file naming them. The barrel names its exports now. + +**`hasCommand` is now a field on the entry, derived from the parsed unit.** It +used to be computed as `commandCode !== undefined` inside +`deriveStepEnteredEffect`, which made a payload flag an accident of which +builder produced the entry — the collect-side builder renders nothing, so every +entry it produced reported `hasCommand: false` regardless of the unit. A command +that renders to the empty string is now correctly `hasCommand: true`. + +**Both re-entry frontier seams shed their `entry` parameter.** Each read exactly +one field off it — `isSubstep` — and both now derive that from the state they +already hold, through the same `resolveCurrentExecutionUnit` the entry seam +uses. A caller-supplied entry was the wrong shape for it anyway: the field +describes the cursor, so taking it from the caller let an entry describing one +cursor decide a question about another. + +The unfenced seam (`projectAndConsumeReEntryFrontier`) enters through +`enterExecutionUnit` with the verified bearers attached, and its `projected` arm +returns the whole classified entry rather than bare observations, so the caller +gets the same classification on the re-entry path as on an ordinary one. The +ordering guarantee is untouched: the consume still commits before the entry is +returned, so a failed consume discloses no bearers. The fenced twin +(`prepareReEntryFrontierConsume`, which `rundown collect` drives) returns the +prepared state and the projected frontier, leaving the commit and the disclosure +to the caller's transaction — `RunbookCollectionService` enters through +`enterExecutionUnit` after its commit lands. + +**`enterExecutionUnit` is declared `async`.** Its body is synchronous today, but +three refusals run before the derivation returns — the snapshot freshness gate, +the machine compile, and the render itself — and without the keyword all three +threw in the CALLER's tick rather than rejecting the promise the signature +advertises. A caller that attached `.catch(...)` to the returned promise, or +collected the call in `Promise.all`, observed none of them. `await` callers are +unaffected. + +**Behaviour notes.** Helper path containment now resolves against `manager.cwd` +rather than the `cwd` argument threaded into the loop — the canonicalised +directory the actor service already used for artifact path projection, so the +two can no longer disagree. (The CLI always passes `process.cwd()`, which Node +returns already resolved, so the two values are identical in production; the +canonicalisation only bites a caller that supplies a symlinked path, and +containment wants the resolved one anyway.) + +Three refusals are now typed `InvalidRunbookStateError` rather than bare, which +is what routes each onto the CLI's existing RD-309 finish/stop/prune recovery +rather than an envelope carrying the wrong instruction: + +- A run whose `templateVars` carry no string `ContextId` or `WorkPath` + (`reason: 'missing_render_context'`). +- A cursor naming a step the parsed runbook does not define + (`reason: 'cursor_step_not_in_runbook'`, raised by `findStepOrThrow`, which + now takes the run id for the defect). This one was a live misclassification: + the collect path wraps any non-`InvalidRunbookStateError` rejection out of the + entry seam as RD-833, whose recovery reads "fix the helper and re-delegate" — + the wrong instruction entirely for corrupt persisted state. +- A persisted row carrying no `prompted` (`reason: 'missing_prompted'`). + `RunbookState.prompted` is required now and `create` always writes it, exactly + as `templateVars` already worked, so the `?? false` at each read site is gone + rather than unreachable. The field decides whether a run announces its + commands or executes them, and is the value a composing parent inherits down + into a fresh inline child, so defaulting it silently adapted an incompatible + row into an executing run. + +**Five branches came out as provably dead** while mutation-testing the new +module to 100%, and each was a second spelling of a fact the types already +carried: `currentStep.kind === 'command'` and `currentStep.kind === 'for'` +(`command` is declared on `Substep` and `StepWithCommand` only, `forClause` on +`ResolvedStepWithFor` only — both are now structural `in` checks); two of the +five identity checks in the inline-intent projection (`entry.stepId` IS +`state.step` by construction, and the entry's `substepId` check subsumes the raw +cursor's); and an outer `typeof state.snapshot` guard the optional chain already +answered. The cursor overlay that used to sit in `snapshotForEntry` went with +them — it existed to satisfy `deriveStepEnteredEffect`'s guards, which this work +deletes, so nothing read it any more. + +**The #816 divergence is closed rather than characterised.** `rundown collect` +used to build its own partial entry — ids, position, name and flags, and none of +the four rendered fields — while the CLI execution loop's builder filled all of +them, so the same cursor produced two different `STEP_ENTERED` payloads +depending on which command reached it. There is one builder now and nothing left +to disagree, so the characterisation assertions are inverted rather than +deleted: what was `toBeUndefined()` is the rendered value, and what was `false` +is the composed one. They read the emitted payload, because the argument they +used to capture is core-private. The end-to-end contrast is pinned in the CLI's +`integration/step-entered-run-collect-agreement.test.ts`, and the two loop-half +assertions moved from the CLI's mocked loop onto the real derivation in +`packages/core/__tests__/runbook/execution-unit-entry.test.ts`, asserting the +same values on the same fixtures. + +Behaviour is otherwise unchanged. diff --git a/.changeset/loop-prompted-off-the-parameter.md b/.changeset/loop-prompted-off-the-parameter.md new file mode 100644 index 000000000..caabf96ab --- /dev/null +++ b/.changeset/loop-prompted-off-the-parameter.md @@ -0,0 +1,35 @@ +--- +'@rundown-org/core': patch +'@rundown-org/cli': patch +--- + +# `runExecutionLoop` reads `prompted` off the run, not off a parameter + +`runExecutionLoop` took `prompted: boolean` as its fifth argument. Every one of +its six call sites passed the run's own persisted flag — four of them spelled +`!!state.prompted` or the equivalent, and the other two (`transitions.ts`, +`runbook-pipeline.ts`) passed a value core had already derived from +`Boolean(state.prompted)` or written to the row with `manager.create`. The loop +loads that same state on its first line. + +So the parameter was never a way to configure the loop. It was a way for a +caller to disagree with state about a fact state owns, and nothing in the tree +used it that way. It is gone; the loop derives `prompted` once from the state it +loads, above the `while`, because the flag is fixed at run creation and cannot +vary across iterations. + +`launchInlineChildFromIntent` keeps its own `prompted` parameter, and that is +not the same fact: on the fresh-child branch it is the value the composing +parent _inherits down_ into a child run that does not exist yet and therefore +has no persisted flag to read. The resumed-child branch beside it already read +`!!existingChild.prompted` rather than the parameter. + +Behaviour-neutral prefactor for #799: the entry seam that follows derives +`prompted` from state, so the parameter had to go either way, and removing it +first keeps that change to one concern. + +`LifecycleLoopDirective`'s `prompted` field goes with it. It existed only to +feed that argument from `runSeamTransition`; with the argument gone it is a +second copy of a persisted flag, and a second copy is a way to disagree. The +directive now says only whether to run the loop, which is the one thing the +frontend cannot decide for itself. diff --git a/.changeset/reentry-frontier-commit-before-render.md b/.changeset/reentry-frontier-commit-before-render.md new file mode 100644 index 000000000..03c71444a --- /dev/null +++ b/.changeset/reentry-frontier-commit-before-render.md @@ -0,0 +1,30 @@ +--- +'@rundown-org/core': patch +'@rundown-org/cli': patch +--- + +Fix the re-entry frontier seam's render ordering, deduplicate `findStepOrThrow`, +let the entry seam accept a caller-precomputed position, and correct a +misleading comment (code review follow-up on #817/#819/#820). + +- **`projectAndConsumeReEntryFrontier` (core)**: rendering the execution unit — + which can invoke non-idempotent `--helpers` JS — now happens AFTER + `DELEGATE_FRONTIER_CONSUMED` commits, not before. Previously a failed commit + still ran the render's side effects; the next retry re-projects the + still-persisted frontier and would run them again. The render now runs against + the committed state, mirroring the pattern `collection-service.ts`'s + `finishCollection` already used for the fenced twin. +- **`findStepOrThrow` (core, cli)**: the CLI (`services/execution.ts`) and two + core modules (`collection-service.ts`, `completion-service.ts`) each carried + their own copy of this lookup. All three now import the canonical + implementation from `execution-units.ts`. +- **`deriveExecutionUnitEntry` (core)**: accepts an optional caller-precomputed + `position`, used instead of re-deriving one via `countNumberedSteps` + + `buildStepPosition`. The CLI execution loop already computes this value once + per iteration for its own error-reporting events; it now forwards it to + `enterExecutionUnit` instead of paying for the identical derivation twice. +- **`runExecutionLoop`'s `prompted` fallback comment (cli)**: corrected. + `RunbookState.prompted` and `CreateOptions.prompted` are genuinely optional at + the type level (unlike `templateVars`, `load()` carries no fail-closed guard + for a missing one). The fallback is unreachable only because of call-site + discipline, not because the type forbids `undefined`. diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ebb52836a..e9367858c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,13 +1,19 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: "en-US" +# Hard limit: 250 characters AFTER YAML folds this block into one line. The +# previous value was 502 and CodeRabbit silently fell back to default settings +# for the whole file. Measure before editing — the folded length is what counts, +# not the source lines. The semver clause is load-bearing (CLAUDE.md forbids +# raising it as a finding); the rest of that policy lives in CLAUDE.md, which +# CodeRabbit auto-detects through `knowledge_base.code_guidelines` (enabled at +# the foot of this file). Do NOT add CLAUDE.md to `reviews.path_instructions` — +# that field maps instructions onto files being REVIEWED, so listing it there +# would have CodeRabbit review the guidelines instead of applying them. tone_instructions: >- - Focus on type safety, state machine correctness, security policy correctness, - persisted state no-migration, CLI output stability, and design principles - from CLAUDE.md. Be direct and specific. Flag action-type mapping violations. - This project is unreleased with no downstream consumers: never comment on - semver bump levels, changeset major/minor/patch choices, or breaking-change - impact on hypothetical consumers. A finding whose only consequence lands on a - consumer that does not exist is out of scope. + Focus on type safety, state-machine correctness, security policy, + persisted-state no-migration, CLI output stability, and CLAUDE.md design + principles. Be direct and specific. Unreleased project: never raise semver or + hypothetical-consumer impact. reviews: profile: "assertive" diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index c9b8cceae..c83406506 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -36,6 +36,7 @@ OSTYPE stashable Subcategorize TOCTOU +TSESTree Turborepo Unasserted Unforgeable @@ -57,9 +58,11 @@ bisectable callsite callsites canonicalise +canonicalised canonicalises categorisation categorises +characterised centralise Centralised centralising @@ -135,12 +138,14 @@ lockfile materialise materialised materialising +materialises macrostep mdast misattributes miscomputation microstep minimatch +mintable Misordered mjs monorepo @@ -168,6 +173,7 @@ notavar nums opts parallelised +parallelises parameterised parameterizing parseable diff --git a/docs/internal/architecture.md b/docs/internal/architecture.md index 9599828bd..8f441f70e 100644 --- a/docs/internal/architecture.md +++ b/docs/internal/architecture.md @@ -570,13 +570,21 @@ events for stdout/stderr (and for the MCP server when it is the front end): | `ERROR_OCCURRED { code, message }` | When a typed `lastAction` variant indicates a machine-internal failure (e.g. `RETRY_ERROR`). See [Typed `lastAction` Discriminants](#typed-lastaction-discriminants). | | `COMMAND_STARTED` / `COMMAND_COMPLETED` / `POLICY_DENIED` | Machine-owned. Command execution is the Category C actor `commandExecActor` (`packages/core/src/runbook/actors/command-exec-actor.ts`), wired into every leaf in `compiler.ts`. These observations flow through the `MachineExecutionObserver` effect collector in core (`packages/core/src/events/execution-observation.ts`); the CLI supplies the `runExternalCommand` services that the actor calls but does not emit the events. | +`STEP_ENTERED` is built by exactly one function, `deriveExecutionUnitEntry` +(`packages/core/src/runbook/execution-unit-entry.ts`), reached through +`RunbookActorService.enterExecutionUnit`. Every frontend enters a unit that way +— `rundown run`'s execution loop and `rundown collect`'s re-entry disclosure +alike — so the payload cannot vary with the command that produced it. The seam +also classifies the entry, returning `awaiting` / `runnable` / `inline-launch`; +the rendered command travels only on the `runnable` arm, inside a nominally +branded record the module is the sole producer of. + `STEP_ENTERED` may include `delegateFrontier` for authored `- DELEGATE` targets or `inlineLaunch` for non-DELEGATE child-runbook targets. `inlineLaunch` is -projected from persisted `context.inlineLaunchIntent` by -`RunbookActorService.observeExecutionUnitEntry`; it includes the parent -identity, parent frame, preallocated child run id, and child runbook reference. -The CLI launch loop consumes this typed intent, creates the child run with -inline parent linkage, sends `INLINE_CHILD_STARTED`, then sends +projected from persisted `context.inlineLaunchIntent` by the same seam; it +includes the parent identity, parent frame, preallocated child run id, and child +runbook reference. The CLI launch loop consumes this typed intent, creates the +child run with inline parent linkage, sends `INLINE_CHILD_STARTED`, then sends `INLINE_LAUNCH_CONSUMED`. Variable inheritance uses the internal context snapshot carried on the intent, but the public JSON renderer redacts that snapshot from `step_entered.inlineLaunch`. @@ -680,8 +688,8 @@ it. re-announcing the step and re-running any command it carries. The discriminant is the surviving intent itself (`#hasUnconsumedInlineLaunchIntent`), which is the same value - `observeExecutionUnitEntry` re-projects, so the seam's decision and the - loop's behaviour agree by construction. + `enterExecutionUnit` re-projects, so the seam's decision and the loop's + behaviour agree by construction. **A child is activated only by the launch span that wins it.** The seam matches on a running child with matching linkage, which is also what a @@ -875,20 +883,33 @@ code, `RD-821` (`DELEGATION_INVARIANT_VIOLATED`): The last two rows share one **disclosure boundary** — the same reader, the same projector, and the same refusal arm — in two seams that differ only in when the -consume commits. Both live in `packages/core/src/runbook/re-entry-frontier.ts`, -and each frontend contributes only its rendered `StepEntryMetadata`, its emitter -wiring, and its exit-code mapping. +consume commits. Both live in `packages/core/src/runbook/re-entry-frontier.ts`. +Neither takes a caller-supplied entry: each derives "is the cursor on a substep" +from the state it already holds, and the entry the bearers ride on is rendered +by `enterExecutionUnit`. A frontend contributes its emitter wiring and its +exit-code mapping, nothing more. | Seam | Driver | Consume | Arms | | ---------------------------------- | ------------------ | ---------------------------------------- | ----------------------------------------------------------------- | | `projectAndConsumeReEntryFrontier` | `runExecutionLoop` | Committed by the seam, via `sendAndSync` | `none` / `projected` / `projection_refused` / `consume_failed` | | `prepareReEntryFrontierConsume` | `rundown collect` | **Derived**, committed by the caller | `none` / `projected` / `projection_refused` — no `consume_failed` | -The unfenced seam commits the consume **before** returning observations, so a -failed consume discloses no bearer. The fenced twin cannot observe until the -caller's single transaction has landed, which strengthens the same guarantee: -where the unfenced seam can leave a consume committed while the surrounding work -is not, a refused transaction consumes nothing and discloses nothing. +The unfenced seam commits the consume **before** returning the entered unit, so +a failed consume discloses no bearer. The fenced twin cannot enter the unit +until the caller's single transaction has landed, which strengthens the same +guarantee: where the unfenced seam can leave a consume committed while the +surrounding work is not, a refused transaction consumes nothing and discloses +nothing. + +A collect's disclosure has one failure mode the loop's does not, and it is new +with the shared entry: a collect that has already committed can fail to RENDER +the entry its bearers ride on — typically a `--helpers` helper raising. Nothing +recovers the bearers (the consume is durable, so a retry answers the idempotent +no-op), so the collect rejects rather than reporting a phantom success with an +empty observation list. It rejects with a code of its own, `RD-833` +(`DELEGATION_FRONTIER_DISCLOSURE_FAILED`), except when the render refusal is +`InvalidRunbookStateError` — corrupt persisted state keeps its class so the +CLI's RD-309 arm still prints finish/stop/prune. That is why `consume_failed` has no fenced counterpart. A derivation cannot half-commit, so the only way a collect's consume does not land is that its diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 12a9f5d77..059318dc7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1272,39 +1272,40 @@ JSON output compatibility: ### Common Errors and Resolutions -| Error | Cause | Resolution | -| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| "No active runbook" | No runbook in stack | Run `rundown run ` | -| "Runbook file not found" | Missing runbook | Check file path | -| "Step N does not exist" | Invalid GOTO target | Check step numbers | -| "Invalid step target" | Bad goto format | Use "N" or "N.M" | -| "FOR loop references undefined data source" | Sourced FOR clause without matching source | Define source via --input-json, config.yaml, or --input-file | -| "File drift detected" | Data file changed during iteration | Ensure file stability or restart runbook | -| `ACTOR_CONTEXT_REQUIRED` | Bare mutating command, or a `--run`-only mutation, on a delegation-exposed run | Name your bearer lane with `--claim-id ` (the only mutation authority); `--run` never satisfies the refusal, and must not be combined with `--claim-id` | -| `RUN_TARGET_UNAVAILABLE` | `--run` target is not a running member of this session's stack | Use a run id from the active session stack (claimed children are never stack members — target them with `--claim-id`) | -| `RUN_TARGET_MISMATCH` | `delegate --retry --run ` where the named run is a valid target but does not own the token | Drop `--run` to let the token resolve its own owner, or name the run that actually owns the token — the refusal does not disclose it | -| `INVALID_RUN_ID` | Malformed `--run` value | Supply a valid run id — `rd_<32 hex characters>` | -| `CLAIM_GRANT_REQUIRED` | The verified bearer lacks the exact grant for the command/target (e.g. `rundown collect` from a claim without `collect-for-run` on the delegating run) | Present a `--claim-id ` whose grant controls the target run | -| `CLAIM_BEARER_MISMATCH` | The presented bearer is not the claim the command targeted. **Unreachable from the CLI** — `--claim-id` supplies both the evidence and the target, so they cannot disagree; only a programmatic frontend that populates them independently can provoke it | Present the bearer for the claim you are targeting. Distinct from `ACTOR_CONTEXT_REQUIRED` on purpose: authority _was_ named, so "pass `--claim-id`" would misdiagnose it | -| `INVALID_SYNTAX` | `--claim-id` and `--run` supplied together on the same command | Pass either `--claim-id` or `--run`, not both — they are mutually exclusive | -| `EXECUTION_IN_PROGRESS` | The command had to change session targeting (stack, stash slot, or a claim) for a run another process is currently executing; ownership is exclusive, so nothing was written | Wait for the owning process to finish and retry — the command is safe to repeat. An owner that was hard-killed rather than finishing does not strand the run: every execution-owning command probes for a dead owner before refusing, and clears or recovers the lease when it finds one | -| `RECOVERY_REQUIRED` | The named run's last execution attempt ended without recording an outcome, so whether its effect ran is unknown. Unlike `EXECUTION_IN_PROGRESS`, no live process is advancing it, so waiting will not clear it — but a session-targeting refusal leaves the run execution-owned, so a later execution-owning command still refuses `EXECUTION_IN_PROGRESS` while the interrupted owner is alive, and recovers the epoch inline once it is gone | There is no `rundown recover` command. What happens next depends on which origin emitted it, and the message tells you which: from the **execution fence** (`Run … needs recovery: …`) the runner already recovered that epoch inline, so the run itself is unblocked — but the refusal says your mutation did not commit, NOT that nothing happened: the attempt had crossed the effect boundary, so its external effect may already have run. Confirm whether it did before repeating the command; from a **session-targeting write** (`Run … ended execution with an unknown outcome at epoch N; …`) nothing was written and no recovery was started, so retrying cannot help — see [docs/spec/cli-output.md § Recovery required](../spec/cli-output.md#recovery-required) | -| `AGGREGATE_RECOVERY_REQUIRED` | The multi-run form: an atomic multi-run mutation (forced inline terminal cascade, aggregate delegation abort) crossed its effect boundary leaving more than one run without a recorded outcome. Only this envelope carries `details.runs` | Recovery of every `(runId, epoch)` named in `details.runs` is driven inline and per member, best-effort — there is no `rundown recover` command to run. Nothing was written; retry once each named attempt has been recovered | -| `STALE_CLAIM` | The presented bearer stopped controlling the target run between the moment the mutation captured its authority and the moment it tried to commit — the claim was released, rotated or re-issued, its generation advanced, or the delegated parent went terminal or was relinked. The transaction refused atomically; nothing was written. Carries no `details` | Do not retry with the same claim — it is no longer authority. Re-resolve your bearer against the parent's current delegation, or report the lost claim to the orchestrator. Distinct from the resolution-time `DELEGATION_SUPERSEDED` / `CLAIMED_RUNBOOK_UNAVAILABLE`, which refuse before any mutation is attempted | -| `CONCURRENT_MODIFICATION` | The parent run changed after a delegated child link was derived but before its atomic claim/link commit | Retry the claim; no claim or parent link was written | -| `RD-821` (`DELEGATION_INVARIANT_VIOLATED`) | A delegation bearer could not be handed back: the claim you presented is not the claim that issued it, or the re-derived bearer does not match its persisted hash. Emitted by `delegate`'s echo, and by every surface that re-enters a persisted delegation frontier — the execution loop (the run stops) and `rundown collect` (`reason: 'frontier_projection_refused'`) | Present the claim that _issued_ the delegation — retrying with the same non-issuing claim refuses identically, and a rotated or released claim is not authority at all. If the issuing claim is gone and you still hold the delegation token from an earlier disclosure, tear the delegation down and re-issue: `rundown abort --claim-id --force`, then re-delegate — where `` is the delegating run's **current** run-control claim, never the gone-or-rotated issuing one (`abort` authorizes on the `abort-delegation` grant over the parent run, so any live run-control claim for it suffices). If you hold no live run-control claim for that run — none can be re-minted once it has issued a delegation — or you no longer hold the token, the delegation cannot be re-disclosed and the run must be stopped and restarted | -| `RD-826` (`DELEGATION_REPLACEMENT_CONSUMED`) | `delegate --retry` named a bearer that was already replaced, and the replacement shows committed evidence of use — claimed by a child, aborted, or its frame entry advanced | Target the current delegation instead of the superseded bearer, or `rundown abort --claim-id --force` and re-delegate. Retrying the same bearer refuses identically | -| `RD-827` (`DELEGATION_RETRY_IDENTITY_UNMATCHED`) | `delegate --retry ` named a bearer that identifies neither the delegation currently recorded at the target nor one it superseded | Re-read the current delegation with `rundown status` and retry the bearer it names | -| `RD-828` (`DELEGATION_SUPERSESSION_AMBIGUOUS`) | More than one delegation attempt records the named bearer as superseded, so there is no single replacement to echo or judge. Unreachable by construction — it is refused, never resolved | Invalid state: finish or `rundown prune` the run and restart from the source runbook | -| `RD-829` (`DELEGATION_FRONTIER_CONSUME_FAILED`) | A persisted delegation re-entry frontier projected, but the machine did not accept the consume, so it is still pending and no bearer was disclosed. Emitted by the execution loop (the run stops). Not reachable from `rundown collect`, whose consume is derived inside its one transaction — a refusal there leaves the frontier untouched and reports the transactional code instead | Transient — repeat the operation. The next attempt re-projects and re-consumes the same frontier | -| `RD-830` (`INLINE_CHILD_FRAME_SUPERSEDED`) | The inline child recorded at this frame was launched at an earlier entry and the parent has since re-entered the frame. Reachable from an ordinary gesture — a self-targeting `GOTO`/`RETRY` advances the frame's entry counter — rather than from corrupt state. The same judgement delegation makes when it closes a child `cursor-advanced` | Finish, stop, or prune the superseded child run, then re-enter; the same re-entry then launches a fresh child under the current entry | -| `RD-831` (`INLINE_CHILD_LINKAGE_MISMATCH`) | The persisted inline child names a different parent run, step, substep, or frame than the launch intent describes. Distinct from `RD-830`: inconsistent state rather than a superseded generation of the same linkage | `rundown prune` the child run and restart the parent from source | -| `RD-305` (`INCOMPATIBLE_STATE_SCHEMA`) | `.rundown/rundown.db` carries a schema version this build cannot read, and Rundown never migrates persisted state. Affects the whole database, so every in-flight run is unrecoverable | Delete `.rundown/rundown.db` and restart your runbooks from source | -| `RD-306` (`WAL_JOURNAL_MODE_UNAVAILABLE`) | The database did not enter WAL mode. SQLite still serializes cross-process writers through file locking in rollback-journal mode, but that mode does not provide WAL's reader/writer concurrency and is not a validated Rundown deployment mode. SQLite returned a non-WAL mode or no readable mode, which narrows the cause to a VFS with no shared memory (a network mount such as NFS or SMB), a temporary database opened with no filename, or a connection already in a write transaction | Establish which of the three applies before moving anything. A read-only file or directory is **not** among them — that fails the pragma outright and surfaces as `RD-307`. A network mount is the common case; move the project to local disk | -| `RD-307` (`STATE_STORE_UNAVAILABLE`) | `.rundown/rundown.db` could not be opened, so commands that access persisted run state cannot continue. Store-independent commands such as `rundown check` remain available. The driver's verbatim cause distinguishes a read-only file or directory, a file that is not a database, lock contention that outlasted the bounded timeout and retries, and a host whose SQLite adapter cannot initialize | Retry after transient lock contention; repair the host or file for persistent failures. Rundown never downgrades to the single-writer sql.js adapter outside WebContainer | -| `RD-308` (`CONCURRENT_STATE_MODIFICATION`) | A run-state read-modify-write spent its optimistic compare-and-swap budget because another process committed to the same run first. Nothing was written and the persisted state is intact. This is the thrown face of the same condition `CONCURRENT_MODIFICATION` renders as a command outcome | Re-run the command — alone among the `3xx` state errors this one is transient, not a refusal | -| `RD-309` (`INVALID_PERSISTED_RUN_STATE`) | One run's persisted state does not match the contract this build reads: unparseable state, a `schemaVersion` other than `1`, a missing required field such as `templateVars`, or a deprecated dynamic-step snapshot. Only that run is affected — the database and every other run in it are intact | Finish it (`rundown complete`), stop it (`rundown stop`), or discard it (`rundown prune --inactive`), then re-run the runbook from source. The prune mode is load-bearing: a bare `rundown prune` selects completed and stopped runs out of `manager.list()`, which skips every invalid row, so it exits `0` having pruned nothing. `--inactive` reaches the run through prune's invalid-id path, discarding any other orphaned run alongside it; `--all` reaches it too, but discards every run in the project. Rundown never migrates persisted state | -| `COLLECT_OPERATION_FAILED` | `rundown collect` could not finish: a delegated outcome did not apply to the target cursor | Check the message, which names the cursor mismatch; the frontier failures have their own codes above | +| Error | Cause | Resolution | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "No active runbook" | No runbook in stack | Run `rundown run ` | +| "Runbook file not found" | Missing runbook | Check file path | +| "Step N does not exist" | Invalid GOTO target | Check step numbers | +| "Invalid step target" | Bad goto format | Use "N" or "N.M" | +| "FOR loop references undefined data source" | Sourced FOR clause without matching source | Define source via --input-json, config.yaml, or --input-file | +| "File drift detected" | Data file changed during iteration | Ensure file stability or restart runbook | +| `ACTOR_CONTEXT_REQUIRED` | Bare mutating command, or a `--run`-only mutation, on a delegation-exposed run | Name your bearer lane with `--claim-id ` (the only mutation authority); `--run` never satisfies the refusal, and must not be combined with `--claim-id` | +| `RUN_TARGET_UNAVAILABLE` | `--run` target is not a running member of this session's stack | Use a run id from the active session stack (claimed children are never stack members — target them with `--claim-id`) | +| `RUN_TARGET_MISMATCH` | `delegate --retry --run ` where the named run is a valid target but does not own the token | Drop `--run` to let the token resolve its own owner, or name the run that actually owns the token — the refusal does not disclose it | +| `INVALID_RUN_ID` | Malformed `--run` value | Supply a valid run id — `rd_<32 hex characters>` | +| `CLAIM_GRANT_REQUIRED` | The verified bearer lacks the exact grant for the command/target (e.g. `rundown collect` from a claim without `collect-for-run` on the delegating run) | Present a `--claim-id ` whose grant controls the target run | +| `CLAIM_BEARER_MISMATCH` | The presented bearer is not the claim the command targeted. **Unreachable from the CLI** — `--claim-id` supplies both the evidence and the target, so they cannot disagree; only a programmatic frontend that populates them independently can provoke it | Present the bearer for the claim you are targeting. Distinct from `ACTOR_CONTEXT_REQUIRED` on purpose: authority _was_ named, so "pass `--claim-id`" would misdiagnose it | +| `INVALID_SYNTAX` | `--claim-id` and `--run` supplied together on the same command | Pass either `--claim-id` or `--run`, not both — they are mutually exclusive | +| `EXECUTION_IN_PROGRESS` | The command had to change session targeting (stack, stash slot, or a claim) for a run another process is currently executing; ownership is exclusive, so nothing was written | Wait for the owning process to finish and retry — the command is safe to repeat. An owner that was hard-killed rather than finishing does not strand the run: every execution-owning command probes for a dead owner before refusing, and clears or recovers the lease when it finds one | +| `RECOVERY_REQUIRED` | The named run's last execution attempt ended without recording an outcome, so whether its effect ran is unknown. Unlike `EXECUTION_IN_PROGRESS`, no live process is advancing it, so waiting will not clear it — but a session-targeting refusal leaves the run execution-owned, so a later execution-owning command still refuses `EXECUTION_IN_PROGRESS` while the interrupted owner is alive, and recovers the epoch inline once it is gone | There is no `rundown recover` command. What happens next depends on which origin emitted it, and the message tells you which: from the **execution fence** (`Run … needs recovery: …`) the runner already recovered that epoch inline, so the run itself is unblocked — but the refusal says your mutation did not commit, NOT that nothing happened: the attempt had crossed the effect boundary, so its external effect may already have run. Confirm whether it did before repeating the command; from a **session-targeting write** (`Run … ended execution with an unknown outcome at epoch N; …`) nothing was written and no recovery was started, so retrying cannot help — see [docs/spec/cli-output.md § Recovery required](../spec/cli-output.md#recovery-required) | +| `AGGREGATE_RECOVERY_REQUIRED` | The multi-run form: an atomic multi-run mutation (forced inline terminal cascade, aggregate delegation abort) crossed its effect boundary leaving more than one run without a recorded outcome. Only this envelope carries `details.runs` | Recovery of every `(runId, epoch)` named in `details.runs` is driven inline and per member, best-effort — there is no `rundown recover` command to run. Nothing was written; retry once each named attempt has been recovered | +| `STALE_CLAIM` | The presented bearer stopped controlling the target run between the moment the mutation captured its authority and the moment it tried to commit — the claim was released, rotated or re-issued, its generation advanced, or the delegated parent went terminal or was relinked. The transaction refused atomically; nothing was written. Carries no `details` | Do not retry with the same claim — it is no longer authority. Re-resolve your bearer against the parent's current delegation, or report the lost claim to the orchestrator. Distinct from the resolution-time `DELEGATION_SUPERSEDED` / `CLAIMED_RUNBOOK_UNAVAILABLE`, which refuse before any mutation is attempted | +| `CONCURRENT_MODIFICATION` | The parent run changed after a delegated child link was derived but before its atomic claim/link commit | Retry the claim; no claim or parent link was written | +| `RD-821` (`DELEGATION_INVARIANT_VIOLATED`) | A delegation bearer could not be handed back: the claim you presented is not the claim that issued it, or the re-derived bearer does not match its persisted hash. Emitted by `delegate`'s echo, and by every surface that re-enters a persisted delegation frontier — the execution loop (the run stops) and `rundown collect` (`reason: 'frontier_projection_refused'`) | Present the claim that _issued_ the delegation — retrying with the same non-issuing claim refuses identically, and a rotated or released claim is not authority at all. If the issuing claim is gone and you still hold the delegation token from an earlier disclosure, tear the delegation down and re-issue: `rundown abort --claim-id --force`, then re-delegate — where `` is the delegating run's **current** run-control claim, never the gone-or-rotated issuing one (`abort` authorizes on the `abort-delegation` grant over the parent run, so any live run-control claim for it suffices). If you hold no live run-control claim for that run — none can be re-minted once it has issued a delegation — or you no longer hold the token, the delegation cannot be re-disclosed and the run must be stopped and restarted | +| `RD-826` (`DELEGATION_REPLACEMENT_CONSUMED`) | `delegate --retry` named a bearer that was already replaced, and the replacement shows committed evidence of use — claimed by a child, aborted, or its frame entry advanced | Target the current delegation instead of the superseded bearer, or `rundown abort --claim-id --force` and re-delegate. Retrying the same bearer refuses identically | +| `RD-827` (`DELEGATION_RETRY_IDENTITY_UNMATCHED`) | `delegate --retry ` named a bearer that identifies neither the delegation currently recorded at the target nor one it superseded | Re-read the current delegation with `rundown status` and retry the bearer it names | +| `RD-828` (`DELEGATION_SUPERSESSION_AMBIGUOUS`) | More than one delegation attempt records the named bearer as superseded, so there is no single replacement to echo or judge. Unreachable by construction — it is refused, never resolved | Invalid state: finish or `rundown prune` the run and restart from the source runbook | +| `RD-829` (`DELEGATION_FRONTIER_CONSUME_FAILED`) | A persisted delegation re-entry frontier projected, but the machine did not accept the consume, so it is still pending and no bearer was disclosed. Emitted by the execution loop (the run stops). Not reachable from `rundown collect`, whose consume is derived inside its one transaction — a refusal there leaves the frontier untouched and reports the transactional code instead | Transient — repeat the operation. The next attempt re-projects and re-consumes the same frontier | +| `RD-833` (`DELEGATION_FRONTIER_DISCLOSURE_FAILED`) | A `rundown collect` committed its aggregate and consumed the persisted re-entry frontier, then could not render the `STEP_ENTERED` entry the freshly derived bearers ride on. The collection **landed** — the outcomes are drained and the frontier is gone. Distinct from `RD-829`, where the consume never committed. Usually a `--helpers` helper raising while expanding the unit's description, prompt, or command | Fix the helper, then re-delegate the step. Retrying the collect answers the idempotent no-op rather than re-deriving the bearers, so the delegations the lost bearers addressed must be re-issued | +| `RD-830` (`INLINE_CHILD_FRAME_SUPERSEDED`) | The inline child recorded at this frame was launched at an earlier entry and the parent has since re-entered the frame. Reachable from an ordinary gesture — a self-targeting `GOTO`/`RETRY` advances the frame's entry counter — rather than from corrupt state. The same judgement delegation makes when it closes a child `cursor-advanced` | Finish, stop, or prune the superseded child run, then re-enter; the same re-entry then launches a fresh child under the current entry | +| `RD-831` (`INLINE_CHILD_LINKAGE_MISMATCH`) | The persisted inline child names a different parent run, step, substep, or frame than the launch intent describes. Distinct from `RD-830`: inconsistent state rather than a superseded generation of the same linkage | `rundown prune` the child run and restart the parent from source | +| `RD-305` (`INCOMPATIBLE_STATE_SCHEMA`) | `.rundown/rundown.db` carries a schema version this build cannot read, and Rundown never migrates persisted state. Affects the whole database, so every in-flight run is unrecoverable | Delete `.rundown/rundown.db` and restart your runbooks from source | +| `RD-306` (`WAL_JOURNAL_MODE_UNAVAILABLE`) | The database did not enter WAL mode. SQLite still serializes cross-process writers through file locking in rollback-journal mode, but that mode does not provide WAL's reader/writer concurrency and is not a validated Rundown deployment mode. SQLite returned a non-WAL mode or no readable mode, which narrows the cause to a VFS with no shared memory (a network mount such as NFS or SMB), a temporary database opened with no filename, or a connection already in a write transaction | Establish which of the three applies before moving anything. A read-only file or directory is **not** among them — that fails the pragma outright and surfaces as `RD-307`. A network mount is the common case; move the project to local disk | +| `RD-307` (`STATE_STORE_UNAVAILABLE`) | `.rundown/rundown.db` could not be opened, so commands that access persisted run state cannot continue. Store-independent commands such as `rundown check` remain available. The driver's verbatim cause distinguishes a read-only file or directory, a file that is not a database, lock contention that outlasted the bounded timeout and retries, and a host whose SQLite adapter cannot initialize | Retry after transient lock contention; repair the host or file for persistent failures. Rundown never downgrades to the single-writer sql.js adapter outside WebContainer | +| `RD-308` (`CONCURRENT_STATE_MODIFICATION`) | A run-state read-modify-write spent its optimistic compare-and-swap budget because another process committed to the same run first. Nothing was written and the persisted state is intact. This is the thrown face of the same condition `CONCURRENT_MODIFICATION` renders as a command outcome | Re-run the command — alone among the `3xx` state errors this one is transient, not a refusal | +| `RD-309` (`INVALID_PERSISTED_RUN_STATE`) | One run's persisted state does not match the contract this build reads: unparseable state, a `schemaVersion` other than `1`, a missing required field such as `templateVars`, `prompted`, or `frontmatterOutputs`, a `snapshot.value` this build cannot read or that names a step the runbook no longer declares, a `step` cursor naming a step the runbook no longer declares, effective variables carrying no `ContextId`/`WorkPath` to render against, or a deprecated dynamic-step snapshot. Only that run is affected — the database and every other run in it are intact | Finish it (`rundown complete`), stop it (`rundown stop`), or discard it (`rundown prune --inactive`), then re-run the runbook from source. The prune mode is load-bearing: a bare `rundown prune` selects completed and stopped runs out of `manager.list()`, which skips every invalid row, so it exits `0` having pruned nothing. `--inactive` reaches the run through prune's invalid-id path, discarding any other orphaned run alongside it; `--all` reaches it too, but discards every run in the project. Rundown never migrates persisted state | +| `COLLECT_OPERATION_FAILED` | `rundown collect` could not finish: a delegated outcome did not apply to the target cursor | Check the message, which names the cursor mismatch; the frontier failures have their own codes above | ### State Recovery diff --git a/docs/spec/cli-output.md b/docs/spec/cli-output.md index a98b3e109..c2e903f33 100644 --- a/docs/spec/cli-output.md +++ b/docs/spec/cli-output.md @@ -1819,9 +1819,10 @@ Error RD-308: Runbook state lost to a concurrent writer - Run rd_9e725b142d81dab A run in the database does not match the state contract this build reads: unparseable persisted state, a `RunbookState.schemaVersion` other than `1`, a -missing required field such as `templateVars`, or a deprecated dynamic-step -snapshot. Rundown never migrates persisted state, so the run cannot be resumed -and is never silently repaired. +missing required field such as `templateVars` or `prompted`, a cursor naming a +step the runbook no longer declares, or a deprecated dynamic-step snapshot. +Rundown never migrates persisted state, so the run cannot be resumed and is +never silently repaired. Scope is deliberately narrow and is the reason this is not `RD-305`: **only that run is affected**, and the database and every other run in it are intact. The @@ -1836,9 +1837,12 @@ validation, so it exits `0` having pruned nothing at all. `--inactive` (or Being scoped to one run is also why `context` names it. `runId` is the affected run, `reason` is a closed set naming which refusal fired (`unparseable_json`, -`invalid_schema_version`, `missing_template_vars`, `schema_validation_failed`, -`legacy_dynamic_step_snapshot`, `malformed_delegate_frontier`, -`unrecognized_recovery_reason`), and `schemaVersion` — present only for +`invalid_schema_version`, `missing_template_vars`, `missing_prompted`, +`schema_validation_failed`, `legacy_dynamic_step_snapshot`, +`malformed_delegate_frontier`, `unrecognized_recovery_reason`, +`missing_render_context`, `unsupported_snapshot_state_value`, +`snapshot_step_not_in_runbook`, `cursor_step_not_in_runbook`, +`missing_frontmatter_outputs`), and `schemaVersion` — present only for `invalid_schema_version` — is the version the row claims, reported exactly as persisted and deliberately not narrowed to a number. That last field appears **nowhere** in the message prose, so structured context is the only way to read diff --git a/eslint-rules/no-rendered-unit-command-cast.mjs b/eslint-rules/no-rendered-unit-command-cast.mjs new file mode 100644 index 000000000..d7dadca3e --- /dev/null +++ b/eslint-rules/no-rendered-unit-command-cast.mjs @@ -0,0 +1,116 @@ +// @ts-check +import { ESLintUtils } from '@typescript-eslint/utils'; + +const BRAND_NAME = 'RenderedUnitCommand'; +const BRAND_DECLARING_FILE_SUFFIX = 'runbook/execution-unit-entry.ts'; + +export const RENDERED_UNIT_COMMAND_PROVENANCE = + 'A RenderedUnitCommand is minted only by deriveExecutionUnitEntry. Reach it through RunbookActorService.enterExecutionUnit; asserting one anywhere else claims provenance the value does not have.'; + +const createRule = ESLintUtils.RuleCreator( + () => 'https://github.com/tobyhede/rundown/blob/main/eslint.config.js', +); + +/** + * Does `tsType` name the RenderedUnitCommand interface — directly, through a + * chain of `extends`, or as a member of a union/intersection? + * + * Resolved through the checker rather than through the syntax that named the + * type, which is the property a `no-restricted-syntax` selector cannot have: a + * selector matches an identifier's TEXT, so an import rename + * (`RenderedUnitCommand as Local`) or a type alias produces a different + * identifier at the assertion site and slips past every selector that + * enumerates spellings. The checker instead resolves `Local` — or a base type, + * or a union member — back to the same declared symbol, so identity is + * checked once, structurally, rather than by re-deriving every path a name can + * travel. + * + * `seen` guards a type graph that recurses through base types and union + * members; nothing in this codebase's type graph is cyclic, but nothing about + * the checker's API guarantees that in general. + * + * @param {import('typescript').Type} tsType - The resolved TypeScript type to test. + * @param {Set} seen - Symbols already visited, to bound recursion. + * @returns {boolean} True if `tsType` is, extends, or unions/intersects the + * branded interface. + */ +function namesRenderedUnitCommand(tsType, seen) { + const symbol = tsType.getSymbol?.(); + if (symbol) { + if (seen.has(symbol)) return false; + seen.add(symbol); + if ( + symbol.getName() === BRAND_NAME && + (symbol.getDeclarations() ?? []).some((declaration) => + declaration.getSourceFile().fileName.endsWith(BRAND_DECLARING_FILE_SUFFIX), + ) + ) { + return true; + } + } + + const baseTypes = tsType.getBaseTypes?.() ?? []; + if (baseTypes.some((base) => namesRenderedUnitCommand(base, seen))) return true; + + if (tsType.isUnionOrIntersection?.()) { + return tsType.types.some((member) => namesRenderedUnitCommand(member, seen)); + } + + return false; +} + +/** + * Bans every type-checker-resolvable way to assert a value into + * `RenderedUnitCommand` outside its producing module. + * + * This replaces a `no-restricted-syntax` selector set that matched the + * identifier `RenderedUnitCommand` by name. That approach has a hole by + * construction: the set of SPELLINGS a selector must enumerate is unbounded + * (an import rename, a re-exported alias, a type alias one hop further away), + * while the set of underlying TYPES the checker can resolve those spellings to + * is exactly one. Checking the resolved type closes the hole for every + * spelling at once, including ones no selector was ever written for. + * + * Scoped to `TSAsExpression` and `TSTypeAssertion` because those are the two + * syntaxes that can mint the brand — `deriveExecutionUnitEntry` is the only + * code that legitimately produces a value of this type, so a cast anywhere + * else materialises a value the runtime never verified. + */ +export const noRenderedUnitCommandCast = createRule({ + name: 'no-rendered-unit-command-cast', + meta: { + type: 'problem', + docs: { + description: + 'Disallow asserting a value to RenderedUnitCommand outside deriveExecutionUnitEntry.', + }, + schema: [], + messages: { + forbidden: RENDERED_UNIT_COMMAND_PROVENANCE, + }, + }, + defaultOptions: [], + create(context) { + const services = ESLintUtils.getParserServices(context); + + /** + * @param {import('@typescript-eslint/utils').TSESTree.TypeNode} typeAnnotationNode + */ + function check(typeAnnotationNode) { + const tsTypeNode = services.esTreeNodeToTSNodeMap.get(typeAnnotationNode); + const tsType = services.program.getTypeChecker().getTypeFromTypeNode(tsTypeNode); + if (namesRenderedUnitCommand(tsType, new Set())) { + context.report({ node: typeAnnotationNode, messageId: 'forbidden' }); + } + } + + return { + TSAsExpression(node) { + check(node.typeAnnotation); + }, + TSTypeAssertion(node) { + check(node.typeAnnotation); + }, + }; + }, +}); diff --git a/eslint.config.js b/eslint.config.js index d44f34ea5..77a2b95ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,7 @@ import tseslint from 'typescript-eslint'; import globals from 'globals'; import jsdoc from 'eslint-plugin-jsdoc'; import { ignores } from './eslint.ignores.js'; +import { noRenderedUnitCommandCast } from './eslint-rules/no-rendered-unit-command-cast.mjs'; // Ban direct `Error.isError(...)` calls — undefined in Node ≤ 23 (notably // WebContainer's bundled Node 22.x). Use the polyfilled helpers from @@ -37,6 +38,36 @@ const trustedArtifactCastSelectors = [ }, ]; +// Ban every assertion that can mint a `RenderedUnitCommand`. The brand witnesses +// that a command string came from the sanctioned expander inside +// `deriveExecutionUnitEntry`, so a cast anywhere else asserts a provenance the +// value does not have. Tier 1 (a `declare const` unique symbol) means there is +// no runtime check to fall back on — the cast IS the mint — which is exactly why +// it has to be confined to the producer. `execution-unit-entry.ts` is exempted +// below. +// +// This used to be a `no-restricted-syntax` selector matching the identifier +// `RenderedUnitCommand` by name, which has a hole by construction: the set of +// SPELLINGS a selector must enumerate (a bare cast, a qualified one, an +// import-renamed one, a type alias, an interface that inherits the brand) is +// unbounded, while the set of underlying TYPES those spellings can resolve to +// is exactly one. `local/no-rendered-unit-command-cast` +// (./eslint-rules/no-rendered-unit-command-cast.mjs) checks the resolved type +// instead — via `tsconfig.eslint.json`'s type-aware parser project, already +// wired below — so an import rename that produces a NEW identifier at the +// assertion site is caught the same as a direct cast, because the checker +// resolves both back to the same declared symbol. +// +// `RenderedUnitCommand` is also not re-exported from `@rundown-org/core`, so +// outside `packages/core` the name cannot be brought into scope at all — a +// second, structural layer this rule does not need to reason about. +// `scripts/__tests__/eslint-brand-cast-guard.test.mjs` lints one committed +// fixture per laundering route through this config, so a regression in either +// layer fails rather than reading as passing. Those fixtures are deliberate +// violations, which is why `packages/core/__tests__/fixtures/brand-cast/**` is +// in eslint.ignores.js; that test re-includes them with `ignore: false` and +// asserts the ignore entry is still there. + // Closes the dynamic-import gap left by the front-end no-restricted-imports // boundary (below): no-restricted-imports vets static named/aliased/namespace // imports of parser template-syntax APIs, but not `await import('@rundown-org/parser')`. @@ -122,6 +153,13 @@ export default tseslint.config( // Global settings for all TypeScript files { files: ['**/*.ts'], + plugins: { + local: { + rules: { + 'no-rendered-unit-command-cast': noRenderedUnitCommandCast, + }, + }, + }, languageOptions: { globals: { ...globals.node, @@ -132,6 +170,11 @@ export default tseslint.config( }, }, rules: { + // Type-aware ban on minting RenderedUnitCommand outside its producer. + // Full rationale at the top of this file; exempted for + // execution-unit-entry.ts below. + 'local/no-rendered-unit-command-cast': 'error', + // TSDoc coverage for exported symbols 'jsdoc/require-jsdoc': [ 'error', @@ -237,6 +280,28 @@ export default tseslint.config( }, }, + // The rendered-command brand producer: the one place `as RenderedUnitCommand` + // is allowed, because the cast IS the mint. Scoped to the module that owns the + // brand symbol, so the exemption cannot travel with the type. + { + files: ['packages/core/src/runbook/execution-unit-entry.ts'], + rules: { + // The one file the type-aware ban above does not apply to — it mints the + // brand. + 'local/no-rendered-unit-command-cast': 'off', + // Every selector the base block applies, minus the one this file produces. + // Re-declaring replaces the whole list, so omitting the parser spread here + // would make this the only source file where a dynamic parser import goes + // unflagged. + 'no-restricted-syntax': [ + 'error', + errorIsErrorSelector, + ...trustedArtifactCastSelectors, + ...parserDynamicImportSelectors, + ], + }, + }, + // Trust-brand producers and test helpers: the only places where direct // `as TrustedArtifact*` casts are allowed. The production producers in // `effective-vars.ts` attach the runtime brand via `Object.defineProperty` @@ -271,6 +336,43 @@ export default tseslint.config( 'packages/mcp/src/**/*.ts', 'packages/claude-code-plugin/src/**/*.ts', ], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@rundown-org/parser', + importNames: ['tokenizeTemplate', 'parseTemplateExpression', 'parseOutputExpression'], + message: + 'Front-end packages must not import parser template-syntax APIs. Template tokenization and expression parsing are core-internal; consume the rendered/evaluated result via @rundown-org/core instead.', + }, + { + name: '@rundown-org/core', + importNames: [ + 'buildStepVariables', + 'deriveExecutionUnitEntry', + 'expandLoopVariables', + 'expandLoopVariablesForCommand', + ], + message: + 'Rendering an execution unit is core-owned (#799). Enter the unit through RunbookActorService.enterExecutionUnit and read back the classified entry; a front end that builds its own frame or expands its own fields is the divergence that seam exists to remove.', + }, + ], + }, + ], + }, + }, + + // `packages/cli/src/services/template-renderer.ts` is a CLI-helper-defaulting + // re-export shim over core's expanders with ZERO production importers — it is + // reached only by its own test suites, and is scheduled for deletion once those + // move to core (#799 design record). It predates the render boundary above and + // is not a front end re-implementing rendering, so the core-import ban is lifted + // here and the parser ban is re-declared so it keeps applying. Delete this block + // with the file. + { + files: ['packages/cli/src/services/template-renderer.ts'], rules: { '@typescript-eslint/no-restricted-imports': [ 'error', @@ -340,6 +442,11 @@ export default tseslint.config( // dynamic-import `@rundown-org/parser` to mock allowed APIs (e.g. extractFrontmatter) // while passing the rest of the namespace through. The Error.isError and // trust-brand-cast bans still apply to tests, so they are re-declared here. + // The RenderedUnitCommand ban is NOT re-declared: it lives on its own rule ID + // (`local/no-rendered-unit-command-cast`) rather than inside this + // `no-restricted-syntax` array, so setting it once in the base block already + // covers test files — re-declaring `no-restricted-syntax` here replaces only + // that array, not sibling rule IDs. // // brand-helpers.ts is excluded: it carries its own `no-restricted-syntax: off` // override (the sanctioned place for `as TrustedArtifact*` casts), and that diff --git a/eslint.ignores.js b/eslint.ignores.js index 1b954b36b..b00c58532 100644 --- a/eslint.ignores.js +++ b/eslint.ignores.js @@ -25,5 +25,14 @@ export const ignores = [ '**/.stryker-tmp*/**', 'reports/**', 'tests/e2e/fixtures/**', + // Deliberate `RenderedUnitCommand` forgeries. Every file there violates + // `local/no-rendered-unit-command-cast` on purpose, so linting them normally + // would fail this gate forever. `scripts/__tests__/eslint-brand-cast-guard.test.mjs` + // re-includes them with `new ESLint({ ignore: false })`, which overrides file + // SELECTION only — the rule configuration those paths resolve is this config, + // unmodified, which is what makes the test meaningful. That test also asserts + // this entry still hides them, so deleting it fails rather than quietly + // reddening the repository. + 'packages/core/__tests__/fixtures/brand-cast/**', '.claude-docker/**', ]; diff --git a/package.json b/package.json index c0bc9e029..64fd8fe64 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.64.0", + "@typescript-eslint/utils": "^8.64.0", "xstate": "^5.32.5" } } diff --git a/packages/cli/__tests__/commands/stash-pop.test.ts b/packages/cli/__tests__/commands/stash-pop.test.ts index 6b4709578..649ac2b38 100644 --- a/packages/cli/__tests__/commands/stash-pop.test.ts +++ b/packages/cli/__tests__/commands/stash-pop.test.ts @@ -1126,6 +1126,7 @@ rd echo "hello" retryCount: 0, variables: {}, templateVars: { ContextId: 'stash-pop-ctx', WorkPath: '.rundown/work' }, + prompted: false, steps: [], startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -1158,6 +1159,7 @@ rd echo "hello" retryCount: 0, variables: {}, templateVars: { ContextId: 'stash-pop-ctx', WorkPath: '.rundown/work' }, + prompted: false, steps: [], startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -1199,6 +1201,7 @@ rd echo "hello" retryCount: 0, variables: {}, templateVars: { ContextId: 'stash-pop-ctx', WorkPath: '.rundown/work' }, + prompted: false, steps: [], startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/packages/cli/__tests__/helpers/claim-and-launch.test.ts b/packages/cli/__tests__/helpers/claim-and-launch.test.ts index 16f9cb895..454115549 100644 --- a/packages/cli/__tests__/helpers/claim-and-launch.test.ts +++ b/packages/cli/__tests__/helpers/claim-and-launch.test.ts @@ -285,7 +285,6 @@ jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => ({ // Mock execution service jest.unstable_mockModule('../../src/services/execution', () => ({ - buildStepVariables: mockFn<() => Record>().mockReturnValue({ Step: '1.1' }), runExecutionLoop: mockFn<(...args: unknown[]) => Promise<'done' | 'stopped' | 'waiting'>>().mockResolvedValue( 'done', diff --git a/packages/cli/__tests__/helpers/delegate-inference.test.ts b/packages/cli/__tests__/helpers/delegate-inference.test.ts index c7eeededc..a7c379b5d 100644 --- a/packages/cli/__tests__/helpers/delegate-inference.test.ts +++ b/packages/cli/__tests__/helpers/delegate-inference.test.ts @@ -62,6 +62,7 @@ function makeState(overrides: Partial = {}): RunbookState { } = overrides; const runbook = overrideRunbook ?? { source: 'project' as const, path: 'test.runbook.md' }; return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: brandRunIdForTest(`rd_${'a'.repeat(32)}`), runbook, diff --git a/packages/cli/__tests__/helpers/delegation-completion.test.ts b/packages/cli/__tests__/helpers/delegation-completion.test.ts index b96e0c4cb..b0dbfcd2b 100644 --- a/packages/cli/__tests__/helpers/delegation-completion.test.ts +++ b/packages/cli/__tests__/helpers/delegation-completion.test.ts @@ -235,6 +235,7 @@ const { function makeState(id: RunbookState['id'], overrides: Partial = {}): RunbookState { const base: RunbookState = { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id, runbook: { source: 'project', path: 'test.md' }, @@ -817,7 +818,6 @@ describe('buildAdvanceInlineParent (CLI execution callable)', () => { PARENT_RUN_ID, expect.anything(), '/test', - expect.any(Boolean), expect.anything(), expect.objectContaining({ terminalReleaseMode: 'defer-to-caller' }), ); @@ -941,11 +941,10 @@ describe('buildAdvanceInlineParent (CLI execution callable)', () => { PARENT_RUN_ID, expect.anything(), '/test', - expect.any(Boolean), expect.anything(), expect.anything(), ); - expect(jest.mocked(runExecutionLoop).mock.calls[0][6]?.delegationRuntime).toBe(runtime); + expect(jest.mocked(runExecutionLoop).mock.calls[0][5]?.delegationRuntime).toBe(runtime); }); // The core seam recurses up an inline chain, invoking this same callable for @@ -975,7 +974,6 @@ describe('buildAdvanceInlineParent (CLI execution callable)', () => { PARENT_RUN_ID, expect.anything(), '/test', - expect.any(Boolean), expect.anything(), expect.objectContaining({ delegationRuntime: undefined }), ); @@ -1038,11 +1036,10 @@ describe('propagateChildTerminal run-scoped delegation runtime', () => { PARENT_RUN_ID, expect.anything(), '/test', - expect.any(Boolean), expect.anything(), expect.anything(), ); - expect(jest.mocked(runExecutionLoop).mock.calls[0][6]?.delegationRuntime).toBe(runtime); + expect(jest.mocked(runExecutionLoop).mock.calls[0][5]?.delegationRuntime).toBe(runtime); }); }); diff --git a/packages/cli/__tests__/helpers/execution-emitter.test.ts b/packages/cli/__tests__/helpers/execution-emitter.test.ts index 0137b00ed..7f38e927d 100644 --- a/packages/cli/__tests__/helpers/execution-emitter.test.ts +++ b/packages/cli/__tests__/helpers/execution-emitter.test.ts @@ -11,6 +11,7 @@ type ExecutionEvent = Parameters[0]; describe('createBridgedEmitter', () => { function makeState(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: 'wf-test' as RunbookState['id'], runbook: { source: 'project', path: 'test-runbook.runbook.md' }, diff --git a/packages/cli/__tests__/helpers/goto-workflow.test.ts b/packages/cli/__tests__/helpers/goto-workflow.test.ts index f2f466ada..6e87adbac 100644 --- a/packages/cli/__tests__/helpers/goto-workflow.test.ts +++ b/packages/cli/__tests__/helpers/goto-workflow.test.ts @@ -1000,11 +1000,11 @@ describe('executeGoto', () => { const call = jest.mocked(runExecutionLoop).mock.calls.at(-1); expect(call).toBeDefined(); - expect(call?.slice(0, 5)).toEqual([ctx.manager, DEFAULT_RUNBOOK_ID, ctx.steps, '/test', false]); + expect(call?.slice(0, 4)).toEqual([ctx.manager, DEFAULT_RUNBOOK_ID, ctx.steps, '/test']); // By reference, not by shape: a structural matcher would also accept a pair // rebuilt from the same halves, and forwarding the caller's own runtime is // the whole point of the assertion. - expect(call?.[6]?.delegationRuntime).toBe(delegationRuntime); + expect(call?.[5]?.delegationRuntime).toBe(delegationRuntime); // The mutation takes only the issuer, so the pair is unpacked at that call // site — pinned by identity so the unpack cannot silently take the wrong half. expect(runNavigationMutation).toHaveBeenCalledWith( diff --git a/packages/cli/__tests__/helpers/runbook-pipeline.test.ts b/packages/cli/__tests__/helpers/runbook-pipeline.test.ts index 92ee6c1db..712616021 100644 --- a/packages/cli/__tests__/helpers/runbook-pipeline.test.ts +++ b/packages/cli/__tests__/helpers/runbook-pipeline.test.ts @@ -415,9 +415,6 @@ jest.unstable_mockModule('../../src/helpers/resolve-runbook', () => { // Mock execution service jest.unstable_mockModule('../../src/services/execution', () => ({ - buildStepVariables: mockFn<(...args: unknown[]) => Record>().mockReturnValue({ - Step: '1.1', - }), runExecutionLoop: mockFn<(...args: unknown[]) => Promise>().mockResolvedValue('done'), })); @@ -509,7 +506,7 @@ const parser = await import('@rundown-org/parser'); const { resolveRunbookFile, resolveRunbookRef, buildRunbookRef } = await import( '../../src/helpers/resolve-runbook.js' ); -const { runExecutionLoop, buildStepVariables } = await import('../../src/services/execution.js'); +const { runExecutionLoop } = await import('../../src/services/execution.js'); const { createBridgedEmitter } = await import('../../src/helpers/execution-emitter.js'); const { FileSourcePolicyError, ArtifactChannelError, resolveVariables } = await import( '../../src/services/variable-discovery.js' @@ -656,7 +653,6 @@ beforeEach(() => { warnings: [], providedKeys: new Set(), }); - jest.mocked(buildStepVariables).mockReturnValue({ Step: '1.1' }); jest .mocked(substituteRunbookVariables) .mockImplementation( @@ -2175,7 +2171,7 @@ describe('startRunbook', () => { // token deriver, so it receives the branded pair whole. Pinned by REFERENCE: // a structural matcher also passes against a pair rebuilt from the same two // halves further down, which is precisely the forwarding defect this guards. - expect(jest.mocked(runExecutionLoop).mock.calls.at(-1)?.[6]?.delegationRuntime).toBe( + expect(jest.mocked(runExecutionLoop).mock.calls.at(-1)?.[5]?.delegationRuntime).toBe( delegationRuntime, ); // Hand-off 3 — the caller. `run --prompted --step` reads `delegationRuntime` diff --git a/packages/cli/__tests__/helpers/status-builder.test.ts b/packages/cli/__tests__/helpers/status-builder.test.ts index 0efbc0c27..7847ca896 100644 --- a/packages/cli/__tests__/helpers/status-builder.test.ts +++ b/packages/cli/__tests__/helpers/status-builder.test.ts @@ -149,6 +149,7 @@ const { buildInactiveStatus, buildStashedStatus, buildActiveStatus } = await imp function makeState(overrides: Partial = {}): RunbookState { const baseState: RunbookState = { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: DEFAULT_RUN_ID, runbook: { source: 'project', path: 'test.runbook.md' }, diff --git a/packages/cli/__tests__/helpers/transitions.test.ts b/packages/cli/__tests__/helpers/transitions.test.ts index 2e6b0c4df..36a29d365 100644 --- a/packages/cli/__tests__/helpers/transitions.test.ts +++ b/packages/cli/__tests__/helpers/transitions.test.ts @@ -950,7 +950,7 @@ describe('runSeamTransition — applied render (buildActionSink / renderTransiti mockRunExecutionLoop.mockResolvedValue('stopped'); mockRunTransition.mockResolvedValue( appliedOutcome({ - loop: { kind: 'run', prompted: false }, + loop: { kind: 'run' }, terminalReleaseMode: 'release-runbook', }), ); @@ -960,11 +960,11 @@ describe('runSeamTransition — applied render (buildActionSink / renderTransiti expect(mockRunExecutionLoop).toHaveBeenCalledTimes(1); const loopArgs = mockRunExecutionLoop.mock.calls[0]; // The seam directive's terminalReleaseMode + output are forwarded verbatim. - expect(loopArgs[6]).toEqual({ terminalReleaseMode: 'release-runbook', output }); + expect(loopArgs[5]).toEqual({ terminalReleaseMode: 'release-runbook', output }); // A bare caller presented no bearer, so the key must be ABSENT rather than // present-and-undefined: the loop spreads this object into the fence input, // where an explicit `claimKey: undefined` is a different request from no key. - expect(Object.hasOwn(loopArgs[6] as object, 'claimKey')).toBe(false); + expect(Object.hasOwn(loopArgs[5] as object, 'claimKey')).toBe(false); expect(result.applied).toEqual({ status: 'stopped', runId: PARENT_RUN_ID }); expect(result.exitError).toBe(true); }); @@ -976,7 +976,7 @@ describe('runSeamTransition — applied render (buildActionSink / renderTransiti const output = makeOutput(); mockRunTransition.mockResolvedValue( appliedOutcome({ - loop: { kind: 'run', prompted: false }, + loop: { kind: 'run' }, terminalReleaseMode: 'release-runbook', }), ); @@ -986,7 +986,7 @@ describe('runSeamTransition — applied render (buildActionSink / renderTransiti }); const loopArgs = mockRunExecutionLoop.mock.calls[0]; - expect(loopArgs[6]).toEqual({ + expect(loopArgs[5]).toEqual({ terminalReleaseMode: 'release-runbook', claimKey: TEST_CLAIM_KEY, output, diff --git a/packages/cli/__tests__/integration/inline-child-launch.test.ts b/packages/cli/__tests__/integration/inline-child-launch.test.ts index 14c878d32..f066db2ea 100644 --- a/packages/cli/__tests__/integration/inline-child-launch.test.ts +++ b/packages/cli/__tests__/integration/inline-child-launch.test.ts @@ -118,6 +118,48 @@ Child prompt. await writeFile(join(workspace.runbooksDir(), 'child.runbook.md'), childRunbook); } + /** + * Write the composing parent and its child for the prompted-inheritance pair. + * + * The two tests that use it are an A/B on ONE variable — whether the parent was + * started `--prompted` — so the runbooks either side of that variable have to be + * identical for the comparison to mean anything. A shared writer enforces that; + * two copies only agreed by inspection. + * + * The child body stays a parameter because the pair does differ there, and + * deliberately: the prompted parent's child carries a command, so its + * inherited flag is observable as a command announced rather than run. + * + * @param childBody - Body of the child's single step. + */ + async function writeComposingParentAndChild(childBody: string): Promise { + await writeFile( + join(workspace.rootRunbooksDir(), 'parent.runbook.md'), + `# Parent + +## 1. Start +- PASS CONTINUE + +Ready. + +## 2. Compose +- PASS ALL CONTINUE +- FAIL ANY STOP + +- child.runbook.md +`, + ); + const childRunbook = `# Child + +## 1. Create +- PASS COMPLETE + +${childBody} +`; + await writeFile(join(workspace.rootRunbooksDir(), 'child.runbook.md'), childRunbook); + await writeFile(join(workspace.runbooksDir(), 'child.runbook.md'), childRunbook); + } + it('launches an inline child from a typed STEP_ENTERED intent', async () => { await writeFile( join(workspace.rootRunbooksDir(), 'parent.runbook.md'), @@ -375,6 +417,54 @@ Child prompt. // latch, until this, had no notion of an owner at all, so a launch stranded // here reported `waiting` on every later observation — forever, with no // diagnostic naming the condition. + it("starts an inline child in the parent run's prompted mode", async () => { + // The composing parent's `prompted` flag is INHERITED by the child it + // launches: the child run does not exist yet, so it has no persisted flag of + // its own to read, and this is the one place the loop still needs the value. + // A prompted parent still performs the launch — the classification puts + // inline-launch ahead of awaiting — which is what makes the inheritance + // observable at all. + await writeComposingParentAndChild('```bash\necho child-ran\n```'); + + const start = await runCliInProcess('run --prompted runbooks/parent.runbook.md', workspace); + expect(start.exitCode).toBe(0); + + const advance = await runCliInProcess(await withRunTarget(['pass'], workspace), workspace); + const events = flattenEvents(parseConcatenatedJson(advance.stdout)); + + const inlineStepIndex = events.findIndex( + (event) => event.type === 'step_entered' && event.inlineLaunch !== undefined, + ); + expect(inlineStepIndex).toBeGreaterThanOrEqual(0); + const childStarted = events.find( + (event, index) => index > inlineStepIndex && event.type === 'runbook_started', + ); + expect(childStarted).toBeDefined(); + // Inherited, not defaulted: a parent started WITHOUT `--prompted` yields + // `false` here, which is what makes this assertion about the flag rather + // than about the field existing. + expect(childStarted?.prompted).toBe(true); + }); + + it('starts an inline child in automatic mode when the parent is not prompted', async () => { + await writeComposingParentAndChild('Child prompt.'); + + const start = await runCliInProcess('run runbooks/parent.runbook.md', workspace); + expect(start.exitCode).toBe(0); + + const advance = await runCliInProcess(await withRunTarget(['pass'], workspace), workspace); + const events = flattenEvents(parseConcatenatedJson(advance.stdout)); + + const inlineStepIndex = events.findIndex( + (event) => event.type === 'step_entered' && event.inlineLaunch !== undefined, + ); + expect(inlineStepIndex).toBeGreaterThanOrEqual(0); + const childStarted = events.find( + (event, index) => index > inlineStepIndex && event.type === 'runbook_started', + ); + expect(childStarted?.prompted).toBe(false); + }); + it('reclaims an inline launch latched by a dead process and performs it', async () => { await writeInlineParentAndChild(); // Above every platform's pid_max (Linux 4194304, macOS 99998), so this owner diff --git a/packages/cli/__tests__/integration/step-entered-divergence-characterisation.test.ts b/packages/cli/__tests__/integration/step-entered-run-collect-agreement.test.ts similarity index 73% rename from packages/cli/__tests__/integration/step-entered-divergence-characterisation.test.ts rename to packages/cli/__tests__/integration/step-entered-run-collect-agreement.test.ts index 40909ce86..f95e9fa32 100644 --- a/packages/cli/__tests__/integration/step-entered-divergence-characterisation.test.ts +++ b/packages/cli/__tests__/integration/step-entered-run-collect-agreement.test.ts @@ -12,29 +12,24 @@ import { } from '../helpers/test-utils.js'; /** - * Characterisation of today's `STEP_ENTERED` divergence between `rundown run` - * and `rundown collect` (#816, part of #799). + * `rundown run` and `rundown collect` emit the SAME `STEP_ENTERED` for the same + * execution unit (#816 characterised the divergence; #820 closed it). * - * These tests pin CURRENT behaviour, including the defect. Two functions build - * the `StepEntryMetadata` that becomes a `STEP_ENTERED` payload and they - * disagree: the CLI execution loop renders `description`, `prompt`, - * `commandCode` and `commandLang`; core's collection service fills ids, - * position, name and flags and leaves every rendered field absent. All four are - * optional on the type, which is what lets the disagreement compile. + * Two functions used to build the `StepEntryMetadata` behind this payload and + * they disagreed: the CLI execution loop rendered `description`, `prompt`, + * `commandCode` and `commandLang`; core's collection service filled ids, + * position, name and flags and left every rendered field absent. All four are + * optional on the type, which is what let the disagreement compile. * - * The divergence is asserted end to end, on ONE substep of ONE runbook entered - * twice — first by `rundown run`, then by the RETRY re-entry that `rundown - * collect` drives — because that is the level at which an orchestrator observes - * it. Everything except the rendered fields is asserted to AGREE, which is what - * makes the missing description a divergence rather than a different event. - * - * CORRECT VALUE: the run payload. A substep's description and prompt do not - * depend on which command entered it, so `rundown collect` under-fills. When - * #799 moves the rendering behind the machine, the two `toBeUndefined()` - * assertions below become the run payload's values and this reads as a - * one-line diff. + * There is one builder now — the core entry seam both paths enter through — so + * this file's job flipped from pinning the gap to pinning its absence. The + * assertion is still end to end, on ONE substep of ONE runbook entered twice: + * first by `rundown run`, then by the RETRY re-entry that `rundown collect` + * drives. That is the level at which an orchestrator observes it, and it is the + * only level at which "the same unit, entered two ways" is a statement about the + * product rather than about a function. */ -describe('STEP_ENTERED divergence between run and collect (#816 characterisation)', () => { +describe('STEP_ENTERED agreement between run and collect (#816 / #820)', () => { let workspace: TestWorkspace; beforeEach(async () => { @@ -148,7 +143,7 @@ describe('STEP_ENTERED divergence between run and collect (#816 characterisation await runCliInProcess([result, '--claim-id', claimId], workspace); } - it('drops the rendered description and prompt on the collect path and keeps every other field', async () => { + it('renders the same description and prompt whichever command entered the substep', async () => { await writeRunbooks(); // ---- Path 1: `rundown run` enters substep 1.1 for the first time. ------- @@ -173,15 +168,16 @@ describe('STEP_ENTERED divergence between run and collect (#816 characterisation expect(collectEntries).toHaveLength(1); const collectEntry = collectEntries[0]; - // THE DIVERGENCE. Same substep, same runbook, same cursor — and core's - // builder carries neither rendered field. - expect(collectEntry.description).toBeUndefined(); - expect(collectEntry.prompt).toBeUndefined(); + // THE FLIP. Both of these were `toBeUndefined()` before #820: same substep, + // same runbook, same cursor, and core's builder carried neither rendered + // field. + expect(collectEntry.description).toBe(SUBSTEP_DESCRIPTION); + expect(collectEntry.prompt).toBe(SUBSTEP_PROMPT); - // ...and everything the two builders both fill agrees, which is what makes - // the two payloads comparable at all. Asserted field by field rather than - // by diffing the objects, because the frontier tokens are freshly minted on - // re-entry (that is the RETRY working) and the envelope carries a `seq`. + // ...and everything else agrees too, which is what makes the two payloads + // comparable at all. Asserted field by field rather than by diffing the + // objects, because the frontier tokens are freshly minted on re-entry (that + // is the RETRY working) and the envelope carries a `seq`. expect(collectEntry.position).toEqual(runEntry.position); expect(collectEntry.stepName).toBe(runEntry.stepName); expect(collectEntry.isSubstep).toBe(runEntry.isSubstep); diff --git a/packages/cli/__tests__/services/execution-delegation-issuance.test.ts b/packages/cli/__tests__/services/execution-delegation-issuance.test.ts index 28b0cb128..d76882374 100644 --- a/packages/cli/__tests__/services/execution-delegation-issuance.test.ts +++ b/packages/cli/__tests__/services/execution-delegation-issuance.test.ts @@ -181,7 +181,7 @@ describe('runExecutionLoop command transition into a DELEGATE frontier', () => { it('issues the frontier credential under the loop-supplied verified authority', async () => { const { state, delegationRuntime } = await seedParent(); - const result = await runExecutionLoop(manager, state.id, steps, cwd, false, emitter, { + const result = await runExecutionLoop(manager, state.id, steps, cwd, emitter, { actorService, commandServices: passingCommandServices, delegationRuntime, diff --git a/packages/cli/__tests__/services/execution-loop.test.ts b/packages/cli/__tests__/services/execution-loop.test.ts index 368d4653e..c4750e9e9 100644 --- a/packages/cli/__tests__/services/execution-loop.test.ts +++ b/packages/cli/__tests__/services/execution-loop.test.ts @@ -3,8 +3,10 @@ import type { DelegationRuntimeCapabilities, DelegationTokenDeriver, DelegationTokenHash, + EnterExecutionUnitInput, ErrorCodeKey, ExecutionEventEmitter, + ExecutionUnitEntry, FrameKey, InlineLaunchStart, InlineLinkage, @@ -65,8 +67,12 @@ const mockActorService = { getContextSnapshot: mockFn< (id: string, steps: unknown) => Promise | null> >() as any, - observeExecutionUnitEntry: mockFn< - (id: string, steps: unknown, entry: Record) => Promise + // The entry seam. Declared with core's own input/output types because the + // default implementation below IS core's derivation — the double stands in for + // the service, not for the rendering, so it cannot drift from what production + // renders. Tests that need a specific classification override it per call. + enterExecutionUnit: mockFn< + (input: EnterExecutionUnitInput) => Promise >() as any, prepareActorMutation: mockFn(), }; @@ -386,6 +392,11 @@ describe('runExecutionLoop', () => { runbookPath: 'test.runbook.md', step, status: 'running', + // Every persisted run carries this: `create` always writes it and `load` + // refuses a row without it, so a loose fixture that omitted it modelled a + // state the manager cannot hand back. Ahead of the spread so a test that + // is about prompted mode still overrides it. + prompted: false, ...overrides, templateVars: { ...baseTemplateVars, @@ -407,6 +418,20 @@ describe('runExecutionLoop', () => { snapshot: { context: { delegateFrontier: frontier } }, ...overrides, }); + /** + * Realistic post-commit state for a `DELEGATE_FRONTIER_CONSUMED` `sendAndSync` + * mock result. + * + * The entry is rendered from the COMMITTED state (RD-827 finding 1: rendering + * — which can run `--helpers` JS — must not run before the consume that gates + * it, so it now runs against `consumed.state` rather than the pre-commit + * capture). A bare `{ id, step, substep, status }` stub carries no + * `templateVars`, so it trips `deriveExecutionUnitEntry`'s missing-`WorkPath` + * guard the instant it is used for rendering instead of only for loop control. + * The frontier is cleared from context, mirroring what the real consume retires. + */ + const frontierConsumedState = (overrides: Record = {}) => + frontierLoopState(undefined, { snapshot: { context: {} }, ...overrides }); const commandCompletedEffect = (result: 'pass' | 'fail' = 'pass') => ({ kind: 'execution_observation', event: { @@ -542,44 +567,17 @@ describe('runExecutionLoop', () => { }); mockActorService.getContextSnapshot.mockReset(); mockActorService.getContextSnapshot.mockResolvedValue(null); - mockActorService.observeExecutionUnitEntry.mockReset(); - mockActorService.observeExecutionUnitEntry.mockImplementation( - async (id: string, steps: unknown, entry: Record) => { - const context = await mockActorService.getContextSnapshot(id, steps); - return [ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: entry.position, - stepName: entry.stepName, - description: entry.description, - prompt: entry.prompt, - hasCommand: entry.commandCode !== undefined, - commandCode: entry.commandCode, - commandLang: entry.commandLang, - isSubstep: entry.isSubstep, - prompted: entry.prompted, - artifacts: - context && - typeof context === 'object' && - 'enteredArtifacts' in context && - context.enteredArtifacts && - typeof context.enteredArtifacts === 'object' - ? actualCore.toPublicArtifactMap( - context.enteredArtifacts as Parameters< - typeof actualCore.toPublicArtifactMap - >[0], - { cwd: '/tmp', workPath: actualCore.WORK_DIR }, - ) - : {}, - delegateFrontier: entry.delegateFrontier, - }, - }, - }, - ]; - }, + mockActorService.enterExecutionUnit.mockReset(); + // Core's own derivation, with the two dependencies the real service binds + // supplied here: the project directory and the CLI helper registry. The + // double therefore renders exactly what production renders, and these tests + // stay about the loop's wiring rather than about rendering. + mockActorService.enterExecutionUnit.mockImplementation(async (input: EnterExecutionUnitInput) => + actualCore.deriveExecutionUnitEntry({ + ...input, + cwd: '/tmp', + helpers: getHelperRegistry(), + }), ); mockEmitter = { @@ -651,7 +649,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); expect(result).toBe('stopped'); @@ -1050,7 +1047,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1073,14 +1069,13 @@ describe('runExecutionLoop', () => { }); it('returns waiting if prompted mode is on', async () => { - mockManager.load.mockResolvedValue(makeLoopState()); + mockManager.load.mockResolvedValue(makeLoopState('1', { prompted: true })); const result = await runExecutionLoop( asManager(mockManager), runbookId, asSteps(steps), '/tmp', - true, asEmitter(mockEmitter), ); @@ -1104,17 +1099,21 @@ describe('runExecutionLoop', () => { key: 'plan.json', timestamp: '2026-05-12T00:00:00.000Z', }; - mockManager.load.mockResolvedValue(makeLoopState()); - mockActorService.getContextSnapshot.mockResolvedValue({ - enteredArtifacts: { PlanPath: artifact }, - }); + // Off the run's own persisted snapshot, which is where core reads it: the + // entry seam takes the state it renders against, so a separate context read + // is not a source it consults. + mockManager.load.mockResolvedValue( + makeLoopState('1', { + prompted: true, + snapshot: { context: { enteredArtifacts: { PlanPath: artifact } } }, + }), + ); await runExecutionLoop( asManager(mockManager), runbookId, asSteps(steps), '/tmp', - true, asEmitter(mockEmitter), ); @@ -1135,17 +1134,18 @@ describe('runExecutionLoop', () => { }); it('emits STEP_ENTERED.artifacts as an empty object when no ARTIFACTS resolved', async () => { - mockManager.load.mockResolvedValue(makeLoopState()); - mockActorService.getContextSnapshot.mockResolvedValue({ - enteredArtifacts: undefined, - }); + mockManager.load.mockResolvedValue( + makeLoopState('1', { + prompted: true, + snapshot: { context: { enteredArtifacts: undefined } }, + }), + ); await runExecutionLoop( asManager(mockManager), runbookId, asSteps(steps), '/tmp', - true, asEmitter(mockEmitter), ); @@ -1169,155 +1169,12 @@ describe('runExecutionLoop', () => { runbookId, asSteps(stepsNoCmd), '/tmp', - false, asEmitter(mockEmitter), ); expect(result).toBe('waiting'); }); - // --------------------------------------------------------------------------- - // #816 characterisation — the LOOP half of the STEP_ENTERED divergence. - // - // Two builders produce the `StepEntryMetadata` behind a STEP_ENTERED payload: - // this loop's (`execution.ts`, every field filled) and core's collect-side one - // (`collection-service.ts`, ids/position/name/flags only). These pin what the - // loop builder does TODAY on the two axes the end-to-end contrast in - // `integration/step-entered-divergence-characterisation.test.ts` cannot reach, - // so #799's move reads as an assertion flipping rather than as a new test. - // - // The entry is captured off `observeExecutionUnitEntry` rather than off the - // emitted event, because that argument IS the builder's output and the payload - // is a lossy projection of it — `substepId` never reaches the event at all. - // --------------------------------------------------------------------------- - describe('STEP_ENTERED entry metadata (#816 characterisation)', () => { - type ObserveEntryMock = jest.Mock< - (id: string, steps: unknown, entry: Record) => Promise - >; - - /** - * The entry metadata the loop handed core for the unit it entered. - * - * @returns The single captured `StepEntryMetadata`-shaped argument. - */ - function capturedEntry(): Record { - const { calls } = (mockActorService.observeExecutionUnitEntry as ObserveEntryMock).mock; - expect(calls).toHaveLength(1); - return calls[0][2]; - } - - it('composes prompted from the loop flag OR the prompted-FOR step kind', async () => { - // A FOR step whose bounds did not resolve is demoted to `prompted-for`: - // substeps, no iteration machinery, the original FOR text kept as the - // step prompt. - const promptedForSteps: LooseStep[] = [ - { - kind: 'prompted-for', - name: '1', - description: 'Fan out over an unresolved source', - prompt: 'FOR item IN {{ items }}', - substeps: [ - { - id: '1', - description: 'Handle one item', - transitions: { - pass: { kind: 'pass', retry: 0, action: { type: 'CONTINUE' }, next: 'CONTINUE' }, - fail: { kind: 'fail', retry: 0, action: { type: 'STOP' }, next: 'STOP' }, - }, - }, - ], - transitions: { - pass: { kind: 'pass', retry: 0, action: { type: 'CONTINUE' }, next: 'CONTINUE' }, - fail: { kind: 'fail', retry: 0, action: { type: 'STOP' }, next: 'STOP' }, - }, - }, - ]; - mockManager.load.mockResolvedValue(makeLoopState('1', { substep: '1' })); - - const result = await runExecutionLoop( - asManager(mockManager), - runbookId, - asSteps(promptedForSteps), - '/tmp', - // The persisted/CLI prompted flag, explicitly FALSE. Everything below - // is about the second term. - false, - asEmitter(mockEmitter), - ); - - expect(result).toBe('waiting'); - // THE DIVERGENCE. The loop ORs `currentStep.kind === 'prompted-for'` into - // the flag it was called with; core's collect-side builder reads - // `!!advanced.prompted` alone and would report `false` for this same - // cursor on this same step. - // - // CORRECT VALUE: `true`. The payload field documents whether execution is - // prompted rather than automatic, and a prompted-FOR step IS prompted — - // the loop returns 'waiting' on exactly this term, as asserted above. So - // the collect path under-reports, and #799's move makes the composed - // value the one both paths derive. - expect(capturedEntry().prompted).toBe(true); - }); - - it('takes substepId from the raw cursor and isSubstep from the resolved unit', async () => { - // A cursor naming a substep the current step does not define. - // `resolveCurrentExecutionUnit` falls back to the parent step for it, so - // the two fields are derived from different sources and disagree. - const substepSteps: LooseStep[] = [ - { - kind: 'substeps', - name: '1', - description: 'Fan out', - aggregation: { strategy: 'ALL' }, - substeps: [ - { - id: '1', - description: 'The only live substep', - transitions: { - pass: { kind: 'pass', retry: 0, action: { type: 'CONTINUE' }, next: 'CONTINUE' }, - fail: { kind: 'fail', retry: 0, action: { type: 'STOP' }, next: 'STOP' }, - }, - }, - ], - transitions: { - pass: { kind: 'pass', retry: 0, action: { type: 'CONTINUE' }, next: 'CONTINUE' }, - fail: { kind: 'fail', retry: 0, action: { type: 'STOP' }, next: 'STOP' }, - }, - }, - ]; - mockManager.load.mockResolvedValue(makeLoopState('1', { substep: '9' })); - - const result = await runExecutionLoop( - asManager(mockManager), - runbookId, - asSteps(substepSteps), - '/tmp', - false, - asEmitter(mockEmitter), - ); - - expect(result).toBe('waiting'); - const entry = capturedEntry(); - // THE DIVERGENCE, inside one builder rather than between two: `substepId` - // comes straight off the raw cursor while `isSubstep` comes off the - // resolved execution unit, so a cursor naming no live substep yields a - // populated `substepId` alongside `isSubstep: false`. - // - // CORRECT VALUE: `substepId: undefined` with `isSubstep: false`. Both - // describe the same question — is the unit being entered a substep? — so - // both must come from the resolved unit. This matters beyond tidiness: - // the frontier seams gate credential disclosure on `isSubstep`, and - // `deriveStepEnteredEffect`'s cursor guard fires on `substepId`, so the - // two fields answering differently splits one decision across two seams. - expect(entry.substepId).toBe('9'); - expect(entry.isSubstep).toBe(false); - // The name confirms the fallback landed on the parent step: the substep - // arm would have used the substep's own id. - expect(entry.stepName).toBe('1'); - expect(entry.description).toBe('Fan out'); - }); - }); - it('executes command and advances to next step', async () => { mockManager.load .mockResolvedValueOnce(makeLoopState('1')) @@ -1348,7 +1205,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(testSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1406,7 +1262,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps([steps[0]]), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1466,7 +1321,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps([steps[0]]), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1500,7 +1354,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1537,7 +1390,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps([steps[0]]), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1582,7 +1434,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1680,7 +1531,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1718,7 +1568,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1758,7 +1607,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook' }, ); @@ -1782,7 +1630,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook' }, ); @@ -1837,7 +1684,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook' }, ); @@ -1876,7 +1722,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1909,7 +1754,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1940,7 +1784,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -1984,7 +1827,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'defer-to-caller' }, ); @@ -2026,7 +1868,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'defer-to-caller' }, ); @@ -2059,7 +1900,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook' }, ); @@ -2096,7 +1936,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'future-mode' as ExecutionTerminalReleaseMode }, ), @@ -2134,7 +1973,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), options, ); @@ -2199,7 +2037,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(commandSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2251,7 +2088,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2326,7 +2162,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2384,7 +2219,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2437,7 +2271,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2485,7 +2318,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2517,7 +2349,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(steps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2558,8 +2389,7 @@ describe('runExecutionLoop', () => { asManager(mockManager), runbookId, asSteps(promptedForSteps), - '/tmp', - false, // prompted=false — step itself gates execution + '/tmp', // prompted=false — step itself gates execution asEmitter(mockEmitter), ); @@ -2592,7 +2422,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(promptedForSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2631,7 +2460,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(promptedForSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2669,7 +2497,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(promptedForSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2702,14 +2529,13 @@ describe('runExecutionLoop', () => { }, ]; - mockManager.load.mockResolvedValue(makeLoopState('1', { substep: '1' })); + mockManager.load.mockResolvedValue(makeLoopState('1', { substep: '1', prompted: true })); const result = await runExecutionLoop( asManager(mockManager), runbookId, asSteps(forSteps), '/tmp', - true, asEmitter(mockEmitter), ); @@ -2775,7 +2601,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(stepsWithOutputs), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2844,7 +2669,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(stepsWithOutputsForCommandResult), '/tmp', - false, asEmitter(mockEmitter), ); @@ -2896,7 +2720,7 @@ describe('runExecutionLoop', () => { ]), ); mockActorService.sendAndSync.mockResolvedValue({ - state: { id: runbookId, step: '1', substep: '1', status: 'running' }, + state: frontierConsumedState(), snapshot: {}, }); @@ -2905,7 +2729,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime((credential) => @@ -2982,7 +2805,7 @@ describe('runExecutionLoop', () => { ]; mockActorService.sendAndSync.mockResolvedValue({ - state: { id: runbookId, step: '1', substep: '1', status: 'running' }, + state: frontierConsumedState(), snapshot: {}, }); @@ -2991,7 +2814,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime((credential) => @@ -3052,7 +2874,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -3078,7 +2899,7 @@ describe('runExecutionLoop', () => { }); // None of the frontier-dependent work may run on the refusal path. - expect(mockActorService.observeExecutionUnitEntry).not.toHaveBeenCalled(); + expect(mockActorService.enterExecutionUnit).not.toHaveBeenCalled(); expect(mockActorService.sendAndSync).not.toHaveBeenCalledWith(runbookId, delegateSteps, { type: 'DELEGATE_FRONTIER_CONSUMED', }); @@ -3097,7 +2918,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(singleDelegateFrontierSteps()), '/tmp', - false, asEmitter(mockEmitter), ); @@ -3120,7 +2940,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(singleDelegateFrontierSteps()), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook' }, ); @@ -3160,7 +2979,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), // Derives a well-formed bearer that is not the one the frontier recorded. { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_other') }, @@ -3181,7 +2999,7 @@ describe('runExecutionLoop', () => { }); // None of the frontier-dependent work may run on the refusal path. - expect(mockActorService.observeExecutionUnitEntry).not.toHaveBeenCalled(); + expect(mockActorService.enterExecutionUnit).not.toHaveBeenCalled(); expect(mockActorService.sendAndSync).not.toHaveBeenCalledWith(runbookId, delegateSteps, { type: 'DELEGATE_FRONTIER_CONSUMED', }); @@ -3202,7 +3020,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime(rotatedIssuerDeriver) }, ); @@ -3218,7 +3035,7 @@ describe('runExecutionLoop', () => { payload: { position: { current: '1', total: 1, substep: '1' }, message }, }); - expect(mockActorService.observeExecutionUnitEntry).not.toHaveBeenCalled(); + expect(mockActorService.enterExecutionUnit).not.toHaveBeenCalled(); expect(mockActorService.sendAndSync).not.toHaveBeenCalledWith(runbookId, delegateSteps, { type: 'DELEGATE_FRONTIER_CONSUMED', }); @@ -3237,7 +3054,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(singleDelegateFrontierSteps()), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_other') }, ); @@ -3263,7 +3079,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(singleDelegateFrontierSteps()), '/tmp', - false, asEmitter(mockEmitter), { terminalReleaseMode: 'release-runbook', @@ -3303,7 +3118,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_retry_a') }, ); @@ -3349,7 +3163,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(singleDelegateFrontierSteps()), '/tmp', - false, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_retry_a') }, ), @@ -3431,21 +3244,28 @@ describe('runExecutionLoop', () => { .mockResolvedValueOnce(parentState) .mockResolvedValueOnce(parentState) .mockResolvedValueOnce(existingChild); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); mockActorService.sendAndSync .mockResolvedValueOnce({ state: parentState, snapshot: {} }) .mockRejectedValueOnce(new Error('consume failed')); @@ -3455,7 +3275,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { executionEvent: jest.fn() } as never }, ); @@ -3508,7 +3327,7 @@ describe('runExecutionLoop', () => { expect(mockActorService.sendAndSync).toHaveBeenNthCalledWith(2, runbookId, inlineSteps, { type: 'INLINE_LAUNCH_CONSUMED', }); - expect(mockActorService.observeExecutionUnitEntry).toHaveBeenCalledTimes(1); + expect(mockActorService.enterExecutionUnit).toHaveBeenCalledTimes(1); }); // The rollback's refusal arms, which no test reached: a `switch` jumps @@ -3609,21 +3428,28 @@ describe('runExecutionLoop', () => { .mockResolvedValueOnce(parentState) .mockResolvedValueOnce(parentState) .mockResolvedValueOnce(existingChild); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); mockActorService.sendAndSync .mockResolvedValueOnce({ state: parentState, snapshot: {} }) .mockRejectedValueOnce(new Error('consume failed')); @@ -3634,7 +3460,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { executionEvent: jest.fn() } as never }, ); @@ -3742,21 +3567,28 @@ describe('runExecutionLoop', () => { // The child already holds the top, so the conditional activation writes // nothing and reports as much. mockSessionService.pushRunbookIfNotActive.mockResolvedValue({ status: 'already-active' }); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); mockActorService.sendAndSync .mockResolvedValueOnce({ state: parentState, snapshot: {} }) .mockRejectedValueOnce(new Error('consume failed')); @@ -3766,7 +3598,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { executionEvent: jest.fn() } as never }, ); @@ -3861,21 +3692,28 @@ describe('runExecutionLoop', () => { .mockResolvedValueOnce(parentState) .mockResolvedValueOnce(existingChild) .mockResolvedValue(existingChild); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); mockActorService.sendAndSync .mockResolvedValueOnce({ state: parentState, snapshot: {} }) .mockResolvedValueOnce({ state: parentState, snapshot: {} }); @@ -3885,7 +3723,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { executionEvent: jest.fn() } as never }, ); @@ -4047,21 +3884,28 @@ describe('runExecutionLoop', () => { return { state: parent, snapshot: {} }; }, ); - mockActorService.observeExecutionUnitEntry.mockImplementation(async () => [ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValue({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); const driveLoop = () => runExecutionLoop( @@ -4069,7 +3913,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), // `warning` is part of the double because the contender stands down // through the arm that names the process holding the launch. @@ -4242,21 +4085,28 @@ describe('runExecutionLoop', () => { */ const latchOutcomes: { next: Record | null; value: unknown }[] = []; - const stepEnteredWithInlineLaunch = () => [ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + const stepEnteredWithInlineLaunch = () => ({ + kind: 'inline-launch' as const, + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation' as const, + event: { + type: 'STEP_ENTERED' as const, + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]; + ], + }); /** * The launch path's operator channel, which two arms below write to. @@ -4277,7 +4127,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: mockOutput as never }, ); @@ -4308,7 +4157,7 @@ describe('runExecutionLoop', () => { mockOutput.warning.mockClear(); mockOutput.executionEvent.mockClear(); captureLatch(); - mockActorService.observeExecutionUnitEntry.mockResolvedValue(stepEnteredWithInlineLaunch()); + mockActorService.enterExecutionUnit.mockResolvedValue(stepEnteredWithInlineLaunch()); // Armed by default so that a refusal arm broken by a future edit fails on // its own assertion below. Left unarmed, `prepareActorMutation`'s double // throws `Actor synchronization failed` the moment a refusal wrongly falls @@ -5174,21 +5023,28 @@ describe('runExecutionLoop', () => { }), }, } as never); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); mockActorService.sendAndSync .mockResolvedValueOnce({ state: parentState, snapshot: {} }) .mockResolvedValueOnce({ state: parentState, snapshot: {} }); @@ -5202,7 +5058,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { executionEvent } as never }, ); @@ -5306,21 +5161,28 @@ describe('runExecutionLoop', () => { .mockResolvedValueOnce(existingChild) .mockResolvedValue(existingChild); mockSessionService.getActive.mockResolvedValueOnce({ id: runbookId }); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); // Twice: the latch write this observer now performs (it took an unlatched // launch), then the intent consumption after the child is activated. mockActorService.sendAndSync.mockResolvedValue({ state: parentState, snapshot: {} }); @@ -5335,7 +5197,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: output as never }, ); @@ -5434,21 +5295,28 @@ describe('runExecutionLoop', () => { ); // The latch write, which precedes the activation this test fails. mockActorService.sendAndSync.mockResolvedValue({ state: parentState, snapshot: {} }); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); await expect( runExecutionLoop( @@ -5456,7 +5324,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: {} as never }, ), @@ -5508,21 +5375,28 @@ describe('runExecutionLoop', () => { snapshot: { context: { inlineLaunchIntent: undefined } }, }); mockManager.load.mockResolvedValue(parentState); - mockActorService.observeExecutionUnitEntry.mockResolvedValueOnce([ - { - kind: 'execution_observation', - event: { - type: 'STEP_ENTERED', - payload: { - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Inline child', - isSubstep: true, - inlineLaunch, + mockActorService.enterExecutionUnit.mockResolvedValueOnce({ + kind: 'inline-launch', + launch: inlineLaunch, + effects: [ + { + kind: 'execution_observation', + event: { + type: 'STEP_ENTERED', + payload: { + position: { current: '1.1', total: 1 }, + stepName: '1', + description: 'Inline child', + hasCommand: false, + isSubstep: true, + prompted: false, + artifacts: {}, + inlineLaunch, + }, }, }, - }, - ]); + ], + }); // The superseded arm writes to the operator channel now, so the double has // to carry one: a stand-down that returns `waiting` and touches nothing else @@ -5534,7 +5408,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(inlineSteps), mockManager.cwd, - false, asEmitter(mockEmitter), { output: { warning } as never }, ); @@ -5581,10 +5454,12 @@ describe('runExecutionLoop', () => { ]; mockManager.load.mockResolvedValue( - frontierLoopState([persistedFrontierEntry('1.1', 'child-a.runbook.md', 'rdtk_retry_a')]), + frontierLoopState([persistedFrontierEntry('1.1', 'child-a.runbook.md', 'rdtk_retry_a')], { + prompted: true, + }), ); mockActorService.sendAndSync.mockResolvedValue({ - state: { id: runbookId, step: '1', substep: '1', status: 'running' }, + state: frontierConsumedState({ prompted: true }), snapshot: {}, }); @@ -5593,7 +5468,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - true, asEmitter(mockEmitter), { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_retry_a') }, ); @@ -5612,6 +5486,74 @@ describe('runExecutionLoop', () => { }); }); + it('does not act on an inline-launch intent reached through a projected frontier', async () => { + // The one-shot intent is consumed by the launch it drives, and the frontier + // seam's own consume has ALREADY committed by the time the classified entry + // comes back. Launching here would start a child the re-entry never armed — + // so the guard is on `reentry.status`, not on the classification alone. + const delegateSteps: any[] = [ + { + kind: 'substeps', + name: '1', + description: 'Parallel work', + substeps: [ + { + id: '1', + description: 'First task', + delegate: true, + runbooks: ['child-a.runbook.md'], + transitions: { pass: { next: 'COMPLETE' }, fail: { next: 'STOP' } }, + }, + ], + transitions: { pass: { next: 'COMPLETE' }, fail: { next: 'STOP' } }, + }, + ]; + + mockManager.load.mockResolvedValue( + frontierLoopState([persistedFrontierEntry('1.1', 'child-a.runbook.md', 'rdtk_retry_a')]), + ); + mockActorService.sendAndSync.mockResolvedValue({ + state: { id: runbookId, step: '1', substep: '1', status: 'running' }, + snapshot: {}, + }); + // The seam enters through the SAME double, so this is what its `projected` + // arm carries back to the loop. + mockActorService.enterExecutionUnit.mockResolvedValue({ + kind: 'inline-launch', + launch: { + parentRunId: runbookId, + parentStepId: '1', + parentStep: '1', + parentFrameKey: '1|', + parentEntry: 1, + childRunId: `rd_${'3'.repeat(32)}`, + childRunbookPath: 'child-a.runbook.md', + childRunbookRef: { source: 'project', path: 'child-a.runbook.md' }, + contextSnapshot: { vars: {}, ancestors: [], step: '1', substep: '1', at: '1.1' }, + }, + effects: [], + }); + + const result = await runExecutionLoop( + asManager(mockManager), + runbookId, + asSteps(delegateSteps), + '/tmp', + asEmitter(mockEmitter), + { delegationRuntime: frontierProjectionRuntime(() => 'rdtk_retry_a') }, + ); + + // No launch was attempted: the latch is the launch span's first write, and + // it was never reached. + expect(mockManager.mutateStateReturning).not.toHaveBeenCalled(); + expect(mockedResolveRunbookRef).not.toHaveBeenCalled(); + // The frontier still consumed — the re-entry itself is unaffected. + expect(mockActorService.sendAndSync).toHaveBeenCalledWith(runbookId, delegateSteps, { + type: 'DELEGATE_FRONTIER_CONSUMED', + }); + expect(result).toBe('waiting'); + }); + it('STEP_ENTERED does not issue delegations when delegateFrontier is absent', async () => { const delegateSteps: any[] = [ { @@ -5640,7 +5582,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), ); @@ -5697,7 +5638,6 @@ describe('runExecutionLoop', () => { runbookId, asSteps(delegateSteps), '/tmp', - false, asEmitter(mockEmitter), ); diff --git a/packages/cli/__tests__/services/execution-recovery-actor.test.ts b/packages/cli/__tests__/services/execution-recovery-actor.test.ts index fc6e644f9..b916d2901 100644 --- a/packages/cli/__tests__/services/execution-recovery-actor.test.ts +++ b/packages/cli/__tests__/services/execution-recovery-actor.test.ts @@ -117,7 +117,7 @@ describe('runExecutionLoop interrupted fenced command', () => { // reach the REAL factory, whose returned actor the recovery service drives. const recovery = jest.spyOn(actorService, 'createRecoveryActor'); - const result = await runExecutionLoop(manager, state.id, steps, cwd, false, emitter, { + const result = await runExecutionLoop(manager, state.id, steps, cwd, emitter, { actorService, }); diff --git a/packages/cli/__tests__/services/execution.test.ts b/packages/cli/__tests__/services/execution.test.ts index 829c05abc..8ca8a1040 100644 --- a/packages/cli/__tests__/services/execution.test.ts +++ b/packages/cli/__tests__/services/execution.test.ts @@ -1,12 +1,8 @@ import { describe, it, expect } from '@jest/globals'; -import { - isValidResult, - getStepRetryMax, - buildStepVariables, - buildMetadata, -} from '../../src/services/execution.js'; +import { isValidResult, getStepRetryMax, buildMetadata } from '../../src/services/execution.js'; import { expandLoopVariables } from '../../src/services/template-renderer.js'; import { + buildStepVariables, createJsonArrayStream, isRunbookComplete, isRunbookStopped, diff --git a/packages/cli/src/commands/collect.ts b/packages/cli/src/commands/collect.ts index 8aaa4033e..d0be7e044 100644 --- a/packages/cli/src/commands/collect.ts +++ b/packages/cli/src/commands/collect.ts @@ -600,27 +600,19 @@ async function runCollect(ctx: TransitionContext, options: CollectOptions): Prom const advanced = await manager.load(state.id); if (advanced) { const loopSteps = [...getRunbookFromState(advanced, cwd)]; - const loopResult = await runExecutionLoop( - manager, - advanced.id, - loopSteps, - cwd, - !!advanced.prompted, - emitter, - { - terminalReleaseMode: 'release-runbook', - output, - commandStreamOptions, - // Core verified the collector's bearer behind the collection seam and - // returned the delegation capabilities bound to it. The CLI never mints - // authority — it only carries what core handed back, and only for the - // run core bound it to (`outcome.targetRunId === advanced.id`, the - // collect target this loop drives). Without them a collect that - // advances into a DELEGATE step is refused `actor_context_required` on - // issuance, and the following turn on frontier projection. - delegationRuntime: outcome.delegationRuntime, - }, - ); + const loopResult = await runExecutionLoop(manager, advanced.id, loopSteps, cwd, emitter, { + terminalReleaseMode: 'release-runbook', + output, + commandStreamOptions, + // Core verified the collector's bearer behind the collection seam and + // returned the delegation capabilities bound to it. The CLI never mints + // authority — it only carries what core handed back, and only for the + // run core bound it to (`outcome.targetRunId === advanced.id`, the + // collect target this loop drives). Without them a collect that + // advances into a DELEGATE step is refused `actor_context_required` on + // issuance, and the following turn on frontier projection. + delegationRuntime: outcome.delegationRuntime, + }); // Do NOT early-return on a stopped loop: the run may have reached a // terminal state INSIDE the loop and still owe its parent a propagation // (the run loop does not propagate the executed run's own terminal). Defer diff --git a/packages/cli/src/commands/pop.ts b/packages/cli/src/commands/pop.ts index 761b8b193..60d5ca1ee 100644 --- a/packages/cli/src/commands/pop.ts +++ b/packages/cli/src/commands/pop.ts @@ -165,7 +165,7 @@ export function registerPopCommand(program: Command): void { step: { name: currentStep.name, description: currentStep.description, - prompted: !!state.prompted, + prompted: state.prompted, }, restoredId: state.id, }); diff --git a/packages/cli/src/helpers/delegation-completion.ts b/packages/cli/src/helpers/delegation-completion.ts index a4e613354..9cb15bb9d 100644 --- a/packages/cli/src/helpers/delegation-completion.ts +++ b/packages/cli/src/helpers/delegation-completion.ts @@ -230,7 +230,6 @@ export function buildAdvanceInlineParent( parentRunId, loopSteps, cwd, - !!loopState.prompted, emitter, // 'defer-to-caller': the loop does NOT release parentRunId — the core seam // is the sole release owner and releases once (with retain) on terminal. diff --git a/packages/cli/src/helpers/execution-emitter.ts b/packages/cli/src/helpers/execution-emitter.ts index 8949ae3df..1e33a1924 100644 --- a/packages/cli/src/helpers/execution-emitter.ts +++ b/packages/cli/src/helpers/execution-emitter.ts @@ -24,7 +24,7 @@ import type { OutputEmitter } from '../services/output-emitter.js'; * @example * ```typescript * const emitter = createBridgedEmitter(state, output); - * await runExecutionLoop(manager, state.id, steps, cwd, prompted, emitter, agent); + * await runExecutionLoop(manager, state.id, steps, cwd, emitter, options); * ``` */ export function createBridgedEmitter( diff --git a/packages/cli/src/helpers/goto-workflow.ts b/packages/cli/src/helpers/goto-workflow.ts index a38869e00..bc7111091 100644 --- a/packages/cli/src/helpers/goto-workflow.ts +++ b/packages/cli/src/helpers/goto-workflow.ts @@ -410,23 +410,15 @@ export async function executeGoto(ctx: GotoContext, target: StepId): Promise s.name === stepName); - if (!step) throw new Error(`Step '${stepName}' not found — possible state corruption`); - return step; -} - type TransitionApplicationResult = | { status: 'continue'; state: RunbookState } | { status: 'done' } @@ -697,7 +674,7 @@ async function launchInlineChildFromIntent({ emitRunbookStarted( childEmitter, existingChild, - !!existingChild.prompted, + existingChild.prompted, adoption.runtime.claimId, ); } @@ -706,7 +683,6 @@ async function launchInlineChildFromIntent({ childRunId, [...getRunbookFromState(existingChild, cwd)], cwd, - !!existingChild.prompted, childEmitter, { output, @@ -1101,7 +1077,7 @@ export async function drainResolvedCompletions({ // for each transition before the next apply is derived. That is why the loop // lives here rather than in core. const entry = applied.entry; - const currentStep = findStepOrThrow(steps, entry.stateBefore.step); + const currentStep = findStepOrThrow(steps, entry.stateBefore.step, entry.stateBefore.id); const observed = await observeAndOrchestrate({ sessionService, emitter, @@ -1134,11 +1110,14 @@ export async function drainResolvedCompletions({ * - A prompt-only step is reached (no command) * - In prompted mode (no auto-execution) * + * Prompted mode is read from the run's own persisted `prompted` flag rather + * than supplied by the caller: it is a fact the run state owns, fixed at + * creation, and a parameter is only a way for a caller to disagree with it. + * * @param manager - Runbook state manager instance * @param runbookId - Branded run id * @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) * @param emitter - Event emitter for execution events * @param options - Optional execution loop behavior overrides * @returns 'done' if completed, 'stopped' if stopped, 'waiting' if prompt-only @@ -1154,26 +1133,43 @@ export async function drainResolvedCompletions({ * three arms come from the shared core seam * {@link projectAndConsumeReEntryFrontier}, so `rundown collect` reports each * condition under the same code. - * @throws {Error} If state lookup via {@link findStepOrThrow} fails, the core - * actor/lifecycle/session services throw while advancing transitions, - * command execution rejects, or the emitter raises during event dispatch. + * @throws {Error} If the core actor/lifecycle/session services throw while + * advancing transitions, entering an execution unit cannot render it (a + * `--helpers` helper raising), command execution rejects, or the emitter + * raises during event dispatch. * @throws {InvalidRunbookStateError} If the run's persisted snapshot carries a - * structurally malformed `delegateFrontier`. Per the no-migration rule this is - * corrupt persisted state whose recovery path is explicit user action - * (finish, stop, prune, restart), not a refusal the loop can absorb. + * structurally malformed `delegateFrontier`, its cursor names a step the + * parsed runbook does not define ({@link findStepOrThrow}), or it carries no + * `ContextId` / `WorkPath` to render its frame against. Per the no-migration + * rule each is corrupt persisted state whose recovery path is explicit user + * action (finish, stop, prune, restart), not a refusal the loop can absorb. */ export async function runExecutionLoop( manager: RunbookStateManager, runbookId: RunId, steps: ResolvedStep[], cwd: string, - prompted: boolean, emitter: ExecutionEventEmitter, options: ExecutionLoopOptions = {}, ): Promise<'done' | 'stopped' | 'waiting'> { const state = await manager.load(runbookId); if (!state) return 'stopped'; + // The run owns this fact. It is written once at creation and never varies + // across the loop, so it is read once here rather than re-derived per + // iteration from a `currentState` that can only carry the same value. + // + // Since #819 it has exactly ONE consumer: the value a composing parent + // inherits DOWN into a fresh inline child, which has no persisted flag of its + // own to read yet. Every other use — the `awaiting` classification, the + // `STEP_ENTERED` payload — moved into the entry seam, which reads the run + // directly. + // + // No fallback: `RunbookState.prompted` is required and `RunbookStateManager` + // .`load` refuses a persisted row without it, so an absent flag is invalid + // state refused upstream rather than a mode this read has to guess at. + const prompted = state.prompted; + const terminalReleaseMode = options.terminalReleaseMode ?? 'stack-pop'; // Unconditional, in every release mode: the terminal session release is now // committed inside the fenced command mutation, so `orchestrateTransition` @@ -1193,7 +1189,7 @@ export async function runExecutionLoop( if (currentState.lifecycle === 'stopped') { const terminalSnap = asTerminalSnapshotOrDefault(currentState.snapshot); const snapIsTerminal = isRunbookStopped(terminalSnap) || isRunbookComplete(terminalSnap); - const currentStepForProjection = findStepOrThrow(steps, currentState.step); + const currentStepForProjection = findStepOrThrow(steps, currentState.step, currentState.id); if (snapIsTerminal) { // Machine-driven stop: delegate to core projection @@ -1280,7 +1276,7 @@ export async function runExecutionLoop( } if (snapIsTerminal) { - const currentStepForProjection = findStepOrThrow(steps, currentState.step); + const currentStepForProjection = findStepOrThrow(steps, currentState.step, currentState.id); const observation = deriveTransitionObservation({ steps, currentStep: currentStepForProjection, @@ -1324,12 +1320,12 @@ export async function runExecutionLoop( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - const currentStep = findStepOrThrow(steps, currentState.step); + const currentStep = findStepOrThrow(steps, currentState.step, currentState.id); const totalSteps = countNumberedSteps(steps); // Determine the active execution unit: substep if we're at one, otherwise the step. - const itemToRender = resolveCurrentExecutionUnit(currentStep, currentState.substep); + const currentUnit = resolveCurrentExecutionUnit(currentStep, currentState.substep); const drainResult = await drainResolvedCompletions({ actorService, @@ -1385,87 +1381,29 @@ export async function runExecutionLoop( continue; } - // Expand per-step dynamic variables ({{Step}}, {{Index}}, {{var}}) for current iteration. - // mergeEffectiveVars overlays state.variables (step OUTPUTS) on state.templateVars - // (seeded inputs) so subsequent steps can reference outputs from prior steps in - // descriptions, prompts, and OUTPUTS expressions. Sole producer of EffectiveVars - // — same precedence as buildContextSnapshot and buildExecutionFrame. - const mergedTemplateVars = mergeEffectiveVars(currentState); - const stepVars = buildStepVariables({ - stepId: currentState.step, - substepId: currentState.substep, - forStack: currentState.forStack, - forClause: currentStep.kind === 'for' ? currentStep.forClause : undefined, - templateVars: mergedTemplateVars, - }); - const helperOptions = { - helpers: getHelperRegistry(), - context: buildRunnableRenderContext({ - runId: runbookId, - cwd, - vars: mergedTemplateVars, - }), - }; - const expandedDescription = expandLoopVariables( - itemToRender.description, - stepVars, - helperOptions, - ); - // For prompted-for substeps, fall back to the step-level prompt (the reconstructed FOR text) - const rawPrompt = - itemToRender.prompt ?? (currentStep.kind === 'prompted-for' ? currentStep.prompt : undefined); - const expandedPrompt = rawPrompt - ? expandLoopVariables(rawPrompt, stepVars, helperOptions) - : rawPrompt; - - // Emit STEP_ENTERED event + // Rendering is core's. The loop derives exactly one fact for itself — whether + // the cursor is on a substep — because the authority precondition below has + // to answer it BEFORE any entry exists, and a non-substep entry can never + // disclose a frontier. + const cursorIsOnSubstep = 'id' in currentUnit; + const stepPosition = buildStepPosition( currentState.step, totalSteps, currentState.substep, currentState.forStack, ); - const isSubstep = 'id' in itemToRender; - const command = isSubstep - ? itemToRender.command - : currentStep.kind === 'command' - ? currentStep.command - : undefined; - - // Compute before STEP_ENTERED so the event includes the prompted FOR flag - const stepIsPrompted = currentStep.kind === 'prompted-for'; - - // Expand once: artifact-producing helpers in command code append a manifest - // row per call, so a second expansion would duplicate the entries. Sits - // beside the description/prompt expansions above (and ahead of the frontier - // seam) because the seam observes the rendered entry when it projects. - const expandedCommandCode = command - ? expandLoopVariablesForCommand(command.code, stepVars, helperOptions) - : undefined; - - const entryMetadata = { - stepId: currentState.step, - substepId: currentState.substep, - position: stepPosition, - stepName: isSubstep ? itemToRender.id : itemToRender.name, - description: expandedDescription, - prompt: expandedPrompt, - commandCode: expandedCommandCode, - commandLang: command?.lang, - isSubstep, - prompted: prompted || stepIsPrompted, - }; const delegationTokenDeriver = options.delegationRuntime?.deriveDelegationToken; // The authority precondition, and the only frontier question the loop asks // itself: is there something to disclose that we hold no authority to // disclose? The pending-frontier read is core's — the same validating reader // the seam uses, so the loop never parses the persisted blob — and the - // `isSubstep` term matches the seam's own gate, since a non-substep entry - // can never disclose a frontier and so needs no authority. + // substep term is derived the same way the seam derives its own, since a + // non-substep unit can never disclose a frontier and so needs no authority. if ( delegationTokenDeriver === undefined && - entryMetadata.isSubstep && + cursorIsOnSubstep && readPersistedReEntryFrontier(currentState).length > 0 ) { // A missing deriver is a refusal of this continuation, not a crash. @@ -1515,7 +1453,6 @@ export async function runExecutionLoop( steps, state: currentState, deriveToken: delegationTokenDeriver, - entry: entryMetadata, }); if (reentry.status === 'projection_refused') { @@ -1579,26 +1516,32 @@ export async function runExecutionLoop( ); } - // A projected frontier has already been observed and consumed by the seam; - // an ordinary entry still needs observing here. - const entryEffects = + // One classified entry either way. A projected frontier was entered by the + // seam — with its bearers attached — so re-entering here would announce the + // unit twice; every other path enters through the same core seam. + const entered: ExecutionUnitEntry = reentry.status === 'projected' - ? reentry.observations - : await actorService.observeExecutionUnitEntry(runbookId, steps, entryMetadata); - for (const effect of entryEffects) { + ? reentry.entered + : await actorService.enterExecutionUnit({ + state: currentState, + steps, + // Already computed above from the same `currentState` + `steps` for + // this iteration's own error-reporting events — handing it in avoids + // a second `countNumberedSteps` full-array scan for the identical + // value (RD-827 finding 3). + position: stepPosition, + }); + for (const effect of entered.effects) { emitter.emit(effect.event); } if (reentry.status === 'projected') { currentState = reentry.state; } - const inlineLaunch = entryEffects - .map((effect) => - effect.event.type === 'STEP_ENTERED' ? effect.event.payload.inlineLaunch : undefined, - ) - .find((intent): intent is InlineLaunchIntent => intent !== undefined); - - if (reentry.status === 'none' && inlineLaunch) { + // A one-shot intent is consumed by the launch it drives, and the seam's + // consume has already committed on the projected path — so acting on one + // here would launch a child the re-entry never armed. + if (reentry.status === 'none' && entered.kind === 'inline-launch') { if (!options.output) { emitter.emit({ type: 'ERROR_OCCURRED', @@ -1616,7 +1559,7 @@ export async function runExecutionLoop( emitter, cwd, steps, - intent: inlineLaunch, + intent: entered.launch, prompted, output: options.output, commandStreamOptions: options.commandStreamOptions, @@ -1628,29 +1571,13 @@ export async function runExecutionLoop( }); } - // If CLI prompted mode, per-step prompted FOR, OR no command - // Use itemToRender which may be a substep with its own command - if (prompted || stepIsPrompted || expandedCommandCode === undefined) { + // Prompted mode, a prompted-FOR step, and a unit with no command are one arm + // now, decided by core. The loop no longer reads an undefined rendered + // command as its signal for "nothing to run". + if (entered.kind !== 'runnable') { return 'waiting'; } - - // Build rundown-injected environment variables (RD_WORK_PATH, RD_RUN_ID, etc.) - // Keys come from BUILTIN_VARIABLES so a rename in variable-discovery.ts - // surfaces here as a typecheck error instead of silently breaking injection. - const rdInjected: Record = {}; - const workPath = stepVars[BUILTIN_VARIABLES.WorkPath]; - const contextId = stepVars[BUILTIN_VARIABLES.ContextId]; - if (typeof workPath === 'string') rdInjected.RD_WORK_PATH = workPath; - if (typeof contextId === 'string') rdInjected.RD_CONTEXT_ID = contextId; - rdInjected.RD_RUN_ID = currentState.id; - rdInjected.RD_RUNBOOK_REF = currentState.runbook.path; - rdInjected.RD_RUNBOOK_SOURCE = currentState.runbook.source; - - // Execute the command actor through the core-owned execution fence. The - // external command runs in `prepareActorMutation`; persistence happens only - // under the exact captured authority and execution attempt. - const extracted = extractDisplayCommand(expandedCommandCode); - const displayCommand = extracted || expandedCommandCode; + const { code: expandedCommandCode, displayCommand, rdInjected } = entered.command; let previousState = currentState; const fencedCommand = await actorMutationRunner.run({ runId: runbookId, @@ -1822,7 +1749,7 @@ export function buildMetadata(state: RunbookState): RunbookMetadata { file: state.runbook.path, state: DB_FILE, runId: state.id, - prompted: state.prompted ?? undefined, + prompted: state.prompted, }; } diff --git a/packages/core/__tests__/errors/factory.test.ts b/packages/core/__tests__/errors/factory.test.ts index 4f0086862..41d3d3fe8 100644 --- a/packages/core/__tests__/errors/factory.test.ts +++ b/packages/core/__tests__/errors/factory.test.ts @@ -658,6 +658,28 @@ describe('Errors factory - exhaustive coverage', () => { '--index 2 names a FOR iteration the parent has not entered; iteration 1 is active', ); }); + + it('frontierDisclosureFailed maps runId + the render cause → RD-833', () => { + // #820. The condition is post-commit and NOT retryable: a collect that + // landed its aggregate could not render the entry its freshly derived + // bearers ride on. The `runId` key is what lets an agent name the run + // whose bearers were lost without parsing the prose, and `message` carries + // the render failure's own text — usually a `--helpers` helper raising — + // which is the only fact the envelope's title cannot supply. + const error = Errors.frontierDisclosureFailed( + 'rd_11111111111111111111111111111111', + 'helper "slugify" threw: boom', + ); + expect(error).toBeInstanceOf(RundownError); + expect(error.code).toBe('RD-833'); + expect(error.context.runId).toBe('rd_11111111111111111111111111111111'); + expect(error.context.message).toBe('helper "slugify" threw: boom'); + // The rendered sentence, so a blanked title or a dropped cause is visible + // rather than tolerated by a loose `toContain`. + expect(error.message).toBe( + 'Delegation frontier disclosure could not be rendered - helper "slugify" threw: boom', + ); + }); }); describe('Retry-hook errors', () => { diff --git a/packages/core/__tests__/events/entry-seam-barrel.test.ts b/packages/core/__tests__/events/entry-seam-barrel.test.ts new file mode 100644 index 000000000..0c28f9703 --- /dev/null +++ b/packages/core/__tests__/events/entry-seam-barrel.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from '@jest/globals'; +import * as coreBarrel from '../../src/index.js'; +import * as eventsBarrel from '../../src/events/index.js'; +import * as runbookBarrel from '../../src/runbook/index.js'; + +/** + * What `@rundown-org/core` may NOT hand a front end. + * + * `RunbookActorService.enterExecutionUnit` is the single seam for entering an + * execution unit, and two invariants rest on it having exactly one producer + * downstream: + * + * - `deriveStepEnteredEffect` used to carry two cursor-mismatch guards, refusing + * an entry whose `stepId` / `substepId` disagreed with the snapshot. #820 + * deleted them on the grounds that the entry now has ONE producer, which reads + * the cursor and the snapshot off the same `RunbookState`. That reasoning only + * holds while the deriver — and the metadata shapes it consumes — cannot be + * reached from outside core with a hand-built entry. + * - `RenderedUnitCommand` is minted by a module-private `declare const` unique + * symbol, so the ONLY way to produce one outside its module is a type + * assertion. ESLint bans the assertion forms; not exporting the NAME is what + * makes them unwritable in the first place, because a caller that cannot name + * the type cannot assert to it, alias it, or reach it through a namespace + * import. + * + * A wildcard `export *` re-export puts every one of these on the public surface + * without any file naming them, which is how they got there. This suite is the + * gate: the runtime half below, and the compile-time half for the type-only + * names in `entry-seam-barrel.typecheck.ts`. + */ +describe('core public surface — entry-seam internals', () => { + it.each([ + { barrel: 'src/index.js', mod: coreBarrel }, + { barrel: 'src/events/index.js', mod: eventsBarrel }, + ])('keeps deriveStepEnteredEffect off $barrel', ({ mod }) => { + expect(Object.hasOwn(mod, 'deriveStepEnteredEffect')).toBe(false); + }); + + // The negative above is only meaningful if this barrel really is the surface + // it claims to be, so pin a symbol from the same module that IS public. Without + // it, a barrel that exported nothing at all would pass. + it.each([ + { barrel: 'src/index.js', mod: coreBarrel }, + { barrel: 'src/events/index.js', mod: eventsBarrel }, + ])('still re-exports the public observation helpers from $barrel', ({ mod }) => { + expect(Object.hasOwn(mod, 'projectDelegateFrontier')).toBe(true); + expect(Object.hasOwn(mod, 'createExecutionEffectCollector')).toBe(true); + }); + + // `deriveExecutionUnitEntry` stays exported deliberately — front-end test + // doubles stand in for the service and must not re-implement its rendering, + // and an ESLint no-restricted-imports boundary keeps every front end's `src/**` + // off it. Pinned so narrowing the barrel above cannot take it out by accident. + it('keeps deriveExecutionUnitEntry exported for test doubles', () => { + expect(Object.hasOwn(runbookBarrel, 'deriveExecutionUnitEntry')).toBe(true); + expect(Object.hasOwn(coreBarrel, 'deriveExecutionUnitEntry')).toBe(true); + }); +}); diff --git a/packages/core/__tests__/events/entry-seam-barrel.typecheck.ts b/packages/core/__tests__/events/entry-seam-barrel.typecheck.ts new file mode 100644 index 000000000..bf4a1dee8 --- /dev/null +++ b/packages/core/__tests__/events/entry-seam-barrel.typecheck.ts @@ -0,0 +1,56 @@ +/** + * Compile-time half of the entry-seam public-surface gate. + * + * Intentionally compile-only, following `runbook/xstate-patterns.typecheck.ts`: + * `pnpm --filter @rundown-org/core check:types` evaluates the + * `@ts-expect-error` directives below, and each one fails the build if the error + * it expects stops occurring — which is exactly what happens when one of these + * names returns to the barrel. + * + * Type-only names are erased at runtime, so the runtime suite in + * `entry-seam-barrel.test.ts` cannot see them. It covers the value export + * (`deriveStepEnteredEffect`); these three are the types it cannot reach. The + * rationale for each is in that suite's header. + * + * Every name is referenced below, which is load-bearing rather than tidy: an + * unreferenced type-only import can itself be an error on the import line, and + * `@ts-expect-error` cannot tell one error from another. A directive satisfied + * by "this import is unused" would pass whether or not the export exists, which + * is the exact failure this file is meant to catch. + * + * The references live on interface properties, not type aliases. Aliasing + * `RenderedUnitCommand` is itself banned outside its producing module — the alias + * is how you launder the brand into a name no assertion selector recognises — and + * a file asserting the type is unreachable has no business demonstrating the one + * route around that. + */ + +// @ts-expect-error - StepEntryMetadata is core-internal; the deriver's single-producer invariant depends on it +import type { StepEntryMetadata } from '../../src/index.js'; +// @ts-expect-error - StepEntryObservationInput is core-internal, for the same reason +import type { StepEntryObservationInput } from '../../src/index.js'; +// @ts-expect-error - RenderedUnitCommand is unnameable outside core so its brand cannot be asserted into existence +import type { RenderedUnitCommand } from '../../src/index.js'; +import type { ExecutionUnitEntry } from '../../src/index.js'; + +/** Holds the three names the barrel must not carry. */ +export interface UnreachableFromTheBarrel { + /** Entry metadata the deriver consumes. */ + readonly metadata: StepEntryMetadata; + /** Its observation input wrapper. */ + readonly input: StepEntryObservationInput; + /** The provenance brand on a rendered command. */ + readonly command: RenderedUnitCommand; +} + +/** + * The positive control. + * + * `ExecutionUnitEntry` is the seam's public return type and MUST stay reachable. + * Without this, deleting the whole `execution-unit-entry` export block would + * satisfy every negative above. + */ +export interface ReachableFromTheBarrel { + /** The classified entry `enterExecutionUnit` returns. */ + readonly entry: ExecutionUnitEntry; +} diff --git a/packages/core/__tests__/events/execution-observation.test.ts b/packages/core/__tests__/events/execution-observation.test.ts index 537bebf66..bc0e284de 100644 --- a/packages/core/__tests__/events/execution-observation.test.ts +++ b/packages/core/__tests__/events/execution-observation.test.ts @@ -145,6 +145,7 @@ describe('execution observation projection', () => { prompt: 'Check output', commandCode: 'npm test', commandLang: 'bash', + hasCommand: true, isSubstep: false, prompted: false, delegateFrontier: [{ id: '1.1', runbook: 'child', token: 'rdt_example' }], @@ -184,6 +185,7 @@ describe('execution observation projection', () => { stepId: '2', position: { current: '2', total: 2 }, stepName: 'Inspect', + hasCommand: false, isSubstep: false, prompted: false, }, @@ -209,6 +211,7 @@ describe('execution observation projection', () => { substepId: '3.2', position: { current: '3.2', total: 3 }, stepName: '3.2', + hasCommand: false, isSubstep: true, prompted: false, }, @@ -244,6 +247,7 @@ describe('execution observation projection', () => { substepId: '1', position: { current: '2.1', total: 3 }, stepName: '1', + hasCommand: false, isSubstep: true, prompted: false, inlineLaunch, @@ -255,46 +259,14 @@ describe('execution observation projection', () => { expect(effect.event.payload.inlineLaunch).toEqual(inlineLaunch); }); - it('rejects STEP_ENTERED when entry metadata does not match the current snapshot step', () => { - expect(() => - deriveStepEnteredEffect({ - artifactPathOptions: ARTIFACT_PATH_OPTIONS, - snapshot: { context: { step: '2', enteredArtifacts: {} } }, - entry: { - stepId: '1', - position: { current: '1', total: 1 }, - stepName: 'Build', - isSubstep: false, - prompted: false, - }, - }), - ).toThrow('Cannot observe STEP_ENTERED for step 1 while machine snapshot is at 2'); - }); - - it('rejects STEP_ENTERED when entry substepId does not match the snapshot substep (regression: deriveStepEnteredEffect missing substep identity guard)', () => { - // Regression coverage: stale/mismatched substep metadata must fail closed - // instead of emitting STEP_ENTERED for the wrong substep. - expect(() => - deriveStepEnteredEffect({ - artifactPathOptions: ARTIFACT_PATH_OPTIONS, - snapshot: { - context: { - step: 'step::1', - substep: 'step::1::__parent-entry::1', - enteredArtifacts: {}, - }, - }, - entry: { - stepId: 'step::1', - substepId: 'step::1::__parent-entry::2', - position: { current: '1.1', total: 2 }, - stepName: 'Sub A', - isSubstep: true, - prompted: false, - }, - }), - ).toThrow(/substep/i); - }); + // The two cursor-mismatch guards that lived here are gone (#820). They refused + // an entry whose `stepId` / `substepId` disagreed with the snapshot, which was + // reachable only because the entry was a PARAMETER — any caller could supply + // one describing a different cursor. It now has a single producer, + // `deriveExecutionUnitEntry`, which reads the cursor and the snapshot off the + // same `RunbookState`, so the mismatch is unrepresentable rather than merely + // untested. `execution-unit-entry.test.ts` pins the derivation that replaced + // them. it('collects command output and failure observations in memory only', () => { const collector = createExecutionEffectCollector(); diff --git a/packages/core/__tests__/fixtures/brand-cast/README.md b/packages/core/__tests__/fixtures/brand-cast/README.md new file mode 100644 index 000000000..8e7892aff --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/README.md @@ -0,0 +1,35 @@ +# Brand-cast forgery fixtures + +Every file here is a deliberate violation of +`local/no-rendered-unit-command-cast` +(`eslint-rules/no-rendered-unit-command-cast.mjs`), plus one negative control +that must stay clean. `scripts/__tests__/eslint-brand-cast-guard.test.mjs` lints +the directory through the real `eslint.config.js` and asserts which ones the +rule flags. + +They are real, committed `.ts` files rather than string literals in the test +because the rule is type-aware: it resolves the asserted-to type through the +TypeScript checker, so the file has to be a member of `tsconfig.eslint.json`'s +program with the bytes ESLint reports on and the bytes TypeScript parsed being +the same bytes. `packages/core/__tests__/**/*.ts` is in that program, and the +relative import below reaches the REAL brand declaration, so the checker +resolves the same symbol a production cast would name. + +Three properties of this location are load-bearing, and all three are asserted +by the test rather than left to comment: + +- The directory is listed in `eslint.ignores.js`, so an ordinary + `pnpm run check:lint:typed` and an editor's ESLint watcher skip it. Without + that, ten deliberate violations would fail the repository lint gate forever. + The test re-includes them with `new ESLint({ ignore: false })`, which changes + file SELECTION only — the rule configuration these files resolve is untouched. +- It is outside `packages/core/src`, so it is outside `tsconfig.json`'s build + `include` (never emitted to `dist`) and outside the Stryker `mutate` glob + (never planned as a mutation scope by `scripts/mutation-shard-plan.mjs`). +- It is inside `tsconfig.test.json`'s `include`, so `check:types` type-checks + it. That is deliberate: every forgery below is legal TypeScript, which is + precisely why a lint rule and not the compiler has to be the thing that stops + it. + +Adding a file here without referencing it from the test is a hard failure — the +test asserts the directory listing and its own case list are the same set. diff --git a/packages/core/__tests__/fixtures/brand-cast/alias-two-hops.ts b/packages/core/__tests__/fixtures/brand-cast/alias-two-hops.ts new file mode 100644 index 000000000..6683bd763 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/alias-two-hops.ts @@ -0,0 +1,12 @@ +// Two hops, which is the point: a rule that unwraps exactly one level of aliasing +// passes `local-type-alias.ts` and fails here. The checker's symbol resolution has +// no depth limit, so both must resolve to the same declaration. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +type First = RenderedUnitCommand; +type Second = First; + +declare const value: unknown; + +/** Forged two alias hops from the brand, past any single-level unwrap. */ +export const forged = value as Second; diff --git a/packages/core/__tests__/fixtures/brand-cast/angle-bracket-qualified.ts b/packages/core/__tests__/fixtures/brand-cast/angle-bracket-qualified.ts new file mode 100644 index 000000000..ba3f8c96b --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/angle-bracket-qualified.ts @@ -0,0 +1,9 @@ +// Both evasions at once: the older assertion node AND a qualified name. Covered +// separately because a rule can handle each independently and still miss the +// combination. +import type * as producer from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged through both evasions at once: the older node and a qualified name. */ +export const forged = value; diff --git a/packages/core/__tests__/fixtures/brand-cast/angle-bracket.ts b/packages/core/__tests__/fixtures/brand-cast/angle-bracket.ts new file mode 100644 index 000000000..cc4839bf6 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/angle-bracket.ts @@ -0,0 +1,9 @@ +// The pre-JSX assertion syntax, still legal in `.ts`. A different AST node +// (`TSTypeAssertion`, not `TSAsExpression`), so a selector written for one is +// blind to the other. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged through the pre-JSX assertion node rather than `as`. */ +export const forged = value; diff --git a/packages/core/__tests__/fixtures/brand-cast/control-unrelated-assertions.ts b/packages/core/__tests__/fixtures/brand-cast/control-unrelated-assertions.ts new file mode 100644 index 000000000..997e99380 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/control-unrelated-assertions.ts @@ -0,0 +1,17 @@ +// The negative control, and the only file here the rule must leave alone. +// +// Without it, a rule broad enough to flag every assertion in the repository would +// satisfy all ten forgeries — unusable rather than correct. It carries one of each +// assertion NODE the forgeries use (`TSAsExpression`, `TSTypeAssertion`, and an +// assertion to a locally declared object type), so a rule that keys off syntax +// rather than the resolved type fails here instead of passing everywhere. +declare const value: unknown; + +type Unrelated = { readonly code: string }; + +/** An `as` assertion to an unrelated built-in. The ban must not fire. */ +export const fine = value as string; +/** An angle-bracket assertion to an unrelated built-in. The ban must not fire. */ +export const alsoFine = 0; +/** An assertion to a locally declared object type. The ban must not fire. */ +export const stillFine = value as Unrelated; diff --git a/packages/core/__tests__/fixtures/brand-cast/direct-as.ts b/packages/core/__tests__/fixtures/brand-cast/direct-as.ts new file mode 100644 index 000000000..b54ae2d5c --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/direct-as.ts @@ -0,0 +1,8 @@ +// The plain spelling. A `TSAsExpression` whose type annotation is the bare +// identifier — the only route the original name-matching selector caught. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged by the plainest route: one `as` naming the brand directly. */ +export const forged = value as RenderedUnitCommand; diff --git a/packages/core/__tests__/fixtures/brand-cast/double-through-unknown.ts b/packages/core/__tests__/fixtures/brand-cast/double-through-unknown.ts new file mode 100644 index 000000000..8bf83f034 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/double-through-unknown.ts @@ -0,0 +1,10 @@ +// `as unknown as T`, the standard way past a "neither type sufficiently overlaps" +// error and so the first thing reached for when a direct cast is refused. The +// outer assertion is a `TSAsExpression` like any other; what changes is that the +// operand is a `string`, which a direct cast would not accept. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: string; + +/** Forged from a `string`, which only the detour through `unknown` makes assignable. */ +export const forged = value as unknown as RenderedUnitCommand; diff --git a/packages/core/__tests__/fixtures/brand-cast/import-renamed.ts b/packages/core/__tests__/fixtures/brand-cast/import-renamed.ts new file mode 100644 index 000000000..f3e398d29 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/import-renamed.ts @@ -0,0 +1,10 @@ +// The gap a name-matching selector cannot close at any level of effort: the +// identifier at the assertion site is `Renamed`, chosen freely by whoever writes +// the import, so no enumeration of spellings of `RenderedUnitCommand` can +// anticipate it. The checker resolves it back to the same declared symbol. +import type { RenderedUnitCommand as Renamed } from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged through an import rename, the spelling no selector can anticipate. */ +export const forged = value as Renamed; diff --git a/packages/core/__tests__/fixtures/brand-cast/interface-inheritance.ts b/packages/core/__tests__/fixtures/brand-cast/interface-inheritance.ts new file mode 100644 index 000000000..18d1d8ae6 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/interface-inheritance.ts @@ -0,0 +1,11 @@ +// Inheritance rather than aliasing. `Laundered` is its own declared symbol — it is +// NOT the brand's symbol — so resolving the annotation to a declaration is not +// enough on its own; the rule has to walk base types as well. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +interface Laundered extends RenderedUnitCommand {} + +declare const value: unknown; + +/** Forged through a distinct symbol that merely inherits the brand. */ +export const forged = value as Laundered; diff --git a/packages/core/__tests__/fixtures/brand-cast/local-type-alias.ts b/packages/core/__tests__/fixtures/brand-cast/local-type-alias.ts new file mode 100644 index 000000000..fbd53eaa5 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/local-type-alias.ts @@ -0,0 +1,11 @@ +// One hop through a local alias. The identifier at the assertion site is a name +// this repository has never seen, and the alias declaration is a different +// statement entirely — nothing at the cast site mentions the brand. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +type Laundered = RenderedUnitCommand; + +declare const value: unknown; + +/** Forged through an alias, so nothing at the assertion site names the brand. */ +export const forged = value as Laundered; diff --git a/packages/core/__tests__/fixtures/brand-cast/namespace-qualified.ts b/packages/core/__tests__/fixtures/brand-cast/namespace-qualified.ts new file mode 100644 index 000000000..6dc79722f --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/namespace-qualified.ts @@ -0,0 +1,8 @@ +// A qualified name. The annotation is a `TSQualifiedName`, so it has no +// `typeName.name` for a selector to match against. +import type * as producer from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged through a qualified name, so the annotation carries no bare identifier. */ +export const forged = value as producer.RenderedUnitCommand; diff --git a/packages/core/__tests__/fixtures/brand-cast/union-member.ts b/packages/core/__tests__/fixtures/brand-cast/union-member.ts new file mode 100644 index 000000000..ea4c1eb24 --- /dev/null +++ b/packages/core/__tests__/fixtures/brand-cast/union-member.ts @@ -0,0 +1,14 @@ +// The brand reached as one constituent of a union. The annotation resolves to a +// `TSUnionType` whose own symbol is nothing, so the rule has to descend into +// constituents rather than stopping at the top-level type. +// +// `| undefined` and not `| never`: TypeScript normalises `T | never` back to `T` +// before the checker ever hands it over, so that spelling silently degrades into +// a duplicate of `direct-as.ts` and exercises no union code at all. It did, until +// deleting the rule's union branch failed to fail this test. +import type { RenderedUnitCommand } from '../../../src/runbook/execution-unit-entry.js'; + +declare const value: unknown; + +/** Forged as one constituent of a union the checker does not collapse away. */ +export const forged = value as RenderedUnitCommand | undefined; diff --git a/packages/core/__tests__/runbook/abort-delegation.test.ts b/packages/core/__tests__/runbook/abort-delegation.test.ts index b91bc267b..5bc3c0c89 100644 --- a/packages/core/__tests__/runbook/abort-delegation.test.ts +++ b/packages/core/__tests__/runbook/abort-delegation.test.ts @@ -33,6 +33,7 @@ function makeDelegation(overrides: Partial = {}): StepDelegation /** Helper: create minimal RunbookState for testing. */ function makeState(substepStates: SubstepState[]): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: RUN_ID, runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/actor-context.test.ts b/packages/core/__tests__/runbook/actor-context.test.ts index 5de53edfe..4c3905aff 100644 --- a/packages/core/__tests__/runbook/actor-context.test.ts +++ b/packages/core/__tests__/runbook/actor-context.test.ts @@ -23,6 +23,7 @@ const claimKey = assertClaimLookupKey('rdclk_11111111111111111111111111111111'); function baseState(id = runIdA): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id, runbook: { source: 'project', path: 'p.md' }, diff --git a/packages/core/__tests__/runbook/actor-service.test.ts b/packages/core/__tests__/runbook/actor-service.test.ts index cdbf5f031..1dcc8f7f8 100644 --- a/packages/core/__tests__/runbook/actor-service.test.ts +++ b/packages/core/__tests__/runbook/actor-service.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createActor, waitFor, type Snapshot } from 'xstate'; -import { RunbookStateManager } from '../../src/runbook/state.js'; +import { InvalidRunbookStateError, RunbookStateManager } from '../../src/runbook/state.js'; import { merge, replace } from '../../src/runbook/state-update-ops.js'; import { extractEnteredArtifacts, @@ -41,6 +41,7 @@ import { brandTrustedArtifactRecordForTest, } from '../../src/testing/effective-vars.js'; import { seedRawRunState } from '../../src/testing/state-fixtures.js'; +import { getErrorMessage } from '../../src/errors.js'; import { makeDelegatedSubstepState, makeDelegationCredentialIssuer, @@ -157,6 +158,30 @@ npm test \`\`\` `); + /** + * Enter the unit a persisted run's cursor names, and return its observations. + * + * `enterExecutionUnit` takes the state rather than an id, so these cases — + * every one of which writes a snapshot through the manager first — reload it + * here. What they assert is unchanged: the snapshot-derived half of the + * `STEP_ENTERED` payload (artifacts, the inline-launch intent) and the + * persisted-state refusals that run before any of it. + * + * @param service - Actor service under test. + * @param id - Run to enter. + * @param steps - Parsed steps for that run. + * @returns The entry's observation effects. + */ + async function enterEffects( + service: RunbookActorService, + id: string, + steps: readonly ResolvedStep[], + ) { + const loaded = await manager.load(id); + if (!loaded) throw new Error(`expected persisted state for ${id}`); + return (await service.enterExecutionUnit({ state: loaded, steps })).effects; + } + function commandTemplateVars( runId: string, ): Record { @@ -1032,13 +1057,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, stepsWithOneCommand, { - stepId: '1', - position: { current: '1', total: 1 }, - stepName: 'Build', - isSubstep: false, - prompted: false, - }); + const effects = await enterEffects(service, state.id, stepsWithOneCommand); expect(effects).toHaveLength(1); expect(effects[0]?.event.type).toBe('STEP_ENTERED'); @@ -1049,19 +1068,16 @@ echo ok expect(JSON.stringify(persisted)).not.toContain('STEP_ENTERED'); }); - it('projects STEP_ENTERED artifact paths under the default work dir when WorkPath is absent', async () => { + it('refuses to enter a run whose variables carry no WorkPath', async () => { + // There used to be a WORK_DIR fallback here, and it was never the whole + // story: the render context the same entry expands helper paths against + // refused a missing WorkPath outright, so the fallback could only produce + // an entry whose artifact paths and helper paths named different roots. + // One read now, and a run that cannot name its own work directory is + // corrupt persisted state (RD-309, finish/stop/prune) rather than a run + // rendered against a guess. const runId = assertRunId('rd_88888888888888888888888888888888'); const service = new RunbookActorService(manager); - const artifact = { - kind: 'artifact-record' as const, - uri: `rd://artifacts/ctx/${runId}/plan.md`, - runId, - contextId: 'ctx', - runbook: { source: 'project' as const, path: 'workflow.runbook.md' }, - key: 'plan.md', - timestamp: '2026-05-15T00:00:00.000Z', - }; - // Omit WorkPath so deriveStepEnteredEffect falls back to WORK_DIR. const { WorkPath: _omitted, ...templateVarsWithoutWorkPath } = commandTemplateVars(runId); const state = await manager.create( { source: 'project', path: 'workflow.runbook.md' }, @@ -1073,35 +1089,268 @@ echo ok templateVars: templateVarsWithoutWorkPath, }, ); - const bootstrap = await service.createActor(state.id, stepsWithOneCommand); - if (!bootstrap) throw new Error('expected bootstrap actor'); - const baseSnapshot = bootstrap.getPersistedSnapshot() as { - readonly context?: Readonly>; - readonly [key: string]: unknown; - }; - service.stopActor(bootstrap); - await manager.update(state.id, { - snapshot: { - ...baseSnapshot, - context: { - ...(baseSnapshot.context ?? {}), - enteredArtifacts: { PlanPath: artifact }, - }, + + await expect(enterEffects(service, state.id, stepsWithOneCommand)).rejects.toThrow( + /is missing WorkPath/, + ); + }); + + // The freshness gate is CONDITIONAL on a snapshot existing, and both halves + // of that condition are load-bearing. These two tests are the pair: a run + // with no snapshot must enter (a gate that always ran would hand + // `assertFreshSnapshotValue` an `undefined` value and refuse every fresh + // run), and a run whose snapshot names a step the runbook no longer defines + // must be refused (a gate that never ran would enter it). + it('enters a run that has never synced a snapshot', async () => { + const runId = assertRunId('rd_88888888888888888888888888888889'); + const service = new RunbookActorService(manager); + const state = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId, + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), }, + ); + + const effects = await enterEffects(service, state.id, stepsWithOneCommand); + + expect(effects).toHaveLength(1); + expect(effects[0]?.event.type).toBe('STEP_ENTERED'); + }); + + // The guards raise `InvalidRunbookStateError` rather than a bare `Error`, and + // the DEFECT is the machine-readable half of that: RD-309's envelope reports + // `runId` and `reason` in FIELDS so a consumer never parses the prose. It is + // also what `finishCollection` reads to decide whether a committed collect + // reports RD-309 (prune/restart) or RD-833 (fix the helper), so a blanked + // reason would send an operator to the wrong recovery. + it('names the run and the reason in the defect of each persisted-snapshot refusal', async () => { + const service = new RunbookActorService(manager); + const seed = async (runId: string, snapshot: unknown) => { + const created = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId: assertRunId(runId), + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), + }, + ); + await manager.update(created.id, { snapshot }); + return created.id; + }; + const defectOf = async (id: string) => { + try { + await enterEffects(service, id, stepsWithOneCommand); + throw new Error(`expected ${id} to be refused`); + } catch (error) { + expect(error).toBeInstanceOf(InvalidRunbookStateError); + return (error as InvalidRunbookStateError).defect; + } + }; + + const unreadable = await seed('rd_8888888888888888888888888888888b', { value: 42 }); + expect(await defectOf(unreadable)).toEqual({ + runId: unreadable, + reason: 'unsupported_snapshot_state_value', }); - const effects = await service.observeExecutionUnitEntry(state.id, stepsWithOneCommand, { - stepId: '1', - position: { current: '1', total: 1 }, - stepName: 'Build', - isSubstep: false, - prompted: false, + const transient = await seed('rd_8888888888888888888888888888888c', { + value: 'step::1::__parent-entry::1', + }); + expect(await defectOf(transient)).toEqual({ + runId: transient, + reason: 'unsupported_snapshot_state_value', }); - expect(effects[0]?.event.type).toBe('STEP_ENTERED'); - if (effects[0]?.event.type !== 'STEP_ENTERED') throw new Error('expected STEP_ENTERED'); - const artifacts = effects[0].event.payload.artifacts as Record; - expect(artifacts.PlanPath.path).toContain(`.rundown/work/.rd-ctx/${runId}/plan.md`); + // A string the `step::…` grammar does not accept. A separate arm from the + // unreadable shape above — that one never reaches the parse — so its + // defect needs asserting separately or the whole literal can be emptied + // with this table still green. + const unparseable = await seed('rd_8888888888888888888888888888888f', { + value: 'some-old-format', + }); + expect(await defectOf(unparseable)).toEqual({ + runId: unparseable, + reason: 'unsupported_snapshot_state_value', + }); + + const missingStep = await seed('rd_8888888888888888888888888888888d', { + value: 'step::Gone', + }); + expect(await defectOf(missingStep)).toEqual({ + runId: missingStep, + reason: 'snapshot_step_not_in_runbook', + }); + }); + + // The recovery instruction, not just the diagnosis. Every refusal here is + // one run's corrupt persisted state, and what an operator can DO about it + // lives in the second sentence — a sentence the class-and-defect assertions + // above cannot see and the first-sentence message assertions stop short of. + // Emptying any of these leaves a refusal that says what is wrong and not how + // to recover, which for RD-309 is most of the value of the message. + it.each([ + { + label: 'a transient parent-entry state', + runId: 'rd_888888888888888888888888888888a1', + snapshot: { value: 'step::1::__parent-entry::1' }, + message: + /is a transient parent-entry state\. Prune invalid runbook state and restart execution\./, + }, + { + label: 'an unparseable state value', + runId: 'rd_888888888888888888888888888888a2', + snapshot: { value: 'some-old-format' }, + message: + /Unsupported persisted stateValue "some-old-format" .*\. Prune invalid runbook state and restart execution\./, + }, + { + label: 'a step the runbook does not declare', + runId: 'rd_888888888888888888888888888888a3', + snapshot: { value: 'step::Gone' }, + message: + /references missing step "Gone"\. Prune invalid runbook state and restart execution\./, + }, + ])( + 'spells the recovery when the snapshot carries $label', + async ({ runId, snapshot, message }) => { + const service = new RunbookActorService(manager); + const created = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId: assertRunId(runId), + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), + }, + ); + await manager.update(created.id, { snapshot }); + + await expect(enterEffects(service, created.id, stepsWithOneCommand)).rejects.toThrow( + message, + ); + }, + ); + + it('names the run and the reason when the run carries no frontmatter outputs', async () => { + const runId = assertRunId('rd_8888888888888888888888888888888e'); + const service = new RunbookActorService(manager); + const created = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId, + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), + }, + ); + await manager.save({ ...created, frontmatterOutputs: undefined }); + + try { + await enterEffects(service, created.id, stepsWithOneCommand); + throw new Error('expected the run to be refused'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidRunbookStateError); + expect((error as InvalidRunbookStateError).defect).toEqual({ + runId, + reason: 'missing_frontmatter_outputs', + }); + // The recovery, for the reason the snapshot table above pins its own: + // the defect says which refusal fired, and only the message says what + // to do about it. This arm names a different command from the snapshot + // refusals — a run missing its OUTPUTS declarations is pruned, not + // resumed — so it cannot borrow their assertion. + expect(getErrorMessage(error)).toMatch( + /missing frontmatter outputs declarations\. Run `rundown prune` and restart execution\./, + ); + } + }); + + it('refuses to enter a run whose persisted snapshot names a step the runbook no longer defines', async () => { + const runId = assertRunId('rd_8888888888888888888888888888888a'); + const service = new RunbookActorService(manager); + const state = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId, + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), + }, + ); + await manager.update(state.id, { snapshot: { value: 'step::Gone', context: {} } }); + + await expect(enterEffects(service, state.id, stepsWithOneCommand)).rejects.toThrow( + /references missing step "Gone"/, + ); + }); + + // Every refusal above reaches its assertion through `enterEffects`, which is + // `async` and therefore launders a synchronous throw into a rejection of its + // OWN promise. That hides the question this pins: does the promise + // `enterExecutionUnit` returns reject, or does the method throw in the + // caller's tick and leave that promise unborn? `Promise.allSettled` answers + // it, because a synchronous throw escapes before `allSettled` is ever + // called — so the seam is exercised directly here, never through the helper. + // + // A caller that attaches `.catch(...)` to the returned promise, or collects + // the call in `Promise.all`, observes nothing otherwise. The method's own + // doc comment already tells callers not to depend on same-tick settling; + // this is what makes that true on the failure path as well. + it.each([ + { + label: 'the snapshot freshness gate', + plant: async (id: string) => { + await manager.update(id, { snapshot: { value: 'step::Gone', context: {} } }); + }, + }, + { + label: 'the machine compile', + plant: async (id: string) => { + const loaded = await manager.load(id); + if (!loaded) throw new Error(`expected persisted state for ${id}`); + await manager.save({ ...loaded, frontmatterOutputs: undefined }); + }, + }, + { + label: 'the entry derivation', + plant: async (id: string) => { + const loaded = await manager.load(id); + if (!loaded) throw new Error(`expected persisted state for ${id}`); + const { WorkPath: _omitted, ...withoutWorkPath } = loaded.templateVars; + await manager.save({ ...loaded, templateVars: withoutWorkPath }); + }, + }, + ])('rejects rather than throwing when $label refuses', async ({ plant }) => { + const runId = assertRunId('rd_8888888888888888888888888888888b'); + const service = new RunbookActorService(manager); + const state = await manager.create( + { source: 'project', path: 'workflow.runbook.md' }, + { title: 'Step effects', description: '', steps: stepsWithOneCommand }, + { + runId, + runbookPath: 'workflow.runbook.md', + frontmatterOutputs: [], + templateVars: commandTemplateVars(runId), + }, + ); + await plant(state.id); + const loaded = await manager.load(state.id); + if (!loaded) throw new Error('expected persisted state'); + + const [settled] = await Promise.allSettled([ + service.enterExecutionUnit({ state: loaded, steps: stepsWithOneCommand }), + ]); + + expect(settled.status).toBe('rejected'); }); it('passes inline launch child id and clock dependencies through service-created actors', async () => { @@ -1122,7 +1371,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1264,7 +1516,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1334,7 +1589,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1367,20 +1625,15 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '1', - position: { current: '1.1', total: 1 }, - stepName: 'Parent', - description: '', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; if (effect.event.type !== 'STEP_ENTERED') throw new Error('expected STEP_ENTERED'); - expect(effect.event.payload.description).toBe(''); + // Derived from the parsed substep rather than supplied by the caller, which + // is the whole point of the seam: the entry describes the unit the run is + // on, not whatever the caller happened to pass. + expect(effect.event.payload.description).toBe('Runbook: child.runbook.md'); expect(effect.event.payload.hasCommand).toBe(false); expect(effect.event.payload.inlineLaunch).toMatchObject({ parentEntry: 4, @@ -1407,7 +1660,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1440,15 +1696,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '1', - position: { current: '1.1', total: 1 }, - stepName: '1', - description: 'Runbook: child.runbook.md', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; @@ -1486,7 +1734,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1519,15 +1770,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '1', - position: { current: '1.1', total: 1 }, - stepName: 'Parent', - description: '', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; @@ -1557,7 +1800,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1591,15 +1837,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '2', - position: { current: '1.2', total: 1 }, - stepName: 'Parent', - description: '', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; @@ -1625,7 +1863,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1675,15 +1916,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '1', - position: { current: '1.1', total: 1 }, - stepName: 'Parent', - description: '', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; @@ -1708,7 +1941,10 @@ echo ok runId: assertRunId('rd_11111111111111111111111111111111'), runbookPath: 'parent.runbook.md', frontmatterOutputs: [], - templateVars: { RunId: 'rd_11111111111111111111111111111111' }, + // Every real run is seeded with ContextId and WorkPath at creation, and + // entering a unit renders against them, so a fixture without both is a + // run that could not exist. + templateVars: commandTemplateVars('rd_11111111111111111111111111111111'), }, ); const service = new RunbookActorService(manager, { @@ -1760,15 +1996,7 @@ echo ok }, }); - const effects = await service.observeExecutionUnitEntry(state.id, steps, { - stepId: '1', - substepId: '1', - position: { current: '1.1', total: 1 }, - stepName: 'Parent', - description: '', - isSubstep: true, - prompted: false, - }); + const effects = await enterEffects(service, state.id, steps); expect(effects).toHaveLength(1); const effect = effects[0]; @@ -1798,15 +2026,9 @@ echo ok frontmatterOutputs: undefined, }); - await expect( - service.observeExecutionUnitEntry(state.id, stepsWithOneCommand, { - stepId: '1', - position: { current: '1', total: 1 }, - stepName: 'Build', - isSubstep: false, - prompted: false, - }), - ).rejects.toThrow(/Invalid runbook state.*frontmatter outputs/); + await expect(enterEffects(service, state.id, stepsWithOneCommand)).rejects.toThrow( + /Invalid runbook state.*frontmatter outputs/, + ); }); it('clears stale lastResult when GOTO is synchronized from the machine', async () => { diff --git a/packages/core/__tests__/runbook/branded-artifact-parse-seam.test.ts b/packages/core/__tests__/runbook/branded-artifact-parse-seam.test.ts index 013229588..49b89add5 100644 --- a/packages/core/__tests__/runbook/branded-artifact-parse-seam.test.ts +++ b/packages/core/__tests__/runbook/branded-artifact-parse-seam.test.ts @@ -35,6 +35,7 @@ function validStateWithVariables(variables: Record): Record = {}): RunbookState { const frameKey = buildFrameKey('1'); return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: runId, runbook: { source: 'project', path: 'p.md' }, diff --git a/packages/core/__tests__/runbook/collection-service.test.ts b/packages/core/__tests__/runbook/collection-service.test.ts index 8d554cec0..9319f0ff3 100644 --- a/packages/core/__tests__/runbook/collection-service.test.ts +++ b/packages/core/__tests__/runbook/collection-service.test.ts @@ -29,6 +29,9 @@ import { type SessionMutationResult, } from '../../src/runbook/index.js'; import { ErrorCodes } from '../../src/errors/codes.js'; +import { logger } from '../../src/logger.js'; +import { RundownError } from '../../src/errors/rundown-error.js'; +import { getErrorMessage } from '../../src/errors.js'; import { createDelegationCredentialIssuer, createDelegationTokenDeriver, @@ -44,10 +47,7 @@ import type { CollectionSessionService, RunbookCollectionServiceDependencies, } from '../../src/runbook/collection-service.js'; -import type { - ExecutionObservationEffect, - StepEntryMetadata, -} from '../../src/events/execution-observation.js'; +import type { ExecutionObservationEffect } from '../../src/events/execution-observation.js'; import type { RecoveryActor } from '../../src/runbook/execution-recovery-service.js'; import { ExecutionLifecycleService } from '../../src/runbook/execution-lifecycle-service.js'; import { brandCurrentCursorResolvedCompletionForTest } from '../../src/runbook/completion-service.js'; @@ -224,6 +224,7 @@ describe('RunbookCollectionService', () => { function state(overrides: Partial = {}): RunbookState { return { + prompted: false, id: runId, runbook: { source: 'project', path: 'collection-test.md' }, runbookPath: 'collection-test.md', @@ -232,7 +233,10 @@ describe('RunbookCollectionService', () => { stepName: 'Delegate work', retryCount: 0, variables: brandStoredOutputsForTest({}), - templateVars: brandInitialTemplateVarsForTest({}), + templateVars: brandInitialTemplateVarsForTest({ + ContextId: 'ctx', + WorkPath: '.rundown/work', + }), steps: [], lifecycle: 'running', startedAt: '2026-06-17T00:00:00.000Z', @@ -798,9 +802,9 @@ describe('RunbookCollectionService', () => { const consumeSpy = jest .spyOn(actorService, 'prepareActorMutation') .mockResolvedValueOnce(preparedMutation(consumedState, { context: {} })); - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockResolvedValue(frontierEffects); + const enterEntrySpy = jest + .spyOn(actorService, 'enterExecutionUnit') + .mockResolvedValue({ kind: 'awaiting', effects: frontierEffects }); const outcome = await collectionService.collectDelegationOutcomes({ targetState: target, @@ -822,7 +826,7 @@ describe('RunbookCollectionService', () => { }, }); expect(outcome.reEntryObservations).toEqual(frontierEffects); - expect(observeEntrySpy).toHaveBeenCalledTimes(1); + expect(enterEntrySpy).toHaveBeenCalledTimes(1); // Asserted on the captured call rather than through `toHaveBeenCalledWith`: // jest's recursive `AsymmetricMatcher` mapped type expands over `RunbookState` // in this signature and trips TS2589 ("type instantiation is excessively @@ -866,7 +870,7 @@ describe('RunbookCollectionService', () => { jest .spyOn(actorService, 'prepareActorMutation') .mockResolvedValue(preparedMutation(state({ ...target, snapshot: { context: {} } }))); - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); const svc = makeCollectionService({ actorMutationRunner: runnerWithPreCommitEffect(async () => { await manager.save({ ...target, retryCount: 2 }); @@ -883,7 +887,7 @@ describe('RunbookCollectionService', () => { expect(outcome).toMatchObject({ kind: 'concurrent_modification', runId }); // No disclosure: observation is strictly post-commit, so a refused commit // reconstructs no bearers at all. - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); // ...and the frontier is still persisted, so the next collect re-projects it. expect(await persistedFrontier(runId)).toEqual([retry.persisted]); }); @@ -931,9 +935,9 @@ describe('RunbookCollectionService', () => { }, }, ]; - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockResolvedValue(reEntryEffects); + const enterEntrySpy = jest + .spyOn(actorService, 'enterExecutionUnit') + .mockResolvedValue({ kind: 'awaiting', effects: reEntryEffects }); // Frontier consume derives cleanly this time, so the projection is surfaced. const consumeSpy = jest .spyOn(actorService, 'prepareActorMutation') @@ -952,7 +956,7 @@ describe('RunbookCollectionService', () => { applied: 0, reEntryObservations: reEntryEffects, }); - expect(observeEntrySpy).toHaveBeenCalledTimes(1); + expect(enterEntrySpy).toHaveBeenCalledTimes(1); // Read the captured call rather than matching it: see the TS2589 note above. const consumeCall = consumeSpy.mock.calls.at(-1); expect(consumeCall?.[0]).toBe(runId); @@ -1850,7 +1854,7 @@ describe('RunbookCollectionService', () => { unresolved: 1, applied: [], }); - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); await expect( collectionService.collectDelegationOutcomes({ @@ -1866,7 +1870,7 @@ describe('RunbookCollectionService', () => { code: 'COLLECT_OPERATION_FAILED', message: expect.any(String), }); - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); }); it('counts only delegate substeps as required, ignoring plain substeps in the same step', async () => { @@ -2383,7 +2387,7 @@ describe('RunbookCollectionService', () => { issuerClaimKey: assertClaimLookupKey(`rdclk_${'9'.repeat(32)}`), }, }; - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); const consumeSpy = jest.spyOn(actorService, 'prepareActorMutation'); await expect(collectWithPersistedFrontier([rotatedIssuer])).resolves.toMatchObject({ @@ -2394,7 +2398,7 @@ describe('RunbookCollectionService', () => { // the execution loop already reported under RD-821 for the same input. code: ErrorCodes.DELEGATION_INVARIANT_VIOLATED.code, }); - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); expect(consumeSpy).not.toHaveBeenCalled(); }); @@ -2404,7 +2408,7 @@ describe('RunbookCollectionService', () => { ...persisted, tokenHash: assertDelegationTokenHash(`sha256:${'0'.repeat(64)}`), }; - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); const consumeSpy = jest.spyOn(actorService, 'prepareActorMutation'); await expect(collectWithPersistedFrontier([wrongHash])).resolves.toMatchObject({ @@ -2413,7 +2417,7 @@ describe('RunbookCollectionService', () => { // Was `COLLECT_OPERATION_FAILED` — see the rotated-issuer test above. code: ErrorCodes.DELEGATION_INVARIANT_VIOLATED.code, }); - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); expect(consumeSpy).not.toHaveBeenCalled(); }); @@ -2431,7 +2435,7 @@ describe('RunbookCollectionService', () => { // no-op with NO `reEntryObservations`. The `false`/`&&` mutants of L313 would // fall through to the observation path. `observeExecutionUnitEntry` is spied to // prove it is never reached. - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); const outcome = await collectWithPersistedFrontier([]); expect(outcome).toMatchObject({ @@ -2440,7 +2444,7 @@ describe('RunbookCollectionService', () => { step: '1', }); expect(outcome).not.toHaveProperty('reEntryObservations'); - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); }); it('treats a present frontier with an undefined cursor substep as no re-entry', async () => { @@ -2466,7 +2470,7 @@ describe('RunbookCollectionService', () => { unresolved: 1, applied: [], }); - const observeEntrySpy = jest.spyOn(actorService, 'observeExecutionUnitEntry'); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); const outcome = await collectionService.collectDelegationOutcomes({ targetState: target, @@ -2481,15 +2485,18 @@ describe('RunbookCollectionService', () => { step: '1', }); expect(outcome).not.toHaveProperty('reEntryObservations'); - expect(observeEntrySpy).not.toHaveBeenCalled(); + expect(enterEntrySpy).not.toHaveBeenCalled(); }); // --------------------------------------------------------------------------- - // Region 4 — observation input wiring + result spread - // (collection-service.ts L323-335, L469). + // Region 4 — entry input wiring + result spread. // - // Assert the exact `entry` object passed to `observeExecutionUnitEntry`, plus - // the conditional `reEntryObservations` spread on the result. + // Assert the exact input passed to `enterExecutionUnit`, plus the conditional + // `reEntryObservations` spread on the result. Since #820 the collect side + // supplies only the committed state, the steps, and the projected bearers — + // everything the entry carries is rendered by the seam from those, which is + // why the per-field entry assertions that used to live here are now the + // characterisation block further down (which reads the emitted payload). // --------------------------------------------------------------------------- /** Capture-and-assert helper: run a no-op collect that projects a valid frontier. */ @@ -2527,9 +2534,9 @@ describe('RunbookCollectionService', () => { }, }, ]; - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockResolvedValue(reEntryEffects); + const enterEntrySpy = jest + .spyOn(actorService, 'enterExecutionUnit') + .mockResolvedValue({ kind: 'awaiting', effects: reEntryEffects }); jest .spyOn(actorService, 'prepareActorMutation') .mockResolvedValue(preparedMutation(target, { context: {} })); @@ -2539,51 +2546,33 @@ describe('RunbookCollectionService', () => { callerEvidence: ORCHESTRATOR_EVIDENCE, frame: activeFrame(frameKey, 1), }); - return { outcome, observeEntrySpy, frontier, reEntryEffects }; + return { outcome, enterEntrySpy, frontier, reEntryEffects }; } - it('passes a non-empty steps array and a fully-populated substep entry to observeExecutionUnitEntry', async () => { - // Region 4 input wiring: - // * L325 `[...input.steps]` → `[]`: assert the steps arg is non-empty and - // equals the runbook steps. - // * L326 object literal → `{}`: assert the entry carries its populated fields. - // * L331 `isSubstep: true` → `false`: assert `isSubstep === true`. - // * frontier wiring: assert `delegateFrontier` equals the projected frontier. - const { observeEntrySpy, frontier } = await projectFrontierAndCapture({}); - - expect(observeEntrySpy).toHaveBeenCalledTimes(1); - const [idArg, stepsArg, entry] = observeEntrySpy.mock.calls[0]; - expect(idArg).toBe(runId); - expect(stepsArg).toEqual(steps); - expect(stepsArg.length).toBeGreaterThan(0); - expect(entry).toMatchObject({ - stepId: '1', - substepId: '1', - stepName: '1', - isSubstep: true, - delegateFrontier: frontier, - }); - expect(entry).toHaveProperty('position'); + it('enters the COMMITTED target with the runbook steps and the projected bearers', async () => { + // The three things the collect side still supplies, and the only three it + // can get wrong: the state whose commit just landed, a non-empty steps array + // (an emptied copy would make the entry unresolvable against the runbook), + // and the bearers the projection reconstructed. + const { enterEntrySpy, frontier } = await projectFrontierAndCapture({}); + + expect(enterEntrySpy).toHaveBeenCalledTimes(1); + const input = enterEntrySpy.mock.calls[0][0]; + expect(input.state.id).toBe(runId); + expect(input.steps).toEqual(steps); + expect(input.steps.length).toBeGreaterThan(0); + expect(input.delegateFrontier).toEqual(frontier); }); - it('forwards prompted:true to observeExecutionUnitEntry when the reloaded cursor was prompted', async () => { - // L332 `!!advanced.prompted` (mutant `advanced.prompted` collapse): with a - // truthy `prompted` on the reloaded state, the entry's `prompted` must be the - // boolean `true`. (`!!` and the bare value coincide for an already-boolean - // input, so the contrast that fixes this is the falsy test below.) - const { observeEntrySpy } = await projectFrontierAndCapture({ prompted: true }); - const entry = observeEntrySpy.mock.calls[0][2]; - expect(entry.prompted).toBe(true); - }); + it('enters the state the transaction committed, not the pre-drain state it started from', async () => { + // Post-commit by construction: the entry describes the run as it exists + // after the consume, so a pre-commit state would announce a frontier the + // commit has already retired. + const { enterEntrySpy } = await projectFrontierAndCapture({}); - it('forwards prompted:false to observeExecutionUnitEntry when the reloaded cursor was not prompted', async () => { - // L332 `!!advanced.prompted` (mutant `!advanced.prompted`): with `prompted` - // undefined/falsy, the coerced value must be the boolean `false`. The - // `!advanced.prompted` mutant would forward `true` here, so this kills it; the - // pair with the prompted:true test above pins both L332 BooleanLiteral mutants. - const { observeEntrySpy } = await projectFrontierAndCapture({ prompted: undefined }); - const entry = observeEntrySpy.mock.calls[0][2]; - expect(entry.prompted).toBe(false); + const input = enterEntrySpy.mock.calls[0][0]; + const committed = await manager.load(runId); + expect(input.state).toEqual(committed); }); it('includes reEntryObservations on the result only when the frontier projects', async () => { @@ -2602,41 +2591,49 @@ describe('RunbookCollectionService', () => { }); // --------------------------------------------------------------------------- - // #816 characterisation — the COLLECT half of the STEP_ENTERED divergence. + // #816 characterisation — the COLLECT half of the STEP_ENTERED divergence, + // FLIPPED by #820. // - // `prepareCollectReEntryFrontier` and the CLI execution loop both build a - // `StepEntryMetadata`, and they disagree about what it carries. These pin what - // THIS builder does today. The loop half is pinned in the CLI's - // `services/execution-loop.test.ts`, and the two paths are contrasted end to - // end in `integration/step-entered-divergence-characterisation.test.ts`. + // These pinned a builder that no longer exists. `prepareCollectReEntryFrontier` + // used to hand the frontier seam a hand-built `StepEntryMetadata` carrying ids, + // position, name and flags and none of the four rendered fields, while the CLI + // execution loop's builder filled all of them. Collect now enters through the + // same core seam `rundown run` does, so there is one builder and nothing left + // to disagree. // - // Additive by construction: every payload pinned above stays exactly as it is, - // so the fix that follows shows up as an assertion flipping here rather than - // as churn across the suite. + // The assertions below are the same three facts inverted: what was + // `toBeUndefined()` is the rendered value, and what was `false` is the composed + // one. They read the EMITTED payload rather than a captured argument, because + // the argument they used to capture is core-private now. The end-to-end + // contrast is in the CLI's + // `integration/step-entered-run-collect-agreement.test.ts`. // --------------------------------------------------------------------------- - describe('STEP_ENTERED entry metadata (#816 characterisation)', () => { + describe('STEP_ENTERED entry metadata (#816 flip)', () => { /** - * Run a no-op collect that projects a valid frontier and return the entry it - * handed `observeExecutionUnitEntry`. + * Run a no-op collect that projects a valid frontier, and return the + * `STEP_ENTERED` payload it discloses. * - * A local twin of `projectFrontierAndCapture` above, which closes over the - * shared `steps` fixture. This one takes the step graph, because the - * `prompted` characterisation below needs a step KIND the shared fixture - * does not carry. + * The real `enterExecutionUnit` runs here — that is the point. A local twin + * of `projectFrontierAndCapture` above, which closes over the shared `steps` + * fixture; this one takes the step graph, because the `prompted` case needs a + * step KIND the shared fixture does not carry. * * @param collectSteps - Step graph the collect resolves its target step in. * @param overrides - Target-state overrides applied on top of the fixture. - * @returns The single captured entry metadata. + * @returns The single disclosed `STEP_ENTERED` payload. */ - async function captureCollectEntry( + async function collectStepEnteredPayload( collectSteps: ResolvedStep[], overrides: Partial = {}, - ): Promise { + ): Promise> { const frameKey = buildFrameKey('1'); const retry = frontierEntry(); const target = state({ retryCount: 1, - snapshot: { context: { delegateFrontier: [retry.persisted] } }, + // A real `snapshot.value`, unlike the sibling fixtures above: entering + // the unit runs the persisted-snapshot freshness guard, which the + // spied-out seam never reached. + snapshot: { value: 'step::1::1', context: { delegateFrontier: [retry.persisted] } }, ...overrides, }); await manager.save(target); @@ -2646,9 +2643,6 @@ describe('RunbookCollectionService', () => { unresolved: 1, applied: [], }); - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockResolvedValue([]); jest .spyOn(actorService, 'prepareActorMutation') .mockResolvedValue(preparedMutation(target, { context: {} })); @@ -2660,45 +2654,38 @@ describe('RunbookCollectionService', () => { frame: activeFrame(frameKey, 1), }); - expect(outcome.kind).toBe('collection_applied'); - expect(observeEntrySpy).toHaveBeenCalledTimes(1); - return observeEntrySpy.mock.calls[0][2]; + if (outcome.kind !== 'collection_applied') { + throw new Error(`expected collection_applied, got ${outcome.kind}`); + } + const effects = outcome.reEntryObservations ?? []; + expect(effects).toHaveLength(1); + const event = effects[0].event; + if (event.type !== 'STEP_ENTERED') + throw new Error(`expected STEP_ENTERED, got ${event.type}`); + return event.payload as unknown as Record; } - it('carries none of the four rendered fields, though the substep it names has a description', async () => { - // The shared fixture gives substep '1' a description, so a builder that - // rendered anything at all would have something to render here. + it('carries the rendered description of the substep it names', async () => { + // The shared fixture gives substep '1' a description, which the old + // builder had and dropped. expect(steps[0]).toMatchObject({ substeps: expect.arrayContaining([expect.objectContaining({ id: '1', description: 'A' })]), }); - const entry = await captureCollectEntry(steps); + const payload = await collectStepEnteredPayload(steps); - // THE DIVERGENCE. All four rendered fields are optional on - // `StepEntryMetadata`, which is what lets this builder omit every one of - // them and still compile against a type the CLI loop fills completely. - // `hasCommand` on the derived event is computed as - // `commandCode !== undefined`, so the omission does not stop at the - // absent fields — it decides a flag too. - // - // CORRECT VALUE: the rendered fields, as the loop supplies them. A - // substep's description does not depend on which command entered it, so - // this path under-fills. - expect(entry.description).toBeUndefined(); - expect(entry.prompt).toBeUndefined(); - expect(entry.commandCode).toBeUndefined(); - expect(entry.commandLang).toBeUndefined(); - // The fields it DOES fill, so the omission reads as a gap in one builder - // rather than as an entry naming some other unit. - expect(entry).toMatchObject({ - stepId: '1', - substepId: '1', - stepName: '1', - isSubstep: true, - }); + // THE FLIP. `toBeUndefined()` on all four rendered fields before #820. + // A substep's description does not depend on which command entered it. + expect(payload.description).toBe('A'); + // The fixture substep declares no command, so `commandCode` is still + // absent — but `hasCommand` now says so because the PARSED unit says so, + // not because no rendering happened. + expect(payload.commandCode).toBeUndefined(); + expect(payload.hasCommand).toBe(false); + expect(payload).toMatchObject({ stepName: '1', isSubstep: true }); }); - it('reads prompted off the persisted flag alone, never off the step kind', async () => { + it('composes prompted from the persisted flag and the step kind', async () => { // The same two delegate substeps, hung off a step whose FOR bounds did // not resolve. `resolvedStepHasSubsteps` accepts `prompted-for`, so the // collect reaches its frontier exactly as it does for the shared fixture. @@ -2722,19 +2709,58 @@ describe('RunbookCollectionService', () => { }, ]; - const entry = await captureCollectEntry(promptedForSteps, { prompted: undefined }); - - // THE DIVERGENCE. This builder never looks at the step graph, so a - // prompted-FOR step reports `prompted: false` on the persisted flag - // alone. The CLI loop ORs `currentStep.kind === 'prompted-for'` into the - // same field and reports `true` for this exact cursor and step — pinned - // in the CLI's `execution-loop.test.ts`. + // `false`, not absent: the run's own flag is off, which is what makes the + // composition below about the step kind rather than about the run. + const payload = await collectStepEnteredPayload(promptedForSteps, { prompted: false }); + + // THE FLIP. `false` before #820, on the persisted flag alone. The field + // documents whether execution is prompted rather than automatic, and a + // prompted-FOR step IS prompted — the entry seam classifies it `awaiting` + // on this same term. + expect(payload.prompted).toBe(true); + // The substep carries no prompt of its own, so the step-level FOR text is + // what an orchestrator is shown — a field the old builder never carried. + expect(payload.prompt).toBe('FOR item IN {{ items }}'); + }); + + it('neither enters nor consumes the frontier for a cursor naming no live substep', async () => { + // The third divergence, and the only one that never reached the payload: + // `substepId` came off the raw cursor while `isSubstep` came off the + // resolved unit. Both answer one question, and both come off the resolved + // unit now — so a cursor naming no live substep is not a substep entry, + // and the frontier it would have disclosed stays persisted. // - // CORRECT VALUE: `true`. The field documents whether execution is - // prompted rather than automatic, and a prompted-FOR step is: the loop - // returns 'waiting' on that same term. So this path under-reports, and - // the fix makes the composed value the one both paths derive. - expect(entry.prompted).toBe(false); + // Named for what it asserts rather than for that origin: the payload is + // never read here, so a title promising payload coverage would send a + // reader looking for it in the wrong file. The `substepId` assertion + // itself lives on the seam that renders the payload, in + // `execution-unit-entry.test.ts`. + const frameKey = buildFrameKey('1'); + const retry = frontierEntry(); + const target = state({ + substep: '9', + retryCount: 1, + snapshot: { context: { delegateFrontier: [retry.persisted] } }, + }); + await manager.save(target); + jest.spyOn(completionService, 'prepareResolvedCompletionDrain').mockResolvedValue({ + status: 'continue', + state: target, + unresolved: 1, + applied: [], + }); + const enterEntrySpy = jest.spyOn(actorService, 'enterExecutionUnit'); + + const outcome = await collectionService.collectDelegationOutcomes({ + targetState: target, + steps, + callerEvidence: ORCHESTRATOR_EVIDENCE, + frame: activeFrame(frameKey, 1), + }); + + expect(outcome.kind).toBe('already_collected'); + expect(enterEntrySpy).not.toHaveBeenCalled(); + expect(await persistedFrontier(runId)).toHaveLength(1); }); }); @@ -3089,18 +3115,10 @@ describe('RunbookCollectionService', () => { claimId: bearerClaimId, claimKey: presentedClaimKey, }), - entry: { - stepId: '1', - substepId: '1', - position: { current: '1', total: 2, substep: '1' }, - stepName: '1', - isSubstep: true, - prompted: false, - }, }); expect(prepared.status).toBe('projected'); - // The seam hands back a state to commit and metadata to disclose afterwards — + // The seam hands back a state to commit and bearers to disclose afterwards — // never a "the write did not land" arm, because it performs no write. expect(prepared).not.toHaveProperty('status', 'consume_failed'); if (prepared.status !== 'projected') throw new Error('expected projected'); @@ -3446,38 +3464,124 @@ describe('RunbookCollectionService', () => { return { target, drainSpy }; } - it('propagates a post-commit observation failure rather than reporting a phantom success', async () => { + it('reports a post-commit render failure as RD-833 rather than a phantom success', async () => { // The alternative — swallowing and returning `reEntryObservations: []` — // would tell the orchestrator "collected, nothing to re-enter" while the // frontier's bearers had already been consumed and thrown away. That is // silent stranding; a rejection is at least an operator-visible fact. const { target } = await seedFrontierReadyToProject(); - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockRejectedValue(new Error('entry observation exploded')); + const enterEntrySpy = jest + .spyOn(actorService, 'enterExecutionUnit') + .mockRejectedValue(new Error('entry rendering exploded')); - await expect( - collectionService.collectDelegationOutcomes({ + const logged = jest.spyOn(logger, 'error').mockResolvedValue(undefined); + + const rejection = await collectionService + .collectDelegationOutcomes({ targetState: target, steps, callerEvidence: ORCHESTRATOR_EVIDENCE, frame: activeFrame(buildFrameKey('1'), 1), - }), - ).rejects.toThrow('entry observation exploded'); - expect(observeEntrySpy).toHaveBeenCalledTimes(1); + }) + .then( + () => { + throw new Error('expected the committed collect to reject'); + }, + (error: unknown) => error, + ); + + // The log line is not decoration. The rejection carries the render cause + // but cannot say the collection COMMITTED — which is the fact that decides + // whether an operator retries or re-delegates — so this is the only place + // that fact is recorded, and it must name the run it is about. + expect(logged).toHaveBeenCalledWith( + 'collection committed but its re-entry disclosure could not be rendered', + { runId, error: 'entry rendering exploded' }, + ); + + // Typed, not bare. #820 made rendering part of the collect path, so this + // is a NEW way for a collect to fail — it used to emit a thinner event + // that needed no rendering at all. A bare Error reaches the CLI wrapper's + // fallback arm and prints RD-999 "Unknown error", an envelope that cannot + // carry the recovery this condition has (fix the helper, then re-delegate: + // a retry cannot recover the bearers). + expect(rejection).toBeInstanceOf(RundownError); + expect((rejection as RundownError).code).toBe('RD-833'); + // The cause is preserved rather than swallowed by the envelope, and the + // run is named in context so an operator need not parse the message. + expect(getErrorMessage(rejection)).toMatch(/entry rendering exploded/); + expect((rejection as RundownError).context).toMatchObject({ runId }); + expect(enterEntrySpy).toHaveBeenCalledTimes(1); // The transaction COMMITTED before the disclosure was attempted: the // frontier is gone, which is exactly why the failure must not be silent. expect(await persistedFrontier(runId)).toEqual([]); }); + it('keeps a corrupt-state render refusal as InvalidRunbookStateError, not RD-833', async () => { + // The two recoveries differ, so the two classes must. RD-309 prints + // finish/stop/prune, which is right for a run that cannot describe itself; + // RD-833 prints "fix the helper and re-delegate", which is not. + const { target } = await seedFrontierReadyToProject(); + jest.spyOn(actorService, 'enterExecutionUnit').mockRejectedValue( + new InvalidRunbookStateError(`Runbook state ${runId} is missing WorkPath.`, { + runId, + reason: 'missing_render_context', + }), + ); + + await expect( + collectionService.collectDelegationOutcomes({ + targetState: target, + steps, + callerEvidence: ORCHESTRATOR_EVIDENCE, + frame: activeFrame(buildFrameKey('1'), 1), + }), + ).rejects.toBeInstanceOf(InvalidRunbookStateError); + }); + + it('keeps the REAL persisted-snapshot guards on the RD-309 path, through the unmocked seam', async () => { + // The class check above is only as good as what the seam actually raises. + // `enterExecutionUnit` runs `assertFreshSnapshotValue` and + // `compileMachineFromState` BEFORE it renders anything, and both used to + // throw a bare `Error` — which this catch would have relabelled RD-833, + // telling an operator to fix a helper when the run's snapshot is corrupt + // and the recovery is prune/restart. Driven through the real seam, with no + // `enterExecutionUnit` spy, so the classification is pinned end to end. + // The prepared consume this fixture commits carries `{ context: {} }` and + // therefore no `snapshot.value`, which is exactly the shape the freshness + // guard refuses — so the entry never reaches rendering at all. + const { target } = await seedFrontierReadyToProject(); + + const rejection = await collectionService + .collectDelegationOutcomes({ + targetState: target, + steps, + callerEvidence: ORCHESTRATOR_EVIDENCE, + frame: activeFrame(buildFrameKey('1'), 1), + }) + .then( + () => { + throw new Error('expected the committed collect to reject'); + }, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(InvalidRunbookStateError); + // NOT the RD-833 envelope: the classes are disjoint, so a guard that + // regressed to a bare `Error` would be caught here rather than quietly + // relabelled with the wrong recovery. + expect(rejection).not.toBeInstanceOf(RundownError); + expect(getErrorMessage(rejection)).toMatch(/Unsupported snapshot\.value shape/); + }); + it('answers a retry of that committed collection as an idempotent no-op', async () => { // What bounds the fail-loud choice: the caller who retries on the // rejection cannot double-apply. The collection already landed, so the // retry drains nothing and re-projects nothing. const { target, drainSpy } = await seedFrontierReadyToProject(); - const observeEntrySpy = jest - .spyOn(actorService, 'observeExecutionUnitEntry') - .mockRejectedValue(new Error('entry observation exploded')); + const enterEntrySpy = jest + .spyOn(actorService, 'enterExecutionUnit') + .mockRejectedValue(new Error('entry rendering exploded')); await expect( collectionService.collectDelegationOutcomes({ targetState: target, @@ -3485,7 +3589,7 @@ describe('RunbookCollectionService', () => { callerEvidence: ORCHESTRATOR_EVIDENCE, frame: activeFrame(buildFrameKey('1'), 1), }), - ).rejects.toThrow('entry observation exploded'); + ).rejects.toThrow(/entry rendering exploded/); const committed = await manager.load(runId); if (!committed) throw new Error('the committed target must exist'); @@ -3495,7 +3599,7 @@ describe('RunbookCollectionService', () => { unresolved: 1, applied: [], }); - observeEntrySpy.mockResolvedValue([]); + enterEntrySpy.mockResolvedValue({ kind: 'awaiting', effects: [] }); await expect( collectionService.collectDelegationOutcomes({ @@ -3506,7 +3610,7 @@ describe('RunbookCollectionService', () => { }), ).resolves.toMatchObject({ kind: 'already_collected', targetRunId: runId }); // No second disclosure attempt: there is no frontier left to project. - expect(observeEntrySpy).toHaveBeenCalledTimes(1); + expect(enterEntrySpy).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/core/__tests__/runbook/command-policy.properties.test.ts b/packages/core/__tests__/runbook/command-policy.properties.test.ts index e5b1946bb..d98a2388e 100644 --- a/packages/core/__tests__/runbook/command-policy.properties.test.ts +++ b/packages/core/__tests__/runbook/command-policy.properties.test.ts @@ -41,6 +41,7 @@ const tokenHash = assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`); function baseState(id = runIdA): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id, runbook: { source: 'project', path: 'p.md' }, diff --git a/packages/core/__tests__/runbook/command-policy.test.ts b/packages/core/__tests__/runbook/command-policy.test.ts index 1fd791a2e..6d91a303b 100644 --- a/packages/core/__tests__/runbook/command-policy.test.ts +++ b/packages/core/__tests__/runbook/command-policy.test.ts @@ -35,6 +35,7 @@ const tokenHash = assertDelegationTokenHash(`sha256:${'a'.repeat(64)}`); function state(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: parentRunId, runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/compiler.test.ts b/packages/core/__tests__/runbook/compiler.test.ts index ba26ec971..48abb1981 100644 --- a/packages/core/__tests__/runbook/compiler.test.ts +++ b/packages/core/__tests__/runbook/compiler.test.ts @@ -12222,6 +12222,7 @@ echo ok const frameKey = buildFrameKey('1'); // Start from a minimal persistent state; createDelegation updates substepStates. let state: RunbookState = { + prompted: false, id: brandRunIdForTest(`rd_${'a'.repeat(32)}`), runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', @@ -12842,6 +12843,7 @@ echo ok ): SubstepState { const frameKey = buildFrameKey('1', iteration); const baseState: RunbookState = { + prompted: false, id: brandRunIdForTest(`rd_${'b'.repeat(32)}`), runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', diff --git a/packages/core/__tests__/runbook/completion-service.test.ts b/packages/core/__tests__/runbook/completion-service.test.ts index a1680602d..8172c6505 100644 --- a/packages/core/__tests__/runbook/completion-service.test.ts +++ b/packages/core/__tests__/runbook/completion-service.test.ts @@ -80,6 +80,7 @@ describe('RunbookCompletionService', () => { function state(overrides: Partial = {}): RunbookState { return { + prompted: false, id: runbookId, runbook: { source: 'project', path: 'test.md' }, runbookPath: 'test.md', diff --git a/packages/core/__tests__/runbook/delegation-context.test.ts b/packages/core/__tests__/runbook/delegation-context.test.ts index 08f7f9ba0..86d7b975c 100644 --- a/packages/core/__tests__/runbook/delegation-context.test.ts +++ b/packages/core/__tests__/runbook/delegation-context.test.ts @@ -40,6 +40,7 @@ function ancestorRunId(index: number): RunId { /** Helper: create minimal RunbookState for buildContextSnapshot tests. */ function makeMinimalState(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: RUN_ID, runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/delegation-exposure.properties.test.ts b/packages/core/__tests__/runbook/delegation-exposure.properties.test.ts index 8d73bbdb5..566a1902b 100644 --- a/packages/core/__tests__/runbook/delegation-exposure.properties.test.ts +++ b/packages/core/__tests__/runbook/delegation-exposure.properties.test.ts @@ -214,6 +214,7 @@ function buildState(clauses: ExposureClauses, noise: ExposureNoise): RunbookStat substepStates.push(inlineSubstepRecord(noise)); } return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: runId, runbook: { source: 'project', path: 'exposure-properties.md' }, diff --git a/packages/core/__tests__/runbook/delegation-exposure.test.ts b/packages/core/__tests__/runbook/delegation-exposure.test.ts index 19a7b6342..e9dc015ad 100644 --- a/packages/core/__tests__/runbook/delegation-exposure.test.ts +++ b/packages/core/__tests__/runbook/delegation-exposure.test.ts @@ -71,6 +71,7 @@ function stepsWithInlineRunbookListSubstep(): readonly ResolvedStep[] { function plainState(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: runId, runbook: { source: 'project', path: 'exposure-test.md' }, diff --git a/packages/core/__tests__/runbook/delegation-inference.properties.test.ts b/packages/core/__tests__/runbook/delegation-inference.properties.test.ts index f3d98c9df..087f21862 100644 --- a/packages/core/__tests__/runbook/delegation-inference.properties.test.ts +++ b/packages/core/__tests__/runbook/delegation-inference.properties.test.ts @@ -199,6 +199,7 @@ function makeFrontierState( substepStates: readonly SubstepState[], ): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: brandRunIdForTest(`rd_${'1'.repeat(32)}`), runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/delegation-inference.test.ts b/packages/core/__tests__/runbook/delegation-inference.test.ts index 575bc87e9..c7fe53ae7 100644 --- a/packages/core/__tests__/runbook/delegation-inference.test.ts +++ b/packages/core/__tests__/runbook/delegation-inference.test.ts @@ -452,6 +452,7 @@ describe('deriveDelegateFrontier', () => { substepStates: readonly SubstepState[], ): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: brandRunIdForTest(`rd_${'1'.repeat(32)}`), runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/delegation-lifecycle-read-model.test.ts b/packages/core/__tests__/runbook/delegation-lifecycle-read-model.test.ts index 391f378f7..42184d608 100644 --- a/packages/core/__tests__/runbook/delegation-lifecycle-read-model.test.ts +++ b/packages/core/__tests__/runbook/delegation-lifecycle-read-model.test.ts @@ -26,6 +26,7 @@ const runbookId = brandRunIdForTest('rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); function state(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: runbookId, runbook: { source: 'project', path: 'parent.md' }, diff --git a/packages/core/__tests__/runbook/delegation-propagation.test.ts b/packages/core/__tests__/runbook/delegation-propagation.test.ts index c34e8c2d5..6daf7dc74 100644 --- a/packages/core/__tests__/runbook/delegation-propagation.test.ts +++ b/packages/core/__tests__/runbook/delegation-propagation.test.ts @@ -35,6 +35,7 @@ describe('DelegationLinkage extended fields', () => { retryCount: 0, variables: {}, templateVars: {}, + prompted: false, steps: [], startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -170,6 +171,7 @@ describe('parentLinkage discriminated union schema', () => { retryCount: 0, variables: {}, templateVars: {}, + prompted: false, steps: [], startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -282,6 +284,7 @@ describe('parentLinkage discriminated union schema', () => { describe('frame identity derivation for propagation', () => { function makeState(overrides: Partial): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: LOCAL_RUN_ID, runbook: { source: 'project', path: 'test.md' }, diff --git a/packages/core/__tests__/runbook/delegation-scan.test.ts b/packages/core/__tests__/runbook/delegation-scan.test.ts index dea557368..55b15e0b1 100644 --- a/packages/core/__tests__/runbook/delegation-scan.test.ts +++ b/packages/core/__tests__/runbook/delegation-scan.test.ts @@ -61,6 +61,7 @@ describe('DelegationScanService', () => { function makeState(id: string, overrides: Partial = {}): RunbookState { return { + prompted: false, id: id as RunbookState['id'], runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', diff --git a/packages/core/__tests__/runbook/delegation-schemas.test.ts b/packages/core/__tests__/runbook/delegation-schemas.test.ts index 99c4370e6..c9f50918e 100644 --- a/packages/core/__tests__/runbook/delegation-schemas.test.ts +++ b/packages/core/__tests__/runbook/delegation-schemas.test.ts @@ -1112,6 +1112,7 @@ function createMinimalRunbookState(overrides: Record = {}) { retryCount: 0, variables: {}, templateVars: {}, + prompted: false, steps: [{ id: '1', status: 'running' }], startedAt: '2026-02-27T10:00:00.000Z', updatedAt: '2026-02-27T10:00:00.000Z', diff --git a/packages/core/__tests__/runbook/delegation-service-fixtures.ts b/packages/core/__tests__/runbook/delegation-service-fixtures.ts index a3568dada..7aabecb03 100644 --- a/packages/core/__tests__/runbook/delegation-service-fixtures.ts +++ b/packages/core/__tests__/runbook/delegation-service-fixtures.ts @@ -99,6 +99,7 @@ function makeTestSubstep(id: string): { /** Helper: create minimal RunbookState for testing. */ export function makeState(overrides: Partial = {}): RunbookState { return { + prompted: false, id: brandRunIdForTest(`rd_${'c'.repeat(32)}`), runbook: { source: 'project', path: 'parent.md' }, runbookPath: 'parent.md', diff --git a/packages/core/__tests__/runbook/delegation-service.test.ts b/packages/core/__tests__/runbook/delegation-service.test.ts index 073caa5ad..d517d7894 100644 --- a/packages/core/__tests__/runbook/delegation-service.test.ts +++ b/packages/core/__tests__/runbook/delegation-service.test.ts @@ -38,6 +38,7 @@ function runState(overrides: Partial = {}): RunbookState { retryCount: 0, variables: {} as RunbookState['variables'], templateVars: {} as RunbookState['templateVars'], + prompted: false, steps: [], startedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z', diff --git a/packages/core/__tests__/runbook/entry-projection-ordering.test.ts b/packages/core/__tests__/runbook/entry-projection-ordering.test.ts index f97df493b..4fe2b29e6 100644 --- a/packages/core/__tests__/runbook/entry-projection-ordering.test.ts +++ b/packages/core/__tests__/runbook/entry-projection-ordering.test.ts @@ -147,6 +147,7 @@ describe('entry projection ordering: machine credential issuance agrees with com function baseState(overrides: Partial = {}): RunbookState { return { + prompted: false, id: runId, runbook: { source: 'project', path: 'investigation.md' }, runbookPath: 'investigation.md', diff --git a/packages/core/__tests__/runbook/execution-unit-entry.test.ts b/packages/core/__tests__/runbook/execution-unit-entry.test.ts new file mode 100644 index 000000000..e6236457f --- /dev/null +++ b/packages/core/__tests__/runbook/execution-unit-entry.test.ts @@ -0,0 +1,728 @@ +import { describe, expect, it } from '@jest/globals'; +import { assertRunId } from '../../src/runbook/run-id.js'; +import { deriveExecutionUnitEntry } from '../../src/runbook/execution-unit-entry.js'; +import type { ResolvedStep, RunbookState } from '../../src/runbook/types.js'; +import { buildFrameKey } from '../../src/runbook/targeting.js'; +import { InvalidRunbookStateError } from '../../src/runbook/state.js'; +import { WORK_DIR } from '../../src/paths.js'; +import { + brandEffectiveVarsForTest, + brandInitialTemplateVarsForTest, + brandStoredOutputsForTest, +} from '../../src/testing/effective-vars.js'; +import { + makeBaseStep, + makeCommandStep, + makeResolvedStepWithFor, + makeResolvedStepWithPromptedFor, + makeResolvedStepWithSubsteps, + makeSubstep, +} from '../helpers/step-factories.js'; + +const runId = assertRunId(`rd_${'1'.repeat(32)}`); +const CONTEXT_ID = 'ctx-entry'; +const CWD = '/project'; + +/** + * One open FOR iteration frame on the run's stack. + * + * @param frame - Variable, iteration and inclusive range for the open loop. + * @returns A `ForContext` for `RunbookState.forStack`. + */ +function forFrame(frame: { + variable: string; + iteration: number; + start: number; + end: number; +}): NonNullable[number] { + return { + stepId: '1', + iteration: frame.iteration, + start: frame.start, + end: frame.end, + variable: frame.variable, + implicit: false, + source: { kind: 'range' }, + }; +} + +/** Template vars carrying the two required built-ins plus whatever a case needs. */ +function vars(extra: Record = {}) { + return brandInitialTemplateVarsForTest({ ContextId: CONTEXT_ID, WorkPath: WORK_DIR, ...extra }); +} + +/** + * A run positioned on one execution unit, seeded with the two variables every + * frame renders against. + * + * @param overrides - Fields the individual case is about. + * @returns A `RunbookState` the entry seam can render. + */ +function state(overrides: Partial = {}): RunbookState { + return { + prompted: false, + id: runId, + runbook: { source: 'project', path: 'entry-test.md' }, + runbookPath: 'entry-test.md', + step: '1', + stepName: 'Entry test', + retryCount: 0, + variables: brandStoredOutputsForTest({}), + templateVars: vars(), + steps: [], + lifecycle: 'running', + startedAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + activeFrameKey: buildFrameKey('1'), + activeEntry: 1, + frameEntryCounts: { [buildFrameKey('1')]: 1 }, + substepStates: [], + resolvedCompletions: {}, + schemaVersion: 1, + frontmatterOutputs: [], + ...overrides, + }; +} + +/** Enter the unit a fixture run's cursor names. */ +function enter(steps: readonly ResolvedStep[], target: RunbookState = state()) { + return deriveExecutionUnitEntry({ state: target, steps, cwd: CWD }); +} + +/** + * The single `STEP_ENTERED` payload an entry announces. + * + * @param entry - Result of {@link enter}. + * @returns The payload, as a bag of fields. + */ +function payloadOf(entry: ReturnType): Record { + expect(entry.effects).toHaveLength(1); + const event = entry.effects[0].event; + if (event.type !== 'STEP_ENTERED') throw new Error(`expected STEP_ENTERED, got ${event.type}`); + return event.payload as unknown as Record; +} + +const commandStep = makeCommandStep({ + description: 'Say hello to {{ who }}', + command: { code: 'echo {{ who }}', lang: 'bash' }, +}); + +describe('deriveExecutionUnitEntry', () => { + describe('classification', () => { + it('classifies a command unit runnable and renders the command once, into both places it appears', () => { + const entered = enter([commandStep], state({ templateVars: vars({ who: 'world' }) })); + + expect(entered.kind).toBe('runnable'); + if (entered.kind !== 'runnable') throw new Error('unreachable'); + // The announced command and the executed command are the SAME expansion. + // That is the property the one-value return delivers: a non-deterministic + // `--helpers` helper cannot make them differ. + expect(entered.command.code).toBe('echo world'); + expect(payloadOf(entered).commandCode).toBe('echo world'); + expect(payloadOf(entered).description).toBe('Say hello to world'); + expect(payloadOf(entered).commandLang).toBe('bash'); + }); + + it('shell-escapes the command expansion but not the description', () => { + // Two expanders, one frame. `expandLoopVariablesForCommand` escapes every + // substitution because the result reaches a shell; `expandLoopVariables` + // must not, because the result reaches a reader. A mutant routing both + // through one expander shows up here. + const entered = enter( + [makeCommandStep({ description: 'about {{ who }}', command: { code: 'echo {{ who }}' } })], + state({ templateVars: vars({ who: 'a b; rm -rf /' }) }), + ); + + if (entered.kind !== 'runnable') throw new Error('expected a runnable unit'); + expect(entered.command.code).toBe("echo 'a b; rm -rf /'"); + expect(payloadOf(entered).description).toBe('about a b; rm -rf /'); + }); + + it('carries the rundown-injected environment on the runnable arm', () => { + const entered = enter([commandStep]); + + if (entered.kind !== 'runnable') throw new Error('expected a runnable unit'); + expect(entered.command.rdInjected).toEqual({ + RD_WORK_PATH: WORK_DIR, + RD_CONTEXT_ID: CONTEXT_ID, + RD_RUN_ID: runId, + RD_RUNBOOK_REF: 'entry-test.md', + RD_RUNBOOK_SOURCE: 'project', + }); + }); + + it('injects the run work path even when a FOR variable shadows the name in the frame', () => { + // `RD_WORK_PATH` is the subprocess's work directory, not whatever the loop + // happens to bind. The frame is `effectiveVars` plus the loop's bindings, + // so reading the env off the frame would let `FOR WorkPath IN …` rename the + // work directory for the child — and would disagree with the artifact paths + // the same entry announces, which come from the render context. + const entered = enter( + [ + makeResolvedStepWithFor({ + forClause: { variable: 'WorkPath', start: 1, end: 3 }, + substeps: [makeSubstep({ id: '1', command: { code: 'echo hi' } })], + }), + ], + state({ + substep: '1', + forStack: [forFrame({ variable: 'WorkPath', iteration: 2, start: 1, end: 3 })], + }), + ); + + if (entered.kind !== 'runnable') throw new Error('expected a runnable unit'); + expect(entered.command.rdInjected.RD_WORK_PATH).toBe(WORK_DIR); + }); + + it('classifies a prompted run awaiting even though the unit carries a command', () => { + const entered = enter([commandStep], state({ prompted: true })); + + expect(entered.kind).toBe('awaiting'); + // The unit still ANNOUNCES its command — a prompted operator needs to see + // what they are being asked to run. `hasCommand` is the unit's property; + // `awaiting` is this process's instruction. + expect(payloadOf(entered)).toMatchObject({ hasCommand: true, prompted: true }); + expect(payloadOf(entered).commandCode).toBe('echo {{ who }}'); + }); + + it('classifies a unit declaring no command awaiting, with hasCommand false', () => { + const entered = enter([makeBaseStep({ description: 'Nothing to run' })]); + + expect(entered.kind).toBe('awaiting'); + expect(payloadOf(entered).hasCommand).toBe(false); + expect(payloadOf(entered).commandCode).toBeUndefined(); + expect(payloadOf(entered).commandLang).toBeUndefined(); + }); + + it('derives hasCommand from the parsed unit, not from the rendered text', () => { + // The case that distinguishes the two derivations: a command whose text + // renders to the empty string still declares a command. + const entered = enter([makeCommandStep({ command: { code: '', lang: 'bash' } })]); + + expect(payloadOf(entered).hasCommand).toBe(true); + expect(entered.kind).toBe('runnable'); + }); + }); + + describe('position input', () => { + it('uses a caller-supplied position instead of deriving one (RD-827 finding 3)', () => { + // Every caller with a position already in scope (the CLI execution loop + // computes one via `countNumberedSteps` + `buildStepPosition` for its own + // error-reporting events) hands it in rather than making this function + // repeat that full-array scan for the identical value. `total: 999` could + // never arise from `countNumberedSteps([commandStep])` (which is 1), so + // the assertion below only passes if the supplied value rode through + // without being recomputed. + const suppliedPosition = { current: '1', total: 999 }; + + const entered = deriveExecutionUnitEntry({ + state: state(), + steps: [commandStep], + cwd: CWD, + position: suppliedPosition, + }); + + expect(payloadOf(entered).position).toEqual(suppliedPosition); + }); + + it('derives its own position when the caller supplies none', () => { + const entered = enter([commandStep], state()); + + expect(payloadOf(entered).position).toMatchObject({ current: '1', total: 1 }); + }); + }); + + describe('the unit the cursor resolves to', () => { + const parentWithSubsteps = makeResolvedStepWithSubsteps({ + description: 'Fan out', + substeps: [ + makeSubstep({ + id: '1', + description: 'Handle {{ who }}', + prompt: 'Substep prompt', + command: { code: 'echo sub-{{ who }}', lang: 'sh' }, + }), + ], + }); + + it('renders the SUBSTEP when the cursor names a live one', () => { + const entered = enter( + [parentWithSubsteps], + state({ substep: '1', templateVars: vars({ who: 'alice' }) }), + ); + + // Every field off the substep, not the parent: its id as the name, its own + // description, prompt and command. + expect(payloadOf(entered)).toMatchObject({ + stepName: '1', + isSubstep: true, + description: 'Handle alice', + prompt: 'Substep prompt', + hasCommand: true, + commandCode: 'echo sub-alice', + commandLang: 'sh', + }); + }); + + it('falls back to the parent STEP when the cursor names no live substep', () => { + const entered = enter([parentWithSubsteps], state({ substep: '9' })); + + // The parent step's name and description, and none of the substep's + // fields — a cursor naming nothing does not borrow from substep 1. + expect(payloadOf(entered)).toMatchObject({ + stepName: '1', + isSubstep: false, + description: 'Fan out', + hasCommand: false, + }); + expect(payloadOf(entered).prompt).toBeUndefined(); + }); + + it('takes a step-level command only from a command step', () => { + // `kind: 'command'` is what carries `command` at step level. A substeps + // step never does, so the parent-step arm must not reach for one. + expect(payloadOf(enter([parentWithSubsteps])).hasCommand).toBe(false); + expect(payloadOf(enter([commandStep])).hasCommand).toBe(true); + }); + }); + + describe('the FOR frame', () => { + const forStep = makeResolvedStepWithFor({ + forClause: { variable: 'i', start: 4, end: 9 }, + substeps: [makeSubstep({ id: '1', description: 'iteration {{ i }} of {{ Index }}' })], + }); + + it('seeds the frame from the FOR clause when no iteration is open yet', () => { + // `forClause` is passed ONLY for a `kind: 'for'` step. Without it the frame + // carries no `Index` and no loop variable, so both placeholders survive + // unresolved — which is exactly what the mutants on that ternary produce. + const entered = enter([forStep], state({ substep: '1' })); + + expect(payloadOf(entered).description).toBe('iteration 4 of 4'); + }); + + it('prefers the open iteration on the FOR stack over the clause start', () => { + const entered = enter( + [forStep], + state({ + substep: '1', + forStack: [forFrame({ variable: 'i', iteration: 7, start: 4, end: 9 })], + }), + ); + + expect(payloadOf(entered).description).toBe('iteration 7 of 7'); + }); + + it('does not seed a FOR frame for a non-FOR step that happens to have substeps', () => { + const substepsStep = makeResolvedStepWithSubsteps({ + substeps: [makeSubstep({ id: '1', description: 'index is {{ Index }}' })], + }); + + const entered = enter([substepsStep], state({ substep: '1' })); + + expect(payloadOf(entered).description).toBe('index is {{ Index }}'); + }); + + it('reports the raw cursor in position while naming the resolved unit', () => { + const entered = enter([forStep], state({ substep: '1' })); + + // Position describes where the run IS; `stepName` / `isSubstep` describe + // what it entered. Two separate questions. + expect(payloadOf(entered).position).toMatchObject({ current: '1', substep: '1' }); + }); + }); + + describe('prompt fallback', () => { + it('uses the substep prompt when it has one, even on a prompted-FOR step', () => { + const entered = enter( + [ + makeResolvedStepWithPromptedFor({ + prompt: 'FOR item IN {{ items }}', + substeps: [makeSubstep({ id: '1', prompt: 'The substep speaks for itself' })], + }), + ], + state({ substep: '1' }), + ); + + expect(payloadOf(entered).prompt).toBe('The substep speaks for itself'); + }); + + it('falls back to the step-level FOR text when the substep has no prompt', () => { + const entered = enter( + [ + makeResolvedStepWithPromptedFor({ + prompt: 'FOR item IN {{ items }}', + substeps: [makeSubstep({ id: '1' })], + }), + ], + state({ substep: '1' }), + ); + + expect(payloadOf(entered).prompt).toBe('FOR item IN {{ items }}'); + }); + + it('renders no prompt for a substep with none, even when the STEP has one', () => { + // The step-level fallback is reserved for a prompted FOR, whose prompt IS + // the reconstructed loop text. An ordinary step's prompt belongs to the + // step, so a substep with none must not inherit it. + const entered = enter( + [ + makeResolvedStepWithSubsteps({ + prompt: 'The parent step speaks', + substeps: [makeSubstep({ id: '1' })], + }), + ], + state({ substep: '1' }), + ); + + expect(payloadOf(entered).prompt).toBeUndefined(); + }); + + it('renders no prompt for a substep with none on a step that is not a prompted FOR', () => { + const entered = enter( + [makeResolvedStepWithSubsteps({ substeps: [makeSubstep({ id: '1' })] })], + state({ substep: '1' }), + ); + + expect(payloadOf(entered).prompt).toBeUndefined(); + }); + }); + + describe('the helper render context', () => { + it('renders artifact-path helpers at the RUNNABLE tier, so paths carry the run id', () => { + // `{{ path "key" }}` resolves against the render context, and the run id + // segment is contributed only by the `runnable` tier — the `prepared` tier + // has no run to name. An entry is always a live run, so a context built at + // the wrong tier would silently render every artifact path one segment + // short, pointing at a directory the run does not own. + const entered = enter([ + makeCommandStep({ description: 'plan lives at {{ path "plan.json" }}' }), + ]); + + expect(payloadOf(entered).description).toContain(`${WORK_DIR}/.rd-${CONTEXT_ID}/${runId}/`); + expect(payloadOf(entered).description).toContain('plan.json'); + }); + }); + + describe('the snapshot the entry is observed against', () => { + const artifact = { + kind: 'artifact-record' as const, + uri: `rd://artifacts/${CONTEXT_ID}/${runId}/plan.md`, + runId, + contextId: CONTEXT_ID, + runbook: { source: 'project' as const, path: 'entry-test.md' }, + key: 'plan.md', + timestamp: '2026-08-19T00:00:00.000Z', + }; + + it('projects entered artifacts under the run work path', () => { + const entered = enter( + [commandStep], + state({ snapshot: { context: { enteredArtifacts: { PlanPath: artifact } } } }), + ); + + const artifacts = payloadOf(entered).artifacts as Record; + // Rooted at the SAME work directory the helper context renders against: + // one read of `WorkPath`, not two. + expect(artifacts.PlanPath.path).toContain(`${WORK_DIR}/.rd-${CONTEXT_ID}/${runId}/plan.md`); + expect(artifacts.PlanPath.path).toContain(CWD); + }); + + it('enters a run whose persisted snapshot is not an object at all', () => { + // `RunbookState.snapshot` is `unknown`. A non-object there is corrupt, but + // it is not the entry's business to refuse it — the freshness guard on the + // service seam owns that call. Reading it must simply yield no context + // rather than throw on a property access. + const entered = enter([commandStep], state({ snapshot: 'not-an-object' })); + + expect(payloadOf(entered)).toMatchObject({ stepName: '1', artifacts: {} }); + }); + + it('enters a run whose persisted snapshot carries no context', () => { + const entered = enter([commandStep], state({ snapshot: { value: 'step::1' } })); + + expect(payloadOf(entered)).toMatchObject({ stepName: '1', artifacts: {} }); + }); + + it('enters a run whose persisted context is not an object', () => { + // The guard is on the value actually read. Handing a string onward would + // reach `'enteredArtifacts' in candidate`, and `in` against a primitive + // throws — so this is a refusal-to-read, not a nicety. + const entered = enter([commandStep], state({ snapshot: { context: 'oops' } })); + + expect(payloadOf(entered)).toMatchObject({ stepName: '1', artifacts: {} }); + }); + + it('enters a run whose persisted context is null', () => { + const entered = enter([commandStep], state({ snapshot: { context: null } })); + + expect(payloadOf(entered)).toMatchObject({ stepName: '1', artifacts: {} }); + }); + + it('enters a run that has never synced a snapshot', () => { + // The no-snapshot fallback still has to name the cursor, because the + // observation reads `context.step` back off it. + const entered = enter([commandStep], state({ snapshot: undefined })); + + expect(payloadOf(entered)).toMatchObject({ stepName: '1', artifacts: {} }); + }); + + it('names the unit from the run columns, never from the snapshot blob', () => { + // The structured columns are the authority on where the run is; the blob + // may lag. A snapshot naming a different step must not win, or the entry + // would describe a unit the run has left. + const entered = enter( + [commandStep], + state({ snapshot: { context: { step: 'stale', enteredArtifacts: {} } } }), + ); + + expect(payloadOf(entered).stepName).toBe('1'); + }); + }); + + describe('inline launch', () => { + const inlineSteps = [ + makeResolvedStepWithSubsteps({ + substeps: [makeSubstep({ id: '1', description: 'Inline child' })], + }), + ]; + const frameKey = buildFrameKey('1'); + + /** A persisted intent naming this run's step 1 / substep 1 at the active frame. */ + function intent(overrides: Record = {}) { + return { + parentRunId: runId, + parentStepId: '1', + parentStep: '1', + parentFrameKey: frameKey, + childRunId: assertRunId(`rd_${'2'.repeat(32)}`), + childRunbookPath: 'child.runbook.md', + childRunbookRef: { source: 'project', path: 'child.runbook.md' }, + contextSnapshot: { + vars: brandEffectiveVarsForTest({}), + ancestors: [], + step: '1', + substep: '1', + at: '1.1', + }, + ...overrides, + }; + } + + /** Enter substep 1 with the given persisted inline-launch intent. */ + function enterWithIntent(inlineLaunchIntent: unknown, overrides: Partial = {}) { + return enter( + inlineSteps, + state({ substep: '1', snapshot: { context: { inlineLaunchIntent } }, ...overrides }), + ); + } + + it('classifies inline-launch and carries the intent with its parent entry', () => { + const entered = enterWithIntent(intent()); + + expect(entered.kind).toBe('inline-launch'); + if (entered.kind !== 'inline-launch') throw new Error('unreachable'); + expect(entered.launch).toMatchObject({ + parentStepId: '1', + parentFrameKey: frameKey, + // Stamped from the run's own frame counter, not from the intent — the + // intent is written before the entry it belongs to is observed. + parentEntry: 1, + }); + expect(payloadOf(entered).inlineLaunch).toMatchObject({ parentEntry: 1 }); + }); + + it('ignores a persisted value that is not a launch intent', () => { + const entered = enterWithIntent({ nonsense: true }); + + expect(entered.kind).toBe('awaiting'); + expect(payloadOf(entered).inlineLaunch).toBeUndefined(); + }); + + it('ignores an intent naming another run', () => { + expect( + enterWithIntent(intent({ parentRunId: assertRunId(`rd_${'9'.repeat(32)}`) })).kind, + ).toBe('awaiting'); + }); + + it('ignores an intent naming another step', () => { + expect(enterWithIntent(intent({ parentStep: '2' })).kind).toBe('awaiting'); + }); + + it('ignores an intent naming another substep', () => { + expect(enterWithIntent(intent({ parentStepId: '2' })).kind).toBe('awaiting'); + }); + + it('ignores an intent whose authored frame is no longer open', () => { + // Openness flows from the frame stack, never from the monotonic entry + // counter — whose keys persist after a loop advances, and would otherwise + // re-project a stale prior-iteration intent onto the current frame. + const entered = enterWithIntent(intent({ parentFrameKey: buildFrameKey('1', 4) }), { + activeFrameKey: frameKey, + frameEntryCounts: { [buildFrameKey('1')]: 1, [buildFrameKey('1', 4)]: 3 }, + }); + + expect(entered.kind).toBe('awaiting'); + }); + + it('does not project an intent onto a cursor that resolves to the parent step', () => { + // `substepId` and `isSubstep` both come off the resolved unit, so a cursor + // naming no live substep is not the substep the intent addresses. + const entered = enter( + inlineSteps, + state({ substep: '9', snapshot: { context: { inlineLaunchIntent: intent() } } }), + ); + + expect(entered.kind).toBe('awaiting'); + }); + }); + + describe('delegation bearers', () => { + const frontier = [{ id: '1.1', runbook: 'child.md', token: 'rdtk_example' }]; + + it('discloses the supplied bearers on the entry payload', () => { + const entered = deriveExecutionUnitEntry({ + state: state(), + steps: [commandStep], + delegateFrontier: frontier, + cwd: CWD, + }); + + expect(payloadOf(entered).delegateFrontier).toEqual(frontier); + }); + + it('carries no frontier on an ordinary entry', () => { + expect(payloadOf(enter([commandStep])).delegateFrontier).toBeUndefined(); + }); + }); + + describe('render failures', () => { + it('refuses a run whose variables carry no ContextId as invalid persisted state', () => { + const noContextId = state({ + templateVars: brandInitialTemplateVarsForTest({ WorkPath: WORK_DIR }), + }); + + // Typed, not bare: the CLI maps `InvalidRunbookStateError` onto the + // finish/stop/prune recovery path, and a run that cannot render its own + // frame is corrupt persisted state by the no-migration rule. + expect(() => enter([commandStep], noContextId)).toThrow(InvalidRunbookStateError); + expect(() => enter([commandStep], noContextId)).toThrow(/missing ContextId/); + }); + + it('refuses a run whose variables carry no WorkPath as invalid persisted state', () => { + const noWorkPath = state({ + templateVars: brandInitialTemplateVarsForTest({ ContextId: CONTEXT_ID }), + }); + + expect(() => enter([commandStep], noWorkPath)).toThrow(InvalidRunbookStateError); + expect(() => enter([commandStep], noWorkPath)).toThrow(/missing WorkPath/); + }); + + it('names the run in the refusal defect, not only in the prose', () => { + const noWorkPath = state({ + templateVars: brandInitialTemplateVarsForTest({ ContextId: CONTEXT_ID }), + }); + + try { + enter([commandStep], noWorkPath); + throw new Error('expected a refusal'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidRunbookStateError); + expect((error as InvalidRunbookStateError).defect).toEqual({ + runId, + reason: 'missing_render_context', + }); + } + }); + + it('refuses a cursor naming a step the parsed runbook does not define', () => { + // Same class as the two render refusals above, and for the same reason: a + // cursor that has diverged from the compiled steps is corrupt persisted + // state, whose recovery is prune or restart. A bare `Error` here would be + // relabelled RD-833 by the collect path's catch, which tells the operator + // to fix a helper and re-delegate instead. + expect(() => enter([commandStep], state({ step: '9' }))).toThrow(InvalidRunbookStateError); + expect(() => enter([commandStep], state({ step: '9' }))).toThrow('Step "9" not found'); + + try { + enter([commandStep], state({ step: '9' })); + throw new Error('expected a refusal'); + } catch (error) { + expect((error as InvalidRunbookStateError).defect).toEqual({ + runId, + reason: 'cursor_step_not_in_runbook', + }); + } + }); + }); + + // --------------------------------------------------------------------------- + // #816 characterisation — the two divergences the CLI execution loop's builder + // used to own, now pinned against the one seam that renders every entry. + // + // These moved here from `packages/cli/__tests__/services/execution-loop.test.ts` + // when the loop stopped rendering (#819). They assert the SAME values on the + // same fixtures; only the subject changed, from a mocked loop to the real + // derivation. The end-to-end contrast against `rundown collect` lives in + // `packages/cli/__tests__/integration/step-entered-run-collect-agreement.test.ts`. + // --------------------------------------------------------------------------- + describe('STEP_ENTERED entry metadata (#816 characterisation)', () => { + it('composes prompted from the run flag OR the prompted-FOR step kind', () => { + // A FOR step whose bounds did not resolve is demoted to `prompted-for`: + // substeps, no iteration machinery, the original FOR text kept as the + // step prompt. + const promptedForSteps = [ + makeResolvedStepWithPromptedFor({ + description: 'Fan out over an unresolved source', + prompt: 'FOR item IN {{ items }}', + substeps: [makeSubstep({ id: '1', description: 'Handle one item' })], + }), + ]; + + // The run's persisted prompted flag, explicitly FALSE. Everything below is + // about the second term. + const entered = enter(promptedForSteps, state({ substep: '1', prompted: false })); + + expect(entered.kind).toBe('awaiting'); + // THE DIVERGENCE. This seam ORs `currentStep.kind === 'prompted-for'` into + // the flag; core's collect-side builder read `!!advanced.prompted` alone + // and reported `false` for this same cursor on this same step. + // + // CORRECT VALUE: `true`. The payload field documents whether execution is + // prompted rather than automatic, and a prompted-FOR step IS prompted — + // the classification above turns on exactly this term. + expect(payloadOf(entered).prompted).toBe(true); + }); + + it('reports prompted false for a non-prompted run on an ordinary step', () => { + // The other half of the OR, so neither term can be dropped unnoticed. + expect(payloadOf(enter([commandStep])).prompted).toBe(false); + }); + + it('derives substepId and isSubstep from the same resolved unit', () => { + // A cursor naming a substep the current step does not define. + // `resolveCurrentExecutionUnit` falls back to the parent step for it, so + // the two fields used to be derived from different sources and disagree: + // `substepId` came straight off the raw cursor while `isSubstep` came off + // the resolved unit, yielding a populated `substepId` alongside + // `isSubstep: false`. + // + // `substepId` never reaches the payload, so the observable trace is the + // pair below: position still reports the raw cursor, while the unit fields + // report the step the cursor actually resolved to. + const substepSteps = [ + makeResolvedStepWithSubsteps({ + description: 'Fan out', + substeps: [makeSubstep({ id: '1', description: 'The only live substep' })], + }), + ]; + + const payload = payloadOf(enter(substepSteps, state({ substep: '9' }))); + + expect(payload.position).toMatchObject({ current: '1', substep: '9' }); + expect(payload.isSubstep).toBe(false); + expect(payload.stepName).toBe('1'); + expect(payload.description).toBe('Fan out'); + }); + }); +}); diff --git a/packages/core/__tests__/runbook/execution-units.test.ts b/packages/core/__tests__/runbook/execution-units.test.ts index 30fd82675..16aa0506c 100644 --- a/packages/core/__tests__/runbook/execution-units.test.ts +++ b/packages/core/__tests__/runbook/execution-units.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from '@jest/globals'; import { extractUnitOutputs, + findStepOrThrow, resolveCurrentExecutionUnit, } from '../../src/runbook/execution-units.js'; import type { OutputDeclaration } from '@rundown-org/parser'; import type { ResolvedStep, Substep } from '../../src/runbook/types.js'; import { makeBaseStep } from '../helpers/step-factories.js'; +import { InvalidRunbookStateError } from '../../src/runbook/persisted-state-guards.js'; +import { RundownError } from '../../src/errors/rundown-error.js'; function makeSubstep(id: string): Substep { return { @@ -18,6 +21,59 @@ function makeSubstep(id: string): Substep { }; } +describe('findStepOrThrow', () => { + const first = makeBaseStep({ name: '1', description: 'First' }); + const second = makeBaseStep({ name: 'RECOVER', description: 'Named step' }); + const runId = 'rd_0123456789abcdef0123456789abcdef'; + + it('returns the step whose name matches the cursor', () => { + expect(findStepOrThrow([first, second], '1', runId)).toBe(first); + // Named (non-numeric) steps resolve the same way — the cursor carries the + // step's `name`, not its ordinal. + expect(findStepOrThrow([first, second], 'RECOVER', runId)).toBe(second); + }); + + it('refuses a cursor no step carries, naming the step it looked for', () => { + // A run's `step` column and its compiled steps are written together, so a + // miss means they have diverged. The message names the cursor because that + // is the only fact distinguishing this from any other lookup failure. + expect(() => findStepOrThrow([first, second], 'Gone', runId)).toThrow('Step "Gone" not found'); + }); + + it('refuses against an empty step list', () => { + expect(() => findStepOrThrow([], '1', runId)).toThrow('Step "1" not found'); + }); + + // The CLASS is load-bearing, not decoration on the message. `rundown collect` + // wraps every non-`InvalidRunbookStateError` rejection from the entry seam as + // RD-833, whose recovery reads "fix the helper and re-delegate" — the wrong + // instruction entirely for a diverged cursor, which is corrupt persisted + // state recoverable only by prune or restart. A bare `Error` here would pass + // both message assertions above and still be relabelled at that seam, so both + // refusals pin the class and the structured defect the CLI's RD-309 mapping + // reads. + it.each([ + { label: 'a cursor no step carries', steps: [first, second], cursor: 'Gone' }, + { label: 'an empty step list', steps: [], cursor: '1' }, + ])('classifies $label as invalid persisted state', ({ steps, cursor }) => { + let caught: unknown; + try { + findStepOrThrow(steps, cursor, runId); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(InvalidRunbookStateError); + // NOT a RundownError: the two classes are disjoint, which is exactly what + // keeps the collect path's RD-833 catch from swallowing this one. + expect(caught).not.toBeInstanceOf(RundownError); + expect((caught as InvalidRunbookStateError).defect).toEqual({ + runId, + reason: 'cursor_step_not_in_runbook', + }); + }); +}); + describe('resolveCurrentExecutionUnit', () => { it('returns the parent step when no substep id is active', () => { const step = makeBaseStep({ name: '1', description: 'Parent' }); diff --git a/packages/core/__tests__/runbook/frame-entry-multi-entry-paths.test.ts b/packages/core/__tests__/runbook/frame-entry-multi-entry-paths.test.ts index bfaa11c53..627ebdcd8 100644 --- a/packages/core/__tests__/runbook/frame-entry-multi-entry-paths.test.ts +++ b/packages/core/__tests__/runbook/frame-entry-multi-entry-paths.test.ts @@ -121,6 +121,7 @@ describe('one mutation, one entry bump', () => { function baseState(overrides: Partial = {}): RunbookState { return { + prompted: false, id: runId, runbook: { source: 'project', path: 'paths.md' }, runbookPath: 'paths.md', diff --git a/packages/core/__tests__/runbook/inline-parent-advance.test.ts b/packages/core/__tests__/runbook/inline-parent-advance.test.ts index 589ea5318..ba8fda726 100644 --- a/packages/core/__tests__/runbook/inline-parent-advance.test.ts +++ b/packages/core/__tests__/runbook/inline-parent-advance.test.ts @@ -55,6 +55,7 @@ function delegationLinkage(parentRunId: RunId = PARENT): DelegationLinkage { function makeState(id: RunId, overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id, runbook: { source: 'project', path: 'test.md' }, diff --git a/packages/core/__tests__/runbook/inline-propagation-guard.properties.test.ts b/packages/core/__tests__/runbook/inline-propagation-guard.properties.test.ts index 9139f195c..c7e7f1139 100644 --- a/packages/core/__tests__/runbook/inline-propagation-guard.properties.test.ts +++ b/packages/core/__tests__/runbook/inline-propagation-guard.properties.test.ts @@ -115,6 +115,7 @@ function makeState( parentEntry: 1, }; return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id, runbook: { source: 'project', path: 'test.md' }, diff --git a/packages/core/__tests__/runbook/lifecycle-command-service.test.ts b/packages/core/__tests__/runbook/lifecycle-command-service.test.ts index bd46bbcc1..e1e6644af 100644 --- a/packages/core/__tests__/runbook/lifecycle-command-service.test.ts +++ b/packages/core/__tests__/runbook/lifecycle-command-service.test.ts @@ -333,6 +333,7 @@ describe('RunbookLifecycleCommandService', () => { function baseState(overrides: Partial = {}): RunbookState { return { + prompted: false, id: runId, runbook: { source: 'project', path: 'lifecycle-test.md' }, runbookPath: 'lifecycle-test.md', @@ -5274,6 +5275,13 @@ describe('RunbookLifecycleCommandService', () => { }); expect(outcome.kind).toBe('applied'); + if (outcome.kind !== 'applied') return; + // The directive, not just the commit. `loop` is what tells the CLI to + // enter the next unit after this transition, and a substep drive that + // applied a completion has advanced the cursor — so standing down here + // would strand the run one step short with no diagnostic. The `none` arm + // is pinned by the idempotent-duplicate test below, which applies nothing. + expect(outcome.loop).toEqual({ kind: 'run' }); const persisted = await manager.load(namedRunId); expect(persisted?.step).toBe('2'); expect(persisted?.substep).toBeUndefined(); @@ -5858,7 +5866,7 @@ describe('RunbookLifecycleCommandService', () => { if (outcome.kind !== 'applied') return; expect(outcome.status).toBe('continue'); expect(outcome.terminalReleaseMode).toBe('stack-pop'); - expect(outcome.loop).toEqual({ kind: 'run', prompted: false }); + expect(outcome.loop).toEqual({ kind: 'run' }); expect(outcome.events.some((e) => e.type === 'STEP_TRANSITIONED')).toBe(true); expect(outcome.updatedState?.step).toBe('2'); expect(fenced).toHaveBeenCalledTimes(1); @@ -5907,7 +5915,7 @@ describe('RunbookLifecycleCommandService', () => { expect(outcome.kind).toBe('applied'); if (outcome.kind !== 'applied') return; expect(outcome.status).toBe('continue'); - expect(outcome.loop).toEqual({ kind: 'run', prompted: false }); + expect(outcome.loop).toEqual({ kind: 'run' }); expect(outcome.events.some((e) => e.type === 'STEP_TRANSITIONED')).toBe(true); expect(outcome.updatedState?.step).toBe('2'); @@ -6671,6 +6679,10 @@ describe('RunbookLifecycleCommandService', () => { // Idempotent: surfaced as already-resolved, not an advance. expect(outcome.duplicate?.at).toBe('1.1'); expect(outcome.updatedState).toBeUndefined(); + // And the loop stands down. This is the counterpart to the `run` arm on + // the applied path: a duplicate advanced nothing, so re-entering would + // announce a unit the run is not on. + expect(outcome.loop).toEqual({ kind: 'none' }); // No orphan row was written and the run did not move. const persisted = await manager.load(runId); diff --git a/packages/core/__tests__/runbook/manual-completion-cursor.test.ts b/packages/core/__tests__/runbook/manual-completion-cursor.test.ts index 4e87159e7..35aeea667 100644 --- a/packages/core/__tests__/runbook/manual-completion-cursor.test.ts +++ b/packages/core/__tests__/runbook/manual-completion-cursor.test.ts @@ -65,6 +65,7 @@ const promptedForSteps: readonly ResolvedStep[] = [ function makeState(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: brandRunIdForTest('rd_cccccccccccccccccccccccccccccccc'), runbook: { source: 'project', path: 'cursor-test.md' }, diff --git a/packages/core/__tests__/runbook/persisted-state-guards.test.ts b/packages/core/__tests__/runbook/persisted-state-guards.test.ts index cdd17439b..8442b29c5 100644 --- a/packages/core/__tests__/runbook/persisted-state-guards.test.ts +++ b/packages/core/__tests__/runbook/persisted-state-guards.test.ts @@ -32,6 +32,7 @@ const BASE_SCHEMA_STATE = { startedAt: '2026-04-19T00:00:00.000Z', updatedAt: '2026-04-19T00:00:00.000Z', templateVars: {}, + prompted: false, }; describe('RunbookStateSchema — schema version 1 and lifecycle fields', () => { @@ -48,6 +49,23 @@ describe('RunbookStateSchema — schema version 1 and lifecycle fields', () => { expect(result.success).toBe(false); }); + // Required on the same terms, and refused at the same seam. This is what + // extends the refusal past `RunbookStateManager.load` — which names the field + // explicitly — to the in-transaction reader `RunbookStore.readRun`, whose only + // structural gate is this parse. + it('rejects state without prompted — persistable state always carries it', () => { + const { prompted: _omit, ...withoutPrompted } = BASE_SCHEMA_STATE; + + const result = RunbookStateSchema.safeParse({ + ...withoutPrompted, + variables: {}, + lifecycle: 'running', + schemaVersion: 1, + }); + + expect(result.success).toBe(false); + }); + it('accepts state with schemaVersion 1 and lifecycle field', () => { const parsed = RunbookStateSchema.parse({ ...BASE_SCHEMA_STATE, @@ -217,6 +235,7 @@ describe('RunbookStateManager.load() — invalid state enforcement', () => { startedAt: '2026-04-19T00:00:00.000Z', updatedAt: '2026-04-19T00:00:00.000Z', templateVars: {}, + prompted: false, lifecycle: 'running', schemaVersion: 1, }; @@ -319,6 +338,25 @@ describe('RunbookStateManager.load() — invalid state enforcement', () => { await expect(load).rejects.toThrow(/prune/i); }); + it('rejects current-schema state missing prompted instead of defaulting it', async () => { + // A v1 row without `prompted` is incompatible state, not a run to guess a + // mode for. Defaulting it would decide whether the run announces its + // commands or executes them, which is the whole content of the field. + const id = 'rd_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'; + const { prompted: _omit, ...withoutPrompted } = VALID_V1_STATE; + await seedRawRunState(tmpDir, { + ...withoutPrompted, + id, + runbook: { source: 'project', path: 'x.md' }, + }); + + const load = manager.load(id); + + await expect(load).rejects.toBeInstanceOf(InvalidRunbookStateError); + await expect(load).rejects.toThrow(/missing prompted/); + await expect(load).rejects.toThrow(/prune/i); + }); + it('rejects state with future schemaVersion', async () => { const id = 'rd_ffffffffffffffffffffffffffffffff'; await seedRawRunState(tmpDir, { ...VALID_V1_STATE, id, schemaVersion: 2 }); diff --git a/packages/core/__tests__/runbook/re-entry-frontier.test.ts b/packages/core/__tests__/runbook/re-entry-frontier.test.ts index a39ef0338..d93ff75f3 100644 --- a/packages/core/__tests__/runbook/re-entry-frontier.test.ts +++ b/packages/core/__tests__/runbook/re-entry-frontier.test.ts @@ -12,10 +12,13 @@ import { } from '../../src/runbook/delegation-token.js'; import { assertClaimId, assertClaimLookupKey, assertRunId } from '../../src/runbook/index.js'; import { + prepareReEntryFrontierConsume, projectAndConsumeReEntryFrontier, readPersistedReEntryFrontier, + type PrepareReEntryFrontierActorService, type ReEntryFrontierActorService, } from '../../src/runbook/re-entry-frontier.js'; +import type { ExecutionUnitEntry } from '../../src/runbook/execution-unit-entry.js'; import { InvalidRunbookStateError } from '../../src/runbook/state.js'; import { buildFrameKey } from '../../src/runbook/targeting.js'; import type { @@ -23,10 +26,7 @@ import type { ResolvedStep, RunbookState, } from '../../src/runbook/types.js'; -import type { - ExecutionObservationEffect, - StepEntryMetadata, -} from '../../src/events/execution-observation.js'; +import type { ExecutionObservationEffect } from '../../src/events/execution-observation.js'; import { brandStoredOutputsForTest, brandInitialTemplateVarsForTest, @@ -109,6 +109,7 @@ const steps: readonly ResolvedStep[] = [ function state(overrides: Partial = {}): RunbookState { return { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: runId, runbook: { source: 'project', path: 're-entry-test.md' }, @@ -138,26 +139,20 @@ function stateWithFrontier(delegateFrontier: unknown): RunbookState { return state({ snapshot: { context: { delegateFrontier } } }); } -/** Rendered entry metadata for a substep — the only shape that can carry a frontier. */ -const substepEntry: Omit = { - stepId: '1', - substepId: '1', - position: { current: '1', total: 1, substep: '1' }, - stepName: '1', - description: 'Delegate A', - prompt: 'Dispatch the child', - isSubstep: true, - prompted: false, -}; - -/** The same rendering for a cursor that has advanced off the substeps. */ -const stepEntry: Omit = { - stepId: '1', - position: { current: '1', total: 1 }, - stepName: '1', - isSubstep: false, - prompted: false, -}; +/** + * A run whose cursor has advanced off the substeps. + * + * The seam derives "is this a substep" from the cursor itself now, so the + * non-substep case is a STATE, not a caller-supplied entry that says so. Both + * spellings reach the same arm: `substep: undefined` is off the substeps + * entirely, and a cursor naming no live substep resolves back to the parent step. + * + * @param delegateFrontier - Value to persist in the snapshot's `delegateFrontier`. + * @returns A run positioned on the step rather than on one of its substeps. + */ +function stepCursorWithFrontier(delegateFrontier: unknown): RunbookState { + return state({ substep: undefined, snapshot: { context: { delegateFrontier } } }); +} function observationEffect(stepName: string): ExecutionObservationEffect { return { @@ -189,6 +184,7 @@ function makeActorService( } = {}, ) { const observations = options.observations ?? [observationEffect('1')]; + const entered: ExecutionUnitEntry = { kind: 'awaiting', effects: observations }; const consumed = options.consumed === undefined ? ({ @@ -198,11 +194,11 @@ function makeActorService( } satisfies ActorSyncResult) : options.consumed; const calls: string[] = []; - const observeExecutionUnitEntry = jest - .fn() + const enterExecutionUnit = jest + .fn() .mockImplementation(async () => { - calls.push('observe'); - return observations; + calls.push('enter'); + return entered; }); const sendAndSync = jest .fn() @@ -210,10 +206,178 @@ function makeActorService( calls.push('sendAndSync'); return consumed; }); - const service: ReEntryFrontierActorService = { observeExecutionUnitEntry, sendAndSync }; - return { service, observeExecutionUnitEntry, sendAndSync, calls, observations, consumed }; + const service: ReEntryFrontierActorService = { enterExecutionUnit, sendAndSync }; + return { service, enterExecutionUnit, sendAndSync, calls, entered, observations, consumed }; +} + +/** + * Build a structural {@link PrepareReEntryFrontierActorService} double. + * + * Narrower than the unfenced one by design: the fenced twin DERIVES its consume + * and never commits, so a double that could commit would let a regression reach + * the store unnoticed. + * + * @param nextState - State the derived consume produces. + * @returns The double plus the spy the assertions read. + */ +function makePrepareActorService(nextState: RunbookState = state({ substep: '2' })) { + const prepareActorMutation = jest + .fn() + .mockImplementation(async (_id, previousState) => ({ + previousState, + nextState, + snapshot: { context: {} }, + effects: [], + })); + return { service: { prepareActorMutation }, prepareActorMutation, nextState }; } +describe('prepareReEntryFrontierConsume', () => { + /** A deriver that fails the test if the seam reaches it. */ + function neverDerives(): DelegationTokenDeriver { + return jest.fn().mockImplementation(() => { + throw new Error('deriveToken must not be called on the none arm'); + }); + } + + it('returns none when the run carries no persisted frontier', async () => { + const actor = makePrepareActorService(); + + await expect( + prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: state(), + deriveToken: neverDerives(), + }), + ).resolves.toEqual({ status: 'none' }); + expect(actor.prepareActorMutation).not.toHaveBeenCalled(); + }); + + it('returns none for an empty persisted frontier without deriving a consume', async () => { + const actor = makePrepareActorService(); + + await expect( + prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: stateWithFrontier([]), + deriveToken: neverDerives(), + }), + ).resolves.toEqual({ status: 'none' }); + expect(actor.prepareActorMutation).not.toHaveBeenCalled(); + }); + + it('returns none for a cursor off the substeps even when a frontier is persisted', async () => { + // A step-level execution unit can never carry a frontier, so the seam must + // not disclose one to it. The frontier stays persisted for the substep entry + // that can legitimately receive it. + const actor = makePrepareActorService(); + + await expect( + prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: stepCursorWithFrontier([frontierEntry().persisted]), + deriveToken: neverDerives(), + }), + ).resolves.toEqual({ status: 'none' }); + expect(actor.prepareActorMutation).not.toHaveBeenCalled(); + }); + + it('validates the persisted blob before the non-substep short-circuit', async () => { + // Read-then-gate, not gate-then-read, exactly as the unfenced twin does. + const actor = makePrepareActorService(); + + await expect( + prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: stepCursorWithFrontier('oops'), + deriveToken: neverDerives(), + }), + ).rejects.toBeInstanceOf(InvalidRunbookStateError); + expect(actor.prepareActorMutation).not.toHaveBeenCalled(); + }); + + it('projects the bearers and derives the consume against the captured state', async () => { + const entry = frontierEntry(); + const actor = makePrepareActorService(); + const captured = stateWithFrontier([entry.persisted]); + + const prepared = await prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: captured, + deriveToken, + }); + + expect(prepared).toEqual({ + status: 'projected', + nextState: actor.nextState, + frontier: [entry.public], + }); + // The EXACT captured state, and the consume event the machine retires the + // frontier with — derived, never committed. + const [id, previousState, forwardedSteps, event] = actor.prepareActorMutation.mock.calls[0]; + expect(id).toBe(runId); + expect(previousState).toBe(captured); + expect(forwardedSteps).toEqual(steps); + expect(event).toEqual({ type: 'DELEGATE_FRONTIER_CONSUMED' }); + }); + + it('preserves persisted frontier order in the projected bearers', async () => { + const first = frontierEntry('1.1', 'child-a.md', 'A'); + const second = frontierEntry('1.2', 'child-b.md', 'B'); + + const prepared = await prepareReEntryFrontierConsume({ + actorService: makePrepareActorService().service, + steps, + state: stateWithFrontier([first.persisted, second.persisted]), + deriveToken, + }); + + if (prepared.status !== 'projected') throw new Error('expected projected'); + expect(prepared.frontier).toEqual([first.public, second.public]); + }); + + it('refuses projection when the deriver is not the frontier issuer, without deriving a consume', async () => { + // The disclosure boundary, shared verbatim with the unfenced twin: refuse + // rather than prepare a consume that would retire bearers this authority + // cannot vouch for. + const actor = makePrepareActorService(); + + await expect( + prepareReEntryFrontierConsume({ + actorService: actor.service, + steps, + state: stateWithFrontier([frontierEntry().persisted]), + deriveToken: deriveForeignToken, + }), + ).resolves.toEqual({ + status: 'projection_refused', + message: 'Delegation credential belongs to a different issuer claim', + }); + expect(actor.prepareActorMutation).not.toHaveBeenCalled(); + }); + + it('never leaks a bearer through the refusal message', async () => { + const base = frontierEntry(); + + const prepared = await prepareReEntryFrontierConsume({ + actorService: makePrepareActorService().service, + steps, + state: stateWithFrontier([ + { ...base.persisted, tokenHash: assertDelegationTokenHash(`sha256:${'0'.repeat(64)}`) }, + ]), + deriveToken, + }); + + expect(prepared).toMatchObject({ status: 'projection_refused' }); + expect(JSON.stringify(prepared)).not.toMatch(/rdtk_/); + }); +}); + describe('readPersistedReEntryFrontier', () => { it('returns empty when the run carries no persisted snapshot', () => { // `state.snapshot` is `unknown` and may be absent entirely (a run that has @@ -435,10 +599,10 @@ describe('readPersistedReEntryFrontier', () => { }); describe('projectAndConsumeReEntryFrontier', () => { - /** First recorded `observeExecutionUnitEntry` call, as a typed tuple. */ - function observedCall(actor: ReturnType) { - expect(actor.observeExecutionUnitEntry).toHaveBeenCalledTimes(1); - return actor.observeExecutionUnitEntry.mock.calls[0]; + /** The single recorded `enterExecutionUnit` input. */ + function enteredWith(actor: ReturnType) { + expect(actor.enterExecutionUnit).toHaveBeenCalledTimes(1); + return actor.enterExecutionUnit.mock.calls[0][0]; } /** A deriver that fails the test if the seam reaches it. */ @@ -457,7 +621,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: state(), deriveToken: neverDerives(), - entry: substepEntry, }), ).resolves.toEqual({ status: 'none' }); expect(actor.calls).toEqual([]); @@ -474,7 +637,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([]), deriveToken: neverDerives(), - entry: substepEntry, }), ).resolves.toEqual({ status: 'none' }); expect(actor.calls).toEqual([]); @@ -490,9 +652,8 @@ describe('projectAndConsumeReEntryFrontier', () => { projectAndConsumeReEntryFrontier({ actorService: actor.service, steps, - state: stateWithFrontier([frontierEntry().persisted]), + state: stepCursorWithFrontier([frontierEntry().persisted]), deriveToken: neverDerives(), - entry: stepEntry, }), ).resolves.toEqual({ status: 'none' }); expect(actor.calls).toEqual([]); @@ -508,9 +669,8 @@ describe('projectAndConsumeReEntryFrontier', () => { projectAndConsumeReEntryFrontier({ actorService: actor.service, steps, - state: stateWithFrontier('oops'), + state: stepCursorWithFrontier('oops'), deriveToken: neverDerives(), - entry: stepEntry, }), ).rejects.toBeInstanceOf(InvalidRunbookStateError); expect(actor.calls).toEqual([]); @@ -525,12 +685,11 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([entry.persisted]), deriveToken, - entry: substepEntry, }); expect(result).toEqual({ status: 'projected', - observations: actor.observations, + entered: actor.entered, state: actor.consumed?.state, }); // The consumed state, NOT the input state: the caller continues from the @@ -539,29 +698,34 @@ describe('projectAndConsumeReEntryFrontier', () => { expect(result).toMatchObject({ status: 'projected', state: { substep: '2' } }); }); - it('observes the caller entry augmented with the projected bearers', async () => { - // The seam contributes `delegateFrontier` and nothing else — every other - // field is the frontend's rendering decision and must survive untouched. + it('enters the unit with the projected bearers and the COMMITTED state and caller steps', async () => { + // The seam contributes `delegateFrontier` and nothing else beyond routing the + // render through the state the commit just produced. Rendering against the + // pre-commit `target` would describe a run that does not yet exist in that + // shape — the same reasoning `finishCollection` documents for the fenced + // twin — so the entry must be derived from `consumed.state`, never `target`. const entry = frontierEntry(); const actor = makeActorService(); + const target = stateWithFrontier([entry.persisted]); await projectAndConsumeReEntryFrontier({ actorService: actor.service, steps, - state: stateWithFrontier([entry.persisted]), + state: target, deriveToken, - entry: substepEntry, }); // Asserted through `mock.calls` rather than `toHaveBeenCalledWith`: the // latter's tuple matcher recurses through `ResolvedStep` deeply enough to // trip TS2589 on this signature. - const [observedRunId, observedSteps, observedEntry] = observedCall(actor); - expect(observedRunId).toBe(runId); + const input = enteredWith(actor); + // The COMMITTED state, not the pre-commit capture: rendering must reflect + // the run as it exists after `DELEGATE_FRONTIER_CONSUMED` landed. + expect(input.state).toBe(actor.consumed?.state); // The seam forwards the caller's steps verbatim; an emptied copy would make - // the observation unresolvable against the machine. - expect(observedSteps).toEqual(steps); - expect(observedEntry).toEqual({ ...substepEntry, delegateFrontier: [entry.public] }); + // the entry unresolvable against the runbook. + expect(input.steps).toEqual(steps); + expect(input.delegateFrontier).toEqual([entry.public]); }); it('preserves persisted frontier order in the projected bearers', async () => { @@ -574,10 +738,9 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([first.persisted, second.persisted]), deriveToken, - entry: substepEntry, }); - expect(observedCall(actor)[2].delegateFrontier).toEqual([first.public, second.public]); + expect(enteredWith(actor).delegateFrontier).toEqual([first.public, second.public]); }); it('commits the consume with DELEGATE_FRONTIER_CONSUMED on the same run', async () => { @@ -588,7 +751,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([frontierEntry().persisted]), deriveToken, - entry: substepEntry, }); expect(actor.sendAndSync).toHaveBeenCalledTimes(1); @@ -598,9 +760,12 @@ describe('projectAndConsumeReEntryFrontier', () => { expect(consumeCall[2]).toEqual({ type: 'DELEGATE_FRONTIER_CONSUMED' }); }); - it('observes the entry before committing the consume', async () => { - // The documented ordering. Observing after the consume would leave a window - // where the frontier is consumed but the bearers were never surfaced. + it('commits the consume before rendering the entry', async () => { + // The documented ordering (RD-827 finding 1). Rendering can run arbitrary + // `--helpers` JS with real side effects; rendering before the commit would + // re-run those side effects on every retry that follows a failed commit, + // since the frontier stays persisted and the next attempt re-projects it. + // Committing first bounds the side effect to happen at most once per commit. const actor = makeActorService(); await projectAndConsumeReEntryFrontier({ @@ -608,10 +773,9 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([frontierEntry().persisted]), deriveToken, - entry: substepEntry, }); - expect(actor.calls).toEqual(['observe', 'sendAndSync']); + expect(actor.calls).toEqual(['sendAndSync', 'enter']); }); it('refuses projection when the deriver is not the frontier issuer', async () => { @@ -626,7 +790,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([frontierEntry().persisted]), deriveToken: deriveForeignToken, - entry: substepEntry, }), ).resolves.toEqual({ status: 'projection_refused', @@ -651,7 +814,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([tampered]), deriveToken, - entry: substepEntry, }), ).resolves.toEqual({ status: 'projection_refused', @@ -673,7 +835,6 @@ describe('projectAndConsumeReEntryFrontier', () => { { ...base.persisted, tokenHash: assertDelegationTokenHash(`sha256:${'0'.repeat(64)}`) }, ]), deriveToken, - entry: substepEntry, }); expect(result).toMatchObject({ status: 'projection_refused' }); @@ -694,15 +855,16 @@ describe('projectAndConsumeReEntryFrontier', () => { // eslint-disable-next-line @typescript-eslint/only-throw-error throw 'derivation exploded'; }, - entry: substepEntry, }), ).resolves.toEqual({ status: 'projection_refused', message: 'derivation exploded' }); expect(actor.calls).toEqual([]); }); - it('withholds observations when the consume is not accepted', async () => { - // The frontier is still persisted, so the next attempt re-projects it. - // Returning the observations here would orphan the bearers they carry. + it('never renders the entry when the consume is not accepted', async () => { + // The frontier is still persisted, so the next attempt re-projects it. The + // unit must never be entered on this arm: rendering can invoke non-idempotent + // `--helpers` JS, and a render whose commit never landed would re-run that + // side effect again on the retry that follows. const actor = makeActorService({ consumed: null }); const result = await projectAndConsumeReEntryFrontier({ @@ -710,15 +872,13 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([frontierEntry().persisted]), deriveToken, - entry: substepEntry, }); expect(result).toEqual({ status: 'consume_failed' }); - expect(result).not.toHaveProperty('observations'); + expect(result).not.toHaveProperty('entered'); expect(result).not.toHaveProperty('state'); - // The entry WAS observed — the arm is about what is returned, not about - // skipping the observation — so the ordering guarantee still holds. - expect(actor.calls).toEqual(['observe', 'sendAndSync']); + expect(actor.calls).toEqual(['sendAndSync']); + expect(actor.enterExecutionUnit).not.toHaveBeenCalled(); }); it('carries no bearer in the consume_failed result', async () => { @@ -729,7 +889,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([frontierEntry().persisted]), deriveToken, - entry: substepEntry, }); expect(JSON.stringify(result)).not.toMatch(/rdtk_/); @@ -744,7 +903,6 @@ describe('projectAndConsumeReEntryFrontier', () => { steps, state: stateWithFrontier([{ id: '1.1', runbook: 'child-a.md' }]), deriveToken, - entry: substepEntry, }), ).rejects.toBeInstanceOf(InvalidRunbookStateError); expect(actor.calls).toEqual([]); diff --git a/packages/core/__tests__/runbook/state.test.ts b/packages/core/__tests__/runbook/state.test.ts index 89b85422f..adaf04158 100644 --- a/packages/core/__tests__/runbook/state.test.ts +++ b/packages/core/__tests__/runbook/state.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { isError } from '../../src/errors.js'; +import { getErrorMessage, isError } from '../../src/errors.js'; import { applyRunbookStateUpdate, generateRunId, @@ -840,11 +840,17 @@ describe('RunbookStateManager', () => { expect(state.id).toMatch(/^rd_[a-f0-9]{32}$/); }); - it('defaults to auto mode (prompted undefined)', async () => { + it('defaults to auto mode, written as a definite false', async () => { + // Written, not left absent. `load` refuses a persisted row without + // `prompted`, so a run created without one would be unreadable the moment + // it is saved — and this default is what makes that refusal unreachable + // from the one production creation path. const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { runbookPath: 'test.md', }); - expect(state.prompted).toBeUndefined(); + + expect(state.prompted).toBe(false); + expect((await manager.load(state.id))?.prompted).toBe(false); }); it('accepts prompted option', async () => { @@ -1970,6 +1976,52 @@ describe('RunbookStateManager', () => { expect(await refusedDefect(id)).toEqual({ runId: id, reason: 'missing_template_vars' }); }); + // Refused, not defaulted. `prompted` decides whether a run announces its + // commands or executes them, and it is the value a composing parent + // inherits DOWN into a fresh inline child — so a `?? false` at the read site + // would silently adapt an incompatible row into an executing run. `create` + // always writes the field for the same reason `templateVars` is always + // written: a row that reached this refusal originates outside the one + // production creation path, which the no-migration rule treats as invalid + // regardless of which value it would have defaulted to. + it('names the run when prompted is missing', async () => { + const id = await seedThenPlant((state) => { + const { prompted: _omit, ...rest } = state; + return rest; + }); + + expect(await refusedDefect(id)).toEqual({ runId: id, reason: 'missing_prompted' }); + // The prose too, and in this suite rather than only in the guards suite: + // `reason` tells a consumer which refusal fired, and the message is the + // only place that names the field and what to do about it. Both halves, + // because they are separate string literals — the diagnosis and the + // recovery — and either can be emptied without the other noticing. + const refusal = await manager.load(id).then( + () => { + throw new Error('load resolved; expected a refusal'); + }, + (error: unknown) => error, + ); + expect(getErrorMessage(refusal)).toMatch(/missing prompted/); + expect(getErrorMessage(refusal)).toMatch(/Prune this run and re-run the runbook\./); + }); + + // Both spellings, because the field is a boolean and the guard must not + // reduce it to truthiness: a run created WITHOUT `--prompted` persists + // `false`, and a guard written as `if (!raw.prompted)` would refuse every + // one of them. + it.each([{ prompted: true }, { prompted: false }])( + 'loads a run persisting prompted: $prompted', + async ({ prompted }) => { + const state = await manager.create({ source: 'project', path: 'test.md' }, mockRunbook, { + runbookPath: 'test.md', + prompted, + }); + + expect((await manager.load(state.id))?.prompted).toBe(prompted); + }, + ); + it('names the run when the schema parse fails', async () => { // `artifactVars` is a removed field the schema refuses explicitly, so this // reaches the schema-parse arm rather than one of the named checks above. diff --git a/packages/core/__tests__/runbook/types.test.ts b/packages/core/__tests__/runbook/types.test.ts index 393c37f8c..4653eec40 100644 --- a/packages/core/__tests__/runbook/types.test.ts +++ b/packages/core/__tests__/runbook/types.test.ts @@ -112,6 +112,7 @@ describe('Substep interface', () => { describe('RunbookState runbookSrc field', () => { it('should include runbookSrc field', () => { const state: RunbookState = { + prompted: false, templateVars: brandInitialTemplateVarsForTest({}), id: brandRunIdForTest(`rd_${'e'.repeat(32)}`), runbook: { source: 'project', path: 'test.runbook.md' }, diff --git a/packages/core/__tests__/schemas.test.ts b/packages/core/__tests__/schemas.test.ts index 80fb55c76..e1cacce39 100644 --- a/packages/core/__tests__/schemas.test.ts +++ b/packages/core/__tests__/schemas.test.ts @@ -30,6 +30,7 @@ const createValidState = (overrides: Record = {}) => ({ retryCount: 0, variables: {}, templateVars: {}, + prompted: false, steps: [], startedAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', diff --git a/packages/core/src/errors/codes.ts b/packages/core/src/errors/codes.ts index b67790b04..80268388c 100644 --- a/packages/core/src/errors/codes.ts +++ b/packages/core/src/errors/codes.ts @@ -543,6 +543,22 @@ export const ErrorCodes = { `loop to advance and delegate then.`, docSlug: 'delegation-index-not-active', }, + DELEGATION_FRONTIER_DISCLOSURE_FAILED: { + code: 'RD-833', + category: ErrorCategory.DELEGATION, + title: 'Delegation frontier disclosure could not be rendered', + description: + `A collect committed its aggregate and consumed the persisted re-entry ` + + `frontier, and then could not render the STEP_ENTERED entry the freshly ` + + `derived bearers ride on. The collection LANDED: the outcomes are drained ` + + `and the frontier is gone, so retrying answers the idempotent no-op rather ` + + `than re-deriving the bearers. Distinct from RD-829, where the consume ` + + `never committed and a retry does recover. The usual cause is a ` + + `\`--helpers\` helper raising while expanding the unit's description, ` + + `prompt, or command. Fix the helper, then re-delegate the step: the ` + + `delegations the lost bearers addressed must be re-issued.`, + docSlug: 'delegation-frontier-disclosure-failed', + }, // Retry hook (9xx) — sub-range of ErrorCategory.EXECUTION reserved for // retry-hook lifecycle failures (delegation re-issuance, frame-key invariants, // canonical-at requirements). Kept as EXECUTION rather than a dedicated diff --git a/packages/core/src/errors/factory.ts b/packages/core/src/errors/factory.ts index 73cf2bbd4..355c4fff5 100644 --- a/packages/core/src/errors/factory.ts +++ b/packages/core/src/errors/factory.ts @@ -97,6 +97,22 @@ export const Errors = { concurrentStateModification: (runId: string, detail: string): RundownError => new RundownError('CONCURRENT_STATE_MODIFICATION', { runId, message: detail }), + // The one post-commit failure a collect can suffer. Spelled with the run id in + // context so an operator can name the run whose bearers were lost without + // parsing the message, and with the render failure's own text as `message` so + // the cause (usually a `--helpers` helper raising) is not swallowed by the + // envelope. + // Stryker disable next-line ArrowFunction: undetectable, not uncovered. Every + // member of this literal is evaluated at module load, so replacing this body + // with `() => undefined` is a STATIC mutant — jest's module registry has + // already cached `Errors` by the time the mutant is applied, and the mutated + // arrow is never the one the test calls. Verified by hand: editing the source + // to `() => undefined` DOES fail the RD-833 factory test. The two mutants on + // the same line that Stryker can observe (the code string and the context + // literal) are both killed by it. + frontierDisclosureFailed: (runId: string, detail: string): RundownError => + new RundownError('DELEGATION_FRONTIER_DISCLOSURE_FAILED', { runId, message: detail }), + // The recovery is spelled into the MESSAGE, not left to the code's // `description`, for the same reason `walJournalModeUnavailable` spells its // candidate causes there: the description reaches an operator only through diff --git a/packages/core/src/errors/rundown-error.ts b/packages/core/src/errors/rundown-error.ts index adad0a885..630ed0db1 100644 --- a/packages/core/src/errors/rundown-error.ts +++ b/packages/core/src/errors/rundown-error.ts @@ -51,6 +51,8 @@ export type InvalidRunStateReason = | 'invalid_schema_version' /** A current-schema row is missing the required `templateVars` field. */ | 'missing_template_vars' + /** A current-schema row is missing the required `prompted` field. */ + | 'missing_prompted' /** The row parsed as JSON but failed the `RunbookState` schema. */ | 'schema_validation_failed' /** The deprecated dynamic-step snapshot shape (`GOTO_NEXT` or `instance`). */ @@ -58,7 +60,17 @@ export type InvalidRunStateReason = /** The persisted snapshot's `delegateFrontier` is not a valid entry array. */ | 'malformed_delegate_frontier' /** A persisted `execution_attempts.reason` is not a recognized reason. */ - | 'unrecognized_recovery_reason'; + | 'unrecognized_recovery_reason' + /** The run carries no `ContextId` / `WorkPath` to render its frame against. */ + | 'missing_render_context' + /** The persisted `snapshot.value` is not a shape this build can read. */ + | 'unsupported_snapshot_state_value' + /** The persisted `snapshot.value` names a step the parsed runbook does not declare. */ + | 'snapshot_step_not_in_runbook' + /** The run's `step` cursor names a step the parsed runbook does not declare. */ + | 'cursor_step_not_in_runbook' + /** A current-schema row is missing the required `frontmatterOutputs` field. */ + | 'missing_frontmatter_outputs'; /** * Structured facts about one run's refused persisted state (RD-309). diff --git a/packages/core/src/events/execution-observation.ts b/packages/core/src/events/execution-observation.ts index d54c876a1..cdc6371ed 100644 --- a/packages/core/src/events/execution-observation.ts +++ b/packages/core/src/events/execution-observation.ts @@ -91,7 +91,18 @@ export interface StepEntryObservationInput { readonly artifactPathOptions: ArtifactPathOptions; } -/** Frontend-rendered metadata for the execution unit being entered. */ +/** + * Metadata for the execution unit being entered. + * + * Core-internal since #820: `deriveExecutionUnitEntry` is its only producer and + * {@link deriveStepEnteredEffect} its only consumer, so it is a local passed + * between two core functions rather than a parameter any caller supplies. It was + * a parameter of three exported seams before that, and having two builders fill + * it differently is the divergence #799 exists to close — a front end that can + * hand core an entry can hand it one that disagrees with the run. + * + * @internal + */ export interface StepEntryMetadata { /** Current parent step id. */ readonly stepId: string; @@ -105,6 +116,14 @@ export interface StepEntryMetadata { readonly description?: string; /** Rendered prompt text. */ readonly prompt?: string; + /** + * Whether the parsed execution unit declares a command. + * + * Derived from the unit, never from whether {@link commandCode} happens to be + * present: the two answered differently depending on which builder produced + * the entry, which made a payload flag an accident of the caller. + */ + readonly hasCommand: boolean; /** Rendered command code, when the execution unit has a command. */ readonly commandCode?: string; /** Command language info string. */ @@ -179,61 +198,23 @@ function extractSnapshotEnteredArtifacts( return {}; } -function snapshotSubstep(snapshot: unknown): string | undefined { - if ( - snapshot && - typeof snapshot === 'object' && - 'context' in snapshot && - snapshot.context && - typeof snapshot.context === 'object' - ) { - return (snapshot.context as { readonly substep?: unknown }).substep as string | undefined; - } - return undefined; -} - -function snapshotStep(snapshot: unknown): string | undefined { - if ( - snapshot && - typeof snapshot === 'object' && - 'context' in snapshot && - snapshot.context && - typeof snapshot.context === 'object' - ) { - return (snapshot.context as { readonly step?: unknown }).step as string | undefined; - } - return undefined; -} - /** - * Derive a STEP_ENTERED observation from a machine snapshot and rendered entry metadata. + * Derive a STEP_ENTERED observation from a machine snapshot and entry metadata. + * + * Two cursor-mismatch guards used to live here, refusing an entry whose + * `stepId` / `substepId` disagreed with the snapshot. They existed because the + * entry was a PARAMETER — a caller could hand this function metadata describing + * some other cursor. Since #820 the entry has one producer, + * `deriveExecutionUnitEntry`, which reads both the cursor and the snapshot off + * the same `RunbookState`. The bug class the guards caught is unrepresentable, + * so they are gone rather than kept as unreachable code. * - * @param input - Snapshot plus rendered execution-unit metadata. + * @param input - Snapshot plus execution-unit metadata. * @returns Non-persisted STEP_ENTERED observation effect. - * @throws {Error} When the STEP_ENTERED entry step does not match the snapshot step. - * @throws {Error} When the STEP_ENTERED entry substep does not match the snapshot substep. */ export function deriveStepEnteredEffect( input: StepEntryObservationInput, ): ExecutionObservationEffect { - const step = snapshotStep(input.snapshot); - if (step !== input.entry.stepId) { - throw new Error( - `Cannot observe STEP_ENTERED for step ${input.entry.stepId} while machine snapshot is at ${ - step ?? '' - }`, - ); - } - if (input.entry.substepId !== undefined) { - const substep = snapshotSubstep(input.snapshot); - if (substep !== input.entry.substepId) { - throw new Error( - `Cannot observe STEP_ENTERED for substep ${input.entry.substepId} while machine snapshot is at ${ - substep ?? '' - }`, - ); - } - } return { kind: 'execution_observation', event: { @@ -243,7 +224,7 @@ export function deriveStepEnteredEffect( stepName: input.entry.stepName, description: input.entry.description, prompt: input.entry.prompt, - hasCommand: input.entry.commandCode !== undefined, + hasCommand: input.entry.hasCommand, commandCode: input.entry.commandCode, commandLang: input.entry.commandLang, isSubstep: input.entry.isSubstep, diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index a412fc5ca..ccd1e023d 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -1,5 +1,28 @@ export type * from './types.js'; export * from './emitter.js'; export * from './transition-observation.js'; -export * from './execution-observation.js'; +// Named exports, not a wildcard. `execution-observation.ts` also declares the entry +// seam's internals — `StepEntryMetadata`, `StepEntryObservationInput`, and +// `deriveStepEnteredEffect` — and a wildcard put all three on +// `@rundown-org/core` without any file naming them. That matters because #820 +// deleted the deriver's two cursor-mismatch guards on the grounds that the entry +// has exactly ONE producer, `deriveExecutionUnitEntry`, which reads the cursor +// and the snapshot off the same `RunbookState`. A front end that could reach the +// deriver with a hand-built entry is precisely the bug class those guards +// caught. `packages/core/__tests__/events/entry-seam-barrel.test.ts` and its +// `.typecheck.ts` sibling pin both halves of this list. +export { + commandCompletedEffect, + commandStartedEffect, + createExecutionEffectCollector, + policyDeniedEffect, + projectDelegateFrontier, +} from './execution-observation.js'; +export type { + CommandStartedObservationInput, + ExecutionEffectCollector, + ExecutionObservationEffect, + ExecutionObservationEvent, + MachineExecutionObserver, +} from './execution-observation.js'; export * from './subscribers/index.js'; diff --git a/packages/core/src/runbook/actor-service.ts b/packages/core/src/runbook/actor-service.ts index c8ee2c781..e61a3d970 100644 --- a/packages/core/src/runbook/actor-service.ts +++ b/packages/core/src/runbook/actor-service.ts @@ -26,11 +26,7 @@ import type { CommandExecutionOutput, CommandExecutionServices, } from './actors/command-exec-actor.js'; -import type { - InlineLaunchIntentWithoutParentEntry, - ResolveInlineRunbook, -} from './actors/inline-launch-intent-actor.js'; -import { isInlineLaunchIntentWithoutParentEntry } from './actors/inline-launch-intent-actor.js'; +import type { ResolveInlineRunbook } from './actors/inline-launch-intent-actor.js'; import type { ResolveDelegationRunbook } from './delegation-inference.js'; import type { DelegationCredentialIssuer } from './delegation-credential.js'; import { @@ -64,29 +60,23 @@ import { import type { RecoveryActor } from './execution-recovery-service.js'; import { flattenTemplateVars } from './output-evaluator.js'; import { merge, replace, type ResolvedCompletionsOp } from './state-update-ops.js'; -import { - deriveActiveFrame, - deriveOpenFrames, - frameKeyForCursor, - type FrameKey, -} from './targeting.js'; +import { deriveActiveFrame, frameKeyForCursor } from './targeting.js'; import { inferFrameEntryFromState, type FrameEntryCoordinates } from './frame-entry.js'; -import { rebrandContextSnapshotArtifacts } from './delegation-context.js'; +import { InvalidRunbookStateError } from './persisted-state-guards.js'; import { resolvedStepHasSubsteps } from '@rundown-org/parser'; import { logger } from '../logger.js'; -import { WORK_DIR } from '../paths.js'; import { isArtifactRecord } from './artifact-schema.js'; import { isForResolutionFailureCode } from './actors/for-iterate-actor.js'; import { commandCompletedEffect, commandStartedEffect, createExecutionEffectCollector, - deriveStepEnteredEffect, policyDeniedEffect, type ExecutionObservationEffect, type MachineExecutionObserver, - type StepEntryMetadata, } from '../events/execution-observation.js'; +import { deriveExecutionUnitEntry, type ExecutionUnitEntry } from './execution-unit-entry.js'; +import type { DelegateFrontierEntry } from '../events/types.js'; import type { StepPosition } from '../events/types.js'; /** @@ -97,25 +87,6 @@ import type { StepPosition } from '../events/types.js'; */ export type { AnyActorRef } from 'xstate'; -function shouldProjectInlineLaunchIntent( - state: RunbookState, - entry: StepEntryMetadata, - intent: InlineLaunchIntentWithoutParentEntry, -): boolean { - if (state.id !== intent.parentRunId) return false; - if (state.step !== intent.parentStep) return false; - if (entry.stepId !== intent.parentStep) return false; - if (state.substep !== intent.parentStepId) return false; - if (entry.substepId !== intent.parentStepId) return false; - - // Project the one-shot intent only when its authored frame is still live (the - // active frame or an open FOR context). Openness flows from `deriveOpenFrames` - // (forStack) — never from the monotonic entry counter, whose keys persist after - // a loop advances and would otherwise re-project a stale prior-iteration intent - // onto the current frame. - return deriveOpenFrames(state).has(intent.parentFrameKey as FrameKey); -} - /** * Result of a {@link RunbookActorService.sendAndSync} operation. * @@ -200,6 +171,40 @@ export type PreparedManualDelegationMutation = } | Exclude; +/** Inputs to {@link RunbookActorService.enterExecutionUnit}. */ +export interface EnterExecutionUnitInput { + /** + * Run whose cursor names the unit being entered. + * + * Taken as a value rather than looked up by id, so the entry is derived + * against the EXACT state the caller decided on — including a prepared state + * a fenced caller has not committed yet. + */ + readonly state: RunbookState; + /** Parsed steps for that run. */ + readonly steps: readonly ResolvedStep[]; + /** + * Reconstructed delegation bearers to disclose with this entry. + * + * Supplied only by the re-entry frontier seam, which verifies each token + * against its persisted hash before handing it here. + * + * Explicitly `| undefined` rather than merely optional: an absent frontier and + * one passed as `undefined` are the same fact, and under + * `exactOptionalPropertyTypes` the distinction would otherwise force every + * forwarding call site into a conditional spread that says nothing. + */ + readonly delegateFrontier?: readonly DelegateFrontierEntry[] | undefined; + /** + * Caller-precomputed position, forwarded verbatim to + * {@link deriveExecutionUnitEntry} instead of letting it re-derive one. + * + * Optional: a caller with no position already in scope leaves this seam to + * derive it, exactly as before. + */ + readonly position?: StepPosition | undefined; +} + /** Runtime dependencies for {@link RunbookActorService}. */ export interface RunbookActorServiceOptions { /** Resolve authored child runbook references for machine-owned delegation issuance. */ @@ -884,22 +889,42 @@ export class RunbookActorService { }; } + /** + * Refuse a persisted snapshot this build cannot read. + * + * Every refusal here is one run's corrupt persisted state, and every message + * already spells RD-309's remediation — "Prune invalid runbook state and + * restart execution". They therefore raise {@link InvalidRunbookStateError} + * rather than a bare `Error`: the class is what routes them onto the CLI's + * finish/stop/prune envelope instead of RD-999 "Unknown error", and what lets + * a caller distinguish "this run is unusable" from "the operation failed". + * `finishCollection` reads exactly that distinction to decide whether a + * committed collect reports RD-309 or RD-833. + * + * @param id - Run whose snapshot is being read. + * @param snapshot - The persisted snapshot envelope. + * @param steps - Parsed steps the snapshot's cursor must name. + * @throws {InvalidRunbookStateError} When the snapshot value is an + * unreadable shape, a transient parent-entry state, unparseable, or names a + * step the runbook does not declare. + */ private assertFreshSnapshotValue( id: string, snapshot: PersistedRunbookSnapshot, steps: readonly ResolvedStep[], ): void { - if (isPendingMachineEffectSnapshotValue(snapshot.value)) { - throw new Error( + const unsupportedShape = (): InvalidRunbookStateError => + new InvalidRunbookStateError( `Unsupported snapshot.value shape for runbook "${id}": ${JSON.stringify(snapshot.value)}`, + { runId: id, reason: 'unsupported_snapshot_state_value' }, ); + if (isPendingMachineEffectSnapshotValue(snapshot.value)) { + throw unsupportedShape(); } const stateValue = stateValueAsString(snapshot.value); if (stateValue === null) { - throw new Error( - `Unsupported snapshot.value shape for runbook "${id}": ${JSON.stringify(snapshot.value)}`, - ); + throw unsupportedShape(); } if (stateValue === 'COMPLETE' || stateValue === 'STOPPED') return; if (stateValue === RECOVERY_REQUIRED_STATE_NAME) return; @@ -913,24 +938,27 @@ export class RunbookActorService { // wrong substep, wrong recovery path. Bail with a clear diagnostic // before the regex runs. if (stateValue.includes('::__parent-entry::')) { - throw new Error( + throw new InvalidRunbookStateError( `Persisted stateValue "${stateValue}" for runbook "${id}" is a transient parent-entry state. ` + 'Prune invalid runbook state and restart execution.', + { runId: id, reason: 'unsupported_snapshot_state_value' }, ); } const parsed = parseStepStateValue(stateValue); if (!parsed) { - throw new Error( + throw new InvalidRunbookStateError( `Unsupported persisted stateValue "${stateValue}" for runbook "${id}". ` + 'Prune invalid runbook state and restart execution.', + { runId: id, reason: 'unsupported_snapshot_state_value' }, ); } const stepName = parsed.stepName; if (!steps.find((s) => s.name === stepName)) { - throw new Error( + throw new InvalidRunbookStateError( `Persisted stateValue "${stateValue}" for runbook "${id}" references missing step "${stepName}". ` + 'Prune invalid runbook state and restart execution.', + { runId: id, reason: 'snapshot_step_not_in_runbook' }, ); } } @@ -949,7 +977,8 @@ export class RunbookActorService { * @param executionObserver - Optional non-persisted observer for command actor output * @param runtime - Optional verified runtime capabilities for machine-owned actors * @returns Compiled XState machine seeded with all hydration-time context - * @throws {Error} If `state.frontmatterOutputs` is undefined (invalid state) + * @throws {InvalidRunbookStateError} If `state.frontmatterOutputs` is + * undefined — one run's corrupt persisted state, not an operation failure */ private compileMachineFromState( id: string, @@ -959,9 +988,10 @@ export class RunbookActorService { runtime?: RunbookActorRuntimeCapabilities, ): ReturnType { if (state.frontmatterOutputs === undefined) { - throw new Error( + throw new InvalidRunbookStateError( `Invalid runbook state for "${id}": missing frontmatter outputs declarations. ` + 'Run `rundown prune` and restart execution.', + { runId: id, reason: 'missing_frontmatter_outputs' }, ); } return compileRunbookToMachine(steps, { @@ -1628,75 +1658,57 @@ export class RunbookActorService { } /** - * Project STEP_ENTERED observation effects from persisted machine state. + * Enter the execution unit the run's cursor names. * - * This method is read-only: it hydrates a snapshot without starting an actor, - * derives observation payloads, and does not persist. + * The single seam for entering a unit: it renders the unit's description, + * prompt and command against the run's own frame, observes the entry, and + * classifies what the caller must do next. Read-only — it hydrates nothing, + * starts no actor, and persists nothing. * - * @param id - Runbook state ID - * @param steps - Parsed runbook steps - * @param entry - Frontend-supplied rendered execution-unit metadata - * @returns Non-persisted STEP_ENTERED effects, or an empty array when state is missing - * @throws {Error} When persisted state is invalid or incompatible, or when the entry step - * or substep metadata does not match the machine snapshot cursor. + * Two dependencies are bound here rather than passed by the caller, because + * both are process-scoped and neither is the caller's to choose: the + * canonicalised project directory (`manager.cwd`) and the runtime helper + * registry (`options.helpers`), which is the DI seam the CLI already fills + * through `createCliRunbookActorService`. + * + * @param input - Run state, parsed steps, and any verified frontier bearers. + * @returns The classified entry: `awaiting`, `runnable`, or `inline-launch`. + * @throws {Error} When the persisted snapshot names a state the compiled + * machine does not have, when the run's `frontmatterOutputs` are missing, + * when the cursor names a step the runbook does not define, or when a + * `--helpers` helper throws while expanding a field. + * @throws {InvalidRunbookStateError} When the run carries no `ContextId` or + * `WorkPath` to render its frame against. */ - async observeExecutionUnitEntry( - id: string, - steps: readonly ResolvedStep[], - entry: StepEntryMetadata, - ): Promise { - const state = await this.manager.load(id); - if (!state) return []; + // The `async` IS the contract here, not a leftover. The rule's premise is that + // an async function with no `await` could have been synchronous; this one + // could not. Dropping the keyword makes the three refusals below throw in the + // caller's own tick instead of rejecting the promise the signature returns, so + // a caller using `.catch(...)` or `Promise.all` observes no failure at all. + // eslint-disable-next-line @typescript-eslint/require-await -- see above: async is the contract + async enterExecutionUnit(input: EnterExecutionUnitInput): Promise { + const { state, steps } = input; if (state.snapshot) { - this.assertFreshSnapshotValue(id, state.snapshot as PersistedRunbookSnapshot, steps); + this.assertFreshSnapshotValue(state.id, state.snapshot as PersistedRunbookSnapshot, steps); } - this.compileMachineFromState(id, state, steps); - const snapshot = - state.snapshot && typeof state.snapshot === 'object' - ? { - ...(state.snapshot as Record), - context: { - ...((state.snapshot as { readonly context?: Record }).context ?? {}), - step: state.step, - substepStates: - state.substepStates ?? - (state.snapshot as { readonly context?: Record }).context - ?.substepStates, - substep: - state.substep ?? - (state.snapshot as { readonly context?: Record }).context?.substep, - }, - } - : { context: { step: state.step, substep: state.substep } }; - const inlineLaunchIntent = (snapshot.context as Partial).inlineLaunchIntent; - const intent = isInlineLaunchIntentWithoutParentEntry(inlineLaunchIntent) - ? { - ...inlineLaunchIntent, - contextSnapshot: rebrandContextSnapshotArtifacts(inlineLaunchIntent.contextSnapshot), - } - : undefined; - const observedEntry = - intent !== undefined && shouldProjectInlineLaunchIntent(state, entry, intent) - ? { - ...entry, - inlineLaunch: { - ...intent, - parentEntry: inferFrameEntryFromState(state, intent.parentFrameKey as FrameKey), - }, - } - : entry; - const workPath = - typeof state.templateVars.WorkPath === 'string' ? state.templateVars.WorkPath : WORK_DIR; - return [ - deriveStepEnteredEffect({ - snapshot, - entry: observedEntry, - artifactPathOptions: { - cwd: this.manager.cwd, - workPath, - }, - }), - ]; + // Compiled for its refusals alone — a run whose frontmatter OUTPUTS are + // missing cannot be entered — which is why the machine is discarded. + this.compileMachineFromState(state.id, state, steps); + // `async` though every line of the body is synchronous today: this is a + // service seam whose siblings are all async, and callers must not come to + // depend on it settling — or FAILING — in the same tick. Without the + // keyword the three refusals above and below throw in the caller's own + // tick, so a caller that attaches `.catch(...)` to the returned promise, or + // collects the call in `Promise.all`, never observes them at all. `await` + // callers are unaffected either way. + return deriveExecutionUnitEntry({ + state, + steps, + delegateFrontier: input.delegateFrontier, + cwd: this.manager.cwd, + helpers: this.options.helpers, + position: input.position, + }); } /** diff --git a/packages/core/src/runbook/collection-service.ts b/packages/core/src/runbook/collection-service.ts index 7708861a6..617fa86f1 100644 --- a/packages/core/src/runbook/collection-service.ts +++ b/packages/core/src/runbook/collection-service.ts @@ -19,6 +19,7 @@ import { resolveMutationAuthority, type CommandTargetReader } from './command-ta import type { AppliedResolvedCompletion } from './completion-service.js'; import type { RunbookCompletionService } from './completion-service.js'; import { isPostDelegateAggregationCursor } from './delegation-inference.js'; +import { findStepOrThrow } from './execution-units.js'; import type { ExecutionLifecycleService } from './execution-lifecycle-service.js'; import type { RunbookStateManager } from './state.js'; import { @@ -26,9 +27,8 @@ import { type PreparedReEntryProjection, } from './re-entry-frontier.js'; import type { Frame, FrameKey } from './targeting.js'; -import { buildStepPosition, completionTargetsFrame, findSubstepState } from './targeting.js'; +import { completionTargetsFrame, findSubstepState } from './targeting.js'; import { deriveActiveCompletionFrame } from './frame-entry.js'; -import { countNumberedSteps } from './step-utils.js'; import type { ClaimSeenRecordResult, ReleaseRunbookResult } from './session-service.js'; import type { SessionMutationResult } from './storage/runbook-store.js'; import type { ResolvedStep, RunbookState, RunId } from './types.js'; @@ -37,9 +37,11 @@ import { type DelegationRuntimeCapabilities, } from './delegation-credential.js'; import { ErrorCodes } from '../errors/codes.js'; +import { Errors } from '../errors/factory.js'; import { getErrorMessage } from '../errors.js'; +import { InvalidRunbookStateError } from './persisted-state-guards.js'; import { logger } from '../logger.js'; -import type { StepEntryMetadata } from '../events/execution-observation.js'; +import type { DelegateFrontierEntry } from '../events/types.js'; import { deriveTransitionObservation, type TransitionObservationEvent, @@ -199,12 +201,6 @@ function delegateSubstepIds(step: ResolvedStep | undefined): readonly string[] { return step.substeps.filter((substep) => substep.delegate).map((substep) => substep.id); } -function findStepOrThrow(steps: readonly ResolvedStep[], stepName: string): ResolvedStep { - const step = steps.find((candidate) => candidate.name === stepName); - if (!step) throw new Error(`Step "${stepName}" not found`); - return step; -} - // Set of delegate substep ids that have a LIVE resolved-completion row in the // target frame. This is the authoritative 'outcome available to collect' // signal; `substepState.status` is only a mirror and can go stale across a @@ -430,7 +426,7 @@ function deriveCollectionTransitionObservations( applied: readonly AppliedResolvedCompletion[], ): readonly TransitionObservationEvent[] { return applied.flatMap((entry) => { - const currentStep = findStepOrThrow(input.steps, entry.stateBefore.step); + const currentStep = findStepOrThrow(input.steps, entry.stateBefore.step, entry.stateBefore.id); return deriveTransitionObservation({ steps: input.steps, currentStep, @@ -448,9 +444,10 @@ function deriveCollectionTransitionObservations( * The seam itself lives in `re-entry-frontier.ts` and shares its disclosure * boundary verbatim with the CLI execution loop (F6): both entry points reach * the same persisted data under the same conditions, so both classify it with - * the same arms and report each arm under the same code. All this wrapper - * contributes is the rendered entry metadata for the collect cursor and the - * verified deriver. + * the same arms and report each arm under the same code. Since #820 this wrapper + * contributes only the verified deriver — the entry the disclosure rides on is + * rendered by core when the commit lands, from the same state the seam decided + * against. * * @param input - Collection operation input (services + target + steps). * @param advanced - Prepared post-drain state whose snapshot carries the frontier. @@ -463,53 +460,11 @@ async function prepareCollectReEntryFrontier( advanced: RunbookState, delegationRuntime: DelegationRuntimeCapabilities, ): Promise { - const position = buildStepPosition( - advanced.step, - countNumberedSteps(input.steps), - advanced.substep, - advanced.forStack, - ); - const substep = advanced.substep; - // A cursor that has advanced off the substeps cannot carry a frontier, and the - // seam short-circuits on `isSubstep: false` without observing. Spelled as two - // complete literals rather than one with `??` fallbacks so neither variant - // carries a field it could never have. - // Equivalent mutants on the non-substep arm below: the seam short-circuits to - // `status: 'none'` on `isSubstep: false` and never observes that entry, so - // every field of it EXCEPT `isSubstep` is unobservable — and collapsing the - // whole literal to `{}` leaves `isSubstep` undefined, which is falsy, so it - // reaches the same arm. `isSubstep: false` itself stays mutated: flipping it to - // `true` IS killed, by the "treats a present frontier with an undefined cursor - // substep as no re-entry" test. The arm is spelled out rather than - // short-circuited here so the malformed-snapshot guard inside the seam still - // runs for an off-substep cursor. - const entry = - substep === undefined - ? // Stryker disable ObjectLiteral,StringLiteral: equivalent — this entry is never observed - { - stepId: advanced.step, - position, - stepName: advanced.step, - isSubstep: false, - // Stryker disable next-line BooleanLiteral: equivalent — never observed (see above) - prompted: !!advanced.prompted, - } - : // Stryker restore ObjectLiteral,StringLiteral - { - stepId: advanced.step, - substepId: substep, - position, - stepName: substep, - isSubstep: true, - prompted: !!advanced.prompted, - }; - return await prepareReEntryFrontierConsume({ actorService: input.actorService, steps: input.steps, state: advanced, deriveToken: delegationRuntime.deriveDelegationToken, - entry, }); } @@ -558,7 +513,7 @@ interface PreparedCollection { /** Whether the prepared parent write was a FRESH upward report. */ readonly reportedTerminalOutcome: boolean; /** Post-commit frontier disclosure, withheld until the commit lands. */ - readonly frontierEntry?: StepEntryMetadata; + readonly frontierDisclosure?: readonly DelegateFrontierEntry[]; /** The collection outcome to return once the commit succeeds. */ readonly value: DelegationPolicyOutcome; /** Whether the prepared target state is terminal (drives release + upward walk). */ @@ -883,7 +838,13 @@ async function prepareCollection( return { target: reentry.status === 'projected' ? reentry.nextState : drained.state, reportedTerminalOutcome: false, - ...(reentry.status === 'projected' ? { frontierEntry: reentry.entry } : {}), + // Stryker disable next-line ConditionalExpression: equivalent — forcing the + // condition true spreads `frontierDisclosure: reentry.frontier`, and on + // every other arm `reentry` carries no `frontier`, so the key lands as + // `undefined`. `finishCollection` gates on `!== undefined`, so present-and- + // undefined and absent reach the same branch. The spread exists only to + // satisfy `exactOptionalPropertyTypes`, not to change behaviour. + ...(reentry.status === 'projected' ? { frontierDisclosure: reentry.frontier } : {}), value: { kind: 'collection_applied', targetRunId, @@ -1019,8 +980,13 @@ function prepareTerminalCollection( * @param input - Collection operation input (services + target + steps). * @param prepared - The prepared collection whose commit has landed. * @returns The final collection outcome with post-commit data folded in. - * @throws {unknown} The observation failure, unchanged, when a committed - * collection cannot render its re-entry disclosure (see above). + * @throws {InvalidRunbookStateError} When the committed target cannot describe + * itself well enough to render its re-entry entry — corrupt persisted state, + * whose recovery (finish/stop/prune) the CLI's RD-309 arm already spells. + * @throws {RundownError} `DELEGATION_FRONTIER_DISCLOSURE_FAILED` (RD-833) for + * any other render failure — typically a `--helpers` helper raising. Typed + * rather than bare because this is a new way for a collect to fail: it used + * to emit a thinner event that needed no rendering. */ async function finishCollection( input: CollectDelegationOutcomesOperationInput, @@ -1029,26 +995,45 @@ async function finishCollection( const value = prepared.value; if (value.kind !== 'collection_applied') return value; - if (prepared.frontierEntry !== undefined && prepared.target !== undefined) { + // Stryker disable next-line ConditionalExpression: equivalent on the second + // term — `frontierDisclosure` is set only on `prepareCollection`'s projected + // arm, which sets `target` in the same literal, so the first term already + // implies the second. It is spelled out because `target` is optional on + // `PreparedCollection` and TypeScript narrows on the check, not on the + // invariant. + if (prepared.frontierDisclosure !== undefined && prepared.target !== undefined) { try { - const observations = await input.actorService.observeExecutionUnitEntry( - prepared.target.id, - [...input.steps], - prepared.frontierEntry, - ); - return { ...value, reEntryObservations: observations }; - } catch (observationError) { - // Attribute, then RE-THROW unchanged. The rejection is the outcome; this - // log exists because the rejection alone cannot say that the collection - // COMMITTED — which is the fact an operator needs and the only fact the - // error's own message will not carry. Re-throwing the original preserves - // its class, so the CLI's `InvalidRunbookStateError` → finish/stop/prune - // mapping still fires for a corrupt persisted snapshot. - void logger.error('collection committed but its re-entry disclosure could not be observed', { + // The SAME seam `rundown run` enters through, so the `STEP_ENTERED` a + // collect emits carries every rendered field a run's does. The state is + // the one this transaction just committed, which is why the entry cannot + // be derived before the commit: it would describe a run that does not yet + // exist in that shape, and would disclose bearers a refused commit never + // consumed. + const entered = await input.actorService.enterExecutionUnit({ + state: prepared.target, + steps: input.steps, + delegateFrontier: prepared.frontierDisclosure, + }); + return { ...value, reEntryObservations: entered.effects }; + } catch (renderError) { + // Attribute, then reject. The rejection is the outcome; this log exists + // because the rejection alone cannot say that the collection COMMITTED — + // the fact an operator needs and the only one the error's own message + // will not carry. + void logger.error('collection committed but its re-entry disclosure could not be rendered', { runId: prepared.target.id, - error: getErrorMessage(observationError), + error: getErrorMessage(renderError), }); - throw observationError; + // A corrupt persisted snapshot keeps its own class, so the CLI's RD-309 + // mapping still fires and still prints finish/stop/prune — the right + // recovery for a run that cannot describe itself. Everything else is a + // render failure, and since #820 that is a NEW way for a collect to fail: + // it used to emit a thinner event that needed no rendering at all. It gets + // a code of its own rather than escaping bare as RD-999 "Unknown error", + // because the two recoveries differ — RD-833's is "fix the helper and + // re-delegate", and a retry cannot recover the bearers. + if (renderError instanceof InvalidRunbookStateError) throw renderError; + throw Errors.frontierDisclosureFailed(prepared.target.id, getErrorMessage(renderError)); } } diff --git a/packages/core/src/runbook/completion-service.ts b/packages/core/src/runbook/completion-service.ts index 4045567ab..f5c13054a 100644 --- a/packages/core/src/runbook/completion-service.ts +++ b/packages/core/src/runbook/completion-service.ts @@ -8,6 +8,7 @@ import { type RunbookStateManager, type RunbookStateUpdate, } from './state.js'; +import { findStepOrThrow } from './execution-units.js'; import { deriveActiveCompletionFrame } from './frame-entry.js'; import { activeFrame, @@ -421,12 +422,6 @@ export type PreparedResolvedCompletionDrain = readonly applied: readonly []; }; -function findStepOrThrow(steps: readonly ResolvedStep[], stepName: string): ResolvedStep { - const step = steps.find((candidate) => candidate.name === stepName); - if (!step) throw new Error(`Step "${stepName}" not found`); - return step; -} - /** * Map a state's lifecycle to the terminal status that reports it, if terminal. * @@ -806,7 +801,7 @@ function selectNextResolvedCompletionApply( steps: readonly ResolvedStep[], frameOverride?: Frame, ): ResolvedCompletionApplySelection { - const currentStep = findStepOrThrow(steps, state.step); + const currentStep = findStepOrThrow(steps, state.step, state.id); if (!resolvedStepHasSubsteps(currentStep) || !state.substep) { return { kind: 'none', unresolved: 0 }; } diff --git a/packages/core/src/runbook/execution-unit-entry.ts b/packages/core/src/runbook/execution-unit-entry.ts new file mode 100644 index 000000000..45214447d --- /dev/null +++ b/packages/core/src/runbook/execution-unit-entry.ts @@ -0,0 +1,472 @@ +/** + * Entering one execution unit: render it, observe the entry, and classify what + * the caller must do next. + * + * The CLI execution loop used to own all three. It merged effective variables, + * built the step frame, chose which expander applied to which field, packed a + * `StepEntryMetadata`, and then read its own rendered command back out to decide + * whether to run anything — an `undefined` rendered command was the loop's + * control-flow signal for "nothing to run". Rendering precedence is a + * language-level concern the spec owns, so it belongs behind the machine + * (#799); `undefined`-as-signal is a missing type. + * + * Both are answered here. {@link deriveExecutionUnitEntry} renders against the + * run's own frame and returns {@link ExecutionUnitEntry} — `awaiting`, + * `runnable`, or `inline-launch`. The command travels only on the `runnable` + * arm, inside a {@link RenderedUnitCommand} whose brand is mintable only in this + * module, so there is no path from the result back to unexpanded text. + * + * Pure: no filesystem, no persistence, no actor. `RunbookActorService` + * .`enterExecutionUnit` is the seam callers reach, binding this function's two + * process-scoped dependencies (the canonicalised project directory and the + * runtime helper registry) and running the persisted-snapshot guards first. + * + * @module runbook/execution-unit-entry + */ + +import type { DelegateFrontierEntry, InlineLaunchIntent } from '../events/types.js'; +import { + deriveStepEnteredEffect, + type ExecutionObservationEffect, + type StepEntryMetadata, +} from '../events/execution-observation.js'; +import { InvalidRunbookStateError } from './persisted-state-guards.js'; +import { BUILTIN_VARIABLES } from './variable-preparation.js'; +import { buildStepPosition, deriveOpenFrames, type FrameKey } from './targeting.js'; +import { countNumberedSteps } from './step-utils.js'; +import { extractDisplayCommand } from '../cli/command-utils.js'; +import { findStepOrThrow, resolveCurrentExecutionUnit } from './execution-units.js'; +import { mergeEffectiveVars } from './effective-vars.js'; +import { buildStepVariables } from './runtime-frame.js'; +import { + expandLoopVariables, + expandLoopVariablesForCommand, + type TemplateRenderContext, + type TemplateRenderOptions, +} from './template-renderer.js'; +import { + isInlineLaunchIntentWithoutParentEntry, + type InlineLaunchIntentWithoutParentEntry, +} from './actors/inline-launch-intent-actor.js'; +import { rebrandContextSnapshotArtifacts } from './delegation-context.js'; +import { inferFrameEntryFromState } from './frame-entry.js'; +import type { TemplateHelperRegistry } from './helper-invoke.js'; +import type { ResolvedStep, RunbookState } from './types.js'; +import type { RunId } from './run-id.js'; +import type { StepPosition } from '../cli/types.js'; + +/** + * Module-private nominal brand on {@link RenderedUnitCommand}. + * + * Tier 1 of the two-tier doctrine at `effective-vars.ts` — a `declare const` + * `unique symbol`, purely type-level, produced only by + * {@link deriveExecutionUnitEntry}. Tier 1 is what this value needs: the branded + * record is consumed by typed functions and never round-trips through JSON. + * `EXECUTE_COMMAND` targets `__execute-command`, whose `invoke.input` reads the + * event and builds the actor input; there is no `assign`, so a rendered command + * never reaches persisted context and can never re-enter unbranded. + * + * The brand is on the RECORD, not on the command string. `string & {brand}` + * would foreclose a later upgrade to tier 2, because a primitive cannot carry a + * runtime symbol. + * + * It witnesses PROVENANCE, not cardinality: it proves the text came from the + * blessed expander, and cannot express "expanded exactly once" — two calls to + * this function mint two valid values. Single entry per unit is pinned by the + * call-count assertions on the seam, not by the type. + */ +declare const renderedUnitCommandBrand: unique symbol; + +/** + * A command rendered for one execution-unit entry, announced and executed as + * one value. + * + * The announced `STEP_ENTERED.commandCode` and the string handed to + * `EXECUTE_COMMAND` are the SAME expansion, which is the property that matters: + * helpers loaded through `--helpers` are arbitrary synchronous JavaScript, so a + * non-deterministic one expanded twice would make the command a runbook + * announces differ from the command it runs. Returning both from one value + * delivers that by construction rather than by statement ordering. + * + * (The rationale this replaces — "artifact-producing helpers append a manifest + * row per call, so a second expansion would duplicate the entries" — was wrong. + * `expandLoopVariablesForCommand` is synchronous and reduces to `substituteText`, + * which imports neither `fs` nor the manifest module, and the manifest append is + * idempotent by identity in any case.) + */ +export interface RenderedUnitCommand { + /** Phantom brand; never present at runtime. */ + readonly [renderedUnitCommandBrand]: true; + /** Fully expanded command text, shell-escaped at every substitution. */ + readonly code: string; + /** Display-safe projection of {@link code} for observations. */ + readonly displayCommand: string; + /** Rundown-injected environment for the child process (`RD_*`). */ + readonly rdInjected: Readonly>; +} + +/** Fields every {@link ExecutionUnitEntry} arm carries. */ +interface ExecutionUnitEntryBase { + /** + * Entry observations to emit, in order. + * + * Exactly one `STEP_ENTERED` today. Modelled as the effect list rather than a + * single event so the arm shape does not have to change when the entry grows + * a second observation. + */ + readonly effects: readonly ExecutionObservationEffect[]; +} + +/** The unit was entered and there is nothing for this process to run. */ +export interface ExecutionUnitAwaiting extends ExecutionUnitEntryBase { + /** Discriminant. */ + readonly kind: 'awaiting'; +} + +/** The unit was entered and carries a command this process must execute. */ +export interface ExecutionUnitRunnable extends ExecutionUnitEntryBase { + /** Discriminant. */ + readonly kind: 'runnable'; + /** The one expansion, announced in {@link ExecutionUnitEntryBase.effects} and executed by the caller. */ + readonly command: RenderedUnitCommand; +} + +/** The unit was entered and composes a child runbook inline. */ +export interface ExecutionUnitInlineLaunch extends ExecutionUnitEntryBase { + /** Discriminant. */ + readonly kind: 'inline-launch'; + /** One-shot intent the machine prepared for this frame. */ + readonly launch: InlineLaunchIntent; +} + +/** + * What entering one execution unit leaves the caller to do. + * + * Exhaustive by construction, which is the point: it replaces the loop's use of + * an undefined rendered-command field as a control-flow signal. `awaiting` is + * the arm for a prompted run, a prompted-FOR step, and a unit with no command + * at all — three conditions the caller previously spelled out itself and can no + * longer get wrong. + */ +export type ExecutionUnitEntry = + | ExecutionUnitAwaiting + | ExecutionUnitRunnable + | ExecutionUnitInlineLaunch; + +/** Inputs to {@link deriveExecutionUnitEntry}. */ +export interface DeriveExecutionUnitEntryInput { + /** Run whose cursor names the unit being entered. */ + readonly state: RunbookState; + /** Parsed steps for that run. */ + readonly steps: readonly ResolvedStep[]; + /** + * Reconstructed delegation bearers disclosed with this entry. + * + * Supplied only by the re-entry frontier seam, which verifies each token + * against its persisted hash before handing it here. Absent on every ordinary + * entry, and `| undefined` because absent and explicitly-undefined are the + * same fact to this function. + */ + readonly delegateFrontier?: readonly DelegateFrontierEntry[] | undefined; + /** Canonicalised project directory used as the helper containment boundary. */ + readonly cwd: string; + /** Runtime template helpers loaded for this process, if any were declared. */ + readonly helpers?: TemplateHelperRegistry | undefined; + /** + * Caller-precomputed position, used verbatim instead of deriving one. + * + * `buildStepPosition` needs nothing this function does not already have + * (`state.step`, `countNumberedSteps(steps)`, `state.substep`, + * `state.forStack`), so a caller that already computed the identical value + * for its own purposes — the CLI execution loop derives one for its + * independent error-reporting events on the same iteration — hands it in + * rather than paying for the same full-array `countNumberedSteps` scan twice. + * `| undefined` because omitted and explicitly-undefined are the same fact. + */ + readonly position?: StepPosition | undefined; +} + +/** + * Build the helper render context for a run from its own effective variables. + * + * Moved here from the CLI (`helpers/render-context.ts`) with the rest of the + * rendering: helper containment is a language-level concern, and a front end + * assembling it was the same inversion #799 names. + * + * The CLI's copy survives for `getRunbookFromState`, whose reload path is not + * part of entering a unit. The two differ only in the error class, and merging + * them means deciding whether `rundown stop` should treat a run with no + * `WorkPath` as unusable — a question about `stop`, not about rendering. Its + * docstring records the same, so neither side can be collapsed into the other by + * accident. + * + * @param runId - Run whose frame is being rendered. + * @param cwd - Canonicalised project directory used as the containment boundary. + * @param vars - Effective variables for the run. + * @returns Runnable render context for helper invocation. + * @throws {InvalidRunbookStateError} When the run's variables carry no string + * `ContextId` or `WorkPath`. Both are written at run creation, so their + * absence is corrupt persisted state, and per the no-migration rule the + * recovery path is explicit user action (finish, stop, prune, restart). + */ +function buildRunnableRenderContext( + runId: RunId, + cwd: string, + vars: Readonly>, +): Extract { + const read = (name: (typeof BUILTIN_VARIABLES)['ContextId' | 'WorkPath']): string => { + const value = vars[name]; + if (typeof value !== 'string') { + throw new InvalidRunbookStateError( + `Runbook state ${runId} is missing ${name}. Delete state and re-run the runbook.`, + { runId, reason: 'missing_render_context' }, + ); + } + return value; + }; + return { + kind: 'runnable', + cwd, + workPath: read(BUILTIN_VARIABLES.WorkPath), + contextId: read(BUILTIN_VARIABLES.ContextId), + runId, + }; +} + +/** + * Rundown-injected environment for a command subprocess. + * + * `RD_WORK_PATH` / `RD_CONTEXT_ID` are read off the render context rather than + * off the rendered frame, which is both simpler and stricter. The frame is + * `effectiveVars` plus the loop's own bindings, so a `FOR WorkPath IN …` step + * shadows the run's work directory there — and the CLI's version of this + * function, which read the frame, then guarded each value with a + * `typeof === 'string'` check and silently dropped it. The context is validated + * once, cannot be shadowed, and is the same value the entry's artifact paths are + * projected against, so the env and the payload name one work directory. + * + * The `RD_*` names are the published subprocess contract and are spelled + * literally. + * + * @param state - Run the command belongs to. + * @param context - Validated render context for the run's frame. + * @returns Environment overlay merged into the child process. + */ +function buildRdInjectedEnv( + state: RunbookState, + context: Extract, +): Readonly> { + return { + RD_WORK_PATH: context.workPath, + RD_CONTEXT_ID: context.contextId, + RD_RUN_ID: state.id, + RD_RUNBOOK_REF: state.runbook.path, + RD_RUNBOOK_SOURCE: state.runbook.source, + }; +} + +/** + * Whether a persisted one-shot inline-launch intent belongs to THIS entry. + * + * Moved here from `actor-service.ts` with the rest of the entry projection, so + * the classification and the payload field it turns on are derived in one place. + * + * @param state - Run being entered. + * @param entry - Entry metadata for the unit the cursor names. + * @param intent - Persisted intent read from the run's snapshot context. + * @returns True when the intent names this run, step, substep, and a live frame. + */ +function shouldProjectInlineLaunchIntent( + state: RunbookState, + entry: StepEntryMetadata, + intent: InlineLaunchIntentWithoutParentEntry, +): boolean { + if (state.id !== intent.parentRunId) return false; + if (state.step !== intent.parentStep) return false; + // The RESOLVED substep, not the raw cursor. Two checks used to stand here — + // one on `state.substep` and one on the entry — and the entry's subsumes the + // cursor's: they agree whenever the cursor names a live substep, and where + // they differ the entry carries `undefined`, which rejects any real intent. + // A cursor naming no live substep is not the substep an intent addresses. + if (entry.substepId !== intent.parentStepId) return false; + + // Project the one-shot intent only when its authored frame is still live (the + // active frame or an open FOR context). Openness flows from `deriveOpenFrames` + // (forStack) — never from the monotonic entry counter, whose keys persist after + // a loop advances and would otherwise re-project a stale prior-iteration intent + // onto the current frame. + return deriveOpenFrames(state).has(intent.parentFrameKey as FrameKey); +} + +/** + * Attach the run's live inline-launch intent to an entry, when it has one. + * + * @param state - Run being entered. + * @param context - The run's persisted machine context. + * @param entry - Entry metadata for the unit the cursor names. + * @returns The entry, carrying `inlineLaunch` when a live intent names it. + */ +function withInlineLaunchIntent( + state: RunbookState, + context: Readonly>, + entry: StepEntryMetadata, +): StepEntryMetadata { + const persisted = context.inlineLaunchIntent; + if (!isInlineLaunchIntentWithoutParentEntry(persisted)) return entry; + const intent: InlineLaunchIntentWithoutParentEntry = { + ...persisted, + contextSnapshot: rebrandContextSnapshotArtifacts(persisted.contextSnapshot), + }; + if (!shouldProjectInlineLaunchIntent(state, entry, intent)) return entry; + return { + ...entry, + inlineLaunch: { + ...intent, + parentEntry: inferFrameEntryFromState(state, intent.parentFrameKey as FrameKey), + }, + }; +} + +/** + * The persisted machine context a run carries, if it has synced one. + * + * `RunbookState.snapshot` is `unknown` and may be absent entirely (a run that has + * never synced), so every read of it goes through here. + * + * This used to OVERLAY the run's committed cursor onto that context, because + * `deriveStepEnteredEffect` validated the entry's `stepId` / `substepId` against + * it. Those guards are gone (#820) — the entry has one producer, which reads the + * cursor off the same state — and with them the only fields the overlay existed + * to supply. What remains reads two keys, both of which are the machine's own. + * + * @param state - Run being entered. + * @returns The persisted context, or an empty one. + */ +function persistedContext(state: RunbookState): Record { + // One guard, on the value actually used. An outer `typeof state.snapshot` + // check would be unreachable: the snapshot is JSON, so anything that is not an + // object has no `context` to read, and the optional chain already answers that. + const context = (state.snapshot as { readonly context?: unknown } | undefined)?.context; + return context !== null && typeof context === 'object' + ? (context as Record) + : {}; +} + +/** + * Render, observe, and classify the entry into the run's current execution unit. + * + * The single producer of {@link StepEntryMetadata}: every field of the + * `STEP_ENTERED` payload is derived here from the run's own state and steps, so + * two entry points into the same unit can no longer disagree about what it + * carries. + * + * `prompted` composes the run's persisted flag with the step kind, because a + * prompted-FOR step is prompted whatever the run was started as, and the + * `awaiting` classification turns on the same composed term. + * + * @param input - Run state, steps, render dependencies, and any frontier bearers. + * @returns The classified entry. + * @throws {InvalidRunbookStateError} When the run carries no `ContextId` or + * `WorkPath` to render against. + * @throws {Error} When the run's cursor names a step the parsed runbook does not + * define, or when a `--helpers` helper throws while expanding a field. + */ +export function deriveExecutionUnitEntry(input: DeriveExecutionUnitEntryInput): ExecutionUnitEntry { + const { state, steps } = input; + const currentStep = findStepOrThrow(steps, state.step, state.id); + const unit = resolveCurrentExecutionUnit(currentStep, state.substep); + + const effectiveVars = mergeEffectiveVars(state); + const stepVars = buildStepVariables({ + stepId: state.step, + substepId: state.substep, + forStack: state.forStack, + // Structural, for the same reason the command lookup below is: `forClause` is + // declared on `ResolvedStepWithFor` and nowhere else, so keying on the step + // kind would be a second spelling of the same fact with a dead arm. + forClause: 'forClause' in currentStep ? currentStep.forClause : undefined, + templateVars: effectiveVars, + }); + const renderOptions = { + helpers: input.helpers, + context: buildRunnableRenderContext(state.id, input.cwd, effectiveVars), + } satisfies TemplateRenderOptions; + + // A prompted-FOR substep carries no prompt of its own; the step-level prompt + // is the reconstructed FOR text, and it is what the operator needs to see. + const rawPrompt = + unit.prompt ?? (currentStep.kind === 'prompted-for' ? currentStep.prompt : undefined); + // One structural check covers both tiers: `command` is declared on `Substep` + // and on `StepWithCommand` and nowhere else, so a substeps / FOR / prompted-FOR + // step can never carry one and needs no separate arm. (The parser's own + // `hasCommand` guard says the same thing but is typed over `Step`, which a + // `ResolvedStep` is not.) + const unitCommand = 'command' in unit ? unit.command : undefined; + const code = + unitCommand === undefined + ? undefined + : expandLoopVariablesForCommand(unitCommand.code, stepVars, renderOptions); + + const entry: StepEntryMetadata = { + stepId: state.step, + // Off the RESOLVED unit, not off the raw cursor. `substepId` and + // `isSubstep` answer one question — is the unit being entered a substep? — + // so a cursor naming no live substep must not yield a populated `substepId` + // beside `isSubstep: false`. `position` still reports the raw cursor, + // because position describes where the run IS rather than what it entered. + substepId: 'id' in unit ? unit.id : undefined, + position: + input.position ?? + buildStepPosition(state.step, countNumberedSteps(steps), state.substep, state.forStack), + stepName: 'id' in unit ? unit.id : unit.name, + description: expandLoopVariables(unit.description, stepVars, renderOptions), + prompt: + rawPrompt === undefined ? undefined : expandLoopVariables(rawPrompt, stepVars, renderOptions), + hasCommand: unitCommand !== undefined, + commandCode: code, + commandLang: unitCommand?.lang, + isSubstep: 'id' in unit, + // A prompted-FOR step is prompted whatever the run was started as, and the + // `awaiting` classification below turns on this same composed term. + prompted: state.prompted || currentStep.kind === 'prompted-for', + delegateFrontier: input.delegateFrontier, + }; + + const context = persistedContext(state); + const observed = withInlineLaunchIntent(state, context, entry); + const effects = [ + deriveStepEnteredEffect({ + snapshot: context, + entry: observed, + // The SAME `workPath` the helper context renders against. It used to fall + // back to `WORK_DIR` here while the render context refused without it, so + // an entry could project artifact paths under one root and expand helper + // paths against another. One read, one refusal, one root. + artifactPathOptions: { cwd: input.cwd, workPath: renderOptions.context.workPath }, + }), + ]; + + const launch = observed.inlineLaunch; + if (launch !== undefined) { + return { kind: 'inline-launch', effects, launch }; + } + + // The three conditions that leave this process nothing to run, collapsed into + // one arm: a prompted run, a prompted-FOR step, and a unit declaring no + // command. `hasCommand` comes off the parsed unit rather than off whether an + // expansion produced text, so a command that renders to the empty string is + // still runnable. + if (observed.prompted || code === undefined) { + return { kind: 'awaiting', effects }; + } + + return { + kind: 'runnable', + effects, + command: { + code, + displayCommand: extractDisplayCommand(code) || code, + rdInjected: buildRdInjectedEnv(state, renderOptions.context), + } as RenderedUnitCommand, + }; +} diff --git a/packages/core/src/runbook/execution-units.ts b/packages/core/src/runbook/execution-units.ts index fdd36d7fb..142ea1f04 100644 --- a/packages/core/src/runbook/execution-units.ts +++ b/packages/core/src/runbook/execution-units.ts @@ -1,7 +1,45 @@ import { resolvedStepHasSubsteps } from '@rundown-org/parser'; import type { OutputDeclaration } from '@rundown-org/parser'; +import { InvalidRunbookStateError } from './persisted-state-guards.js'; import type { ResolvedStep, Substep } from './types.js'; +/** + * Look up a step by name, refusing a cursor the parsed runbook does not define. + * + * A run's `step` column and its compiled steps are written together, so a miss + * means the two have diverged — a corrupt cursor, or steps parsed from a + * different document than the one the run was started against. Every caller + * that resolves an execution unit needs the same refusal, so it lives beside + * {@link resolveCurrentExecutionUnit} rather than being re-declared per module. + * + * The class is the contract, not the message. `rundown collect` wraps any + * non-`InvalidRunbookStateError` rejection out of the entry seam as RD-833, + * whose recovery is "fix the helper and re-delegate" — the wrong instruction for + * a diverged cursor, which is corrupt persisted state and recoverable only by + * prune or restart. Raising the typed refusal is what routes it onto the CLI's + * existing RD-309 finish/stop/prune path instead. + * + * @param steps - Parsed steps for the run. + * @param stepName - Step name from the run's cursor. + * @param runId - The run whose cursor is being resolved, for the RD-309 defect. + * @returns The matching step. + * @throws {InvalidRunbookStateError} When no step carries that name. + */ +export function findStepOrThrow( + steps: readonly ResolvedStep[], + stepName: string, + runId: string, +): ResolvedStep { + const step = steps.find((candidate) => candidate.name === stepName); + if (!step) { + throw new InvalidRunbookStateError(`Step "${stepName}" not found`, { + runId, + reason: 'cursor_step_not_in_runbook', + }); + } + return step; +} + /** * Resolve the currently executing unit for the active runbook cursor. * diff --git a/packages/core/src/runbook/index.ts b/packages/core/src/runbook/index.ts index 919968c72..d663a53f0 100644 --- a/packages/core/src/runbook/index.ts +++ b/packages/core/src/runbook/index.ts @@ -75,7 +75,33 @@ export { type ExecutionEpoch, type GuardedMutationResult, } from './storage/mutation-result.js'; -export { extractUnitOutputs, resolveCurrentExecutionUnit } from './execution-units.js'; +export { + extractUnitOutputs, + findStepOrThrow, + resolveCurrentExecutionUnit, +} from './execution-units.js'; +// The renderer behind the entry seam. `RunbookActorService.enterExecutionUnit` +// is the one production door — it binds the process-scoped dependencies and runs +// the persisted-snapshot guards first — so the bare function is banned from every +// front end's `src/**` by an ESLint no-restricted-imports boundary. It is exported +// for core's own tests and for front-end test doubles, which stand in for the +// service and must not re-implement its rendering. +export { deriveExecutionUnitEntry } from './execution-unit-entry.js'; +// `RenderedUnitCommand` is deliberately absent. Its brand is a module-private +// `declare const` unique symbol, so the only way to produce one outside its +// module is a type assertion — and an assertion needs the type's NAME in scope. +// ESLint bans the assertion syntaxes; withholding the name from the barrel is +// what makes them unwritable outside core in the first place, closing the alias +// and namespace-qualified spellings no selector can enumerate. Nothing outside +// core needs it: the value travels on `ExecutionUnitRunnable.command`, which +// callers destructure rather than annotate. +export type { + DeriveExecutionUnitEntryInput, + ExecutionUnitAwaiting, + ExecutionUnitEntry, + ExecutionUnitInlineLaunch, + ExecutionUnitRunnable, +} from './execution-unit-entry.js'; export { buildContextVars, buildStepVariables, @@ -428,6 +454,7 @@ export { RunbookActorService, type ActorSyncResult, type AnyActorRef, + type EnterExecutionUnitInput, type PreparedDelegationChildLink, type PreparedDelegationChildUnlink, type PrepareDelegationChildLinkResult, diff --git a/packages/core/src/runbook/lifecycle-command-service.ts b/packages/core/src/runbook/lifecycle-command-service.ts index 5ab7277d6..587c5addb 100644 --- a/packages/core/src/runbook/lifecycle-command-service.ts +++ b/packages/core/src/runbook/lifecycle-command-service.ts @@ -639,10 +639,12 @@ export type LifecycleTerminalReleaseMode = 'release-runbook' | 'stack-pop'; * seam applied a transition. The loop spawns command-step subprocesses, which is * inherently a CLI side effect (Category A); the seam decides whether it should * run and the frontend runs it. + * + * Whether that loop runs prompted is deliberately NOT carried here: the flag is + * persisted on the run, the loop reads it there, and a directive field would only + * let this seam and the run disagree about it. */ -export type LifecycleLoopDirective = - | { readonly kind: 'none' } - | { readonly kind: 'run'; readonly prompted: boolean }; +export type LifecycleLoopDirective = { readonly kind: 'none' } | { readonly kind: 'run' }; /** * Refusal: the caller named a claim-shaped target without presenting that @@ -3853,10 +3855,7 @@ export class RunbookLifecycleCommandService { }; } - const loop: LifecycleLoopDirective = - drained.applied > 0 - ? { kind: 'run', prompted: Boolean(drained.observedState.prompted) } - : { kind: 'none' }; + const loop: LifecycleLoopDirective = drained.applied > 0 ? { kind: 'run' } : { kind: 'none' }; return { kind: 'applied', runId, @@ -3987,7 +3986,7 @@ export class RunbookLifecycleCommandService { terminalReleaseMode, status: 'continue', events: reconciled.events, - loop: { kind: 'run', prompted: Boolean(updatedState.prompted) }, + loop: { kind: 'run' }, updatedState, }; } @@ -4159,7 +4158,7 @@ export class RunbookLifecycleCommandService { // execution unit the parent never left — re-announcing the step and // re-running any command it carries. if (this.#hasUnconsumedInlineLaunchIntent(parentState, childRunId)) { - return { kind: 'run', prompted: Boolean(parentState.prompted) }; + return { kind: 'run' }; } // The finished-launch arm, and the only one that activates. Conditional in diff --git a/packages/core/src/runbook/re-entry-frontier.ts b/packages/core/src/runbook/re-entry-frontier.ts index f8d060de1..201a50d00 100644 --- a/packages/core/src/runbook/re-entry-frontier.ts +++ b/packages/core/src/runbook/re-entry-frontier.ts @@ -1,12 +1,11 @@ import { getErrorMessage } from '../errors.js'; -import { - projectDelegateFrontier, - type ExecutionObservationEffect, - type StepEntryMetadata, -} from '../events/execution-observation.js'; +import { projectDelegateFrontier } from '../events/execution-observation.js'; +import type { DelegateFrontierEntry } from '../events/types.js'; import { PersistedDelegateFrontierEntrySchema } from '../schemas.js'; import type { RunbookActorService } from './actor-service.js'; import type { DelegationTokenDeriver } from './delegation-credential.js'; +import { findStepOrThrow, resolveCurrentExecutionUnit } from './execution-units.js'; +import type { ExecutionUnitEntry } from './execution-unit-entry.js'; import { InvalidRunbookStateError } from './state.js'; import type { PersistedDelegateFrontierEntry, ResolvedStep, RunbookState } from './types.js'; @@ -27,16 +26,25 @@ import type { PersistedDelegateFrontierEntry, ResolvedStep, RunbookState } from * not hash to the persisted verifier. Not retryable — the same authority * refuses identically. * - `consume_failed` — projection succeeded but the machine did not accept the - * consume, so the frontier is still persisted. Retryable, and no observations - * are returned: their tokens would be orphaned by the next attempt. + * consume, so the frontier is still persisted. Retryable, and the unit is + * never entered: no observations are returned, no bearer tokens would be + * orphaned by the next attempt, and no render side effect runs for a commit + * that never landed. */ export type ReEntryProjection = | { readonly status: 'none' } | { /** The frontier projected, was observed, and the consume committed. */ readonly status: 'projected'; - /** Entry observations carrying the reconstructed delegation bearers. */ - readonly observations: readonly ExecutionObservationEffect[]; + /** + * The classified entry carrying the reconstructed delegation bearers. + * + * The whole entry rather than its observations alone: the caller needs the + * same `awaiting` / `runnable` / `inline-launch` classification here that it + * gets from an ordinary entry, and re-deriving it outside the seam would be + * a second renderer. + */ + readonly entered: ExecutionUnitEntry; /** Committed state after `DELEGATE_FRONTIER_CONSUMED`. */ readonly state: RunbookState; } @@ -59,7 +67,7 @@ export type ReEntryProjection = */ export type ReEntryFrontierActorService = Pick< RunbookActorService, - 'observeExecutionUnitEntry' | 'sendAndSync' + 'enterExecutionUnit' | 'sendAndSync' >; /** @@ -89,12 +97,13 @@ export type PreparedReEntryProjection = /** Prepared state after `DELEGATE_FRONTIER_CONSUMED`, for the owned commit. */ readonly nextState: RunbookState; /** - * Entry metadata carrying the reconstructed bearers, to be observed ONLY - * after the commit lands. Held as data rather than as rendered - * observations because observation reads committed state — deriving it - * here would disclose bearers a refused commit never consumed. + * The reconstructed bearers, to be disclosed ONLY after the commit lands. + * + * Held as data rather than as a rendered entry because entering the unit + * reads committed state — doing it here would disclose bearers a refused + * commit never consumed. */ - readonly entry: StepEntryMetadata; + readonly frontier: readonly DelegateFrontierEntry[]; } | { /** A disclosure boundary refused the projection. */ @@ -116,8 +125,6 @@ export interface PrepareReEntryFrontierConsumeInput { readonly state: RunbookState; /** Verified same-issuer deriver, bound to the exact issuing claim. */ readonly deriveToken: DelegationTokenDeriver; - /** Frontend-rendered entry metadata for the execution unit being re-entered. */ - readonly entry: Omit; } /** @@ -136,8 +143,10 @@ export interface PrepareReEntryFrontierConsumeInput { * surrounding work does not. Here the caller cannot observe until its ONE commit * has landed, so a refused transaction discloses nothing and consumes nothing. * - * @param input - Actor service, steps, captured state, verified deriver, and entry metadata. + * @param input - Actor service, steps, captured state, and verified deriver. * @returns The prepared re-entry outcome. + * @throws {Error} When the run's cursor names a step the parsed runbook does not + * define. * @throws {InvalidRunbookStateError} When the persisted snapshot carries a * `delegateFrontier` that is not an array of structurally valid entries. Per * the no-migration rule this is corrupt/incompatible persisted state, and the @@ -149,7 +158,7 @@ export async function prepareReEntryFrontierConsume( input: PrepareReEntryFrontierConsumeInput, ): Promise { const persistedFrontier = readPersistedReEntryFrontier(input.state); - if (persistedFrontier.length === 0 || !input.entry.isSubstep) { + if (persistedFrontier.length === 0 || !cursorIsOnSubstep(input.state, input.steps)) { return { status: 'none' }; } @@ -166,11 +175,7 @@ export async function prepareReEntryFrontierConsume( input.steps, { type: 'DELEGATE_FRONTIER_CONSUMED' }, ); - return { - status: 'projected', - nextState: mutation.nextState, - entry: { ...input.entry, delegateFrontier: frontier }, - }; + return { status: 'projected', nextState: mutation.nextState, frontier }; } /** Inputs for {@link projectAndConsumeReEntryFrontier}. */ @@ -189,13 +194,27 @@ export interface ProjectAndConsumeReEntryFrontierInput { * issuing claim — never persisted, never read from context. */ readonly deriveToken: DelegationTokenDeriver; - /** - * Frontend-rendered entry metadata for the execution unit being (re-)entered. - * The seam supplies `delegateFrontier`; everything else is the caller's - * rendering decision. A non-substep entry can never carry a frontier, so - * `isSubstep: false` short-circuits to `none` without observing. - */ - readonly entry: Omit; +} + +/** + * Whether the unit a run's cursor names is a substep. + * + * The one fact the seam used to read off a caller-supplied entry, and the + * complete reason that parameter existed. Deriving it from the state the seam + * already holds removes the last route by which a caller could hand the seam an + * entry that disagrees with the run: a non-substep unit can never carry a + * frontier, so getting this wrong would gate credential disclosure on someone + * else's rendering decision. + * + * @param state - Run whose cursor is being resolved. + * @param steps - Parsed steps for that run. + * @returns True when the cursor resolves to a live substep. + * @throws {Error} When the cursor names a step the runbook does not define. + */ +function cursorIsOnSubstep(state: RunbookState, steps: readonly ResolvedStep[]): boolean { + return ( + 'id' in resolveCurrentExecutionUnit(findStepOrThrow(steps, state.step, state.id), state.substep) + ); } /** @@ -258,15 +277,23 @@ export function readPersistedReEntryFrontier( * `rundown collect` (via `collectDelegationOutcomes`) and `rundown run` (via the * CLI execution loop) both reach the same persisted data under the same * conditions; sharing the seam is what keeps one condition reported as one fact. - * Frontends contribute only their rendered {@link StepEntryMetadata}, and map the - * returned arms onto their own envelopes — emitter wiring and exit codes. + * Frontends contribute nothing to the entry: the seam enters the unit through + * `RunbookActorService.enterExecutionUnit`, which renders it from the run's own + * state. Callers map the returned arms onto their own envelopes — emitter wiring + * and exit codes — and read the classified entry off the `projected` arm. * - * Ordering is deliberate: the consume commits BEFORE the observations are - * returned, so a failed consume discloses no bearers. The frontier stays - * persisted in that case, and the next attempt re-projects it. + * Ordering is deliberate: the consume commits BEFORE the unit is entered, so a + * failed consume neither discloses bearers nor renders. Rendering can run + * arbitrary `--helpers` JS, so this is not only a disclosure boundary — it also + * bounds a non-idempotent helper to running at most once per commit. The + * frontier stays persisted when the consume fails, and the next attempt + * re-projects it; had rendering already run on that attempt, the retry would + * re-invoke the same helper a second time. * - * @param input - Actor service, steps, committed state, verified deriver, and rendered entry metadata. + * @param input - Actor service, steps, committed state, and verified deriver. * @returns The classified re-entry outcome. + * @throws {Error} When the run's cursor names a step the parsed runbook does not + * define, or when entering the unit cannot render it. * @throws {InvalidRunbookStateError} When the persisted snapshot carries a * `delegateFrontier` that is not an array of structurally valid entries. Per * the no-migration rule this is corrupt/incompatible persisted state, and the @@ -277,7 +304,7 @@ export async function projectAndConsumeReEntryFrontier( input: ProjectAndConsumeReEntryFrontierInput, ): Promise { const persistedFrontier = readPersistedReEntryFrontier(input.state); - if (persistedFrontier.length === 0 || !input.entry.isSubstep) { + if (persistedFrontier.length === 0 || !cursorIsOnSubstep(input.state, input.steps)) { return { status: 'none' }; } @@ -288,12 +315,6 @@ export async function projectAndConsumeReEntryFrontier( return { status: 'projection_refused', message: getErrorMessage(error) }; } - const observations = await input.actorService.observeExecutionUnitEntry( - input.state.id, - [...input.steps], - { ...input.entry, delegateFrontier: frontier }, - ); - const consumed = await input.actorService.sendAndSync(input.state.id, [...input.steps], { type: 'DELEGATE_FRONTIER_CONSUMED', }); @@ -301,5 +322,14 @@ export async function projectAndConsumeReEntryFrontier( return { status: 'consume_failed' }; } - return { status: 'projected', observations, state: consumed.state }; + // The COMMITTED state, not `input.state`: rendering must describe the run as + // it exists after the consume landed, and must not run before the commit that + // gates it — see the ordering note above. + const entered = await input.actorService.enterExecutionUnit({ + state: consumed.state, + steps: input.steps, + delegateFrontier: frontier, + }); + + return { status: 'projected', entered, state: consumed.state }; } diff --git a/packages/core/src/runbook/state.ts b/packages/core/src/runbook/state.ts index 7e244a7b4..12501d518 100644 --- a/packages/core/src/runbook/state.ts +++ b/packages/core/src/runbook/state.ts @@ -470,8 +470,9 @@ export class RunbookStateManager { * filesystem-backed `.rundown/runs//outputs/` tree holds captured command * output only, and is created on demand by the capture path. * - * `templateVars` is always written — `{}` when the caller supplies none — because - * {@link load} refuses a persisted row without it. + * `templateVars` is always written — `{}` when the caller supplies none — and + * so is `prompted` (`false` when the caller names no mode), because {@link load} + * refuses a persisted row without either. * * @param runbookRef - Canonical runbook identity * @param runbook - The parsed runbook definition @@ -504,7 +505,10 @@ export class RunbookStateManager { parentLinkage: options.parentLinkage, startedAt: now, updatedAt: now, - prompted: options.prompted, + // Always written, `false` when the caller names no mode: `load` refuses a + // persisted row without `prompted` rather than defaulting one, so a run + // created without it would be unreadable the moment it is saved. + prompted: options.prompted ?? false, runbookSrc: options.runbookSrc, // Always written, `{}` when the caller supplies none: `load` refuses a // persisted row without templateVars rather than reconstructing it, so a @@ -535,7 +539,7 @@ export class RunbookStateManager { * @throws {Error} If `id` is traversal-unsafe — rejected before any store access * @throws {InvalidRunbookStateError} If the record exists but its `state_json` * is unparseable, fails schema validation, has an incompatible schemaVersion, - * or is missing `templateVars` + * or is missing `templateVars` or `prompted` * @throws {LegacySnapshotError} If the runbook state uses deprecated dynamic-step snapshots */ async load(id: string): Promise { @@ -582,6 +586,21 @@ export class RunbookStateManager { ); } + // Same rule, same reason. `prompted` decides whether the run announces its + // commands or executes them, and it is the value a composing parent + // inherits down into a fresh inline child. Defaulting an absent one at the + // read sites would silently adapt an incompatible row into an executing + // run; `create` always writes the field, so a row without it originates + // outside this codebase's only creation path. Named here rather than left + // to the schema parse below so the refusal says which field is missing. + if (raw.prompted === undefined) { + throw new InvalidRunbookStateError( + `Invalid runbook state for "${id}": missing prompted. ` + + `Prune this run and re-run the runbook.`, + { runId, reason: 'missing_prompted' }, + ); + } + const result = makeRunbookStateSchema(this.cwd).safeParse(raw); if (!result.success) { throw new InvalidRunbookStateError( diff --git a/packages/core/src/runbook/types.ts b/packages/core/src/runbook/types.ts index 407943adf..faf8038ab 100644 --- a/packages/core/src/runbook/types.ts +++ b/packages/core/src/runbook/types.ts @@ -1130,7 +1130,14 @@ export interface RunbookState { readonly startedAt: string; readonly updatedAt: string; - readonly prompted?: boolean; + /** + * Whether this run announces its commands rather than executing them. + * + * Required, and written once at creation: `load` refuses a persisted row + * without it rather than defaulting, so no reader has to spell a fallback that + * would silently adapt an incompatible row into an executing run. + */ + readonly prompted: boolean; readonly lastResult?: 'pass' | 'fail'; readonly lastAction?: LastAction; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 504210b2e..4dc960128 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -959,7 +959,13 @@ const RunbookStateObjectSchema = z // TSDoc there for the bypass-prohibition. Do not add a structural // `.superRefine()` here without a public XState snapshot schema to anchor it. snapshot: z.unknown().optional(), - prompted: z.boolean().optional(), + // Required, on the same reasoning as `templateVars` below. + // `RunbookStateManager.create` always writes it (`false` at minimum), and it + // decides whether the run announces its commands or executes them — a fact + // no reader may default. `state.load()` checks `schemaVersion` and names + // this field's absence explicitly before this schema parses, so requiring it + // here cannot mask invalid-state detection for rows of another version. + prompted: z.boolean(), lastResult: z.enum(['pass', 'fail']).optional(), lastAction: LastActionSchema.optional(), runbookSrc: z.string().optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0926417e..3b2157fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@types/node': specifier: ^26.2.0 version: 26.2.0 + '@typescript-eslint/utils': + specifier: ^8.64.0 + version: 8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) astro: specifier: ^7.2.0 version: 7.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) diff --git a/scripts/__tests__/eslint-brand-cast-guard.test.mjs b/scripts/__tests__/eslint-brand-cast-guard.test.mjs new file mode 100644 index 000000000..1db9c5a5d --- /dev/null +++ b/scripts/__tests__/eslint-brand-cast-guard.test.mjs @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; + +/** + * Behavioural gate over the `RenderedUnitCommand` provenance ban. + * + * The brand is tier 1 — a module-private `declare const` unique symbol — so + * there is no runtime check to fall back on: a type assertion IS the mint. The + * ban (`local/no-rendered-unit-command-cast`, + * ../../eslint-rules/no-rendered-unit-command-cast.mjs) is type-aware: it + * resolves the TYPE an assertion names through the checker rather than matching + * the SYNTAX that named it, which is what lets it catch an import rename or a + * type alias without enumerating spellings. + * + * Asserted by running the real `eslint.config.js` over fixtures rather than by + * reading the rule's source, because the rule itself is the thing under test: a + * bug in its type resolution matches nothing and reads as passing. + * + * Note the layered defence this sits inside. Outside `packages/core`, the type's + * NAME is not exported from `@rundown-org/core` at all, so none of these + * spellings can even be written; that is pinned by + * `packages/core/__tests__/events/entry-seam-barrel.test.ts` and its + * `.typecheck.ts` twin. This rule is what holds INSIDE core, where a relative + * import puts the name back in scope. + * + * ## Why committed files, and not `lintText` + * + * The obvious shape for this test is `lintText(snippet, { filePath })` aimed at + * an existing core module, so that the snippet inherits that path's flat-config + * block and TypeScript project membership. That shape is unsound, and it failed + * in CI while passing on every developer machine. + * + * `lintText` hands ESLint one copy of the source (the snippet) while the + * type-aware parser serves the AST out of a long-lived TypeScript watch program + * keyed by path. Nothing reconciles the two: typescript-estree's + * `getAstFromProgram` returns whatever `SourceFile` the program already holds + * for that path, with no comparison against the text ESLint is reporting on. + * Keeping them in step depends entirely on an invalidation path — a content + * hash in `parsedFilesSeenHash`, a file-watcher callback registered under a + * canonicalised path whose casing rules come from `ts.sys` — that is sensitive + * to platform and to program state. When it does not fire, ESLint reports + * positions against a 4-line snippet while the rules walk the 1151-line module + * that lives at that path. In CI that surfaced as `jsdoc/check-param-names` + * dereferencing a source line past the end of the snippet, and then as every + * subsequent case resolving `RenderedUnitCommand` to an error type, so the rule + * under test had nothing to resolve and silently never fired. + * + * Real files remove that failure mode by construction rather than by timing: the + * bytes ESLint reads and the bytes TypeScript reads are the same bytes, so a + * "stale" program entry and a fresh one are indistinguishable and no + * invalidation is ever required. + * + * ## Why COMMITTED files, and not files this test writes and deletes + * + * Writing them per-run and sweeping them afterwards also fixes the staleness, + * and it was the first fix applied here. It is not enough, because a transient + * real file in a real package is visible to everything else that reads the + * working tree while it exists: + * + * - Under `packages/core/src`, the files land inside the Stryker `mutate` glob. + * `scripts/mutation-shard-plan.mjs` globs the filesystem (not `git ls-files`, + * so `.gitignore` does not hide them) and then `readFileSync`s every hit, and + * `scripts/__tests__/mutation-sharding.test.mjs` drives that real planner at + * the repo root — in parallel, because `node --test` parallelises test FILES. + * Measured against a churn loop, 21 of 25 planner runs died `ENOENT` between + * the glob and the read. A one-shot write/sweep makes that window narrow, not + * absent. + * - The same path is inside `packages/core/tsconfig.json`'s build `include`, so + * a concurrent `tsc` emits fixture output into `dist/`. + * - They are deliberate lint violations for as long as they exist, so an + * editor's ESLint watcher or a hand-run `check:lint:typed` in that window + * reports errors that are about to stop existing. + * + * Committing the fixtures under `__tests__` removes all three by construction: + * outside `src`, they are outside both the mutate glob and the build include; + * and being permanent, there is no window at all. What committing costs is that + * ten deliberate violations would fail the repository lint gate forever, so the + * directory is listed in `eslint.ignores.js` and re-included here with + * `ignore: false`. That option overrides file SELECTION only — the rule + * configuration these paths resolve is the real config, unmodified, which is + * what keeps the test meaningful. `pins the ignore entry` and + * `resolves the ban for production core source` below assert both halves of + * that arrangement, so neither can rot silently. + * + * The fixtures still resolve `../../../src/runbook/execution-unit-entry.js` to + * the REAL producer, and `packages/core/__tests__` is inside + * `tsconfig.eslint.json`'s `include`, so the checker sees the actual brand + * declaration rather than a stand-in. + */ + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); + +/** Repo-relative, because that is the form `eslint.ignores.js` matches on. */ +const FIXTURE_REL = 'packages/core/__tests__/fixtures/brand-cast'; +const FIXTURE_DIR = path.join(repoRoot, FIXTURE_REL); + +const RULE = 'local/no-rendered-unit-command-cast'; +const PROVENANCE = /minted only by deriveExecutionUnitEntry/; + +// One entry per way the brand can be asserted into existence. `as` was the only +// one the original selector matched; the rest are the gap. Each fixture imports +// the type it asserts to — an unresolvable name is not real TypeScript (`tsc` +// refuses it before this rule would ever run), and the type-aware rule correctly +// has nothing to resolve an unbound identifier to. Why each route defeats a +// syntax-matching selector is documented in the fixture itself. +const FORGERIES = [ + { file: 'direct-as.ts', label: 'a direct as-assertion' }, + { file: 'double-through-unknown.ts', label: 'a double assertion through unknown' }, + { file: 'angle-bracket.ts', label: 'an angle-bracket assertion' }, + { file: 'namespace-qualified.ts', label: 'an assertion through a namespace-qualified name' }, + { + file: 'angle-bracket-qualified.ts', + label: 'an angle-bracket assertion through a namespace-qualified name', + }, + { file: 'local-type-alias.ts', label: 'an assertion through a local type alias' }, + { file: 'alias-two-hops.ts', label: 'a type alias two hops from the brand' }, + { file: 'interface-inheritance.ts', label: 'an interface that inherits the brand' }, + { file: 'import-renamed.ts', label: 'an import renamed at the specifier' }, + { file: 'union-member.ts', label: 'a union type naming the brand' }, +]; + +// The negative control. Without it a rule broad enough to flag every `as` in the +// repository would satisfy every case above, and the rule would be unusable +// rather than correct. It is linted and read back through exactly the same path +// as the forgeries — a control linted differently from what it controls proves +// nothing about it. +const CONTROL = { + file: 'control-unrelated-assertions.ts', + label: 'leaves unrelated assertions alone', +}; + +const CASES = [...FORGERIES, CONTROL]; + +/** Provenance-ban messages per fixture basename. */ +const provenanceMessages = new Map(); + +before(async () => { + const files = CASES.map((testCase) => path.join(FIXTURE_DIR, testCase.file)); + + // `ignore: false` re-includes the deliberately-ignored fixture directory. It + // affects which files are linted, not how — see the header. + const results = await new ESLint({ cwd: repoRoot, ignore: false }).lintFiles(files); + + assert.equal( + results.length, + files.length, + 'every fixture must be linted; a missing result means ESLint skipped one as unmatched', + ); + + for (const result of results) { + const name = path.basename(result.filePath); + + const fatal = result.messages.filter((message) => message.fatal); + assert.deepEqual( + fatal.map((message) => message.message), + [], + `${name} failed to parse; the type-aware rule never ran`, + ); + + provenanceMessages.set( + name, + result.messages + .filter((message) => message.ruleId === RULE) + .map((message) => message.message), + ); + } +}); + +for (const { file, label } of FORGERIES) { + test(`bans ${label}`, () => { + const messages = provenanceMessages.get(file); + + assert.ok( + messages?.some((message) => PROVENANCE.test(message)), + `expected the provenance ban to fire on ${label} (${file}); got: ${JSON.stringify(messages)}`, + ); + }); +} + +test(CONTROL.label, () => { + assert.deepEqual(provenanceMessages.get(CONTROL.file), []); +}); + +// A fixture nobody lints is dead weight that reads as coverage, and a case +// naming a file that no longer exists would fail only in `before`, where the +// message is about ESLint rather than about the drift. Pin the two lists to each +// other instead. +test('lints every fixture in the directory, and no fixture it does not have', () => { + const onDisk = readdirSync(FIXTURE_DIR) + .filter((entry) => entry.endsWith('.ts')) + .sort(); + + assert.deepEqual( + onDisk, + CASES.map((testCase) => testCase.file).sort(), + 'add the new fixture to FORGERIES (or delete it); every .ts file here must be a declared case', + ); +}); + +// The fixtures are violations on purpose, so an ordinary lint run has to skip +// them. Deleting the `eslint.ignores.js` entry would turn `check:lint:typed` red +// permanently while leaving every assertion above green, because `ignore: false` +// makes this suite indifferent to it. This is the assertion that notices. +test('pins the ignore entry that keeps the forgeries out of the ordinary lint run', async () => { + const eslint = new ESLint({ cwd: repoRoot }); + + for (const { file } of CASES) { + assert.equal( + await eslint.isPathIgnored(path.join(FIXTURE_DIR, file)), + true, + `${file} must be ignored by the default lint run; see eslint.ignores.js`, + ); + } +}); + +// The fixtures live under `__tests__` for the reasons in the header, so this +// suite no longer demonstrates the ban firing on a file in `src`. Assert that +// directly rather than inferring it: the configured severity for a production +// core module, and the single exemption for the module that mints the brand. +test('resolves the ban for production core source, and lifts it only for the producer', async () => { + const eslint = new ESLint({ cwd: repoRoot, ignore: false }); + + // `calculateConfigForFile` resolves a path, not a file, and answers happily for + // one that does not exist. Both source paths below are named because they are + // real modules, so check that they still are — otherwise a rename turns these + // into assertions about a path shape while the test name still claims + // production source. + const severity = async (relPath) => { + assert.ok(existsSync(path.join(repoRoot, relPath)), `${relPath} no longer exists`); + return (await eslint.calculateConfigForFile(path.join(repoRoot, relPath))).rules?.[RULE]?.[0]; + }; + + assert.equal( + await severity('packages/core/src/runbook/collection-service.ts'), + 2, + 'the ban must be an error on ordinary core source', + ); + assert.equal( + await severity('packages/core/src/runbook/execution-unit-entry.ts'), + 0, + 'the producer mints the brand, so it is the one file exempted', + ); + assert.equal( + await severity(path.join(FIXTURE_REL, CONTROL.file)), + 2, + 'the fixtures must resolve the same severity as production source, or they test nothing', + ); +});