Skip to content

Commit 4ffc95c

Browse files
os-zhuangclaude
andcommitted
fix(automation): bind flow triggers on cold boot, not just after HMR reload
Record-triggered flows silently never fired after a fresh process start. The 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). Those flows live only in the metadata service, and the sole path that reads it — the 'metadata:reloaded' re-sync — fires only on a dev HMR reload, never on a cold boot or in production. So after any real restart, no flow bound its trigger and record-change automations did not fire until an unrelated src edit triggered an HMR reload. Bind flows from the metadata service once at 'kernel:ready' (after every plugin's init/start, when the metadata service is fully populated), reusing the idempotent resyncFlowsFromMetadata(). Regression test boots a kernel with an inline-app record-triggered flow and asserts it is bound after bootstrap() with no metadata:reloaded fired — fails on the pre-fix code. Found dogfooding a Studio-built automation end-to-end on a clean instance: record hooks fired but flows never did; 0 flows bound at boot while the metadata service held 20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e661d5 commit 4ffc95c

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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. The automation service's boot-time flow pull reads
9+
`ql.registry.listItems('flow')`, which is **empty for flows defined inline in
10+
an app manifest**`registry.registerApp()` stores the app under type `'app'`
11+
and never promotes its inline flows to standalone registry `'flow'` items.
12+
Those flows live only in the metadata service (which flattens them), and the
13+
one path that reads it — the `metadata:reloaded` re-sync — fires solely on a
14+
dev HMR reload, never on a cold boot (or in production).
15+
16+
Net effect: after any real restart, no flow bound its trigger, so
17+
record-change automations did not fire until an unrelated source edit happened
18+
to trigger an HMR reload that ran the re-sync.
19+
20+
Fix: bind flows from the metadata service once at `kernel:ready` — after every
21+
plugin has finished `init()`/`start()` and the metadata service is fully
22+
populated. This reuses the existing `resyncFlowsFromMetadata()` (idempotent
23+
with the boot pull, so it is safe whichever source held the flow). Added a
24+
regression test that boots the kernel with an inline-app record-triggered flow
25+
and asserts it is bound after `bootstrap()` alone — with no `metadata:reloaded`
26+
ever fired — which fails on the pre-fix code.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 live only in the metadata service —
6+
// registerApp stores the app under type 'app' and never promotes its inline
7+
// flows to standalone registry 'flow' items, so the automation boot pull
8+
// (`ql.registry.listItems('flow')`) sees ZERO of them. The metadata-based
9+
// re-sync that DOES see them fires only on 'metadata:reloaded', which never
10+
// fires on a fresh boot (or in production). The bug: record-triggered
11+
// automations silently never bound on a cold start, so they never fired until
12+
// an unrelated src edit happened to trigger an HMR reload.
13+
//
14+
// The fix binds flows from the metadata service at 'kernel:ready'. This proves
15+
// the flow is bound after bootstrap() alone — with NO 'metadata:reloaded' ever
16+
// fired, and NO objectql registry present (mirroring the empty boot pull).
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { LiteKernel } from '@objectstack/core';
20+
import { AutomationEngine } from './engine.js';
21+
import { AutomationServicePlugin } from './plugin.js';
22+
import type { FlowTrigger, FlowTriggerBinding } from './engine.js';
23+
import type { AutomationContext } from '@objectstack/spec/contracts';
24+
25+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
26+
27+
/** A record-after-update triggered flow, the shape the metadata service serves. */
28+
function recordTriggeredFlow(name: string, object: string) {
29+
return {
30+
name,
31+
label: name,
32+
type: 'autolaunched',
33+
nodes: [
34+
{
35+
id: 'start',
36+
type: 'start',
37+
label: 'Start',
38+
config: { objectName: object, triggerType: 'record-after-update', condition: 'status == "done"' },
39+
},
40+
{ id: 'end', type: 'end', label: 'End' },
41+
],
42+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
43+
};
44+
}
45+
46+
/** A recording FlowTrigger of type 'record_change' (stands in for the real one). */
47+
function recordingRecordChangeTrigger() {
48+
const bound = new Map<string, (ctx: AutomationContext) => Promise<void>>();
49+
const trigger: FlowTrigger = {
50+
type: 'record_change',
51+
start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise<void>) {
52+
bound.set(binding.flowName, cb);
53+
},
54+
stop(flowName: string) {
55+
bound.delete(flowName);
56+
},
57+
};
58+
return {
59+
trigger,
60+
has: (n: string) => bound.has(n),
61+
fire: async (n: string) => {
62+
await bound.get(n)?.({ event: 'record_change', params: { flowName: n } } as AutomationContext);
63+
},
64+
};
65+
}
66+
67+
/** Only the `list('flow')` the metadata re-sync reads — no objectql registry. */
68+
function fakeMetadataService(flows: unknown[]) {
69+
return {
70+
async list(type: string) {
71+
return type === 'flow' ? flows : [];
72+
},
73+
};
74+
}
75+
76+
/**
77+
* Boot with a metadata service that HAS the flow but NO objectql registry, so
78+
* the boot pull is empty — exactly the inline-app-flow cold-boot scenario. The
79+
* recording record-change trigger is registered during init (before
80+
* kernel:ready), the way the real RecordChangeTriggerPlugin registers its
81+
* trigger, so the kernel:ready bind can find it.
82+
*/
83+
async function bootKernel(flows: unknown[], rec: ReturnType<typeof recordingRecordChangeTrigger>) {
84+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
85+
kernel.use(new AutomationServicePlugin());
86+
const harness = {
87+
name: 'test.harness',
88+
type: 'standard' as const,
89+
version: '1.0.0',
90+
dependencies: [] as string[],
91+
async init(ctx: any) {
92+
ctx.registerService('metadata', fakeMetadataService(flows));
93+
// AutomationServicePlugin.init() ran first (registered above), so the
94+
// engine service exists; register the trigger before kernel:ready.
95+
ctx.getService('automation').registerTrigger(rec.trigger);
96+
},
97+
async start() {},
98+
};
99+
kernel.use(harness as never);
100+
await kernel.bootstrap();
101+
return kernel;
102+
}
103+
104+
describe('record-triggered flow binds on cold boot (kernel:ready sync)', () => {
105+
it('binds an inline-app record flow after bootstrap() with no metadata:reloaded', async () => {
106+
const rec = recordingRecordChangeTrigger();
107+
const kernel = await bootKernel([recordTriggeredFlow('notify_on_done', 'task')], rec);
108+
await flush();
109+
110+
// The core fix: the flow is bound to its record-change trigger after a
111+
// plain cold boot — no 'metadata:reloaded' was ever fired.
112+
expect(rec.has('notify_on_done'), 'record-triggered flow bound at kernel:ready').toBe(true);
113+
114+
const engine = kernel.getService<AutomationEngine>('automation');
115+
expect(engine.getActiveTriggerBindings().map((b) => b.flowName)).toContain('notify_on_done');
116+
117+
await kernel.shutdown();
118+
});
119+
120+
it('does not bind when the metadata service serves no flows', async () => {
121+
const rec = recordingRecordChangeTrigger();
122+
const kernel = await bootKernel([], rec);
123+
await flush();
124+
expect(rec.has('notify_on_done')).toBe(false);
125+
await kernel.shutdown();
126+
});
127+
});

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

Lines changed: 20 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 from the METADATA SERVICE ──────────────────────────
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. Those flows DO live in the metadata
247+
// service (it flattens app flows into the 'flow' collection), but the
248+
// only path that reads it — the 'metadata:reloaded' re-sync above —
249+
// fires solely on a dev HMR reload, NOT on a fresh boot (and never in
250+
// production). The net effect was that record-triggered flows silently
251+
// never bound on a cold start, so their automations never fired until an
252+
// unrelated src edit happened to trigger an HMR reload.
253+
//
254+
// Bind from the metadata service once at kernel:ready — after every
255+
// plugin has finished init()/start() and the metadata service is fully
256+
// populated. resyncFlowsFromMetadata() is idempotent with the boot pull
257+
// (registerFlow re-binds), so it is safe whichever source held the flow.
258+
ctx.hook('kernel:ready', async () => {
259+
await this.resyncFlowsFromMetadata(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

0 commit comments

Comments
 (0)