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

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
is running** — the Studio online-authoring journey: author a record-triggered
automation, publish it, immediately update a matching record — did **not** fire.
Its trigger only bound on the next process restart.

Two gaps, both fixed:

1. **The publish path fired no rebind signal.** `POST /packages/:id/publish-drafts`
→ `protocol.publishPackageDrafts` promotes the drafts to active but emitted no
event the automation service listens to. The runtime dispatcher now announces
`metadata:reloaded` after a successful publish — the same signal a dev artifact
reload fires (`MetadataPlugin._reloadAndAnnounce`) — so boot-cached consumers
re-sync without a restart.

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 does not surface inline app flows), so even when the
hook fired it bound nothing. It now reads `protocol.getMetaItems({ type: 'flow' })`
— the same flattened flow view #2560's cold-boot bind and `GET /meta/flow` use —
while keeping the teardown of flows removed from the artifact. A failed or
unavailable protocol read is a no-op and never tears down live flows.

Production is largely unaffected (a deploy reboots the process, so #2560's
cold-boot bind covers it); this closes the gap for dev and single-instance
Studio authoring.

Verified end-to-end on a clean instance: authored a record-triggered flow in a
package, published it via `POST /packages/:id/publish-drafts` **without
restarting**, then updated a matching record and observed the flow fire (before
the fix it did not). New regression tests boot a kernel whose protocol serves a
flow only after boot and assert `metadata:reloaded` binds it — and that the
re-sync reads the protocol, not `metadata.list` — both failing on the pre-fix code.
42 changes: 42 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,48 @@ describe('HttpDispatcher', () => {
expect((result.response as any)?.body?.data?.publishedCount).toBe(3);
});

it('POST /packages/:id/publish-drafts announces metadata:reloaded so boot-cached consumers re-sync', async () => {
// #2560 follow-up: a flow published while the server runs must bind its
// trigger WITHOUT a restart. The publish path fires 'metadata:reloaded'
// — the same signal a dev artifact reload fires — so the automation
// service re-syncs the just-published flow from the protocol.
const publishPackageDrafts = vi.fn().mockResolvedValue({
success: true, publishedCount: 1, failedCount: 0,
published: [{ type: 'flow', name: 'ticket_closed', version: '1' }], failed: [],
});
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts });
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
return null;
});
const trigger = vi.fn().mockResolvedValue(undefined);
(kernel as any).context.trigger = trigger;

const result = await dispatcher.handlePackages('/com.example.ops/publish-drafts', 'POST', {}, {}, { request: {} });

expect(result.response?.status).toBe(200);
expect(trigger).toHaveBeenCalledWith(
'metadata:reloaded',
expect.objectContaining({ changed: expect.arrayContaining(['flow/ticket_closed']) }),
);
});

it('POST /packages/:id/publish-drafts does NOT announce when nothing was published', async () => {
const publishPackageDrafts = vi.fn().mockResolvedValue({
success: false, publishedCount: 0, failedCount: 0, published: [], failed: [],
});
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts });
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
return null;
});
const trigger = vi.fn().mockResolvedValue(undefined);
(kernel as any).context.trigger = trigger;

await dispatcher.handlePackages('/app.empty/publish-drafts', 'POST', {}, {}, { request: {} });
expect(trigger).not.toHaveBeenCalled();
});

it('POST /packages/:id/publish-drafts returns 501 when protocol lacks the method', async () => {
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({});
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2173,6 +2173,31 @@ export class HttpDispatcher {
} catch (e: any) {
(result as any).unhideError = e?.message ?? 'visibility flip failed';
}
// A publish promoted drafts to active (or unhid an additive
// app) at RUNTIME — but boot-cached consumers still hold the
// pre-publish view. The load-bearing one is the automation
// engine: a record-triggered flow authored + published in the
// Studio does NOT bind its trigger (record-change automations
// never fire) until the next restart. Announce
// 'metadata:reloaded' — the same signal a dev artifact reload
// fires (MetadataPlugin._reloadAndAnnounce) — so subscribers
// re-sync WITHOUT a restart. #2560 covers the cold-boot bind;
// this covers publish-while-running. `this.kernel.context` is
// the same handle the service resolver uses above. Best-effort:
// a subscriber failure must never fail the publish (the drafts
// are already live), so it rides the response instead.
try {
const changed = [
...(((result as any)?.published ?? []) as Array<{ type: string; name: string }>)
.map((p) => `${p.type}/${p.name}`),
...(((result as any)?.unhiddenApps ?? []) as string[]).map((n) => `app/${n}`),
];
if (changed.length > 0 && this.kernel?.context?.trigger) {
await this.kernel.context.trigger('metadata:reloaded', { changed });
}
} catch (e: any) {
(result as any).rebindError = e?.message ?? 'metadata:reloaded announce failed';
}
return { handled: true, response: this.success(result) };
} catch (e: any) {
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
Expand Down
36 changes: 22 additions & 14 deletions packages/services/service-automation/src/flow-hot-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
// This proves the fix end-to-end on the automation side: the 'metadata:reloaded'
// hook re-registers every current flow (re-binding its trigger — for a scheduled
// flow that is the job cancel + reschedule the ScheduleTrigger performs) and
// tears down flows that vanished from the artifact. A recording trigger stands in
// for the concrete ScheduleTrigger (whose idempotent job re-bind is covered by
// tears down flows that vanished from the artifact. The re-sync reads the
// protocol's flattened flow view (`getMetaItems({type:'flow'})`) — the SAME
// source #2560's cold-boot bind uses — so the fake here is a `protocol` service,
// not a `metadata` one (`metadata.list('flow')` returns 0 in a real server; the
// same hook also fires on a Studio publish). A recording trigger stands in for
// the concrete ScheduleTrigger (whose idempotent job re-bind is covered by
// trigger-schedule's schedule-runas-e2e test) so this stays dependency-light.

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

/** A mutable fake `metadata` service exposing just the `list('flow')` the re-sync uses. */
function fakeMetadataService(initial: unknown[]) {
/**
* A mutable fake `protocol` service exposing just the flattened
* `getMetaItems({type:'flow'})` view the re-sync reads (returned as the
* `{ items: [...] }` envelope the real protocol serves).
*/
function fakeProtocolService(initial: unknown[]) {
let flows = initial;
return {
service: {
async list(type: string) {
return type === 'flow' ? flows : [];
async getMetaItems(q: { type: string }) {
return { items: q.type === 'flow' ? flows : [] };
},
},
setFlows: (next: unknown[]) => { flows = next; },
};
}

async function bootKernel(meta: { service: unknown }) {
async function bootKernel(proto: { service: unknown }) {
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
const harness = {
name: 'test.harness',
type: 'standard' as const,
version: '1.0.0',
dependencies: [] as string[],
async init(ctx: any) {
ctx.registerService('metadata', meta.service);
ctx.registerService('protocol', proto.service);
},
async start() {},
};
Expand All @@ -119,8 +127,8 @@ const reload = (kernel: LiteKernel) => (kernel as any).context.trigger('metadata

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

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

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

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

// Delete the flow file → recompiled artifact no longer carries it.
meta.setFlows([]);
proto.setFlows([]);
await reload(kernel);
await flush();

Expand Down
Loading