Skip to content

Commit 8e0f3db

Browse files
fix(cli): the flow-region gate never passes a flow it could not judge, and the boot banner counts refused flows (#29243)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CRVFsFbBGh67CEn4YUnUGT)_ Fixes #29059 Clause-②: no Metadata-protocol: none domain:server#3 · os-steve · session_01ADHkmQBbn1k9FtQeh2nJSs ## 维护者速读 - 改了什么:`os validate` / `os build` 的 flow 区域门禁在 FlowSchema 解析失败时不再静默放行,改为报一条带路径的 VALIDATION_ERROR;`os serve` / `os dev` 启动横幅新增一行「N flow(s) declared, M refused at registration」。 - 为什么改:卡 #29059 是 #29043 合同复审的四条应修项加 #28893 的启动半边:门禁的静默跳过、空转的盲区断言、未测的 packages[] 主体、过期计数,以及启动时被拒的 flow 只在 Boot diagnostics 里逐条出现。 - 风险与回滚:今天被拒或被收的 flow 都不变(解析失败分支对作者不可达,见下);横幅只在有拒载时多一行,启动是否继续不变。回滚即 revert 本 PR。 - 先看:`packages/cli/src/utils/flow-region-shape.ts`(解析失败分支)、`packages/cli/src/commands/serve.ts` 的 `collectAutomationSummary`(拒载计数读 engine 自己的 `getFlowRegistration`)、`packages/cli/src/utils/format.ts` 的 `printAutomationSummary`(行序与全拒时的早返回)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/commands/serve-automation-refused.test.ts src/utils/flow-region-shape.test.ts`,预期 7 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes All five Wanted items of #29059. 1. **No silent pass on a parse failure** (`utils/flow-region-shape.ts`). `findFlowRegionRefusals` used to `return` with no row when `FlowSchema.safeParse` refused a flow. It now pushes a `VALIDATION_ERROR` row at the flow's `path`, named by the flow's `name` (or its path when it has no string name), carrying the schema's issues. The door refuses on it like any other row. New unit test `src/utils/flow-region-shape.test.ts` forces the branch (own flows, nameless body, `packages[]` entry). 2. **The blindness pin is no longer vacuous** (`test/authoring-rule-command-parity.test.ts`). Each `OUTSIDE_THE_TABLE` entry now names its `gate`, and the case first asserts that `gate(stack)` is non-empty and carries the marker, then that the registry run as `os lint` runs it reports nothing with the marker. The docblock now says it measures the registry, not the `os lint` command. 3. **`packages[]` subject tested end to end** (`test/validate-flow-region-shape.test.ts`). A two-entry assembled artifact with the malformed flow in the second entry: `os validate --json` and `os build --json` both exit 1 with exactly one region row `{ path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_ops_lead_time' }`, and build writes no `dist/`. 4. **Stale prose**: the parity file's test-budget header says "the ten rule cases" (nine `CASES` plus one `OUTSIDE_THE_TABLE`). 5. **Boot banner count** (`commands/serve.ts`, `utils/format.ts`). `collectAutomationSummary` takes the declared flows' names and counts those for which the engine's `getFlowRegistration(name).state` is `refused`. That read already exists on the engine and on `IAutomationService` (optional, the metadata door uses it), and the record behind it is written by the plugin's `kernel:ready` cold-boot bind (`recordFlowRefusal`). So no new engine or plugin surface. The banner prints `⚠ Flows: N flow(s) declared, M refused at registration — …` after the Flows count line, and alone when every declared flow was refused (the old early return on `flowCount === 0` would have printed nothing). No line when nothing is refused. Whether boot continues is unchanged. `AutomationReadySummary` gains `refusedCount`. It is not on any published entry of `@objectstack/cli` (`exports` is `.`, `./console`, `./hook-body`; `index.ts` re-exports only the `ServeCommand` default), and the banner is stderr text, so no JSON or published member changes. **Forced test edits:** `src/utils/format.boot-warning-classes.test.ts` and `src/utils/format.flow-contender-agreement.test.ts` build `AutomationReadySummary` literals by hand; each gains `refusedCount: 0` because the member is required. ## Tests Red first, by ablation (`scripts/ablation-replace.mjs`, each leg restored and proven by blob hash equal to `HEAD` and an empty `git diff HEAD`): <details><summary>(1) parse failure: put the silent return back</summary> On `03cc2f40`, `flow-region-shape.ts` anchor `if (!result.success) {` replaced by `if (result.success === false) { return; } if (false) {`: `src/utils/flow-region-shape.test.ts` 3 failed / 3, each `expected [] to deeply equal [ { path: 'flows[0]', … } ]`. (A first attempt whose replacement contained the anchor was refused by the tool as a no-op and restored; it is not counted.) </details> <details><summary>(2) vacuous pin: a fixture typo</summary> Fixture edge `dy` rewired `d → y` to `x → y` (the catch region becomes single-exit): with this PR's assertion, `known gap` case 1 failed, `[]: expected [] to not deeply equal []`. Control leg, same typo with the new assertion deleted (the pin as on `main`): 1 passed. The old pin stayed green on a fixture nothing refuses. </details> <details><summary>(3) packages[] subject</summary> `const subjects = entries.length > 0` → `false && …` (walk the empty top level only): the assembled case failed, `os validate` exit `+0`, expected 1. Path spelling `packages[N].manifest.` → `packages[N].`: failed with `path: "packages[1].flows[0]"` against the expected `packages[1].manifest.flows[0]`. </details> <details><summary>(5) boot banner count</summary> `serve.ts` `refusedCount++` → `void 0`: `serve-automation-refused.test.ts` 2 failed / 4 (`refusedCount` 0). On `48c3a64c`, the all-refused branch's `printRefused();` → `void printRefused;`: 1 failed / 4, `expected [] to have a length of 1`. (On `03cc2f40`, the earlier line placement, gating the line on `flowCount > 0` failed the same case.) The test drives the REAL `AutomationEngine` (dist): `registerFlow` refuses a one-branch `parallel`, the test records it the way the host's sync does, and the collector and the real `printServerReady` read it. Real boot, one-off measurement (not kept as a test): `os serve` on a two-flow config with one one-branch `parallel`, `OS_DATABASE_URL=:memory:`, printed ```text ⚠ Flows: 2 flow(s) declared, 1 refused at registration — refused flows never run; each one's reason is under Boot diagnostics ('failed to register flow') Flows: 1 flow(s) 1 bound to triggers (record_change, schedule, time_relative, api) ``` with both `[Automation] failed to register flow` and `[Automation] cold-boot flow bind: failed to register flow` for `bt_fan` under Boot diagnostics, and boot continued to `Press Ctrl+C to stop`. (That run had the line before the count line; `48c3a64c` moved it after.) </details> ## Verification All on the final head `fc24941128` (this branch merged with `origin/main` `dedc350519`; clean merge), after rebuilding `@objectstack/cli` and its dependency closure: - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck`): exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 374 files, 5252 tests passed. - `test/authoring-rule-command-parity.test.ts` (integration tier, edited here): 12 / 12 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 63 commands for this change set; all 63 exit 0; `--ran` reconciliation: 63 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm lint`: exit 0. On the pre-merge head `48c3a64c` the same set was green too. Five gates first exited 3 (prerequisite not met: no full build, shallow clone) and were rerun green after `turbo run build` and an unshallow fetch: `check-plugin-teardown-shape --self-test`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. Dogfood battery: not owed (no `!` in the title, `Clause-②: no` with no narrowing arm). <details><summary>The 63 derived commands, each with its exit code</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: 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-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.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 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: 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:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: 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:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: 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:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Acceptance notes - **Clause-② stays `no`.** The parse-failure branch cannot be reached by an authored stack: every flow the walk reads was parsed by the stack schema's `flows: z.array(FlowSchema)` (and, under `packages[]`, by `AssembledPackageBodySchema`, whose collections come from the same stack shape), and `FlowSchema` accepts its own output. All existing validate/build/parity cases and the CONTROL case stay green, so nothing that passes today is refused. - **What the count does not see.** It counts DECLARED flow names (the config, packages included). A runtime-authored `sys_metadata` flow the engine refuses is not declared, so it is not counted; its per-flow line under Boot diagnostics is unchanged. And the record is written by the `kernel:ready` bind; the boot pull's own refusals are deliberately not recorded (AGENTS.md "Startup registry reads"). A host with no `protocol` service runs no such bind, so its count reads 0 there while the warn still prints. Every `os serve` boot measured here has the bind. - Integration tier: `test/authoring-rule-command-parity.test.ts` is integration (it spawns the CLI) and was run locally in full because it is edited; the rest of the integration tier is declared to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQBbn1k9FtQeh2nJSs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78f78ac commit 8e0f3db

10 files changed

Lines changed: 333 additions & 14 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): the os validate / os build flow-region check never passes a flow it could not judge, and the boot banner counts flows refused at registration
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **No silent pass.** The flow-region check `os validate` and `os build` run (the automation engine's registration verdict, ADR-0031) parses each flow with `FlowSchema` before it judges the regions. When that parse failed it used to skip the flow with no row, so the flow passed unjudged. It now refuses it with a `VALIDATION_ERROR` row at the flow's `path`, naming the flow and the schema's issues. No authored stack reaches this branch today: the stack schema has already parsed every flow the check reads, and `FlowSchema` accepts its own output. Nothing refused or accepted today changes.
11+
- **Refused flows in the boot banner.** When the automation engine refuses a declared flow at registration, boot still continues and the per-flow reason is still under Boot diagnostics. The `os serve` / `os dev` banner now also prints one line beside the declared count, for example `⚠ Flows: 2 flow(s) declared, 1 refused at registration`, read from the engine's own record (`getFlowRegistration`). It prints even when every declared flow was refused and nothing registered. A boot with nothing refused prints no such line.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#29059] The boot banner counts declared flows the engine REFUSED at
4+
// registration, beside the declared count.
5+
//
6+
// A flow `registerFlow` refuses is logged once at WARN and the boot goes on
7+
// without it (#28893's second ask; that is unchanged here). Before this the
8+
// banner's Flows line counted only what armed, so a refused flow showed only
9+
// as a per-flow line under Boot diagnostics, and a boot whose every declared
10+
// flow was refused printed no Flows line at all.
11+
//
12+
// The count is read off the engine's own record: the host's sync calls
13+
// `recordFlowRefusal` when `registerFlow` throws, and the banner asks
14+
// `getFlowRegistration(name)` for each declared name, the read the metadata
15+
// door already serves. So the engine here is the REAL one (dist, already in
16+
// `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`), refusing a real
17+
// malformed region, not a fake that answers `refused` by fiat. Locally: build
18+
// `@objectstack/service-automation` before running this.
19+
20+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
21+
import { AutomationEngine } from '@objectstack/service-automation';
22+
import { collectAutomationSummary } from './serve.js';
23+
import { printServerReady, type ServerReadyOptions } from '../utils/format.js';
24+
25+
const quiet = () => ({ debug() {}, info() {}, warn() {}, error() {} }) as never;
26+
27+
const kernelWith = (automation: unknown) => ({
28+
getService(name: string) {
29+
if (name === 'automation') return automation;
30+
throw new Error(`Service '${name}' not found`);
31+
},
32+
});
33+
34+
const n = (id: string, type = 'assignment', extra: Record<string, unknown> = {}) => ({ id, type, label: id, ...extra });
35+
const e = (id: string, source: string, target: string) => ({ id, source, target, type: 'default' });
36+
37+
/** An autolaunched flow: start → <middle> → end. */
38+
const flow = (name: string, middle: Record<string, unknown>) => ({
39+
name, label: name, type: 'autolaunched', status: 'active',
40+
nodes: [n('start', 'start'), middle, n('end', 'end')],
41+
edges: [e('e1', 'start', String(middle.id)), e('e2', String(middle.id), 'end')],
42+
});
43+
44+
/** A `parallel` with one branch: `validateControlFlow` refuses it at registration. */
45+
const refusedFlow = (name: string) => flow(name, n('fan', 'parallel', { config: { branches: [{ nodes: [n('only')], edges: [] }] } }));
46+
const goodFlow = (name: string) => flow(name, n('work'));
47+
48+
/** Register each flow the way the host's sync does: a refusal is recorded, never thrown on. */
49+
function engineWith(flows: Array<{ name: string }>): AutomationEngine {
50+
const engine = new AutomationEngine(quiet());
51+
for (const def of flows) {
52+
try {
53+
engine.registerFlow(def.name, def as never);
54+
} catch (err) {
55+
engine.recordFlowRefusal(def.name, err);
56+
}
57+
}
58+
return engine;
59+
}
60+
61+
const base: ServerReadyOptions = {
62+
externalBaseOrigin: 'http://localhost:3000',
63+
httpServer: 'composed',
64+
configFile: 'objectstack.config.ts',
65+
isDev: true,
66+
pluginCount: 1,
67+
};
68+
69+
describe('[#29059] the boot banner counts flows refused at registration', () => {
70+
let lines: string[];
71+
let spy: ReturnType<typeof vi.spyOn>;
72+
beforeEach(() => {
73+
lines = [];
74+
// stderr, not stdout (#7915) — the whole banner is a diagnostic.
75+
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
76+
lines.push(args.join(' '));
77+
});
78+
});
79+
afterEach(() => spy.mockRestore());
80+
81+
const flowsLines = () => lines.filter((l) => l.includes('Flows:'));
82+
83+
it('one of two declared flows refused: the count rides beside the declared count', () => {
84+
const declared = [goodFlow('ok_flow'), refusedFlow('fan_flow')];
85+
const engine = engineWith(declared);
86+
// The engine really refused it: the record is its own, not the test's.
87+
expect(engine.getFlowRegistration('fan_flow').state).toBe('refused');
88+
89+
const summary = collectAutomationSummary(kernelWith(engine), declared.length, declared.map((f) => f.name))!;
90+
expect(summary).toMatchObject({ declaredFlowCount: 2, refusedCount: 1, flowCount: 1 });
91+
92+
printServerReady({ ...base, automation: summary });
93+
expect(flowsLines().filter((l) => l.includes('2 flow(s) declared, 1 refused at registration'))).toHaveLength(1);
94+
});
95+
96+
it('every declared flow refused: the banner still says so, though nothing registered', () => {
97+
const declared = [refusedFlow('fan_flow')];
98+
const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['fan_flow'])!;
99+
expect(summary).toMatchObject({ declaredFlowCount: 1, refusedCount: 1, flowCount: 0 });
100+
101+
printServerReady({ ...base, automation: summary });
102+
expect(flowsLines().filter((l) => l.includes('1 flow(s) declared, 1 refused at registration'))).toHaveLength(1);
103+
});
104+
105+
it('CONTROL: nothing refused prints no refused line', () => {
106+
const declared = [goodFlow('ok_flow')];
107+
const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['ok_flow'])!;
108+
expect(summary.refusedCount).toBe(0);
109+
110+
printServerReady({ ...base, automation: summary });
111+
expect(lines.filter((l) => l.includes('refused at registration'))).toEqual([]);
112+
expect(flowsLines()).toHaveLength(1);
113+
});
114+
115+
it('an engine that does not answer the read degrades to a zero count, not a crash', () => {
116+
const older = { getFlowRuntimeStates: () => [{ name: 'ok_flow', enabled: true, bound: false }] };
117+
const summary = collectAutomationSummary(kernelWith(older), 2, ['ok_flow', 'fan_flow'])!;
118+
expect(summary.refusedCount).toBe(0);
119+
});
120+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5704,9 +5704,13 @@ export default class Serve extends Command {
57045704
// [#22288] Counted by `resolveStackCollection`'s rule, so a flow a
57055705
// package declares is counted: the "declared but the automation engine is
57065706
// not enabled" line used to stay silent for every multi-package app.
5707+
const declaredFlows = resolveStackCollection(config, 'flows');
57075708
const automationSummary = collectAutomationSummary(
57085709
kernel,
5709-
resolveStackCollection(config, 'flows').length,
5710+
declaredFlows.length,
5711+
declaredFlows
5712+
.map((f) => (f as { name?: unknown } | null)?.name)
5713+
.filter((name): name is string => typeof name === 'string' && name !== ''),
57105714
);
57115715

57125716
// ── Seed outcome summary (#3415/#3430) ─────────────────────────
@@ -7240,6 +7244,11 @@ export function resolveBannerConfigRow(opts: {
72407244
export function collectAutomationSummary(
72417245
kernel: any,
72427246
declaredFlowCount: number,
7247+
/**
7248+
* [#29059] The declared flows' names, for the refused-at-registration count.
7249+
* Optional so a caller with only a count keeps its summary; its count is 0.
7250+
*/
7251+
declaredFlowNames: readonly string[] = [],
72437252
): AutomationReadySummary | undefined {
72447253
let automation: any;
72457254
try { automation = kernel?.getService?.('automation'); } catch { /* not registered */ }
@@ -7249,6 +7258,7 @@ export function collectAutomationSummary(
72497258
? {
72507259
enabled: false,
72517260
declaredFlowCount,
7261+
refusedCount: 0,
72527262
flowCount: 0,
72537263
boundCount: 0,
72547264
triggerTypes: [],
@@ -7291,6 +7301,21 @@ export function collectAutomationSummary(
72917301
try { states = automation.getFlowRuntimeStates?.() ?? []; } catch { /* older engine */ }
72927302
if (states.length === 0 && declaredFlowCount === 0) return undefined;
72937303

7304+
// [#29059] Declared flows the engine refused at registration, read off the
7305+
// engine's OWN record through the read it already serves the metadata door
7306+
// (`IAutomationService.getFlowRegistration`), one declared name at a time.
7307+
// No new engine surface: the probe is optional and feature-detected like the
7308+
// ones beside it. The record is written by the `kernel:ready` bind, which
7309+
// has settled by the time the banner prints.
7310+
let refusedCount = 0;
7311+
if (typeof automation.getFlowRegistration === 'function') {
7312+
for (const name of new Set(declaredFlowNames)) {
7313+
try {
7314+
if (automation.getFlowRegistration(name)?.state === 'refused') refusedCount++;
7315+
} catch { /* older engine */ }
7316+
}
7317+
}
7318+
72947319
let triggerTypes: string[] = [];
72957320
try { triggerTypes = automation.getRegisteredTriggerTypes?.() ?? []; } catch { /* older engine */ }
72967321

@@ -7327,6 +7352,7 @@ export function collectAutomationSummary(
73277352
return {
73287353
enabled: true,
73297354
declaredFlowCount,
7355+
refusedCount,
73307356
flowCount: states.length,
73317357
boundCount: states.filter((s) => s.bound).length,
73327358
triggerTypes,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29059] `findFlowRegionRefusals` never passes a flow it could not judge.
5+
*
6+
* The walk parses each flow with `FlowSchema` before it asks
7+
* `validateControlFlow` for the region verdict. The stack schema has already
8+
* parsed every flow it reads, and `FlowSchema` is idempotent on its own output,
9+
* so no authored stack reaches the refused-parse branch today; the cases below
10+
* hand the walk bodies the schema refuses, which is the only way to force it.
11+
* Before #29059 the branch was a bare `return`: no row, so `os validate` and
12+
* `os build` passed a flow whose region shape nobody had judged.
13+
*
14+
* What the door does with a row is pinned end to end in
15+
* `test/validate-flow-region-shape.test.ts`; here the subject is the row.
16+
*/
17+
18+
import { describe, expect, it } from 'vitest';
19+
20+
import { findFlowRegionRefusals } from './flow-region-shape.js';
21+
22+
/** A body `FlowSchema` refuses: `nodes` must be an array. */
23+
const unparseable = (name?: string) => ({ ...(name === undefined ? {} : { name }), label: 'Broken', type: 'autolaunched', nodes: 'start', edges: [] });
24+
25+
const located = (rows: ReturnType<typeof findFlowRegionRefusals>) => rows.map(({ path, code, flow }) => ({ path, code, flow }));
26+
27+
describe('[#29059] a flow the flow schema refuses is a row, never a silent pass', () => {
28+
it('the stack\'s own flows: the row names the flow and its path', () => {
29+
const rows = findFlowRegionRefusals({ flows: [unparseable('rgn_broken')] });
30+
expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]);
31+
expect(rows[0]!.message).toContain("'rgn_broken'");
32+
// The schema's own issue rides the row, so the author is told what it refused.
33+
expect(rows[0]!.message).toContain('nodes');
34+
});
35+
36+
it('a body with no string name is named by its path', () => {
37+
const rows = findFlowRegionRefusals({ flows: [unparseable()] });
38+
expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'flows[0]' }]);
39+
});
40+
41+
it('an artifact\'s packages[] entry: located under its own entry', () => {
42+
const rows = findFlowRegionRefusals({
43+
flows: [unparseable('rgn_top_level_not_walked')],
44+
packages: [{ manifest: { id: 'com.example.a', flows: [] } }, { manifest: { id: 'com.example.b', flows: [unparseable('rgn_broken')] } }],
45+
});
46+
expect(located(rows)).toEqual([{ path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]);
47+
});
48+
});

‎packages/cli/src/utils/flow-region-shape.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@
1919
*
2020
* Walked the way `findExtensionFieldSuffixRefusals` (`./extension-field-suffix.ts`)
2121
* walks: each `packages[]` entry's flows, or the stack's own flows when it
22-
* declares no packages. A flow the schema refuses is left to the schema step,
23-
* which has already reported it.
22+
* declares no packages.
23+
*
24+
* A flow `FlowSchema` refuses HERE is a row, never a skip (#29059). The stack
25+
* schema has already parsed every flow this walk reads, and `FlowSchema` is
26+
* idempotent on its own output today, so the branch cannot be reached by an
27+
* author. If that ever stops being true the region verdict was never computed,
28+
* and a silent `return` would pass the flow: the false-clean direction this
29+
* gate exists to close. The row says the flow could not be judged, with the
30+
* schema's issues, and the door refuses on it like any other row.
2431
*
2532
* Not judged here: a cycle in the flow's TOP-LEVEL graph. The engine refuses it
2633
* with `detectCycles`, which lives in `@objectstack/service-automation` and is
@@ -37,9 +44,12 @@ export interface FlowRegionRefusalRow {
3744
path: string;
3845
/** The code the CLI's other author-time refusals carry. */
3946
code: 'VALIDATION_ERROR';
40-
/** The flow's name. */
47+
/** The flow's name; its `path` when the body carries no string name. */
4148
flow: string;
42-
/** `Flow '<name>' …: ` followed by the engine's refusal, verbatim. */
49+
/**
50+
* `Flow '<name>' …: ` followed by the engine's refusal, verbatim — or, when
51+
* `FlowSchema` refuses the parsed body, the schema's issues (#29059).
52+
*/
4353
message: string;
4454
}
4555

@@ -54,7 +64,20 @@ export function findFlowRegionRefusals(parsed: Record<string, unknown>): FlowReg
5464
if (!Array.isArray(flows)) continue;
5565
flows.forEach((raw, i) => {
5666
const result = FlowSchema.safeParse(raw);
57-
if (!result.success) return;
67+
if (!result.success) {
68+
const declared = (raw as { name?: unknown } | null)?.name;
69+
const name = typeof declared === 'string' && declared !== '' ? declared : `${at}flows[${i}]`;
70+
const issues = result.error.issues
71+
.map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
72+
.join('; ');
73+
rows.push({
74+
path: `${at}flows[${i}]`,
75+
code: 'VALIDATION_ERROR',
76+
flow: name,
77+
message: `Flow '${name}' could not be judged for its region shape: the flow schema refused the parsed body (${issues})`,
78+
});
79+
return;
80+
}
5881
try {
5982
validateControlFlow(result.data);
6083
} catch (err) {

‎packages/cli/src/utils/format.boot-warning-classes.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ const policyRefused = (names: string[], triggerType = 'schedule'): AutomationRea
238238
const summary = (over: Partial<AutomationReadySummary>): AutomationReadySummary => ({
239239
enabled: true,
240240
declaredFlowCount: 0,
241+
refusedCount: 0,
241242
flowCount: 10,
242243
boundCount: 0,
243244
triggerTypes: ['schedule', 'record_change'],

‎packages/cli/src/utils/format.flow-contender-agreement.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const base: ServerReadyOptions = {
7777
const summaryWith = (armed: Contender, shadowedCount = 1): AutomationReadySummary => ({
7878
enabled: true,
7979
declaredFlowCount: 1,
80+
refusedCount: 0,
8081
flowCount: 1,
8182
boundCount: 1,
8283
triggerTypes: ['record_change'],

‎packages/cli/src/utils/format.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,15 @@ export interface AutomationReadySummary {
10111011
enabled: boolean;
10121012
/** Flows declared in the stack config (used when the engine is absent). */
10131013
declaredFlowCount: number;
1014+
/**
1015+
* [#29059] Declared flows the engine REFUSED at registration, from its own
1016+
* record (`getFlowRegistration(name).state === 'refused'`), counted by
1017+
* distinct declared name. A refused flow is absent from `flowCount` and from
1018+
* every list below, so without this the banner counts only what armed and
1019+
* the refusal shows only as a per-flow line under Boot diagnostics.
1020+
* 0 when the engine is absent or does not answer the read.
1021+
*/
1022+
refusedCount: number;
10141023
/** Flows registered in the engine (0 when `enabled` is false). */
10151024
flowCount: number;
10161025
/** Flows bound to a trigger. */
@@ -1713,12 +1722,30 @@ function printAutomationSummary(a: AutomationReadySummary): string[] {
17131722
}
17141723
return restated;
17151724
}
1716-
if (a.flowCount === 0) return restated;
1725+
// [#29059] Declared flows the engine refused at registration, beside the
1726+
// declared count. Boot continued either way; this only says so. The
1727+
// engine's per-flow reason stays where it is, under Boot diagnostics.
1728+
const printRefused = (): void => {
1729+
if (a.refusedCount === 0) return;
1730+
console.error(
1731+
chalk.yellow(
1732+
` ⚠ Flows: ${a.declaredFlowCount} flow(s) declared, ${a.refusedCount} refused at registration — ` +
1733+
`refused flows never run; each one's reason is under Boot diagnostics ('failed to register flow')`,
1734+
),
1735+
);
1736+
};
1737+
// A boot whose every declared flow was refused registers none: it prints
1738+
// the refused line alone rather than no Flows line at all.
1739+
if (a.flowCount === 0) {
1740+
printRefused();
1741+
return restated;
1742+
}
17171743

17181744
const parts = [`${a.flowCount} flow(s)`, `${a.boundCount} bound to triggers`];
17191745
if (a.triggerTypes.length > 0) parts.push(`(${a.triggerTypes.join(', ')})`);
17201746
if (a.draftCount > 0) parts.push(`· ${a.draftCount} draft`);
17211747
console.error(chalk.dim(` Flows: ${parts.join(' ')}`));
1748+
printRefused();
17221749

17231750
// #12028 — printed FIRST among the warnings because it re-reads every line
17241751
// above it: the counts describe the ARMED bodies only, so when a name is

0 commit comments

Comments
 (0)