Skip to content

Commit 1e145eb

Browse files
os-zhuangclaude
andauthored
fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3240)
* fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234) `compactStepsForHistory` bounded a terminal run's persisted step log to the last `MAX_PERSISTED_HISTORY_STEPS` entries with a plain tail-slice. With the ADR-0031 structured-region step logs (#1505) a single `loop` can emit `iterations × body-steps` entries, so the tail-slice dropped the `loop`/`parallel`/`try_catch` container step (it precedes all its body steps) and every early iteration — leaving `getRun`/`listRuns` (after a restart or ring-buffer eviction) with body steps the Runs surface could no longer nest, and silently hiding an early failure. Introduce an exported, region-aware `compactStepLogForHistory`; the private method now delegates to it. Over budget it keeps the run's structural backbone — every top-level step (including the region container steps) and every failure, each pulled in with its ancestor container chain — plus the most recent body steps, order-preserving and hard-capped at `max` so `steps_json` stays bounded (#2585). Every retained body step keeps its enclosing container(s), so the compacted log never contains an orphan and the observability surface's per-iteration / per-region nesting still reconstructs. Under budget behavior is unchanged (whole log, stacks stripped). Adds 7 unit tests (cap, container/backbone retention, recent-iteration tail, early-failure retention, order preservation, nested-container no-orphan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi * docs(automation): correct run-history durability + region-step nesting in flows.mdx The "Runs" section still described run history as an in-memory ring buffer whose `sys_automation_run` rows "hold only live pauses", so "history starts fresh on each boot". That predates the durable terminal run history (#2585): terminal runs (completed / failed) are mirrored to `sys_automation_run` with a bounded step log, and `listRuns` / `getRun` merge it so a run's status / steps / failure reason survive a restart or ring-buffer eviction. Also note the Studio Runs panel now nests body steps by iteration / branch / handler (#1505, objectui #2667). Surfaced by the docs-drift check on #3240. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83e8f7d commit 1e145eb

4 files changed

Lines changed: 225 additions & 13 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234)
6+
7+
`compactStepsForHistory` bounded a terminal run's persisted step log to the last
8+
`MAX_PERSISTED_HISTORY_STEPS` entries with a plain tail-slice. With the ADR-0031
9+
structured-region step logs (#1505) a single `loop` can emit
10+
`iterations × body-steps` entries, so the tail-slice dropped the
11+
`loop`/`parallel`/`try_catch` **container** step (it precedes all its body steps)
12+
and every early iteration — leaving `getRun`/`listRuns` (after a process restart
13+
or ring-buffer eviction) with body steps the Runs surface could no longer nest,
14+
and silently hiding an early failure.
15+
16+
Compaction is now region-aware (new exported `compactStepLogForHistory`): over
17+
budget it keeps the run's structural backbone — every top-level step (including
18+
the region container steps) and every failure, each pulled in with its ancestor
19+
container chain — plus the most recent body steps, order-preserving and
20+
hard-capped at `max` so `steps_json` stays bounded (#2585). Every retained body
21+
step keeps its enclosing container(s), so the compacted log never contains an
22+
orphan and the observability surface's per-iteration / per-region nesting still
23+
reconstructs.

content/docs/automation/flows.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,10 +445,12 @@ GET /api/v1/automation/{flow}/runs/{runId}/screen # re-fetch a paused screen
445445
```
446446

447447
Each run's `steps[]` records every executed node — including loop iterations,
448-
parallel branch bodies, and try/catch region steps — and the Studio flow
449-
designer surfaces the same data in its **Runs** side panel. Run history is an
450-
in-memory ring buffer (the durable rows in `sys_automation_run` hold only
451-
*live pauses*), so history starts fresh on each boot.
448+
parallel branch bodies, and try/catch region steps — which the Studio flow
449+
designer surfaces, nested by iteration / branch / handler, in its **Runs** side
450+
panel. Recent runs are held in an in-memory ring buffer; terminal runs
451+
(completed / failed) are also mirrored to `sys_automation_run` as durable
452+
history with a bounded step log, so `listRuns` / `getRun` still report a run's
453+
status, steps, and failure reason after a restart or ring-buffer eviction.
452454

453455
## Edges
454456

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

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,88 @@ export interface StepLogEntry {
352352
regionKind?: string;
353353
}
354354

355+
/**
356+
* Compact a run's step log for durable history (#2585, #3234).
357+
*
358+
* Under {@link MAX_PERSISTED_HISTORY_STEPS} the log is persisted whole (only
359+
* `error.stack` is dropped — the code/message pair is the designer-facing "why";
360+
* stacks bloat rows without aiding the Runs surface).
361+
*
362+
* Over budget — a long `loop` alone can emit `iterations × body-steps` entries —
363+
* a plain tail-slice would drop the `loop`/`parallel`/`try_catch` **container**
364+
* step (it precedes all its body steps) and every early iteration, leaving the
365+
* Runs surface with body steps it can no longer nest and, worse, silently hiding
366+
* an early failure. Instead select a bounded, order-preserving subset that keeps
367+
* the run's structural backbone:
368+
*
369+
* 1. Every **top-level** step (`parentNodeId === undefined`) — `start`/`end`,
370+
* main-graph nodes, and the region container steps. Bounded by the flow's
371+
* static node count, not by loop iterations.
372+
* 2. Every **failure**, wherever it occurred — the reason the run is worth
373+
* keeping — each pulled in with its ancestor container chain for context.
374+
* 3. The most **recent** body steps (the tail shows what the run was doing when
375+
* it ended), each also pulled in with its ancestor chain.
376+
*
377+
* Every retained body step therefore keeps its enclosing container(s), so the
378+
* compacted log never contains an orphan and the observability surface's
379+
* per-iteration / per-region nesting still reconstructs; the result is
380+
* hard-capped at `max` so `steps_json` stays bounded (#2585).
381+
*/
382+
export function compactStepLogForHistory(
383+
steps: StepLogEntry[],
384+
max: number = MAX_PERSISTED_HISTORY_STEPS,
385+
): StepLogEntry[] {
386+
const strip = (s: StepLogEntry): StepLogEntry =>
387+
s.error?.stack ? { ...s, error: { code: s.error.code, message: s.error.message } } : s;
388+
389+
if (steps.length <= max) return steps.map(strip);
390+
391+
// Nearest preceding container-instance index for each step (its parent), or
392+
// -1 when top-level / its container is not in the log. The flat log is
393+
// pre-order, so a step's container is the closest earlier step whose nodeId
394+
// equals this step's parentNodeId (the same instance `buildStepTree` nests
395+
// under). O(n) via a running last-seen-index map.
396+
const parentIdx = new Array<number>(steps.length).fill(-1);
397+
const lastSeen = new Map<string, number>();
398+
for (let i = 0; i < steps.length; i++) {
399+
const pid = steps[i].parentNodeId;
400+
if (pid !== undefined) parentIdx[i] = lastSeen.get(pid) ?? -1;
401+
lastSeen.set(steps[i].nodeId, i);
402+
}
403+
// Indices of `i`'s ancestor chain (i first) not already selected in `into`.
404+
const missingChain = (i: number, into: Set<number>): number[] => {
405+
const chain: number[] = [];
406+
for (let k = i; k >= 0 && !into.has(k); k = parentIdx[k]) chain.push(k);
407+
return chain;
408+
};
409+
410+
const keep = new Set<number>();
411+
// (1) + (2): structural backbone + every failure, each with its container chain.
412+
for (let i = 0; i < steps.length; i++) {
413+
if (steps[i].parentNodeId === undefined || steps[i].status === 'failure') {
414+
for (const k of missingChain(i, keep)) keep.add(k);
415+
}
416+
}
417+
418+
const emit = (): StepLogEntry[] => {
419+
let idx = [...keep].sort((a, b) => a - b);
420+
// The backbone alone can exceed the cap on a very large flow — keep the
421+
// most recent `max` selected steps so the row stays bounded.
422+
if (idx.length > max) idx = idx.slice(idx.length - max);
423+
return idx.map((i) => strip(steps[i]));
424+
};
425+
if (keep.size >= max) return emit();
426+
427+
// (3): fill the remaining budget with the most recent body steps, each with
428+
// its ancestor chain so no retained body step is left orphaned.
429+
for (let i = steps.length - 1; i >= 0 && keep.size < max; i--) {
430+
if (keep.has(i)) continue;
431+
const chain = missingChain(i, keep);
432+
if (keep.size + chain.length <= max) for (const k of chain) keep.add(k);
433+
}
434+
return emit();
435+
}
436+
355437
/**
356438
* Internal execution log entry — compatible with ExecutionLog from spec.
357439
*/
@@ -2074,16 +2156,15 @@ export class AutomationEngine implements IAutomationService {
20742156
}
20752157

20762158
/**
2077-
* Compact a run's step log for durable history: keep the newest
2078-
* {@link MAX_PERSISTED_HISTORY_STEPS} steps (the tail carries the failure)
2079-
* and drop `error.stack` (the code/message pair is the designer-facing
2080-
* "why"; stacks bloat rows without aiding the Runs surface). Bounds the
2081-
* `steps_json` column so history rows stay cheap under retention (#2585).
2159+
* Compact a run's step log for durable history. Delegates to the region-aware
2160+
* {@link compactStepLogForHistory} (#3234): under {@link MAX_PERSISTED_HISTORY_STEPS}
2161+
* the log is kept whole (stacks stripped); over budget it keeps the run's
2162+
* structural backbone (top-level + container steps + every failure) plus the
2163+
* most recent body steps, so a long loop's container survives and the Runs
2164+
* surface can still nest what it retains.
20822165
*/
20832166
private compactStepsForHistory(steps: StepLogEntry[]): StepLogEntry[] {
2084-
return steps.slice(-MAX_PERSISTED_HISTORY_STEPS).map((s) =>
2085-
s.error?.stack ? { ...s, error: { code: s.error.code, message: s.error.message } } : s,
2086-
);
2167+
return compactStepLogForHistory(steps, MAX_PERSISTED_HISTORY_STEPS);
20872168
}
20882169

20892170
/**

packages/services/service-automation/src/run-history.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
// never break the run that produced it.
88

99
import { describe, it, expect } from 'vitest';
10-
import { AutomationEngine } from './engine.js';
10+
import { AutomationEngine, compactStepLogForHistory, MAX_PERSISTED_HISTORY_STEPS } from './engine.js';
11+
import type { StepLogEntry } from './engine.js';
1112
import { InMemorySuspendedRunStore } from './suspended-run-store.js';
1213
import type { AutomationContext } from '@objectstack/spec/contracts';
1314

@@ -150,3 +151,108 @@ describe('automation run history (durable observability)', () => {
150151
await flush();
151152
});
152153
});
154+
155+
// ── #3234: region-aware durable-history compaction ──────────────────────────
156+
157+
const AT = '2026-07-18T00:00:00.000Z';
158+
159+
/** A run log for a top-level `loop` over `iterations` items (one body step each,
160+
* optionally failing one). Mirrors the engine's folded region log (#1479):
161+
* container step top-level, body steps tagged with `parentNodeId`/`iteration`. */
162+
function loopRunLog(iterations: number, opts: { failAt?: number } = {}): StepLogEntry[] {
163+
const steps: StepLogEntry[] = [
164+
{ nodeId: 'start', nodeType: 'start', status: 'success', startedAt: AT },
165+
{ nodeId: 'each', nodeType: 'loop', status: 'success', startedAt: AT },
166+
];
167+
for (let i = 0; i < iterations; i++) {
168+
const fail = opts.failAt === i;
169+
steps.push({
170+
nodeId: 'body', nodeType: 'http', status: fail ? 'failure' : 'success', startedAt: AT,
171+
parentNodeId: 'each', iteration: i, regionKind: 'loop-body',
172+
...(fail ? { error: { code: 'E_HTTP', message: 'boom', stack: 'at foo\nat bar' } } : {}),
173+
});
174+
}
175+
steps.push({ nodeId: 'end', nodeType: 'end', status: 'success', startedAt: AT });
176+
return steps;
177+
}
178+
179+
/** Every retained body step has an earlier retained step whose nodeId is its
180+
* parentNodeId — i.e. the compacted log has no orphan, so the observability
181+
* surface can still nest what survived. */
182+
function hasNoOrphans(steps: StepLogEntry[]): boolean {
183+
for (let i = 0; i < steps.length; i++) {
184+
const pid = steps[i].parentNodeId;
185+
if (pid === undefined) continue;
186+
let found = false;
187+
for (let j = i - 1; j >= 0; j--) if (steps[j].nodeId === pid) { found = true; break; }
188+
if (!found) return false;
189+
}
190+
return true;
191+
}
192+
193+
describe('compactStepLogForHistory (#3234 region-aware history compaction)', () => {
194+
it('keeps a small log whole and strips error stacks', () => {
195+
const log = loopRunLog(3, { failAt: 1 });
196+
const out = compactStepLogForHistory(log, MAX_PERSISTED_HISTORY_STEPS);
197+
expect(out).toHaveLength(log.length);
198+
const failed = out.find((s) => s.status === 'failure');
199+
expect(failed?.error?.message).toBe('boom');
200+
expect(failed?.error?.stack).toBeUndefined();
201+
});
202+
203+
it('caps an over-budget loop at `max`', () => {
204+
const out = compactStepLogForHistory(loopRunLog(500), 200);
205+
expect(out.length).toBeLessThanOrEqual(200);
206+
expect(out.length).toBeGreaterThan(0);
207+
});
208+
209+
it('retains the loop CONTAINER + top-level backbone so body steps never orphan (the fix)', () => {
210+
// Pre-fix, a plain tail-slice dropped `start` + the `each` container
211+
// (they precede 500 body steps), leaving the Runs surface unable to nest.
212+
const out = compactStepLogForHistory(loopRunLog(500), 200);
213+
expect(out.some((s) => s.nodeId === 'each' && s.nodeType === 'loop')).toBe(true);
214+
expect(out.some((s) => s.nodeId === 'start')).toBe(true);
215+
expect(out.some((s) => s.nodeId === 'end')).toBe(true);
216+
expect(hasNoOrphans(out)).toBe(true);
217+
});
218+
219+
it('keeps the most recent iterations (the tail)', () => {
220+
const out = compactStepLogForHistory(loopRunLog(500), 200);
221+
const iters = out.filter((s) => s.regionKind === 'loop-body').map((s) => s.iteration!);
222+
expect(Math.max(...iters)).toBe(499);
223+
});
224+
225+
it('keeps an EARLY failure even though it is not in the tail', () => {
226+
// A plain tail-slice would silently drop a failure at iteration 2 of 500.
227+
const out = compactStepLogForHistory(loopRunLog(500, { failAt: 2 }), 200);
228+
const failed = out.find((s) => s.status === 'failure');
229+
expect(failed?.iteration).toBe(2);
230+
expect(failed?.error?.stack).toBeUndefined();
231+
expect(hasNoOrphans(out)).toBe(true);
232+
});
233+
234+
it('preserves original execution order', () => {
235+
const out = compactStepLogForHistory(loopRunLog(500, { failAt: 2 }), 200);
236+
const iters = out.filter((s) => s.regionKind === 'loop-body').map((s) => s.iteration!);
237+
expect(iters).toEqual([...iters].sort((a, b) => a - b));
238+
});
239+
240+
it('keeps a nested inner container for its retained body steps (no orphan)', () => {
241+
// outer loop (top-level) → inner loop (per outer iteration) → body steps.
242+
const steps: StepLogEntry[] = [
243+
{ nodeId: 'start', nodeType: 'start', status: 'success', startedAt: AT },
244+
{ nodeId: 'outer', nodeType: 'loop', status: 'success', startedAt: AT },
245+
];
246+
for (let o = 0; o < 60; o++) {
247+
steps.push({ nodeId: 'inner', nodeType: 'loop', status: 'success', startedAt: AT, parentNodeId: 'outer', iteration: o, regionKind: 'loop-body' });
248+
for (let n = 0; n < 5; n++) {
249+
steps.push({ nodeId: 'body', nodeType: 'http', status: 'success', startedAt: AT, parentNodeId: 'inner', iteration: n, regionKind: 'loop-body' });
250+
}
251+
}
252+
steps.push({ nodeId: 'end', nodeType: 'end', status: 'success', startedAt: AT });
253+
const out = compactStepLogForHistory(steps, 200);
254+
expect(out.length).toBeLessThanOrEqual(200);
255+
expect(hasNoOrphans(out)).toBe(true);
256+
expect(out.some((s) => s.nodeId === 'outer')).toBe(true);
257+
});
258+
});

0 commit comments

Comments
 (0)