Skip to content

Commit f3fea0a

Browse files
refactor(service-automation): move 14 delete-bearing AutomationEngine bodies into their seam modules (#25725)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XPa8wo37pD4eagmNazfkued)_ Part of #25222 (slice 8a of the `engine.ts` split; #25222 remains open for slice 8b, `resumeInternal`). Clause-②: no Metadata-protocol: none Claim 6391244018 (`domain:server` seat 1). Ruling-ref: 6384677413 (ruling B: slice 8 after the census reader, landed as #25660) and 6348891160 (the series' `.d.ts` bar). Resumed from the trial commit `claude/project-thread-nirtwt@6305caf` that Release 6385919610 handed over, rebuilt onto `main` `f6ba1343b` and re-proved there. ## What moved Move-only. The bodies of 14 `AutomationEngine` methods move, token for token, to the end of the seam module that already holds their section. No new module. Each class method keeps its name, docblock, signature and position, and calls its body with a host literal of exactly the members the body reads. - `dispatch-claims.ts`: `pruneInProcessDispatchClaims` - `run-resume.ts`: `persistSuspendedRun`, `forgetSuspendedRun`, `evictConsumedSuspension`, `takeSubflowParentStrand` - `plugin-extension-api.ts`: `unregisterNodeExecutor`, `unregisterTrigger`, `unregisterConnector` - `flow-definitions.ts`: `unregisterConnectorProvider`, `withdrawFlow`, `hydrateFlowActivations`, `toggleFlow` - `consumed-suspension-restore.ts`: `journalConsumedSuspension`, `restoreOneConsumedSuspension` Shape B, as in slices 1 to 7. Methods go to the host as late-bound closures, so a spy set on the instance is still the one called. `store` and `flowActivationStore` go through getters, because `setSuspendedRunStore` and `setFlowActivationStore` reassign them. Every other field goes by value: a grep for `this.FIELD =` over the package source finds only the constructor's `this.logger = logger` among them, so the value passed is the field, including where an async body reads it after an `await`. The five async stubs drop `async` and return the promise the moved function makes. `forgetSuspendedRun` keeps its `durableRecordAlreadyConsumed = false` default in both places. `engine.ts` exports nothing new: the bodies read only names already exported (`IN_PROCESS_DISPATCH_CLAIM_TTL_MS`, `MAX_CONSUMED_SUSPENSIONS`, `buildRunTrigger`, and types). The six bodies Release 6385919610 names as stays (`execute`, `bubbleToParent`, `registerFlow`, `activateFlowTrigger`, `deactivateFlowTrigger`, `declineOntoDisabledSubflows`) stay, for its reasons. Slice 8b (`resumeInternal`) is a later PR. ## Rebuilt onto current `main` The trial sat on `9dac9eb8d6`. Since then `main` changed `engine.ts` (#25703 adds the constructor-read field `credentialResolver`) and `flow-definitions.ts` (#25643, #25703). The rebuild is a cherry-pick of `6305caf7` onto `f6ba1343b` with one textual conflict: both sides appended members to `FlowDefinitionsHost` (`credentialResolver` from `main`, ten members from the trial); both are kept. None of the 14 bodies reads `credentialResolver` (measured below), so no host gains it. ## One reader the trial missed: `flow-activation-ledger.test.ts` The trial's readings did not include vitest. On the rebuilt tree one test went red: the pin "the only writers of the activation projection are hydration and toggleFlow" cuts the engine's sources into members and asks which member writes `flowLedgerDisabled`. It cut on a four-space-indented line, which is a member's name in `engine.ts` but an ordinary statement inside a top-level function, so once `hydrateFlowActivations` and `toggleFlow` moved, the pin cut them mid-body and named a chunk carrying neither name. The pin now cuts a seam module only where a line starts in column 0, and keeps its `engine.ts` split (second commit, test file only). Control: with `this.flowLedgerDisabled.add(name);` injected into the moved `withdrawFlow` body, the pin goes red and names `withdrawFlow`; the file was restored by `git checkout HEAD --` and its hash matched the HEAD blob `f711ccad`. ## Move proof (TypeScript AST, base `f6ba1343b`, head `035ed1efa`) - All 14 moved functions are token-identical to their base method: AST leaf tokens (kind and text) of the body, the parameters (the head's `this` parameter dropped), the return type and the `async` modifier. Body token counts: 41, 106, 103, 33, 30, 64, 103, 22, 22, 74, 129, 266, 134, 731. - Negative controls: a one-token on-disk edit in a scratch copy (`rec.at < cutoff` to `rec.at <= cutoff` in `pruneInProcessDispatchClaims`) reads DIFFERENT, and so does an in-memory one-token change to `toggleFlow`. - Hosts: for each of the 14, the set of `this.X` reads in the moved body equals the `Pick` keys of its `this` type, which equals the keys of the class stub's host literal, and the stub calls the function of its own name. No moved body reads `credentialResolver`. - Every line deleted from `engine.ts` (590) is found among the modules' added lines, except the five `async` signature lines the stubs drop and the 12 lines of the four rewritten import comments. ## Readers, byte-identical at both ends Taken in two worktrees, base `f6ba1343b` and head (re-taken on the final head): - `check-startup-registry-verdict`: 46 seams across 3,491 files, 39 read-only, both ends; `--list` identical apart from `engine.ts` line numbers, so `registerFlow`'s line is unchanged; `--self-test` identical. - Tenant-audit census: `check-tenant-audit-census.mjs` and its `--self-test` identical (238 write call sites certified, 162 decidable, 23 prose figures, every `UNTYPED_RECEIVERS` row matched); `tenant-audit-census.mjs` human output identical; `--json` identical apart from its `ref` field, the measured commit. - `check-system-context-census` and its `--self-test` identical (149 elevation read sites in 77 files, 129 symbols); `isystem-census.mjs --json` identical. - `check-platform-checklist` (986 of 996 symbol anchors, the 10 on the #16898 residual), `check-adr-symbol-anchors` (2,384 anchors across 142 records), `check-adr-anchors`, and spec `check:liveness` (1,187 classified): each output identical. ## `.d.ts` and bundles - `dist/index.d.ts` is raw byte-identical at both ends, sha256 `a5cbe3bbb8aff2805674e1d9f119946199697e469a3e8a5b9bc7eb4fc08bc7cb` (`index.d.cts` the same bytes), so the ruling's normalised identity holds trivially. - ESM and CJS export lists: 39 names each, the same list in both bundles, identical at both ends, with each export's `typeof` and function name unchanged. ## Tests and gates (head `035ed1efa`) - `@objectstack/service-automation` vitest: Test Files 201 passed (201), Tests 2636 passed (2636), the same at base `f6ba1343b`. Package `typecheck` (`tsc --noEmit` and `check:test-typecheck`) passes; ESLint over the package is clean. - `dispatch-gates --commands` derives 60 families on the 7 paths; all 60 exit 0, and `--ran` reconciles 60 derived, 60 run, 0 NOT-MEASURED from recorded exit codes. Two needed a prerequisite first, not a fix: `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into this shallow clone, and `check:dual-build-cjs-loads` needed the workspace's `dist/` trees. - `check-governed-merges --branch`: 0 of 7 paths on the register, NOT governed; 1,582 changed lines, under 3,000. - `skip-changeset`: nothing published changes (the `.d.ts` bytes and the export lists are identical). ## Acceptance notes - Two comments in moved bodies point by position at text that is now in another file: `unregisterTrigger`'s "see `forgetSuspendedRun`'s catch above" (now in `run-resume.ts`) and `persistSuspendedRun`'s "the docblock above argues it" (the docblock stays on the class method in `engine.ts`). Moved unedited, as slices 1 to 7 did with theirs. - `main` moved after the base (#25710, #25720); neither touches `packages/services/service-automation`. --- _Generated by [Claude Code](https://claude.ai/code/session_01UMkNEokXPqZh51HTHCnyph)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43edf90 commit f3fea0a

7 files changed

Lines changed: 932 additions & 650 deletions

File tree

‎packages/services/service-automation/src/consumed-suspension-restore.ts‎

Lines changed: 287 additions & 10 deletions
Large diffs are not rendered by default.

‎packages/services/service-automation/src/dispatch-claims.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,32 @@
33
/**
44
* The dispatch and suspension claims of {@link AutomationEngine}: how a
55
* dispatch is claimed, in the durable store or in process, then settled and
6-
* read back; how a run id is minted; how a degraded advance claim is announced;
7-
* and how a suspension is released.
6+
* read back; how the expired in-process claims are pruned; how a run id is
7+
* minted; how a degraded advance claim is announced; and how a suspension is
8+
* released.
89
*
9-
* [#25222] Slice 7 of the `engine.ts` split, move-only. Each function here is
10-
* the body of the `AutomationEngine` method of the same name, moved unedited.
11-
* The class keeps that method (its name, docblock and position are in the
12-
* published `.d.ts`) and calls the body with a {@link DispatchClaimsHost}
13-
* literal holding exactly the members the body reads. An async method's stub is
14-
* no longer `async`: it returns the promise the moved function makes.
10+
* [#25222] Slices 7 and 8 of the `engine.ts` split, move-only. Each function
11+
* here is the body of the `AutomationEngine` method of the same name, moved
12+
* unedited. The class keeps that method (its name, docblock and position are
13+
* in the published `.d.ts`) and calls the body with a {@link
14+
* DispatchClaimsHost} literal holding exactly the members the body reads. An
15+
* async method's stub is no longer `async`: it returns the promise the moved
16+
* function makes.
1517
*/
1618

1719
import { describeThrownForLog } from './thrown-cause-diagnostics.js';
18-
import type { AutomationEngine, FlowDispatchClaim, FlowDispatchOutcome, SuspendedRun, SuspensionReleaseReason } from './engine.js';
20+
// The value below (`IN_PROCESS_DISPATCH_CLAIM_TTL_MS`) is read only inside the
21+
// function bodies, at call time, never while a module is evaluating, so the
22+
// import cycle with ./engine.js is safe under ESM live bindings; the tsup
23+
// bundle is one file and has no cycle.
24+
import {
25+
IN_PROCESS_DISPATCH_CLAIM_TTL_MS,
26+
type AutomationEngine,
27+
type FlowDispatchClaim,
28+
type FlowDispatchOutcome,
29+
type SuspendedRun,
30+
type SuspensionReleaseReason,
31+
} from './engine.js';
1932

2033
/**
2134
* The `AutomationEngine` members the moved bodies read, each typed as the class
@@ -201,3 +214,11 @@ export async function releaseSuspension(this: Pick<DispatchClaimsHost, 'resolveS
201214
);
202215
}
203216
}
217+
218+
/** The body of {@link AutomationEngine.pruneInProcessDispatchClaims}, moved unedited; that method's docblock is its contract. */
219+
export function pruneInProcessDispatchClaims(this: Pick<DispatchClaimsHost, 'inProcessDispatchClaims'>, now: number): void {
220+
const cutoff = now - IN_PROCESS_DISPATCH_CLAIM_TTL_MS;
221+
for (const [k, rec] of this.inProcessDispatchClaims) {
222+
if (rec.at < cutoff) this.inProcessDispatchClaims.delete(k);
223+
}
224+
}

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 160 additions & 590 deletions
Large diffs are not rendered by default.

‎packages/services/service-automation/src/flow-activation-ledger.test.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,9 +1030,10 @@ describe('#10243 — the process-local `flowEnabled` map is retired', () => {
10301030
// body moved to: a grep-level pin reads them all, or a moved body is a place
10311031
// the retired mechanism could come back unseen.
10321032
const ENGINE_SOURCES = ['./engine.ts', './flow-registration-checks.ts', './plugin-extension-api.ts', './dag-traversal.ts', './run-history.ts', './run-resume.ts', './dispatch-claims.ts', './flow-definitions.ts', './subflow-guards.ts', './consumed-suspension-restore.ts', './node-text-rendering.ts'];
1033-
const engineSource = ENGINE_SOURCES.map((path) =>
1033+
const engineSources = ENGINE_SOURCES.map((path) =>
10341034
readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'),
1035-
).join('\n');
1035+
);
1036+
const engineSource = engineSources.join('\n');
10361037

10371038
it('the identifier no longer exists as engine STATE', () => {
10381039
// A grep-level pin, because the thing being asserted is the absence of
@@ -1051,9 +1052,14 @@ describe('#10243 — the process-local `flowEnabled` map is retired', () => {
10511052
const writes = [...engineSource.matchAll(/this\.flowLedgerDisabled\.(add|delete)\(/g)];
10521053
expect(writes.length).toBeGreaterThan(0);
10531054

1054-
const writingMethods = engineSource
1055-
// A class member, or a moved body's top-level declaration.
1056-
.split(/\n (?=[a-zA-Z]|\/\*\*)|\n(?=export function |\/\*\*)/)
1055+
const writingMethods = engineSources
1056+
// A class member of `engine.ts`, or a moved body's top-level
1057+
// declaration. A moved body's statements sit at the four-space
1058+
// indent a class member's name has in `engine.ts`, so a module is
1059+
// cut only where a line starts in column 0.
1060+
.flatMap((source, i) => source.split(
1061+
ENGINE_SOURCES[i] === './engine.ts' ? /\n (?=[a-zA-Z]|\/\*\*)|\n(?=export function |\/\*\*)/ : /\n(?=\S)/,
1062+
))
10571063
.filter((chunk) => /this\.flowLedgerDisabled\.(add|delete)\(/.test(chunk));
10581064
for (const chunk of writingMethods) {
10591065
expect(

‎packages/services/service-automation/src/flow-definitions.ts‎

Lines changed: 177 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
/**
44
* The flow definitions of {@link AutomationEngine}: how a stored or authored
5-
* flow is canonicalised, unregistered, refused and rolled back; how its runtime
6-
* states and trigger bindings are reported; and the function,
7-
* connector-provider and credential surfaces a flow reads.
5+
* flow is canonicalised, unregistered, withdrawn, refused, toggled and rolled
6+
* back; how the durable toggles are hydrated; how a flow's runtime states and
7+
* trigger bindings are reported; and the function, connector-provider and
8+
* credential surfaces a flow reads.
89
*
9-
* [#25222] Slice 7 of the `engine.ts` split, move-only. Each function here is
10-
* the body of the `AutomationEngine` method of the same name, moved unedited.
11-
* The class keeps that method (its name, docblock and position are in the
12-
* published `.d.ts`) and calls the body with a {@link FlowDefinitionsHost}
13-
* literal holding exactly the members the body reads.
10+
* [#25222] Slices 7 and 8 of the `engine.ts` split, move-only. Each function
11+
* here is the body of the `AutomationEngine` method of the same name, moved
12+
* unedited. The class keeps that method (its name, docblock and position are
13+
* in the published `.d.ts`) and calls the body with a {@link
14+
* FlowDefinitionsHost} literal holding exactly the members the body reads. An
15+
* async method's stub is no longer `async`: it returns the promise the moved
16+
* function makes.
1417
*/
1518

1619
import type { ConnectorProviderFactory } from '@objectstack/spec/integration';
@@ -43,10 +46,12 @@ import {
4346
* passes exactly those. Methods go as late-bound closures, so a spy or test
4447
* double set on the instance is still the one called. A field the class assigns
4548
* only at its declaration or in its constructor goes by value, so the value
46-
* passed is the field. Two fields go through a getter, because a method
47-
* reassigns each: `functionResolver` (`setFunctionResolver`) and
48-
* `flowCredentialSource` (`setFlowCredentialSource`). Each read in the body
49-
* reads the engine's current value, as it did through `this`.
49+
* passed is the field, including where an async body reads it after an
50+
* `await`. Three fields go through a getter, because a method reassigns each:
51+
* `functionResolver` (`setFunctionResolver`), `flowCredentialSource`
52+
* (`setFlowCredentialSource`) and `flowActivationStore`
53+
* (`setFlowActivationStore`). Each read in the body reads the engine's current
54+
* value, as it did through `this`.
5055
*/
5156
export interface FlowDefinitionsHost {
5257
readonly functionResolver: AutomationEngine['functionResolver'];
@@ -72,6 +77,16 @@ export interface FlowDefinitionsHost {
7277
readonly flowCredentialSource: AutomationEngine['flowCredentialSource'];
7378
readonly flowVersionHistory: AutomationEngine['flowVersionHistory'];
7479
readonly credentialResolver: AutomationEngine['credentialResolver'];
80+
readonly deactivateFlowTrigger: AutomationEngine['deactivateFlowTrigger'];
81+
readonly flowStatusDisabled: AutomationEngine['flowStatusDisabled'];
82+
readonly flowActivationStore: AutomationEngine['flowActivationStore'];
83+
readonly flowLedgerDisabled: AutomationEngine['flowLedgerDisabled'];
84+
readonly rejudgeSubflowCallers: AutomationEngine['rejudgeSubflowCallers'];
85+
readonly refuseCustomerAuthoredToggle: AutomationEngine['refuseCustomerAuthoredToggle'];
86+
readonly packagedFlowOwner: AutomationEngine['packagedFlowOwner'];
87+
readonly refuseEnableOntoDisabledSubflow: AutomationEngine['refuseEnableOntoDisabledSubflow'];
88+
readonly parkedRunsOf: AutomationEngine['parkedRunsOf'];
89+
readonly activateFlowTrigger: AutomationEngine['activateFlowTrigger'];
7590
}
7691

7792
/** The body of {@link AutomationEngine.resolveFunction}, moved unedited; that method's docblock is its contract. */
@@ -392,3 +407,153 @@ export function rollbackFlow(this: Pick<FlowDefinitionsHost, 'flowVersionHistory
392407
this.flows.set(name, entry.definition);
393408
this.logger.info(`Flow '${name}' rolled back to version ${version}`);
394409
}
410+
411+
/** The body of {@link AutomationEngine.unregisterConnectorProvider}, moved unedited; that method's docblock is its contract. */
412+
export function unregisterConnectorProvider(this: Pick<FlowDefinitionsHost, 'connectorProviders' | 'logger'>, providerKey: string): void {
413+
this.connectorProviders.delete(providerKey);
414+
this.logger.info(`Connector provider unregistered: ${providerKey}`);
415+
}
416+
417+
/** The body of {@link AutomationEngine.withdrawFlow}, moved unedited; that method's docblock is its contract. */
418+
export function withdrawFlow(this: Pick<FlowDefinitionsHost, 'deactivateFlowTrigger' | 'flows' | 'flowStatusDisabled' | 'flowVersionHistory' | 'policyDisabledFlows' | 'subflowDeclinedFlows' | 'flowRefusals' | 'logger'>, name: string): void {
419+
this.deactivateFlowTrigger(name);
420+
this.flows.delete(name);
421+
// [ADR-0126 §7.2] `flowLedgerDisabled` is deliberately NOT cleared. It
422+
// mirrors a durable row, and unregistering a flow does not delete that
423+
// row — so dropping the projection here would silently re-arm a
424+
// disabled packaged flow the moment it was re-registered (a hot
425+
// reload, a Studio publish, the next boot pull), which is the one
426+
// thing the durable switch exists to prevent. The projection is a Set
427+
// of names; a genuinely deleted flow leaves one harmless string.
428+
this.flowStatusDisabled.delete(name);
429+
this.flowVersionHistory.delete(name);
430+
// [#17396] Dropped with the flow, unlike `flowLedgerDisabled` above:
431+
// that one mirrors a DURABLE row and must survive, while this records
432+
// an in-process bind attempt that no longer has a subject.
433+
this.policyDisabledFlows.delete(name);
434+
this.subflowDeclinedFlows.delete(name);
435+
// [#23316] A withdrawal ends a refusal as it ends a registration: the
436+
// record's subject is gone. A host that withdraws BECAUSE it refused
437+
// records the refusal after this — see {@link recordFlowRefusal}.
438+
this.flowRefusals.delete(name);
439+
this.logger.info(`Flow unregistered: ${name}`);
440+
}
441+
442+
/** The body of {@link AutomationEngine.hydrateFlowActivations}, moved unedited; that method's docblock is its contract. */
443+
export async function hydrateFlowActivations(this: Pick<FlowDefinitionsHost, 'flowActivationStore' | 'flowLedgerDisabled' | 'flows' | 'deactivateFlowTrigger' | 'rejudgeSubflowCallers'>): Promise<string[]> {
444+
if (!this.flowActivationStore) return [];
445+
const rows = await this.flowActivationStore.list();
446+
const disabled: string[] = [];
447+
for (const row of rows) {
448+
if (row.active) {
449+
this.flowLedgerDisabled.delete(row.name);
450+
continue;
451+
}
452+
this.flowLedgerDisabled.add(row.name);
453+
// Only a flow this engine actually holds can be unbound; a row for
454+
// a flow this deployment does not ship is kept in the projection
455+
// (it costs one string, and the flow may be registered later by a
456+
// hot reload) but has nothing to disarm now.
457+
if (this.flows.has(row.name)) {
458+
this.deactivateFlowTrigger(row.name);
459+
disabled.push(row.name);
460+
}
461+
}
462+
// [#20725, ADR-0126 §7.3] The callers of what the ledger just switched
463+
// off go back to the arming gate. On a stock boot none is armed yet
464+
// (the trigger plugins register at kernel:ready, and the gate judges
465+
// them then), so this changes nothing there; a host whose trigger was
466+
// registered before the pull would otherwise keep a caller armed onto
467+
// a subflow this read has just switched off.
468+
for (const name of disabled) this.rejudgeSubflowCallers(name);
469+
return disabled;
470+
}
471+
472+
/** The body of {@link AutomationEngine.toggleFlow}, moved unedited; that method's docblock is its contract. */
473+
export async function toggleFlow(this: Pick<FlowDefinitionsHost, 'flows' | 'refuseCustomerAuthoredToggle' | 'packagedFlowOwner' | 'refuseEnableOntoDisabledSubflow' | 'packagedSubflowCallers' | 'isFlowEnabled' | 'parkedRunsOf' | 'refuseUnderReachingCallers' | 'flowActivationStore' | 'logger' | 'flowLedgerDisabled' | 'activateFlowTrigger' | 'deactivateFlowTrigger' | 'rejudgeSubflowCallers'>, name: string, enabled: boolean): Promise<void> {
474+
const flow = this.flows.get(name);
475+
if (!flow) {
476+
throw new Error(`Flow '${name}' not found`);
477+
}
478+
479+
// [#20726, ADR-0126 §7.2] FIRST, ahead of both §7.3 guards: a flow no
480+
// package ships is not this door's to switch in either direction, so
481+
// neither guard's question arises, the disable guard's run-store read
482+
// is never awaited for it, and the refusal lands before the ledger
483+
// write and before any in-process change — with a ledger attached or
484+
// in the degraded mode without one. Nothing half-flips.
485+
this.refuseCustomerAuthoredToggle(name, enabled);
486+
// Past the refusal the loader's set names a package, and the ledger row
487+
// is attributed to exactly that one — read with the verdict, so the
488+
// run-store await below cannot separate the two.
489+
const packageOwner = this.packagedFlowOwner(name) as string;
490+
491+
// [ADR-0126 §7.3] The subflow guard runs in BOTH directions, because
492+
// a subflow pair breaks from either end. Disabling a child breaks the
493+
// callers that still reach it — armed, or holding a parked run;
494+
// re-arming a caller whose child is off breaks the caller itself, at
495+
// its subflow node, on the child's refusal. Arming a flow never breaks
496+
// the flows that call IT, so enabling a child is not guarded by its
497+
// callers.
498+
if (enabled) {
499+
this.refuseEnableOntoDisabledSubflow(name);
500+
} else {
501+
// A switched-off caller guards only while it holds a parked run,
502+
// so only then are the run stores read — and only that read is
503+
// awaited: every other disable flips exactly as it did before,
504+
// synchronously up to its durable write. A store that cannot be
505+
// listed throws out of `parkedRunsOf` and nothing is written.
506+
const callers = this.packagedSubflowCallers(name);
507+
const switchedOff = callers.filter((c) => !this.isFlowEnabled(c));
508+
const parked = switchedOff.length > 0
509+
? await this.parkedRunsOf(new Set(switchedOff))
510+
: new Map<string, string[]>();
511+
this.refuseUnderReachingCallers(name, 'disable', callers, parked);
512+
}
513+
514+
// The durable row FIRST. A store that throws aborts the flip with
515+
// nothing changed in process, so the engine never reports an
516+
// activation state the ledger does not carry.
517+
if (this.flowActivationStore) {
518+
await this.flowActivationStore.setActive({
519+
name,
520+
// [#20761] The package the LOADER's set names, never the
521+
// definition's stamps — so no row is attributed to a package
522+
// from an authoring door. Read once, up top, with the verdict.
523+
packageId: packageOwner,
524+
active: enabled,
525+
});
526+
} else {
527+
// Degrading to in-process is a legitimate mode for a host with no
528+
// ObjectQL — degrading to it while REPORTING durability is not
529+
// (the posture this package already takes for suspended runs).
530+
// Note the leak commit 02b41232d measured is closed by the route's authority gate,
531+
// not by durability, so this degraded mode is not that leak.
532+
this.logger.warn(
533+
`[Automation] flow '${name}' ${enabled ? 'enabled' : 'disabled'} IN PROCESS ONLY — no activation ledger is ` +
534+
`attached (sys_metadata_activation), so this flip will NOT survive a restart.`,
535+
);
536+
}
537+
538+
if (enabled) this.flowLedgerDisabled.delete(name);
539+
else this.flowLedgerDisabled.add(name);
540+
541+
this.logger.info(`Flow '${name}' ${enabled ? 'enabled' : 'disabled'}`);
542+
// A disabled flow should stop receiving trigger events; a re-enabled one
543+
// should resume. execute() also guards disabled flows, but unbinding
544+
// avoids firing the trigger (and its event-source subscription) at all.
545+
// Re-enabling moves only the LEDGER bit: a flow whose `status` still
546+
// disables it stays unarmed, by `activateFlowTrigger`'s enablement gate
547+
// — armed, it would only fire runs `execute()` refuses.
548+
if (enabled) {
549+
this.activateFlowTrigger(name);
550+
} else {
551+
this.deactivateFlowTrigger(name);
552+
}
553+
// [#20725, ADR-0126 §7.3] Enabling a subflow arms the packaged callers
554+
// the gate declined onto it — through the gate, which still declines
555+
// one held back by ANOTHER disabled subflow. (Disabling it re-judges
556+
// nothing that the guard above let through: an armed caller refuses
557+
// the disable.)
558+
this.rejudgeSubflowCallers(name);
559+
}

0 commit comments

Comments
 (0)