Skip to content

Commit e57a4c3

Browse files
refactor(service-automation): move 42 engine bodies into five seam modules (#25222 slice 7) (#25597)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuCEzLkAB12FfRHL9ipwfKPd)_ Part of #25222 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 This is slice 7 of the `engine.ts` split. Its claim is comment 6378948491, narrowed by one body in comment 6379054858. It follows the seat's shape call A: one PR, five seam modules. The bodies of 42 `AutomationEngine` methods move, unedited, into five new modules under `packages/services/service-automation/src/`: | Module | Host | Bodies | What they do | | --- | --- | --- | --- | | `dispatch-claims.ts` | `DispatchClaimsHost` | 7 | `claim`, `claimInProcess`, `settleDispatch`, `readDispatch`, `nextRunId`, `warnAdvanceClaimDegraded`, `releaseSuspension`: claiming, settling and reading a dispatch, minting a run id, announcing a degraded advance claim, releasing a suspension | | `flow-definitions.ts` | `FlowDefinitionsHost` | 12 | `resolveFunction`, `registerConnectorProvider`, `canonicalizeStoredFlow`, `canonicalizeFlow`, `unregisterFlow`, `recordFlowRefusal`, `getFlowRuntimeStates`, `describeUnboundReason`, `getTriggerBindingAudit`, `flowCredentialHoldings`, `hookSecretResolver`, `rollbackFlow`: canonicalising, unregistering, refusing and rolling back a flow; its runtime states and trigger bindings; its function, connector-provider and credential surfaces | | `subflow-guards.ts` | `SubflowGuardsHost` | 11 | `describeDisabledFlow`, `subflowTargets`, `packagedSubflowCallers`, `parkedRunsOf`, `refuseUnderReachingCallers`, `disabledPackagedSubflows`, `ledgerDisabledChainReaches`, `refuseEnableOntoDisabledSubflow`, `describeDisabledSubflows`, `rejudgeSubflowCallers`, `refuseCustomerAuthoredToggle`: finding and describing disabled or packaged subflows and their callers, and refusing what would reach a disabled subflow | | `consumed-suspension-restore.ts` | `ConsumedSuspensionRestoreHost` | 6 | `rowSupersedesJournal`, `resolveConsumedSuspensionWitnesses`, `restoreConsumedSuspension`, `resolveRestoreChain`, `peekConsumedSuspension`, `inspectConsumedSuspension`: resolving a consumed suspension's witnesses, and restoring, peeking at or inspecting one | | `node-text-rendering.ts` | `NodeTextRenderingHost` | 6 | `unknownNodeTypes`, `warnIfNodeTypeVocabularyNeverSealed`, `setNodeError`, `renderRefusalMessage`, `renderFlowTextSlot`, `translatedFlowTemplate`: the node-type vocabulary checks a run makes, a failed node's error variable, and rendering refusal messages and flow text slots | The change is move-only, and the card stays open for the later slices. The branch is `claude/project-thread-nirtwt`, which the harness pins for this session. It was restarted from `main` at `ea6fe000` after slice 6 (#25567) merged. The branch name has no issue number, so the claim comment is the identity record. ## Scope - **The 33-site question.** The ruling on the 33 `this.FIELD.delete/insert/update(` sites in 21 methods is still pending with Sales. This slice moves **0** of those sites, because none of the 42 bodies holds one. `check-tenant-audit-census` and `check-system-context-census` give byte-identical output at base and head (see Readers). - **Return types.** Each of the 42 methods declares its return type; the generator refuses one that does not. So no public body with an inferred return type moves. - **Four bodies stay**, because each reads a name that `engine.ts` documents as private, and moving it would mean exporting that name: - `claimAdvance` returns `AdvanceClaim`, whose docblock says "Module-private: it is a control-flow verdict, not a contract." - `validateNodeInputSchemas` throws `InputSchemaViolationError`, whose docblock says "Not exported on purpose — callers read the ADR-0112 `code` off the result, never the throw." - `refuseInvalidScreenInput` and `buildSubflowResumeSignal` (slice 6's two) read `ENGINE_BUILT_SIGNAL` / `engineBuilt`. `docs/adr/0019-approval-as-flow-node.md` calls it "a module-private symbol", and its docblock says "Module-private and symbol-keyed". - **`pullConnectorSource` stays too** (the claim amendment, comment 6379054858). `connector-pull.ts` already has a top-level `pullConnectorSource`, which `index.ts` re-exports. With the method's body moved as well, the bundle held two top-level functions of that name, and the bundler renamed the public one: both bundles exported `pullConnectorSource2 as pullConnectorSource`, so its `.name` changed. That would be an observable change to a public export, so the body stays. On this head, every exported function's `.name` matches base in both bundles (see Bundles below). - **None of the other stays moves.** The remaining-bodies table (comment 6376268510) marks these as stays: - for liveness, `retryExecution` and `getConnectorDescriptors`; - for private statics, `resolveCanonicalDescriptor`, `resolveResumeAuthority` and `resolveEffectiveSuspension`. A moved function reading a private static would not compile, and `tsc` passes; - for the startup-registry gate, `knownNodeTypes`, `getUnknownNodeTypeAudit`, `sealNodeTypeVocabulary` and `warnUnknownNodeTypes`. - **Size.** 2,406 changed lines, under the 3,000-line bar: `engine.ts` +365 / -771, the five modules +1,268, and the test 1 / 1. `check-governed-merges --branch` on this commit says NOT governed, 0 of 7 paths on the register. ## What stays in `engine.ts` - Every moved method keeps its name, docblock, signature and position in the class, because tsup emits each member, private ones included, into the published `.d.ts`. ## The shape (slices 1 to 6) - **The host interfaces.** Each module's host holds exactly the members its bodies read, each typed as the class member itself (`AutomationEngine['m']`), so a renamed member is a compile error. There is no cast, and no member becomes public or protected. - Each moved function types `this` as the `Pick` of its host over exactly the members its own body reads, read off each body's AST in first-read order. - Six bodies read no member and are called directly, without a host: `nextRunId`, `subflowTargets`, `describeDisabledSubflows`, `rowSupersedesJournal`, `unknownNodeTypes` and `setNodeError`. - **Methods** are passed as late-bound closures, `(...a) => this.m(...a)`, so a spy or test double set on the instance is still the one called. - **Six fields go through a getter**, in the `store` form (#25223 cut 1, PR #25290), because a method reassigns each. The write sites are `engine.ts` at the base `ea6fe000`: - `flowDispatchStore`: `setFlowDispatchStore` (`:2778`); - `functionResolver`: `setFunctionResolver` (`:3962`); - `flowCredentialSource`: `setFlowCredentialSource` (`:4746`); - `store`: `setSuspendedRunStore` (`:2769`), and the constructor (`:2745`); - `nodeTypeVocabularySealed`: `sealNodeTypeVocabulary` (`:9424`); - `i18nServiceSource`: `setI18nServiceSource` (`:4769`). For each one, the stub builds `const readX = () => this.X;` and the host member `get X() { return readX(); }`. Each read in the body reads the engine's current value, as it did inside the class. - **Four warned-once flags go through a get/set pair** (#25224 cut 10, PR #25508), because a moved body sets each. The stub adds `const writeX = (v: AutomationEngine['X']) => { this.X = v; };` and the host member `set X(v) { writeX(v); }` beside the getter, and the host interface member is not `readonly`. So each write lands on the engine, as it did through `this`. - `git grep -nE "(dispatchClaimDegradationWarned|dispatchOutcomeDegradationWarned|advanceClaimDegradationWarned|nodeTypeSealOmissionWarned)"` over the whole repo at the base finds 13 lines, all in `engine.ts`: - the four declarations, each `= false`: `nodeTypeSealOmissionWarned` (`:2540`), `dispatchClaimDegradationWarned` (`:2725`), `advanceClaimDegradationWarned` (`:2734`), `dispatchOutcomeDegradationWarned` (`:2741`); - one `{@link}` in a docblock (`:2544`); - the four writes, each in the one moved body that sets it: `claim` (`:2815`), `readDispatch` (`:2911`), `warnAdvanceClaimDegraded` (`:3310`), `warnIfNodeTypeVocabularyNeverSealed` (`:9481`); - the four reads just above those writes, in the same bodies. - The constructor writes none of them, and no other file names any of them. - **Fifteen fields go by value**: `logger`, `inProcessDispatchClaims`, `nodeExecutors`, `connectorProviders`, `flows`, `flowRefusals`, `flowShadowing`, `boundFlowTriggers`, `policyDisabledFlows`, `subflowDeclinedFlows`, `triggers`, `flowVersionHistory`, `flowLedgerDisabled`, `flowStatusDisabled` and `consumedSuspensions`. The seat asked for proof that none is reassigned after construction: - `git grep -nE "this\.(FIELDS)\s*(=[^=>]|\?\?=|\|\|=|&&=)" -- packages/services/service-automation/src`, with the fifteen names, finds 1 line, in the constructor: `this.logger = logger;` (`:2744`). - Across the repo, the same pattern on any receiver (`\.(FIELDS)`) for the fourteen names other than `logger` finds nothing. The bracket form, `['name']` followed by `=` or `??=`, finds nothing for any of the fifteen. And `\.(flows|triggers|logger)\s*(=[^=>]|\?\?=)` on any receiver in the package finds only that constructor line. - The bodies change the collections' contents in place, on the engine's own `Map`s and `Set`s, and never rebind the fields. - **Async.** The stubs of the nine async methods drop `async` and return the moved function's promise, as in slices 3 to 6. - The caller gets the very promise the body makes. - Dropping `async` is the only edit to those signature lines, and the `.d.ts` does not print `async`. - **Parameter defaults.** `canonicalizeStoredFlow` and `canonicalizeFlow` take `options: { includeRetired?: boolean } = {}`. - The class stub keeps that default exactly as written, so a caller that omits `options` still gets `{}`. - The moved text keeps it too, token-identical, because the moved lines are unedited. That default is now dead: the stub always passes `options`, already defaulted. It is disclosed here, not edited. - The moved bodies keep saying `this.`, so the moved lines are unedited. ## Module-level declarations and imports - **Seven helpers gain `export`, in place.** The moved bodies read them, so each module imports them from `./engine.js`. Nothing else about them changes: the same line, the same docblock, the same position. `index.ts` adds no re-export, and the package's export map is `.` only, so none of them is reachable from outside the package. The signatures at head: - `export const CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION: readonly string[] = [` (read by `canonicalizeFlow`) - `export const MAX_RESTORE_CHAIN_HOPS = 32;` (read by `resolveRestoreChain`) - `export function chainEntryOf(runId: string, result: SuspensionRestoreResult): ChainRestoreEntry {` (read by `restoreConsumedSuspension`) - `export function flowRegistrationTodo(flowName: string, site: RegistrationCheckSite): ConversionTodoNotice {` (read by `canonicalizeStoredFlow`) - `export function graftConditionEnvelopes(converted: unknown, parsed: unknown): unknown {` (read by `canonicalizeFlow`) - `export function negotiatedServiceLocale(service: RefusalI18nService, requested: string): string {` (read by `translatedFlowTemplate`) - `export function nestedChildRunId(correlation: string | undefined): string | undefined {` (read by `resolveRestoreChain`) - None of their docblocks says the name is private. Two other files place `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` "in `service-automation/src/engine.ts`" (`metadata-core`'s `artifact-forward-conversion.ts` and `metadata-protocol`'s `protocol.ts`), and it stays there. - **The cut-6 trap.** `flowRegistrationTodo`'s signature names types from other files (`RegistrationCheckSite`, `ConversionTodoNotice`). On this PR the rolled-up `.d.ts` gains no declaration and moves none; its only raw change is 87 reordered string-literal unions, each shown below as a swap. - The ESM and CJS export lists are identical at base and head (39 names each, ESM equal to CJS on each side). They are listed in the `.d.ts` section. - **The modules' imports** use the same specifiers as `engine.ts`'s own imports. A comment above each value import from `./engine.js` says the value is read only inside function bodies, at call time, so the import cycle is safe under ESM live bindings, as in slices 3, 5 and 6. The tsup bundle is one file and has no cycle. - **Import edits in `engine.ts`.** - `import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js';` moves, verbatim, to `flow-definitions.ts`: only moved bodies read those names. - The 6-line `[#15788]` comment and `import { renderTextSlot, FlowTextTemplateError } from './builtin/template.js';` move, verbatim, to `node-text-rendering.ts`. The comment's claim ("`template.ts` imports only `../guard-refusal.js` and package-external contracts, so nothing it pulls in reaches back here") is as true there as it was in `engine.ts`. - Three imports are narrowed to the names `engine.ts` still reads: - `@objectstack/spec/automation` keeps `FLOW_RESERVED_NODE_TYPES` and `FLOW_STRUCTURAL_NODE_TYPES`; `FlowSchema`, `validateControlFlow` and `collectFlowGraphs` go to the modules; - `@objectstack/spec` keeps `CONVERSION_TODO_CODE` and the types `ConversionNotice` and `ConversionTodoNotice`; `applyConversionsToFlow` goes to `flow-definitions.ts`; - `@objectstack/spec/system` keeps `resolveBundleLocale`; `flowRefusalMessageKey` goes to `node-text-rendering.ts`. - No `engine.ts` docblock `{@link}`s any of the names that left, so no link stops resolving. `tsc` with `noUnusedLocals` passes, so every name left in an `engine.ts` import is still read or linked. - `engine.ts` gains the five module imports, after the `./run-resume.js` one, each with a comment naming its bodies. - **Authored text.** Each module's docblock and its host interface's docblock are new. Each host docblock names the method that reassigns each getter field and the body that sets each get/set flag. ## Proof of move-only - **Moved block.** This is `git diff --color-moved=plain --color-moved-ws=allow-indentation-change ea6fe00 HEAD` over `engine.ts` and the five modules. It detects 752 of the 771 deleted lines and 892 of the 1,633 added lines as moved. - **Deleted, not moved (19):** - the signature heads of the nine async methods, each of which drops `async`; - the seven helper declaration lines, each of which gains `export`; - the three import lines that are narrowed. - **Added, not moved (741):** the modules' docblocks, imports and host interfaces; the 42 pointer docblocks and rewritten function heads; the five module imports in `engine.ts` and their comments; the narrowed imports; the nine heads without `async`; the seven `export` lines; the 42 stub bodies; and lines git does not pair with a moved block. - **An independent check.** A script compared base and head `engine.ts` against the five modules. - **Module lines.** 898 of the modules' 1,268 lines are in 43 verbatim runs of base `engine.ts`: the 42 method texts, de-indented one level, and the `[#15788]` comment with its `./builtin/template.js` import. The script names each of the other 370: - 278 head lines: per module, the copyright line, the docblock, the imports and their comments, the host docblock and the host interface (10, 22, 15, 9 and 6 members); - the 42 pointer docblocks; - 50 signature lines: 42 `export function` heads, and 8 `this` parameter lines, each a `Pick` of the host. - **Deleted lines.** Every line deleted from `engine.ts` (771 in all, 9 of them blank) is among the modules' lines, apart from the 19 above. The deleted `./flow-credential-projection.js` import, the `[#15788]` comment and the `./builtin/template.js` import are among them, verbatim. - **Added lines.** Of the 365 lines added to `engine.ts`, 284 are found nowhere in base: - the five module import blocks (47 lines) and their comments (17); - the three narrowed imports and the seven `export` lines; - the nine heads without `async`; - the stub lines: 42 pointer comments, 36 host literals with their 36 calls, 35 method closures, 16 by-value members, 15 read/write closures, 15 getter and setter members, and 6 direct calls. The other 81, such as `logger: this.logger,` and `};`, are lines base already contains elsewhere. - **Literals.** No string or template literal in the 42 method bodies spans a line, checked on the TypeScript AST of base `engine.ts`. So de-indenting changed no string value. - **Tests.** `@objectstack/service-automation`'s suite passes the same at base and head, 199 of 199 files and 2628 of 2628 tests. ## `.d.ts` reading (ruling 6348891160, item 2) The merge base is `ea6fe000`, the head of `main`, so the merge-base reading and the base reading are one reading. - **Base** is `dist/`, built at `ea6fe000` in this container, in a worktree of that commit. - **Head** is `dist/`, built at this PR's commit. - The package's dependencies were built by `turbo run build --filter=@objectstack/service-automation` (31 of 31 tasks), and both sides resolve the same built dependencies. The readings: - **Raw sha256**, `index.d.ts` and `index.d.cts`, which are identical to each other on both sides: base `1a5162a4dd929399460a3936e22747a74cf0a8db24d398ffbab5d7ead7bf212e`, head `deb08bdfb32c2a247b98780ad67162bd2938da46669e81af313526f69af6ecc4`. - **Normalised sha256**, all four files: base `0df32585069f743135440763aabff8c23473231181297c8d164f5f7ee4f10232`, head `0df32585069f743135440763aabff8c23473231181297c8d164f5f7ee4f10232`. - **Raw diff**, `index.d.ts` base vs head: **174 lines**, 87 per side, with 19,626 lines on each side. They are 87 one-line `c` hunks: each base line and its head line are the same line with one string-literal union's members in another order. Outside the union, each pair is byte-identical, and each union holds the same members on both sides. - **The 87 pairs**, which are five distinct swaps. All of them sit inside three declarations, `SysAutomationRun`, `SysFlowDispatch` and `SysFlowCredential`, which inline the spec's field and flow types: | Member | Union | The swap | Lines (base = head) | | --- | --- | --- | --- | | `type:` | 49 field types | `"secret"` moves from 3rd to 6th, after `"code"` | 5851, 6366, 13468, 13983, 15679, 16194 | | `dialect:` | `"cron" \| "cel" \| "template"` | `"template"` moves from 3rd to 1st | 72 lines: 5889, 5933, 5942, 5976, 6002, 6011, 6020, 6033, 6043, 6088, 6097, 6109, 6118, 6130, 6139, 6151, 6160, 6202, 6396, 6428, 6756, 6775, 6829, 6840, 13506, 13550, 13559, 13593, 13619, 13628, 13637, 13650, 13660, 13705, 13714, 13726, 13735, 13747, 13756, 13768, 13777, 13819, 14013, 14045, 14373, 14392, 14446, 14457, 15717, 15761, 15770, 15804, 15830, 15839, 15848, 15861, 15871, 15916, 15925, 15937, 15946, 15958, 15967, 15979, 15988, 16030, 16224, 16256, 16584, 16603, 16657, 16668 | | `type?:` | 6 action types | `"form"` and `"api"` swap (2nd and 3rd) | 6720, 14337, 16548 | | `type?:` | 49 field types | `"secret"` moves from 3rd to 6th, after `"code"` | 6747, 14364, 16575 | | `category?:` | 7 categories | `"flow"` and `"data"` swap (1st and 2nd) | 6858, 14475, 16686 | For example, line 5889 is `dialect: "cron" | "cel" | "template";` at base and `dialect: "template" | "cron" | "cel";` at head. A script checked each of the 87 pairs: it replaced each union with a placeholder and compared the rest of the line, and it compared each union's sorted members. All 87 are swaps, 0 are not. - **The normaliser**, the ruling's one-liner, verbatim: `node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum` - **Positive controls**, each one line changed in the head `index.d.ts`, then the same normaliser: - Line 1865, `private claimInProcess;`, a private member whose body moved, made public (`claimInProcess;`): `196ae606dce0584f296ce860802b020ab52eb604cde3f4f09cb835f5deba1b0c`. - The same member renamed (`private claimInProcessRenamed;`): `a972fa4d01a6a8cd8654a4ce08619a48df26fe4745758cc0bcfca4c9edf4bf4d`. - Line 5889 with one union member changed (`"cel"` to `"cel2"`): `a21f02dba31881caec16bae8623f780cc6aae2cc9cd56f110d7eb0cb4a66b188`. - All three differ from `0df32585…`. So the normaliser does not hide a visibility change, a rename or a changed union member; it hides only member order. - **Item 1 holds:** - (a) the declaration set and visibility are the same: no line is added, removed or moved, and the 87 changed lines change only member order; - (b) the files are byte-identical after the normalisation; - (c) the ESM and CJS export lists are identical; - (d) every raw-diff line is a union-member swap. ## Bundles - **Export lists.** 39 names each, identical at base and at head, and ESM equal to CJS on each side: `AutomationEngine`, `AutomationServicePlugin`, `CONNECTOR_PULL_PROVIDERS`, `ConnectorPullError`, `DEFAULT_MAX_EXECUTION_LOG_SIZE`, `DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW`, `FLOW_CREDENTIAL_MIGRATION_ID`, `FLOW_CREDENTIAL_OBJECT`, `FLOW_CREDENTIAL_VALUE_FIELD`, `FlowCredentialChannel`, `FlowCredentialChannelRefusal`, `FlowCredentialUnresolvableError`, `InMemoryFlowActivationStore`, `InMemoryFlowDispatchStore`, `InMemorySuspendedRunStore`, `MAX_PERSISTED_HISTORY_STEPS`, `ObjectStoreFlowActivationStore`, `ObjectStoreFlowDispatchStore`, `ObjectStoreSuspendedRunStore`, `SysAutomationRun`, `SysFlowCredential`, `SysFlowDispatch`, `UnscopedRunDataAccessError`, `createPackageFileLoader`, `describeFlowContender`, `formatRunSummaryLine`, `installBuiltinNodes`, `migrateFlowCredentialsIntoChannel`, `pullConnectorSource`, `registerConnectorNodes`, `registerCrudNodes`, `registerHttpNodes`, `registerLogicNodes`, `registerScreenNodes`, `renderFlowContender`, `resolveFlowPrecedence`, `resolveRunDataContext`, `stampSystemInsertOwner`, `summarizeRun`. - **Each export's code.** A script fingerprinted each export's `Function#toString` in both bundles, at base and at head. Only `AutomationEngine` differs, because its stubs changed. In the CJS bundle, six other functions (`migrateFlowCredentialsIntoChannel` and the five `register*Nodes`) print differently too, but only in esbuild's import-namespace variable names (`import_automation3` becomes another number, because the new modules add import statements). With those names folded, they are identical. - **Each export's name.** Every exported function's `.name` matches base in both bundles. (`AutomationEngine` prints as `_AutomationEngine` at both ends.) This is the check that caught `pullConnectorSource`; see Scope. - The bundle JS bytes otherwise differ only because code moved within the bundle. ## Readers **One reader re-pointed.** `src/flow-activation-ledger.test.ts`'s `ENGINE_SOURCES` holds the #10243 grep pins, which are absence checks. It gains the five modules, so a moved body is not a place the retired mechanism could come back unseen. That 1-line edit is the PR's seventh file. **Readers examined.** The base for each is `ea6fe000`, in a worktree of that commit. - **`check-tenant-audit-census`** (derived): output byte-identical to base. - It certifies 238 write call sites (162 decidable; 2 provably without tenant context, 29 more unreadable). - It holds 23 prose figures and matches every `UNTYPED_RECEIVERS` row. - Its `--self-test` output is identical too. - **`check-system-context-census`** (derived): output byte-identical. It finds 149 elevation read sites in 22 packages across 77 files, in 129 symbols. - **`check:liveness`** (spec lane, not derived; run by hand as `packages/spec`'s `tsx scripts/liveness/check-liveness.mts`): output byte-identical at base and head. - 753 (entry, cited file) pairs: 752 anchored, 1 exempt. - 982 of 982 symbol anchors resolve. - **`check:platform-checklist`** (not derived; run by hand): output identical at base and head. 987 of 997 symbol anchors resolve; the other 10 are the same ones, the named #16898 residual. - **`check-adr-symbol-anchors`** (not derived; run by hand): output identical at base and head. 2384 anchors across 142 records resolve. - **`check:adr-anchors`** (decision letters; run by hand at the merge base and the head): output identical apart from the count of files holding a citation, 6699 at base and 6702 at head. - The 52309 citations are the same in number: the citations in the moved bodies of `flow-definitions.ts`, `subflow-guards.ts` and `node-text-rendering.ts` are now counted in those three files. - It reads 84 anchored files and 1939 decision-letter citations, the same at both ends. - Neither `engine.ts` nor any of the five modules is on the anchored surface. - **`check:startup-registry-verdict`** (not derived; run by hand at both ends, on the hand-gate list): 46 seams, 40 of them read-only, none recording a verdict, at base and at head. - Its `--list` is the same at both ends apart from line numbers and the scanned-file count, 3463 at base and 3468 at head; the five extra files are the new modules. - None of its four engine bodies moves. `warnIfNodeTypeVocabularyNeverSealed` and `unknownNodeTypes` move, and the `registerFlow` and `sealNodeTypeVocabulary` seams still name the same `warn` sites, at their new line numbers. - **Symbol anchors into moved methods.** Four anchors name a method whose body moved: - `docs/adr/0087-metadata-protocol-upgrade-contract.md`, `engine.ts#canonicalizeStoredFlow`; - `docs/qa/platform-checklist/areas/automation.json`, `engine.ts#packagedSubflowCallers`; - `packages/spec/liveness/translation.json`, `engine.ts#renderRefusalMessage` and `engine.ts#translatedFlowTemplate`. Each still resolves: the method stays in `engine.ts` with its docblock and calls its body. This is the same as slice 5's `engine.ts#recordLog` anchor in `approvals.json`. The readers above give identical output, and the `translation.json` key-mention pairs (`refusals`, `message`) stay anchored through `engine.ts`'s own docblocks. - **`path:line` citations.** None points into this package's `engine.ts` at a line this slice moves. The one fixture citation, `service-automation/src/engine.ts:44` in `packages/spec/scripts/liveness/evidence.test.ts`, is a realm-attribution string that `scanEvidence` parses without reading the file. ## Gates - **Derived.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this commit derives 60 families from the 7 changed paths: 2,406 changed lines, under the 3,000-line human-merge threshold. These are the same 60 as slice 6. - All 60 ran in this container on this commit, with each exit code captured before any pipe, and all 60 exit 0. `dispatch-gates.mjs --ran` reconciles: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN. - An earlier run on the first commit, the one that still moved `pullConnectorSource`, was stopped after 23 families when I narrowed the slice. It is not counted. - **By hand, at base and head, not derived:** `check:liveness`, `check:platform-checklist`, `check-adr-symbol-anchors`, `check:adr-anchors` and `check:startup-registry-verdict`, with the readings in the Readers section above. - **`check-governed-merges --branch`** on this commit: 0 of 7 paths on the register, NOT governed, 2,406 lines. - **Package checks.** - `@objectstack/service-automation`'s `vitest run` gives 199 of 199 files and 2628 of 2628 tests, the same at base and head. - The package's `typecheck` (`tsc --noEmit` and `check:test-typecheck`) passes. - ESLint on the seven files passes. - **Open PRs**, re-listed just before this PR was opened: 11. - I diffed each one's head against its merge base with `origin/main`. None of them touches `service-automation/src`, the readers above, their ledgers, `docs/adr/0019-approval-as-flow-node.md`, or the three files that hold anchors into moved methods. - The Version Packages PR #24876 touches only the package's `CHANGELOG.md` and `package.json`. - `main` has moved to `e0734559`. Of what landed since `ea6fe000`, only #25545 touches a reader above, `scripts/check-system-context-census.mjs`, and only in its `plugin-security` rows. ```text node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs :: exit 0 node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 node scripts/check-platform-object-tenancy-census.mjs :: exit 0 node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-position-name-fold-loaders.mjs :: exit 0 node scripts/check-position-name-fold-loaders.mjs --self-test :: exit 0 node scripts/check-registry-log-declared.mjs :: exit 0 node scripts/check-registry-log-declared.mjs --self-test :: exit 0 node scripts/check-rest-log-spy-declared.mjs :: exit 0 node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 node scripts/check-system-context-census.mjs :: exit 0 node scripts/check-system-context-census.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/check-undeclared-dep-imports.mjs :: exit 0 node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` ## Acceptance notes - This is the slice the seat agreed (shape call A), less `pullConnectorSource`, which stays so that the public `pullConnectorSource` export keeps its name. The claim amendment says so. - Six host getters in the slice-5 `store` form, and four get/set pairs in the #25508 form, one for each warned-once flag a moved body sets. - Seven helpers gain `export` in place. No new public export: the export lists and every export's code, apart from `AutomationEngine`, are the same. - The `.d.ts` changes only in the order of members in 87 string-literal unions, each shown above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Mnj4nkWuhJ18xEdZvb2cjE Co-authored-by: Claude <noreply@anthropic.com>
1 parent dad27ef commit e57a4c3

7 files changed

Lines changed: 1634 additions & 772 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The consumed-suspension restore of {@link AutomationEngine}: how a consumed
5+
* suspension's witnesses are resolved, and how a consumed suspension is
6+
* restored along its chain, peeked at or inspected.
7+
*
8+
* [#25222] Slice 7 of the `engine.ts` split, move-only. Each function here is
9+
* the body of the `AutomationEngine` method of the same name, moved unedited.
10+
* The class keeps that method (its name, docblock and position are in the
11+
* published `.d.ts`) and calls the body with a {@link
12+
* ConsumedSuspensionRestoreHost} literal holding exactly the members the body
13+
* reads. An async method's stub is no longer `async`: it returns the promise
14+
* the moved function makes.
15+
*/
16+
17+
import { describeThrownForLog } from './thrown-cause-diagnostics.js';
18+
// The values below (`MAX_RESTORE_CHAIN_HOPS`, `chainEntryOf` and
19+
// `nestedChildRunId`) are read only inside the function bodies, at call time,
20+
// never while a module is evaluating, so the import cycle with ./engine.js is
21+
// safe under ESM live bindings; the tsup bundle is one file and has no cycle.
22+
import {
23+
MAX_RESTORE_CHAIN_HOPS,
24+
chainEntryOf,
25+
nestedChildRunId,
26+
type AutomationEngine,
27+
type ChainRestoreEntry,
28+
type ConsumedSuspension,
29+
type ConsumedSuspensionDropNotice,
30+
type ConsumedSuspensionInspection,
31+
type RunRecord,
32+
type SuspensionRestoreResult,
33+
} from './engine.js';
34+
35+
/**
36+
* The `AutomationEngine` members the moved bodies read, each typed as the class
37+
* member itself, so a renamed or respelled member is a compile error. Each
38+
* function takes the `Pick` of the members its own body reads, and the class
39+
* passes exactly those. Methods go as late-bound closures, so a spy or test
40+
* double set on the instance is still the one called. A field the class assigns
41+
* only at its declaration or in its constructor goes by value, so the value
42+
* passed is the field, including where an async body reads it after an `await`.
43+
* `store` goes through a getter, because `setSuspendedRunStore` reassigns it:
44+
* each read in the body reads the engine's current value, as it did through
45+
* `this`.
46+
*/
47+
export interface ConsumedSuspensionRestoreHost {
48+
readonly consumedSuspensions: AutomationEngine['consumedSuspensions'];
49+
readonly rowSupersedesJournal: AutomationEngine['rowSupersedesJournal'];
50+
readonly resolveRestoreChain: AutomationEngine['resolveRestoreChain'];
51+
readonly restoreOneConsumedSuspension: AutomationEngine['restoreOneConsumedSuspension'];
52+
readonly peekConsumedSuspension: AutomationEngine['peekConsumedSuspension'];
53+
readonly loadSuspendedRunStrict: AutomationEngine['loadSuspendedRunStrict'];
54+
readonly store: AutomationEngine['store'];
55+
readonly resolveConsumedSuspensionWitnesses: AutomationEngine['resolveConsumedSuspensionWitnesses'];
56+
readonly logger: AutomationEngine['logger'];
57+
}
58+
59+
/** The body of {@link AutomationEngine.rowSupersedesJournal}, moved unedited; that method's docblock is its contract. */
60+
export function rowSupersedesJournal(
61+
hot: ConsumedSuspension,
62+
rowPause: { nodeId?: string; correlation?: string },
63+
): boolean {
64+
const samePause =
65+
hot.run.nodeId === rowPause.nodeId &&
66+
(hot.run.correlation ?? undefined) === (rowPause.correlation ?? undefined);
67+
if (samePause) return false;
68+
return hot.persisted === 'landed';
69+
}
70+
71+
/** The body of {@link AutomationEngine.resolveConsumedSuspensionWitnesses}, moved unedited; that method's docblock is its contract. */
72+
export function resolveConsumedSuspensionWitnesses(
73+
this: Pick<ConsumedSuspensionRestoreHost, 'consumedSuspensions' | 'rowSupersedesJournal'>,
74+
runId: string,
75+
terminal: RunRecord | null,
76+
): {
77+
consumed?: ConsumedSuspension;
78+
witness?: 'journal' | 'durable';
79+
dropped?: ConsumedSuspensionDropNotice;
80+
/** The hot copy describes a pause the run has since LEFT. */
81+
staleHot: boolean;
82+
} {
83+
const hot = this.consumedSuspensions.get(runId);
84+
if (!terminal) {
85+
return hot ? { consumed: hot, witness: 'journal', staleHot: false } : { staleHot: false };
86+
}
87+
if (terminal.consumedSuspension) {
88+
const durable: ConsumedSuspension = {
89+
run: terminal.consumedSuspension,
90+
consumedAt: terminal.finishedAt ?? terminal.startedAt,
91+
error: terminal.error ?? '',
92+
};
93+
if (hot && !this.rowSupersedesJournal(hot, durable.run)) {
94+
return { consumed: hot, witness: 'journal', staleHot: false };
95+
}
96+
return { consumed: durable, witness: 'durable', staleHot: hot !== undefined };
97+
}
98+
if (terminal.consumedSuspensionDropped) {
99+
const dropped = terminal.consumedSuspensionDropped;
100+
if (hot && !this.rowSupersedesJournal(hot, dropped)) {
101+
return { consumed: hot, witness: 'journal', dropped, staleHot: false };
102+
}
103+
return { dropped, staleHot: hot !== undefined };
104+
}
105+
if (hot && hot.persisted !== 'landed') {
106+
// The row predates this strand — this process's own write for it
107+
// never reached the store (in flight, or failed and reported at
108+
// `error`). The store's silence says nothing.
109+
return { consumed: hot, witness: 'journal', staleHot: false };
110+
}
111+
// A later terminal record with no snapshot and no drop notice:
112+
// completed, cancelled or cascade-failed after any hot copy was
113+
// taken. Stale by definition.
114+
return { staleHot: hot !== undefined };
115+
}
116+
117+
/** The body of {@link AutomationEngine.restoreConsumedSuspension}, moved unedited; that method's docblock is its contract. */
118+
export async function restoreConsumedSuspension(
119+
this: Pick<ConsumedSuspensionRestoreHost, 'resolveRestoreChain' | 'restoreOneConsumedSuspension'>,
120+
runId: string,
121+
options?: { requestedBy?: string; reason?: string },
122+
): Promise<SuspensionRestoreResult> {
123+
// [#15222] Resolve the nested chain FIRST, read-only. A flat run finds
124+
// nothing here and takes the per-run call below unchanged — same
125+
// guards, same answer, and a result with no `chain` key at all.
126+
//
127+
// A nested one is the whole of this card: the ancestors' pauses were
128+
// consumed by the same cascade and journalled by
129+
// {@link failSuspendedRun}, and re-arming only the run the operator
130+
// named would put the deployment back in the condition the verb exists
131+
// to leave — a leaf that completes into an ancestor which never
132+
// continues. So the chain is one unit of repair, and this is the call
133+
// that makes it one.
134+
const { below, above } = await this.resolveRestoreChain(runId);
135+
if (below.length === 0 && above.length === 0) {
136+
return this.restoreOneConsumedSuspension(runId, options);
137+
}
138+
// DEEPEST FIRST, and the order IS the guarantee: an ancestor becomes
139+
// resumable only after the run it is parked awaiting is parked again,
140+
// so a resume arriving mid-repair delegates into a live child instead
141+
// of continuing past a nested node whose child is terminal.
142+
//
143+
// Every member goes through the SAME per-run call — its own
144+
// `restoring` claim, its own strict live-suspension read, its own
145+
// two-witness read, its own durable park. So the verb's idempotence
146+
// and the #14333 advance claim hold PER RUN in the chain rather than
147+
// once for the chain, and a second chain restore finds every member
148+
// parked and answers `RUN_SUSPENDED` without minting a second pause
149+
// anywhere.
150+
const chain: ChainRestoreEntry[] = [];
151+
for (const memberId of below) {
152+
chain.push(chainEntryOf(memberId, await this.restoreOneConsumedSuspension(memberId, options, runId)));
153+
}
154+
const own = await this.restoreOneConsumedSuspension(runId, options);
155+
chain.push(chainEntryOf(runId, own));
156+
for (const memberId of above) {
157+
chain.push(chainEntryOf(memberId, await this.restoreOneConsumedSuspension(memberId, options, runId)));
158+
}
159+
// The six fields above `chain` keep describing the run the caller
160+
// NAMED, wherever in the chain it sits — a consumer that never heard
161+
// of nesting reads exactly what it always read.
162+
return { ...own, chain };
163+
}
164+
165+
/** The body of {@link AutomationEngine.resolveRestoreChain}, moved unedited; that method's docblock is its contract. */
166+
export async function resolveRestoreChain(this: Pick<ConsumedSuspensionRestoreHost, 'peekConsumedSuspension'>, runId: string): Promise<{ below: string[]; above: string[] }> {
167+
const below: string[] = [];
168+
const above: string[] = [];
169+
const self = await this.peekConsumedSuspension(runId);
170+
if (!self) return { below, above };
171+
172+
let cursor = self;
173+
for (let hops = 0; hops < MAX_RESTORE_CHAIN_HOPS; hops++) {
174+
const childRunId = nestedChildRunId(cursor.run.correlation);
175+
if (childRunId === undefined || childRunId === runId || below.includes(childRunId)) break;
176+
const child = await this.peekConsumedSuspension(childRunId, runId);
177+
if (!child) break;
178+
below.unshift(childRunId);
179+
cursor = child;
180+
}
181+
182+
cursor = self;
183+
for (let hops = 0; hops < MAX_RESTORE_CHAIN_HOPS; hops++) {
184+
const parentRunId = (cursor.run.context as Record<string, unknown> | undefined)?.$parentRunId;
185+
if (typeof parentRunId !== 'string' || !parentRunId) break;
186+
if (parentRunId === runId || above.includes(parentRunId)) break;
187+
const parent = await this.peekConsumedSuspension(parentRunId, runId);
188+
if (!parent) break;
189+
above.push(parentRunId);
190+
cursor = parent;
191+
}
192+
return { below, above };
193+
}
194+
195+
/** The body of {@link AutomationEngine.peekConsumedSuspension}, moved unedited; that method's docblock is its contract. */
196+
export async function peekConsumedSuspension(this: Pick<ConsumedSuspensionRestoreHost, 'loadSuspendedRunStrict' | 'store' | 'resolveConsumedSuspensionWitnesses' | 'logger'>, runId: string, chainOf?: string): Promise<ConsumedSuspension | undefined> {
197+
try {
198+
if (await this.loadSuspendedRunStrict(runId)) return undefined;
199+
const terminal = this.store?.loadTerminal ? await this.store.loadTerminal(runId) : null;
200+
return this.resolveConsumedSuspensionWitnesses(runId, terminal).consumed;
201+
} catch (err) {
202+
if (chainOf !== undefined) {
203+
// #6299 — the driver's own text rides the structured slot.
204+
// #4632 verdict: FUNCTIONAL — the repair is visibly short (the
205+
// `chain` this call returns names only what it reached) and the
206+
// remedy is to re-issue, which is safe per run.
207+
this.logger.warn(
208+
`[automation] restoreConsumedSuspension('${chainOf}') could not read run '${runId}' while ` +
209+
`walking its nested chain, so the walk STOPPED there and nothing beyond it was re-armed ` +
210+
`— the repair this call reports is partial. Nothing was written for that run. Fix the ` +
211+
`store failure in this record's meta, then re-issue the restore: it is idempotent per ` +
212+
`run and puts back only what is still missing.`,
213+
describeThrownForLog(err),
214+
);
215+
}
216+
return undefined;
217+
}
218+
}
219+
220+
/** The body of {@link AutomationEngine.inspectConsumedSuspension}, moved unedited; that method's docblock is its contract. */
221+
export async function inspectConsumedSuspension(this: Pick<ConsumedSuspensionRestoreHost, 'loadSuspendedRunStrict' | 'store' | 'resolveConsumedSuspensionWitnesses'>, runId: string): Promise<ConsumedSuspensionInspection> {
222+
// Already resumable? STRICT read — a store outage throws through.
223+
const live = await this.loadSuspendedRunStrict(runId);
224+
if (live) return { repairable: false, runId, reason: 'RUN_SUSPENDED', nodeId: live.nodeId };
225+
226+
let terminal: RunRecord | null = null;
227+
if (this.store?.loadTerminal) terminal = await this.store.loadTerminal(runId);
228+
229+
const { consumed, witness, dropped } = this.resolveConsumedSuspensionWitnesses(runId, terminal);
230+
if (consumed && witness) {
231+
return {
232+
repairable: true,
233+
runId,
234+
flowName: consumed.run.flowName,
235+
nodeId: consumed.run.nodeId,
236+
...(consumed.run.correlation !== undefined ? { correlation: consumed.run.correlation } : {}),
237+
witness,
238+
consumedAt: consumed.consumedAt,
239+
};
240+
}
241+
if (dropped) return { repairable: false, runId, reason: 'SNAPSHOT_DROPPED', dropped };
242+
return { repairable: false, runId, reason: 'NO_CONSUMED_SUSPENSION' };
243+
}

0 commit comments

Comments
 (0)