Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/29059-flow-region-gate-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/cli': patch
---

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

Clause-②: no
Metadata-protocol: none

- **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.
- **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.
120 changes: 120 additions & 0 deletions packages/cli/src/commands/serve-automation-refused.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#29059] The boot banner counts declared flows the engine REFUSED at
// registration, beside the declared count.
//
// A flow `registerFlow` refuses is logged once at WARN and the boot goes on
// without it (#28893's second ask; that is unchanged here). Before this the
// banner's Flows line counted only what armed, so a refused flow showed only
// as a per-flow line under Boot diagnostics, and a boot whose every declared
// flow was refused printed no Flows line at all.
//
// The count is read off the engine's own record: the host's sync calls
// `recordFlowRefusal` when `registerFlow` throws, and the banner asks
// `getFlowRegistration(name)` for each declared name, the read the metadata
// door already serves. So the engine here is the REAL one (dist, already in
// `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/cli']`), refusing a real
// malformed region, not a fake that answers `refused` by fiat. Locally: build
// `@objectstack/service-automation` before running this.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AutomationEngine } from '@objectstack/service-automation';
import { collectAutomationSummary } from './serve.js';
import { printServerReady, type ServerReadyOptions } from '../utils/format.js';

const quiet = () => ({ debug() {}, info() {}, warn() {}, error() {} }) as never;

const kernelWith = (automation: unknown) => ({
getService(name: string) {
if (name === 'automation') return automation;
throw new Error(`Service '${name}' not found`);
},
});

const n = (id: string, type = 'assignment', extra: Record<string, unknown> = {}) => ({ id, type, label: id, ...extra });
const e = (id: string, source: string, target: string) => ({ id, source, target, type: 'default' });

/** An autolaunched flow: start → <middle> → end. */
const flow = (name: string, middle: Record<string, unknown>) => ({
name, label: name, type: 'autolaunched', status: 'active',
nodes: [n('start', 'start'), middle, n('end', 'end')],
edges: [e('e1', 'start', String(middle.id)), e('e2', String(middle.id), 'end')],
});

/** A `parallel` with one branch: `validateControlFlow` refuses it at registration. */
const refusedFlow = (name: string) => flow(name, n('fan', 'parallel', { config: { branches: [{ nodes: [n('only')], edges: [] }] } }));
const goodFlow = (name: string) => flow(name, n('work'));

/** Register each flow the way the host's sync does: a refusal is recorded, never thrown on. */
function engineWith(flows: Array<{ name: string }>): AutomationEngine {
const engine = new AutomationEngine(quiet());
for (const def of flows) {
try {
engine.registerFlow(def.name, def as never);
} catch (err) {
engine.recordFlowRefusal(def.name, err);
}
}
return engine;
}

const base: ServerReadyOptions = {
externalBaseOrigin: 'http://localhost:3000',
httpServer: 'composed',
configFile: 'objectstack.config.ts',
isDev: true,
pluginCount: 1,
};

describe('[#29059] the boot banner counts flows refused at registration', () => {
let lines: string[];
let spy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
lines = [];
// stderr, not stdout (#7915) — the whole banner is a diagnostic.
spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' '));
});
});
afterEach(() => spy.mockRestore());

const flowsLines = () => lines.filter((l) => l.includes('Flows:'));

it('one of two declared flows refused: the count rides beside the declared count', () => {
const declared = [goodFlow('ok_flow'), refusedFlow('fan_flow')];
const engine = engineWith(declared);
// The engine really refused it: the record is its own, not the test's.
expect(engine.getFlowRegistration('fan_flow').state).toBe('refused');

const summary = collectAutomationSummary(kernelWith(engine), declared.length, declared.map((f) => f.name))!;
expect(summary).toMatchObject({ declaredFlowCount: 2, refusedCount: 1, flowCount: 1 });

printServerReady({ ...base, automation: summary });
expect(flowsLines().filter((l) => l.includes('2 flow(s) declared, 1 refused at registration'))).toHaveLength(1);
});

it('every declared flow refused: the banner still says so, though nothing registered', () => {
const declared = [refusedFlow('fan_flow')];
const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['fan_flow'])!;
expect(summary).toMatchObject({ declaredFlowCount: 1, refusedCount: 1, flowCount: 0 });

printServerReady({ ...base, automation: summary });
expect(flowsLines().filter((l) => l.includes('1 flow(s) declared, 1 refused at registration'))).toHaveLength(1);
});

it('CONTROL: nothing refused prints no refused line', () => {
const declared = [goodFlow('ok_flow')];
const summary = collectAutomationSummary(kernelWith(engineWith(declared)), 1, ['ok_flow'])!;
expect(summary.refusedCount).toBe(0);

printServerReady({ ...base, automation: summary });
expect(lines.filter((l) => l.includes('refused at registration'))).toEqual([]);
expect(flowsLines()).toHaveLength(1);
});

