Skip to content

Commit 9bed0b0

Browse files
authored
fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028) (#12562)
* wip: surface flow-name shadowing on the os dev/os start banner * chore(cli): changeset for the banner flow-name shadowing line
1 parent aa45919 commit 9bed0b0

4 files changed

Lines changed: 404 additions & 4 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): the startup banner names a contested flow name and says which definition is armed (#12028)
6+
7+
`os dev` / `os start` print an automation summary that reads binding STATE off the
8+
live engine, because a flow that failed to arm emits no log line to go looking for
9+
and the boot-quiet stdout window swallows the engine's own `warn` narration. That
10+
summary was silent about the one failure it could not express as a count.
11+
12+
The engine's flow map is keyed by BARE name. When a packaged flow and a
13+
runtime-authored `sys_metadata` overlay both claim one name, ADR-0005 precedence
14+
arms one and the loser is not in the map — so it is not in `listFlows()`, not in
15+
`getFlowRuntimeStates()`'s rows, and therefore not in any number the banner
16+
prints. `3 flow(s), 3 bound to triggers` was a true sentence about a set that did
17+
not contain the definition the operator had just edited, and nothing on the banner
18+
said otherwise. #11997 gave the engine the receipt (`getShadowedFlows()`, plus
19+
`armedFrom` / `shadowed` on each runtime-state row) and the automation plugin
20+
warns from it at `kernel:bootstrapped` — but that is a `logger.warn`, which is
21+
exactly the channel this banner exists to work around.
22+
23+
`collectAutomationSummary` now reads that receipt through a probe feature-detected
24+
exactly like the `getTriggerBindingAudit` one beside it, and the banner prints one
25+
line per contested name carrying all three facts an operator needs:
26+
27+
```
28+
⚠ flow 'send-welcome' is claimed by 2 definitions — a runtime-authored row
29+
(sys_metadata) is ARMED, 1 shadowed (ADR-0005 overlay precedence; only the
30+
armed definition dispatches)
31+
```
32+
33+
Naming which body is armed is the point: a line reporting only the count tells an
34+
admin something is wrong and withholds the answer they are standing there to get.
35+
36+
Silent on every healthy boot — no contested name, no line. This banner is read on
37+
every start, and a warning that also fires when nothing is wrong is one readers
38+
learn to skip. Both directions are pinned on what the banner RENDERS, absence
39+
included.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The startup banner names a contested flow name, and says WHICH body is armed
5+
* (#12028).
6+
*
7+
* ── The hole this closes ─────────────────────────────────────────────────
8+
*
9+
* The engine's flow map is keyed by BARE name. When a packaged flow and a
10+
* runtime-authored `sys_metadata` overlay both claim one name, ADR-0005
11+
* precedence arms one of them and the loser is not in the map — so it is not in
12+
* `listFlows()`, not in `getFlowRuntimeStates()`'s row set, and therefore not in
13+
* ANY count this banner prints. `3 flow(s), 3 bound to triggers` is a true
14+
* sentence about a set that does not contain the definition the operator just
15+
* edited, and nothing on the banner contradicts it.
16+
*
17+
* #11997 gave the engine the receipt (`getShadowedFlows()`, plus `armedFrom` /
18+
* `shadowed` on each runtime-state row). The automation plugin warns from it at
19+
* `kernel:bootstrapped` — but that is a `logger.warn`, and the whole reason this
20+
* banner reads engine STATE rather than scraping output is that the boot-quiet
21+
* stdout window swallows exactly those lines. The banner was the reliable
22+
* channel and it was silent.
23+
*
24+
* ── Why these pins read the RENDERED line ────────────────────────────────
25+
*
26+
* Asserting that `collectAutomationSummary` "read the field" would pass with a
27+
* banner that prints nothing, which is the defect. So every pin below drives a
28+
* shadowing receipt through the real `collectAutomationSummary` and the real
29+
* `printServerReady`, and reads the stderr line an operator sees — the shape
30+
* `format.seed-summary.test.ts` and `serve-organizations-message-spelling.test.ts`
31+
* already use on this surface.
32+
*
33+
* ── The instrument must be able to say no ────────────────────────────────
34+
*
35+
* `prints no shadowing line …` is not filler. The banner is read on every
36+
* `os dev` / `os start`; a warning that also appears when nothing is wrong is a
37+
* warning readers learn to skip, and the next real one goes with it. An
38+
* always-firing implementation passes every positive pin in this file, so the
39+
* absence legs are what actually constrain it.
40+
*
41+
* ── The fakes ────────────────────────────────────────────────────────────
42+
*
43+
* `getShadowedFlows()` returns `FlowShadowingRecord[]` — `{ name, armed,
44+
* shadowed }`, where a contender is `{ source: 'package' | 'runtime';
45+
* packageId?: string }` (`packages/services/service-automation/src/engine.ts`,
46+
* `FlowContender` / `FlowShadowingRecord`). Hand-rolled here rather than
47+
* imported, matching the sibling `serve-automation-summary.test.ts`: the probe
48+
* under test is feature-detected against an OLDER automation package, so typing
49+
* these fakes against the current one would defeat the tolerance legs below.
50+
*/
51+
52+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
53+
import { collectAutomationSummary } from './serve.js';
54+
import { printServerReady, type ServerReadyOptions } from '../utils/format.js';
55+
56+
type Contender = { source: 'package' | 'runtime'; packageId?: string };
57+
type ShadowRecord = { name: string; armed: Contender; shadowed: Contender[] };
58+
59+
type FlowState = {
60+
name: string;
61+
enabled: boolean;
62+
bound: boolean;
63+
status?: string;
64+
triggerType?: string;
65+
object?: string;
66+
};
67+
68+
function fakeKernel(services: Record<string, unknown>) {
69+
return {
70+
getService(name: string) {
71+
if (!(name in services)) throw new Error(`Service '${name}' not found`);
72+
return services[name];
73+
},
74+
};
75+
}
76+
77+
/** An engine that knows about shadowing — i.e. anything from #11997 onwards. */
78+
function fakeAutomation(states: FlowState[], shadowing: ShadowRecord[] = []) {
79+
return {
80+
getFlowRuntimeStates: () =>
81+
states.map((s) => {
82+
const record = shadowing.find((r) => r.name === s.name);
83+
// The engine attaches the receipt to the row too, for a name still in
84+
// the flow map. Mirrored here so the fake is not quietly narrower than
85+
// the thing it stands in for.
86+
return record ? { ...s, armedFrom: record.armed, shadowed: record.shadowed } : s;
87+
}),
88+
getRegisteredTriggerTypes: () => ['record_change'],
89+
getTriggerBindingAudit: () => [],
90+
getShadowedFlows: () => shadowing,
91+
};
92+
}
93+
94+
const armed = (states: FlowState[], shadowing: ShadowRecord[] = []) =>
95+
collectAutomationSummary(fakeKernel({ automation: fakeAutomation(states, shadowing) }), states.length);
96+
97+
const flow = (name: string): FlowState => ({
98+
name,
99+
enabled: true,
100+
bound: true,
101+
status: 'active',
102+
triggerType: 'record_change',
103+
object: 'lead',
104+
});
105+
106+
/**
107+
* The realistic pair, in the direction ADR-0005 actually resolves: a runtime
108+
* overlay row OUTRANKS the packaged body, so the definition shipped in the
109+
* package is the one that stopped running.
110+
*/
111+
const CONTESTED: ShadowRecord = {
112+
name: 'send-welcome',
113+
armed: { source: 'runtime' },
114+
shadowed: [{ source: 'package', packageId: 'crm' }],
115+
};
116+
117+
describe('collectAutomationSummary — flow-name shadowing (#12028)', () => {
118+
it('carries the contested name, the armed body and the displaced count', () => {
119+
const summary = armed([flow('send-welcome'), flow('score-lead')], [CONTESTED])!;
120+
expect(summary.shadowed).toEqual([
121+
{ flowName: 'send-welcome', armed: { source: 'runtime' }, shadowedCount: 1 },
122+
]);
123+
});
124+
125+
it('is empty when no name is contested', () => {
126+
expect(armed([flow('send-welcome')])!.shadowed).toEqual([]);
127+
});
128+
129+
it('drops a receipt that displaced nothing — that is not a contested name', () => {
130+
const summary = armed(
131+
[flow('send-welcome')],
132+
[{ name: 'send-welcome', armed: { source: 'runtime' }, shadowed: [] }],
133+
)!;
134+
expect(summary.shadowed).toEqual([]);
135+
});
136+
137+
// The probe is feature-detected exactly like the `unbound` one beside it, and
138+
// with nothing more. These two legs are what "exactly" means: an automation
139+
// package predating #11997 has no `getShadowedFlows` at all, and the banner
140+
// must degrade to its plain counts rather than take the whole boot down.
141+
it('degrades on an engine that predates the receipt, without losing the banner', () => {
142+
const older = {
143+
getFlowRuntimeStates: () => [flow('send-welcome')],
144+
getRegisteredTriggerTypes: () => ['record_change'],
145+
getTriggerBindingAudit: () => [],
146+
};
147+
const summary = collectAutomationSummary(fakeKernel({ automation: older }), 1)!;
148+
expect(summary.shadowed).toEqual([]);
149+
expect(summary.flowCount).toBe(1);
150+
});
151+
152+
it('degrades when the receipt probe throws', () => {
153+
const hostile = {
154+
getFlowRuntimeStates: () => [flow('send-welcome')],
155+
getRegisteredTriggerTypes: () => ['record_change'],
156+
getTriggerBindingAudit: () => [],
157+
getShadowedFlows: () => {
158+
throw new Error('older engine');
159+
},
160+
};
161+
const summary = collectAutomationSummary(fakeKernel({ automation: hostile }), 1)!;
162+
expect(summary.shadowed).toEqual([]);
163+
expect(summary.flowCount).toBe(1);
164+
});
165+
});
166+
167+
describe('startup banner — what an operator reads when a flow name is contested (#12028)', () => {
168+
const base: ServerReadyOptions = {
169+
externalBaseOrigin: 'http://localhost:3000',
170+
configFile: 'objectstack.config.ts',
171+
isDev: true,
172+
pluginCount: 1,
173+
};
174+
let lines: string[];
175+
let spy: ReturnType<typeof vi.spyOn>;
176+
177+
beforeEach(() => {
178+
lines = [];
179+
// stderr, not stdout (#7915) — the whole banner is a diagnostic.
180+
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
181+
lines.push(args.join(' '));
182+
});
183+
});
184+
afterEach(() => spy.mockRestore());
185+
186+
const render = (states: FlowState[], shadowing: ShadowRecord[] = []) => {
187+
printServerReady({ ...base, automation: armed(states, shadowing) });
188+
return lines.filter((l) => l.includes('is claimed by'));
189+
};
190+
191+
it('names the contested flow, WHICH definition is armed, and how many were shadowed', () => {
192+
const shown = render([flow('send-welcome'), flow('score-lead')], [CONTESTED]);
193+
expect(shown).toHaveLength(1);
194+
// All three facts, in the one line the operator gets. The middle one is the
195+
// point of the card: a line that reports the count and stops has told an
196+
// admin something is wrong and withheld which body is running.
197+
expect(shown[0]).toContain("flow 'send-welcome'"); // which name
198+
expect(shown[0]).toContain('a runtime-authored row (sys_metadata) is ARMED'); // which body
199+
expect(shown[0]).toContain('1 shadowed'); // how many lost
200+
expect(shown[0]).toContain('is claimed by 2 definitions');
201+
expect(shown[0]).toContain('only the armed definition dispatches');
202+
});
203+
204+
it('names the package when the packaged body is the one that armed', () => {
205+
const shown = render(
206+
[flow('send-welcome')],
207+
[{ name: 'send-welcome', armed: { source: 'package', packageId: 'crm' }, shadowed: [{ source: 'runtime' }] }],
208+
);
209+
expect(shown[0]).toContain("package 'crm' is ARMED");
210+
});
211+
212+
it('never interpolates an absent package id into the sentence', () => {
213+
const shown = render(
214+
[flow('send-welcome')],
215+
[{ name: 'send-welcome', armed: { source: 'package' }, shadowed: [{ source: 'runtime' }] }],
216+
);
217+
expect(shown[0]).toContain('a code-shipped package (id unknown) is ARMED');
218+
expect(shown[0]).not.toContain('undefined');
219+
});
220+
221+
it('counts every displaced definition, not just the first', () => {
222+
const shown = render(
223+
[flow('send-welcome')],
224+
[{
225+
name: 'send-welcome',
226+
armed: { source: 'runtime' },
227+
shadowed: [{ source: 'package', packageId: 'crm' }, { source: 'package', packageId: 'marketing' }],
228+
}],
229+
);
230+
expect(shown[0]).toContain('is claimed by 3 definitions');
231+
expect(shown[0]).toContain('2 shadowed');
232+
});
233+
234+
it('reports one line per contested name', () => {
235+
const shown = render(
236+
[flow('send-welcome'), flow('score-lead')],
237+
[
238+
CONTESTED,
239+
{ name: 'score-lead', armed: { source: 'runtime' }, shadowed: [{ source: 'package', packageId: 'crm' }] },
240+
],
241+
);
242+
expect(shown).toHaveLength(2);
243+
expect(shown.join('\n')).toContain("flow 'score-lead'");
244+
});
245+
246+
// ── The instrument can say no ──────────────────────────────────────────
247+
it('prints no shadowing line on a healthy boot, while still printing the counts', () => {
248+
expect(render([flow('send-welcome'), flow('score-lead')])).toEqual([]);
249+
// Not silent about everything — the ordinary Flows: row is still there, so
250+
// the absence above is the line being withheld, not the banner being off.
251+
expect(lines.some((l) => l.includes('Flows:') && l.includes('2 flow(s)'))).toBe(true);
252+
});
253+
254+
it('prints no shadowing line for a receipt that displaced nothing', () => {
255+
expect(
256+
render([flow('send-welcome')], [{ name: 'send-welcome', armed: { source: 'runtime' }, shadowed: [] }]),
257+
).toEqual([]);
258+
});
259+
260+
it('prints no shadowing line when the automation engine is not enabled at all', () => {
261+
printServerReady({ ...base, automation: collectAutomationSummary(fakeKernel({}), 2) });
262+
expect(lines.filter((l) => l.includes('is claimed by'))).toEqual([]);
263+
});
264+
});

