Skip to content

Commit ae64120

Browse files
fix(objectql): a flow a composed stack declares only on its top level, beside package bodies, registers with the automation engine at boot (#28091)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D8XZuyAQgr8r5G9qo8hqggD)_ domain:server#4 · os-elon-musk · session_016WV4cyVfkboDHh1cixMUJn Fixes #28048 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:合成栈(`composeStacks`,`manifest: 'preserve'`)里,某个输入带 `packages[]` 包体、又只在顶层声明了一个 flow(没有包体声明同名)时,ObjectQL 的 `manifest` 服务现在把这个“残留”flow 注册进 ObjectQL 注册表,归属栈自己的 `manifest.id`,和残留对象、残留连接器已有的做法一样;自动化引擎开机即注册并可运行它。 - 为什么改:实测修复前,这个 flow 进了元数据服务(`/meta` 能列出),但自动化引擎开机只从注册表拉 flow,所以 `getFlow` 返回空、执行报 “not found”,触发器不会挂上:声明了却静默不生效。 - 风险与回滚:只多注册一种原本漏掉的残留项(开机能跑的东西变多了,属放宽);包体声明的 flow 仍只注册一次、归包体;单栈与常规合成不变。不改 spec,不改作者能写什么。回滚即 revert 本 PR。 - 先看:`packages/objectql/src/plugin.ts`(`unclaimedTopLevelItemsOfType` 与 `manifest` 服务里的残留注册循环)。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/residual-top-level-flow-boot.test.ts`(需先构建 `@objectstack/objectql`),预期 3 passed:残留 flow 开机注册、归栈 id、可运行;包体 flow 只注册一次;无顶层 flow 的栈不变。 - 需要你做的:无,席位落地(欠一次契约复审,席位安排)。 ## What changes - **`packages/objectql/src/plugin.ts`.** `unclaimedTopLevelConnectors(stack)` becomes `unclaimedTopLevelItemsOfType(stack, type)` for `type` `'connector' | 'flow'`: the residual rule's own answer (`unclaimedTopLevel`, `@objectstack/metadata`), filtered to one type, as `unclaimedTopLevelObjects` reads it for objects. The `manifest` service registers the residual's connectors and flows in one loop, in the place the connectors already registered: after every body and after the residual objects, under the residual rule's id, through `registry.registerItem(type, …)`, the verb a body's connectors and flows take. Each item is stamped with the stack's version on a shallow copy, so the artifact's own entry is left as it arrived. The "empty `packages: []`" warning no longer fires for a stack whose only residual is a flow. The connector path is unchanged in behaviour; its docblock moved into the shared reader. - **`packages/services/service-automation/src/residual-top-level-flow-boot.test.ts`** (new). A real kernel (ObjectKernel, ObjectQLPlugin over SQLite, AutomationServicePlugin) boots `composeStacks([mixed, other], { manifest: 'preserve' })` through the `manifest` service, the way `AppPlugin` hands a config boot's stack to it. Pins: the residual flow registers with the engine at boot, owned by the stack's id, and a run succeeds; a flow a package body declares and the top level repeats is registered once, owned by the body; CONTROL: a stack with no top-level flow registers exactly the bodies' flows. - **`packages/objectql/src/manifest-service-residual-objects.test.ts`.** Three engine-half pins, mirroring the connector ones: a residual flow registers under the stack's id and version beside a body's own; it registers when the stack has no other residual item; CONTROL: a top-level flow a body repeats is the body's, registered once. - **`.changeset/28048-residual-top-level-flow-registers.md`.** `@objectstack/objectql` patch. ## Why ### The measurement (before the fix, at `origin/main` `2e955dde`) The new boot test's stack, booted against the unfixed `@objectstack/objectql` build: `mixed` carries `packages: [{ manifest: { id: 'com.acme.orders', flows: [acme_body_flow] } }]` and, on its top level only, `flows: [acme_residual_flow]`. - `ql.registry.listItems('flow')`: `[["acme_body_flow","com.acme.orders"]]`. The residual flow is absent. - `automation.listFlows()`: `["acme_body_flow"]`. - `automation.getFlow('acme_residual_flow')`: `null`; `getFlow('acme_body_flow')` answers. - `automation.execute('acme_residual_flow')`: `{ success: false, error: "Flow 'acme_residual_flow' not found" }`. The card's premise held. The composed stack's `manifest.id` here is the last input's (`com.acme.other`), which is the id the residual rule names; the pin reads it off the stack rather than hard-coding it. ### Why flows, and why this path The triage ruling (6521012414) places the fix in the `manifest` service and names flows. Flows are a type a boot-time reader takes from the registry alone (the automation plugin's boot pull), exactly like connectors, so they take the connectors' path. The card's wider option (register the whole residual through `registerMetadataCollections`) lives in `packages/objectql/src/engine.ts`, which is a hot file held by #25522 and outside this claim's surface; it was not taken. ### Clause-② `yes (widening)`: a declared top-level flow that was silently dropped now registers and runs at boot. Nothing an author can write is refused that was accepted before, and no key is added (`Metadata-protocol: none`). ## Verification - `residual-top-level-flow-boot.test.ts`: red before the fix (1 of 3: the residual case), green after (3 of 3), against a rebuilt `@objectstack/objectql`. `connector-credential-residual-connector.test.ts` stays 4 of 4. - `manifest-service-residual-objects.test.ts`: red before (2 of 15: both residual-flow pins; the flow CONTROL green), 15 passed after. - Ablation on the committed head with `scripts/ablation-replace.mjs` (`(['connector', 'flow'] as const)` → `(['connector' /* ABLATION-28048 */] as const)`, objectql rebuilt under the mutation): engine pins 2 failed / 13 passed, boot pins 1 failed / 6 passed (the connector pins stay green); restored, `git diff HEAD` empty, rebuilt, green. - Full suites: `@objectstack/objectql` 494 files, 9603 passed; `@objectstack/service-automation` 226 files, 2860 passed. `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/service-automation typecheck`: exit 0. `pnpm lint`: exit 0. - `dispatch-gates --commands` derived 63 families on the committed head; all run with exit codes recorded. One exit 3 on the first pass (`check:dual-build-cjs-loads`, no dist for 41 packages), measured green after `turbo run build --filter='./packages/**'`. `--ran`: 63 derived, 63 run, 0 NOT-MEASURED (a derived zero). The head is 10 commits behind `origin/main`; none of them touches the files this diff changes. ## Acceptance notes - **Other residual types.** The `manifest` service now registers the residual's objects, connectors and flows. The other registry-read collections a residual can carry (permissions and the rest of `METADATA_ARRAY_KEYS`) are still metadata-service only, as the card notes by reading. Not measured and not widened here (the triage ruling names flows); named in the os-dev-report. - **Both doors.** The test boots the config door's hand-off to the `manifest` service. The artifact door hands the engine the same stack through the same service; it was not booted separately. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyVfkboDHh1cixMUJn Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6c2ed9 commit ae64120

4 files changed

Lines changed: 258 additions & 36 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a flow a composed stack declares only on its top level, beside package bodies that do not declare it, registers with the automation engine at boot
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The engine's `manifest` service registers a multi-package stack's residual (top-level items no package body declares, ADR-0130 D4) objects and connectors under the stack's own `manifest.id`; it now registers the residual's flows the same way. The automation plugin's boot pull reads flows from the ObjectQL registry only, so a residual flow used to be listed by the metadata service and never registered with the engine: `getFlow` answered `null`, a run answered "not found", and its trigger never armed. Such a flow now registers and runs at boot. A flow a package body declares is still registered once, under that body; stacks whose top level repeats their bodies, and single-package stacks, are unchanged.

‎packages/objectql/src/manifest-service-residual-objects.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,57 @@ describe('ADR-0015 D7 — the manifest service registers the residual\'s connect
332332
expect(connectorOwners(engineOf(kernel))).toEqual([['acme_sap', SERVICE_ID, '2.4.0']]);
333333
});
334334
});
335+
336+
/**
337+
* [ADR-0130 D4] The residual's FLOWS reach the registry too: the automation
338+
* plugin's boot pull reads `registry.listItems('flow')` alone, so a residual
339+
* flow registered by the metadata door only was listed by `/meta` and never
340+
* registered with the engine. The boot-and-run reading is pinned on a kernel
341+
* in `service-automation` (`residual-top-level-flow-boot.test.ts`).
342+
*/
343+
describe('ADR-0130 D4 — the manifest service registers the residual\'s flows', () => {
344+
const flow = (name = 'acme_escalate') => ({
345+
name,
346+
label: 'Escalate',
347+
type: 'autolaunched',
348+
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
349+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
350+
});
351+
const flowOwners = (ql: ObjectQL) =>
352+
(ql.registry.listItems('flow') as Array<{ name?: string; _packageId?: string; _packageVersion?: string }>)
353+
.map((f) => [f.name, f._packageId, f._packageVersion])
354+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
355+
356+
it('registers a residual flow under the stack\'s manifest id and version, beside a body\'s own', async () => {
357+
const residualEntry = flow();
358+
const { kernel } = await boot(registering('app', [
359+
residualStack(noteObj(), { flows: [residualEntry] }, { flows: [flow('acme_route')] }),
360+
]));
361+
expect(flowOwners(engineOf(kernel))).toEqual([
362+
['acme_escalate', RELEASE_ID, '1.0.0'],
363+
['acme_route', SERVICE_ID, '2.4.0'],
364+
]);
365+
// The artifact's own entry is left as it arrived.
366+
expect((residualEntry as { _packageId?: string })._packageId).toBeUndefined();
367+
});
368+
369+
it('registers a residual flow when the stack has no other residual item', async () => {
370+
const { kernel } = await boot(registering('app', [payload({
371+
manifest: { ...app, id: RELEASE_ID },
372+
objects: [caseObj(), accountObj()],
373+
flows: [flow()],
374+
packages: [
375+
{ manifest: { ...svc, objects: [caseObj()] } },
376+
{ manifest: { ...app, objects: [accountObj()] } },
377+
],
378+
})]));
379+
expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', RELEASE_ID, '1.0.0']]);
380+
});
381+
382+
it('CONTROL: a top-level flow a body repeats is the body\'s, registered once', async () => {
383+
const { kernel } = await boot(registering('app', [
384+
residualStack(noteObj(), { flows: [flow()] }, { flows: [flow()] }),
385+
]));
386+
expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', SERVICE_ID, '2.4.0']]);
387+
});
388+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -154,31 +154,38 @@ function unclaimedTopLevelObjects(
154154
}
155155

156156
/**
157-
* The CONNECTORS of a multi-package stack's residual, and the id they are owned
158-
* by — the residual rule's answer read the way {@link unclaimedTopLevelObjects}
159-
* reads it, for the one other residual type a boot-time reader takes from the
160-
* registry alone.
157+
* The items of ONE registry type in a multi-package stack's residual, and the
158+
* id they are owned by — the residual rule's answer read the way
159+
* {@link unclaimedTopLevelObjects} reads it, for the residual types a
160+
* boot-time reader takes from the registry alone:
161161
*
162-
* Why the engine needs them (ADR-0015 D7): the connector credential lender and
163-
* the ADR-0097 reconcile read `registry.listItems('connector')`, never the
164-
* metadata service. A residual connector instance registered by the metadata
165-
* door only was therefore invisible to both at boot — the instance did not
166-
* materialize, and a datasource's `external.connector` borrow of it was refused
167-
* as undeclared until a dev reload carried the artifact in — while the composed
168-
* check (`composeStacks`) accepts that borrow, because the instance is declared.
162+
* - `connector` (ADR-0015 D7): the connector credential lender and the
163+
* ADR-0097 reconcile read `registry.listItems('connector')`, never the
164+
* metadata service. A residual connector instance registered by the
165+
* metadata door only was therefore invisible to both at boot — the instance
166+
* did not materialize, and a datasource's `external.connector` borrow of it
167+
* was refused as undeclared until a dev reload carried the artifact in —
168+
* while the composed check (`composeStacks`) accepts that borrow, because
169+
* the instance is declared.
170+
* - `flow`: the automation plugin's boot pull (`service-automation`'s
171+
* `start()`) reads `registry.listItems('flow')`, never the metadata service.
172+
* A residual flow registered by the metadata door only was listed by `/meta`
173+
* and never registered with the engine: `getFlow` answered `null`, a run
174+
* answered "not found", and its trigger never armed.
169175
*
170-
* @returns `undefined` when the stack has no residual connector.
176+
* @returns `undefined` when the stack has no residual item of `type`.
171177
*/
172-
function unclaimedTopLevelConnectors(
178+
function unclaimedTopLevelItemsOfType(
173179
stack: unknown,
174-
): { ownerId: string | undefined; ownerVersion: string | undefined; connectors: Record<string, unknown>[] } | undefined {
180+
type: 'connector' | 'flow',
181+
): { type: 'connector' | 'flow'; ownerId: string | undefined; ownerVersion: string | undefined; items: Record<string, unknown>[] } | undefined {
175182
const residual = unclaimedTopLevel(stack);
176183
if (!residual) return undefined;
177-
const connectors = residual.items
178-
.filter((entry) => entry.type === 'connector')
184+
const items = residual.items
185+
.filter((entry) => entry.type === type)
179186
.map((entry) => entry.item as Record<string, unknown>);
180-
return connectors.length > 0
181-
? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, connectors }
187+
return items.length > 0
188+
? { type, ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, items }
182189
: undefined;
183190
}
184191

@@ -800,11 +807,14 @@ export class ObjectQLPlugin implements Plugin {
800807
// no package body declares — as the residual rule answers it. See
801808
// {@link unclaimedTopLevelObjects}.
802809
const residual = unclaimedTopLevelObjects(artifact);
803-
// …and its CONNECTORS, which the credential lender reads from the
804-
// registry alone. See {@link unclaimedTopLevelConnectors}.
805-
const residualConnectors = unclaimedTopLevelConnectors(artifact);
806-
807-
if (ordered.length === 0 && residual === undefined && residualConnectors === undefined) {
810+
// …and its CONNECTORS and FLOWS, which the credential lender and the
811+
// automation plugin's boot pull read from the registry alone. See
812+
// {@link unclaimedTopLevelItemsOfType}.
813+
const residualItems = (['connector', 'flow'] as const)
814+
.map((type) => unclaimedTopLevelItemsOfType(artifact, type))
815+
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
816+
817+
if (ordered.length === 0 && residual === undefined && residualItems.length === 0) {
808818
// An artifact that declared `packages: []` registers nothing. Said out
809819
// loud rather than returning quietly: "the install did nothing" is not
810820
// a state anyone should have to infer from an absence.
@@ -878,20 +888,20 @@ export class ObjectQLPlugin implements Plugin {
878888
// bodies (a composed stack keeps one member's manifest) that body's
879889
// record must not be replaced.
880890
if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered);
881-
// [ADR-0015 D7] The residual's connectors, the same way and in the same
882-
// place: after every body, under the residual rule's id, through the
883-
// registry verb a body's own connectors take (`registerApp`'s metadata
884-
// collections). No body declares one of these names — that is what
885-
// makes it residual — so none of them shadows a body's entry.
886-
// Stamped as a residual object is: the version first, on a shallow
887-
// copy, leaving the artifact's own entry as the registry leaves it.
888-
if (residualConnectors) {
889-
const { ownerId, ownerVersion, connectors } = residualConnectors;
890-
for (const connector of connectors) {
891+
// [ADR-0015 D7, ADR-0130 D4] The residual's connectors and flows, the
892+
// same way and in the same place: after every body, under the residual
893+
// rule's id, through the registry verb a body's own connectors and
894+
// flows take (`registerApp`'s metadata collections). No body declares
895+
// one of these names — that is what makes it residual — so none of
896+
// them shadows a body's entry. Stamped as a residual object is: the
897+
// version first, on a shallow copy, leaving the artifact's own entry as
898+
// the registry leaves it.
899+
for (const { type, ownerId, ownerVersion, items } of residualItems) {
900+
for (const item of items) {
891901
const stamped = ownerVersion === undefined
892-
? { ...connector }
893-
: applyProtection({ ...connector } as any, { packageId: ownerId, packageVersion: ownerVersion });
894-
ql.registry.registerItem('connector', stamped, 'name' as any, ownerId);
902+
? { ...item }
903+
: applyProtection({ ...item } as any, { packageId: ownerId, packageVersion: ownerVersion });
904+
ql.registry.registerItem(type, stamped, 'name' as any, ownerId);
895905
}
896906
}
897907
// [#24472] The boot reported every same-name pair it found
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0130 D4] A flow a composed stack declares only on its TOP LEVEL, beside
5+
* package bodies that do not declare it — the stack's residual — is registered
6+
* with the automation engine at boot and runs, the way one a body declares does.
7+
*
8+
* ## The defect this pins
9+
*
10+
* `composeStacks([mixed, other], { manifest: 'preserve' })`, where `mixed` is an
11+
* input that carries `packages[]` AND a top-level flow no body repeats, booted:
12+
* the residual rule registered that flow into the metadata service only, and the
13+
* engine's `manifest` service registered the residual's objects and connectors
14+
* only. The automation plugin's boot pull reads `registry.listItems('flow')`, so
15+
* the flow never reached the engine: `/meta` listed a flow `getFlow` answered
16+
* `null` for, and its trigger never armed.
17+
*
18+
* ## What each pin is for
19+
*
20+
* - the residual flow is registered with the engine at boot, owned by the
21+
* stack's own id (the owner the residual rule names), and runs;
22+
* - a flow a package body declares is registered once, owned by that body,
23+
* whether or not the top level repeats it;
24+
* - CONTROL: a stack with no residual flow registers exactly the bodies' flows.
25+
*
26+
* The stack is booted through the `manifest` service, the way `AppPlugin` hands
27+
* a config boot's stack to it: `{ ...bundle.manifest, ...bundle }`.
28+
*/
29+
import { afterEach, describe, expect, it } from 'vitest';
30+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
31+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { composeStacks, defineStack } from '@objectstack/spec';
34+
35+
import { AutomationServicePlugin } from './plugin.js';
36+
import type { AutomationEngine } from './engine.js';
37+
38+
const MIXED_ID = 'com.acme.bundle';
39+
const BODY_ID = 'com.acme.orders';
40+
41+
const pluginManifest = (id: string) => ({
42+
id,
43+
namespace: id.split('.').pop(),
44+
version: '1.0.0',
45+
type: 'plugin',
46+
name: id.split('.').pop(),
47+
});
48+
49+
const flow = (name: string) => ({
50+
name,
51+
label: name,
52+
type: 'autolaunched',
53+
nodes: [
54+
{ id: 'start', type: 'start', label: 'Start' },
55+
{ id: 'end', type: 'end', label: 'End' },
56+
],
57+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
58+
});
59+
60+
/**
61+
* An input that carries `packages[]` and, on its top level only, `topLevel`
62+
* flows — the mixed shape. `bodyFlows` lands on the package body.
63+
*/
64+
const mixed = (topLevel: unknown[] | undefined, bodyFlows: unknown[]) =>
65+
defineStack(
66+
{
67+
manifest: pluginManifest(MIXED_ID),
68+
...(topLevel ? { flows: topLevel } : {}),
69+
packages: [{ manifest: { ...pluginManifest(BODY_ID), flows: bodyFlows } }],
70+
} as never,
71+
{ strict: false },
72+
);
73+
74+
const other = () => defineStack({ manifest: pluginManifest('com.acme.other') } as never, { strict: false });
75+
76+
const composed = (topLevel: unknown[] | undefined, bodyFlows: unknown[]) =>
77+
composeStacks([mixed(topLevel, bodyFlows), other()] as never, { manifest: 'preserve' } as never) as Record<string, unknown>;
78+
79+
/** What `AppPlugin.init` hands the `manifest` service. */
80+
const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle });
81+
82+
const kernels: ObjectKernel[] = [];
83+
afterEach(async () => {
84+
for (const k of kernels.splice(0)) await k.shutdown().catch(() => {});
85+
});
86+
87+
/** Boot the engine and the automation plugin; register `stack` through the `manifest` service at init. */
88+
async function boot(stack: Record<string, unknown>) {
89+
const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false });
90+
kernels.push(kernel);
91+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
92+
await kernel.use({
93+
name: 'test.driver.sqlite',
94+
type: 'standard',
95+
version: '1.0.0',
96+
async init(ctx: PluginContext) {
97+
ctx.registerService('driver.default', driver);
98+
},
99+
} as Plugin);
100+
await kernel.use(new ObjectQLPlugin());
101+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
102+
await kernel.use({
103+
name: 'test.stack',
104+
type: 'standard',
105+
version: '1.0.0',
106+
dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'],
107+
async init(ctx: PluginContext) {
108+
await ctx.getService<{ register(m: unknown): Promise<void> }>('manifest').register(payload(stack));
109+
},
110+
} as Plugin);
111+
await kernel.bootstrap();
112+
return { ql: kernel.getService<ObjectQL>('objectql'), automation: kernel.getService<AutomationEngine>('automation') };
113+
}
114+
115+
const flowOwners = (ql: ObjectQL): Array<[unknown, unknown]> =>
116+
(ql.registry.listItems('flow') as Array<{ name?: unknown; _packageId?: unknown }>)
117+
.map((f): [unknown, unknown] => [f.name, f._packageId])
118+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
119+
120+
describe('[ADR-0130 D4] a flow a mixed composed input declares only on its top level', () => {
121+
it('is registered with the automation engine at boot, owned by the stack\'s own id, and runs', async () => {
122+
const stack = composed([flow('acme_residual_flow')], [flow('acme_body_flow')]);
123+
const { ql, automation } = await boot(stack);
124+
const ownerId = (stack.manifest as { id?: string }).id;
125+
126+
expect(flowOwners(ql)).toEqual([
127+
['acme_body_flow', BODY_ID],
128+
['acme_residual_flow', ownerId],
129+
]);
130+
expect((await automation.listFlows()).sort()).toEqual(['acme_body_flow', 'acme_residual_flow']);
131+
expect(await automation.getFlow('acme_residual_flow')).not.toBeNull();
132+
const run = await automation.execute('acme_residual_flow');
133+
expect(run.error).toBeUndefined();
134+
expect(run.success).toBe(true);
135+
});
136+
137+
it('a flow a package body declares is registered once, owned by that body, when the top level repeats it', async () => {
138+
const { ql, automation } = await boot(composed([flow('acme_body_flow')], [flow('acme_body_flow')]));
139+
expect(flowOwners(ql)).toEqual([['acme_body_flow', BODY_ID]]);
140+
expect(await automation.listFlows()).toEqual(['acme_body_flow']);
141+
});
142+
143+
it('CONTROL: a stack with no top-level flow registers exactly the bodies\' flows', async () => {
144+
const { ql, automation } = await boot(composed(undefined, [flow('acme_body_flow')]));
145+
expect(flowOwners(ql)).toEqual([['acme_body_flow', BODY_ID]]);
146+
expect(await automation.listFlows()).toEqual(['acme_body_flow']);
147+
});
148+
});

0 commit comments

Comments
 (0)