@dzhechkov/harness-core 0.4.2 → 0.4.4
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 +104 -28
- package/README.md +15 -4
- package/dist/backlog.d.ts +35 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +167 -3
- package/dist/backlog.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +1 -0
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts +6 -2
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +54 -0
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +49 -0
- package/dist/loop-plan-graph.d.ts.map +1 -0
- package/dist/loop-plan-graph.js +128 -0
- package/dist/loop-plan-graph.js.map +1 -0
- package/dist/loop-plan.d.ts +17 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +18 -15
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +8 -0
- package/dist/loop-render.js.map +1 -1
- package/dist/model-recommender.d.ts +91 -0
- package/dist/model-recommender.d.ts.map +1 -0
- package/dist/model-recommender.js +186 -0
- package/dist/model-recommender.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/skills-verify.d.ts +40 -0
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +80 -10
- package/dist/skills-verify.js.map +1 -1
- package/dist/trace-bundle.d.ts +209 -0
- package/dist/trace-bundle.d.ts.map +1 -0
- package/dist/trace-bundle.js +601 -0
- package/dist/trace-bundle.js.map +1 -0
- package/dist/usage.d.ts +7 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +30 -2
- package/dist/usage.js.map +1 -1
- package/package.json +18 -18
- package/sbom.json +217 -27
- package/src/backlog.ts +176 -3
- package/src/index.ts +3 -0
- package/src/loop-blobs.generated.ts +1 -0
- package/src/loop-lint.ts +55 -1
- package/src/loop-plan-graph.ts +132 -0
- package/src/loop-plan.ts +35 -15
- package/src/loop-render.ts +8 -0
- package/src/model-recommender.ts +228 -0
- package/src/registry.ts +4 -1
- package/src/skills-verify.ts +108 -10
- package/src/trace-bundle.ts +743 -0
- package/src/usage.ts +42 -2
- package/LICENSE +0 -21
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable workflow trace bundles — the PURE half.
|
|
3
|
+
*
|
|
4
|
+
* The caller owns every external read and write. This module owns the decisions that must be
|
|
5
|
+
* replayable in a test: which ledger rows belong to a run, whether the harness-record layout is
|
|
6
|
+
* recognised, whether an import may touch a destination, and the exact relative writes it permits.
|
|
7
|
+
* Keeping those decisions independent of the host makes a refusal a value rather than a partially
|
|
8
|
+
* completed mutation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { traceValidateEvent } from './loop-trace.js';
|
|
12
|
+
|
|
13
|
+
/** The version is part of the wire contract: readers refuse versions they do not understand. */
|
|
14
|
+
export const TRACE_BUNDLE_SCHEMA = 'trace-bundle/1';
|
|
15
|
+
|
|
16
|
+
/** Native shelves used by both local runs and imported runs. */
|
|
17
|
+
export const TRACE_BUNDLE_LEDGER_PATH = '.dz/feature-adr/run-cost-ledger.jsonl';
|
|
18
|
+
export const TRACE_BUNDLE_RUN_META_FILE = 'run-meta.json';
|
|
19
|
+
|
|
20
|
+
export interface BundleMember {
|
|
21
|
+
/** path relative to the run directory, or the well-known source for non-run members */
|
|
22
|
+
readonly origin: string;
|
|
23
|
+
/** raw file content, verbatim — never re-serialised, never re-ordered */
|
|
24
|
+
readonly content: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Absence is data: a portable bundle must explain every source it could not carry. */
|
|
28
|
+
export type MemberSlot =
|
|
29
|
+
| { readonly present: true; readonly member: BundleMember }
|
|
30
|
+
| { readonly present: false; readonly reason: string };
|
|
31
|
+
|
|
32
|
+
export interface HarnessRecordResult {
|
|
33
|
+
readonly modelsUsed: Record<string, string>;
|
|
34
|
+
readonly usageEvents?: unknown[];
|
|
35
|
+
readonly [key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The harness record is kept whole so a consumer can recompute attribution from the transported
|
|
40
|
+
* facts. These are only the fields this adapter recognises; extra persisted fields remain intact.
|
|
41
|
+
*/
|
|
42
|
+
export interface HarnessRecord {
|
|
43
|
+
readonly runId: string;
|
|
44
|
+
readonly timestamp: string;
|
|
45
|
+
readonly agentCount: number;
|
|
46
|
+
readonly args: unknown;
|
|
47
|
+
readonly result: HarnessRecordResult;
|
|
48
|
+
readonly [key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Closed degradation vocabulary: callers can make strict-mode policy exhaustive.
|
|
53
|
+
* Only `layout-unrecognised` is ACTIONABLE; `records-absent`, `no-match`, `unreadable`, and
|
|
54
|
+
* `predates-model-routing` are not. The historical split follows a real-store measurement where
|
|
55
|
+
* 3 of 32 distinct slugs were valid older feature-ADR runs without per-stage model routing.
|
|
56
|
+
*/
|
|
57
|
+
export type RunMetaReason =
|
|
58
|
+
| 'records-absent'
|
|
59
|
+
| 'no-match'
|
|
60
|
+
| 'unreadable'
|
|
61
|
+
| 'predates-model-routing'
|
|
62
|
+
| 'layout-unrecognised';
|
|
63
|
+
|
|
64
|
+
export type RunMeta =
|
|
65
|
+
| {
|
|
66
|
+
resolved: true;
|
|
67
|
+
records: HarnessRecord[];
|
|
68
|
+
/** Joined records that were NOT usable, so attribution can never silently fold fewer
|
|
69
|
+
* records than the run actually had. */
|
|
70
|
+
skipped: { count: number; historical: number; unrecognised: number };
|
|
71
|
+
}
|
|
72
|
+
| { resolved: false; reason: RunMetaReason };
|
|
73
|
+
|
|
74
|
+
export type Attribution =
|
|
75
|
+
| { derived: true; rule: string; fromRecordIds: string[]; byStage: Record<string, string> }
|
|
76
|
+
| { derived: false; reason: string };
|
|
77
|
+
|
|
78
|
+
export interface TraceBundle {
|
|
79
|
+
schema: string;
|
|
80
|
+
provenance: {
|
|
81
|
+
sourceRoot: string;
|
|
82
|
+
runAddress: string;
|
|
83
|
+
slug: string | null;
|
|
84
|
+
runId: string | null;
|
|
85
|
+
toolVersion: string;
|
|
86
|
+
createdAt: string | null;
|
|
87
|
+
};
|
|
88
|
+
trace: MemberSlot;
|
|
89
|
+
checkpoints: MemberSlot;
|
|
90
|
+
ledger: {
|
|
91
|
+
present: boolean;
|
|
92
|
+
scanned: number;
|
|
93
|
+
matched: number;
|
|
94
|
+
malformed: number;
|
|
95
|
+
lines: string[];
|
|
96
|
+
reason?: string;
|
|
97
|
+
};
|
|
98
|
+
pairs:
|
|
99
|
+
| { included: false; reason: 'not-requested' | 'no-pairs-found' }
|
|
100
|
+
| { included: true; files: BundleMember[] };
|
|
101
|
+
runMeta: RunMeta;
|
|
102
|
+
attribution: Attribution;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Counts make an honestly empty slice distinguishable from an unread ledger. */
|
|
106
|
+
export interface LedgerSelection {
|
|
107
|
+
lines: string[];
|
|
108
|
+
scanned: number;
|
|
109
|
+
matched: number;
|
|
110
|
+
malformed: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** All external facts needed to build a bundle; optional values degrade to named absence. */
|
|
114
|
+
export interface BuildBundleInput {
|
|
115
|
+
readonly sourceRoot: string;
|
|
116
|
+
readonly runAddress: string;
|
|
117
|
+
readonly slug?: string | null;
|
|
118
|
+
readonly runId?: string | null;
|
|
119
|
+
readonly toolVersion: string;
|
|
120
|
+
readonly createdAt?: string | null;
|
|
121
|
+
readonly trace?: MemberSlot | BundleMember | null;
|
|
122
|
+
readonly checkpoints?: MemberSlot | BundleMember | null;
|
|
123
|
+
/** null means the ledger itself was absent; each array item is one raw JSONL row. */
|
|
124
|
+
readonly ledgerLines?: readonly string[] | null;
|
|
125
|
+
readonly ledgerReason?: string;
|
|
126
|
+
readonly includePairs?: boolean;
|
|
127
|
+
readonly pairFiles?: readonly BundleMember[] | null;
|
|
128
|
+
/** Each item is an already-read record file body, or an already-parsed record. */
|
|
129
|
+
readonly records?: readonly unknown[] | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Refusals are deliberately small and discriminated so an importer cannot accidentally continue. */
|
|
133
|
+
export type ParseResult =
|
|
134
|
+
| { ok: true; bundle: TraceBundle }
|
|
135
|
+
| { ok: false; reason: 'unparseable' }
|
|
136
|
+
| { ok: false; reason: 'unknown-schema'; found: string }
|
|
137
|
+
| { ok: false; reason: 'member-shape'; member: string };
|
|
138
|
+
|
|
139
|
+
/** The two identities used by the existing run-addressing schemes. */
|
|
140
|
+
export interface RunIdentity {
|
|
141
|
+
readonly slug: string | null;
|
|
142
|
+
readonly runId: string | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Facts observed by the I/O caller; no path in the resulting plan is absolute. */
|
|
146
|
+
export interface ImportDestinationFacts {
|
|
147
|
+
/** Target run directory relative to the explicitly supplied destination root. */
|
|
148
|
+
readonly runDir?: string;
|
|
149
|
+
readonly existingPaths?: readonly string[] | ReadonlySet<string>;
|
|
150
|
+
readonly runDirHasContent: boolean;
|
|
151
|
+
/** Identity read from the target run, or null when a fresh target has none. */
|
|
152
|
+
readonly runIdentity: RunIdentity | null;
|
|
153
|
+
readonly force?: boolean;
|
|
154
|
+
readonly withPairs?: boolean;
|
|
155
|
+
/** File name/address of the imported bundle, persisted in the run-meta sidecar. */
|
|
156
|
+
readonly bundleName: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A caller executes writes only when ok is true; fatal refusals therefore produce no writes. */
|
|
160
|
+
export interface ImportPlan {
|
|
161
|
+
writes: { path: string; content: string }[];
|
|
162
|
+
refusals: { path: string; reason: string }[];
|
|
163
|
+
ok: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ATTRIBUTION_RULE =
|
|
167
|
+
'last-writer-wins by timestamp is a CHOICE: a run whose phases used different models has no single honest answer; this reports "who ran it last"';
|
|
168
|
+
|
|
169
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
170
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nonEmptyString(value: unknown): value is string {
|
|
174
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nonNegativeInteger(value: unknown): value is number {
|
|
178
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A member origin must remain below the root to which the caller applies the plan. */
|
|
182
|
+
function safeRelativePath(value: unknown): value is string {
|
|
183
|
+
if (!nonEmptyString(value) || value.includes('\0') || value.includes('\\')) return false;
|
|
184
|
+
if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return false;
|
|
185
|
+
return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function memberShape(value: unknown): value is BundleMember {
|
|
189
|
+
if (!isObject(value)) return false;
|
|
190
|
+
return safeRelativePath(value['origin']) && typeof value['content'] === 'string';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function memberSlotShape(value: unknown): value is MemberSlot {
|
|
194
|
+
if (!isObject(value) || typeof value['present'] !== 'boolean') return false;
|
|
195
|
+
if (value['present'] === true) return memberShape(value['member']);
|
|
196
|
+
return nonEmptyString(value['reason']);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizedSlot(value: MemberSlot | BundleMember | null | undefined, absentReason: string): MemberSlot {
|
|
200
|
+
try {
|
|
201
|
+
if (memberSlotShape(value)) return value;
|
|
202
|
+
if (memberShape(value)) return { present: true, member: value };
|
|
203
|
+
} catch {
|
|
204
|
+
// A hostile value is simply not a readable member; exporting the other facts remains useful.
|
|
205
|
+
}
|
|
206
|
+
return { present: false, reason: absentReason };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Select only rows attributable to this logical run. Slug fallback is deliberately disabled when
|
|
211
|
+
* a row carries any run id: otherwise a foreign loop run sharing a slug leaks into the slice.
|
|
212
|
+
*/
|
|
213
|
+
export function selectLedgerRows(
|
|
214
|
+
lines: readonly string[] | null | undefined,
|
|
215
|
+
identity: { readonly runId: string | null; readonly slug: string | null } | null | undefined,
|
|
216
|
+
): LedgerSelection {
|
|
217
|
+
const selected: string[] = [];
|
|
218
|
+
let scanned = 0;
|
|
219
|
+
let malformed = 0;
|
|
220
|
+
const source = Array.isArray(lines) ? lines : [];
|
|
221
|
+
const targetRunId = identity !== null && identity !== undefined && typeof identity.runId === 'string' ? identity.runId : null;
|
|
222
|
+
const targetSlug = identity !== null && identity !== undefined && typeof identity.slug === 'string' ? identity.slug : null;
|
|
223
|
+
|
|
224
|
+
for (const line of source) {
|
|
225
|
+
scanned++;
|
|
226
|
+
if (typeof line !== 'string') {
|
|
227
|
+
malformed++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let row: unknown;
|
|
231
|
+
try {
|
|
232
|
+
row = JSON.parse(line);
|
|
233
|
+
} catch {
|
|
234
|
+
malformed++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!isObject(row)) continue;
|
|
238
|
+
|
|
239
|
+
const rowRunId = row['runId'];
|
|
240
|
+
const carriesRunId = nonEmptyString(rowRunId);
|
|
241
|
+
const byRunId = carriesRunId && targetRunId !== null && rowRunId === targetRunId;
|
|
242
|
+
const bySlug = !carriesRunId && targetSlug !== null && row['slug'] === targetSlug;
|
|
243
|
+
if (byRunId || bySlug) selected.push(line);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { lines: selected, scanned, matched: selected.length, malformed };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readRecord(value: unknown): { ok: true; record: Record<string, unknown> } | { ok: false } {
|
|
250
|
+
let parsed = value;
|
|
251
|
+
if (typeof value === 'string') {
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(value);
|
|
254
|
+
} catch {
|
|
255
|
+
return { ok: false };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return isObject(parsed) ? { ok: true, record: parsed } : { ok: false };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readArgsSlug(value: unknown): { ok: true; slug: string | null } | { ok: false } {
|
|
262
|
+
let args = value;
|
|
263
|
+
if (typeof value === 'string') {
|
|
264
|
+
try {
|
|
265
|
+
args = JSON.parse(value);
|
|
266
|
+
} catch {
|
|
267
|
+
return { ok: false };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (args === null || args === undefined) return { ok: true, slug: null };
|
|
271
|
+
if (!isObject(args)) return { ok: true, slug: null };
|
|
272
|
+
return { ok: true, slug: typeof args['slug'] === 'string' ? args['slug'] : null };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function modelsUsedShape(value: unknown): value is Record<string, string> {
|
|
276
|
+
if (!isObject(value)) return false;
|
|
277
|
+
const entries = Object.entries(value);
|
|
278
|
+
return entries.length > 0 && entries.every(([stage, model]) => stage !== '' && nonEmptyString(model));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const HISTORICAL_FEATURE_ADR_RESULT_KEYS = [
|
|
282
|
+
'slug',
|
|
283
|
+
'tier',
|
|
284
|
+
'artifactsDir',
|
|
285
|
+
'qeGrade',
|
|
286
|
+
'design',
|
|
287
|
+
'codeWrote',
|
|
288
|
+
'gaps',
|
|
289
|
+
] as const;
|
|
290
|
+
|
|
291
|
+
function historicalFeatureAdrResultShape(value: unknown): boolean {
|
|
292
|
+
if (!isObject(value)) return false;
|
|
293
|
+
// Older feature-ADR result fields drifted across versions, so require a recognisable threshold
|
|
294
|
+
// rather than an exact key set while still refusing arbitrary objects.
|
|
295
|
+
const recognisedKeys = HISTORICAL_FEATURE_ADR_RESULT_KEYS.filter((key) => (
|
|
296
|
+
Object.prototype.hasOwnProperty.call(value, key)
|
|
297
|
+
));
|
|
298
|
+
return recognisedKeys.length >= 3;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function recordPredatesModelRouting(record: Record<string, unknown>): boolean {
|
|
302
|
+
const result = record['result'];
|
|
303
|
+
if (result === null || result === undefined) return true;
|
|
304
|
+
if (!isObject(result) || modelsUsedShape(result['modelsUsed'])) return false;
|
|
305
|
+
return historicalFeatureAdrResultShape(result);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function harnessRecordShape(value: unknown): value is HarnessRecord {
|
|
309
|
+
if (!isObject(value)) return false;
|
|
310
|
+
if (!nonEmptyString(value['runId']) || !nonEmptyString(value['timestamp'])) return false;
|
|
311
|
+
if (typeof value['agentCount'] !== 'number' || !Number.isFinite(value['agentCount'])) return false;
|
|
312
|
+
const result = value['result'];
|
|
313
|
+
if (!isObject(result) || !modelsUsedShape(result['modelsUsed'])) return false;
|
|
314
|
+
return !('usageEvents' in result) || Array.isArray(result['usageEvents']);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Join persisted harness records without guessing through an unfamiliar layout. Returning no
|
|
319
|
+
* records on a joined shape failure prevents a model-blind record from appearing model-aware.
|
|
320
|
+
*/
|
|
321
|
+
export function resolveRunMeta(records: readonly unknown[] | null | undefined, slug: string | null): RunMeta {
|
|
322
|
+
try {
|
|
323
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
324
|
+
return { resolved: false, reason: 'records-absent' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const joined: Record<string, unknown>[] = [];
|
|
328
|
+
for (const source of records) {
|
|
329
|
+
const read = readRecord(source);
|
|
330
|
+
if (!read.ok) return { resolved: false, reason: 'unreadable' };
|
|
331
|
+
const args = readArgsSlug(read.record['args']);
|
|
332
|
+
if (!args.ok) return { resolved: false, reason: 'unreadable' };
|
|
333
|
+
if (slug !== null && args.slug === slug) joined.push(read.record);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (joined.length === 0) return { resolved: false, reason: 'no-match' };
|
|
337
|
+
|
|
338
|
+
// The unit of judgement is the RECORD, not the slug: the two-phase L/XL flow puts an older
|
|
339
|
+
// first phase and a newer re-invoke under ONE slug, so refusing the slug because one sibling
|
|
340
|
+
// predates model routing throws away data that is right there (MEASURED: 1 slug of 32).
|
|
341
|
+
const recognised: HarnessRecord[] = [];
|
|
342
|
+
const notUsable: Record<string, unknown>[] = [];
|
|
343
|
+
for (const record of joined) {
|
|
344
|
+
if (harnessRecordShape(record)) recognised.push(record as HarnessRecord);
|
|
345
|
+
else notUsable.push(record);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (recognised.length === 0) {
|
|
349
|
+
const reason = notUsable.every(recordPredatesModelRouting)
|
|
350
|
+
? 'predates-model-routing'
|
|
351
|
+
: 'layout-unrecognised';
|
|
352
|
+
return { resolved: false, reason };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const historical = notUsable.filter(recordPredatesModelRouting).length;
|
|
356
|
+
const skipped = {
|
|
357
|
+
count: notUsable.length,
|
|
358
|
+
historical,
|
|
359
|
+
unrecognised: notUsable.length - historical,
|
|
360
|
+
};
|
|
361
|
+
const ordered = recognised
|
|
362
|
+
.map((record, index) => ({ record, index }))
|
|
363
|
+
.sort((a, b) => {
|
|
364
|
+
const byTimestamp = a.record.timestamp < b.record.timestamp ? -1 : a.record.timestamp > b.record.timestamp ? 1 : 0;
|
|
365
|
+
return byTimestamp === 0 ? a.index - b.index : byTimestamp;
|
|
366
|
+
})
|
|
367
|
+
.map(({ record }) => record);
|
|
368
|
+
return { resolved: true, records: ordered, skipped };
|
|
369
|
+
} catch {
|
|
370
|
+
return { resolved: false, reason: 'unreadable' };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Fold the labelled convenience view alongside its source records. Last-writer-wins is explicitly
|
|
376
|
+
* a policy choice, and the source ids let a later consumer choose and audit a different policy.
|
|
377
|
+
*/
|
|
378
|
+
export function foldAttribution(records: readonly HarnessRecord[] | null | undefined): Attribution {
|
|
379
|
+
if (!Array.isArray(records) || records.length === 0) return { derived: false, reason: 'no-runmeta' };
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const ordered = records
|
|
383
|
+
.map((record, index) => ({ record, index }))
|
|
384
|
+
.sort((a, b) => {
|
|
385
|
+
const left = typeof a.record.timestamp === 'string' ? a.record.timestamp : '';
|
|
386
|
+
const right = typeof b.record.timestamp === 'string' ? b.record.timestamp : '';
|
|
387
|
+
const byTimestamp = left < right ? -1 : left > right ? 1 : 0;
|
|
388
|
+
return byTimestamp === 0 ? a.index - b.index : byTimestamp;
|
|
389
|
+
});
|
|
390
|
+
const byStage: Record<string, string> = {};
|
|
391
|
+
const fromRecordIds: string[] = [];
|
|
392
|
+
for (const { record } of ordered) {
|
|
393
|
+
if (nonEmptyString(record.runId)) fromRecordIds.push(record.runId);
|
|
394
|
+
const models = isObject(record.result) ? record.result.modelsUsed : undefined;
|
|
395
|
+
if (!isObject(models)) continue;
|
|
396
|
+
for (const [stage, model] of Object.entries(models)) {
|
|
397
|
+
if (stage === '' || !nonEmptyString(model)) continue;
|
|
398
|
+
Object.defineProperty(byStage, stage, {
|
|
399
|
+
value: model,
|
|
400
|
+
writable: true,
|
|
401
|
+
enumerable: true,
|
|
402
|
+
configurable: true,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { derived: true, rule: ATTRIBUTION_RULE, fromRecordIds, byStage };
|
|
407
|
+
} catch {
|
|
408
|
+
return { derived: false, reason: 'unreadable-runmeta' };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function emptyBundle(): TraceBundle {
|
|
413
|
+
return {
|
|
414
|
+
schema: TRACE_BUNDLE_SCHEMA,
|
|
415
|
+
provenance: {
|
|
416
|
+
sourceRoot: '',
|
|
417
|
+
runAddress: '',
|
|
418
|
+
slug: null,
|
|
419
|
+
runId: null,
|
|
420
|
+
toolVersion: '',
|
|
421
|
+
createdAt: null,
|
|
422
|
+
},
|
|
423
|
+
trace: { present: false, reason: 'invalid-input' },
|
|
424
|
+
checkpoints: { present: false, reason: 'invalid-input' },
|
|
425
|
+
ledger: {
|
|
426
|
+
present: false,
|
|
427
|
+
scanned: 0,
|
|
428
|
+
matched: 0,
|
|
429
|
+
malformed: 0,
|
|
430
|
+
lines: [],
|
|
431
|
+
reason: 'invalid-input',
|
|
432
|
+
},
|
|
433
|
+
pairs: { included: false, reason: 'not-requested' },
|
|
434
|
+
runMeta: { resolved: false, reason: 'records-absent' },
|
|
435
|
+
attribution: { derived: false, reason: 'no-runmeta' },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Assemble a bundle solely from facts the caller has already read. Malformed facts degrade safely. */
|
|
440
|
+
export function buildBundle(input: BuildBundleInput): TraceBundle {
|
|
441
|
+
try {
|
|
442
|
+
const slug = typeof input.slug === 'string' ? input.slug : null;
|
|
443
|
+
const runId = typeof input.runId === 'string' ? input.runId : null;
|
|
444
|
+
const trace = normalizedSlot(input.trace, 'not-provided');
|
|
445
|
+
const checkpoints = normalizedSlot(input.checkpoints, 'not-provided');
|
|
446
|
+
|
|
447
|
+
let ledger: TraceBundle['ledger'];
|
|
448
|
+
if (Array.isArray(input.ledgerLines)) {
|
|
449
|
+
const selection = selectLedgerRows(input.ledgerLines, { runId, slug });
|
|
450
|
+
ledger = { present: true, ...selection };
|
|
451
|
+
} else {
|
|
452
|
+
ledger = {
|
|
453
|
+
present: false,
|
|
454
|
+
scanned: 0,
|
|
455
|
+
matched: 0,
|
|
456
|
+
malformed: 0,
|
|
457
|
+
lines: [],
|
|
458
|
+
reason: nonEmptyString(input.ledgerReason) ? input.ledgerReason : 'not-provided',
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let pairs: TraceBundle['pairs'];
|
|
463
|
+
if (input.includePairs !== true) {
|
|
464
|
+
pairs = { included: false, reason: 'not-requested' };
|
|
465
|
+
} else {
|
|
466
|
+
const files = Array.isArray(input.pairFiles) ? input.pairFiles.filter(memberShape) : [];
|
|
467
|
+
pairs = files.length === 0
|
|
468
|
+
? { included: false, reason: 'no-pairs-found' }
|
|
469
|
+
: { included: true, files: [...files] };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const runMeta = resolveRunMeta(input.records, slug);
|
|
473
|
+
return {
|
|
474
|
+
schema: TRACE_BUNDLE_SCHEMA,
|
|
475
|
+
provenance: {
|
|
476
|
+
sourceRoot: typeof input.sourceRoot === 'string' ? input.sourceRoot : '',
|
|
477
|
+
runAddress: typeof input.runAddress === 'string' ? input.runAddress : '',
|
|
478
|
+
slug,
|
|
479
|
+
runId,
|
|
480
|
+
toolVersion: typeof input.toolVersion === 'string' ? input.toolVersion : '',
|
|
481
|
+
createdAt: typeof input.createdAt === 'string' ? input.createdAt : null,
|
|
482
|
+
},
|
|
483
|
+
trace,
|
|
484
|
+
checkpoints,
|
|
485
|
+
ledger,
|
|
486
|
+
pairs,
|
|
487
|
+
runMeta,
|
|
488
|
+
attribution: foldAttribution(runMeta.resolved ? runMeta.records : []),
|
|
489
|
+
};
|
|
490
|
+
} catch {
|
|
491
|
+
return emptyBundle();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** JSON escaping changes only the container representation; member content round-trips verbatim. */
|
|
496
|
+
export function serializeBundle(bundle: TraceBundle): string {
|
|
497
|
+
try {
|
|
498
|
+
const text = JSON.stringify(bundle);
|
|
499
|
+
return typeof text === 'string' ? text : '';
|
|
500
|
+
} catch {
|
|
501
|
+
return '';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function provenanceShape(value: unknown): value is TraceBundle['provenance'] {
|
|
506
|
+
if (!isObject(value)) return false;
|
|
507
|
+
return (
|
|
508
|
+
typeof value['sourceRoot'] === 'string' &&
|
|
509
|
+
typeof value['runAddress'] === 'string' &&
|
|
510
|
+
(typeof value['slug'] === 'string' || value['slug'] === null) &&
|
|
511
|
+
(typeof value['runId'] === 'string' || value['runId'] === null) &&
|
|
512
|
+
typeof value['toolVersion'] === 'string' &&
|
|
513
|
+
(typeof value['createdAt'] === 'string' || value['createdAt'] === null)
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function ledgerShape(value: unknown): value is TraceBundle['ledger'] {
|
|
518
|
+
if (!isObject(value) || typeof value['present'] !== 'boolean') return false;
|
|
519
|
+
if (!nonNegativeInteger(value['scanned']) || !nonNegativeInteger(value['matched']) || !nonNegativeInteger(value['malformed'])) return false;
|
|
520
|
+
if (!Array.isArray(value['lines']) || !value['lines'].every((line) => typeof line === 'string' && jsonObjectLine(line))) return false;
|
|
521
|
+
if (value['matched'] !== value['lines'].length || value['scanned'] < value['matched'] + value['malformed']) return false;
|
|
522
|
+
return value['present'] === true || nonEmptyString(value['reason']);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function jsonObjectLine(line: string): boolean {
|
|
526
|
+
try {
|
|
527
|
+
return isObject(JSON.parse(line));
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function jsonlShape(content: string, validate: (value: unknown) => boolean): boolean {
|
|
534
|
+
for (const line of content.split('\n')) {
|
|
535
|
+
if (line.trim() === '') continue;
|
|
536
|
+
let value: unknown;
|
|
537
|
+
try {
|
|
538
|
+
value = JSON.parse(line);
|
|
539
|
+
} catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
if (!validate(value)) return false;
|
|
543
|
+
}
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function traceContentShape(slot: MemberSlot): boolean {
|
|
548
|
+
return !slot.present || jsonlShape(slot.member.content, (event) => traceValidateEvent(event) === null);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function checkpointContentShape(slot: MemberSlot): boolean {
|
|
552
|
+
return !slot.present || jsonlShape(slot.member.content, (entry) => (
|
|
553
|
+
isObject(entry) &&
|
|
554
|
+
nonEmptyString(entry['stage']) &&
|
|
555
|
+
nonEmptyString(entry['inputHash']) &&
|
|
556
|
+
'result' in entry &&
|
|
557
|
+
entry['result'] !== null &&
|
|
558
|
+
entry['result'] !== undefined
|
|
559
|
+
));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function pairContentShape(member: BundleMember): boolean {
|
|
563
|
+
return jsonlShape(member.content, (pair) => (
|
|
564
|
+
isObject(pair) &&
|
|
565
|
+
nonEmptyString(pair['schema']) &&
|
|
566
|
+
nonEmptyString(pair['slug']) &&
|
|
567
|
+
nonEmptyString(pair['stage']) &&
|
|
568
|
+
typeof pair['input'] === 'string' &&
|
|
569
|
+
typeof pair['output'] === 'string'
|
|
570
|
+
));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function pairsShape(value: unknown): value is TraceBundle['pairs'] {
|
|
574
|
+
if (!isObject(value) || typeof value['included'] !== 'boolean') return false;
|
|
575
|
+
if (value['included'] === false) return value['reason'] === 'not-requested' || value['reason'] === 'no-pairs-found';
|
|
576
|
+
return Array.isArray(value['files']) && value['files'].length > 0 && value['files'].every((file) => memberShape(file) && pairContentShape(file));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function runMetaShape(value: unknown): value is RunMeta {
|
|
580
|
+
if (!isObject(value) || typeof value['resolved'] !== 'boolean') return false;
|
|
581
|
+
if (value['resolved'] === false) {
|
|
582
|
+
return value['reason'] === 'records-absent' || value['reason'] === 'no-match' || value['reason'] === 'unreadable' || value['reason'] === 'predates-model-routing' || value['reason'] === 'layout-unrecognised';
|
|
583
|
+
}
|
|
584
|
+
return Array.isArray(value['records']) && value['records'].every(harnessRecordShape);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function stringRecordShape(value: unknown): value is Record<string, string> {
|
|
588
|
+
return isObject(value) && Object.entries(value).every(([key, item]) => key !== '' && typeof item === 'string');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function attributionShape(value: unknown): value is Attribution {
|
|
592
|
+
if (!isObject(value) || typeof value['derived'] !== 'boolean') return false;
|
|
593
|
+
if (value['derived'] === false) return nonEmptyString(value['reason']);
|
|
594
|
+
return (
|
|
595
|
+
nonEmptyString(value['rule']) &&
|
|
596
|
+
Array.isArray(value['fromRecordIds']) &&
|
|
597
|
+
value['fromRecordIds'].every(nonEmptyString) &&
|
|
598
|
+
stringRecordShape(value['byStage'])
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Recognise the complete current format or refuse it. Validation finishes before the bundle is
|
|
604
|
+
* returned, so an importer can never receive a valid-looking subset of a corrupt artifact.
|
|
605
|
+
*/
|
|
606
|
+
export function parseBundle(text: string): ParseResult {
|
|
607
|
+
let parsed: unknown;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(text);
|
|
610
|
+
} catch {
|
|
611
|
+
return { ok: false, reason: 'unparseable' };
|
|
612
|
+
}
|
|
613
|
+
if (!isObject(parsed)) return { ok: false, reason: 'unknown-schema', found: 'undefined' };
|
|
614
|
+
if (parsed['schema'] !== TRACE_BUNDLE_SCHEMA) {
|
|
615
|
+
let found: string;
|
|
616
|
+
try {
|
|
617
|
+
found = String(parsed['schema']);
|
|
618
|
+
} catch {
|
|
619
|
+
found = 'unreadable';
|
|
620
|
+
}
|
|
621
|
+
return { ok: false, reason: 'unknown-schema', found };
|
|
622
|
+
}
|
|
623
|
+
if (!provenanceShape(parsed['provenance'])) return { ok: false, reason: 'member-shape', member: 'provenance' };
|
|
624
|
+
if (!memberSlotShape(parsed['trace']) || !traceContentShape(parsed['trace'])) return { ok: false, reason: 'member-shape', member: 'trace' };
|
|
625
|
+
if (!memberSlotShape(parsed['checkpoints']) || !checkpointContentShape(parsed['checkpoints'])) return { ok: false, reason: 'member-shape', member: 'checkpoints' };
|
|
626
|
+
if (!ledgerShape(parsed['ledger'])) return { ok: false, reason: 'member-shape', member: 'ledger' };
|
|
627
|
+
if (!pairsShape(parsed['pairs'])) return { ok: false, reason: 'member-shape', member: 'pairs' };
|
|
628
|
+
if (!runMetaShape(parsed['runMeta'])) return { ok: false, reason: 'member-shape', member: 'runMeta' };
|
|
629
|
+
if (!attributionShape(parsed['attribution'])) return { ok: false, reason: 'member-shape', member: 'attribution' };
|
|
630
|
+
return { ok: true, bundle: parsed as unknown as TraceBundle };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function canonicalRunDir(bundle: TraceBundle): string | null {
|
|
634
|
+
if (nonEmptyString(bundle.provenance.slug) && safeRelativePath(bundle.provenance.slug)) {
|
|
635
|
+
return 'features/' + bundle.provenance.slug;
|
|
636
|
+
}
|
|
637
|
+
if (nonEmptyString(bundle.provenance.runId) && safeRelativePath(bundle.provenance.runId)) {
|
|
638
|
+
return '.dz/loop-trace/' + bundle.provenance.runId;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function mismatchReason(bundle: TraceBundle, destination: RunIdentity | null): string | null {
|
|
644
|
+
if (destination === null) return null;
|
|
645
|
+
const bundleSlug = bundle.provenance.slug;
|
|
646
|
+
const bundleRunId = bundle.provenance.runId;
|
|
647
|
+
if (destination.slug !== null && destination.slug !== bundleSlug) {
|
|
648
|
+
return `destination slug "${destination.slug}" disagrees with bundle provenance slug "${String(bundleSlug)}"`;
|
|
649
|
+
}
|
|
650
|
+
if (destination.runId !== null && destination.runId !== bundleRunId) {
|
|
651
|
+
return `destination runId "${destination.runId}" disagrees with bundle provenance runId "${String(bundleRunId)}"`;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function joinRelative(base: string, child: string): string | null {
|
|
657
|
+
if (!safeRelativePath(base) || !safeRelativePath(child)) return null;
|
|
658
|
+
return base + '/' + child;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function listingHasRunContent(paths: readonly string[] | ReadonlySet<string> | undefined, runDir: string): boolean {
|
|
662
|
+
if (paths === undefined) return false;
|
|
663
|
+
for (const path of paths) {
|
|
664
|
+
if (path.startsWith(runDir + '/')) return true;
|
|
665
|
+
}
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function runMetaContent(bundle: TraceBundle, bundleName: string): string {
|
|
670
|
+
return JSON.stringify({
|
|
671
|
+
schema: TRACE_BUNDLE_SCHEMA,
|
|
672
|
+
sourceBundle: bundleName,
|
|
673
|
+
provenance: bundle.provenance,
|
|
674
|
+
runMeta: bundle.runMeta,
|
|
675
|
+
attribution: bundle.attribution,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Plan native-layout reconstruction without touching the destination. Every fatal check is
|
|
681
|
+
* completed before writes are returned, preserving the all-or-nothing refusal boundary.
|
|
682
|
+
*/
|
|
683
|
+
export function planImport(bundle: TraceBundle, destFacts: ImportDestinationFacts): ImportPlan {
|
|
684
|
+
const refusals: ImportPlan['refusals'] = [];
|
|
685
|
+
try {
|
|
686
|
+
const canonical = canonicalRunDir(bundle);
|
|
687
|
+
const suppliedRunDir = nonEmptyString(destFacts.runDir) ? destFacts.runDir : null;
|
|
688
|
+
const runDir = suppliedRunDir ?? canonical;
|
|
689
|
+
if (runDir === null || !safeRelativePath(runDir)) {
|
|
690
|
+
refusals.push({ path: suppliedRunDir ?? '.', reason: 'bundle provenance does not identify a safe native run directory' });
|
|
691
|
+
} else if (canonical !== null && suppliedRunDir !== null && suppliedRunDir !== canonical) {
|
|
692
|
+
refusals.push({ path: suppliedRunDir, reason: `bundle provenance identifies run directory "${canonical}", not "${suppliedRunDir}"` });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const mismatch = mismatchReason(bundle, destFacts.runIdentity);
|
|
696
|
+
if (mismatch !== null) refusals.push({ path: runDir ?? '.', reason: mismatch });
|
|
697
|
+
|
|
698
|
+
const runDirHasContent = runDir !== null && (
|
|
699
|
+
destFacts.runDirHasContent || listingHasRunContent(destFacts.existingPaths, runDir)
|
|
700
|
+
);
|
|
701
|
+
if (runDir !== null && runDirHasContent) {
|
|
702
|
+
if (destFacts.force !== true) {
|
|
703
|
+
refusals.push({ path: runDir, reason: 'destination run directory already has content; force is required for the bundle own run' });
|
|
704
|
+
} else if (destFacts.runIdentity === null || (destFacts.runIdentity.slug === null && destFacts.runIdentity.runId === null)) {
|
|
705
|
+
refusals.push({ path: runDir, reason: 'destination identity is unavailable, so bundle ownership cannot be established even under force' });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!nonEmptyString(destFacts.bundleName)) {
|
|
710
|
+
refusals.push({ path: runDir ?? '.', reason: 'source bundle name is required for the run-meta sidecar' });
|
|
711
|
+
}
|
|
712
|
+
if (refusals.length > 0 || runDir === null) return { writes: [], refusals, ok: false };
|
|
713
|
+
|
|
714
|
+
const writes: ImportPlan['writes'] = [];
|
|
715
|
+
const addRunMember = (slot: MemberSlot, label: string): void => {
|
|
716
|
+
if (!slot.present) return;
|
|
717
|
+
const path = joinRelative(runDir, slot.member.origin);
|
|
718
|
+
if (path === null) refusals.push({ path: slot.member.origin, reason: `${label} origin is not a safe relative path` });
|
|
719
|
+
else writes.push({ path, content: slot.member.content });
|
|
720
|
+
};
|
|
721
|
+
addRunMember(bundle.trace, 'trace');
|
|
722
|
+
addRunMember(bundle.checkpoints, 'checkpoints');
|
|
723
|
+
|
|
724
|
+
if (bundle.ledger.present) {
|
|
725
|
+
writes.push({ path: TRACE_BUNDLE_LEDGER_PATH, content: bundle.ledger.lines.join('\n') });
|
|
726
|
+
}
|
|
727
|
+
if (bundle.pairs.included && destFacts.withPairs === true) {
|
|
728
|
+
for (const file of bundle.pairs.files) {
|
|
729
|
+
if (!memberShape(file)) refusals.push({ path: 'pairs', reason: 'pair origin is not a safe relative path' });
|
|
730
|
+
else writes.push({ path: file.origin, content: file.content });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const runMetaPath = joinRelative(runDir, TRACE_BUNDLE_RUN_META_FILE);
|
|
735
|
+
if (runMetaPath === null) refusals.push({ path: runDir, reason: 'run-meta path is not safe' });
|
|
736
|
+
else writes.push({ path: runMetaPath, content: runMetaContent(bundle, destFacts.bundleName) });
|
|
737
|
+
|
|
738
|
+
if (refusals.length > 0) return { writes: [], refusals, ok: false };
|
|
739
|
+
return { writes, refusals: [], ok: true };
|
|
740
|
+
} catch {
|
|
741
|
+
return { writes: [], refusals: [{ path: '.', reason: 'destination facts are unreadable' }], ok: false };
|
|
742
|
+
}
|
|
743
|
+
}
|