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
15 changes: 15 additions & 0 deletions .changeset/33341-notify-summary-row-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

fix(service-automation): a run summary's node row carries the warnings its steps raised, so a notify whose channel was refused no longer reads as a clean success in the trigger response

Clause-②: yes (widening)
Metadata-protocol: added

A `notify` step whose only channel was refused (for example `push` on a deployment with no push channel registered) already put a warning naming the channel on its step in the run history. Its row in the run summary's `nodes[]` did not: the trigger response (`POST /api/v1/automation/:name/trigger`), the `summary` a flow door's `400 FLOW_FAILED` details carry, and the persisted run row all said `status: 'success'`, `acted: 0` and nothing else, so a caller reading only the summary could not tell that nobody was notified.

`FlowRunNodeSummarySchema` (`@objectstack/spec/automation`) gains an optional `warnings: string[]`, the same element shape as `ExecutionStepLog.warnings`, and `summarizeRun` fills it on every node row from that node's step warnings: distinct texts in first-seen order, absent when no execution raised one. Every node type's step warnings ride along, not only `notify`'s, so a `create_record` / `update_record` whose requested fields the data engine stripped says so on its row too. The list is bounded per node (five distinct texts); past that a final entry says how many distinct warnings were left out, and the run's step log keeps each execution's own. Node `status`, the counters and flow control are unchanged: a refused channel is still a successful step.

Nothing to migrate: the member is optional and additive. A client that validates the summary with `FlowRunSummarySchema` from an older `@objectstack/spec` strips the new member and reads the row as before.
2 changes: 2 additions & 0 deletions content/docs/references/automation/execution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none |
| **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |
| **unmeasured** | `integer` | optional | Executions that may have caused an effect the platform cannot count (see ExecutionStepMetrics.unmeasuredEffect) |
| **warnings** | `string[]` | optional | Advisory warnings the node's executions raised (`ExecutionStepLog.warnings`), distinct texts in first-seen order. A `success` node may carry them — e.g. a `notify` whose channel was refused, or a write whose requested fields the data engine stripped — so a node that partly did not do its job never reads as a clean success. Bounded per node like the rest of the summary: past the bound a final entry says how many distinct warnings were left out, and the run's step log holds each execution's own. Absent when no execution raised one |


---
Expand Down Expand Up @@ -339,6 +340,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none |
| **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |
| **unmeasured** | `integer` | optional | Executions that may have caused an effect the platform cannot count (see ExecutionStepMetrics.unmeasuredEffect) |
| **warnings** | `string[]` | optional | Advisory warnings the node's executions raised (`ExecutionStepLog.warnings`), distinct texts in first-seen order. A `success` node may carry them — e.g. a `notify` whose channel was refused, or a write whose requested fields the data engine stripped — so a node that partly did not do its job never reads as a clean success. Bounded per node like the rest of the summary: past the bound a final entry says how many distinct warnings were left out, and the run's step log holds each execution's own. Absent when no execution raised one |

### Nested Shape: `FlowRunSummary.gates[number]`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
NotificationDispatcher,
} from '@objectstack/service-messaging';
import type { MessagingChannel } from '@objectstack/service-messaging';
import { FlowRunSummarySchema } from '@objectstack/spec/automation';
import { AutomationEngine } from '../engine.js';
import { registerNotifyNode } from './notify-node.js';

Expand Down Expand Up @@ -290,3 +291,78 @@ describe('notify step names a refused channel on its run-history row', () => {
expect(step.warnings![0]).not.toContain("'inbox'");
});
});