it('an engine that does not answer the read degrades to a zero count, not a crash', () => {
const older = { getFlowRuntimeStates: () => [{ name: 'ok_flow', enabled: true, bound: false }] };
const summary = collectAutomationSummary(kernelWith(older), 2, ['ok_flow', 'fan_flow'])!;
expect(summary.refusedCount).toBe(0);
});
});
28 changes: 27 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5704,9 +5704,13 @@ export default class Serve extends Command {
// [#22288] Counted by `resolveStackCollection`'s rule, so a flow a
// package declares is counted: the "declared but the automation engine is
// not enabled" line used to stay silent for every multi-package app.
const declaredFlows = resolveStackCollection(config, 'flows');
const automationSummary = collectAutomationSummary(
kernel,
resolveStackCollection(config, 'flows').length,
declaredFlows.length,
declaredFlows
.map((f) => (f as { name?: unknown } | null)?.name)
.filter((name): name is string => typeof name === 'string' && name !== ''),
);

// ── Seed outcome summary (#3415/#3430) ─────────────────────────
Expand Down Expand Up @@ -7240,6 +7244,11 @@ export function resolveBannerConfigRow(opts: {
export function collectAutomationSummary(
kernel: any,
declaredFlowCount: number,
/**
* [#29059] The declared flows' names, for the refused-at-registration count.
* Optional so a caller with only a count keeps its summary; its count is 0.
*/
declaredFlowNames: readonly string[] = [],
): AutomationReadySummary | undefined {
let automation: any;
try { automation = kernel?.getService?.('automation'); } catch { /* not registered */ }
Expand All @@ -7249,6 +7258,7 @@ export function collectAutomationSummary(
? {
enabled: false,
declaredFlowCount,
refusedCount: 0,
flowCount: 0,
boundCount: 0,
triggerTypes: [],
Expand Down Expand Up @@ -7291,6 +7301,21 @@ export function collectAutomationSummary(
try { states = automation.getFlowRuntimeStates?.() ?? []; } catch { /* older engine */ }
if (states.length === 0 && declaredFlowCount === 0) return undefined;

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

let triggerTypes: string[] = [];
try { triggerTypes = automation.getRegisteredTriggerTypes?.() ?? []; } catch { /* older engine */ }

Expand Down Expand Up @@ -7327,6 +7352,7 @@ export function collectAutomationSummary(
return {
enabled: true,
declaredFlowCount,
refusedCount,
flowCount: states.length,
boundCount: states.filter((s) => s.bound).length,
triggerTypes,
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/utils/flow-region-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29059] `findFlowRegionRefusals` never passes a flow it could not judge.
*
* The walk parses each flow with `FlowSchema` before it asks
* `validateControlFlow` for the region verdict. The stack schema has already
* parsed every flow it reads, and `FlowSchema` is idempotent on its own output,
* so no authored stack reaches the refused-parse branch today; the cases below
* hand the walk bodies the schema refuses, which is the only way to force it.
* Before #29059 the branch was a bare `return`: no row, so `os validate` and
* `os build` passed a flow whose region shape nobody had judged.
*
* What the door does with a row is pinned end to end in
* `test/validate-flow-region-shape.test.ts`; here the subject is the row.
*/

import { describe, expect, it } from 'vitest';

import { findFlowRegionRefusals } from './flow-region-shape.js';

/** A body `FlowSchema` refuses: `nodes` must be an array. */
const unparseable = (name?: string) => ({ ...(name === undefined ? {} : { name }), label: 'Broken', type: 'autolaunched', nodes: 'start', edges: [] });

const located = (rows: ReturnType<typeof findFlowRegionRefusals>) => rows.map(({ path, code, flow }) => ({ path, code, flow }));

describe('[#29059] a flow the flow schema refuses is a row, never a silent pass', () => {
it('the stack\'s own flows: the row names the flow and its path', () => {
const rows = findFlowRegionRefusals({ flows: [unparseable('rgn_broken')] });
expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]);
expect(rows[0]!.message).toContain("'rgn_broken'");
// The schema's own issue rides the row, so the author is told what it refused.
expect(rows[0]!.message).toContain('nodes');
});

it('a body with no string name is named by its path', () => {
const rows = findFlowRegionRefusals({ flows: [unparseable()] });
expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: 'flows[0]' }]);
});

it('an artifact\'s packages[] entry: located under its own entry', () => {
const rows = findFlowRegionRefusals({
flows: [unparseable('rgn_top_level_not_walked')],
packages: [{ manifest: { id: 'com.example.a', flows: [] } }, { manifest: { id: 'com.example.b', flows: [unparseable('rgn_broken')] } }],
});
expect(located(rows)).toEqual([{ path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]);
});
});
33 changes: 28 additions & 5 deletions packages/cli/src/utils/flow-region-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@
*
* Walked the way `findExtensionFieldSuffixRefusals` (`./extension-field-suffix.ts`)
* walks: each `packages[]` entry's flows, or the stack's own flows when it
* declares no packages. A flow the schema refuses is left to the schema step,
* which has already reported it.
* declares no packages.
*
* A flow `FlowSchema` refuses HERE is a row, never a skip (#29059). The stack
* schema has already parsed every flow this walk reads, and `FlowSchema` is
* idempotent on its own output today, so the branch cannot be reached by an
* author. If that ever stops being true the region verdict was never computed,
* and a silent `return` would pass the flow: the false-clean direction this
* gate exists to close. The row says the flow could not be judged, with the
* schema's issues, and the door refuses on it like any other row.
*
* Not judged here: a cycle in the flow's TOP-LEVEL graph. The engine refuses it
* with `detectCycles`, which lives in `@objectstack/service-automation` and is
Expand All @@ -37,9 +44,12 @@ export interface FlowRegionRefusalRow {
path: string;
/** The code the CLI's other author-time refusals carry. */
code: 'VALIDATION_ERROR';
/** The flow's name. */
/** The flow's name; its `path` when the body carries no string name. */
flow: string;
/** `Flow '<name>' …: ` followed by the engine's refusal, verbatim. */
/**
* `Flow '<name>' …: ` followed by the engine's refusal, verbatim — or, when
* `FlowSchema` refuses the parsed body, the schema's issues (#29059).
*/
message: string;
}

Expand All @@ -54,7 +64,20 @@ export function findFlowRegionRefusals(parsed: Record<string, unknown>): FlowReg
if (!Array.isArray(flows)) continue;
flows.forEach((raw, i) => {
const result = FlowSchema.safeParse(raw);
if (!result.success) return;
if (!result.success) {
const declared = (raw as { name?: unknown } | null)?.name;
const name = typeof declared === 'string' && declared !== '' ? declared : `${at}flows[${i}]`;
const issues = result.error.issues
.map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
.join('; ');
rows.push({
path: `${at}flows[${i}]`,
code: 'VALIDATION_ERROR',
flow: name,
message: `Flow '${name}' could not be judged for its region shape: the flow schema refused the parsed body (${issues})`,
});
return;
}
try {
validateControlFlow(result.data);
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/format.boot-warning-classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ const policyRefused = (names: string[], triggerType = 'schedule'): AutomationRea
const summary = (over: Partial<AutomationReadySummary>): AutomationReadySummary => ({
enabled: true,
declaredFlowCount: 0,
refusedCount: 0,
flowCount: 10,
boundCount: 0,
triggerTypes: ['schedule', 'record_change'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const base: ServerReadyOptions = {
const summaryWith = (armed: Contender, shadowedCount = 1): AutomationReadySummary => ({
enabled: true,
declaredFlowCount: 1,
refusedCount: 0,
flowCount: 1,
boundCount: 1,
triggerTypes: ['record_change'],
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,15 @@ export interface AutomationReadySummary {
enabled: boolean;
/** Flows declared in the stack config (used when the engine is absent). */
declaredFlowCount: number;
/**
* [#29059] Declared flows the engine REFUSED at registration, from its own
* record (`getFlowRegistration(name).state === 'refused'`), counted by
* distinct declared name. A refused flow is absent from `flowCount` and from
* every list below, so without this the banner counts only what armed and
* the refusal shows only as a per-flow line under Boot diagnostics.
* 0 when the engine is absent or does not answer the read.
*/
refusedCount: number;
/** Flows registered in the engine (0 when `enabled` is false). */
flowCount: number;
/** Flows bound to a trigger. */
Expand Down Expand Up @@ -1713,12 +1722,30 @@ function printAutomationSummary(a: AutomationReadySummary): string[] {
}
return restated;
}
if (a.flowCount === 0) return restated;
// [#29059] Declared flows the engine refused at registration, beside the
// declared count. Boot continued either way; this only says so. The
// engine's per-flow reason stays where it is, under Boot diagnostics.
const printRefused = (): void => {
if (a.refusedCount === 0) return;
console.error(
chalk.yellow(
` ⚠ Flows: ${a.declaredFlowCount} flow(s) declared, ${a.refusedCount} refused at registration — ` +
`refused flows never run; each one's reason is under Boot diagnostics ('failed to register flow')`,
),
);
};
// A boot whose every declared flow was refused registers none: it prints
// the refused line alone rather than no Flows line at all.
if (a.flowCount === 0) {
printRefused();
return restated;
}

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

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