Skip to content

Commit f84f8d5

Browse files
os-zhuangclaude
andauthored
fix(automation): bind flow triggers on cold boot, not just after HMR reload (#2560)
* chore: bump objectui to 3e4268041bc2 fix(plugin-grid): schema-aware multi-value semantics for bulk-edit params (#2206) objectui@3e4268041bc2523bc3f40a4d54646672ce3394c5 * fix(automation): bind flow triggers on cold boot, not just after HMR reload Record-triggered (and other trigger-typed) flows silently never fired on a fresh process start — in dev and in production. The automation service's boot-time flow pull reads ql.registry.listItems('flow'), which is EMPTY for flows defined inline in an app manifest (registerApp stores the app under type 'app' and never promotes its inline flows to standalone registry 'flow' items). The re-sync that could see them ran only on the 'metadata:reloaded' hook, which never fires on a cold boot (os dev restarts the process on recompile rather than firing it; production never reloads). Net: after any real restart, no flow bound its trigger, so record-change automations did not fire at all. Bind flows at kernel:ready from protocol.getMetaItems({ type: 'flow' }) — the canonical flattened flow view that GET /meta/flow serves and that DOES surface inline app flows — once every plugin has finished init()/start() (so the app, hence its flows, is registered). registerFlow is idempotent, so re-binding a flow the boot pull already registered is harmless. Verified end-to-end on a clean instance: before, updating a record fired 0 flows (0 bound at boot); after, a cold boot binds all flows and a single record update fires every matching record-triggered flow (10 fired). The fix also turns GREEN the pre-existing end-to-end regression record-change-integration.test.ts (#1909) — 'fires a flow pulled from the registry ... bound when the trigger registers on kernel:ready' — which was red because that binding never happened. New cold-boot unit test asserts an inline-app record flow, served only via protocol.getMetaItems, is bound after bootstrap() with no metadata:reloaded fired; it fails on the pre-fix code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 24b62ee commit f84f8d5

4 files changed

Lines changed: 237 additions & 1 deletion

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): bind flow triggers on a cold boot, not just after an HMR reload
6+
7+
Record-triggered (and other trigger-typed) flows silently never fired on a
8+
fresh process start — in dev and in production. The automation service's
9+
boot-time flow pull reads `ql.registry.listItems('flow')`, which is **empty for
10+
flows defined inline in an app manifest**`registry.registerApp()` stores the
11+
app under type `'app'` and never promotes its inline flows to standalone
12+
registry `'flow'` items. The re-sync that *could* see them only ran on the
13+
`metadata:reloaded` hook, which never fires on a cold boot (`os dev` restarts
14+
the process on recompile rather than firing it, and production never reloads).
15+
16+
Net effect: after any real restart, **no flow bound its trigger**, so
17+
record-change automations did not fire at all.
18+
19+
Fix: bind flows at `kernel:ready` from `protocol.getMetaItems({ type: 'flow' })`
20+
— the canonical flattened flow view that `GET /meta/flow` serves and that does
21+
surface inline app flows — once every plugin has finished `init()`/`start()`
22+
(so the app, hence its flows, is registered). `registerFlow` is idempotent, so
23+
re-binding a flow the boot pull already registered is harmless.
24+
25+
Verified end-to-end on a clean instance: before the fix, updating a record
26+
fired **0** flows (0 bound at boot); after, a cold boot binds all flows and a
27+
single record update fires every matching record-triggered flow. Regression
28+
test boots a kernel with an inline-app record-triggered flow served only via
29+
`protocol.getMetaItems` and asserts it is bound after `bootstrap()` alone with
30+
no `metadata:reloaded` fired — it fails on the pre-fix code.

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
09e1b261563284fbeddc7c5c1bde753330ccab87
1+
3e4268041bc2523bc3f40a4d54646672ce3394c5
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Regression: a record-triggered flow must bind on a COLD boot.
4+
//
5+
// Flows defined inline in an app manifest are never promoted to standalone
6+
// registry 'flow' items (registerApp stores the app under type 'app'), so the
7+
// automation boot pull (`ql.registry.listItems('flow')`) sees ZERO of them.
8+
// The canonical flattened flow view — `protocol.getMetaItems({ type: 'flow' })`,
9+
// what `GET /meta/flow` serves — does surface them, but nothing bound from it
10+
// on a cold boot: the pre-fix re-sync used `metadata.list('flow')` and only ran
11+
// on 'metadata:reloaded', which never fires on a fresh boot (or in production).
12+
// The bug: record-triggered automations silently never bound on a cold start,
13+
// so they never fired.
14+
//
15+
// The fix binds flows from `protocol.getMetaItems({ type: 'flow' })` at
16+
// 'kernel:ready'. This proves the flow is bound after bootstrap() alone — with
17+
// NO 'metadata:reloaded' ever fired, from the protocol service alone.
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { LiteKernel } from '@objectstack/core';
21+
import { AutomationEngine } from './engine.js';
22+
import { AutomationServicePlugin } from './plugin.js';
23+
import type { FlowTrigger, FlowTriggerBinding } from './engine.js';
24+
import type { AutomationContext } from '@objectstack/spec/contracts';
25+
26+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
27+
28+
/** A record-after-update triggered flow, the shape the metadata service serves. */
29+
function recordTriggeredFlow(name: string, object: string) {
30+
return {
31+
name,
32+
label: name,
33+
type: 'autolaunched',
34+
nodes: [
35+
{
36+
id: 'start',
37+
type: 'start',
38+
label: 'Start',
39+
config: { objectName: object, triggerType: 'record-after-update', condition: 'status == "done"' },
40+
},
41+
{ id: 'end', type: 'end', label: 'End' },
42+
],
43+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
44+
};
45+
}
46+
47+
/** A recording FlowTrigger of type 'record_change' (stands in for the real one). */
48+
function recordingRecordChangeTrigger() {
49+
const bound = new Map<string, (ctx: AutomationContext) => Promise<void>>();
50+
const trigger: FlowTrigger = {
51+
type: 'record_change',
52+
start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise<void>) {
53+
bound.set(binding.flowName, cb);
54+
},
55+
stop(flowName: string) {
56+
bound.delete(flowName);
57+
},
58+
};
59+
return {
60+
trigger,
61+
has: (n: string) => bound.has(n),
62+
fire: async (n: string) => {
63+
await bound.get(n)?.({ event: 'record_change', params: { flowName: n } } as AutomationContext);
64+
},
65+
};
66+
}
67+
68+
/**
69+
* The protocol's flattened flow view — `getMetaItems({ type: 'flow' })` — served
70+
* as the `{ items: [...] }` envelope the real protocol returns, so the fix's
71+
* unwrap path is exercised. No objectql registry, mirroring the empty boot pull.
72+
*/
73+
function fakeProtocolService(flows: unknown[]) {
74+
return {
75+
async getMetaItems(q: { type: string }) {
76+
return { items: q.type === 'flow' ? flows : [] };
77+
},
78+
};
79+
}
80+
81+
/**
82+
* Boot with a protocol service that HAS the flow but NO objectql registry, so
83+
* the boot pull is empty — exactly the inline-app-flow cold-boot scenario. The
84+
* recording record-change trigger is registered during init (before
85+
* kernel:ready), the way the real RecordChangeTriggerPlugin registers its
86+
* trigger, so the kernel:ready bind can find it.
87+
*/
88+
async function bootKernel(flows: unknown[], rec: ReturnType<typeof recordingRecordChangeTrigger>) {
89+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
90+
kernel.use(new AutomationServicePlugin());
91+
const harness = {
92+
name: 'test.harness',
93+
type: 'standard' as const,
94+
version: '1.0.0',
95+
dependencies: [] as string[],
96+
async init(ctx: any) {
97+
ctx.registerService('protocol', fakeProtocolService(flows));
98+
// AutomationServicePlugin.init() ran first (registered above), so the
99+
// engine service exists; register the trigger before kernel:ready.
100+
ctx.getService('automation').registerTrigger(rec.trigger);
101+
},
102+
async start() {},
103+
};
104+
kernel.use(harness as never);
105+
await kernel.bootstrap();
106+
return kernel;
107+
}
108+
109+
describe('record-triggered flow binds on cold boot (kernel:ready sync)', () => {
110+
it('binds an inline-app record flow after bootstrap() with no metadata:reloaded', async () => {
111+
const rec = recordingRecordChangeTrigger();
112+
const kernel = await bootKernel([recordTriggeredFlow('notify_on_done', 'task')], rec);
113+
await flush();
114+
115+
// The core fix: the flow is bound to its record-change trigger after a
116+
// plain cold boot — no 'metadata:reloaded' was ever fired.
117+
expect(rec.has('notify_on_done'), 'record-triggered flow bound at kernel:ready').toBe(true);
118+
119+
const engine = kernel.getService<AutomationEngine>('automation');
120+
expect(engine.getActiveTriggerBindings().map((b) => b.flowName)).toContain('notify_on_done');
121+
122+
await kernel.shutdown();
123+
});
124+
125+
it('does not bind when the metadata service serves no flows', async () => {
126+
const rec = recordingRecordChangeTrigger();
127+
const kernel = await bootKernel([], rec);
128+
await flush();
129+
expect(rec.has('notify_on_done')).toBe(false);
130+
await kernel.shutdown();
131+
});
132+
});