/**
* The run's own answer names the refused channel too, not only its step.
*
* The trigger door serves the engine's result verbatim (`deps.success(result)`
* in the runtime's automation domain), so `run.summary` below IS the `summary`
* a `POST /automation/:name/trigger` caller reads. The notify row in
* `summary.nodes[]` used to be a plain `success` with `acted: 0` and no reason:
* a caller reading the summary could not tell "nothing to send" from "the only
* channel was refused". The row now carries the step's advisory `warnings`, in
* the step's own shape (a list of strings), and the run still completes: flow
* control is unchanged.
*
* Each case parses the summary through the spec's `FlowRunSummarySchema`, the
* schema a client validates the response with: an undeclared member is stripped
* there, so a warning the contract does not declare would not reach the caller.
*/
describe('the trigger response summary row of a notify step carries its refused-channel warning', () => {
function notifyRow(run: { summary?: unknown }) {
const summary = FlowRunSummarySchema.parse(run.summary);
const row = summary.nodes.find((n) => n.nodeId === 'notify');
expect(row, 'the notify node has a summary row').toBeDefined();
return row!;
}

it('durable path, the only channel unregistered: the row warns, names the channel, and equals the step warning', async () => {
const { engine } = bootOutboxStack([recordingChannel('inbox').channel]);
engine.registerFlow('nudge', notifyFlow(['push']));
const run = await engine.execute('nudge');

expect(run.success, 'flow control is unchanged: the run is not aborted').toBe(true);
const row = notifyRow(run);
expect(row).toMatchObject({ status: 'success', runs: 1, acted: 0 });
expect(row.warnings).toHaveLength(1);
expect(row.warnings![0]).toContain("'push'");
const [history] = await engine.listRuns('nudge');
const step = history.steps.find((s) => s.nodeId === 'notify')!;
expect(row.warnings, 'the row carries the step warning verbatim').toEqual(step.warnings);
});

it('inline path, the only channel unregistered: the same warning on the row', async () => {
const messaging = new MessagingService({ logger: silentLogger() });
messaging.registerChannel(recordingChannel('inbox').channel);
const engine = engineWith(messaging);
engine.registerFlow('nudge', notifyFlow(['push']));
const run = await engine.execute('nudge');

expect(run.success).toBe(true);
const row = notifyRow(run);
expect(row.warnings).toHaveLength(1);
expect(row.warnings![0]).toContain("'push'");
});

it('partial delivery: the row names only the refused channel', async () => {
const { engine } = bootOutboxStack([recordingChannel('inbox').channel]);
engine.registerFlow('nudge', notifyFlow(['inbox', 'push']));
const run = await engine.execute('nudge');

const row = notifyRow(run);
expect(row.warnings).toHaveLength(1);
expect(row.warnings![0]).toContain("'push'");
expect(row.warnings![0]).not.toContain("'inbox'");
});

it('CONTROL: a registered channel leaves the row with no warnings member at all', async () => {
const { engine } = bootOutboxStack([recordingChannel('inbox').channel]);
engine.registerFlow('nudge', notifyFlow(['inbox']));
const run = await engine.execute('nudge');

expect(run.success).toBe(true);
const row = notifyRow(run);
expect(row.status).toBe('success');
expect('warnings' in row, 'absent, never an empty list').toBe(false);
});
});
50 changes: 49 additions & 1 deletion packages/services/service-automation/src/run-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql';
import { AutomationEngine } from './engine.js';
import type { StepLogEntry, NodeExecutor, RunRecord } from './engine.js';
import { summarizeRun, formatRunSummaryLine } from './run-summary.js';
import { summarizeRun, formatRunSummaryLine, MAX_NODE_SUMMARY_WARNINGS } from './run-summary.js';
import { InMemorySuspendedRunStore, ObjectStoreSuspendedRunStore } from './suspended-run-store.js';
import { registerCrudNodes } from './builtin/crud-nodes.js';
import { registerLoopNode } from './builtin/loop-node.js';
Expand Down Expand Up @@ -135,6 +135,54 @@ describe('summarizeRun', () => {
});
});

