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
14 changes: 14 additions & 0 deletions .changeset/embedded-host-unsealed-node-type-vocabulary-warns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-automation': patch
---

自动化引擎:嵌入式 host 从未调用 `sealNodeTypeVocabulary()` 时,首次执行 flow 会告警一次(#4792)

#4771 把 ADR-0018 的节点类型校验从 `registerFlow` 挪到了 `sealNodeTypeVocabulary()`。`AutomationServicePlugin` 在 `kernel:bootstrapped` 自动 seal,插件路径不受影响;但自己 `new AutomationEngine()` 且从不 seal 的嵌入式 host 就彻底拿不到这项校验,而且完全静默 —— 只有读过 changeset 的人才知道要补一行调用。现在这类 host 在第一次真正执行 flow 时会得到一条 `warn`,说明丢了什么、以及要调用哪个方法。

- 首次执行是最早既安全又必然到达的时点:正在跑 flow 的 host 显然已经装配完毕(否则这次执行本身就会 `NO_EXECUTOR` 失败)。
- **每个引擎实例一次**,不是每进程一次 —— 一个 host 建了多个引擎(按租户/环境各一个是常见形态)就是在每个上都漏了这次调用。
- 告警只报「缺了这次调用」这个关于 host 的事实,**不报**未知节点类型的审计结果:未 seal 的引擎其词汇表按契约仍可增长,在那里断言「某类型没有执行器」正是 #4771 删掉的那种会被本次启动反驳的判断。需要审计结果又不想封闭词汇表的 host 用只读的 `getUnknownNodeTypeAudit()`。
- 也**不会**顺带自动 seal:「谁决定词汇表封闭」只能有一个答案(host)。而且 seal 之后 `registerFlow` 会转为即时校验,自动 seal 会让「先执行、后注册插件执行器」(ADR-0018 允许)的嵌入式 host 开始收到 #4771 那种误报。

走 `AutomationServicePlugin` 的部署与已显式调用过 `sealNodeTypeVocabulary()` 的 host 都不会多打任何日志(两条哨兵测试守着)。
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ describe('decision branch routing (#4414)', () => {
return { success: true };
},
});
// This harness IS an embedded host, so it owes the host's half of the
// ADR-0018 contract: every executor it will contribute is registered
// above, so the vocabulary is closed (#4771). Without it the first
// `execute()` below reports the omission (#4792) and the
// zero-warning assertions in this file — which are about #4414 routing,
// not about node types — would count that line. Declaring the seal is
// the honest fix; filtering the warning out of the assertions would have
// hidden a real signal in every future test that borrows this harness.
engine.sealNodeTypeVocabulary();
});

/** The guard from `examples/app-crm/src/flows/convert-lead.flow.ts`. */
Expand Down Expand Up @@ -353,6 +362,10 @@ describe('objectui-authored decision shape (FlowEdgeInspector.applyBranch)', ()
{ id: 'e3', source: 'check', target: 'auto', label: 'Standard', isDefault: true },
],
});
// Same reason as the harness above: an embedded host closes its own
// vocabulary once every executor is in (#4771/#4792), and every node
// type this flow uses is registered above, so the seal is silent.
engine.sealNodeTypeVocabulary();
});

