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
16 changes: 16 additions & 0 deletions .changeset/18235-flow-runtime-state-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/types": patch
---

`FlowRuntimeState` now declares `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can tell a policy-disabled flow apart from a broken binding (#18235).

Ruling G item 6 on #17396 names three surfaces that must each carry a DISTINCT reason for a flow left unarmed because package-authored scheduled work is switched off, and must never read as "binding failed". Two of them shipped: `getTriggerBindingAudit()` and the CLI startup summary. The third — a console — could not be built: Studio's only status door answers `FlowRuntimeState` rows, and that shape had no field a reason could travel in, so on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'`, byte-identical to one whose trigger is missing.

**Clause-②: yes (widening)** — one new key on an already-published payload, so the shape a consumer reads against grows. Nothing previously emitted is removed or renamed, and no producer is required to write it.

- **Optional, and additive by measurement.** Every producer of these rows — the engine, and the test doubles in `packages/runtime`, `packages/cli` and `packages/qa/dogfood` — writes `{ name, enabled, bound }` at minimum; a required key would have broken all of them and would demand a reason from rows that have none. The key is absent (not `undefined`-valued) on any row that is bound, disabled, or declares no trigger.
- **One vocabulary, not a new one.** The sentence is the one `getTriggerBindingAudit()` already answers for the same flow: both doors now read a single private `describeUnboundReason()` on the engine, so Studio and the boot summary cannot drift. A free-form string, matching the two surfaces that already carry this reason; ⛔ consumers render it, they do not parse it.
- **Read from the RECORD, never re-derived.** The policy sentence comes from the engine's recorded refusal (`policyDisabledFlows`, cleared the moment a flow gets past the gate), never from a live `resolveScheduledWorkPolicy()` read at call time. `_status` is served on demand, arbitrarily long after the bind — re-deriving would report a binding failure for a trigger that was never called, the defect the implementing round of #17396 already caught once.
- **Wire, not rendering.** `SCHEDULED_WORK_DISABLED_REASON`'s docblock is corrected: Studio's door now carries the reason, while displaying it distinctly remains objectui#9217's card. Declared is not delivered, and reaching the wire is not being shown. The published prose carrying the same claim moves with it — `content/docs/automation/flows.mdx`'s callout said the status door "has no field to say why", which this change makes false; both carriers are corrected in one landing, and neither now claims a console *renders* it.
11 changes: 7 additions & 4 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2114,10 +2114,13 @@ policy** — a distinct reason, never "binding failed". Nothing about the flow
needs fixing; the deployment has not asked for it. `os doctor` prints the
effective value.

Studio does **not** carry that reason today: `GET /automation/_status` reports
each flow as `bound` or not and has no field to say why, so a policy-disabled
flow is indistinguishable there from one whose trigger is missing. Read the
startup summary or the audit for the reason.
The status door carries that reason too: `GET /automation/_status` answers one
row per flow, and an unarmed flow's row holds the same sentence in its optional
`reason` field — read from what the engine RECORDED when it refused the bind,
never re-derived later, so a switch flipped since cannot turn it into a binding
failure. ⛔ Reaching the wire is not the same as being **rendered**: whether a
console shows it as a distinct state rather than as an error is that console's
own change. The startup summary and the audit carry the same sentence.

⛔ Platform-internal scheduled work — approvals escalation, the lifecycle
Reaper, the messaging dispatch loop, membership backfill — is **not** gated by
Expand Down
28 changes: 28 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,34 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
status: 'active', triggerType: 'on_create', object: 'sales_lead',
});
});

/**
* [#18235] Ruled item 6's third surface is this door. The engine records WHY
* a flow is unarmed; this route is the only way that sentence reaches a
* console, and it reaches it by passing the row through — so the pin is that
* `reason` is NOT dropped on the way out, beside a row that carries none.
*/
it('/automation/_status carries the unbound reason, and omits it where there is none', async () => {
const automation = {
listFlows: vi.fn(),
getFlow: vi.fn(),
getFlowRuntimeStates: vi.fn().mockReturnValue([
{
name: 'daily_digest', enabled: true, bound: false, status: 'active', triggerType: 'schedule',
reason: 'disabled by deployment policy — package-authored scheduled work is off on this deployment',
},
{ name: 'nurture', enabled: true, bound: true, status: 'active', triggerType: 'on_create', object: 'sales_lead' },
]),
};
const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation/_status', undefined, {}, {} as any);
expect(result.response?.status).toBe(200);
const flows = result.response?.body?.data?.flows as Array<Record<string, unknown>>;
expect(flows?.[0]?.reason, 'the policy sentence must survive the door').toMatch(/deployment policy/);
expect(String(flows?.[0]?.reason)).not.toMatch(/binding failed/);
// ⭐ DARK control: the healthy row is unchanged — no key appears on a
// row the producer did not put one on.
expect(Object.keys(flows?.[1] ?? {})).not.toContain('reason');
});
});

// ---------------------------------------------------------------------------
Expand Down
114 changes: 114 additions & 0 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3497,4 +3497,118 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
engine.registerFlow('b', scheduleFlow('b'));
expect(rec.started.map((s) => s.flowName)).toEqual(['b']);
});

