diff --git a/.changeset/automation-bind-flows-cold-boot.md b/.changeset/automation-bind-flows-cold-boot.md new file mode 100644 index 0000000000..77f0ae7cd0 --- /dev/null +++ b/.changeset/automation-bind-flows-cold-boot.md @@ -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. diff --git a/.objectui-sha b/.objectui-sha index 66c3debb65..c275227bec 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -09e1b261563284fbeddc7c5c1bde753330ccab87 \ No newline at end of file +3e4268041bc2523bc3f40a4d54646672ce3394c5 diff --git a/packages/services/service-automation/src/flow-cold-boot-bind.test.ts b/packages/services/service-automation/src/flow-cold-boot-bind.test.ts new file mode 100644 index 0000000000..0d2851fb46 --- /dev/null +++ b/packages/services/service-automation/src/flow-cold-boot-bind.test.ts @@ -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((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 Promise>(); + const trigger: FlowTrigger = { + type: 'record_change', + start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise) { + 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) { + 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('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(); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 56c78e668e..bfc81831a0 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -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 @@ -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 { + if (!this.engine) return; + let protocol: { getMetaItems?(q: { type: string }): Promise } | 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: }` 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 { this.engine = undefined; }