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
30 changes: 30 additions & 0 deletions .changeset/automation-bind-flows-cold-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/service-automation": patch
---

fix(automation): bind flow triggers on a cold boot, not just after an 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** — `registry.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 only ran on the
`metadata:reloaded` hook, which never fires on a cold boot (`os dev` restarts
the process on recompile rather than firing it, and production never reloads).

Net effect: after any real restart, **no flow bound its trigger**, so
record-change automations did not fire at all.

Fix: 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 the fix, 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. Regression
test boots a kernel with an inline-app record-triggered flow served only via
`protocol.getMetaItems` and asserts it is bound after `bootstrap()` alone with
no `metadata:reloaded` fired — it fails on the pre-fix code.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
09e1b261563284fbeddc7c5c1bde753330ccab87
3e4268041bc2523bc3f40a4d54646672ce3394c5
132 changes: 132 additions & 0 deletions packages/services/service-automation/src/flow-cold-boot-bind.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Regression: a record-triggered flow must bind on a COLD boot.
//
// Flows defined inline in an app manifest are never promoted to standalone
// registry 'flow' items (registerApp stores the app under type 'app'), so the
// automation boot pull (`ql.registry.listItems('flow')`) sees ZERO of them.
// The canonical flattened flow view — `protocol.getMetaItems({ type: 'flow' })`,
// what `GET /meta/flow` serves — does surface them, but nothing bound from it
// on a cold boot: the pre-fix re-sync used `metadata.list('flow')` and only ran
// on 'metadata:reloaded', which never fires on a fresh boot (or in production).
// The bug: record-triggered automations silently never bound on a cold start,
// so they never fired.
//
// The fix binds flows from `protocol.getMetaItems({ type: 'flow' })` at
// 'kernel:ready'. This proves the flow is bound after bootstrap() alone — with
// NO 'metadata:reloaded' ever fired, from the protocol service alone.

import { describe, it, expect } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { AutomationEngine } from './engine.js';
import { AutomationServicePlugin } from './plugin.js';
import type { FlowTrigger, FlowTriggerBinding } from './engine.js';
import type { AutomationContext } from '@objectstack/spec/contracts';

const flush = () => new Promise<void>((r) => setTimeout(r, 0));

/** A record-after-update triggered flow, the shape the metadata service serves. */
function recordTriggeredFlow(name: string, object: string) {
return {
name,
label: name,
type: 'autolaunched',
nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
config: { objectName: object, triggerType: 'record-after-update', condition: 'status == "done"' },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
};
}

/** A recording FlowTrigger of type 'record_change' (stands in for the real one). */
function recordingRecordChangeTrigger() {
const bound = new Map<string, (ctx: AutomationContext) => Promise<void>>();
const trigger: FlowTrigger = {
type: 'record_change',
start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise<void>) {
bound.set(binding.flowName, cb);
},
stop(flowName: string) {
bound.delete(flowName);
},
};
return {
trigger,
has: (n: string) => bound.has(n),
fire: async (n: string) => {
await bound.get(n)?.({ event: 'record_change', params: { flowName: n } } as AutomationContext);
},
};
}

/**
* The protocol's flattened flow view — `getMetaItems({ type: 'flow' })` — served
* as the `{ items: [...] }` envelope the real protocol returns, so the fix's
* unwrap path is exercised. No objectql registry, mirroring the empty boot pull.
*/
function fakeProtocolService(flows: unknown[]) {
return {
async getMetaItems(q: { type: string }) {
return { items: q.type === 'flow' ? flows : [] };
},
};
}

/**
* Boot with a protocol service that HAS the flow but NO objectql registry, so
* the boot pull is empty — exactly the inline-app-flow cold-boot scenario. The
* recording record-change trigger is registered during init (before
* kernel:ready), the way the real RecordChangeTriggerPlugin registers its
* trigger, so the kernel:ready bind can find it.
*/
async function bootKernel(flows: unknown[], rec: ReturnType<typeof recordingRecordChangeTrigger>) {
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
kernel.use(new AutomationServicePlugin());
const harness = {
name: 'test.harness',
type: 'standard' as const,
version: '1.0.0',
dependencies: [] as string[],
async init(ctx: any) {
ctx.registerService('protocol', fakeProtocolService(flows));
// AutomationServicePlugin.init() ran first (registered above), so the
// engine service exists; register the trigger before kernel:ready.
ctx.getService('automation').registerTrigger(rec.trigger);
},
async start() {},
};
kernel.use(harness as never);
await kernel.bootstrap();
return kernel;
}

describe('record-triggered flow binds on cold boot (kernel:ready sync)', () => {
it('binds an inline-app record flow after bootstrap() with no metadata:reloaded', async () => {
const rec = recordingRecordChangeTrigger();
const kernel = await bootKernel([recordTriggeredFlow('notify_on_done', 'task')], rec);
await flush();

// The core fix: the flow is bound to its record-change trigger after a
// plain cold boot — no 'metadata:reloaded' was ever fired.
expect(rec.has('notify_on_done'), 'record-triggered flow bound at kernel:ready').toBe(true);

const engine = kernel.getService<AutomationEngine>('automation');
expect(engine.getActiveTriggerBindings().map((b) => b.flowName)).toContain('notify_on_done');

await kernel.shutdown();
});

it('does not bind when the metadata service serves no flows', async () => {
const rec = recordingRecordChangeTrigger();
const kernel = await bootKernel([], rec);
await flush();
expect(rec.has('notify_on_done')).toBe(false);
await kernel.shutdown();
});
});
74 changes: 74 additions & 0 deletions packages/services/service-automation/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,26 @@ export class AutomationServicePlugin implements Plugin {
await this.resyncFlowsFromMetadata(ctx);
});

// ── Cold-boot bind via the PROTOCOL's flattened flow view ─────────────
// The boot pull above 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 'metadata:reloaded' re-sync
// below can't cover the cold boot either — that hook fires only on a dev
// HMR reload (and `os dev` restarts the process on recompile rather than
// firing it), never on a fresh boot or in production. Net effect:
// record-triggered flows silently never bound on a cold start, so their
// automations never fired.
//
// The canonical flattened flow view — the one `GET /meta/flow` serves —
// is `protocol.getMetaItems({ type: 'flow' })`; it surfaces inline app
// flows on-demand from the registry. Bind from THAT at kernel:ready,
// once every plugin has finished init()/start() (so the app — hence its
// flows — is registered). registerFlow is idempotent with the boot pull.
ctx.hook('kernel:ready', async () => {
await this.syncFlowsFromProtocol(ctx);
});

// ADR-0019 follow-up: re-arm auto-resume timers for runs that were
// suspended at a timer-`wait` node when the process went down. Must run
// *after* the flow pull above — resume() needs the flow definitions
Expand Down Expand Up @@ -332,6 +352,60 @@ export class AutomationServicePlugin implements Plugin {
}
}

/**
* Bind flows from the protocol's flattened flow view — `getMetaItems({ type:
* 'flow' })`, the same source `GET /meta/flow` serves. Unlike
* `registry.listItems('flow')` (the boot pull) this surfaces flows defined
* inline in an app manifest, and unlike `metadata.list('flow')` (the
* `metadata:reloaded` re-sync) it is populated on a cold boot once the app
* is registered. Called at kernel:ready so record-triggered automations
* actually bind on a fresh start. registerFlow is idempotent, so re-binding
* a flow the boot pull already registered is harmless.
*/
private async syncFlowsFromProtocol(ctx: PluginContext): Promise<void> {
if (!this.engine) return;
let protocol: { getMetaItems?(q: { type: string }): Promise<unknown> } | undefined;
try {
protocol = ctx.getService('protocol');
} catch {
return; // no protocol service (bare engine / tests) — nothing to sync
}
if (!protocol || typeof protocol.getMetaItems !== 'function') return;

let raw: unknown;
try {
raw = await protocol.getMetaItems({ type: 'flow' });
} catch (err) {
ctx.logger.warn(
`[Automation] cold-boot flow bind skipped: getMetaItems('flow') failed: ${(err as Error).message}`,
);
return;
}

// getMetaItems hands back a bare array or an `{ items: [...] }` envelope,
// and each entry is either the flow doc or an `{ item: <flow> }` wrapper.
const list = Array.isArray(raw) ? raw : (((raw as { items?: unknown[] })?.items) ?? []);
let bound = 0;
for (const entry of list) {
const def = (
entry && typeof entry === 'object' && 'item' in entry ? (entry as { item: unknown }).item : entry
) as { name?: string } | undefined;
if (!def?.name) continue; // registerFlow is idempotent, so re-binding is safe
try {
this.engine.registerFlow(def.name, def as never);
this.syncedFlowNames.add(def.name);
bound++;
} catch (err) {
ctx.logger.warn(
`[Automation] cold-boot flow bind: failed to register ${def.name}: ${(err as Error).message}`,
);
}
}
if (bound > 0) {
ctx.logger.info(`[Automation] Bound ${bound} flow(s) from the protocol at kernel:ready`);
}
}

async destroy(): Promise<void> {
this.engine = undefined;
}
Expand Down