describe('summarizeRun carries each node\'s step warnings onto its row', () => {
const refused = "notify delivered nothing: every delivery it attempted failed (1). channel 'push' (1 delivery): not registered.";

it('folds a node\'s warnings across its executions: distinct texts, first-seen order', () => {
const s = summarizeRun([
step({ nodeId: 'nudge', nodeType: 'notify', warnings: [refused], metrics: { selected: 1, acted: 0 } }),
step({ nodeId: 'nudge', nodeType: 'notify', warnings: ['second', refused], metrics: { selected: 1, acted: 0 } }),
step({ nodeId: 'nudge', nodeType: 'notify', metrics: { selected: 1, acted: 1 } }),
]);
expect(s.nodes[0]).toMatchObject({ nodeId: 'nudge', runs: 3, status: 'success', warnings: [refused, 'second'] });
});

it('keeps warnings on the node that raised them and leaves every other row without the member', () => {
const s = summarizeRun([
step({ nodeId: 'write', nodeType: 'update_record', warnings: ['dropped'], metrics: { acted: 1 } }),
step({ nodeId: 'gate', nodeType: 'decision' }),
step({ nodeId: 'nudge', nodeType: 'notify', status: 'skipped', skippedBy: { nodeId: 'gate' } }),
]);
expect(s.nodes.find((n) => n.nodeId === 'write')!.warnings).toEqual(['dropped']);
expect('warnings' in s.nodes.find((n) => n.nodeId === 'gate')!).toBe(false);
expect('warnings' in s.nodes.find((n) => n.nodeId === 'nudge')!).toBe(false);
});

it('carries a failed execution\'s warnings too', () => {
const s = summarizeRun([step({ nodeId: 'w', nodeType: 'update_record', status: 'failure', warnings: ['partial'] })]);
expect(s.nodes[0]).toMatchObject({ status: 'failure', warnings: ['partial'] });
});

it('bounds the list per node and says how many distinct texts it left out', () => {
const steps = Array.from({ length: MAX_NODE_SUMMARY_WARNINGS + 3 }, (_, i) =>
step({ nodeId: 'nudge', nodeType: 'notify', warnings: [`failed (${i + 1})`], parentNodeId: 'each', iteration: i }),
);
const warnings = summarizeRun(steps).nodes[0].warnings!;
expect(warnings).toHaveLength(MAX_NODE_SUMMARY_WARNINGS + 1);
expect(warnings.slice(0, MAX_NODE_SUMMARY_WARNINGS)).toEqual(
Array.from({ length: MAX_NODE_SUMMARY_WARNINGS }, (_, i) => `failed (${i + 1})`),
);
expect(warnings[MAX_NODE_SUMMARY_WARNINGS]).toMatch(/^3 more distinct warnings/);
});

it('a list exactly at the bound is published whole, with no overflow entry', () => {
const steps = Array.from({ length: MAX_NODE_SUMMARY_WARNINGS }, (_, i) =>
step({ nodeId: 'nudge', nodeType: 'notify', warnings: [`w${i}`] }),
);
expect(summarizeRun(steps).nodes[0].warnings).toEqual(Array.from({ length: MAX_NODE_SUMMARY_WARNINGS }, (_, i) => `w${i}`));
});
});

