Skip to content

Commit a0e4cb5

Browse files
os-zhuangclaude
andcommitted
fix(automation): bind a flow published while the server runs, without a restart
Follow-up to #2560 (cold-boot flow binding). A flow published while the server runs — author a record-triggered automation in the Studio, publish it, immediately update a matching record — did not fire; its trigger only bound on the next restart. Two gaps, both fixed: 1. The publish path fired no rebind signal. The runtime dispatcher now announces 'metadata:reloaded' after a successful POST /packages/:id/publish-drafts — the same signal a dev artifact reload fires — so boot-cached consumers re-sync. 2. The runtime re-sync read the wrong source. The automation service's 'metadata:reloaded' re-sync pulled metadata.list('flow'), which returns 0 in a real running server; it now reads protocol.getMetaItems({type:'flow'}) — the same flattened view #2560's cold-boot bind uses — keeping teardown of removed flows. A failed/unavailable protocol read is a no-op and never tears down live flows. Verified end-to-end on a clean instance: authored a record-triggered flow in a writable package, published it without restarting, then updated a matching record and observed the flow fire (before the fix it did not). New regression tests: flow-publish-rebind.test.ts (re-sync binds a post-boot flow, tears down a removed one, reads the protocol not metadata.list, and never tears down on a failed read) plus two http-dispatcher.test.ts cases (publish fires / does not fire the hook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1b1b34e commit a0e4cb5

6 files changed

Lines changed: 417 additions & 85 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(automation): bind a flow published while the server runs, without a restart
7+
8+
Follow-up to #2560 (cold-boot flow binding). A flow **published while the server
9+
is running** — the Studio online-authoring journey: author a record-triggered
10+
automation, publish it, immediately update a matching record — did **not** fire.
11+
Its trigger only bound on the next process restart.
12+
13+
Two gaps, both fixed:
14+
15+
1. **The publish path fired no rebind signal.** `POST /packages/:id/publish-drafts`
16+
`protocol.publishPackageDrafts` promotes the drafts to active but emitted no
17+
event the automation service listens to. The runtime dispatcher now announces
18+
`metadata:reloaded` after a successful publish — the same signal a dev artifact
19+
reload fires (`MetadataPlugin._reloadAndAnnounce`) — so boot-cached consumers
20+
re-sync without a restart.
21+
22+
2. **The runtime re-sync read the wrong source.** The automation service's
23+
`metadata:reloaded` re-sync pulled `metadata.list('flow')`, which returns 0 in a
24+
real running server (it does not surface inline app flows), so even when the
25+
hook fired it bound nothing. It now reads `protocol.getMetaItems({ type: 'flow' })`
26+
— the same flattened flow view #2560's cold-boot bind and `GET /meta/flow` use —
27+
while keeping the teardown of flows removed from the artifact. A failed or
28+
unavailable protocol read is a no-op and never tears down live flows.
29+
30+
Production is largely unaffected (a deploy reboots the process, so #2560's
31+
cold-boot bind covers it); this closes the gap for dev and single-instance
32+
Studio authoring.
33+
34+
Verified end-to-end on a clean instance: authored a record-triggered flow in a
35+
package, published it via `POST /packages/:id/publish-drafts` **without
36+
restarting**, then updated a matching record and observed the flow fire (before
37+
the fix it did not). New regression tests boot a kernel whose protocol serves a
38+
flow only after boot and assert `metadata:reloaded` binds it — and that the
39+
re-sync reads the protocol, not `metadata.list` — both failing on the pre-fix code.

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,48 @@ describe('HttpDispatcher', () => {
971971
expect((result.response as any)?.body?.data?.publishedCount).toBe(3);
972972
});
973973