it('takes only the guarded branch when it matches', async () => {
Expand Down
77 changes: 77 additions & 0 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,14 @@ export class AutomationEngine implements IAutomationService {
* started; only then is an unknown type a finding worth warning about.
*/
private nodeTypeVocabularySealed = false;
/**
* Whether this engine has already told its host that
* {@link sealNodeTypeVocabulary} was never called (#4792). Per **instance**,
* not per process: an embedded host that builds several engines (one per
* tenant/environment is the common shape) forgot the call on each of them,
* and a module-level flag would report only whichever engine ran first.
*/
private nodeTypeSealOmissionWarned = false;
private triggers = new Map<string, FlowTrigger>();
/**
* Flows currently wired to a trigger, keyed by flow name → the trigger
Expand Down Expand Up @@ -2379,6 +2387,13 @@ export class AutomationEngine implements IAutomationService {
return { success: false, error: `Flow '${flowName}' is disabled` };
}

// #4792 — a real run is about to start, so if the vocabulary was never
// sealed the ADR-0018 §M1 node-type check never ran on this engine at
// all. Say so once. Placed after the two guards above so the trigger is
// an execution and not a typo'd flow name (which is already loud) or a
// flow that cannot run. See the helper for why here and not earlier.
this.warnIfNodeTypeVocabularyNeverSealed();

// Re-entrancy loop guard (see `activeRecordFlows`). Break the SAME flow
// re-firing for the SAME record while a prior execution is still active —
// a self-trigger cascade whose start condition fails to suppress it would
Expand Down Expand Up @@ -3592,6 +3607,68 @@ export class AutomationEngine implements IAutomationService {
return audit;
}

/**
* Report — once per engine — that a flow ran on an engine whose node-type
* vocabulary was never sealed, so the ADR-0018 §M1 check never ran (#4792).
*
* #4771 made {@link sealNodeTypeVocabulary} the *only* moment node types are
* validated. `AutomationServicePlugin` calls it at `kernel:bootstrapped`, so
* every plugin-hosted deployment is covered — but a host that constructs
* `new AutomationEngine()` itself has no plugin doing it, and before #4771
* those hosts *did* get a verdict at `registerFlow` (an accurate one, since
* an embedded host controls its own ordering and typically registers
* executors first). For them the fix traded an unreliable warning for no
* warning at all, discoverable only by reading a changeset. "Documented" is
* not "enforced" (ADR-0049, #4632), so the omission has to say its own name.
*
* **Why the first `execute()` is the right moment.** It is the earliest point
* that is both safe and certain to be reached: the engine cannot know when a
* host has finished wiring, but a host that is *running flows* has finished —
* this very run resolves its executors from the same registry, and would fail
* `NO_EXECUTOR` otherwise. On the plugin path the seal already happened at
* `kernel:bootstrapped`, strictly before any `execute()`, so that path can
* never reach this line (pinned by test, so the warning cannot become noise).
*
* **Why it names the missing CALL and not the audit findings.** Running the
* unknown-type audit here and warning about what it finds would be the exact
* shape AGENTS.md "Startup registry reads" forbids: an unsealed engine is one
* whose host has *not* declared the vocabulary closed, so "no executor for
* `approval`" is still "not registered YET" — a verdict this process can
* contradict a line later, recorded in a log nobody can retract. That is
* #4771 rebuilt inside the embedded path. The missing call, by contrast, is a
* fact about the host that no later registration can change. A host that
* wants the findings without sealing has {@link getUnknownNodeTypeAudit},
* which is read-only by design.
*
* **Why it does not seal here.** Sealing would silently move the authority
* over "the vocabulary is closed" from the host to the first execution, and
* it would not be harmless: after the seal `registerFlow` validates inline,
* so an embedded host that registers a plugin's executors *after* running a
* flow — legal, ADR-0018 keeps the vocabulary open — would start getting the
* false "will fail at execution time" assertions #4771 exists to delete. The
* engine states the omission; the host still decides when the world is
* closed.
*
* Keep this text clear of {@link warnUnknownNodeTypes}'s "no registered
* executor or descriptor" phrase: tests and log filters use that substring
* to count *per-flow* findings, and a line that merely talks about them must
* not be counted as one.
*/
private warnIfNodeTypeVocabularyNeverSealed(): void {
if (this.nodeTypeVocabularySealed || this.nodeTypeSealOmissionWarned) return;
this.nodeTypeSealOmissionWarned = true;
this.logger.warn(
`[automation] flow executed on an engine whose node-type vocabulary was never sealed — ` +
`sealNodeTypeVocabulary() has not been called, so the ADR-0018 node-type check never ran and this ` +
`engine has never reported a flow whose node types nothing has registered; such nodes now fail ` +
`mid-run with NO_EXECUTOR instead of being named at startup. ` +
`A host that constructs AutomationEngine directly must call engine.sealNodeTypeVocabulary() once every ` +
`plugin has contributed its executors (AutomationServicePlugin does this at 'kernel:bootstrapped'); ` +
`engine.getUnknownNodeTypeAudit() returns the same finding without closing the vocabulary. ` +
`Reported once per engine instance.`,
);
}

/** One warning per flow, shared by the boot audit and the post-seal path. */
private warnUnknownNodeTypes(entry: UnknownNodeTypeAuditEntry): void {
this.logger.warn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #4792 — an embedded host that never seals the vocabulary must be TOLD, not
* left silent.
*
* #4771 moved the ADR-0018 §M1 node-type check out of `registerFlow` and into
* `sealNodeTypeVocabulary()`. `AutomationServicePlugin` calls it at
* `kernel:bootstrapped`, so plugin-hosted deployments are covered — but a host
* that builds `new AutomationEngine()` itself and never calls seal lost the
* check entirely, with no signal. Those hosts control their own ordering, so
* the pre-#4771 verdict was *accurate* for them: the fix swapped an unreliable
* warning for no warning at all, recoverable only by reading a changeset.
*
* Two halves, and the second is as load-bearing as the first:
*
* 1. the omission is now loud, once per engine instance;
* 2. the paths that did nothing wrong — the plugin boot, and a host that
* calls seal itself — gain no log line at all, which is the only thing
* keeping this warning from becoming the noise #4771 deleted.
*
* Reverse-verified while writing: temporarily inverting the guard in
* `warnIfNodeTypeVocabularyNeverSealed()` (warn when the vocabulary IS sealed)
* turns the two sentinels below red and the embedded-host cases green-for-the-
* wrong-reason, so the sentinels really do guard the seal, not just the string.
*/

import { describe, it, expect, vi } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
import { AutomationEngine } from './engine.js';
import { AutomationServicePlugin } from './plugin.js';

/** Substring that identifies the #4792 line, wherever logs are captured. */
const OMISSION_MARKER = 'node-type vocabulary was never sealed';

/** A flow that needs no plugin-contributed executor: structural nodes only. */
const trivialFlow = (name: string) => ({
name,
label: name,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

/** A flow whose only real node type nothing registers (the #4771 subject). */
const approvalFlow = (name: string) => ({
name,
label: name,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'signoff', type: 'approval', label: 'Sign-off', config: { approvers: [{ type: 'user', value: 'u1' }] } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'signoff' },
{ id: 'e2', source: 'signoff', target: 'end' },
],
});

function loggerCapturing(lines: string[]) {
const l: any = {
info: (m: string) => lines.push(`info ${m}`),
debug: () => {},
error: (m: string) => lines.push(`error ${m}`),
warn: (m: string) => lines.push(`warn ${m}`),
child: () => l,
};
return l;
}

const omissionLines = (lines: string[]) => lines.filter((l) => l.includes(OMISSION_MARKER));

/**
* Minimal `objectql` stand-in exposing the one seam the boot flow-pull reads,
* mirroring `flow-node-type-audit.test.ts` — this is how a flow gets registered
* during `AutomationServicePlugin.start()`, i.e. on the real plugin path.
*/
function fakeObjectqlPlugin(flows: unknown[]): Plugin {
return {
name: 'fake-objectql',
version: '1.0.0',
async init(ctx: PluginContext) {
(ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('objectql', {
registry: {
listItems: (type: string) => (type === 'flow' ? flows : []),
getObject: () => undefined,
},
});
},
};
}

const sealedState = (engine: AutomationEngine) =>
(engine as unknown as { nodeTypeVocabularySealed: boolean }).nodeTypeVocabularySealed;

describe('#4792 — a never-sealed node-type vocabulary announces itself at the first execution', () => {
it('warns on the first execute() of an embedded host that never sealed', async () => {
const lines: string[] = [];
const engine = new AutomationEngine(loggerCapturing(lines));
// The embedded shape: register the executors, register the flows, run —
// and never call sealNodeTypeVocabulary(), because no plugin does it here.
engine.registerFlow('embedded_flow', trivialFlow('embedded_flow'));
expect(omissionLines(lines), 'registration alone must stay quiet').toEqual([]);

const result = await engine.execute('embedded_flow');
expect(result.success).toBe(true);

const warned = omissionLines(lines);
expect(warned).toHaveLength(1);
// The line owes both halves AGENTS.md asks a degradation for: what was
// lost, and the call that restores it.
expect(warned[0]).toMatch(/^warn /);
expect(warned[0]).toContain('sealNodeTypeVocabulary()');
expect(warned[0]).toContain('NO_EXECUTOR');
expect(warned[0]).toContain('kernel:bootstrapped');
});

it('says it ONCE per engine — and every engine instance speaks for itself', async () => {
const lines: string[] = [];
const engine = new AutomationEngine(loggerCapturing(lines));
engine.registerFlow('a', trivialFlow('a'));
engine.registerFlow('b', trivialFlow('b'));

await engine.execute('a');
await engine.execute('a');
await engine.execute('b');
expect(omissionLines(lines), 'one line per engine, not one per run').toHaveLength(1);

// Dedupe is per instance, not per process/module: a host that builds one
// engine per tenant forgot the call on each of them, and a module-level
// flag would report only whichever engine happened to run first.
const otherLines: string[] = [];
const other = new AutomationEngine(loggerCapturing(otherLines));
other.registerFlow('a', trivialFlow('a'));
await other.execute('a');
expect(omissionLines(otherLines)).toHaveLength(1);
});

it('does not fire for an unknown or disabled flow name — the trigger is a real run', async () => {
const lines: string[] = [];
const engine = new AutomationEngine(loggerCapturing(lines));
engine.registerFlow('off', trivialFlow('off'));
await engine.toggleFlow('off', false);

expect((await engine.execute('never_registered')).success).toBe(false);
expect((await engine.execute('off')).success).toBe(false);
// Both already answer loudly on their own; the omission is reported when
// an execution actually starts, which is the moment the issue argues is
// both safe and certain to be reached.
expect(omissionLines(lines)).toEqual([]);
});

it('warns but does NOT seal — the host keeps authority over closing the vocabulary', async () => {
const lines: string[] = [];
const engine = new AutomationEngine(loggerCapturing(lines));
engine.registerFlow('embedded_flow', trivialFlow('embedded_flow'));
await engine.execute('embedded_flow');
expect(omissionLines(lines)).toHaveLength(1);

// Auto-sealing here would put two answers behind "who decides the
// vocabulary is closed", and the second one would not be harmless: after
// a seal `registerFlow` validates inline, so a host that registers a
// plugin's executors AFTER its first run — legal, ADR-0018 keeps the
// vocabulary open — would start getting the false "will fail at
// execution time" assertions #4771 exists to delete.
expect(sealedState(engine)).toBe(false);
engine.registerFlow('later', approvalFlow('later'));
expect(lines.filter((l) => l.includes('no registered executor or descriptor'))).toEqual([]);

// …and the host's own seal still works, and still reports the finding.
const audit = engine.sealNodeTypeVocabulary();
expect(audit.map((e) => e.flowName)).toEqual(['later']);
expect(lines.filter((l) => l.includes('no registered executor or descriptor'))).toHaveLength(1);
});

it('SENTINEL — a host that called seal itself gets no extra line', async () => {
const lines: string[] = [];
const engine = new AutomationEngine(loggerCapturing(lines));
engine.registerFlow('embedded_flow', trivialFlow('embedded_flow'));
expect(engine.sealNodeTypeVocabulary()).toEqual([]);

const before = [...lines];
expect((await engine.execute('embedded_flow')).success).toBe(true);

expect(omissionLines(lines)).toEqual([]);
// Nothing at warn/error was added by the run at all — the new code is
// reachable here (execute ran) and stayed silent because the host did
// its part, not because the path was skipped.
expect(sealedState(engine)).toBe(true);
const added = lines.slice(before.length).filter((l) => l.startsWith('warn ') || l.startsWith('error '));
expect(added).toEqual([]);
});

it('SENTINEL — the AutomationServicePlugin path is untouched: sealed at boot, no new log', async () => {
// `kernel:bootstrapped` fires strictly after every plugin's start() and
// every kernel:ready handler, so any execute() is necessarily after the
// seal and this warning can never reach the normal deployment.
const stdout: string[] = [];
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
stdout.push(String(chunk));
return true;
}) as never);
const kernel = new LiteKernel();
kernel.use(fakeObjectqlPlugin([trivialFlow('plugin_flow')]));
kernel.use(new AutomationServicePlugin());
try {
await kernel.bootstrap();
const engine = kernel.getService<AutomationEngine>('automation');
expect(sealedState(engine), 'the plugin seals at kernel:bootstrapped').toBe(true);

const bootLines = stdout.length;
expect((await engine.execute('plugin_flow')).success).toBe(true);

expect(omissionLines(stdout)).toEqual([]);
expect(stdout.slice(bootLines).join('')).not.toContain('sealNodeTypeVocabulary');
} finally {
spy.mockRestore();
await kernel.shutdown();
}
});
});
Loading