Skip to content

Commit 497bda8

Browse files
os-zhuangclaude
andcommitted
feat(automation): honor flow status for enable/disable + expose runtime enable/bound state
The engine bound and ran every registered flow, ignoring the persisted `status` — an author couldn't turn an automation off or tell whether one was live. - registerFlow honors `status`: `obsolete`/`invalid` → disabled (trigger not bound, execute() refuses). `draft`/`active`/legacy-no-status → enabled, so existing flows are unaffected (zero regression). This persists the Studio's on/off switch via the existing `status` field (applied on publish rebind); a flip back out of a disabled status re-enables on re-register; a runtime toggleFlow() override on a still-enabled flow is preserved. - New getFlowRuntimeStates() + GET /api/v1/automation/_status → [{name, enabled, bound}] for every flow — the truth behind the Studio's status badges. Tests: draft/active bind+enable, obsolete neither binds nor enables + execute refuses, obsolete→active re-enables+re-binds, _status route shape. Endpoint runtime-verified on the showcase (20 flows; screen/manual flows correctly bound:false, all enabled). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2082109 commit 497bda8

5 files changed

Lines changed: 138 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
feat(automation): honor flow deployment status for enable/disable + expose runtime enable/bound state
7+
8+
The engine bound and ran **every** registered flow, ignoring the flow's
9+
persisted `status` — so an author had no way to turn an automation off (short of
10+
deleting it) and no way to see whether one was actually live. This is the engine
11+
half of the Studio's "clear on/off switch + visible enabled/bound status".
12+
13+
- **`registerFlow` now honors `status`:** a flow whose deployment `status` is
14+
`obsolete` or `invalid` is treated as **disabled** — its trigger is not bound
15+
and `execute()` refuses it. `draft` / `active` — and any legacy flow with no
16+
explicit status — stay enabled, so **existing flows are unaffected** (zero
17+
regression; this is the on/off switch persisting via the existing `status`
18+
field, applied on the next publish rebind). A status flip back OUT of a
19+
disabled state re-enables on re-register even if the flow had been turned off;
20+
a runtime `toggleFlow()` override on a still-enabled flow is preserved.
21+
22+
- **New `getFlowRuntimeStates()` + `GET /api/v1/automation/_status`:** returns
23+
`[{ name, enabled, bound }]` for every registered flow — the truth behind the
24+
Studio's status badges (persisted `status` is metadata; whether a flow is
25+
actually enabled and wired to its trigger is engine state). Underscore-prefixed
26+
so no flow name can shadow the route; degrades to an empty list on an older
27+
service.
28+
29+
Tests cover: draft/active flows bind + enable (unchanged), an `obsolete` flow is
30+
neither bound nor enabled and `execute()` refuses it, a status flip
31+
obsolete→active re-enables + re-binds, and the `_status` route shape.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ describe('HttpDispatcher', () => {
178178
{ name: 'slack', label: 'Slack', type: 'api', actions: [{ key: 'chat.postMessage', label: 'Post Message' }] },
179179
{ name: 'pg', label: 'Postgres', type: 'database', actions: [] },
180180
]),
181+
getFlowRuntimeStates: vi.fn().mockReturnValue([
182+
{ name: 'flow_a', enabled: true, bound: true },
183+
{ name: 'flow_b', enabled: false, bound: false },
184+
]),
181185
};
182186

183187
// Set up kernel services to include automation
@@ -192,6 +196,17 @@ describe('HttpDispatcher', () => {
192196
expect(result.response?.body?.data?.flows).toEqual(['flow_a', 'flow_b']);
193197
});
194198