packages/cli/src/commands/serve.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5160,9 +5160,9 @@ export function resolveBannerConfigRow(opts: {
51605160
* banner reads the binding state off the engine instead).
51615161
*
51625162
* Every probe is feature-detected so an older `@objectstack/service-automation`
5163-
* (without `getTriggerBindingAudit` / extended runtime states) degrades to the
5164-
* plain count line instead of crashing the banner. Returns `undefined` when
5165-
* there is nothing automation-related to show at all.
5163+
* (without `getTriggerBindingAudit` / `getShadowedFlows` / extended runtime
5164+
* states) degrades to the plain count line instead of crashing the banner.
5165+
* Returns `undefined` when there is nothing automation-related to show at all.
51665166
*/
51675167
export function collectAutomationSummary(
51685168
kernel: any,
@@ -5181,12 +5181,40 @@ export function collectAutomationSummary(
51815181
triggerTypes: [],
51825182
unbound: [],
51835183
unknownObject: [],
5184+
shadowed: [],
51845185
draftCount: 0,
51855186
}
51865187
: undefined;
51875188
}
51885189

5189-
let states: Array<{ name: string; enabled: boolean; bound: boolean; status?: string; triggerType?: string; object?: string }> = [];
5190+
/**
5191+
* One flow body's provenance, as the engine spells it (`FlowContender` in
5192+
* `@objectstack/service-automation`): a code-shipped artifact, or an
5193+
* ADR-0005 runtime overlay row in `sys_metadata`.
5194+
*
5195+
* Declared structurally, like every other shape this function reads off the
5196+
* engine. The probes below are feature-detected precisely so a host running
5197+
* an OLDER automation package still boots its banner, and a nominal import
5198+
* would type these reads against the CURRENT package while the runtime
5199+
* deliberately tolerates a previous one.
5200+
*/
5201+
type Contender = { source: 'package' | 'runtime'; packageId?: string };
5202+
5203+
let states: Array<{
5204+
name: string;
5205+
enabled: boolean;
5206+
bound: boolean;
5207+
status?: string;
5208+
triggerType?: string;
5209+
object?: string;
5210+
// [#12028] `getFlowRuntimeStates()` has attached these two per row since
5211+
// #11997 whenever a bare name had more than one contender. Named here so a
5212+
// later read is type-checked against the row's real shape — casting past
5213+
// this annotation is how the field goes back to being declared and unread,
5214+
// which is the whole defect this banner line closes.
5215+
armedFrom?: Contender;
5216+
shadowed?: Contender[];
5217+
}> = [];
51905218
try { states = automation.getFlowRuntimeStates?.() ?? []; } catch { /* older engine */ }
51915219
if (states.length === 0 && declaredFlowCount === 0) return undefined;
51925220

@@ -5196,6 +5224,19 @@ export function collectAutomationSummary(
51965224
let unbound: Array<{ flowName: string; triggerType: string; reason: string }> = [];
51975225
try { unbound = automation.getTriggerBindingAudit?.() ?? []; } catch { /* older engine */ }
51985226

5227+
// [#12028] Same-named definitions: which body armed, and which lost. Read
5228+
// from the engine's dedicated receipt rather than from `states` above, for
5229+
// one measured reason — `getFlowRuntimeStates()` can only attach the receipt
5230+
// to a row it is already emitting, i.e. to a name still in the flow map,
5231+
// whereas `getShadowedFlows()` returns every receipt the boot pull recorded.
5232+
// A contested name is worth saying out loud either way.
5233+
//
5234+
// Feature-detected exactly like the `unbound` probe above and with nothing
5235+
// more: optional call, `?? []`, `catch` for an older engine. No extra
5236+
// tolerance — the neighbouring read is the standard here.
5237+
let shadowing: Array<{ name: string; armed: Contender; shadowed: Contender[] }> = [];
5238+
try { shadowing = automation.getShadowedFlows?.() ?? []; } catch { /* older engine */ }
5239+
51995240
// Dead bindings: a bound record-change flow whose target object nobody
52005241
// registered — the hook is filtered to a name that never writes.
52015242
const unknownObject: Array<{ flowName: string; object: string }> = [];
@@ -5218,6 +5259,13 @@ export function collectAutomationSummary(
52185259
triggerTypes,
52195260
unbound,
52205261
unknownObject,
5262+
// A receipt that displaced nothing is not a contested name. The engine
5263+
// already refuses to record one, and the banner keeps its own end of that
5264+
// guarantee here rather than inheriting it: a benign boot must print no
5265+
// shadowing line at all, and this is where "benign" is decided.
5266+
shadowed: shadowing
5267+
.filter((r) => r.shadowed.length > 0)
5268+
.map((r) => ({ flowName: r.name, armed: r.armed, shadowedCount: r.shadowed.length })),
52215269
draftCount: states.filter((s) => s.enabled && (s.status ?? 'draft') === 'draft').length,
52225270
};
52235271
}

0 commit comments

Comments
 (0)