diff --git a/.changeset/flow-enable-disable-status.md b/.changeset/flow-enable-disable-status.md new file mode 100644 index 0000000000..aa07a2621a --- /dev/null +++ b/.changeset/flow-enable-disable-status.md @@ -0,0 +1,31 @@ +--- +"@objectstack/service-automation": minor +"@objectstack/runtime": minor +--- + +feat(automation): honor flow deployment status for enable/disable + expose runtime enable/bound state + +The engine bound and ran **every** registered flow, ignoring the flow's +persisted `status` — so an author had no way to turn an automation off (short of +deleting it) and no way to see whether one was actually live. This is the engine +half of the Studio's "clear on/off switch + visible enabled/bound status". + +- **`registerFlow` now honors `status`:** a flow whose deployment `status` is + `obsolete` or `invalid` is treated as **disabled** — its trigger is not bound + and `execute()` refuses it. `draft` / `active` — and any legacy flow with no + explicit status — stay enabled, so **existing flows are unaffected** (zero + regression; this is the on/off switch persisting via the existing `status` + field, applied on the next publish rebind). A status flip back OUT of a + disabled state re-enables on re-register even if the flow had been turned off; + a runtime `toggleFlow()` override on a still-enabled flow is preserved. + +- **New `getFlowRuntimeStates()` + `GET /api/v1/automation/_status`:** returns + `[{ name, enabled, bound }]` for every registered flow — the truth behind the + Studio's status badges (persisted `status` is metadata; whether a flow is + actually enabled and wired to its trigger is engine state). Underscore-prefixed + so no flow name can shadow the route; degrades to an empty list on an older + service. + +Tests cover: draft/active flows bind + enable (unchanged), an `obsolete` flow is +neither bound nor enabled and `execute()` refuses it, a status flip +obsolete→active re-enables + re-binds, and the `_status` route shape. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index c5d01f6396..8611124fcd 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -178,6 +178,10 @@ describe('HttpDispatcher', () => { { name: 'slack', label: 'Slack', type: 'api', actions: [{ key: 'chat.postMessage', label: 'Post Message' }] }, { name: 'pg', label: 'Postgres', type: 'database', actions: [] }, ]), + getFlowRuntimeStates: vi.fn().mockReturnValue([ + { name: 'flow_a', enabled: true, bound: true }, + { name: 'flow_b', enabled: false, bound: false }, + ]), }; // Set up kernel services to include automation @@ -192,6 +196,17 @@ describe('HttpDispatcher', () => { expect(result.response?.body?.data?.flows).toEqual(['flow_a', 'flow_b']); }); + it('should return per-flow runtime enable/bound state via GET /_status', async () => { + const result = await dispatcher.handleAutomation('_status', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.body?.data?.flows).toEqual([ + { name: 'flow_a', enabled: true, bound: true }, + { name: 'flow_b', enabled: false, bound: false }, + ]); + // `_status` must NOT be treated as a flow name (getFlow catch-all). + expect(mockAutomationService.getFlow).not.toHaveBeenCalledWith('_status'); + }); + it('should get a flow via GET /:name', async () => { const result = await dispatcher.handleAutomation('flow_a', 'GET', {}, { request: {} }); expect(result.handled).toBe(true); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 845956e94b..412e8b98b4 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2868,6 +2868,21 @@ export class HttpDispatcher { return { handled: true, response: this.success({ connectors: [], total: 0 }) }; } + // GET /_status → runtime enable/bound state for every flow (backs the + // Studio's Automations status badges: persisted `status` is metadata, but + // whether a flow is actually enabled + bound to its trigger is engine + // state). Underscore-prefixed so no flow name can shadow it; MUST precede + // the `/:name → getFlow` catch-all. + if (parts[0] === '_status' && parts.length === 1 && m === 'GET') { + const svc = automationService as { getFlowRuntimeStates?: () => Array<{ name: string; enabled: boolean; bound: boolean }> }; + if (typeof svc.getFlowRuntimeStates === 'function') { + const flows = svc.getFlowRuntimeStates(); + return { handled: true, response: this.success({ flows, total: flows.length }) }; + } + // Service present but older / does not implement the method. + return { handled: true, response: this.success({ flows: [], total: 0 }) }; + } + // Routes with :name if (parts.length >= 1) { const name = parts[0]; diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 48192ba8cc..f2b9adec72 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -2350,6 +2350,48 @@ describe('AutomationEngine - Flow Trigger Wiring', () => { }); }); +describe('AutomationEngine - flow status enable/disable gate', () => { + let engine: AutomationEngine; + beforeEach(() => { engine = new AutomationEngine(createTestLogger()); }); + + it('binds + enables draft/active flows — existing flows are unaffected', () => { + const rec = recordingTrigger('record_change'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('rc_draft', recordChangeFlow('rc_draft')); // status defaults to 'draft' + engine.registerFlow('rc_active', { ...recordChangeFlow('rc_active'), status: 'active' }); + const states = engine.getFlowRuntimeStates(); + expect(states.find((s) => s.name === 'rc_draft')).toEqual({ name: 'rc_draft', enabled: true, bound: true }); + expect(states.find((s) => s.name === 'rc_active')).toEqual({ name: 'rc_active', enabled: true, bound: true }); + expect(engine.getActiveTriggerBindings()).toHaveLength(2); + }); + + it('does NOT bind or enable a flow whose status is obsolete', () => { + const rec = recordingTrigger('record_change'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); + expect(engine.getActiveTriggerBindings()).toHaveLength(0); + expect(engine.getFlowRuntimeStates().find((s) => s.name === 'rc_obsolete')) + .toEqual({ name: 'rc_obsolete', enabled: false, bound: false }); + }); + + it('refuses to execute a disabled (obsolete) flow', async () => { + engine.registerFlow('x', { ...recordChangeFlow('x'), status: 'obsolete' }); + const res = await engine.execute('x', { event: 'test' } as never); + expect(res.success).toBe(false); + expect(res.error).toContain('disabled'); + }); + + it('flipping status obsolete → active re-enables + re-binds on re-register', () => { + const rec = recordingTrigger('record_change'); + engine.registerTrigger(rec.trigger); + engine.registerFlow('flip', { ...recordChangeFlow('flip'), status: 'obsolete' }); + expect(engine.getFlowRuntimeStates().find((s) => s.name === 'flip')).toMatchObject({ enabled: false, bound: false }); + // Re-author to active and re-register (what a publish rebind does). + engine.registerFlow('flip', { ...recordChangeFlow('flip'), status: 'active' }); + expect(engine.getFlowRuntimeStates().find((s) => s.name === 'flip')).toMatchObject({ enabled: true, bound: true }); + }); +}); + describe('AutomationEngine - Start Condition Gate', () => { let engine: AutomationEngine; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index cd123f0fec..5d7e52eaa6 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -404,6 +404,10 @@ export class AutomationEngine implements IAutomationService { private flows = new Map(); private flowEnabled = new Map(); + /** Flows the persisted deployment `status` currently marks disabled + * (`obsolete`/`invalid`), tracked so a status flip back to active/draft + * re-enables on the next (re)register even if the flow had been turned off. */ + private flowStatusDisabled = new Map(); private flowVersionHistory = new Map>(); private nodeExecutors = new Map(); private actionDescriptors = new Map(); @@ -872,7 +876,20 @@ export class AutomationEngine implements IAutomationService { this.flowVersionHistory.set(name, history); this.flows.set(name, parsed); - if (!this.flowEnabled.has(name)) { + // Enable/disable from the persisted deployment `status`. `obsolete`/`invalid` + // flows are DISABLED (unbound + guarded in execute); `draft`/`active` — and + // any legacy flow with no explicit status — stay enabled, so existing flows + // are unaffected (zero regression). This is how the Studio's on/off switch + // persists: it flips `status` active↔obsolete, applied on the next publish + // rebind. A flip back OUT of a disabled status re-enables even if turned off; + // a runtime toggleFlow() override on a still-enabled flow is preserved. + const flowStatus = (parsed as { status?: string }).status; + const disabledByStatus = flowStatus === 'obsolete' || flowStatus === 'invalid'; + const wasStatusDisabled = this.flowStatusDisabled.get(name) === true; + this.flowStatusDisabled.set(name, disabledByStatus); + if (disabledByStatus) { + this.flowEnabled.set(name, false); + } else if (wasStatusDisabled || !this.flowEnabled.has(name)) { this.flowEnabled.set(name, true); } this.logger.info(`Flow registered: ${name} (version ${parsed.version})`); @@ -888,10 +905,27 @@ export class AutomationEngine implements IAutomationService { this.deactivateFlowTrigger(name); this.flows.delete(name); this.flowEnabled.delete(name); + this.flowStatusDisabled.delete(name); this.flowVersionHistory.delete(name); this.logger.info(`Flow unregistered: ${name}`); } + /** + * Runtime enable/bound state for every registered flow — the truth behind the + * Studio's status badges. The persisted `status` is metadata; whether a flow + * is actually **enabled** (allowed to run) and **bound** (wired to its trigger, + * so it fires) is engine state. `enabled: false` ⇒ status is obsolete/invalid + * or a runtime toggle turned it off; `bound: false` on an enabled flow ⇒ it has + * no trigger (e.g. a manually-invoked/screen flow). + */ + getFlowRuntimeStates(): Array<{ name: string; enabled: boolean; bound: boolean }> { + return [...this.flows.keys()].map((name) => ({ + name, + enabled: this.flowEnabled.get(name) !== false, + bound: this.boundFlowTriggers.has(name), + })); + } + async listFlows(): Promise { return [...this.flows.keys()]; }