From 8e661d544174e02b7120f016d6930a1dc15c74dd Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 11:05:32 +0800 Subject: [PATCH 1/2] chore: bump objectui to 3e4268041bc2 fix(plugin-grid): schema-aware multi-value semantics for bulk-edit params (#2206) objectui@3e4268041bc2523bc3f40a4d54646672ce3394c5 --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b6c4a8eb8da1b209af42dd20af7b6f4f242447fa Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 11:30:53 +0800 Subject: [PATCH 2/2] fix(automation): bind flow triggers on cold boot, not just after HMR reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/automation-bind-flows-cold-boot.md | 30 ++++ .../src/flow-cold-boot-bind.test.ts | 132 ++++++++++++++++++ .../services/service-automation/src/plugin.ts | 74 ++++++++++ 3 files changed, 236 insertions(+) create mode 100644 .changeset/automation-bind-flows-cold-boot.md create mode 100644 packages/services/service-automation/src/flow-cold-boot-bind.test.ts 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/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; }