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
42 changes: 42 additions & 0 deletions .changeset/flow-node-type-audit-at-boot-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/service-automation": minor
"@objectstack/plugin-approvals": patch
---

fix(automation,approvals): 节点类型校验推迟到插件贡献完成之后 —— approval flow 不再被误报"运行时会失败" (#4771)

showcase 每次冷启都打印 8 条断言:这些 flow "will fail at execution time"。8 条全是假的。
`AutomationServicePlugin.start()` 从 ObjectQL registry 拉起 flow 并**当场**校验节点类型,而
`ApprovalsServicePlugin.start()` 在 0.8 秒后才注册 `approval` 执行器 —— 校验器在词汇表还没
成型的时候就下了结论。

真正的代价不是噪音,是信号丢失:**真的没装 approvals 插件**的部署会得到一模一样的 8 条告警,
所以这条 warn 无法区分"健康"和"坏掉",信噪比为 0。

ADR-0018 明确把节点词汇表定义为**开放、可运行时扩展**的(插件通过
`registerNodeExecutor(type)` 贡献类型)。因此校验只在词汇表**封闭**的那一刻才成立:

- `AutomationEngine.sealNodeTypeVocabulary()` —— 宣告词汇表封闭,对**所有**已注册 flow 跑一次
权威校验,每个有问题的 flow warn 一条。`AutomationServicePlugin` 在 `kernel:bootstrapped`
调用它(严格晚于每个插件的 `start()` 和每个 `kernel:ready` handler —— 本插件自己的
`kernel:ready` 还会再注册一批 flow,别的插件也可能在它的 `kernel:ready` 里贡献执行器)。
- `AutomationEngine.getUnknownNodeTypeAudit(): UnknownNodeTypeAuditEntry[]` —— 同一发现的
**状态**形态,供 host(CLI 启动摘要、健康检查)直接读,而不是去 grep 日志。与
`getTriggerBindingAudit()` 同一套路。
- 封闭之后 `registerFlow` **恢复即时告警**:Studio 发布 / dev reload 进正在运行的服务器时,
词汇表确实是完整的,那句断言此时为真。所以这是时序修复,不是把告警静音。

告警文案也随之改成它现在能承诺的事:"Every plugin has started, so nothing will register them
now — these nodes fail at execution time with NO_EXECUTOR",并给出补救动作。

一并修掉同一缺陷类的另一半:`ApprovalsServicePlugin` 在**拿不到 automation 引擎**时,把
"`approval` 节点没注册"记成 `info` —— 而 dev 的默认日志级别是 `warn`,于是**真降级发生时反而
看不见**(#4632:静默降级必须响亮)。现在是 `warn`,写明后果(该部署里每个 ADR-0019 approval
flow 都会以 NO_EXECUTOR 失败)和补救(装 `@objectstack/service-automation`)。`catch` 同时收窄
到"服务查找"这一步,`registerApprovalNode` 内部真出错时会以自己的身份抛出,而不再被贴上
"no automation engine" 的错误标签;`automation` 服务存在但不接受节点执行器的分支从前**一条日志
都不打**,现在同样 warn。

**嵌入式 host 注意**:直接 `new AutomationEngine()` 而不经过 `AutomationServicePlugin` 的宿主,
需要在自己的插件都装好之后调用一次 `sealNodeTypeVocabulary()`,才能拿到这条告警(以及之后的
即时校验)。
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Each node performs a specific action in the flow.
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `id` | `string` | ✅ | Unique node identifier |
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry at `registerFlow()` |
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
| `label` | `string` | ✅ | Display label |
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
| `connectorConfig` | `object` | optional | `{ connectorId, actionId, input }` for a `connector_action` node |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #4771 (second half) — the missing-automation degradation must be LOUD.
*
* `ApprovalsServicePlugin.start()` contributes the ADR-0019 `approval` node
* executor to the flow engine. When there is no engine to contribute it to,
* every approval flow in the deployment is dead on arrival — and that fact was
* logged at `info`, while `os dev` runs at the default `warn` level. The one
* line that mattered was invisible in exactly the deployment where it was true
* (and #4632 already ruled that a silent degradation is a defect, not a style).
*
* The mirror-image assertion matters just as much: when the engine IS present
* the executor is registered and nothing is warned about, because the pair is
* what makes the log line diagnostic rather than decorative.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalsServicePlugin } from './approvals-plugin.js';
import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation';

/** Minimal ObjectQL stand-in — enough for start() to build the service. */
function fakeObjectql() {
return {
async find() { return []; },
async insert(_o: string, d: any) { return { ...d }; },
async update(_o: string, d: any) { return { ...d }; },
async delete() { return { affected: 0 }; },
};
}

function makeCtx(services: Record<string, unknown>) {
const logs = { info: [] as string[], warn: [] as string[] };
const ctx: any = {
getService: (name: string) => {
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
return services[name];
},
registerService: () => {},
logger: {
info: (msg: string) => logs.info.push(msg),
warn: (msg: string) => logs.warn.push(msg),
error: () => {},
debug: () => {},
},
};
return { ctx, logs };
}

describe('ApprovalsServicePlugin — missing automation engine is reported at warn (#4771)', () => {
it('WARNS (not info) and names the consequence when no automation service exists', async () => {
const { ctx, logs } = makeCtx({ objectql: fakeObjectql() });
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);

// The whole point: visible at the default dev log level.
const warned = logs.warn.filter((m) => m.includes('no automation engine'));
expect(warned).toHaveLength(1);
expect(warned[0]).toContain(APPROVAL_NODE_TYPE);
expect(warned[0]).toMatch(/NOT registered/);
// It carries the remedy, not just the symptom.
expect(warned[0]).toMatch(/@objectstack\/service-automation/);
// …and it is no longer buried under a level dev never prints.
expect(logs.info.some((m) => m.includes('no automation engine'))).toBe(false);
});

it('WARNS when an automation service exists but cannot take node executors', async () => {
// A foreign/older `automation` service degrades identically — pre-fix this
// branch logged nothing at all, at any level.
const { ctx, logs } = makeCtx({ objectql: fakeObjectql(), automation: { resume: async () => undefined } });
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);

expect(logs.warn.filter((m) => m.includes('no automation engine'))).toHaveLength(1);
});

it('registers the `approval` executor and says nothing when the engine is present', async () => {
const registered: string[] = [];
const automation = {
registerNodeExecutor: (e: { type: string }) => registered.push(e.type),
resume: async () => undefined,
};
const { ctx, logs } = makeCtx({ objectql: fakeObjectql(), automation });
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);

expect(registered).toEqual([APPROVAL_NODE_TYPE]);
expect(logs.warn.filter((m) => m.includes('no automation engine'))).toEqual([]);
});
});
28 changes: 22 additions & 6 deletions packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,30 @@ export class ApprovalsServicePlugin implements Plugin {
// present. The node lets a flow suspend on an approval and resume on
// decision; the service is wired to the same engine so `decide()` can
// resume the suspended run.
//
// #4771 — the degradation must be LOUD (#4632). This used to be one
// try/catch logging at `info`, and dev's default log level is `warn`: the
// one line that says "every `approval` node in this deployment is dead"
// was invisible in exactly the deployment where it is true, while the flow
// registration was warning about `approval` in the deployments where it is
// false. The catch is also narrowed to the service *lookup*, so a genuine
// failure inside registerApprovalNode surfaces as itself instead of being
// relabelled "no automation engine".
let automation: ApprovalAutomationSurface | undefined;
try {
const automation = ctx.getService<ApprovalAutomationSurface>('automation');
if (automation && typeof automation.registerNodeExecutor === 'function') {
this.service.attachAutomation(automation);
registerApprovalNode(automation, this.service, ctx.logger);
}
automation = ctx.getService<ApprovalAutomationSurface>('automation');
} catch {
ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered');
automation = undefined; // no automation service registered in this stack
}
if (automation && typeof automation.registerNodeExecutor === 'function') {
this.service.attachAutomation(automation);
registerApprovalNode(automation, this.service, ctx.logger);
} else {
ctx.logger.warn(
'ApprovalsServicePlugin: no automation engine — the `approval` flow node is NOT registered. '
+ 'Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. '
+ 'Add @objectstack/service-automation to the stack to enable them.',
);
}
}