packages/services/service-automation/src/plugin.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,26 @@ export class AutomationServicePlugin implements Plugin {
239239
await this.resyncFlowsFromMetadata(ctx);
240240
});
241241

242+
// ── Cold-boot bind via the PROTOCOL's flattened flow view ─────────────
243+
// The boot pull above reads `ql.registry.listItems('flow')`, which is
244+
// EMPTY for flows defined inline in an app manifest: registerApp stores
245+
// the app under type 'app' and never promotes its inline flows to
246+
// standalone registry 'flow' items. The 'metadata:reloaded' re-sync
247+
// below can't cover the cold boot either — that hook fires only on a dev
248+
// HMR reload (and `os dev` restarts the process on recompile rather than
249+
// firing it), never on a fresh boot or in production. Net effect:
250+
// record-triggered flows silently never bound on a cold start, so their
251+
// automations never fired.
252+
//
253+
// The canonical flattened flow view — the one `GET /meta/flow` serves —
254+
// is `protocol.getMetaItems({ type: 'flow' })`; it surfaces inline app
255+
// flows on-demand from the registry. Bind from THAT at kernel:ready,
256+
// once every plugin has finished init()/start() (so the app — hence its
257+
// flows — is registered). registerFlow is idempotent with the boot pull.
258+
ctx.hook('kernel:ready', async () => {
259+
await this.syncFlowsFromProtocol(ctx);
260+
});
261+
242262
// ADR-0019 follow-up: re-arm auto-resume timers for runs that were
243263
// suspended at a timer-`wait` node when the process went down. Must run
244264
// *after* the flow pull above — resume() needs the flow definitions
@@ -332,6 +352,60 @@ export class AutomationServicePlugin implements Plugin {
332352
}
333353
}
334354