974+
it('POST /packages/:id/publish-drafts announces metadata:reloaded so boot-cached consumers re-sync', async () => {
975+
// #2560 follow-up: a flow published while the server runs must bind its
976+
// trigger WITHOUT a restart. The publish path fires 'metadata:reloaded'
977+
// — the same signal a dev artifact reload fires — so the automation
978+
// service re-syncs the just-published flow from the protocol.
979+
const publishPackageDrafts = vi.fn().mockResolvedValue({
980+
success: true, publishedCount: 1, failedCount: 0,
981+
published: [{ type: 'flow', name: 'ticket_closed', version: '1' }], failed: [],
982+
});
983+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
984+
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts });
985+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
986+
return null;
987+
});
988+
const trigger = vi.fn().mockResolvedValue(undefined);
989+
(kernel as any).context.trigger = trigger;
990+
991+
const result = await dispatcher.handlePackages('/com.example.ops/publish-drafts', 'POST', {}, {}, { request: {} });
992+
993+
expect(result.response?.status).toBe(200);
994+
expect(trigger).toHaveBeenCalledWith(
995+
'metadata:reloaded',
996+
expect.objectContaining({ changed: expect.arrayContaining(['flow/ticket_closed']) }),
997+
);
998+
});
999+
1000+
it('POST /packages/:id/publish-drafts does NOT announce when nothing was published', async () => {
1001+
const publishPackageDrafts = vi.fn().mockResolvedValue({
1002+
success: false, publishedCount: 0, failedCount: 0, published: [], failed: [],
1003+
});
1004+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1005+
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts });
1006+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1007+
return null;
1008+
});
1009+
const trigger = vi.fn().mockResolvedValue(undefined);
1010+
(kernel as any).context.trigger = trigger;
1011+
1012+
await dispatcher.handlePackages('/app.empty/publish-drafts', 'POST', {}, {}, { request: {} });
1013+
expect(trigger).not.toHaveBeenCalled();
1014+
});
1015+
9741016
it('POST /packages/:id/publish-drafts returns 501 when protocol lacks the method', async () => {
9751017
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
9761018
if (name === 'protocol') return Promise.resolve({});

packages/runtime/src/http-dispatcher.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2173,6 +2173,31 @@ export class HttpDispatcher {
21732173
} catch (e: any) {
21742174
(result as any).unhideError = e?.message ?? 'visibility flip failed';
21752175
}
2176+
// A publish promoted drafts to active (or unhid an additive
2177+
// app) at RUNTIME — but boot-cached consumers still hold the
2178+
// pre-publish view. The load-bearing one is the automation
2179+
// engine: a record-triggered flow authored + published in the
2180+
// Studio does NOT bind its trigger (record-change automations
2181+
// never fire) until the next restart. Announce
2182+
// 'metadata:reloaded' — the same signal a dev artifact reload
2183+
// fires (MetadataPlugin._reloadAndAnnounce) — so subscribers
2184+
// re-sync WITHOUT a restart. #2560 covers the cold-boot bind;
2185+
// this covers publish-while-running. `this.kernel.context` is
2186+
// the same handle the service resolver uses above. Best-effort:
2187+
// a subscriber failure must never fail the publish (the drafts
2188+
// are already live), so it rides the response instead.
2189+
try {
2190+
const changed = [
2191+
...(((result as any)?.published ?? []) as Array<{ type: string; name: string }>)
2192+
.map((p) => `${p.type}/${p.name}`),
2193+
...(((result as any)?.unhiddenApps ?? []) as string[]).map((n) => `app/${n}`),
2194+
];
2195+
if (changed.length > 0 && this.kernel?.context?.trigger) {
2196+
await this.kernel.context.trigger('metadata:reloaded', { changed });
2197+
}
2198+
} catch (e: any) {
2199+
(result as any).rebindError = e?.message ?? 'metadata:reloaded announce failed';
2200+
}
21762201
return { handled: true, response: this.success(result) };
21772202
} catch (e: any) {
21782203
return { handled: true, response: this.error(e.message, e.statusCode || 500) };

packages/services/service-automation/src/flow-hot-reload.test.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
// This proves the fix end-to-end on the automation side: the 'metadata:reloaded'
1010
// hook re-registers every current flow (re-binding its trigger — for a scheduled
1111
// flow that is the job cancel + reschedule the ScheduleTrigger performs) and
12-
// tears down flows that vanished from the artifact. A recording trigger stands in
13-
// for the concrete ScheduleTrigger (whose idempotent job re-bind is covered by
12+
// tears down flows that vanished from the artifact. The re-sync reads the
13+
// protocol's flattened flow view (`getMetaItems({type:'flow'})`) — the SAME
14+
// source #2560's cold-boot bind uses — so the fake here is a `protocol` service,
15+
// not a `metadata` one (`metadata.list('flow')` returns 0 in a real server; the
16+
// same hook also fires on a Studio publish). A recording trigger stands in for
17+
// the concrete ScheduleTrigger (whose idempotent job re-bind is covered by
1418
// trigger-schedule's schedule-runas-e2e test) so this stays dependency-light.
1519

1620
import { describe, it, expect } from 'vitest';
@@ -84,28 +88,32 @@ function captureRunAs(engine: AutomationEngine): Array<string | undefined> {
8488
return seen;
8589
}
8690

87-
/** A mutable fake `metadata` service exposing just the `list('flow')` the re-sync uses. */
88-
function fakeMetadataService(initial: unknown[]) {
91+
/**
92+
* A mutable fake `protocol` service exposing just the flattened
93+
* `getMetaItems({type:'flow'})` view the re-sync reads (returned as the
94+
* `{ items: [...] }` envelope the real protocol serves).
95+
*/
96+
function fakeProtocolService(initial: unknown[]) {
8997
let flows = initial;
9098
return {
9199
service: {
92-
async list(type: string) {
93-
return type === 'flow' ? flows : [];
100+
async getMetaItems(q: { type: string }) {
101+
return { items: q.type === 'flow' ? flows : [] };
94102
},
95103
},
96104
setFlows: (next: unknown[]) => { flows = next; },
97105
};
98106
}
99107

100-
async function bootKernel(meta: { service: unknown }) {
108+
async function bootKernel(proto: { service: unknown }) {
101109
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
102110
const harness = {
103111
name: 'test.harness',
104112
type: 'standard' as const,
105113
version: '1.0.0',
106114
dependencies: [] as string[],
107115
async init(ctx: any) {
108-
ctx.registerService('metadata', meta.service);
116+
ctx.registerService('protocol', proto.service);
109117
},
110118
async start() {},
111119
};
@@ -119,8 +127,8 @@ const reload = (kernel: LiteKernel) => (kernel as any).context.trigger('metadata
119127

120128
describe("scheduled flow hot-reload re-bind (metadata:reloaded re-sync)", () => {
121129
it('re-binds an edited scheduled flow to its NEW definition without a restart', async () => {
122-
const meta = fakeMetadataService([scheduledFlow('sweep', 'user', 1000)]);
123-
const kernel = await bootKernel(meta);
130+
const proto = fakeProtocolService([scheduledFlow('sweep', 'user', 1000)]);
131+
const kernel = await bootKernel(proto);
124132
const engine = kernel.getService<AutomationEngine>('automation');
125133
const seen = captureRunAs(engine);
126134
const sched = recordingScheduleTrigger();
@@ -136,7 +144,7 @@ describe("scheduled flow hot-reload re-bind (metadata:reloaded re-sync)", () =>
136144

137145
// Edit the flow: runAs:system + interval 5000. Recompile → reload.
138146
sched.events.length = 0;
139-
meta.setFlows([scheduledFlow('sweep', 'system', 5000)]);
147+
proto.setFlows([scheduledFlow('sweep', 'system', 5000)]);
140148
await reload(kernel);
141149
await flush();
142150

@@ -155,8 +163,8 @@ describe("scheduled flow hot-reload re-bind (metadata:reloaded re-sync)", () =>
155163
});
156164

157165
it('tears down a scheduled flow that was deleted from the artifact', async () => {
158-
const meta = fakeMetadataService([scheduledFlow('sweep', 'user', 1000)]);
159-
const kernel = await bootKernel(meta);
166+
const proto = fakeProtocolService([scheduledFlow('sweep', 'user', 1000)]);
167+
const kernel = await bootKernel(proto);
160168
const engine = kernel.getService<AutomationEngine>('automation');
161169
const sched = recordingScheduleTrigger();
162170
engine.registerTrigger(sched.trigger);
@@ -166,7 +174,7 @@ describe("scheduled flow hot-reload re-bind (metadata:reloaded re-sync)", () =>
166174
expect(sched.has('sweep')).toBe(true);
167175

168176
// Delete the flow file → recompiled artifact no longer carries it.
169-
meta.setFlows([]);
177+
proto.setFlows([]);
170178
await reload(kernel);
171179
await flush();
172180

0 commit comments

Comments
 (0)