Expand Down
60 changes: 58 additions & 2 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2316,16 +2316,35 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
expect(warnings.some(w => w.includes('send_sms'))).toBe(false);
});

it('registers the flow but warns when a node type has no executor or descriptor', () => {
it('registers the flow and reports the unknown type once the vocabulary is sealed (#4771)', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));

// Soft-fail per ADR-0018: register but warn (a temporarily-absent
// plugin should not block flow registration).
// plugin should not block flow registration). The warning is deferred
// to the moment the vocabulary can no longer grow — during boot an
// unknown type means "no plugin has registered it YET" (#4771).
expect(() => engine.registerFlow('plugin_node_flow', baseFlow('not_a_real_type'))).not.toThrow();
expect(warnings.some(w => w.includes('not_a_real_type'))).toBe(false);

const audit = engine.sealNodeTypeVocabulary();
expect(audit).toEqual([
expect.objectContaining({ flowName: 'plugin_node_flow', unknownTypes: ['not_a_real_type'] }),
]);
expect(warnings.some(w => w.includes('not_a_real_type'))).toBe(true);
});

it('warns INLINE for a flow registered after the vocabulary is sealed (#4771)', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));

// Post-boot registration (Studio publish / dev reload) is judged against
// a complete vocabulary, so the assertion is true and immediate.
engine.sealNodeTypeVocabulary();
engine.registerFlow('plugin_node_flow', baseFlow('not_a_real_type'));
expect(warnings.filter(w => w.includes('not_a_real_type'))).toHaveLength(1);
});

