Skip to content

Commit 081aa6f

Browse files
os-zhuangclaude
andauthored
feat(spec,service-automation): surface per-run flow summaries — selected / acted / skipped (#4354) (#4377)
* feat(spec,service-automation): surface per-run flow summaries — selected / acted / skipped (#4354) `success: true` never meant "it did its job". A scheduled sweep that selects thirty records and writes none is, from outside, identical to one with nothing to do: same green status, same empty output, same silence, same schedule tomorrow. Nothing anywhere separated "nothing to do" from "broken" — which is how #4347 left three hotcrm production flows completely inert, running daily and green for as long as they had existed. Every terminal run now carries a `FlowRunSummary`: `selected` / `acted` / `skipped` totals, a per-node breakdown, and which gates closed. It is surfaced on `AutomationResult`, in `listRuns` / `getRun`, as one greppable log line, and as queryable `selected_count` / `acted_count` / `skipped_count` columns on `sys_automation_run` — so `selected > 0 AND acted = 0` over consecutive runs is something an operator can alert on rather than notice. The counts are declared by the executors (`NodeExecutionResult.metrics`), not sniffed from node output: only the node knows whether its result is a row count, a record, or a boolean. A closed conditional edge now records a `skipped` step naming the gate — the event that previously left no trace at all — and that step is explicitly not a run, so the ADR-0044 re-entry guard and the per-node `runs` counts are unchanged. Two details decide whether the numbers can be trusted: the summary is folded from the full step log before history compaction (and rehydrated from `summary_json`, never re-folded from the compacted steps), and `subflow` / `map` children roll up into their parent — synchronously via `metrics`, and via `creditChildRun` when the child paused and bubbles back. Additive throughout: `summary` is optional everywhere, legacy rows keep `null` counts rather than `0`, and no execution behaviour changes. The log line defaults to `info`, with `runSummaryLog: 'debug' | 'off'` to lower the volume without turning the measurement off. Verified: service-automation 525/46 (26 new), spec 7165/279 (5 new), runtime 974/68, plugin-approvals 330/13; all 8 spec `check:generated` gates plus `check:liveness` and `check:exported-any`; eslint clean; `tsc --noEmit` at the ledgered 2 pre-existing errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzJqHp1yXjri1tqKP8WoL4 * fix(service-automation,docs): raw NUL in the gate key, and the strictness-ledger count (#4354) Two CI gates on the previous commit, both real: - `check:nul-bytes` — the gate-dedup key joined its parts with LITERAL NUL bytes instead of the escape sequence. Byte-identical at runtime, but a raw NUL makes ripgrep treat the whole file as binary and silently return zero matches, so `run-summary.ts` would have dropped out of code search and every grep-based lint. Written as the escape now, with the reason the joiner is NUL at all spelled out (an edge id is author-supplied text, so a printable separator could collide two distinct gates). - `check:strictness-ledger` — `automation/execution.zod.ts` gained 5 `z.object(` sites (the run-summary family), so its ledger row and the section total were stale. Re-read: they are engine-emitted telemetry that nobody authors, so the row's `wire` verdict covers them unchanged; count updated to 13 (98 for the section) with the additions named. Verified: service-automation 525/46; `pnpm lint`, `check:nul-bytes` and `check:slot-lookup` clean; all seven pure spec audits (liveness, empty-state, variant-docs, strictness-ledger, react-conformance, skill-examples, exported-any) green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzJqHp1yXjri1tqKP8WoL4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d80695 commit 081aa6f

23 files changed

Lines changed: 1757 additions & 41 deletions

.changeset/flow-run-summaries.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec,service-automation): every flow run reports what it actually did — selected / acted / skipped (#4354)
7+
8+
`success: true` never meant "it did its job". A scheduled sweep that selects
9+
thirty records and writes none is, from outside, **identical** to one with
10+
nothing to do: same green status, same empty output, same silence, same schedule
11+
tomorrow. There was no signal anywhere that separated "nothing to do" from
12+
"broken".
13+
14+
That is not theoretical. #4347 left three hotcrm production flows completely
15+
inert — the stalled-deal sweep found every stalled deal and nudged nobody, the
16+
renewal sweep booked nothing, the campaign action enrolled no leads. They ran
17+
daily, on time, green, for as long as they had existed, and were caught only by
18+
adding tests that assert on records written. Automation is exactly the category
19+
where nobody is watching: a UI bug files a ticket within the hour, a dead sweep
20+
files nothing, and the longer it runs the more normal the silence looks.
21+
22+
**Every terminal run now carries a `FlowRunSummary`** — on the
23+
`AutomationResult`, on the run in `listRuns` / `getRun`, in the log, and in the
24+
database:
25+
26+
```
27+
[automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30
28+
```
29+
30+
- `selected` — records read by the run's data nodes
31+
- `acted` — records created / updated / deleted, plus effects dispatched
32+
(notifications delivered)
33+
- `skipped` — node executions a closed gate prevented, one per loop iteration
34+
whose conditional edge evaluated false
35+
- `nodes[]` — per-node terminal status with `runs` / `failures` / `skipped`
36+
- `gates[]` — which gates closed and how often, most-skipped first
37+
38+
**The counts are declared, not sniffed.** Executors report
39+
`NodeExecutionResult.metrics`, because only the node knows what its result
40+
*means*: `update_record`'s is a row count on a bulk write and a record on a by-id
41+
one, `delete_record`'s can be a boolean, `notify`'s is a delivery count. An
42+
engine inferring from output shapes would be guessing, and a machine-readable
43+
count that guesses is worse than none. A node that touches no records
44+
(`decision`, `assignment`) reports nothing — absent is not `0`.
45+
46+
**The gate is named.** A conditional out-edge that evaluates false now records a
47+
`skipped` step tagged with the gate that closed. That event previously left no
48+
trace at all, which is why #4347 was invisible: the flow selected every row and
49+
the loop-body edge never opened. A skipped step is explicitly *not* a run — the
50+
ADR-0044 re-entry guard, per-node `runs`, and node status all exclude it, so a
51+
new observability signal cannot change execution semantics.
52+
53+
**Queryable, so it can be alerted on rather than noticed.**
54+
`sys_automation_run` gains `selected_count` / `acted_count` / `skipped_count`
55+
columns plus a `summary_json` breakdown:
56+
57+
```typescript
58+
const suspect = await engine.find('sys_automation_run', {
59+
where: { status: 'completed', selected_count: { $gt: 0 }, acted_count: 0 },
60+
orderBy: [{ field: 'started_at', order: 'desc' }],
61+
});
62+
```
63+
64+
`selected > 0 && acted == 0` over consecutive runs is a near-perfect
65+
broken-sweep detector. Columns, not JSON: an operator can only alert on what is
66+
filterable. Rows written before this carry `null`, never `0` — "not measured"
67+
must not read as "measured zero", or every legacy row is a false alarm the first
68+
time someone writes that query.
69+
70+
Two details that decide whether the numbers can be trusted. The summary is
71+
folded from the **full** step log before history compaction, so a
72+
5000-iteration sweep does not silently report the ~200 steps that fit in
73+
`steps_json`; and rehydration reads the persisted `summary_json` rather than
74+
re-folding those compacted steps. A `subflow` rolls its child's totals into its
75+
parent, so a sweep that delegates its writes is not read as inert — the child
76+
keeps its own run row, and the parent's summary answers "what did this run
77+
cause".
78+
79+
Additive throughout: `summary` is optional everywhere it appears, existing runs
80+
and stores keep working, and no execution behaviour changes. The one-line log
81+
defaults to `info` — a line nobody sees at their production level is the same
82+
non-signal this closes — with `AutomationServicePlugin`'s
83+
`runSummaryLog: 'debug' | 'off'` to turn the volume down on a very
84+
high-frequency flow without turning the measurement off.
85+
86+
New spec exports: `FlowRunSummarySchema`, `FlowRunNodeSummarySchema`,
87+
`FlowRunGateSummarySchema`, `ExecutionStepMetricsSchema`,
88+
`ExecutionStepSkipReasonSchema` (+ inferred types); `ExecutionLog.summary` and
89+
`ExecutionStepLog.metrics` / `.skippedBy`. `service-automation` exports
90+
`summarizeRun` / `formatRunSummaryLine` so a host building its own surface
91+
reuses the platform's definition instead of re-deriving one.
92+
93+
Does not fix #4347 itself — this is the instrument that would have caught it.
94+
95+
Verified: `@objectstack/service-automation` **522 tests / 46 files** (23 new),
96+
`@objectstack/spec` **7165 / 279** (5 new), `@objectstack/runtime` **974 / 68**,
97+
`@objectstack/plugin-approvals` **330 / 13**; all eight `@objectstack/spec`
98+
`check:generated` gates plus `check:liveness` and `check:exported-any`; and
99+
`tsc --noEmit` on service-automation at its ledgered 2 pre-existing errors.

content/docs/automation/flows.mdx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,61 @@ panel. Recent runs are held in an in-memory ring buffer; terminal runs
579579
history with a bounded step log, so `listRuns` / `getRun` still report a run's
580580
status, steps, and failure reason after a restart or ring-buffer eviction.
581581

582+
### Run summaries
583+
584+
A run that reports `success: true` has not told you it did its job. A scheduled
585+
sweep that selects thirty records and writes none looks *identical* to one with
586+
nothing to do: same green status, same empty output, same silence. Every
587+
terminal run therefore carries a **summary** — on the `AutomationResult`, on the
588+
run in `listRuns` / `getRun`, and in the log:
589+
590+
```
591+
[automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30
592+
```
593+
594+
| Field | Meaning |
595+
| :--- | :--- |
596+
| `selected` | Records **read** by the run's data nodes |
597+
| `acted` | Records **created / updated / deleted**, plus effects dispatched (notifications delivered) |
598+
| `skipped` | Node executions a **closed gate** prevented — one per loop iteration whose conditional edge evaluated false |
599+
| `nodes[]` | Per-node terminal status with `runs` / `failures` / `skipped` and its own selected/acted |
600+
| `gates[]` | Which gates closed and how often, most-skipped first |
601+
602+
The counts come from the node executors themselves — `get_record` reports what
603+
it matched, the write nodes report what the data engine actually changed — not
604+
from the engine guessing at a node's output shape. A node that touches no
605+
records (`decision`, `assignment`) reports nothing at all, which is different
606+
from reporting zero.
607+
608+
The same counts land on `sys_automation_run` as **queryable columns**
609+
(`selected_count`, `acted_count`, `skipped_count`, plus a `summary_json`
610+
breakdown), so a broken sweep is something you can alert on rather than notice:
611+
612+
```typescript
613+
// Runs that selected work and did none of it, newest first.
614+
const suspect = await engine.find('sys_automation_run', {
615+
where: {
616+
status: 'completed',
617+
selected_count: { $gt: 0 },
618+
acted_count: 0,
619+
started_at: { $gte: since },
620+
},
621+
orderBy: [{ field: 'started_at', order: 'desc' }],
622+
});
623+
```
624+
625+
`selected > 0 && acted == 0` over several consecutive runs is a near-perfect
626+
broken-sweep detector — the case that is otherwise invisible, because nobody is
627+
watching automation until it has already been dead for a month. A single such
628+
run is not proof of anything: a sweep whose work is all already done reports the
629+
same thing legitimately, which is why the signal is *consecutive* runs, and why
630+
the platform reports the counts rather than raising the alarm itself.
631+
632+
Rows written before summaries existed carry `null` counts, not `0` — "not
633+
measured" must not read as "measured zero". The log line defaults to `info`;
634+
`AutomationServicePlugin`'s `runSummaryLog: 'debug' | 'off'` turns the volume
635+
down on a very high-frequency flow without turning the measurement off.
636+
582637
## Edges
583638

584639
Edges connect nodes and define the execution path:

content/docs/references/automation/execution.mdx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ AWS Step Functions execution logs.
2222
## TypeScript Usage
2323

2424
```typescript
25-
import { Checkpoint, ConcurrencyPolicy, ExecutionError, ExecutionErrorSeverity, ExecutionLog, ExecutionStatus, ExecutionStepLog, ScheduleState } from '@objectstack/spec/automation';
26-
import type { Checkpoint, ConcurrencyPolicy, ExecutionError, ExecutionErrorSeverity, ExecutionLog, ExecutionStatus, ExecutionStepLog, ScheduleState } from '@objectstack/spec/automation';
25+
import { Checkpoint, ConcurrencyPolicy, ExecutionError, ExecutionErrorSeverity, ExecutionLog, ExecutionStatus, ExecutionStepLog, ExecutionStepMetrics, ExecutionStepSkipReason, FlowRunGateSummary, FlowRunNodeSummary, FlowRunSummary, ScheduleState } from '@objectstack/spec/automation';
26+
import type { Checkpoint, ConcurrencyPolicy, ExecutionError, ExecutionErrorSeverity, ExecutionLog, ExecutionStatus, ExecutionStepLog, ExecutionStepMetrics, ExecutionStepSkipReason, FlowRunGateSummary, FlowRunNodeSummary, FlowRunSummary, ScheduleState } from '@objectstack/spec/automation';
2727

2828
// Validate data
2929
const result = Checkpoint.parse(data);
@@ -108,6 +108,7 @@ const result = Checkpoint.parse(data);
108108
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>` || Current execution status |
109109
| **trigger** | `{ type: string; recordId?: string; object?: string; userId?: string; … }` || What triggered this execution |
110110
| **steps** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Ordered list of executed steps |
111+
| **summary** | `{ selected: integer; acted: integer; skipped: integer; nodes: { nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]; … }` | optional | Per-run rollup: records selected / acted on, gate skips, per-node status |
111112
| **variables** | `Record<string, any>` | optional | Final state of flow variables |
112113
| **startedAt** | `string` || Execution start timestamp |
113114
| **completedAt** | `string` | optional | Execution completion timestamp |
@@ -154,6 +155,83 @@ const result = Checkpoint.parse(data);
154155
| **parentNodeId** | `string` | optional | Enclosing structured-region container node ID (loop/parallel/try_catch) |
155156
| **iteration** | `integer` | optional | Zero-based loop iteration or parallel branch index of the enclosing region |
156157
| **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch |
158+
| **metrics** | `{ selected?: integer; acted?: integer }` | optional | Records this step selected / acted on, as reported by the node executor |
159+
| **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` |
160+
161+
162+
---
163+
164+
## ExecutionStepMetrics
165+
166+
### Properties
167+
168+
| Property | Type | Required | Description |
169+
| :--- | :--- | :--- | :--- |
170+
| **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) |
171+
| **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) |
172+
173+
174+
---
175+
176+
## ExecutionStepSkipReason
177+
178+
### Properties
179+
180+
| Property | Type | Required | Description |
181+
| :--- | :--- | :--- | :--- |
182+
| **nodeId** | `string` || Node whose out-edge did not open (the gate) |
183+
| **edgeId** | `string` | optional | Edge whose condition evaluated false |
184+
| **label** | `string` | optional | Edge label, when the flow names its branches |
185+
186+
187+
---
188+
189+
## FlowRunGateSummary
190+
191+
### Properties
192+
193+
| Property | Type | Required | Description |
194+
| :--- | :--- | :--- | :--- |
195+
| **nodeId** | `string` || Node whose out-edge did not open (the gate) |
196+
| **targetNodeId** | `string` || Node the closed edge would have run |
197+
| **edgeId** | `string` | optional | Edge whose condition evaluated false |
198+
| **label** | `string` | optional | Edge label, when the flow names its branches |
199+
| **skipped** | `integer` || Times this gate evaluated false (once per loop iteration) |
200+
201+
202+
---
203+
204+
## FlowRunNodeSummary
205+
206+
### Properties
207+
208+
| Property | Type | Required | Description |
209+
| :--- | :--- | :--- | :--- |
210+
| **nodeId** | `string` || Node ID |
211+
| **nodeType** | `string` || Node action type (e.g., "get_record", "decision") |
212+
| **nodeLabel** | `string` | optional | Human-readable node label |
213+
| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` || Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped` |
214+
| **runs** | `integer` || Times the node executed (loop iterations and parallel branches each count) |
215+
| **failures** | `integer` || Executions that failed |
216+
| **skipped** | `integer` || Times a closed gate kept this node from running at all |
217+
| **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none |
218+
| **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |
219+
220+
221+
---
222+
223+
## FlowRunSummary
224+
225+
### Properties
226+
227+
| Property | Type | Required | Description |
228+
| :--- | :--- | :--- | :--- |
229+
| **selected** | `integer` || Total records read by the run |
230+
| **acted** | `integer` || Total records written / effects dispatched by the run |
231+
| **skipped** | `integer` || Total node executions a closed gate prevented |
232+
| **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` || Per-node breakdown, in first-execution order |
233+
| **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` || Gates that closed during the run, most-skipped first |
234+
| **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". |
157235

158236

159237
---

content/docs/releases/v17.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,17 @@ is gone.
12091209

12101210
### Automation & flows
12111211

1212+
- **Every terminal run reports what it did** (#4354): `selected` / `acted` /
1213+
`skipped` totals, a per-node breakdown, and *which gate closed* — on the run
1214+
result, in `listRuns` / `getRun`, as one greppable log line
1215+
(`selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30`), and as
1216+
queryable `selected_count` / `acted_count` / `skipped_count` columns on
1217+
`sys_automation_run`. `success: true` never meant "it did its job": a sweep
1218+
that selected thirty records and wrote none was indistinguishable from one
1219+
with nothing to do, which is how three inert production flows ran green for
1220+
as long as they had existed. `selected > 0 && acted == 0` over consecutive
1221+
runs is now the detector. See
1222+
[Run summaries](/docs/automation/flows#run-summaries).
12121223
- **`record-after-write`** fires one flow on create **or** update (#3427), with
12131224
`previous` bound as `null` on the create leg so start conditions can
12141225
discriminate.

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,14 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
165165
| `external-catalog.zod.ts` | 4 | wire (p) | |
166166
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
167167

168-
### `automation/`93 sites
168+
### `automation/`98 sites
169169

170170
| File | Sites | Class | Note |
171171
|---|---|---|---|
172172
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) |
173173
| `sync.zod.ts` / `etl.zod.ts` | 12+10 | authorable (p) | authored pipelines — **candidates** |
174174
| `trigger-registry.zod.ts` | 11 | mixed | descriptors are code-registered (wire-ish); bindings authored |
175-
| `execution.zod.ts` | 8 | wire | run-state envelopes — never strict |
175+
| `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged |
176176
| `state-machine.zod.ts` | 7 | authorable (p) | |
177177
| `control-flow.zod.ts` | 6 | authorable (p) | validated structurally by `validateControlFlow` |
178178
| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes |

0 commit comments

Comments
 (0)