describe('formatRunSummaryLine', () => {
it('is one greppable line carrying selected / acted and the top gate', () => {
const line = formatRunSummaryLine(
Expand Down
58 changes: 58 additions & 0 deletions packages/services/service-automation/src/run-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ import type { StepLogEntry } from './engine.js';
* It is folded from the per-node array rather than accumulated alongside it,
* so the run-level count can never disagree with the breakdown it summarizes;
* `unmeasured` above is a genuinely independent tally and stays one.
*
* Each node's row also carries the advisory `warnings` its executions put on
* their steps, distinct texts in first-seen order. A step that succeeded with
* a warning used to fold into a row that read as a clean `success`: a `notify`
* whose only channel was refused said `acted: 0` and nothing else, so a caller
* reading the trigger response's summary could not tell "nothing to send"
* from "nobody could be reached". The list is bounded per node
* ({@link MAX_NODE_SUMMARY_WARNINGS}), like the rest of the detail. A
* `subflow` child's warnings stay on the child's own run row, as its `skipped`
* gates do.
*/
export function summarizeRun(steps: readonly StepLogEntry[]): FlowRunSummary {
// Mutable accumulators; `status` is decided once the counts are final.
Expand All @@ -85,6 +95,10 @@ export function summarizeRun(steps: readonly StepLogEntry[]): FlowRunSummary {
// different questions: `FlowRunNodeSummary.status` is judged on this
// node's OWN executions, while its `failures` publishes own + rolled-up.
const rolledUp = new Map<string, number>();
// The distinct advisory warnings each node's executions raised, in
// first-seen order (a `Set` keeps insertion order). Published per node by
// `nodeWarnings` once the fold is done.
const warningsSeen = new Map<string, Set<string>>();

for (const step of steps) {
let node = nodes.get(step.nodeId);
Expand Down Expand Up @@ -131,6 +145,15 @@ export function summarizeRun(steps: readonly StepLogEntry[]): FlowRunSummary {
node.runs += 1;
if (step.status === 'failure') node.failures += 1;

if (step.warnings?.length) {
let seen = warningsSeen.get(step.nodeId);
if (!seen) {
seen = new Set<string>();
warningsSeen.set(step.nodeId, seen);
}
for (const warning of step.warnings) seen.add(warning);
}

const metrics = step.metrics;
if (metrics?.selected !== undefined) {
node.selected = (node.selected ?? 0) + metrics.selected;
Expand Down Expand Up @@ -177,6 +200,8 @@ export function summarizeRun(steps: readonly StepLogEntry[]): FlowRunSummary {
// declares: it is no longer only this node's own failed executions.
node.failures += rolledUp.get(node.nodeId) ?? 0;
failed += node.failures;
const warnings = nodeWarnings(warningsSeen.get(node.nodeId));
if (warnings) node.warnings = warnings;
}

return {
Expand All @@ -194,6 +219,39 @@ export function summarizeRun(steps: readonly StepLogEntry[]): FlowRunSummary {
};
}

/**
* How many distinct warning texts one node's summary row carries before the
* rest are counted instead of listed.
*
* The summary's detail is bounded by the flow's STATIC shape, never by how
* often a node ran — that is what keeps a 5000-row sweep's persisted
* `summary_json` under its byte cap (`serializeSummaryBounded`). A warning
* text can embed a per-execution count (a `notify` names how many deliveries
* failed), so a long loop could raise many distinct texts; the row lists the
* first few and says how many it left out.
*/
export const MAX_NODE_SUMMARY_WARNINGS = 5;

/**
* The `warnings` a node's summary row publishes: the distinct texts its
* executions raised, in first-seen order, or `undefined` when none did —
* absent, never `[]`, so "no warning" and "not tracked" read the same way the
* step log's own member does. Past {@link MAX_NODE_SUMMARY_WARNINGS} the list
* is cut and a final entry states how many distinct texts were left out, so
* the cut is visible rather than silent; the run's step log keeps each
* execution's own warnings.
*/
function nodeWarnings(seen: ReadonlySet<string> | undefined): string[] | undefined {
if (!seen || seen.size === 0) return undefined;
const all = [...seen];
if (all.length <= MAX_NODE_SUMMARY_WARNINGS) return all;
const omitted = all.length - MAX_NODE_SUMMARY_WARNINGS;
return [
...all.slice(0, MAX_NODE_SUMMARY_WARNINGS),
`${omitted} more distinct ${omitted === 1 ? 'warning was' : 'warnings were'} raised by this node and left out of the summary; the run's step log holds each execution's own.`,
];
}

/**
* The single structured line a terminal run logs (#4354) — the minimum viable
* version of this feature, and the one that works with no console at all:
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@
"automation/FlowRunNodeSummary:skipped",
"automation/FlowRunNodeSummary:status",
"automation/FlowRunNodeSummary:unmeasured",
"automation/FlowRunNodeSummary:warnings",
"automation/FlowRunSummary:acted",
"automation/FlowRunSummary:detailOmitted",
"automation/FlowRunSummary:failed",
Expand Down
23 changes: 23 additions & 0 deletions packages/spec/src/automation/execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,29 @@ describe('FlowRunSummarySchema', () => {
expect(() => FlowRunSummarySchema.parse({ ...brokenSweep, acted: -1 })).toThrow();
});

describe('a node row\'s warnings', () => {
const refusedNotify = {
nodeId: 'nudge', nodeType: 'notify', status: 'success' as const, runs: 1, failures: 0, skipped: 0,
selected: 1, acted: 0, warnings: ["notify delivered nothing: channel 'push' (1 delivery): not registered."],
};

it('keeps them through a parse, so a client validating the response still reads them', () => {
const summary = FlowRunSummarySchema.parse({ selected: 1, acted: 0, skipped: 0, gates: [], nodes: [refusedNotify] });
expect(summary.nodes[0].warnings).toEqual(refusedNotify.warnings);
});

it('stays absent on a row that carries none, never defaulted to []', () => {
const row = FlowRunNodeSummarySchema.parse({ nodeId: 'gate', nodeType: 'decision', status: 'success', runs: 1, failures: 0, skipped: 0 });
expect('warnings' in row).toBe(false);
});

it('refuses a non-string entry at its path', () => {
const res = FlowRunNodeSummarySchema.safeParse({ ...refusedNotify, warnings: [{ message: 'w' }] });
expect(res.success).toBe(false);
expect(res.error?.issues[0].path).toEqual(['warnings', 0]);
});
});

it('carries an uncountable-effect tally distinct from acted', () => {
// A connector-driven run: `acted: 0` is INCOMPLETE, not zero. The third
// clause of the broken-sweep FIRST FILTER
Expand Down
Loading
Loading