Skip to content

Commit 6b3ba82

Browse files
fix(showcase): escalation flow routes a project with no budget or no owner (#29018)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XUDegLcaGYWsX6oKvpcNQWK)_ Fixes #28951 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:showcase 的项目升级流程(`showcase_project_escalation`)在项目没有预算或没有负责人时也能跑完。预算判断先确认有值再比较,"普通"分支改成决策的默认出口;没有负责人时,普通通知和收尾通知改发给组织所有者(`role:owner`),紧急分支里的负责人提醒放在自己的判断后面,没负责人就跳过(旁边的高管分支已经提醒了所有者)。 - 为什么改:项目一变红,没预算的项目在决策节点就报 CEL `null > int` 失败,没负责人的项目在通知节点失败,谁都收不到提醒。这是新用户照抄的参考应用,教的恰是错误写法。 - 风险与回滚:只动示例应用(私有包,不发布),不碰引擎和 CEL。回滚即 revert。 - 先看:`examples/app-showcase/src/automation/flows/index.ts` 的 e2/e3 两条边和 Owner 分支;新测试 `examples/app-showcase/test/project-escalation-optional-fields.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/example-showcase exec vitest run test/project-escalation-optional-fields.test.ts`,预期 `Tests 7 passed (7)`。 - 需要你做的:无,席位落地。 ## What changes - **`examples/app-showcase/src/automation/flows/index.ts`, `ProjectEscalationFlow`**: - e2 (`critical`) is `record.budget != null && record.budget > 200000`. e3 (`normal`) is the `triage` decision's `isDefault: true` edge, the "otherwise", so a project with no budget routes as the normal path. The flow's own wording ("Critical budget?") reads a project with no budget as not critical. - The critical branch's `Owner` region starts with a `has_owner` decision whose one edge (`!isBlank(record.owner)`) leads to `alert_owner`. A project with no owner skips that alert; the `Exec` branch beside it already alerts `role:owner`, so a fallback there would alert the exec twice. - `notify_normal` and `converge` send to `isBlank(record.owner) ? 'role:owner' : record.owner`, so the escalation reaches the organization's owners when the project has none. The notify node's required-recipient refusal is unchanged. - The docblock says why `has(record.budget)` guards nothing here. - **New `examples/app-showcase/test/project-escalation-optional-fields.test.ts`** (7 cases). ## Why The run's `record` is total over the object's declared fields, so a project with no budget carries `budget: null`. CEL has no `null > int`, so the run failed at `triage`. `has(record.owner) ? record.owner : null` answered `null` for a project with no owner, because `has()` is true on a null column, and the notify refused a node with no recipient. Either way nobody was alerted, and the reference flow taught the unguarded comparison. Reading on the order's spelling: the order named `has(record.budget) && record.budget > 200000`. Measured against the formula engine on `{ budget: null }`, that still faults (`no such overload: dyn‹null› > int`), because `has()` is true on a null key; the card's own log (`dyn‹null›`) shows the trigger record carries the key. So the guard is `!= null`, which is the spelling `skills/objectstack-formula` teaches (`has()` is NOT a null check), the spelling `showcase_project`'s own validation rules use (`record.budget != null && …`), and the one the `validate-null-guards` gate accepts as dominating. The owner guard is `isBlank()`, so a cleared owner (`''`) routes the same as `null`. ## Tests - New file, on the production harness for everything the defect touches: a real `ObjectKernel` with ObjectQL over sqlite and the automation plugin, the app's real `showcase_project` object and real flow. Each case writes a project, turns it red through the data engine, and runs the flow on the row as stored (`record`, plus `previous` before the update), the way the record-change trigger hands it. Each case first asserts the stored row carries every optional field it left out as `null`. Messaging is a recorder of the audience each notify hands over; `fetch` is stubbed for the incident push. - no budget, with owner → completes, `notify_normal` success, `alert` skipped, both notifies to the owner; - budget 1000, no owner → completes, both notifies to `role:owner`; - budget 1000, blank owner (`''`) → completes, both to `role:owner`; - neither → completes, both to `role:owner`; - control: budget 1000 with owner → e3, `notify_normal` success; - budget 500000, no owner → completes, `alert_owner` skipped, `alert_exec` success, one critical alert, to `role:owner`; - control: budget 500000 with owner → owner and exec alerted. - **Red on base** (flows file at `origin/main` `135054eb`, test kept): 4 failed / 2 passed (blank-owner case added after this run). Failures: `condition failed to evaluate as CEL: no such overload: dyn‹null› > int` (no budget; neither), `Node 'notify_normal' failed: notify: at least one recipient is required …` (no owner), `Node 'alert' failed: parallel 'alert': branch failed — Node 'alert_owner' failed: notify: …` (critical, no owner). Both controls green. - **Ablation 1** (`scripts/ablation-replace.mjs`, anchor hit once, blob `9272a372` to `9aa0d008`): e2 back to `record.budget > 200000`. 2 failed / 5 passed (no budget; neither). Restored: blob equal to HEAD (`9272a372`), `git diff HEAD` empty. - **Ablation 2** (blob `9272a372` to `900018be`): the `has_owner` edge condition replaced by `true`. 1 failed / 6 passed (critical, no owner). Restored the same way. - No `dist/` sits on the path: the test imports the flow from `src`. ## Acceptance notes - **Live REST, base vs head** (`pnpm dev -- --fresh` in `examples/app-showcase`, the card's steps: create a project as admin, `PATCH /api/v1/data/showcase_project/ID {"health":"red"}` → 200, three projects: no budget, no owner, control): - base (flows file at `135054eb`): two `ERROR Trigger-fired run of flow 'showcase_project_escalation' failed (trigger 'record_change')` lines, `{"error":"condition failed to evaluate as CEL: no such overload: dyn‹null› > int … source: budget > 200000 …"}` and `{"error":"Node 'notify_normal' failed: notify: at least one recipient is required … has(record.owner) ? record.owner : null …"}`. `GET /api/v1/automation/showcase_project_escalation/runs`: `completed`, `failed`, `failed`. - head: no such line. Runs: three `completed`, each `start, triage success → alert skipped → notify_normal success → converge success`. - **Same-flow sweep (order ruling 3).** Ordering comparisons in `src/automation/**` conditions: this flow's e2 (fixed), and three outside it, not changed here: - `showcase_budget_approval` start condition `budget > 100000 && budget != previous.budget`: a project with no budget faults it. In the live base run above it logged nothing at the default level and the flow did not fire, which is the right outcome for a project with no budget. Its e4/e5 (`budget > 500000` / `<= 500000`) are reached only after that start condition held, so `budget` is set there. - `showcase_committee_quorum` start condition `… && total_amount >= 5000`: `total_amount` is a sum summary; whether it reads `null` on a report with no lines is not measured. - The same `has(FIELD) ? FIELD : null` guard is the recipient of ten other notify slots in this file, on other flows (`assignee`, `owner`, `task.owner`, `task.watcher`). It answers `null` on a null column the same way, which leaves that notify with no recipient; whether each is reachable with the field empty is not measured. (Four more slots write the guarded value into a field, where `null` is a valid answer.) - **Platform first (triage's question).** No lint rule warns about a decision with no otherwise edge (the `flow-decision-*` rules are `inclusive-overlap`, `mode-invalid`, `unconditional-branch`). The publish-time null-guard gate (`packages/lint/src/validate-null-guards.ts`) would have refused this comparison, but its surface ledger still lists flow / edge `condition` as excluded (the totality blocker is gone since #4953; the widening was tracked on #4811, now closed). Reported to the seat as a finding; no engine or lint change here, per the order. ## Verification Run on `39cd0102`, which merges `origin/main` at `00d0668d` (branch base `135054eb`); every run below is on that tree. `origin/main` has since moved by 7 commits, none under `examples/app-showcase/`. Each test command under `os-verify-lock` (`VERDICT command-exit 0`). - `pnpm --filter @objectstack/example-showcase test`: Test Files 43 passed (43); Tests 518 passed (518). - `pnpm --filter @objectstack/example-showcase typecheck`: exit 0. - `pnpm --filter @objectstack/example-showcase validate`: exit 0; no row names `showcase_project_escalation`. - `eslint --no-inline-config` on the two touched files: exit 0. - Derived gates (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 40 families, each exit captured before any pipe), reconciled: `✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 39 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. NOT MEASURED: `check:dual-build-cjs-loads`, reason: it requires every published package's `dist/`. It first read 3 (seven packages unbuilt in this worktree); after building them it read 1 with 82 `Cannot find module '../chunk-….js'` findings across packages this diff does not touch (overlapping local rebuilds left stale chunk references). This diff changes no published package, so CI's fresh build is the reading. - Changeset: none. `@objectstack/example-showcase` is `"private": true`, so nothing here ships; `skip-changeset` rides the create. ## Human Minimum None; the seat lands it. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kZjdnPFmYVCV7PUQU)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 474b40e commit 6b3ba82

2 files changed

Lines changed: 257 additions & 6 deletions

File tree

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,22 @@ export const InvoiceDualSignoffFlow = defineFlow({
14191419
* branch, converging edges) that single-construct flows don't — and runs
14201420
* synchronously (no pause), so it completes in one pass and is fully visible in
14211421
* the Runs panel with nested step folding.
1422+
*
1423+
* `budget` and `owner` are both optional on `showcase_project`, and the run's
1424+
* `record` carries every declared field, so a project with neither reads
1425+
* `budget: null, owner: null`. `has(record.budget)` is true on that row, so it
1426+
* guards nothing. The flow handles both fields itself:
1427+
*
1428+
* - `critical` (e2) tests `record.budget != null` before it compares, because
1429+
* CEL has no `null > int` and the run would fail at the decision. `normal`
1430+
* (e3) is the decision's `isDefault` edge, the "otherwise" that also takes
1431+
* a project with no budget.
1432+
* - The owner alert in the critical branch sits behind its own `decision`, so
1433+
* a project with no owner skips it: the Exec branch beside it already
1434+
* alerts the organization's owners. The normal-path and converge notifies
1435+
* fall back to those owners (`role:owner`), so the escalation still reaches
1436+
* someone. A notify whose every recipient evaluates to nothing fails the
1437+
* run, and is meant to.
14221438
*/
14231439
export const ProjectEscalationFlow = defineFlow({
14241440
name: 'showcase_project_escalation',
@@ -1446,8 +1462,13 @@ export const ProjectEscalationFlow = defineFlow({
14461462
branches: [
14471463
{
14481464
name: 'Owner',
1449-
nodes: [{ id: 'alert_owner', type: 'notify', label: 'Alert Owner', config: { recipients: { dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }, title: '🔴 Critical: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: { dialect: 'cel', source: 'record.id' } } }],
1450-
edges: [],
1465+
// No owner, no owner alert: the Exec branch beside this one
1466+
// already alerts the organization's owners.
1467+
nodes: [
1468+
{ id: 'has_owner', type: 'decision', label: 'Has an owner?' },
1469+
{ id: 'alert_owner', type: 'notify', label: 'Alert Owner', config: { recipients: { dialect: 'cel', source: 'record.owner' }, title: '🔴 Critical: {{ record.name }}', severity: 'critical', sourceObject: 'showcase_project', sourceId: { dialect: 'cel', source: 'record.id' } } },
1470+
],
1471+
edges: [{ id: 'owner_e1', source: 'has_owner', target: 'alert_owner', label: 'has owner', condition: '!isBlank(record.owner)' }],
14511472
},
14521473
{
14531474
name: 'Exec',
@@ -1484,20 +1505,20 @@ export const ProjectEscalationFlow = defineFlow({
14841505
id: 'notify_normal',
14851506
type: 'notify',
14861507
label: 'Notify Owner',
1487-
config: { topic: 'project.escalation', recipients: [{ dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {{ record.name }}', message: 'Health dropped to red — please review.' },
1508+
config: { topic: 'project.escalation', recipients: [{ dialect: 'cel', source: "isBlank(record.owner) ? 'role:owner' : record.owner" }], channels: ['inbox'], severity: 'info', title: 'Project needs attention: {{ record.name }}', message: 'Health dropped to red — please review.' },
14881509
},
14891510
{
14901511
id: 'converge',
14911512
type: 'notify',
14921513
label: 'Escalation Handled',
1493-
config: { topic: 'project.escalation', recipients: [{ dialect: 'cel', source: 'has(record.owner) ? record.owner : null' }], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {{ record.name }}', message: 'The red-health escalation has been processed.' },
1514+
config: { topic: 'project.escalation', recipients: [{ dialect: 'cel', source: "isBlank(record.owner) ? 'role:owner' : record.owner" }], channels: ['inbox'], severity: 'info', title: 'Escalation handled: {{ record.name }}', message: 'The red-health escalation has been processed.' },
14941515
},
14951516
{ id: 'end', type: 'end', label: 'End' },
14961517
],
14971518
edges: [
14981519
{ id: 'e1', source: 'start', target: 'triage' },
1499-
{ id: 'e2', source: 'triage', target: 'alert', label: 'critical', condition: 'budget > 200000' },
1500-
{ id: 'e3', source: 'triage', target: 'notify_normal', label: 'normal', condition: 'budget <= 200000' },
1520+
{ id: 'e2', source: 'triage', target: 'alert', label: 'critical', condition: 'record.budget != null && record.budget > 200000' },
1521+
{ id: 'e3', source: 'triage', target: 'notify_normal', label: 'normal', isDefault: true },
15011522
{ id: 'e4', source: 'alert', target: 'push_incident' },
15021523
{ id: 'e5', source: 'push_incident', target: 'converge' },
15031524
{ id: 'e6', source: 'notify_normal', target: 'converge' },
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `showcase_project_escalation` runs to the end on a project with no budget or
5+
* no owner. Both fields are optional on `showcase_project`, and nothing
6+
* defaults them.
7+
*
8+
* THE DEFECT. The `triage` decision branched on `budget > 200000` (e2) and
9+
* `budget <= 200000` (e3). A flow's trigger record is TOTAL over the object's
10+
* declared fields, so a project with no budget carries `budget: null`. CEL has
11+
* no `null > int` overload, so the run failed terminally at the decision and
12+
* notified nobody. The owner notifies guarded with
13+
* `has(record.owner) ? record.owner : null`. `has()` is true on a null column,
14+
* so the guard answered `null`, and the notify node refused a node left with no
15+
* recipient.
16+
*
17+
* THE FIX, in the reference app alone. e2 tests `record.budget != null` before
18+
* it compares. e3 is the decision's `isDefault` edge, the "otherwise" that also
19+
* takes a project with no budget. The normal-path and converge notifies fall
20+
* back to the organization's owners (`role:owner`, the exec the critical
21+
* branch already alerts) when the owner is blank (`isBlank`, so `null` and
22+
* `''` alike). The critical branch's owner alert sits behind its own
23+
* `decision`, because its sibling branch already alerts the exec.
24+
*
25+
* THE HARNESS IS THE PRODUCTION ONE for everything the defect touched: a real
26+
* kernel with ObjectQL over sqlite, the automation plugin and the app's real
27+
* `showcase_project` object and real flow. The project is written and turned
28+
* red through the data engine, and the run is handed the row as stored, the
29+
* way the record-change trigger hands it (that package is not a dependency of
30+
* this app). So a project with no budget reads `budget: null`, which each case
31+
* checks before it runs, rather than a hand-written literal missing the key.
32+
* Messaging is a recorder: what this file is about is which audience each
33+
* notify hands over, not inbox fan-out.
34+
*/
35+
36+
import { describe, it, expect, afterEach, vi } from 'vitest';
37+
import { ObjectKernel } from '@objectstack/core';
38+
import { ObjectQLPlugin } from '@objectstack/objectql';
39+
import { SqlDriver } from '@objectstack/driver-sql';
40+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
41+
42+
import { Account, Project } from '../src/data/objects/index.js';
43+
import { ProjectEscalationFlow } from '../src/automation/flows/index.js';
44+
import { installDefaultOrganization } from './default-organization.testkit.js';
45+
46+
const SYS = { context: { isSystem: true, positions: [], permissions: [] } as any };
47+
const FLOW = ProjectEscalationFlow.name;
48+
/** A user the directory holds: an inbox-only notify delivers an address only to its holder. */
49+
const OWNER_EMAIL = 'admin@objectos.ai';
50+
51+
const openKernels: Array<{ shutdown?: () => Promise<void> }> = [];
52+
const openDrivers: Array<{ disconnect?: () => Promise<void> }> = [];
53+
afterEach(async () => {
54+
vi.unstubAllGlobals();
55+
while (openKernels.length) {
56+
try { await openKernels.pop()?.shutdown?.(); } catch { /* noop */ }
57+
}
58+
while (openDrivers.length) {
59+
try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ }
60+
}
61+
});
62+
63+
interface Emitted { audience: string[]; title: string }
64+
65+
interface Booted {
66+
automation: AutomationEngine & Record<string, any>;
67+
data: any;
68+
emitted: Emitted[];
69+
accountId: string;
70+
}
71+
72+
async function boot(flow: unknown = ProjectEscalationFlow): Promise<Booted> {
73+
// The critical branch POSTs to an incident system; answer it here so the
74+
// try/catch takes its success path without leaving the process.
75+
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 201, headers: { 'content-type': 'application/json' } })));
76+
77+
const emitted: Emitted[] = [];
78+
const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any);
79+
await kernel.use(new ObjectQLPlugin());
80+
await kernel.use(new AutomationServicePlugin());
81+
await kernel.use({
82+
name: 'test.messaging-recorder', type: 'standard', version: '1.0.0', dependencies: [],
83+
async init(ctx: any) {
84+
ctx.registerService('messaging', {
85+
async emit(n: any) {
86+
emitted.push({ audience: [...(n.audience ?? [])], title: String(n.payload?.title ?? '') });
87+
return { delivered: 1, failed: 0 };
88+
},
89+
});
90+
},
91+
async start() {},
92+
} as any);
93+
await kernel.bootstrap();
94+
openKernels.push(kernel as any);
95+
96+
const objectql: any = kernel.getService('objectql');
97+
const data: any = kernel.getService('data');
98+
const automation = kernel.getService<AutomationEngine>('automation') as AutomationEngine & Record<string, any>;
99+
100+
const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
101+
await driver.connect();
102+
objectql.registerDriver(driver, true);
103+
openDrivers.push(driver);
104+
105+
for (const obj of [Account, Project]) objectql.registry.registerObject(obj, 'showcase', 'showcase');
106+
objectql.registry.registerObject(
107+
{ name: 'sys_user', label: 'User', managedBy: 'better-auth', fields: { email: { type: 'email', label: 'Email' }, name: { type: 'text', label: 'Name' } } },
108+
'@objectstack/example-showcase',
109+
);
110+
await objectql.syncSchemas();
111+
await installDefaultOrganization(objectql);
112+
await objectql.getDriverForObject('sys_user')!.create('sys_user', { id: 'usr_admin', email: OWNER_EMAIL, name: 'Admin' });
113+
114+
automation.registerFlow((flow as any).name, flow as any);
115+
116+
const account = await data.insert('showcase_account', { name: 'Escalation Co' }, SYS);
117+
return { automation, data, emitted, accountId: (Array.isArray(account) ? account[0] : account).id };
118+
}
119+
120+
/**
121+
* Turn a fresh project red, and run the flow on it the way the record-change
122+
* trigger does: `record` is the row as stored (every declared column, so a
123+
* project with no budget reads `budget: null`), `previous` is that row before
124+
* the update.
125+
*/
126+
async function turnRed(booted: Booted, fields: Record<string, unknown>): Promise<{ result: any; run: any; steps: any[] }> {
127+
const created = await booted.data.insert('showcase_project', {
128+
name: 'esc', account: booted.accountId, status: 'planned', health: 'yellow', ...fields,
129+
}, SYS);
130+
const id = (Array.isArray(created) ? created[0] : created).id;
131+
const previous = await booted.data.findOne('showcase_project', { where: { id }, ...SYS });
132+
await booted.data.update('showcase_project', { id, health: 'red' }, SYS);
133+
const record = await booted.data.findOne('showcase_project', { where: { id }, ...SYS });
134+
// The defect's precondition, measured rather than assumed: the stored row
135+
// carries every optional field the case left out, as `null`.
136+
for (const field of ['budget', 'owner']) {
137+
if (!(field in fields)) expect(record).toHaveProperty(field, null);
138+
}
139+
140+
const result = await booted.automation.execute(FLOW, {
141+
record, previous, object: 'showcase_project', event: 'update', organizationId: null,
142+
} as any);
143+
const runs: any[] = (await booted.automation.listRuns(FLOW)) ?? [];
144+
const run = runs[0] ?? {};
145+
return { result, run, steps: run.steps ?? [] };
146+
}
147+
148+
/** `nodeId → status` for the steps that ran or were passed over. */
149+
function stepStatus(steps: any[]): Record<string, string> {
150+
return Object.fromEntries(steps.map((s) => [s.nodeId, s.status]));
151+
}
152+
153+
describe('showcase_project_escalation on a project missing an optional field', () => {
154+
it('a project with NO budget takes the normal path and notifies its owner', async () => {
155+
const booted = await boot();
156+
const { result, steps } = await turnRed(booted, { owner: OWNER_EMAIL });
157+
158+
expect(result.error ?? null).toBeNull();
159+
expect(result.success).toBe(true);
160+
const status = stepStatus(steps);
161+
expect(status.notify_normal).toBe('success');
162+
expect(status.alert).toBe('skipped');
163+
expect(booted.emitted.map((e) => e.audience)).toEqual([[OWNER_EMAIL], [OWNER_EMAIL]]);
164+
});
165+
166+
it('a budgeted project with NO owner takes the normal path and notifies the exec instead', async () => {
167+
const booted = await boot();
168+
const { result, steps } = await turnRed(booted, { budget: 1000 });
169+
170+
expect(result.error ?? null).toBeNull();
171+
expect(result.success).toBe(true);
172+
expect(stepStatus(steps).notify_normal).toBe('success');
173+
expect(booted.emitted.map((e) => e.audience)).toEqual([['role:owner'], ['role:owner']]);
174+
});
175+
176+
it('a blank owner reads as no owner: the normal path notifies the exec', async () => {
177+
const booted = await boot();
178+
const { result } = await turnRed(booted, { budget: 1000, owner: '' });
179+
180+
expect(result.error ?? null).toBeNull();
181+
expect(result.success).toBe(true);
182+
expect(booted.emitted.map((e) => e.audience)).toEqual([['role:owner'], ['role:owner']]);
183+
});
184+
185+
it('a project with neither budget nor owner still completes and reaches the exec', async () => {
186+
const booted = await boot();
187+
const { result } = await turnRed(booted, {});
188+
189+
expect(result.error ?? null).toBeNull();
190+
expect(result.success).toBe(true);
191+
expect(booted.emitted.map((e) => e.audience)).toEqual([['role:owner'], ['role:owner']]);
192+
});
193+
194+
it('control: a budgeted project with an owner still takes e3 to notify_normal', async () => {
195+
const booted = await boot();
196+
const { result, steps } = await turnRed(booted, { budget: 1000, owner: OWNER_EMAIL });
197+
198+
expect(result.success).toBe(true);
199+
const status = stepStatus(steps);
200+
expect(status.notify_normal).toBe('success');
201+
expect(status.alert).toBe('skipped');
202+
expect(booted.emitted.map((e) => e.audience)).toEqual([[OWNER_EMAIL], [OWNER_EMAIL]]);
203+
});
204+
205+
it('a critical project with NO owner skips the owner alert and still alerts the exec once', async () => {
206+
const booted = await boot();
207+
const { result, steps } = await turnRed(booted, { budget: 500_000 });
208+
209+
expect(result.error ?? null).toBeNull();
210+
expect(result.success).toBe(true);
211+
const status = stepStatus(steps);
212+
expect(status.alert_exec).toBe('success');
213+
expect(status.alert_owner).toBe('skipped');
214+
expect(status.notify_normal).toBe('skipped');
215+
const critical = booted.emitted.filter((e) => e.title.startsWith('🔴'));
216+
expect(critical.map((e) => e.audience)).toEqual([['role:owner']]);
217+
});
218+
219+
it('control: a critical project with an owner alerts both the owner and the exec', async () => {
220+
const booted = await boot();
221+
const { result, steps } = await turnRed(booted, { budget: 500_000, owner: OWNER_EMAIL });
222+
223+
expect(result.success).toBe(true);
224+
const status = stepStatus(steps);
225+
expect(status.alert_owner).toBe('success');
226+
expect(status.alert_exec).toBe('success');
227+
const critical = booted.emitted.filter((e) => e.title.startsWith('🔴'));
228+
expect(critical.map((e) => e.audience).sort()).toEqual([[OWNER_EMAIL], ['role:owner']]);
229+
});
230+
});

0 commit comments

Comments
 (0)