@dzhechkov/harness-core 0.3.150 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +410 -62
- package/README.md +81 -3
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +10 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/backlog-embed.d.ts +94 -0
- package/dist/backlog-embed.d.ts.map +1 -0
- package/dist/backlog-embed.js +138 -0
- package/dist/backlog-embed.js.map +1 -0
- package/dist/backlog.d.ts +180 -7
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +429 -26
- package/dist/backlog.js.map +1 -1
- package/dist/challenge-panel.d.ts +3 -0
- package/dist/challenge-panel.d.ts.map +1 -1
- package/dist/challenge-panel.js +3 -0
- package/dist/challenge-panel.js.map +1 -1
- package/dist/export-holdout.d.ts +149 -0
- package/dist/export-holdout.d.ts.map +1 -0
- package/dist/export-holdout.js +198 -0
- package/dist/export-holdout.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +127 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +199 -1
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +3 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +3 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +42 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +73 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +33 -0
- package/dist/loop-blobs.generated.d.ts.map +1 -0
- package/dist/loop-blobs.generated.js +101 -0
- package/dist/loop-blobs.generated.js.map +1 -0
- package/dist/loop-lint.d.ts +63 -0
- package/dist/loop-lint.d.ts.map +1 -0
- package/dist/loop-lint.js +606 -0
- package/dist/loop-lint.js.map +1 -0
- package/dist/loop-plan.d.ts +416 -0
- package/dist/loop-plan.d.ts.map +1 -0
- package/dist/loop-plan.js +1151 -0
- package/dist/loop-plan.js.map +1 -0
- package/dist/loop-render.d.ts +104 -0
- package/dist/loop-render.d.ts.map +1 -0
- package/dist/loop-render.js +1068 -0
- package/dist/loop-render.js.map +1 -0
- package/dist/loop-trace.d.ts +229 -0
- package/dist/loop-trace.d.ts.map +1 -0
- package/dist/loop-trace.js +614 -0
- package/dist/loop-trace.js.map +1 -0
- package/dist/mutation-gate.d.ts +247 -0
- package/dist/mutation-gate.d.ts.map +1 -0
- package/dist/mutation-gate.js +535 -0
- package/dist/mutation-gate.js.map +1 -0
- package/dist/no-stubs.d.ts +53 -0
- package/dist/no-stubs.d.ts.map +1 -0
- package/dist/no-stubs.js +190 -0
- package/dist/no-stubs.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +67 -0
- package/dist/package-skill-layouts.d.ts.map +1 -0
- package/dist/package-skill-layouts.js +81 -0
- package/dist/package-skill-layouts.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +156 -75
- package/dist/patterns.js.map +1 -1
- package/dist/recall-domain-boost.d.ts.map +1 -1
- package/dist/recall-domain-boost.js +6 -0
- package/dist/recall-domain-boost.js.map +1 -1
- package/dist/statusline.d.ts +10 -2
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +122 -36
- package/dist/statusline.js.map +1 -1
- package/dist/store-lock.d.ts +108 -0
- package/dist/store-lock.d.ts.map +1 -0
- package/dist/store-lock.js +231 -0
- package/dist/store-lock.js.map +1 -0
- package/dist/workflows.d.ts +16 -22
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +17 -98
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -4
- package/sbom.json +1073 -203
- package/src/agentdb-index.ts +10 -1
- package/src/backlog-embed.ts +156 -0
- package/src/backlog.ts +536 -28
- package/src/challenge-panel.ts +4 -0
- package/src/export-holdout.ts +235 -0
- package/src/feature-adr-checkpoints.ts +291 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/guard.ts +106 -1
- package/src/index.ts +62 -2
- package/src/loop-blobs.generated.ts +114 -0
- package/src/loop-lint.ts +643 -0
- package/src/loop-plan.ts +1419 -0
- package/src/loop-render.ts +1126 -0
- package/src/loop-trace.ts +727 -0
- package/src/mutation-gate.ts +701 -0
- package/src/no-stubs.ts +204 -0
- package/src/package-skill-layouts.ts +107 -0
- package/src/patterns.ts +135 -60
- package/src/recall-domain-boost.ts +6 -0
- package/src/statusline.ts +117 -30
- package/src/store-lock.ts +258 -0
- package/src/workflows.ts +18 -117
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loop-trace/1` — the trace plane of loop-designer (ADR-003: THE loop is its own sequencer).
|
|
3
|
+
*
|
|
4
|
+
* The host runtime's journal.jsonl has neither `seq` nor `ts` (MEASURED, K3) and is not repo code,
|
|
5
|
+
* so ordering can never come from it. Instead, the GENERATED loop emits its own trace events:
|
|
6
|
+
* `seq` is allocated by ONE module-level counter incremented SYNCHRONOUSLY at the lifecycle
|
|
7
|
+
* transition — immediately before `agent()`/`parallel()` is called (`dispatched`) and synchronously
|
|
8
|
+
* in the continuation after the `await` settles (`settled`). The sandbox runs a single-threaded JS
|
|
9
|
+
* context, so the counter is by construction unique and strictly increasing within a run (INV-14),
|
|
10
|
+
* and the allocation can never be separated from the call by an async write (AM-2 — the seq-at-
|
|
11
|
+
* write-time trap). `wallTime` is written shell-side by the flush command and is DIAGNOSTIC ONLY
|
|
12
|
+
* (INV-16 — never an operand of an invariant).
|
|
13
|
+
*
|
|
14
|
+
* EMITTER HALF (top of file, sandbox-safe — the `trace` blob is generated from these exports by
|
|
15
|
+
* scripts/gen-loop-blobs.mjs; no fs/Date/random/process here, ever).
|
|
16
|
+
*
|
|
17
|
+
* IMPORTS (QE round-7, stated precisely): this module has NO RUNTIME IMPORT. Its single `import
|
|
18
|
+
* type { TraceProjection } from './loop-plan.js'` is type-only and is erased at compile time, so
|
|
19
|
+
* there is no runtime cycle with loop-plan and the generated blob carries no import at all. (The
|
|
20
|
+
* ItemKey domain travels the other way — loop-plan imports `TRACE_KEY_RE` from here and re-exports
|
|
21
|
+
* it as `ITEM_KEY_RE`, one object shared by both layers.) "Import-free" would be the WRONG word.
|
|
22
|
+
* READER HALF (below) — parseTrace / assembleTimeline / runInvariants / renderTimelineHtml,
|
|
23
|
+
* consumed by `dz workflow-trace` and by the fitness suite (same runInvariants, two call sites).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { TraceProjection } from './loop-plan.js';
|
|
27
|
+
|
|
28
|
+
/** Blob version stamp for the emitter half (read by scripts/gen-loop-blobs.mjs). */
|
|
29
|
+
export const LOOP_TRACE_BLOB_VERSION = '1.1.0';
|
|
30
|
+
|
|
31
|
+
export const LOOP_TRACE_SCHEMA_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
/** runId VO: shell-inert, short. */
|
|
34
|
+
export const TRACE_RUNID_RE = /^[a-z0-9-]{1,40}$/;
|
|
35
|
+
/** stepId/itemKey VO: a stated, shell-inert superset of runId's alphabet (dots/underscores/colons
|
|
36
|
+
* for fanout-registry keys; quotes, $, backticks, newlines, spaces all excluded). */
|
|
37
|
+
export const TRACE_KEY_RE = /^[a-z0-9_.:-]{1,64}$/i;
|
|
38
|
+
|
|
39
|
+
export interface TraceDispatchEvent {
|
|
40
|
+
v: 1;
|
|
41
|
+
runId: string;
|
|
42
|
+
seq: number;
|
|
43
|
+
event: 'dispatched';
|
|
44
|
+
invocationId: string;
|
|
45
|
+
stepId: string;
|
|
46
|
+
itemKey: string | null;
|
|
47
|
+
attempt: number;
|
|
48
|
+
phase: string;
|
|
49
|
+
model: string | null;
|
|
50
|
+
causedBy: number[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TraceSettleEvent {
|
|
54
|
+
v: 1;
|
|
55
|
+
runId: string;
|
|
56
|
+
seq: number;
|
|
57
|
+
event: 'settled';
|
|
58
|
+
invocationId: string;
|
|
59
|
+
outcome: 'ok' | 'null' | 'error';
|
|
60
|
+
wallTime?: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TraceRunOpened {
|
|
64
|
+
v: 1;
|
|
65
|
+
runId: string;
|
|
66
|
+
seq: number;
|
|
67
|
+
event: 'run.opened';
|
|
68
|
+
planDigest: string;
|
|
69
|
+
execFp: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TraceRunClosed {
|
|
73
|
+
v: 1;
|
|
74
|
+
runId: string;
|
|
75
|
+
seq: number;
|
|
76
|
+
event: 'run.closed';
|
|
77
|
+
counts: { dispatched: number; settled: number };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type TraceEvent = TraceDispatchEvent | TraceSettleEvent | TraceRunOpened | TraceRunClosed;
|
|
81
|
+
|
|
82
|
+
export interface TraceState {
|
|
83
|
+
runId: string;
|
|
84
|
+
seq: number;
|
|
85
|
+
dispatched: number;
|
|
86
|
+
settled: number;
|
|
87
|
+
/** Buffered, validated event lines awaiting the next flush. */
|
|
88
|
+
buffer: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** shq twin, private to the trace plane (kept self-named so the trace blob never collides with the
|
|
92
|
+
* checkpoints blob's shellQuote when both are included in one script). */
|
|
93
|
+
export function traceShellQuote(s: string): string {
|
|
94
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate one event BEFORE it is buffered (the injection discipline): trace.jsonl is the
|
|
99
|
+
* AUTHORITATIVE ordering source, so a non-conforming or injected event is a HARD ERROR at buffer
|
|
100
|
+
* time — never a line repaired later. Returns an error string or null.
|
|
101
|
+
*/
|
|
102
|
+
export function traceValidateEvent(e: unknown): string | null {
|
|
103
|
+
if (typeof e !== 'object' || e === null || Array.isArray(e)) return 'event must be an object';
|
|
104
|
+
const ev = e as Record<string, unknown>;
|
|
105
|
+
if (ev['v'] !== 1) return 'v must be 1';
|
|
106
|
+
if (typeof ev['runId'] !== 'string' || !TRACE_RUNID_RE.test(ev['runId'])) return 'runId fails its VO regex';
|
|
107
|
+
if (typeof ev['seq'] !== 'number' || !Number.isInteger(ev['seq']) || ev['seq'] < 1) return 'seq must be a positive integer';
|
|
108
|
+
const kind = ev['event'];
|
|
109
|
+
if (kind === 'dispatched') {
|
|
110
|
+
if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '') return 'invocationId required';
|
|
111
|
+
if (typeof ev['stepId'] !== 'string' || !TRACE_KEY_RE.test(ev['stepId'])) return 'stepId fails its VO regex';
|
|
112
|
+
if (ev['itemKey'] !== null && (typeof ev['itemKey'] !== 'string' || !TRACE_KEY_RE.test(ev['itemKey']))) return 'itemKey fails its VO regex';
|
|
113
|
+
if (typeof ev['attempt'] !== 'number' || ev['attempt'] < 1) return 'attempt must be >= 1';
|
|
114
|
+
if (typeof ev['phase'] !== 'string' || ev['phase'] === '') return 'phase required';
|
|
115
|
+
if (!Array.isArray(ev['causedBy']) || (ev['causedBy'] as unknown[]).some((n) => typeof n !== 'number')) return 'causedBy must be a number array';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'settled') {
|
|
119
|
+
if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '') return 'invocationId required';
|
|
120
|
+
if (ev['outcome'] !== 'ok' && ev['outcome'] !== 'null' && ev['outcome'] !== 'error') return 'outcome must be ok|null|error';
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (kind === 'run.opened') {
|
|
124
|
+
if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string') return 'run.opened needs planDigest + execFp';
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (kind === 'run.closed') {
|
|
128
|
+
const c = ev['counts'];
|
|
129
|
+
if (typeof c !== 'object' || c === null) return 'run.closed needs counts';
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return 'unknown event kind';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Open a trace state and buffer the run.opened frame. Throws on an invalid runId (fail-closed). */
|
|
136
|
+
export function traceInit(runId: string, planDigest: string, execFp: string): TraceState {
|
|
137
|
+
if (!TRACE_RUNID_RE.test(runId)) throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));
|
|
138
|
+
const state: TraceState = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };
|
|
139
|
+
const opened: TraceRunOpened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp };
|
|
140
|
+
traceBuffer(state, opened);
|
|
141
|
+
return state;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function traceBuffer(state: TraceState, e: TraceEvent): void {
|
|
145
|
+
const err = traceValidateEvent(e);
|
|
146
|
+
if (err !== null) throw new Error('loop-trace: refusing non-conforming event (' + err + ') — the authoritative ordering source is never repaired later');
|
|
147
|
+
state.buffer.push(JSON.stringify(e));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Allocate the DISPATCH seq and buffer the event — called SYNCHRONOUSLY immediately before the
|
|
152
|
+
* `agent()`/`parallel()` call (the same synchronous statement pair; AM-2). Returns the seq.
|
|
153
|
+
*/
|
|
154
|
+
export function traceOnDispatch(
|
|
155
|
+
state: TraceState,
|
|
156
|
+
e: { invocationId: string; stepId: string; itemKey: string | null; attempt: number; phase: string; model: string | null; causedBy: number[] },
|
|
157
|
+
): number {
|
|
158
|
+
const seq = ++state.seq;
|
|
159
|
+
state.dispatched++;
|
|
160
|
+
traceBuffer(state, {
|
|
161
|
+
v: 1,
|
|
162
|
+
runId: state.runId,
|
|
163
|
+
seq,
|
|
164
|
+
event: 'dispatched',
|
|
165
|
+
invocationId: e.invocationId,
|
|
166
|
+
stepId: e.stepId,
|
|
167
|
+
itemKey: e.itemKey,
|
|
168
|
+
attempt: e.attempt,
|
|
169
|
+
phase: e.phase,
|
|
170
|
+
model: e.model,
|
|
171
|
+
causedBy: e.causedBy,
|
|
172
|
+
});
|
|
173
|
+
return seq;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Allocate the SETTLE seq and buffer the event — called synchronously in the continuation after
|
|
177
|
+
* the await resolves or rejects. Returns the seq (the causedBy input for dependents). */
|
|
178
|
+
export function traceOnSettle(state: TraceState, e: { invocationId: string; outcome: 'ok' | 'null' | 'error' }): number {
|
|
179
|
+
const seq = ++state.seq;
|
|
180
|
+
state.settled++;
|
|
181
|
+
traceBuffer(state, { v: 1, runId: state.runId, seq, event: 'settled', invocationId: e.invocationId, outcome: e.outcome });
|
|
182
|
+
return seq;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Buffer the run.closed frame (a trace without it parses as incomplete). */
|
|
186
|
+
export function traceClose(state: TraceState): void {
|
|
187
|
+
const closed: TraceRunClosed = {
|
|
188
|
+
v: 1,
|
|
189
|
+
runId: state.runId,
|
|
190
|
+
seq: ++state.seq,
|
|
191
|
+
event: 'run.closed',
|
|
192
|
+
counts: { dispatched: state.dispatched, settled: state.settled },
|
|
193
|
+
};
|
|
194
|
+
traceBuffer(state, closed);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Drain the buffer into ONE batched append command (the flush the cheap writer agent runs).
|
|
199
|
+
* Each ENTIRE line is shq-escaped before splicing (JSON.stringify never emits raw newlines, so
|
|
200
|
+
* printf '%s\n' emits exactly one record per line). Wall-clock is added SHELL-SIDE via sed —
|
|
201
|
+
* diagnostic only (INV-16). Returns null when the buffer is empty (no agent call to spend).
|
|
202
|
+
*/
|
|
203
|
+
export function traceFlushCmd(state: TraceState, traceFileAbs: string): string | null {
|
|
204
|
+
if (state.buffer.length === 0) return null;
|
|
205
|
+
const lines = state.buffer.splice(0, state.buffer.length);
|
|
206
|
+
const file = traceShellQuote(traceFileAbs);
|
|
207
|
+
const dir = traceShellQuote(traceFileAbs.replace(/\/[^/]*$/, ''));
|
|
208
|
+
const printfs = lines
|
|
209
|
+
.map((l) => "printf '%s\\n' " + traceShellQuote(l) + ' | sed "s/}$/,\\"wallTime\\":\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\"}/" >> ' + file)
|
|
210
|
+
.join(' && ');
|
|
211
|
+
return 'mkdir -p ' + dir + ' && ' + printfs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build the feature-ADR live-panel telemetry leg. Totality comes from the caller's grouped splice:
|
|
216
|
+
* returning the bare command lets that splice preserve the trace flush's exit status while
|
|
217
|
+
* swallowing only the panel leg's failure. The `loop` producer marker stops a generated loop's
|
|
218
|
+
* high-frequency zero counters from displacing a live `/feature-adr` run's meaningful panel.
|
|
219
|
+
*/
|
|
220
|
+
export function traceFaRecordCmd(dzBin: unknown, slug: unknown, stepLabel: unknown, projectAbs: unknown): string | null {
|
|
221
|
+
if (typeof slug !== 'string' || slug === ''
|
|
222
|
+
|| typeof stepLabel !== 'string' || stepLabel === ''
|
|
223
|
+
|| typeof projectAbs !== 'string' || projectAbs === '') return null;
|
|
224
|
+
const bin = typeof dzBin === 'string' && dzBin !== '' ? dzBin : 'dz';
|
|
225
|
+
const cmd = traceShellQuote(bin) + ' statusline --fa-record --slug ' + traceShellQuote(slug)
|
|
226
|
+
+ ' --step ' + traceShellQuote(stepLabel) + ' --kind loop --project ' + traceShellQuote(projectAbs);
|
|
227
|
+
return cmd + ' >/dev/null 2>&1';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Build one feature-ADR run-cost row without manufacturing wall-clock data in JavaScript. */
|
|
231
|
+
export function traceLedgerLine(opts: {
|
|
232
|
+
slug: unknown;
|
|
233
|
+
runId?: unknown;
|
|
234
|
+
planDigest?: unknown;
|
|
235
|
+
/**
|
|
236
|
+
* `agents` is the TOTAL number of agent invocations this run made — model dispatches AND infra
|
|
237
|
+
* agents (trace flush, checkpoint read/write, training-pair write/backfill, landed-barrier probes,
|
|
238
|
+
* and this ledger writer itself) — counted at write time. It is NOT the trace's model-dispatch
|
|
239
|
+
* count; the ledger's `agents` column means `agent_count` from the Workflow completion
|
|
240
|
+
* notification, and this row must not silently redefine it.
|
|
241
|
+
*/
|
|
242
|
+
agents?: unknown;
|
|
243
|
+
date?: unknown;
|
|
244
|
+
outcome?: unknown;
|
|
245
|
+
}): string | null {
|
|
246
|
+
try {
|
|
247
|
+
if (typeof opts.slug !== 'string' || opts.slug === '') return null;
|
|
248
|
+
const agents = typeof opts.agents === 'number'
|
|
249
|
+
&& Number.isFinite(opts.agents)
|
|
250
|
+
&& Number.isInteger(opts.agents)
|
|
251
|
+
&& opts.agents >= 0
|
|
252
|
+
? opts.agents
|
|
253
|
+
: 0;
|
|
254
|
+
const date = typeof opts.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(opts.date) ? opts.date : null;
|
|
255
|
+
const outcome = typeof opts.outcome === 'string' && opts.outcome !== '' ? opts.outcome : 'unknown';
|
|
256
|
+
const line = JSON.stringify({
|
|
257
|
+
slug: opts.slug,
|
|
258
|
+
stage: 'loop-run',
|
|
259
|
+
tier: null,
|
|
260
|
+
tokens: null,
|
|
261
|
+
minutes: null,
|
|
262
|
+
agents,
|
|
263
|
+
coder: null,
|
|
264
|
+
grade: null,
|
|
265
|
+
date,
|
|
266
|
+
auto: true,
|
|
267
|
+
outcome,
|
|
268
|
+
runId: typeof opts.runId === 'string' ? opts.runId : null,
|
|
269
|
+
planDigest: typeof opts.planDigest === 'string' ? opts.planDigest : null,
|
|
270
|
+
});
|
|
271
|
+
return line.length <= 4000 ? line : null;
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Build the single command that appends a run-cost row and confirms the write. */
|
|
278
|
+
export function traceLedgerAppendCmd(repoAbs: unknown, line: unknown): string | null {
|
|
279
|
+
if (typeof repoAbs !== 'string' || repoAbs === '' || typeof line !== 'string' || line === '') return null;
|
|
280
|
+
const dir = traceShellQuote(repoAbs + '/.dz/feature-adr');
|
|
281
|
+
const file = traceShellQuote(repoAbs + '/.dz/feature-adr/run-cost-ledger.jsonl');
|
|
282
|
+
// The field token has a fixed position, and JSON-escaped scalar values (including slug,
|
|
283
|
+
// runId, and planDigest) cannot introduce the raw `"date":null` token targeted by sed.
|
|
284
|
+
return 'mkdir -p ' + dir
|
|
285
|
+
+ " && printf '%s' " + traceShellQuote(line)
|
|
286
|
+
+ ' | sed "s/\\"date\\":null/\\"date\\":\\"$(date -u +%Y-%m-%d)\\"/" >> ' + file
|
|
287
|
+
+ " && printf '\\n' >> " + file
|
|
288
|
+
+ ' && echo LEDGER-OK';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// READER HALF — parseTrace / runInvariants / assembleTimeline / renderTimelineHtml.
|
|
293
|
+
// Pure over strings; the CLI does the fs.
|
|
294
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export interface TraceRun {
|
|
297
|
+
runId: string | null;
|
|
298
|
+
planDigest: string | null;
|
|
299
|
+
execFp: string | null;
|
|
300
|
+
events: TraceEvent[];
|
|
301
|
+
/** No run.closed frame ⇒ the tail may be lost; truncated-window invariants report inconclusive. */
|
|
302
|
+
incomplete: boolean;
|
|
303
|
+
parseErrors: string[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Parse a trace.jsonl text. Tolerant of a missing run.closed (incomplete: true); a DUPLICATE
|
|
307
|
+
* settle for one invocation is a PARSE ERROR, never a silent merge (INV-15). */
|
|
308
|
+
export function parseTrace(text: string): TraceRun {
|
|
309
|
+
const run: TraceRun = { runId: null, planDigest: null, execFp: null, events: [], incomplete: true, parseErrors: [] };
|
|
310
|
+
const settledSeen = new Set<string>();
|
|
311
|
+
const dispatchSeen = new Set<string>();
|
|
312
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
313
|
+
const t = line.trim();
|
|
314
|
+
if (t === '') continue;
|
|
315
|
+
let e: unknown;
|
|
316
|
+
try {
|
|
317
|
+
e = JSON.parse(t);
|
|
318
|
+
} catch {
|
|
319
|
+
run.parseErrors.push('unparseable line: ' + t.slice(0, 120));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const err = traceValidateEvent(e);
|
|
323
|
+
if (err !== null) {
|
|
324
|
+
run.parseErrors.push('invalid event (' + err + '): ' + t.slice(0, 120));
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const ev = e as TraceEvent;
|
|
328
|
+
if (ev.event === 'run.opened') {
|
|
329
|
+
run.runId = ev.runId;
|
|
330
|
+
run.planDigest = ev.planDigest;
|
|
331
|
+
run.execFp = ev.execFp;
|
|
332
|
+
}
|
|
333
|
+
if (ev.event === 'run.closed') run.incomplete = false;
|
|
334
|
+
if (ev.event === 'dispatched') {
|
|
335
|
+
const key = ev.invocationId + '@' + ev.attempt;
|
|
336
|
+
if (dispatchSeen.has(key)) run.parseErrors.push('duplicate dispatch for ' + key);
|
|
337
|
+
dispatchSeen.add(key);
|
|
338
|
+
}
|
|
339
|
+
if (ev.event === 'settled') {
|
|
340
|
+
if (settledSeen.has(ev.invocationId)) {
|
|
341
|
+
run.parseErrors.push('duplicate settle for invocation ' + ev.invocationId + ' — refusing to merge (INV-15)');
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
settledSeen.add(ev.invocationId);
|
|
345
|
+
}
|
|
346
|
+
run.events.push(ev);
|
|
347
|
+
}
|
|
348
|
+
return run;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type InvariantStatus = 'pass' | 'fail' | 'inconclusive';
|
|
352
|
+
|
|
353
|
+
export interface InvariantVerdict {
|
|
354
|
+
id: string;
|
|
355
|
+
status: InvariantStatus;
|
|
356
|
+
message: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface Invocation {
|
|
360
|
+
invocationId: string;
|
|
361
|
+
stepId: string;
|
|
362
|
+
itemKey: string | null;
|
|
363
|
+
dispatchSeq: number;
|
|
364
|
+
settleSeq: number | null;
|
|
365
|
+
causedBy: number[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function invocations(run: TraceRun): Invocation[] {
|
|
369
|
+
const out = new Map<string, Invocation>();
|
|
370
|
+
for (const e of run.events) {
|
|
371
|
+
if (e.event === 'dispatched') {
|
|
372
|
+
out.set(e.invocationId, {
|
|
373
|
+
invocationId: e.invocationId,
|
|
374
|
+
stepId: e.stepId,
|
|
375
|
+
itemKey: e.itemKey,
|
|
376
|
+
dispatchSeq: e.seq,
|
|
377
|
+
settleSeq: null,
|
|
378
|
+
causedBy: e.causedBy,
|
|
379
|
+
});
|
|
380
|
+
} else if (e.event === 'settled') {
|
|
381
|
+
const inv = out.get(e.invocationId);
|
|
382
|
+
if (inv) inv.settleSeq = e.seq;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return [...out.values()];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Evaluate the plan-derived runtime invariants over an observed trace. Consumes ONLY
|
|
390
|
+
* `toTraceProjection(plan)` (AM-3) and ONLY runtime-assigned `seq` (never wallTime — INV-16).
|
|
391
|
+
* The SAME function serves the fitness suite and `dz workflow-trace` (one implementation,
|
|
392
|
+
* two call sites). An incomplete trace turns window-truncated checks inconclusive, never pass.
|
|
393
|
+
*/
|
|
394
|
+
export function runInvariants(projection: TraceProjection, run: TraceRun): InvariantVerdict[] {
|
|
395
|
+
const out: InvariantVerdict[] = [];
|
|
396
|
+
const invs = invocations(run);
|
|
397
|
+
|
|
398
|
+
// INV-14: seq unique + strictly increasing in event order.
|
|
399
|
+
{
|
|
400
|
+
const seqs = run.events.map((e) => e.seq);
|
|
401
|
+
const dup = seqs.some((s, i) => seqs.indexOf(s) !== i);
|
|
402
|
+
const decreasing = seqs.some((s, i) => i > 0 && s <= (seqs[i - 1] as number));
|
|
403
|
+
if (dup || decreasing) out.push({ id: 'seq-monotonic', status: 'fail', message: 'seq is not unique/strictly-increasing — the single serialized writer property is broken' });
|
|
404
|
+
else out.push({ id: 'seq-monotonic', status: 'pass', message: 'seq unique and strictly increasing (' + seqs.length + ' events)' });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// INV-15: pairing (a dangling dispatch is only conclusive on a complete trace).
|
|
408
|
+
{
|
|
409
|
+
const dangling = invs.filter((i) => i.settleSeq === null);
|
|
410
|
+
if (run.parseErrors.some((p) => p.includes('duplicate settle'))) {
|
|
411
|
+
out.push({ id: 'dispatch-settle-pairing', status: 'fail', message: 'duplicate settle refused at parse (INV-15)' });
|
|
412
|
+
} else if (dangling.length > 0) {
|
|
413
|
+
out.push({
|
|
414
|
+
id: 'dispatch-settle-pairing',
|
|
415
|
+
status: run.incomplete ? 'inconclusive' : 'fail',
|
|
416
|
+
message: dangling.length + ' invocation(s) never settled' + (run.incomplete ? ' (trace incomplete — window truncated)' : ''),
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
out.push({ id: 'dispatch-settle-pairing', status: 'pass', message: 'every dispatch has exactly one settle' });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Happens-before from deps: every dispatch of step X after the settle of each dep (by seq).
|
|
424
|
+
for (const hb of projection.happensBefore) {
|
|
425
|
+
const depSettles = invs.filter((i) => i.stepId === hb.afterSettleOf && i.settleSeq !== null).map((i) => i.settleSeq as number);
|
|
426
|
+
const xDispatches = invs.filter((i) => i.stepId === hb.step).map((i) => i.dispatchSeq);
|
|
427
|
+
const id = 'happens-before:' + hb.afterSettleOf + '→' + hb.step;
|
|
428
|
+
if (xDispatches.length === 0 || depSettles.length === 0) {
|
|
429
|
+
out.push({ id, status: run.incomplete ? 'inconclusive' : 'fail', message: 'edge unobserved' + (run.incomplete ? ' (incomplete trace)' : ' in a complete trace') });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const minDispatch = Math.min(...xDispatches);
|
|
433
|
+
const maxSettle = Math.max(...depSettles);
|
|
434
|
+
// required ordering: at least one settle of the dep precedes EVERY dispatch of X; strict form:
|
|
435
|
+
// every dispatch of X comes after SOME settle of its dep.
|
|
436
|
+
const violated = xDispatches.some((d) => !depSettles.some((s) => s < d));
|
|
437
|
+
if (violated) out.push({ id, status: 'fail', message: `dispatch of ${hb.step} (seq ${minDispatch}) precedes every settle of ${hb.afterSettleOf} (max settle seq ${maxSettle})` });
|
|
438
|
+
else out.push({ id, status: 'pass', message: 'ordering respected' });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Regions: concurrency bound + join coverage (over seq windows, never wallTime).
|
|
442
|
+
for (const region of projection.regions) {
|
|
443
|
+
const members = invs.filter((i) => region.members.includes(i.stepId) || i.stepId === region.fanout);
|
|
444
|
+
const bound = region.maxFanout;
|
|
445
|
+
const idC = 'region-concurrency:' + region.fanout;
|
|
446
|
+
if (bound >= 1 && members.length > 0) {
|
|
447
|
+
// sweep over dispatch/settle transitions in seq order
|
|
448
|
+
const points: { seq: number; delta: number }[] = [];
|
|
449
|
+
for (const m of members) {
|
|
450
|
+
points.push({ seq: m.dispatchSeq, delta: 1 });
|
|
451
|
+
if (m.settleSeq !== null) points.push({ seq: m.settleSeq, delta: -1 });
|
|
452
|
+
}
|
|
453
|
+
points.sort((a, b) => a.seq - b.seq);
|
|
454
|
+
let cur = 0;
|
|
455
|
+
let peak = 0;
|
|
456
|
+
for (const p of points) {
|
|
457
|
+
cur += p.delta;
|
|
458
|
+
peak = Math.max(peak, cur);
|
|
459
|
+
}
|
|
460
|
+
if (peak > bound) out.push({ id: idC, status: 'fail', message: `observed scheduling concurrency ${peak} exceeds maxFanout ${bound}` });
|
|
461
|
+
else out.push({ id: idC, status: 'pass', message: `peak scheduled concurrency ${peak} <= maxFanout ${bound}` });
|
|
462
|
+
}
|
|
463
|
+
// Join coverage: the join itself is a structural pseudo-step (no trace event of its own), so
|
|
464
|
+
// its trace-visible witnesses are (a) every dispatched member settles (a dispatched branch is
|
|
465
|
+
// never skippable) and (b) every post-region dispatching step (`after`) dispatches AFTER every
|
|
466
|
+
// member settle — both by SEQ.
|
|
467
|
+
const idJ = 'join-coverage:' + region.fanout;
|
|
468
|
+
if (region.joinPolicy === 'all-activated' || region.joinPolicy === 'all-declared') {
|
|
469
|
+
const dangling = members.filter((m) => m.settleSeq === null);
|
|
470
|
+
if (dangling.length > 0) {
|
|
471
|
+
out.push({
|
|
472
|
+
id: idJ,
|
|
473
|
+
status: run.incomplete ? 'inconclusive' : 'fail',
|
|
474
|
+
message: `${dangling.length} dispatched branch(es) never settled (policy ${region.joinPolicy}) — a dispatched branch is never skippable` + (run.incomplete ? ' (trace incomplete)' : ''),
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
const afterDispatches = invs.filter((i) => region.after.includes(i.stepId)).map((i) => i.dispatchSeq);
|
|
478
|
+
const maxMemberSettle = members.length > 0 ? Math.max(...members.map((m) => m.settleSeq as number)) : 0;
|
|
479
|
+
const early = afterDispatches.filter((d) => d < maxMemberSettle);
|
|
480
|
+
if (early.length > 0) {
|
|
481
|
+
out.push({ id: idJ, status: 'fail', message: `a post-barrier step dispatched (seq ${Math.min(...early)}) before every branch settled (max settle seq ${maxMemberSettle})` });
|
|
482
|
+
} else {
|
|
483
|
+
out.push({ id: idJ, status: 'pass', message: 'every dispatched branch settled; post-barrier steps dispatched after the last settle' });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── expected-invariants.json evaluation (fitness fixtures — hand-authored, requirement-derived) ──
|
|
493
|
+
|
|
494
|
+
export interface ExpectedInvariant {
|
|
495
|
+
/** happens-before: `before` event must have a smaller seq than `after`.
|
|
496
|
+
* streaming-overlap: QUANTIFIED streaming property (QE round-3 B4) — SOME dispatch of
|
|
497
|
+
* `downstream` must precede the FINAL settle of `upstream` (by seq). */
|
|
498
|
+
type: 'happens-before' | 'no-overlap' | 'max-concurrency' | 'streaming-overlap';
|
|
499
|
+
/** For happens-before. */
|
|
500
|
+
before?: { event: 'dispatched' | 'settled'; stepId: string; itemKey?: string | null };
|
|
501
|
+
after?: { event: 'dispatched' | 'settled'; stepId: string; itemKey?: string | null };
|
|
502
|
+
/** For streaming-overlap. */
|
|
503
|
+
upstream?: string;
|
|
504
|
+
downstream?: string;
|
|
505
|
+
/** For no-overlap / max-concurrency. */
|
|
506
|
+
steps?: string[];
|
|
507
|
+
limit?: number;
|
|
508
|
+
note?: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function findSeq(run: TraceRun, sel: { event: 'dispatched' | 'settled'; stepId: string; itemKey?: string | null }): number | null {
|
|
512
|
+
const invs = invocations(run);
|
|
513
|
+
const matches = invs.filter((i) => i.stepId === sel.stepId && (sel.itemKey === undefined || i.itemKey === (sel.itemKey ?? null)));
|
|
514
|
+
if (matches.length === 0) return null;
|
|
515
|
+
if (sel.event === 'dispatched') return Math.min(...matches.map((m) => m.dispatchSeq));
|
|
516
|
+
const settles = matches.filter((m) => m.settleSeq !== null).map((m) => m.settleSeq as number);
|
|
517
|
+
return settles.length === 0 ? null : Math.max(...settles);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Evaluate hand-authored expected invariants (from a fitness fixture) over a trace — by SEQ only. */
|
|
521
|
+
export function evaluateExpectedInvariants(expected: ExpectedInvariant[], run: TraceRun): InvariantVerdict[] {
|
|
522
|
+
const out: InvariantVerdict[] = [];
|
|
523
|
+
expected.forEach((inv, idx) => {
|
|
524
|
+
const id = 'expected[' + idx + ']:' + inv.type + (inv.note ? ' ' + inv.note : '');
|
|
525
|
+
if (inv.type === 'happens-before') {
|
|
526
|
+
if (!inv.before || !inv.after) {
|
|
527
|
+
out.push({ id, status: 'inconclusive', message: 'malformed expected invariant' });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const b = findSeq(run, inv.before);
|
|
531
|
+
const a = findSeq(run, inv.after);
|
|
532
|
+
if (b === null || a === null) {
|
|
533
|
+
out.push({ id, status: run.incomplete ? 'inconclusive' : 'fail', message: 'selector matched no event' });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (b < a) out.push({ id, status: 'pass', message: `seq ${b} < ${a}` });
|
|
537
|
+
else out.push({ id, status: 'fail', message: `required seq(${inv.before.event} ${inv.before.stepId}) < seq(${inv.after.event} ${inv.after.stepId}) but ${b} >= ${a}` });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (inv.type === 'streaming-overlap') {
|
|
541
|
+
// QE round-3 B4 (reviewer R3(a), CONFIRMED): the round-2 spec was the ITEM-PAIR LITERAL
|
|
542
|
+
// seq(dispatch sb:item1) < seq(settle sa:item3) — a completed reverse-batched pipeline
|
|
543
|
+
// execution with ZERO resolver deviations legally settles sa:item3 first and was condemned.
|
|
544
|
+
// The quantified form: EXISTS a dispatch of `downstream` with seq < the FINAL settle of
|
|
545
|
+
// `upstream`. Every legal completed pipeline order passes (any non-final upstream settle
|
|
546
|
+
// hands off to its downstream dispatch before the final upstream settle under the suite's
|
|
547
|
+
// one-settle-per-turn scheduling model); a barrier topology NEVER passes (its join forces
|
|
548
|
+
// every downstream dispatch after ALL upstream settles).
|
|
549
|
+
if (typeof inv.upstream !== 'string' || typeof inv.downstream !== 'string') {
|
|
550
|
+
out.push({ id, status: 'inconclusive', message: 'streaming-overlap needs upstream + downstream stepIds' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const all = invocations(run);
|
|
554
|
+
const upSettles = all.filter((i) => i.stepId === inv.upstream && i.settleSeq !== null).map((i) => i.settleSeq as number);
|
|
555
|
+
const downDispatches = all.filter((i) => i.stepId === inv.downstream).map((i) => i.dispatchSeq);
|
|
556
|
+
if (upSettles.length === 0 || downDispatches.length === 0) {
|
|
557
|
+
out.push({ id, status: run.incomplete ? 'inconclusive' : 'fail', message: 'selector matched no event' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const finalUpSettle = Math.max(...upSettles);
|
|
561
|
+
const firstOverlap = downDispatches.filter((d) => d < finalUpSettle).sort((x, y) => x - y)[0];
|
|
562
|
+
if (firstOverlap !== undefined) {
|
|
563
|
+
out.push({ id, status: 'pass', message: `streaming overlap observed: seq(dispatch ${inv.downstream}) ${firstOverlap} < seq(final settle ${inv.upstream}) ${finalUpSettle}` });
|
|
564
|
+
} else {
|
|
565
|
+
out.push({ id, status: 'fail', message: `no dispatch of ${inv.downstream} precedes the final settle of ${inv.upstream} (min dispatch seq ${Math.min(...downDispatches)} >= final settle seq ${finalUpSettle}) — barrier-shaped execution, not a streaming pipeline` });
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (inv.type === 'max-concurrency') {
|
|
570
|
+
const invs = invocations(run).filter((i) => (inv.steps ?? []).includes(i.stepId));
|
|
571
|
+
const points: { seq: number; delta: number }[] = [];
|
|
572
|
+
for (const m of invs) {
|
|
573
|
+
points.push({ seq: m.dispatchSeq, delta: 1 });
|
|
574
|
+
if (m.settleSeq !== null) points.push({ seq: m.settleSeq, delta: -1 });
|
|
575
|
+
}
|
|
576
|
+
points.sort((x, y) => x.seq - y.seq);
|
|
577
|
+
let cur = 0;
|
|
578
|
+
let peak = 0;
|
|
579
|
+
for (const p of points) {
|
|
580
|
+
cur += p.delta;
|
|
581
|
+
peak = Math.max(peak, cur);
|
|
582
|
+
}
|
|
583
|
+
const limit = inv.limit ?? Infinity;
|
|
584
|
+
if (peak <= limit) out.push({ id, status: 'pass', message: `peak ${peak} <= ${limit}` });
|
|
585
|
+
else out.push({ id, status: 'fail', message: `peak scheduled concurrency ${peak} > limit ${limit}` });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (inv.type === 'no-overlap') {
|
|
589
|
+
const [sa, sb] = inv.steps ?? [];
|
|
590
|
+
if (!sa || !sb) {
|
|
591
|
+
out.push({ id, status: 'inconclusive', message: 'no-overlap needs two steps' });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const A = invocations(run).filter((i) => i.stepId === sa);
|
|
595
|
+
const B = invocations(run).filter((i) => i.stepId === sb);
|
|
596
|
+
const overlap = A.some((a) => B.some((b) => a.settleSeq !== null && b.settleSeq !== null && a.dispatchSeq < b.settleSeq && b.dispatchSeq < a.settleSeq));
|
|
597
|
+
if (overlap) out.push({ id, status: 'fail', message: `${sa} and ${sb} overlap in scheduled windows` });
|
|
598
|
+
else out.push({ id, status: 'pass', message: 'no forbidden overlap' });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
out.push({ id, status: 'inconclusive', message: 'unknown expected-invariant type' });
|
|
602
|
+
});
|
|
603
|
+
return out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── timeline assembly (dz workflow-trace) ────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
export interface TimelineRow {
|
|
609
|
+
seq: number;
|
|
610
|
+
kind: 'trace' | 'checkpoint' | 'ledger' | 'usage' | 'journal';
|
|
611
|
+
label: string;
|
|
612
|
+
detail: string;
|
|
613
|
+
wallTime: string | null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface Timeline {
|
|
617
|
+
runId: string | null;
|
|
618
|
+
incomplete: boolean;
|
|
619
|
+
rows: TimelineRow[];
|
|
620
|
+
sources: string[];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Merge one timeline: trace.jsonl is the AUTHORITATIVE order (rows sorted by seq); checkpoints,
|
|
625
|
+
* cost-ledger lines and usageEvents are appended as unordered context rows (seq 0); journal.jsonl
|
|
626
|
+
* contributes DIAGNOSTIC agentId correlation only, never ordering (the ACL of 04 §8).
|
|
627
|
+
*/
|
|
628
|
+
export function assembleTimeline(input: {
|
|
629
|
+
trace: string;
|
|
630
|
+
checkpoints?: string | null;
|
|
631
|
+
ledger?: string | null;
|
|
632
|
+
usageEvents?: unknown[] | null;
|
|
633
|
+
journal?: string | null;
|
|
634
|
+
}): Timeline {
|
|
635
|
+
const run = parseTrace(input.trace);
|
|
636
|
+
const rows: TimelineRow[] = [];
|
|
637
|
+
const sources: string[] = ['trace'];
|
|
638
|
+
for (const e of run.events) {
|
|
639
|
+
if (e.event === 'dispatched') {
|
|
640
|
+
rows.push({ seq: e.seq, kind: 'trace', label: `dispatch ${e.stepId}${e.itemKey ? ':' + e.itemKey : ''}#${e.attempt}`, detail: `phase=${e.phase} model=${e.model ?? '-'} causedBy=[${e.causedBy.join(',')}]`, wallTime: null });
|
|
641
|
+
} else if (e.event === 'settled') {
|
|
642
|
+
rows.push({ seq: e.seq, kind: 'trace', label: `settle ${e.invocationId}`, detail: `outcome=${e.outcome}`, wallTime: e.wallTime ?? null });
|
|
643
|
+
} else if (e.event === 'run.opened') {
|
|
644
|
+
rows.push({ seq: e.seq, kind: 'trace', label: 'run.opened', detail: `plan=${e.planDigest.slice(0, 12)} exec-fp=${e.execFp.slice(0, 12)}`, wallTime: null });
|
|
645
|
+
} else {
|
|
646
|
+
rows.push({ seq: e.seq, kind: 'trace', label: 'run.closed', detail: JSON.stringify(e.counts), wallTime: null });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
rows.sort((a, b) => a.seq - b.seq);
|
|
650
|
+
if (input.checkpoints) {
|
|
651
|
+
sources.push('checkpoints');
|
|
652
|
+
for (const line of input.checkpoints.split('\n')) {
|
|
653
|
+
const t = line.trim();
|
|
654
|
+
if (t === '') continue;
|
|
655
|
+
try {
|
|
656
|
+
const e = JSON.parse(t) as { stage?: string };
|
|
657
|
+
rows.push({ seq: 0, kind: 'checkpoint', label: `checkpoint ${e.stage ?? '?'}`, detail: t.slice(0, 120), wallTime: null });
|
|
658
|
+
} catch {
|
|
659
|
+
rows.push({ seq: 0, kind: 'checkpoint', label: 'checkpoint (malformed line)', detail: t.slice(0, 120), wallTime: null });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (input.ledger) {
|
|
664
|
+
sources.push('ledger');
|
|
665
|
+
for (const line of input.ledger.split('\n')) {
|
|
666
|
+
const t = line.trim();
|
|
667
|
+
if (t !== '') rows.push({ seq: 0, kind: 'ledger', label: 'cost', detail: t.slice(0, 160), wallTime: null });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (Array.isArray(input.usageEvents)) {
|
|
671
|
+
sources.push('usageEvents');
|
|
672
|
+
for (const u of input.usageEvents) rows.push({ seq: 0, kind: 'usage', label: 'usage', detail: JSON.stringify(u).slice(0, 160), wallTime: null });
|
|
673
|
+
}
|
|
674
|
+
if (input.journal) {
|
|
675
|
+
sources.push('journal (diagnostic only — never ordering)');
|
|
676
|
+
let n = 0;
|
|
677
|
+
for (const line of input.journal.split('\n')) if (line.trim() !== '') n++;
|
|
678
|
+
rows.push({ seq: 0, kind: 'journal', label: 'journal', detail: `${n} host-journal line(s) — agentId correlation only; the host journal carries no seq/ts and NEVER orders this timeline`, wallTime: null });
|
|
679
|
+
}
|
|
680
|
+
return { runId: run.runId, incomplete: run.incomplete, rows, sources };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function esc(s: string): string {
|
|
684
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* ONE self-contained HTML file: mermaid for the plan TOPOLOGY, an HTML/CSS waterfall TABLE for the
|
|
689
|
+
* event timeline — two different renderers structurally (Codex 04/Q3: mermaid degrades on dense
|
|
690
|
+
* traces; the timeline is never a second mermaid diagram).
|
|
691
|
+
*/
|
|
692
|
+
export function renderTimelineHtml(timeline: Timeline, projection: TraceProjection | null, verdicts: InvariantVerdict[]): string {
|
|
693
|
+
const mermaid: string[] = ['graph TD'];
|
|
694
|
+
if (projection) {
|
|
695
|
+
for (const hb of projection.happensBefore) mermaid.push(` ${hb.afterSettleOf} --> ${hb.step}`);
|
|
696
|
+
for (const r of projection.regions) {
|
|
697
|
+
mermaid.push(` ${r.fanout} -. fanout x${r.maxFanout} .-> ${r.join || 'join'}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const traceRows = timeline.rows.filter((r) => r.kind === 'trace');
|
|
701
|
+
const maxSeq = Math.max(1, ...traceRows.map((r) => r.seq));
|
|
702
|
+
const bar = (seq: number): string => `<div class="bar" style="margin-left:${((seq - 1) / maxSeq) * 80}%;width:${Math.max(1, 80 / maxSeq)}%"></div>`;
|
|
703
|
+
const rowsHtml = timeline.rows
|
|
704
|
+
.map((r) => `<tr class="k-${r.kind}"><td>${r.seq || ''}</td><td>${esc(r.label)}</td><td>${esc(r.detail)}</td><td>${esc(r.wallTime ?? '')}</td><td class="w">${r.kind === 'trace' && r.seq > 0 ? bar(r.seq) : ''}</td></tr>`)
|
|
705
|
+
.join('\n');
|
|
706
|
+
const verdictsHtml = verdicts.map((v) => `<li class="v-${v.status}"><b>${esc(v.id)}</b> — ${v.status.toUpperCase()}: ${esc(v.message)}</li>`).join('\n');
|
|
707
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>loop-trace ${esc(timeline.runId ?? '')}</title>
|
|
708
|
+
<style>
|
|
709
|
+
body{font:14px/1.45 system-ui,sans-serif;margin:1.5rem;max-width:1100px}
|
|
710
|
+
table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:2px 6px;font-size:12px}
|
|
711
|
+
.w{min-width:240px}.bar{height:10px;background:#4a7;border-radius:2px}
|
|
712
|
+
.v-fail{color:#b00}.v-pass{color:#171}.v-inconclusive{color:#970}
|
|
713
|
+
pre.mermaid{background:#f6f6f6;padding:8px}
|
|
714
|
+
.note{color:#666;font-size:12px}
|
|
715
|
+
</style></head><body>
|
|
716
|
+
<h1>loop-trace timeline — run ${esc(timeline.runId ?? '(unknown)')}${timeline.incomplete ? ' <em>(INCOMPLETE — no run.closed; the unflushed tail may be lost)</em>' : ''}</h1>
|
|
717
|
+
<p class="note">Ordering source: the loop's own trace.jsonl seq (runtime-assigned at dispatch/settle transitions). wallTime is diagnostic only (INV-16). Sources: ${esc(timeline.sources.join(', '))}.</p>
|
|
718
|
+
<h2>Plan topology (mermaid)</h2>
|
|
719
|
+
<pre class="mermaid">${esc(mermaid.join('\n'))}</pre>
|
|
720
|
+
<h2>Invariant verdicts</h2>
|
|
721
|
+
<ul>${verdictsHtml || '<li>(no plan given — invariants not evaluated)</li>'}</ul>
|
|
722
|
+
<h2>Event waterfall (HTML table — deliberately not mermaid)</h2>
|
|
723
|
+
<table><tr><th>seq</th><th>event</th><th>detail</th><th>wallTime (diagnostic)</th><th>waterfall</th></tr>
|
|
724
|
+
${rowsHtml}
|
|
725
|
+
</table>
|
|
726
|
+
</body></html>`;
|
|
727
|
+
}
|