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
31 changes: 31 additions & 0 deletions .changeset/flow-enable-disable-status.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
42 changes: 42 additions & 0 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
36 changes: 35 additions & 1 deletion packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ export class AutomationEngine implements IAutomationService {

private flows = new Map<string, FlowParsed>();
private flowEnabled = new Map<string, boolean>();
/** 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<string, boolean>();
private flowVersionHistory = new Map<string, Array<{ version: number; definition: FlowParsed; createdAt: string }>>();
private nodeExecutors = new Map<string, NodeExecutor>();
private actionDescriptors = new Map<string, ActionDescriptor>();
Expand Down Expand Up @@ -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})`);
Expand All @@ -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<string[]> {
return [...this.flows.keys()];
}
Expand Down