Skip to content

Commit 99fcb4a

Browse files
os-billclaude
andauthored
feat(spec,service-automation): FlowRuntimeState carries WHY a flow is not armed (#18635)
Fixes #18235 Clause-②: yes `FlowRuntimeState` gains `reason` — the optional sentence saying WHY a flow is not armed — and the automation engine populates it, so `GET /automation/_status` can finally tell a policy-disabled flow apart from a broken binding. ## What was missing Ruling G item 6 on #17396, verbatim: > **OFF**: neither trigger arms any flow. Every such flow is listed in `getTriggerBindingAudit()`, the CLI startup summary and Studio with a DISTINCT reason — disabled by deployment policy — ⛔ never as "binding failed". PR #18198 delivered the first two surfaces and trimmed every published sentence that claimed the third, so nothing published was false. What was missing is that the third surface could not be BUILT: Studio's only status door is `GET /automation/_status` → `getFlowRuntimeStates()` → `FlowRuntimeState`, and that shape had no field a reason could travel in. Re-measured on `origin/main` at `7299b945a2` before writing a line: ``` packages/spec/src/contracts/automation-service.ts:499-517 interface FlowRuntimeState name / enabled / bound / status? / triggerType? / object? DARK occurrences of `reason` in that interface block 0 LIT occurrences of `bound` in the same block 2 ``` ⇒ on the wire a policy-disabled flow was `enabled: true, bound: false, triggerType: 'schedule'` — byte-identical to one whose trigger is missing, which is the reading ruled item 6 forbids. ## The three open questions, answered by measurement **1. Does the runtime producer move in this PR? YES.** A declared-but-never-populated key is the ADR-0049 shape this repo files findings about, and the honesty rule is stricter still here: `SCHEDULED_WORK_DISABLED_REASON`'s own docblock says ⛔ do not write that Studio reports this reason "until a reason reaches that wire shape: declared is not delivered". Shipping the key alone would leave that sentence exactly as false as it is today, and objectui#9217 exactly as blocked. The producer is `AutomationEngine.getFlowRuntimeStates()` (`packages/services/service-automation/src/engine.ts`), NOT `packages/runtime/src/domains/automation.ts`: that door reads the rows through the contract type and answers `success({ flows, total })` — a verbatim pass-through with no field picking, so it needed no edit, only a pin. **2. Optional or required? OPTIONAL, measured.** Producer set: the engine, plus the test doubles in `packages/runtime` (`domain-handler-registry.test.ts`, `http-dispatcher.test.ts`, `automation-run-read-permission-gate.test.ts`), `packages/cli` (`serve-automation-summary.test.ts`, `serve-automation-shadowing.test.ts`) and `packages/qa/dogfood` — every one of them writes `{ name, enabled, bound }` at minimum. `AutomationEngine implements IAutomationService`, so a required key would also have turned its inline return type red. And semantically a required key would demand a reason from rows that have none: a bound flow, a disabled flow, a manual flow. **3. Closed union or free string? FREE STRING, matching what already ships.** `getTriggerBindingAudit()` answers `{ flowName, triggerType, reason: string }` — a human sentence from a three-branch vocabulary, the policy branch being the shared `SCHEDULED_WORK_DISABLED_REASON` constant; the CLI startup summary prints that string. `status` and `triggerType` beside the new key are free `string`s too. objectui#9217's acceptance item 3 says it outright: "The exact reason-code shape is the platform's … if the platform ships a string, render the string." ⛔ No fourth vocabulary was invented. ## How the vocabulary is held to ONE Both doors now read one private `describeUnboundReason()` on the engine — same eligibility rule (enabled, unbound, declares a trigger), same three branches, same policy sentence read from the RECORDED refusal (`policyDisabledFlows`) and ⛔ never re-derived from a live `resolveScheduledWorkPolicy()` call. `_status` is served on demand, arbitrarily long after the bind — a strictly worse case for re-derivation than the audit's two boot-time callers, and re-derivation is the exact defect #18198's own round caught and fixed. Pinned as an identity between the two doors rather than as two copies of the expected text, so a future edit to either wording fails instead of forking the vocabulary. ## File surface | path | what | |:---|:---| | `packages/spec/src/contracts/automation-service.ts` | `FlowRuntimeState.reason?: string` + its docblock — the card's subject | | `packages/services/service-automation/src/engine.ts` | the producer: `describeUnboundReason()`, read by `getFlowRuntimeStates()` and `getTriggerBindingAudit()` | | `packages/types/src/env.ts` | docblock only — this PR makes `SCHEDULED_WORK_DISABLED_REASON`'s "Studio is NOT one of them" paragraph false, so it is corrected in the same landing (wire yes, rendering still objectui#9217) | | `content/docs/automation/flows.mdx` | docs prose only — its callout said the status door "has no field to say why", which this PR makes false; corrected on the same ground as `env.ts` (patch round) | | `packages/spec/src/contracts/automation-service.test.ts` | type-level identity pins + the runtime probe / lit / dark controls | | `packages/services/service-automation/src/engine.test.ts` | producer pins: policy row, anti-drift identity, record-not-environment, bind failure, DARK absence | | `packages/runtime/src/domain-handler-registry.test.ts` | the wire pin: `_status` carries `reason` through, and omits it where the producer wrote none | | `.changeset/18235-flow-runtime-state-reason.md` | `@objectstack/spec` minor, `@objectstack/service-automation` minor, `@objectstack/types` patch; `Clause-②: yes (widening)` | ⛔ No generated artifact moved — see the DARK control below. Nothing under `packages/spec/src/ui/`, `packages/spec/src/migrations/**` or `packages/spec/scripts/` is touched. ## Red before green `check:generated` is NOT a gate for this change, and that is a measurement, not an assumption: run on the clean tree it was green (15/15), and run again with the new field in place and a rebuilt `dist` — before regenerating anything — it was still green (15/15), because no spec artifact records interface MEMBERS (`api-surface/contracts.json` records `"FlowRuntimeState (interface)"`, `export-origins/contracts.json` records its origin, `declaration-map/` has no `contracts.json` at all, and `api-surface-signatures.json` has zero `FlowRuntimeState` hits). A gate never observed failing is not known to be a gate — so the gate that WAS observed failing is `check:test-typecheck` (CI's required `TypeScript Type Check` job), plus the producer pins: **LEG 1 — the producer stops publishing the key.** Mutation written to disk and proved there (anchor occurrences 1 → 0, blob `ec75d9cf` → `29716a38`): ``` Tests 4 failed | 154 passed (158) × OFF: the status row names the policy, and ⛔ NOT a binding failure × the two doors answer the SAME sentence for the same flow — one computation, no drift × the status row reports what HAPPENED, not what the environment says when it is read × ON: a genuine bind failure reads as one on the status row too ``` ⭐ LIT control inside the same run: the 154 that still pass include every pre-existing ruling-G audit pin — the sibling door is untouched — and the DARK absence test passes in the mutated tree too, which is what a well-formed absence assertion must do. **LEG 2 — the contract stops declaring the key** (anchor 1 → 0, blob `8d9231d4` → `0f89941c`): ``` check:test-typecheck: 2 problem(s) • src/contracts/automation-service.test.ts: 4 type error(s) … ARRIVED: TS2339: Property 'reason' does not exist on type 'FlowRuntimeState'. • src/contracts/automation-service.test.ts: 1 type error(s) … ARRIVED: TS2353: Object literal may only specify known properties, and 'reason' does not exist in type 'FlowRuntimeState'. LEG2 TYPECHECK EXIT=1 ``` ⚠️ Reported as observed, not as the template predicts: in LEG 2 the spec UNIT suite stayed green (17/17). The docblock pin reads the interface's comment, which the mutation left in place; it is `check:test-typecheck` that covers the key's existence. Both legs restored from `HEAD` under a `trap`, and the restore is proved by blob identity (`ec75d9cf` / `8d9231d4` both back) plus an empty `git diff HEAD`, not by an exit code. ## Evidence - `pnpm --filter @objectstack/spec check:generated` — **15/15 up to date**, clean tree and again at the final tree. - `check:api-surface`, `check:docs`, `check:authorable-surface`, `check:export-origins`, `check:declaration-map`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:liveness`, `check:empty-state` — all green. ⚠️ Five of them first answered PREREQUISITE NOT MET (`packages/spec/dist` older than `src`), which is NOT MEASURED rather than red; re-run after `pnpm --filter @objectstack/spec build`, all five exit 0. - Typecheck: `@objectstack/spec`, `@objectstack/service-automation`, `@objectstack/types`, `@objectstack/runtime` — all 0. - Tests: spec **484 files / 13821 passed**; service-automation **136 / 1639**; runtime **264 / 3655**; types **22 / 683**. - `node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF` — exit 0. - Gate reconciliation: `node scripts/pm/dispatch-gates.mjs --ran` — **83 derived, 81 run, 2 NOT MEASURED**. The two are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 (PREREQUISITE NOT MET: they need a whole-workspace build closure) — declared to CI, not claimed as passes. - `pnpm check:nul-bytes` green, plus a direct control-character sweep of all seven touched files: zero hits. ## ⭐ DARK control - No OTHER interface in `contracts/**` gained a member: the whole diff under `packages/spec/src/contracts/` is one `reason?: string;` line plus its docblock, inside the `FlowRuntimeState` block. - No published payload outside `FlowRuntimeState` moved: every generated artifact is byte-unchanged (`git status` after the field + rebuild showed only source files), and `check:generated` agrees at 15/15. - On the wire, a row the producer left without a reason carries no `reason` key at all — asserted on both the engine row and the `_status` response. ## Patch round — the at-tier review's one FAIL, discharged The isolated at-tier contract review returned FAIL on one residual and PASS on everything else. The residual: `content/docs/automation/flows.mdx` still carried #18198's sentence that `GET /automation/_status` "has no field to say why", so a policy-disabled flow "is indistinguishable there" — false the moment this PR adds the field, and false on exactly the ground used to correct `packages/types/src/env.ts` in the same landing. One prose carrier had been corrected and its twin missed. I agree with the FAIL on the merits; no objection recorded. Corrected the same way and no further: the reason reaches the WIRE, read from the recorded refusal, and ⛔ reaching the wire is still not being *rendered* — whether a console shows it as a distinct state is that console's own change, which this page does not claim. Own sweep, independent of the one handed to me: `no field to say why` / `indistinguishable there` hits this file only — the other hits are in `core/security`, `objectql`, `rest` and `spec`, all unrelated — against a lit control of 19 files under `content/` that mention `_status`, and `content/docs/releases/**` (the only other `automation/_status` mention) is release-owned and untouched. ⇒ one residual, not a class. No pin reads this paragraph: the four tests and scripts that name this page cite other sections, so no test is owed. Re-derived after the docs commit: 105 gate families (22 newly derived by the `content/` path), reconciled by `dispatch-gates --ran` at 105 accounted / 103 run / 2 NOT MEASURED / 0 unrun. All 22 new ones green, including `check:skill-examples` (258 prose examples type-check across 3 surfaces), `check:docs-transcript-drift`, `check:doc-security-posture`, `check:corpus-claim-drift`, `check:doc-anchors` and `check:docs-audit-scope`; three of them first answered PREREQUISITE NOT MET on an unbuilt `@objectstack/lint` / `@objectstack/client-react` and were re-run after building those closures. `docs-audit/check-affected-docs.mjs`, `check:doc-authoring`, `check:keyed-text-bounds`, `check:nul-bytes` and `check:generated` (15/15) re-run green on the new head. Tests were not re-run: the patch-round diff is one MDX paragraph plus one changeset sentence, and the only gate that compiles docs prose (`check:skill-examples`) is in the green list above. ## Acceptance notes - **noted, not filed:** `packages/cli/src/commands/serve.ts`'s startup banner re-declares the `getFlowRuntimeStates()` row shape inline and does not name `reason`; it reads the audit for its unbound section, so nothing is wrong today and the banner needs no change. Carrier: whoever next touches that banner's row type. - **to file (class (a), reproducible defect; dedupe words: `cross-package-test-inputs` · `init-created-files-summary` · `packages/spec/dist walk` · `#15565 tree-scoped walk` · `gate vacuous without dist`):** `pnpm check:cross-package-test-inputs` exits 1 on any tree where `packages/spec/dist` is BUILT — `packages/cli/test/init-created-files-summary.e2e.test.ts` descends `packages/spec/dist/` and no declared glob reaches inside it. Proven not to belong to this PR: with this branch's entire diff reverted in the working tree the gate still exits 1 with the identical finding, and on a checkout with no `packages/spec/dist` it exits 0 — i.e. it passes vacuously wherever the lint job has not built spec. Filing is the seat's act; this PR does not touch it. ⚠️ This branch is 5 commits behind `origin/main` at the time of writing; none of those commits touches any of the seven paths above (verified with `git log BASE..origin/main -- THE-SEVEN-PATHS`, empty). The merge queue rebuilds the PR as merged onto current `main` and re-runs the required set there. Authored with Claude Code in session `session_01JbZnqu8bt6YqfJsr9vaFb3` (both the delivery round and this patch round). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf39b83 commit 99fcb4a

8 files changed

Lines changed: 328 additions & 41 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/types": patch
5+
---
6+
7+
`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).
8+
9+
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.
10+
11+
**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.
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **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.

‎content/docs/automation/flows.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,10 +2114,13 @@ policy** — a distinct reason, never "binding failed". Nothing about the flow
21142114
needs fixing; the deployment has not asked for it. `os doctor` prints the
21152115
effective value.
21162116

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

21222125
⛔ Platform-internal scheduled work — approvals escalation, the lifecycle
21232126
Reaper, the messaging dispatch loop, membership backfill — is **not** gated by

‎packages/runtime/src/domain-handler-registry.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,34 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
734734
status: 'active', triggerType: 'on_create', object: 'sales_lead',
735735
});
736736
});
737+
738+
/**
739+
* [#18235] Ruled item 6's third surface is this door. The engine records WHY
740+
* a flow is unarmed; this route is the only way that sentence reaches a
741+
* console, and it reaches it by passing the row through — so the pin is that
742+
* `reason` is NOT dropped on the way out, beside a row that carries none.
743+
*/
744+
it('/automation/_status carries the unbound reason, and omits it where there is none', async () => {
745+
const automation = {
746+
listFlows: vi.fn(),
747+
getFlow: vi.fn(),
748+
getFlowRuntimeStates: vi.fn().mockReturnValue([
749+
{
750+
name: 'daily_digest', enabled: true, bound: false, status: 'active', triggerType: 'schedule',
751+
reason: 'disabled by deployment policy — package-authored scheduled work is off on this deployment',
752+
},
753+
{ name: 'nurture', enabled: true, bound: true, status: 'active', triggerType: 'on_create', object: 'sales_lead' },
754+
]),
755+
};
756+
const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation/_status', undefined, {}, {} as any);
757+
expect(result.response?.status).toBe(200);
758+
const flows = result.response?.body?.data?.flows as Array<Record<string, unknown>>;
759+
expect(flows?.[0]?.reason, 'the policy sentence must survive the door').toMatch(/deployment policy/);
760+
expect(String(flows?.[0]?.reason)).not.toMatch(/binding failed/);
761+
// ⭐ DARK control: the healthy row is unchanged — no key appears on a
762+
// row the producer did not put one on.
763+
expect(Object.keys(flows?.[1] ?? {})).not.toContain('reason');
764+
});
737765
});
738766

739767
// ---------------------------------------------------------------------------

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3497,4 +3497,118 @@ describe('AutomationEngine - the deployment switch (#17396)', () => {
34973497
engine.registerFlow('b', scheduleFlow('b'));
34983498
expect(rec.started.map((s) => s.flowName)).toEqual(['b']);
34993499
});
3500+
3501+
// ─── the THIRD surface: the status door Studio reads (#18235) ──
3502+
//
3503+
// Ruled item 6 names three surfaces. Two of them — the audit above and the
3504+
// CLI startup summary that prints it — can ask the engine a second
3505+
// question. Studio cannot: its only status door is `GET /automation/_status`,
3506+
// which passes `getFlowRuntimeStates()` rows through verbatim. Until the row
3507+
// carried a reason, a policy-disabled flow reached that door as
3508+
// `enabled: true, bound: false` — byte-identical to one whose trigger is
3509+
// missing, which is the reading ruled item 6 forbids.
3510+
it('OFF: the status row names the policy, and ⛔ NOT a binding failure', () => {
3511+
delete process.env[SCHEDULED_WORK_ENV];
3512+
const engine = new AutomationEngine(createTestLogger());
3513+
engine.registerTrigger(recordingTrigger('schedule').trigger);
3514+
engine.registerFlow('digest', scheduleFlow('digest'));
3515+
3516+
const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
3517+
expect(row, 'control: the flow is listed at all').toBeTruthy();
3518+
expect(row).toMatchObject({ enabled: true, bound: false, triggerType: 'schedule' });
3519+
expect(row?.reason).toBe(SCHEDULED_WORK_DISABLED_REASON);
3520+
expect(row?.reason).not.toMatch(/binding failed/);
3521+
expect(row?.reason).toContain(SCHEDULED_WORK_ENV);
3522+
});
3523+
3524+
it('the two doors answer the SAME sentence for the same flow — one computation, no drift', () => {
3525+
// ⭐ ANTI-DRIFT PIN. The whole point of the third surface is that it
3526+
// agrees with the first: an operator reading Studio and an operator
3527+
// reading the boot summary must be told the same thing. Pinned as an
3528+
// identity rather than as two copies of the expected text, so a future
3529+
// edit to either door's wording fails here instead of silently forking
3530+
// the vocabulary ruled item 6 requires to be one.
3531+
delete process.env[SCHEDULED_WORK_ENV];
3532+
const engine = new AutomationEngine(createTestLogger());
3533+
engine.registerTrigger(recordingTrigger('schedule').trigger);
3534+
engine.registerFlow('digest', scheduleFlow('digest'));
3535+
engine.registerFlow('missing_trigger', {
3536+
...scheduleFlow('missing_trigger'),
3537+
type: 'autolaunched' as const,
3538+
nodes: [
3539+
{ id: 'start', type: 'start' as const, label: 'On Update', config: { objectName: 'task', triggerType: 'record-after-update' } },
3540+
{ id: 'end', type: 'end' as const, label: 'End' },
3541+
],
3542+
});
3543+
3544+
const audit = engine.getTriggerBindingAudit();
3545+
const states = engine.getFlowRuntimeStates();
3546+
expect(audit.map((a) => a.flowName).sort(), 'control: both reasons are exercised').toEqual(['digest', 'missing_trigger']);
3547+
for (const entry of audit) {
3548+
expect(
3549+
states.find((s) => s.name === entry.flowName)?.reason,
3550+
`the status door and the binding audit disagree about '${entry.flowName}'`,
3551+
).toBe(entry.reason);
3552+
}
3553+
// And they really are two different sentences — an identity between two
3554+
// constants would pass vacuously.
3555+
expect(new Set(audit.map((a) => a.reason)).size).toBe(2);
3556+
});
3557+
3558+
it('the status row reports what HAPPENED, not what the environment says when it is read', () => {
3559+
// ⭐ REGRESSION PIN, the status door's half. `_status` is served on
3560+
// demand, arbitrarily long after the bind — a strictly worse case than
3561+
// the audit's two boot-time callers. Re-deriving the reason from a live
3562+
// `resolveScheduledWorkPolicy()` here would make an operator who has
3563+
// just set the switch (and not yet restarted) see *binding failed* for
3564+
// a trigger that was never called.
3565+
delete process.env[SCHEDULED_WORK_ENV];
3566+
const engine = new AutomationEngine(createTestLogger());
3567+
const rec = recordingTrigger('schedule');
3568+
engine.registerTrigger(rec.trigger);
3569+
engine.registerFlow('digest', scheduleFlow('digest'));
3570+
expect(rec.started, 'control: the flow really was refused by policy').toHaveLength(0);
3571+
3572+
process.env[SCHEDULED_WORK_ENV] = 'true';
3573+
3574+
expect(engine.getFlowRuntimeStates().find((s) => s.name === 'digest')?.reason)
3575+
.toBe(SCHEDULED_WORK_DISABLED_REASON);
3576+
});
3577+
3578+
it('ON: a genuine bind failure reads as one on the status row too', () => {
3579+
process.env[SCHEDULED_WORK_ENV] = 'true';
3580+
const engine = new AutomationEngine(createTestLogger());
3581+
engine.registerTrigger({
3582+
type: 'schedule',
3583+
start() { throw new Error('the job service refused'); },
3584+
stop() {},
3585+
});
3586+
engine.registerFlow('digest', scheduleFlow('digest'));
3587+
3588+
const row = engine.getFlowRuntimeStates().find((s) => s.name === 'digest');
3589+
expect(row?.reason).toMatch(/binding failed/);
3590+
expect(row?.reason).not.toBe(SCHEDULED_WORK_DISABLED_REASON);
3591+
});
3592+
3593+
it('⭐ DARK: a row with nothing to explain carries NO reason key at all', () => {
3594+
// The five members that were there before this card behave exactly as
3595+
// they did, and `reason` is absent — not `undefined`, not an empty
3596+
// string — on every row that is bound or disabled. A consumer that
3597+
// styles on `reason` must not light up for a healthy flow.
3598+
process.env[SCHEDULED_WORK_ENV] = 'true';
3599+
const engine = new AutomationEngine(createTestLogger());
3600+
engine.registerTrigger(recordingTrigger('schedule').trigger);
3601+
engine.registerTrigger(recordingTrigger('record_change').trigger);
3602+
engine.registerFlow('digest', scheduleFlow('digest')); // bound
3603+
engine.registerFlow('rc_obsolete', { ...recordChangeFlow('rc_obsolete'), status: 'obsolete' }); // disabled
3604+
3605+
const states = engine.getFlowRuntimeStates();
3606+
const bound = states.find((s) => s.name === 'digest');
3607+
const disabled = states.find((s) => s.name === 'rc_obsolete');
3608+
expect(bound).toMatchObject({ enabled: true, bound: true });
3609+
expect(disabled).toMatchObject({ enabled: false, bound: false });
3610+
expect(Object.keys(bound ?? {}), 'a bound flow explains nothing').not.toContain('reason');
3611+
expect(Object.keys(disabled ?? {}), '`enabled: false` already says it').not.toContain('reason');
3612+
expect(engine.getTriggerBindingAudit(), 'control: the sibling door is empty for the same reason').toHaveLength(0);
3613+
});
35003614
});

0 commit comments

Comments
 (0)