355+
/**
356+
* Bind flows from the protocol's flattened flow view — `getMetaItems({ type:
357+
* 'flow' })`, the same source `GET /meta/flow` serves. Unlike
358+
* `registry.listItems('flow')` (the boot pull) this surfaces flows defined
359+
* inline in an app manifest, and unlike `metadata.list('flow')` (the
360+
* `metadata:reloaded` re-sync) it is populated on a cold boot once the app
361+
* is registered. Called at kernel:ready so record-triggered automations
362+
* actually bind on a fresh start. registerFlow is idempotent, so re-binding
363+
* a flow the boot pull already registered is harmless.
364+
*/
365+
private async syncFlowsFromProtocol(ctx: PluginContext): Promise<void> {
366+
if (!this.engine) return;
367+
let protocol: { getMetaItems?(q: { type: string }): Promise<unknown> } | undefined;
368+
try {
369+
protocol = ctx.getService('protocol');
370+
} catch {
371+
return; // no protocol service (bare engine / tests) — nothing to sync
372+
}
373+
if (!protocol || typeof protocol.getMetaItems !== 'function') return;
374+
375+
let raw: unknown;
376+
try {
377+
raw = await protocol.getMetaItems({ type: 'flow' });
378+
} catch (err) {
379+
ctx.logger.warn(
380+
`[Automation] cold-boot flow bind skipped: getMetaItems('flow') failed: ${(err as Error).message}`,
381+
);
382+
return;
383+
}
384+
385+
// getMetaItems hands back a bare array or an `{ items: [...] }` envelope,
386+
// and each entry is either the flow doc or an `{ item: <flow> }` wrapper.
387+
const list = Array.isArray(raw) ? raw : (((raw as { items?: unknown[] })?.items) ?? []);
388+
let bound = 0;
389+
for (const entry of list) {
390+
const def = (
391+
entry && typeof entry === 'object' && 'item' in entry ? (entry as { item: unknown }).item : entry
392+
) as { name?: string } | undefined;
393+
if (!def?.name) continue; // registerFlow is idempotent, so re-binding is safe
394+
try {
395+
this.engine.registerFlow(def.name, def as never);
396+
this.syncedFlowNames.add(def.name);
397+
bound++;
398+
} catch (err) {
399+
ctx.logger.warn(
400+
`[Automation] cold-boot flow bind: failed to register ${def.name}: ${(err as Error).message}`,
401+
);
402+
}
403+
}
404+
if (bound > 0) {
405+
ctx.logger.info(`[Automation] Bound ${bound} flow(s) from the protocol at kernel:ready`);
406+
}
407+
}
408+
335409
async destroy(): Promise<void> {
336410
this.engine = undefined;
337411
}

0 commit comments

Comments
 (0)