|
| 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. |
0 commit comments