diff --git a/.changeset/21848-node-config-values-at-registration.md b/.changeset/21848-node-config-values-at-registration.md new file mode 100644 index 00000000000..34abbdd3c23 --- /dev/null +++ b/.changeset/21848-node-config-values-at-registration.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation)!: a flow the `kernel:ready` cold-boot bind refuses is no longer left registered and `active` from the boot pull + +Clause-②: no (narrowing) + + + +**BREAKING**: a flow that loaded `active` before can now be absent after boot. It ships as `minor` under the launch-window convention for accept-set narrowings. + +**What was kept before.** A boot registers a package's flows twice. The boot pull runs before a plugin that contributes a node type has registered its executor from its own `start()`, so it cannot check that node's config keys against the descriptor's `configSchema`, and it registers the flow and arms its trigger. The `kernel:ready` bind then re-registers every flow once the executor exists. When it refused one, for an undeclared config key for instance, it logged `[Automation] cold-boot flow bind: failed to register flow` and nothing else: the boot pull's registration stayed, `active` and bound to its trigger, so every run reached the node the refusal located. + +**What happens now.** A flow the `kernel:ready` bind refuses is withdrawn: it is not registered, its trigger is unbound, and the same warning names the flow and the refusal. Only that flow is withdrawn; the rest of the package loads, as it already did for a flow the boot pull refuses. A failed or empty read of the flow list still tears nothing down. + +**What an author sees now.** The flow is absent (`GET /automation/:name` answers `404`), and the boot warning carries the located refusal. The handling is to correct the config the warning locates; the flow then registers as before. + +**Unchanged.** What `registerFlow` refuses, at any door. A flow refused through the `/automation` write doors keeps the definition the engine already held, and a runtime reload that brings a refused body keeps the registered one. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 892c2aef296..00f676ba82c 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -138,7 +138,7 @@ Each node performs a specific action in the flow. | `id` | `string` | ✅ | Unique node identifier — unique across the **whole flow**: the top-level `nodes[]` and every region body (`loop.body`, `parallel.branches[]`, `try_catch.try` / `.catch`, at any depth) share one id space, and `FlowSchema` refuses a reused id at parse (`Duplicate node id …`, naming both locations) | | `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) | +| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, an `approval` node's config is judged whole against its declared contract at the flow parse, and the built-in executors `parse()` the value against their Zod contract before running (#4277) | | `connectorConfig` | `object` | ✅ on `connector_action` | `{ connectorId, actionId, input }` — the only input a `connector_action` node's executor reads. `FlowSchema` refuses a `connector_action` node without it, and one whose `connectorId` or `actionId` is blank (empty or whitespace only), at any depth including a region body. `connectorId` is the registered connector's `name`, `actionId` one of the action keys it declares; `input` is optional | | `position` | `{ x, y }` | optional | Visual position on canvas | | `timeoutMs` | `number` | optional | Per-node execution timeout | @@ -155,7 +155,14 @@ node type's published `configSchema` does not declare — naming the path, the declared key set, and a did-you-mean — and the contract-carrying builtins additionally `parse()` their config at execute time, refusing the node on a type or missing-`required` violation (#4277). A node type that publishes no -`configSchema` declares nothing, so nothing can be undeclared. +`configSchema` declares nothing, so nothing can be undeclared. An `approval` +node's config is judged whole by the flow parse, against the contract the spec +declares for it: an undeclared key or a value its executor would refuse +(`escalation.timeoutHours: 0.5` under its `>= 1`) refuses the flow at the +config path, so `registerFlow()` answers it at the `/automation` write doors +(`400 VALIDATION_FAILED`) and at boot. At boot a refused flow is skipped with a +warning and not left registered — including one refused only once a plugin's +node type has registered, after the boot pull had registered it. ### Node Examples diff --git a/packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts b/packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts new file mode 100644 index 00000000000..0df763c15e2 --- /dev/null +++ b/packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A flow whose node config breaks the node's contract is refused where it +// registers — through both registration doors an operator has: the package a +// stack ships (package load) and the admin write door (`POST /automation`) — +// and a flow refused at load is not left registered. +// +// ## What was broken +// +// Registration checked only the config's key NAMES (against the node type's +// descriptor `configSchema`); the values were parsed for the first time when a +// run reached the node. An approval node with `escalation.timeoutHours: 0.5` +// (the contract says `>= 1`) therefore registered and loaded `active`, every +// record the trigger matched was created, and every run then failed at the +// approval node: no approval request opened, so the record existed without +// the gate it was meant to pass, and the user who saved it saw nothing. The +// flow parse (`FlowSchema`, which `registerFlow` runs first) now judges an +// approval node's config against its declared contract, whole. +// +// And at package load, a refusal could fail to hold. The boot pull registers a +// package's flows before a plugin that contributes a node type has registered +// its executor, so it cannot check that node's config keys; the `kernel:ready` +// bind re-registers every flow once the executor exists, and when it refused +// one it only warned — the boot pull's registration stayed `active`. +// +// ## What each case pins +// +// - package load: an approval node's out-of-range escalation and its +// undeclared escalation key are refused (not registered), and the boot names +// the flow and the located config path; +// - package load, the control: a valid escalation registers and RUNS — a +// record of the trigger's object opens a pending approval request; +// - package load, a plugin node type whose executor registers after the boot +// pull: an undeclared config key is refused by the `kernel:ready` bind, and +// the flow is not left registered; its valid sibling is; +// - the admin door: both approval refusals answer `400 VALIDATION_FAILED` +// located at the config path, and nothing is registered under either name; +// - the admin door, the control: a valid escalation registers. +// +// The fixture is built with `strict: false`, on purpose: the build door judges +// the same flows on its own, and this file pins the two runtime doors behind +// it, so the invalid bodies have to reach them. Everything else about the stack +// is an ordinary authored package. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; + +const OBJECT = 'esc_value_request'; +/** A position nothing in this fixture staffs: the request opens and waits. */ +const UNSTAFFED = 'esc_value_unstaffed'; + +const PACKAGED_SUB_HOUR = 'esc_value_packaged_sub_hour'; +const PACKAGED_UNKNOWN_KEY = 'esc_value_packaged_unknown_key'; +const PACKAGED_VALID = 'esc_value_packaged_valid'; +const PACKAGED_PLUGIN_TYPO = 'esc_value_packaged_plugin_typo'; +const PACKAGED_PLUGIN_VALID = 'esc_value_packaged_plugin_valid'; +const DOOR_SUB_HOUR = 'esc_value_door_sub_hour'; +const DOOR_UNKNOWN_KEY = 'esc_value_door_unknown_key'; +const DOOR_VALID = 'esc_value_door_valid'; + +/** The config path the flow parse locates, on the approval node (`nodes[1]`). */ +const SUB_HOUR_PATH = 'nodes.1.config.escalation.timeoutHours'; +const UNKNOWN_KEY_PATH = 'nodes.1.config.escalation.bogusKey'; + +/** An active, record-triggered flow whose one approval node carries `escalation`. */ +function gatedFlow(name: string, escalation: Record) { + return { + name, + label: `Escalation gate ${name}`, + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Create', + config: { objectName: OBJECT, triggerType: 'record-after-create' }, + }, + { + id: 'gate', + type: 'approval', + label: 'Gate', + config: { + approvers: [{ type: 'position', value: UNSTAFFED }], + behavior: 'first_response', + escalation, + }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'gate', target: 'rejected', label: 'reject' }, + ], + }; +} + +const SUB_HOUR = { enabled: true, timeoutHours: 0.5, action: 'notify' }; +const UNKNOWN_KEY = { enabled: true, timeoutHours: 4, action: 'notify', bogusKey: 1 }; +const VALID = { enabled: true, timeoutHours: 4, action: 'notify' }; + +/** + * A plugin node type the spec knows nothing about, contributed the way a + * plugin contributes one: its executor (and the descriptor whose + * `configSchema` declares `count`) registers from the plugin's own `start()`, + * after the automation plugin's boot pull. + */ +const STAMP = 'esc_value_stamp'; + +function stampPlugin() { + return { + name: 'com.dogfood.esc-value-stamp', + version: '0.0.0', + async init() {}, + async start(ctx: { getService(name: string): T }) { + ctx.getService<{ registerNodeExecutor(executor: unknown): void }>('automation').registerNodeExecutor({ + type: STAMP, + descriptor: defineActionDescriptor({ + type: STAMP, + version: '0.0.0', + name: 'Stamp', + category: 'custom', + paradigms: ['flow'], + source: 'plugin', + configSchema: { type: 'object', properties: { count: { type: 'number' } } }, + }), + async execute() { + return { success: true }; + }, + }); + }, + }; +} + +/** A flow run by hand whose one node is the plugin node type, carrying `config`. */ +function stampFlow(name: string, config: Record) { + return { + name, + label: `Stamp ${name}`, + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'stamp', type: STAMP, label: 'Stamp', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'stamp' }, + { id: 'e2', source: 'stamp', target: 'end' }, + ], + }; +} + +const fixtureStack = defineStack( + { + manifest: { + id: 'com.dogfood.escalation-values', + namespace: 'esc_value', + version: '0.0.0', + type: 'app', + name: 'Escalation Values Fixture', + description: 'One object, approval flows with escalation values their contract refuses, and plugin-node flows.', + }, + // ADR-0097: a record-change trigger registers only when the app declares it. + requires: ['automation', 'triggers'], + objects: [ + ObjectSchema.create({ + name: OBJECT, + label: 'Escalation Value Request', + pluralLabel: 'Escalation Value Requests', + sharingModel: 'public_read_write', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }), + ], + flows: [ + gatedFlow(PACKAGED_SUB_HOUR, SUB_HOUR), + gatedFlow(PACKAGED_UNKNOWN_KEY, UNKNOWN_KEY), + gatedFlow(PACKAGED_VALID, VALID), + // `cuont` is a key the plugin node type's descriptor does not declare. + stampFlow(PACKAGED_PLUGIN_TYPO, { cuont: 2 }), + stampFlow(PACKAGED_PLUGIN_VALID, { count: 2 }), + ] as never, + }, + { strict: false }, +); + +interface DispatcherEnvelope { + success?: boolean; + data?: Record; + error?: { + code?: string; + message?: string; + details?: { fields?: Array<{ field?: string; code?: string; message?: string }> }; + }; +} + +describe('a flow node config its contract refuses is refused at registration, and not left registered at load', () => { + let stack: VerifyStack; + let token: string; + /** Everything the platform logger wrote while the stack booted. */ + let bootOutput = ''; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json = (await res.json().catch(() => ({}))) as DispatcherEnvelope; + return { status: res.status, json }; + }; + + /** The boot lines that name `flow` and carry `fragment`. */ + const bootLines = (flow: string, fragment: string) => + bootOutput.split('\n').filter((line) => line.includes(flow) && line.includes(fragment)); + + beforeAll(async () => { + // The core logger writes through the process streams, not `console.*`. + const lines: string[] = []; + const sink = (chunk: unknown): boolean => { + lines.push(String(chunk)); + return true; + }; + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(sink as never); + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(sink as never); + try { + stack = await bootStack(fixtureStack as unknown as Parameters[0], { + automation: true, + extraPlugins: [new RecordChangeTriggerPlugin(), new ApprovalsServicePlugin(), stampPlugin()], + }); + } finally { + outSpy.mockRestore(); + errSpy.mockRestore(); + bootOutput = lines.join(''); + } + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('package load: the sub-hour and the undeclared-key approval flows are not registered', async () => { + for (const name of [PACKAGED_SUB_HOUR, PACKAGED_UNKNOWN_KEY]) { + const read = await call('GET', `/automation/${name}`); + expect(read.status, `${name}: ${JSON.stringify(read.json)}`).toBe(404); + } + }); + + it('package load: the boot names each refused flow and the config path it refused', () => { + expect(bootLines(PACKAGED_SUB_HOUR, 'nodes[1].config.escalation.timeoutHours').length, bootOutput.slice(-4000)) + .toBeGreaterThan(0); + expect(bootLines(PACKAGED_UNKNOWN_KEY, 'nodes[1].config.escalation.bogusKey').length, bootOutput.slice(-4000)) + .toBeGreaterThan(0); + }); + + it('package load, the control: a valid escalation registers active and runs', async () => { + const read = await call('GET', `/automation/${PACKAGED_VALID}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(read.json.data?.status).toBe('active'); + + const created = await stack.apiAs(token, 'POST', `/data/${OBJECT}`, { name: 'gated' }); + expect(created.status, await created.clone().text()).toBe(201); + const createdJson = (await created.json()) as { id?: string; record?: { id?: string } }; + const recordId = String(createdJson.id ?? createdJson.record?.id); + + // The run reached the approval node and the node opened its request: + // only an escalation its executor accepts gets that far. + const pending = await stack.apiAs(token, 'GET', '/approvals/requests?status=pending'); + expect(pending.status).toBe(200); + const rows = ((await pending.json()) as { data: Array> }).data; + const opened = rows.filter((row) => String(row.record_id) === recordId); + expect(opened.length, JSON.stringify(rows)).toBe(1); + expect(opened[0].pending_approvers).toEqual([`position:${UNSTAFFED}`]); + }); + + it('package load: a flow the kernel:ready bind refuses is not left registered from the boot pull', async () => { + const read = await call('GET', `/automation/${PACKAGED_PLUGIN_TYPO}`); + expect(read.status, JSON.stringify(read.json)).toBe(404); + expect(bootLines(PACKAGED_PLUGIN_TYPO, 'config.cuont').length, bootOutput.slice(-4000)).toBeGreaterThan(0); + + // Only that flow: its valid sibling of the same node type is registered. + const sibling = await call('GET', `/automation/${PACKAGED_PLUGIN_VALID}`); + expect(sibling.status, JSON.stringify(sibling.json)).toBe(200); + }); + + it('the admin door: both approval refusals answer 400 VALIDATION_FAILED at the config path, and nothing registers', async () => { + for (const [name, escalation, path] of [ + [DOOR_SUB_HOUR, SUB_HOUR, SUB_HOUR_PATH], + [DOOR_UNKNOWN_KEY, UNKNOWN_KEY, UNKNOWN_KEY_PATH], + ] as const) { + const refused = await call('POST', '/automation', gatedFlow(name, escalation)); + expect(refused.status, JSON.stringify(refused.json)).toBe(400); + expect(refused.json.error?.code).toBe('VALIDATION_FAILED'); + const fields = refused.json.error?.details?.fields ?? []; + expect(fields.map((f) => f.field), JSON.stringify(refused.json)).toContain(path); + + const read = await call('GET', `/automation/${name}`); + expect(read.status, JSON.stringify(read.json)).toBe(404); + } + }); + + it('the admin door, the control: a valid escalation registers', async () => { + const created = await call('POST', '/automation', gatedFlow(DOOR_VALID, VALID)); + expect(created.status, JSON.stringify(created.json)).toBe(200); + const read = await call('GET', `/automation/${DOOR_VALID}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + }); +}); diff --git a/packages/services/service-automation/src/flow-cold-boot-refusal-withdraw.test.ts b/packages/services/service-automation/src/flow-cold-boot-refusal-withdraw.test.ts new file mode 100644 index 00000000000..dc3afc98276 --- /dev/null +++ b/packages/services/service-automation/src/flow-cold-boot-refusal-withdraw.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A flow the `kernel:ready` cold-boot bind refuses is withdrawn, not left + * registered from the boot pull (#21848). + * + * A boot registers flows twice. The boot pull (`AutomationServicePlugin.start()`) + * runs before a plugin that contributes a node type has registered its executor + * from its own `start()`, so it cannot check that node's config keys against the + * descriptor's `configSchema`. The `kernel:ready` bind re-registers every flow + * once the executor exists. When that bind refused the flow, it only warned: the + * boot pull's registration stayed, `active` and bound to its trigger. + * + * Pinned on the real plugin path (a `LiteKernel`, the boot pull and the + * protocol view serving the same packaged flows, the node type's executor + * registered from a later plugin's `start()`): + * - the refused flow is withdrawn — not registered, not bound; + * - only that flow: the package's valid flow stays registered and bound. + */ + +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { AutomationEngine } from './engine.js'; +import type { FlowTrigger, FlowTriggerBinding, NodeExecutor } from './engine.js'; +import { AutomationServicePlugin } from './plugin.js'; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** A plugin node type the spec knows nothing about: its descriptor declares `count`. */ +const STAMP = 'test_stamp'; + +function stampExecutor(): NodeExecutor { + return { + type: STAMP, + descriptor: defineActionDescriptor({ + type: STAMP, + version: '1.0.0', + name: 'Stamp', + category: 'custom', + paradigms: ['flow'], + source: 'plugin', + configSchema: { type: 'object', properties: { count: { type: 'number' } } }, + }), + async execute() { + return { success: true }; + }, + }; +} + +/** An active, record-triggered, packaged flow whose one plugin node carries `config`. */ +function stampFlow(name: string, config: Record) { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + _packageId: 'app.fixture', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'expense', triggerType: 'record-after-create' } }, + { id: 'stamp', type: STAMP, label: 'Stamp', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'stamp' }, + { id: 'e2', source: 'stamp', target: 'end' }, + ], + }; +} + +/** A recording `record_change` trigger (stands in for the real one). */ +function recordingRecordChangeTrigger() { + const bound = new Set(); + const trigger: FlowTrigger = { + type: 'record_change', + start(binding: FlowTriggerBinding, _cb: (ctx: AutomationContext) => Promise) { + bound.add(binding.flowName); + }, + stop(flowName: string) { + bound.delete(flowName); + }, + }; + return { trigger, has: (n: string) => bound.has(n) }; +} + +/** + * The two reads a boot binds flows from: the `objectql` registry (the boot + * pull) and the protocol's flow view (the `kernel:ready` bind), both serving + * the same packaged flows, as a real boot does. + */ +function flowSourcesPlugin(flows: unknown[], rec: ReturnType): Plugin { + return { + name: 'test.flow-sources', + version: '1.0.0', + async init(ctx: PluginContext) { + const c = ctx as unknown as { + registerService(n: string, s: unknown): void; + getService(n: string): T; + }; + c.registerService('objectql', { + registry: { + listItems: (type: string) => (type === 'flow' ? flows : []), + getObject: () => undefined, + }, + }); + c.registerService('protocol', { + async getMetaItemsForExecution(q: { type: string }) { + return { items: q.type === 'flow' ? flows : [] }; + }, + }); + c.getService('automation').registerTrigger(rec.trigger); + }, + }; +} + +/** Contributes the plugin node type from its own `start()`, after the boot pull. */ +function lateStampPlugin(): Plugin { + return { + name: 'test.late-stamp', + version: '1.0.0', + async init() {}, + async start(ctx: PluginContext) { + (ctx as unknown as { getService(n: string): T }) + .getService('automation') + .registerNodeExecutor(stampExecutor()); + }, + }; +} + +describe('boot: a flow the kernel:ready bind refuses does not stay registered from the boot pull', () => { + it('withdraws the refused flow and keeps the valid one bound', async () => { + const rec = recordingRecordChangeTrigger(); + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + // `cuont` is a key the node type's descriptor does not declare. + kernel.use(flowSourcesPlugin([stampFlow('typo', { cuont: 2 }), stampFlow('valid', { count: 2 })], rec)); + kernel.use(lateStampPlugin()); + await kernel.bootstrap(); + await flush(); + + const engine = kernel.getService('automation'); + expect(await engine.getFlow('typo'), 'refused at load ⇒ not registered').toBeNull(); + expect(rec.has('typo'), 'refused at load ⇒ not bound').toBe(false); + expect(engine.getActiveTriggerBindings().map((b) => b.flowName)).not.toContain('typo'); + + // Only the refused flow: the package's valid flow is registered and bound. + expect(await engine.getFlow('valid')).not.toBeNull(); + expect(rec.has('valid')).toBe(true); + + await kernel.shutdown(); + }); + + it('the refusal is the kernel:ready bind\'s: the same body registers when the executor is absent', async () => { + // The control for the pin above: with no plugin contributing the node + // type, neither boot step can check its keys, so the flow registers. + // A green pin above therefore reads the bind's refusal, not one the + // boot pull or the flow parse made on its own. + const rec = recordingRecordChangeTrigger(); + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use(flowSourcesPlugin([stampFlow('typo', { cuont: 2 })], rec)); + await kernel.bootstrap(); + await flush(); + + const engine = kernel.getService('automation'); + expect(await engine.getFlow('typo')).not.toBeNull(); + + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index d69c5a8bcc7..e36e7077781 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -2482,6 +2482,18 @@ export class AutomationServicePlugin implements Plugin { * flows down, so a transient empty/failed read at boot can't unbind the flows * the boot pull already registered. registerFlow is idempotent, so re-binding * a flow the boot pull already registered is harmless. + * + * [#21848] …except where this bind REFUSES the flow: then the registration + * the boot pull made is withdrawn, so a flow refused at load is not left + * registered, whichever of the two boot steps refused it. The two can + * disagree because the node-type vocabulary grows between them — a plugin + * registers its node executor, and with it the descriptor `configSchema` + * registration checks that node's config keys against, from its own + * `start()`, after the boot pull, so the pull registered such a flow + * unjudged and armed it. Leaving that registration in place behind this + * refusal's warning kept the flow `active` and bound to its trigger. + * Only the refused name is withdrawn; a failed or empty READ still tears + * nothing down (the early returns above). */ private async syncFlowsFromProtocol(ctx: PluginContext): Promise { if (!this.engine) return; @@ -2502,6 +2514,12 @@ export class AutomationServicePlugin implements Plugin { flow: entry.name, ...describeThrownForLog(err), }); + // [#21848] The boot pull's registration of this flow does not + // outlive this refusal — see the docblock. + if ((await this.engine.getFlow(entry.name)) !== null) { + this.engine.withdrawFlow(entry.name); + this.syncedFlowNames.delete(entry.name); + } } } if (bound > 0) {