199+
it('should return per-flow runtime enable/bound state via GET /_status', async () => {
200+
const result = await dispatcher.handleAutomation('_status', 'GET', {}, { request: {} });
201+
expect(result.handled).toBe(true);
202+
expect(result.response?.body?.data?.flows).toEqual([
203+
{ name: 'flow_a', enabled: true, bound: true },
204+
{ name: 'flow_b', enabled: false, bound: false },
205+
]);
206+
// `_status` must NOT be treated as a flow name (getFlow catch-all).
207+
expect(mockAutomationService.getFlow).not.toHaveBeenCalledWith('_status');
208+
});
209+
195210
it('should get a flow via GET /:name', async () => {
196211
const result = await dispatcher.handleAutomation('flow_a', 'GET', {}, { request: {} });
197212
expect(result.handled).toBe(true);

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2868,6 +2868,21 @@ export class HttpDispatcher {
28682868
return { handled: true, response: this.success({ connectors: [], total: 0 }) };
28692869
}
28702870

2871+
// GET /_status → runtime enable/bound state for every flow (backs the
2872+
// Studio's Automations status badges: persisted `status` is metadata, but
2873+
// whether a flow is actually enabled + bound to its trigger is engine
2874+
// state). Underscore-prefixed so no flow name can shadow it; MUST precede
2875+
// the `/:name → getFlow` catch-all.
2876+
if (parts[0] === '_status' && parts.length === 1 && m === 'GET') {
2877+
const svc = automationService as { getFlowRuntimeStates?: () => Array<{ name: string; enabled: boolean; bound: boolean }> };
2878+
if (typeof svc.getFlowRuntimeStates === 'function') {
2879+
const flows = svc.getFlowRuntimeStates();
2880+
return { handled: true, response: this.success({ flows, total: flows.length }) };
2881+
}
2882+
// Service present but older / does not implement the method.
2883+
return { handled: true, response: this.success({ flows: [], total: 0 }) };
2884+
}
2885+
28712886
// Routes with :name
28722887
if (parts.length >= 1) {
28732888
const name = parts[0];

‎packages/services/service-automation/src/engine.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2350,6 +2350,48 @@ describe('AutomationEngine - Flow Trigger Wiring', () => {
23502350
});
23512351
});
23522352

2353+
describe('AutomationEngine - flow status enable/disable gate', () => {
2354+
let engine: AutomationEngine;
2355+
beforeEach(() => { engine = new AutomationEngine(createTestLogger()); });
2356+
2357+
it('binds + enables draft/active flows — existing flows are unaffected', () => {
2358+
const rec = recordingTrigger('record_change');
2359+
engine.registerTrigger(rec.trigger);
2360+
engine.registerFlow('rc_draft', recordChangeFlow('rc_draft')); // status defaults to 'draft'
2361+
engine.registerFlow('rc_active', { ...recordChangeFlow('rc_active'), status: 'active' });
2362+
const states = engine.getFlowRuntimeStates();
2363+
expect(states.find((s) => s.name === 'rc_draft')).toEqual({ name: 'rc_draft', enabled: true, bound: true });
2364+
expect(states.find((s) => s.name === 'rc_active')).toEqual({ name: 'rc_active', enabled: true, bound: true });
2365+
expect(engine.getActiveTriggerBindings()).toHaveLength(2);
2366+
});
2367+
2368+
it('does NOT bind or enable a flow whose status is obsolete', () => {
2369+
const rec = recordingTrigger('record_change');
2370+
engine.registerTrigger(rec.trigger);
2371+
engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' });
2372+
expect(engine.getActiveTriggerBindings()).toHaveLength(0);
2373+
expect(engine.getFlowRuntimeStates().find((s) => s.name === 'rc_obsolete'))
2374+
.toEqual({ name: 'rc_obsolete', enabled: false, bound: false });
2375+
});
2376+
2377+
it('refuses to execute a disabled (obsolete) flow', async () => {
2378+
engine.registerFlow('x', { ...recordChangeFlow('x'), status: 'obsolete' });
2379+
const res = await engine.execute('x', { event: 'test' } as never);
2380+
expect(res.success).toBe(false);
2381+
expect(res.error).toContain('disabled');
2382+
});
2383+
2384+
it('flipping status obsolete → active re-enables + re-binds on re-register', () => {
2385+
const rec = recordingTrigger('record_change');
2386+
engine.registerTrigger(rec.trigger);
2387+
engine.registerFlow('flip', { ...recordChangeFlow('flip'), status: 'obsolete' });
2388+
expect(engine.getFlowRuntimeStates().find((s) => s.name === 'flip')).toMatchObject({ enabled: false, bound: false });
2389+
// Re-author to active and re-register (what a publish rebind does).
2390+
engine.registerFlow('flip', { ...recordChangeFlow('flip'), status: 'active' });
2391+
expect(engine.getFlowRuntimeStates().find((s) => s.name === 'flip')).toMatchObject({ enabled: true, bound: true });
2392+
});
2393+
});
2394+
23532395
describe('AutomationEngine - Start Condition Gate', () => {
23542396
let engine: AutomationEngine;
23552397

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,10 @@ export class AutomationEngine implements IAutomationService {
404404

405405
private flows = new Map<string, FlowParsed>();
406406
private flowEnabled = new Map<string, boolean>();
407+
/** Flows the persisted deployment `status` currently marks disabled
408+
* (`obsolete`/`invalid`), tracked so a status flip back to active/draft
409+
* re-enables on the next (re)register even if the flow had been turned off. */
410+
private flowStatusDisabled = new Map<string, boolean>();
407411
private flowVersionHistory = new Map<string, Array<{ version: number; definition: FlowParsed; createdAt: string }>>();
408412
private nodeExecutors = new Map<string, NodeExecutor>();
409413
private actionDescriptors = new Map<string, ActionDescriptor>();
@@ -872,7 +876,20 @@ export class AutomationEngine implements IAutomationService {
872876
this.flowVersionHistory.set(name, history);
873877

874878
this.flows.set(name, parsed);
875-
if (!this.flowEnabled.has(name)) {
879+
// Enable/disable from the persisted deployment `status`. `obsolete`/`invalid`
880+
// flows are DISABLED (unbound + guarded in execute); `draft`/`active` — and
881+
// any legacy flow with no explicit status — stay enabled, so existing flows
882+
// are unaffected (zero regression). This is how the Studio's on/off switch
883+
// persists: it flips `status` active↔obsolete, applied on the next publish
884+
// rebind. A flip back OUT of a disabled status re-enables even if turned off;
885+
// a runtime toggleFlow() override on a still-enabled flow is preserved.
886+
const flowStatus = (parsed as { status?: string }).status;
887+
const disabledByStatus = flowStatus === 'obsolete' || flowStatus === 'invalid';
888+
const wasStatusDisabled = this.flowStatusDisabled.get(name) === true;
889+
this.flowStatusDisabled.set(name, disabledByStatus);
890+
if (disabledByStatus) {
891+
this.flowEnabled.set(name, false);
892+
} else if (wasStatusDisabled || !this.flowEnabled.has(name)) {
876893
this.flowEnabled.set(name, true);
877894
}
878895
this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
@@ -888,10 +905,27 @@ export class AutomationEngine implements IAutomationService {
888905
this.deactivateFlowTrigger(name);
889906
this.flows.delete(name);
890907
this.flowEnabled.delete(name);
908+
this.flowStatusDisabled.delete(name);
891909
this.flowVersionHistory.delete(name);
892910
this.logger.info(`Flow unregistered: ${name}`);
893911
}
894912

913+
/**
914+
* Runtime enable/bound state for every registered flow — the truth behind the
915+
* Studio's status badges. The persisted `status` is metadata; whether a flow
916+
* is actually **enabled** (allowed to run) and **bound** (wired to its trigger,
917+
* so it fires) is engine state. `enabled: false` ⇒ status is obsolete/invalid
918+
* or a runtime toggle turned it off; `bound: false` on an enabled flow ⇒ it has
919+
* no trigger (e.g. a manually-invoked/screen flow).
920+
*/
921+
getFlowRuntimeStates(): Array<{ name: string; enabled: boolean; bound: boolean }> {
922+
return [...this.flows.keys()].map((name) => ({
923+
name,
924+
enabled: this.flowEnabled.get(name) !== false,
925+
bound: this.boundFlowTriggers.has(name),
926+
}));
927+
}
928+
895929
async listFlows(): Promise<string[]> {
896930
return [...this.flows.keys()];
897931
}

0 commit comments

Comments
 (0)