// ─── the THIRD surface: the status door Studio reads (#18235) ──
//
// Ruled item 6 names three surfaces. Two of them — the audit above and the
// CLI startup summary that prints it — can ask the engine a second
// question. Studio cannot: its only status door is `GET /automation/_status`,
// which passes `getFlowRuntimeStates()` rows through verbatim. Until the row
// carried a reason, a policy-disabled flow reached that door as
// `enabled: true, bound: false` — byte-identical to one whose trigger is
// missing, which is the reading ruled item 6 forbids.
it('OFF: the status row names the policy, and ⛔ NOT a binding failure', () => {
delete process.env[SCHEDULED_WORK_ENV];
const engine = new AutomationEngine(createTestLogger());
engine.registerTrigger(recordingTrigger('schedule').trigger);
engine.registerFlow('digest', scheduleFlow('digest'));

const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
expect(row, 'control: the flow is listed at all').toBeTruthy();
expect(row).toMatchObject({ enabled: true, bound: false, triggerType: 'schedule' });
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
expect(row?.reason).not.toMatch(/binding failed/);
expect(row?.reason).toContain(SCHEDULED_WORK_ENV);
});

it('the two doors answer the SAME sentence for the same flow — one computation, no drift', () => {
// ⭐ ANTI-DRIFT PIN. The whole point of the third surface is that it
// agrees with the first: an operator reading Studio and an operator
// reading the boot summary must be told the same thing. Pinned as an
// identity rather than as two copies of the expected text, so a future
// edit to either door's wording fails here instead of silently forking
// the vocabulary ruled item 6 requires to be one.
delete process.env[SCHEDULED_WORK_ENV];
const engine = new AutomationEngine(createTestLogger());
engine.registerTrigger(recordingTrigger('schedule').trigger);
engine.registerFlow('digest', scheduleFlow('digest'));
engine.registerFlow('missing_trigger', {
...scheduleFlow('missing_trigger'),
type: 'autolaunched' as const,
nodes: [
{ id: 'start', type: 'start' as const, label: 'On Update', config: { objectName: 'task', triggerType: 'record-after-update' } },
{ id: 'end', type: 'end' as const, label: 'End' },
],
});

const audit = engine.getTriggerBindingAudit();
const states = engine.getFlowRuntimeStates();
expect(audit.map((a) => a.flowName).sort(), 'control: both reasons are exercised').toEqual(['digest', 'missing_trigger']);
for (const entry of audit) {
expect(
states.find((s) => s.name === entry.flowName)?.reason,
`the status door and the binding audit disagree about '${entry.flowName}'`,
).toBe(entry.reason);
}
// And they really are two different sentences — an identity between two
// constants would pass vacuously.
expect(new Set(audit.map((a) => a.reason)).size).toBe(2);
});

it('the status row reports what HAPPENED, not what the environment says when it is read', () => {
// ⭐ REGRESSION PIN, the status door's half. `_status` is served on
// demand, arbitrarily long after the bind — a strictly worse case than
// the audit's two boot-time callers. Re-deriving the reason from a live
// `resolveScheduledWorkPolicy()` here would make an operator who has
// just set the switch (and not yet restarted) see *binding failed* for
// a trigger that was never called.
delete process.env[SCHEDULED_WORK_ENV];
const engine = new AutomationEngine(createTestLogger());
const rec = recordingTrigger('schedule');
engine.registerTrigger(rec.trigger);
engine.registerFlow('digest', scheduleFlow('digest'));
expect(rec.started, 'control: the flow really was refused by policy').toHaveLength(0);

process.env[SCHEDULED_WORK_ENV] = 'true';

expect(engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.reason)
.toBe(SCHEDULED_WORK_DISABLED_REASON);
});

it('ON: a genuine bind failure reads as one on the status row too', () => {
process.env[SCHEDULED_WORK_ENV] = 'true';
const engine = new AutomationEngine(createTestLogger());
engine.registerTrigger({
type: 'schedule',
start() { throw new Error('the job service refused'); },
stop() {},
});
engine.registerFlow('digest', scheduleFlow('digest'));

const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
expect(row?.reason).toMatch(/binding failed/);
expect(row?.reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON);
});

it('⭐ DARK: a row with nothing to explain carries NO reason key at all', () => {
// The five members that were there before this card behave exactly as
// they did, and `reason` is absent — not `undefined`, not an empty
// string — on every row that is bound or disabled. A consumer that
// styles on `reason` must not light up for a healthy flow.
process.env[SCHEDULED_WORK_ENV] = 'true';
const engine = new AutomationEngine(createTestLogger());
engine.registerTrigger(recordingTrigger('schedule').trigger);
engine.registerTrigger(recordingTrigger('record_change').trigger);
engine.registerFlow('digest', scheduleFlow('digest')); // bound
engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); // disabled

const states = engine.getFlowRuntimeStates();
const bound = states.find((s) => s.name === 'digest');
const disabled = states.find((s) => s.name === 'rc_obsolete');
expect(bound).toMatchObject({ enabled: true, bound: true });
expect(disabled).toMatchObject({ enabled: false, bound: false });
expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason');
expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason');
expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0);
});
});
Loading
Loading