it('does not warn for the structural start/end node types', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));
Expand All @@ -2339,9 +2358,46 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
engine.sealNodeTypeVocabulary();
expect(warnings.filter(w => w.includes('no registered executor'))).toHaveLength(0);
});

it('stays quiet about a DISABLED flow — a flow that cannot run cannot fail (#4771)', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));

// `status: 'obsolete'` unbinds the flow and guards execute(), so
// asserting a run-time failure for it would be the same false claim
// this check was moved to stop making.
engine.registerFlow('retired_flow', { ...baseFlow('not_a_real_type'), status: 'obsolete' });
expect(engine.sealNodeTypeVocabulary()).toEqual([]);
expect(warnings.filter(w => w.includes('not_a_real_type'))).toHaveLength(0);
});

it('seals idempotently — a second seal never re-reports the same finding (#4771)', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));
engine.registerFlow('plugin_node_flow', baseFlow('not_a_real_type'));

expect(engine.sealNodeTypeVocabulary()).toHaveLength(1);
expect(engine.sealNodeTypeVocabulary()).toHaveLength(1); // still reports as STATE…
expect(warnings.filter(w => w.includes('not_a_real_type'))).toHaveLength(1); // …but warns once
});

it('says nothing about a type a plugin registered AFTER the flow (the #4771 false alarm)', () => {
const warnings: string[] = [];
const engine = new AutomationEngine(createCapturingLogger(warnings));

// Exactly the showcase cold-boot order: flows are pulled first, the
// contributing plugin starts second. Pre-fix this warned "will fail at
// execution time" about a node type that was registered 0.8s later.
engine.registerFlow('plugin_node_flow', baseFlow('approval'));
engine.registerNodeExecutor({ type: 'approval', async execute() { return { success: true }; } });

expect(engine.sealNodeTypeVocabulary()).toEqual([]);
expect(warnings.filter(w => w.includes('approval'))).toHaveLength(0);
});

it('publishes a descriptor into the registry when an executor declares one', () => {
const engine = new AutomationEngine(createTestLogger());
engine.registerNodeExecutor({
Expand Down
Loading
Loading