@rulvar/testing 1.0.0
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/LICENSE +202 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +1964 -0
- package/dist/matchers.d.ts +32 -0
- package/dist/matchers.js +32 -0
- package/dist/test-engine-kYp9C72n.d.ts +81 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1964 @@
|
|
|
1
|
+
import { CURRENT_HASH_VERSION, ConfigError, EMPTY_SCHEMA_HASH, EMPTY_TOOLSET_HASH, InMemoryStore, InMemoryTranscriptStore, JournalMissError, Replayer, agentScope, buildDeriverRegistry, canonicalizeSchema, createCanonicalIdMinter, createEngine, defineWorkflow, deriveContentKey, hashWorkflowBody, makeOrchestratorWorkflow, normalizeEntry, projectToJsonSchema, registryKeyRing } from "@rulvar/core";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/fake-adapter.ts
|
|
6
|
+
/**
|
|
7
|
+
* FakeAdapter (M1-T14): a REAL ProviderAdapter that resolves calls from
|
|
8
|
+
* declared patterns instead of the network, behind the same seam as live
|
|
9
|
+
* adapters, so unit tests run through the full engine: journal, scheduler,
|
|
10
|
+
* budget layers, and event stream (docs/09, section "Tier 1: FakeAdapter
|
|
11
|
+
* and createTestEngine"). Calls cost zero USD.
|
|
12
|
+
*/
|
|
13
|
+
/** Scripts a tool-calling turn from a responder. */
|
|
14
|
+
function fakeToolCalls(...calls) {
|
|
15
|
+
return {
|
|
16
|
+
__fake: "tool-calls",
|
|
17
|
+
calls
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Scripts a typed wire failure (e.g. a retryable rate limit). */
|
|
21
|
+
function fakeWireError(error) {
|
|
22
|
+
return {
|
|
23
|
+
__fake: "wire-error",
|
|
24
|
+
error
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function isFakeToolCalls(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && value.__fake === "tool-calls";
|
|
29
|
+
}
|
|
30
|
+
function isFakeWireError(value) {
|
|
31
|
+
return typeof value === "object" && value !== null && value.__fake === "wire-error";
|
|
32
|
+
}
|
|
33
|
+
const FAKE_MODEL = "fake-model";
|
|
34
|
+
const FAKE_MODEL_REF = "fake:fake-model";
|
|
35
|
+
const FAKE_CAPS = {
|
|
36
|
+
structuredOutput: "native",
|
|
37
|
+
supportsTemperature: true,
|
|
38
|
+
supportsParallelTools: true,
|
|
39
|
+
reasoningEfforts: [
|
|
40
|
+
"low",
|
|
41
|
+
"medium",
|
|
42
|
+
"high",
|
|
43
|
+
"xhigh",
|
|
44
|
+
"max"
|
|
45
|
+
],
|
|
46
|
+
contextWindow: 1e6,
|
|
47
|
+
maxOutputTokens: 64e3,
|
|
48
|
+
pricing: {
|
|
49
|
+
inputUsdPerMTok: 0,
|
|
50
|
+
outputUsdPerMTok: 0
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function lastUserText(req) {
|
|
54
|
+
for (let i = req.messages.length - 1; i >= 0; i -= 1) {
|
|
55
|
+
const msg = req.messages[i];
|
|
56
|
+
if (msg?.role !== "user") continue;
|
|
57
|
+
return msg.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
58
|
+
}
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
var FakeAdapter = class {
|
|
62
|
+
id = "fake";
|
|
63
|
+
agents;
|
|
64
|
+
mintId = createCanonicalIdMinter();
|
|
65
|
+
/** Every request this adapter served, in order. */
|
|
66
|
+
calls = [];
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.agents = options.agents;
|
|
69
|
+
}
|
|
70
|
+
caps() {
|
|
71
|
+
return FAKE_CAPS;
|
|
72
|
+
}
|
|
73
|
+
match(call) {
|
|
74
|
+
let fallback;
|
|
75
|
+
for (const [pattern, responder] of Object.entries(this.agents)) {
|
|
76
|
+
if (pattern === "*") {
|
|
77
|
+
fallback = responder;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (call.agentType === pattern || call.label === pattern) return responder;
|
|
81
|
+
try {
|
|
82
|
+
if (new RegExp(pattern).test(call.prompt)) return responder;
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
return fallback;
|
|
86
|
+
}
|
|
87
|
+
async *stream(req) {
|
|
88
|
+
const telemetry = req.providerOptions?.rulvar ?? {};
|
|
89
|
+
const call = {
|
|
90
|
+
prompt: lastUserText(req),
|
|
91
|
+
req,
|
|
92
|
+
...telemetry.agentType === void 0 || telemetry.agentType === "" ? {} : { agentType: telemetry.agentType },
|
|
93
|
+
...telemetry.label === void 0 ? {} : { label: telemetry.label }
|
|
94
|
+
};
|
|
95
|
+
this.calls.push(call);
|
|
96
|
+
const responder = this.match(call);
|
|
97
|
+
if (responder === void 0) {
|
|
98
|
+
yield {
|
|
99
|
+
type: "error",
|
|
100
|
+
error: {
|
|
101
|
+
code: "agent",
|
|
102
|
+
message: `FakeAdapter: no pattern matches agentType='${call.agentType ?? ""}' label='${call.label ?? ""}' prompt='${call.prompt.slice(0, 80)}'; add a '*' fallback`,
|
|
103
|
+
retryable: false,
|
|
104
|
+
data: { kind: "terminal" }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let value;
|
|
110
|
+
try {
|
|
111
|
+
value = typeof responder === "function" ? await responder(call) : responder;
|
|
112
|
+
} catch (thrown) {
|
|
113
|
+
yield {
|
|
114
|
+
type: "error",
|
|
115
|
+
error: {
|
|
116
|
+
code: "agent",
|
|
117
|
+
message: thrown instanceof Error ? thrown.message : String(thrown),
|
|
118
|
+
retryable: false,
|
|
119
|
+
data: { kind: "terminal" }
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (isFakeWireError(value)) {
|
|
125
|
+
yield {
|
|
126
|
+
type: "error",
|
|
127
|
+
error: value.error
|
|
128
|
+
};
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (isFakeToolCalls(value)) {
|
|
132
|
+
const usage = {
|
|
133
|
+
inputTokens: Math.max(1, Math.ceil(call.prompt.length / 4)),
|
|
134
|
+
outputTokens: Math.max(1, value.calls.length * 8),
|
|
135
|
+
cacheReadTokens: 0,
|
|
136
|
+
cacheWriteTokens: 0
|
|
137
|
+
};
|
|
138
|
+
for (const toolCall of value.calls) {
|
|
139
|
+
const id = this.mintId();
|
|
140
|
+
yield {
|
|
141
|
+
type: "tool-call-start",
|
|
142
|
+
id,
|
|
143
|
+
name: toolCall.name
|
|
144
|
+
};
|
|
145
|
+
yield {
|
|
146
|
+
type: "tool-call-end",
|
|
147
|
+
id,
|
|
148
|
+
args: toolCall.args
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
yield {
|
|
152
|
+
type: "finish",
|
|
153
|
+
finish: { reason: "tool-calls" },
|
|
154
|
+
usage
|
|
155
|
+
};
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
159
|
+
const usage = {
|
|
160
|
+
inputTokens: Math.max(1, Math.ceil(call.prompt.length / 4)),
|
|
161
|
+
outputTokens: Math.max(1, Math.ceil(text.length / 4)),
|
|
162
|
+
cacheReadTokens: 0,
|
|
163
|
+
cacheWriteTokens: 0
|
|
164
|
+
};
|
|
165
|
+
const forcedName = typeof req.toolChoice === "object" ? req.toolChoice.name : void 0;
|
|
166
|
+
if (forcedName !== void 0) {
|
|
167
|
+
let args = value;
|
|
168
|
+
if (typeof value === "string") try {
|
|
169
|
+
args = JSON.parse(value);
|
|
170
|
+
} catch {
|
|
171
|
+
args = { text: value };
|
|
172
|
+
}
|
|
173
|
+
const id = this.mintId();
|
|
174
|
+
yield {
|
|
175
|
+
type: "tool-call-start",
|
|
176
|
+
id,
|
|
177
|
+
name: forcedName
|
|
178
|
+
};
|
|
179
|
+
yield {
|
|
180
|
+
type: "tool-call-end",
|
|
181
|
+
id,
|
|
182
|
+
args
|
|
183
|
+
};
|
|
184
|
+
yield {
|
|
185
|
+
type: "finish",
|
|
186
|
+
finish: { reason: "tool-calls" },
|
|
187
|
+
usage
|
|
188
|
+
};
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
yield {
|
|
192
|
+
type: "text-delta",
|
|
193
|
+
text
|
|
194
|
+
};
|
|
195
|
+
yield {
|
|
196
|
+
type: "finish",
|
|
197
|
+
finish: { reason: "stop" },
|
|
198
|
+
usage
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/test-engine.ts
|
|
204
|
+
/**
|
|
205
|
+
* createTestEngine (M1-T14): a full engine over FakeAdapter with zero
|
|
206
|
+
* network. Orchestration logic is exercised, not mocked around: journal,
|
|
207
|
+
* scheduler, budget layers, and event stream are all real (docs/09,
|
|
208
|
+
* section "Tier 1"). Returned handles record their event stream so the
|
|
209
|
+
* matchers can assert over settled runs.
|
|
210
|
+
*/
|
|
211
|
+
function createTestEngine(options) {
|
|
212
|
+
const fake = new FakeAdapter({ agents: options.agents });
|
|
213
|
+
const store = new InMemoryStore();
|
|
214
|
+
const profiles = { ...options.profiles };
|
|
215
|
+
for (const key of Object.keys(options.agents)) if (key !== "*" && profiles[key] === void 0) profiles[key] = {};
|
|
216
|
+
const engine = createEngine({
|
|
217
|
+
adapters: [fake],
|
|
218
|
+
stores: { journal: store },
|
|
219
|
+
defaults: {
|
|
220
|
+
routing: {
|
|
221
|
+
loop: FAKE_MODEL_REF,
|
|
222
|
+
extract: FAKE_MODEL_REF,
|
|
223
|
+
orchestrate: FAKE_MODEL_REF,
|
|
224
|
+
plan: FAKE_MODEL_REF,
|
|
225
|
+
summarize: FAKE_MODEL_REF
|
|
226
|
+
},
|
|
227
|
+
profiles
|
|
228
|
+
},
|
|
229
|
+
...options.budgetDefaults === void 0 ? {} : { budgetDefaults: options.budgetDefaults },
|
|
230
|
+
...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
fake,
|
|
234
|
+
store,
|
|
235
|
+
stores: engine.stores,
|
|
236
|
+
resume: (runId, wf, options) => engine.resume(runId, wf, options),
|
|
237
|
+
deleteRun: (runId) => engine.deleteRun(runId),
|
|
238
|
+
pruneRun: (runId) => engine.pruneRun(runId),
|
|
239
|
+
profileCard: (names) => engine.profileCard(names),
|
|
240
|
+
run(wf, args, opts) {
|
|
241
|
+
const handle = engine.run(wf, args, opts);
|
|
242
|
+
const eventsSeen = [];
|
|
243
|
+
(async () => {
|
|
244
|
+
for await (const event of handle.events) eventsSeen.push(event);
|
|
245
|
+
})().catch(() => void 0);
|
|
246
|
+
return {
|
|
247
|
+
...handle,
|
|
248
|
+
eventsSeen
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/replay-strict.ts
|
|
255
|
+
/**
|
|
256
|
+
* Tier 3: replay-strict journal runs (M2-T10). Executes a workflow
|
|
257
|
+
* against an existing journal and throws JournalMissError on ANY live
|
|
258
|
+
* call: zero live calls or loud failure. Any production journal becomes a
|
|
259
|
+
* deterministic integration test; a journal with open suspensions
|
|
260
|
+
* completes with outcome 'suspended' and zero live calls (docs/09,
|
|
261
|
+
* section "Tier 3: replay-strict journal runs").
|
|
262
|
+
*/
|
|
263
|
+
const FAKE_ROUTING = {
|
|
264
|
+
loop: FAKE_MODEL_REF,
|
|
265
|
+
extract: FAKE_MODEL_REF,
|
|
266
|
+
orchestrate: FAKE_MODEL_REF,
|
|
267
|
+
plan: FAKE_MODEL_REF,
|
|
268
|
+
finalize: FAKE_MODEL_REF,
|
|
269
|
+
summarize: FAKE_MODEL_REF
|
|
270
|
+
};
|
|
271
|
+
async function replayRun(wf, args, options) {
|
|
272
|
+
let store;
|
|
273
|
+
let runId;
|
|
274
|
+
if (Array.isArray(options.journal)) {
|
|
275
|
+
runId = options.journal[0] !== void 0 ? "replay-run" : "replay-empty";
|
|
276
|
+
const memory = new InMemoryStore();
|
|
277
|
+
for (const entry of options.journal) await memory.append(runId, entry);
|
|
278
|
+
await memory.putMeta({
|
|
279
|
+
runId,
|
|
280
|
+
status: "suspended",
|
|
281
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
282
|
+
workflowName: wf.name,
|
|
283
|
+
workflowHash: hashWorkflowBody(wf)
|
|
284
|
+
});
|
|
285
|
+
store = memory;
|
|
286
|
+
} else {
|
|
287
|
+
store = options.journal.store;
|
|
288
|
+
runId = options.journal.runId;
|
|
289
|
+
}
|
|
290
|
+
const handle = createEngine({
|
|
291
|
+
adapters: options.adapters ?? [new FakeAdapter({ agents: {} })],
|
|
292
|
+
stores: { journal: store },
|
|
293
|
+
defaults: {
|
|
294
|
+
routing: options.routing ?? FAKE_ROUTING,
|
|
295
|
+
...options.profiles === void 0 ? {} : { profiles: options.profiles }
|
|
296
|
+
},
|
|
297
|
+
...options.onEscalation === void 0 ? {} : { onEscalation: options.onEscalation }
|
|
298
|
+
}).resume(runId, wf, {
|
|
299
|
+
args,
|
|
300
|
+
dryRun: true
|
|
301
|
+
});
|
|
302
|
+
const outcome = await handle.result;
|
|
303
|
+
if (outcome.error?.code === "journal_miss") throw new JournalMissError(outcome.error.message, { data: outcome.error.data ?? null });
|
|
304
|
+
return {
|
|
305
|
+
outcome,
|
|
306
|
+
preview: await handle.preview
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/cassettes/build-fixtures.ts
|
|
311
|
+
/**
|
|
312
|
+
* M2 cassette and frozen-fixture builders (M2-T12). Fixtures are
|
|
313
|
+
* hand-authored journals with REAL content keys (derived through the
|
|
314
|
+
* frozen KeyDeriver profiles), deterministic timestamps, and fixed span
|
|
315
|
+
* ids, so regeneration is byte-stable.
|
|
316
|
+
*
|
|
317
|
+
* The COMMITTED files under repo cassettes/ and
|
|
318
|
+
* packages/testing/fixtures/frozen/ are the contract; these builders
|
|
319
|
+
* exist to regenerate them DELIBERATELY (scripts/record-m2-cassettes.mjs)
|
|
320
|
+
* and to fail loudly in CI when key derivation drifts (docs/11, section
|
|
321
|
+
* "Frozen journal fixtures": regenerating fixtures to make a test pass is
|
|
322
|
+
* forbidden by policy; any diff requires an explicit hashVersion-bump
|
|
323
|
+
* changeset).
|
|
324
|
+
*/
|
|
325
|
+
const BASE_MS$1 = Date.parse("2026-02-01T00:00:00.000Z");
|
|
326
|
+
const SPAN$1 = "fixture-span";
|
|
327
|
+
function stampOf$1(seq) {
|
|
328
|
+
return new Date(BASE_MS$1 + seq * 1e3).toISOString();
|
|
329
|
+
}
|
|
330
|
+
function usageOf(inputTokens, outputTokens) {
|
|
331
|
+
return {
|
|
332
|
+
inputTokens,
|
|
333
|
+
outputTokens,
|
|
334
|
+
cacheReadTokens: 0,
|
|
335
|
+
cacheWriteTokens: 0
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** The identity ctx.agent computes for a plain prompt against the fake model. */
|
|
339
|
+
function fakeAgentIdentity(prompt, effort) {
|
|
340
|
+
return {
|
|
341
|
+
kind: "agent",
|
|
342
|
+
agentType: "",
|
|
343
|
+
modelSpec: effort === void 0 ? {
|
|
344
|
+
kind: "model",
|
|
345
|
+
model: FAKE_MODEL_REF
|
|
346
|
+
} : {
|
|
347
|
+
kind: "model",
|
|
348
|
+
model: FAKE_MODEL_REF,
|
|
349
|
+
effort
|
|
350
|
+
},
|
|
351
|
+
prompt,
|
|
352
|
+
schemaHash: EMPTY_SCHEMA_HASH,
|
|
353
|
+
toolsetHash: EMPTY_TOOLSET_HASH,
|
|
354
|
+
isolation: "none"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const RING = registryKeyRing(buildDeriverRegistry());
|
|
358
|
+
/**
|
|
359
|
+
* Derives the content key an entry of `hashVersion` carries for this
|
|
360
|
+
* identity. The synthetic hashVersion 0 profile of @rulvar/compat shares
|
|
361
|
+
* the v1 projection by construction (deriverV0Synthetic =
|
|
362
|
+
* { ...deriverV1, hashVersion: 0 }), so v0 fixture keys derive through
|
|
363
|
+
* the v1 profile; synthetic FUTURE versions borrow the v2 derivation (the
|
|
364
|
+
* compatibility scan rejects them before any key is ever compared).
|
|
365
|
+
*/
|
|
366
|
+
function keyFor(identity, hashVersion) {
|
|
367
|
+
const effective = hashVersion === 0 ? 1 : hashVersion > 2 ? 2 : hashVersion;
|
|
368
|
+
const derived = RING.keyFor(identity, effective);
|
|
369
|
+
if (derived === "incomparable") throw new Error(`fixture identity is incomparable under hashVersion ${String(hashVersion)}`);
|
|
370
|
+
return derived.key;
|
|
371
|
+
}
|
|
372
|
+
/** Deterministic journal author: seq, stamps, and ordinals are derived. */
|
|
373
|
+
var FixtureJournal = class {
|
|
374
|
+
entries = [];
|
|
375
|
+
seq = 0;
|
|
376
|
+
ordinals = /* @__PURE__ */ new Map();
|
|
377
|
+
mint(partial) {
|
|
378
|
+
const seq = this.seq;
|
|
379
|
+
this.seq += 1;
|
|
380
|
+
const ordinalKey = `${partial.scope} ${partial.hashVersion} ${partial.key}`;
|
|
381
|
+
const ordinal = partial.ref === void 0 && partial.kind !== "resolution" && partial.kind !== "abandon" ? this.ordinals.get(ordinalKey) ?? 0 : 0;
|
|
382
|
+
if (partial.ref === void 0 && partial.kind !== "resolution" && partial.kind !== "abandon") this.ordinals.set(ordinalKey, ordinal + 1);
|
|
383
|
+
const entry = {
|
|
384
|
+
...partial,
|
|
385
|
+
seq,
|
|
386
|
+
ordinal,
|
|
387
|
+
spanId: SPAN$1,
|
|
388
|
+
startedAt: stampOf$1(seq)
|
|
389
|
+
};
|
|
390
|
+
this.entries.push(entry);
|
|
391
|
+
return entry;
|
|
392
|
+
}
|
|
393
|
+
/** Two-phase agent operation: running plus terminal. Returns the running entry. */
|
|
394
|
+
agentOp(input) {
|
|
395
|
+
const hashVersion = input.hashVersion ?? 2;
|
|
396
|
+
const identity = fakeAgentIdentity(input.prompt, input.effort);
|
|
397
|
+
const running = this.mint({
|
|
398
|
+
hashVersion,
|
|
399
|
+
scope: input.scope ?? "",
|
|
400
|
+
key: keyFor(identity, hashVersion),
|
|
401
|
+
kind: "agent",
|
|
402
|
+
status: "running",
|
|
403
|
+
...input.memoizeOutcome === void 0 ? {} : { memoizeOutcome: input.memoizeOutcome }
|
|
404
|
+
});
|
|
405
|
+
const terminal = this.mint({
|
|
406
|
+
hashVersion,
|
|
407
|
+
ref: running.seq,
|
|
408
|
+
scope: running.scope,
|
|
409
|
+
key: running.key,
|
|
410
|
+
kind: "agent",
|
|
411
|
+
status: input.status ?? "ok",
|
|
412
|
+
...input.value === void 0 ? {} : { value: input.value },
|
|
413
|
+
...input.error === void 0 ? {} : { error: input.error },
|
|
414
|
+
...input.usage === void 0 ? {} : { usage: input.usage },
|
|
415
|
+
servedBy: FAKE_MODEL_REF,
|
|
416
|
+
endedAt: stampOf$1(this.seq)
|
|
417
|
+
});
|
|
418
|
+
terminal.ordinal = running.ordinal;
|
|
419
|
+
return running;
|
|
420
|
+
}
|
|
421
|
+
/** A hanging two-phase dispatch (crash before the terminal). */
|
|
422
|
+
danglingAgent(input) {
|
|
423
|
+
const hashVersion = input.hashVersion ?? 2;
|
|
424
|
+
const identity = fakeAgentIdentity(input.prompt);
|
|
425
|
+
return this.mint({
|
|
426
|
+
hashVersion,
|
|
427
|
+
scope: input.scope ?? "",
|
|
428
|
+
key: keyFor(identity, hashVersion),
|
|
429
|
+
kind: "agent",
|
|
430
|
+
status: "running"
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
stepOp(input) {
|
|
434
|
+
const hashVersion = input.hashVersion ?? 2;
|
|
435
|
+
const identity = {
|
|
436
|
+
kind: "step",
|
|
437
|
+
key: input.label,
|
|
438
|
+
deps: []
|
|
439
|
+
};
|
|
440
|
+
const running = this.mint({
|
|
441
|
+
hashVersion,
|
|
442
|
+
scope: "",
|
|
443
|
+
key: keyFor(identity, hashVersion),
|
|
444
|
+
kind: "step",
|
|
445
|
+
status: "running"
|
|
446
|
+
});
|
|
447
|
+
const terminal = this.mint({
|
|
448
|
+
hashVersion,
|
|
449
|
+
ref: running.seq,
|
|
450
|
+
scope: "",
|
|
451
|
+
key: running.key,
|
|
452
|
+
kind: "step",
|
|
453
|
+
status: "ok",
|
|
454
|
+
value: input.value,
|
|
455
|
+
endedAt: stampOf$1(this.seq)
|
|
456
|
+
});
|
|
457
|
+
terminal.ordinal = running.ordinal;
|
|
458
|
+
return running;
|
|
459
|
+
}
|
|
460
|
+
randNow(value, hashVersion = 2) {
|
|
461
|
+
return this.mint({
|
|
462
|
+
hashVersion,
|
|
463
|
+
scope: "",
|
|
464
|
+
key: keyFor({
|
|
465
|
+
kind: "rand",
|
|
466
|
+
subtype: "now"
|
|
467
|
+
}, hashVersion),
|
|
468
|
+
kind: "rand",
|
|
469
|
+
status: "ok",
|
|
470
|
+
value: {
|
|
471
|
+
subtype: "now",
|
|
472
|
+
value
|
|
473
|
+
},
|
|
474
|
+
endedAt: stampOf$1(this.seq)
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
external(input) {
|
|
478
|
+
const hashVersion = input.hashVersion ?? 2;
|
|
479
|
+
const identity = {
|
|
480
|
+
kind: "external",
|
|
481
|
+
key: input.key
|
|
482
|
+
};
|
|
483
|
+
const payload = { key: input.key };
|
|
484
|
+
if (input.prompt !== void 0) payload.prompt = input.prompt;
|
|
485
|
+
if (input.schema !== void 0) payload.schema = canonicalizeSchema(projectToJsonSchema(input.schema));
|
|
486
|
+
return this.mint({
|
|
487
|
+
hashVersion,
|
|
488
|
+
scope: input.scope ?? "",
|
|
489
|
+
key: keyFor(identity, hashVersion),
|
|
490
|
+
kind: "external",
|
|
491
|
+
status: "suspended",
|
|
492
|
+
value: payload
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
approvalSuspended(input) {
|
|
496
|
+
const hashVersion = input.hashVersion ?? 2;
|
|
497
|
+
const identity = {
|
|
498
|
+
kind: "approval",
|
|
499
|
+
toolName: input.toolName,
|
|
500
|
+
input: input.toolInput
|
|
501
|
+
};
|
|
502
|
+
return this.mint({
|
|
503
|
+
hashVersion,
|
|
504
|
+
scope: "",
|
|
505
|
+
key: keyFor(identity, hashVersion),
|
|
506
|
+
kind: "approval",
|
|
507
|
+
status: "suspended",
|
|
508
|
+
value: {
|
|
509
|
+
toolName: input.toolName,
|
|
510
|
+
input: input.toolInput
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
resolution(input) {
|
|
515
|
+
const target = this.entries.find((entry) => entry.seq === input.target);
|
|
516
|
+
return this.mint({
|
|
517
|
+
hashVersion: 2,
|
|
518
|
+
ref: input.target,
|
|
519
|
+
scope: target?.scope ?? "",
|
|
520
|
+
key: "",
|
|
521
|
+
kind: "resolution",
|
|
522
|
+
status: "ok",
|
|
523
|
+
resolution: {
|
|
524
|
+
target: input.target,
|
|
525
|
+
by: input.by,
|
|
526
|
+
value: input.value,
|
|
527
|
+
...input.decisionRef === void 0 ? {} : { decisionRef: input.decisionRef }
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
abandon(input) {
|
|
532
|
+
const target = this.entries.find((entry) => entry.seq === input.target);
|
|
533
|
+
const payload = {
|
|
534
|
+
target: input.target,
|
|
535
|
+
authorizedBy: input.authorizedBy,
|
|
536
|
+
reason: input.reason,
|
|
537
|
+
retainCheckpoint: true,
|
|
538
|
+
retainWorktree: false
|
|
539
|
+
};
|
|
540
|
+
return this.mint({
|
|
541
|
+
hashVersion: 2,
|
|
542
|
+
ref: input.target,
|
|
543
|
+
scope: target?.scope ?? "",
|
|
544
|
+
key: "",
|
|
545
|
+
kind: "abandon",
|
|
546
|
+
status: "ok",
|
|
547
|
+
abandon: payload
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
decision(input) {
|
|
551
|
+
return this.mint({
|
|
552
|
+
hashVersion: 2,
|
|
553
|
+
scope: "",
|
|
554
|
+
key: deriveContentKey({
|
|
555
|
+
kind: "step",
|
|
556
|
+
key: `decision:${input.decisionType}`,
|
|
557
|
+
deps: []
|
|
558
|
+
}),
|
|
559
|
+
kind: "decision",
|
|
560
|
+
status: "ok",
|
|
561
|
+
value: {
|
|
562
|
+
decisionType: input.decisionType,
|
|
563
|
+
...input.payload
|
|
564
|
+
},
|
|
565
|
+
endedAt: stampOf$1(this.seq)
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
/** Shared schemas (canonical projections are pinned inside the fixtures). */
|
|
570
|
+
const APPROVED_SCHEMA = {
|
|
571
|
+
type: "object",
|
|
572
|
+
additionalProperties: false,
|
|
573
|
+
required: ["approved"],
|
|
574
|
+
properties: { approved: { type: "boolean" } }
|
|
575
|
+
};
|
|
576
|
+
const DECISION_SCHEMA = {
|
|
577
|
+
type: "object",
|
|
578
|
+
additionalProperties: false,
|
|
579
|
+
required: ["decision"],
|
|
580
|
+
properties: { decision: { type: "string" } }
|
|
581
|
+
};
|
|
582
|
+
const GO_SCHEMA = {
|
|
583
|
+
type: "object",
|
|
584
|
+
additionalProperties: false,
|
|
585
|
+
required: ["go"],
|
|
586
|
+
properties: { go: { type: "boolean" } }
|
|
587
|
+
};
|
|
588
|
+
/** Prompts shared between fixture entries and cassette workflow bodies. */
|
|
589
|
+
const PROMPTS = {
|
|
590
|
+
branchWork: "branch work",
|
|
591
|
+
childOk: "child ok",
|
|
592
|
+
childEscalated: "child escalated",
|
|
593
|
+
childHanging: "child hanging",
|
|
594
|
+
classify: "classify the document",
|
|
595
|
+
summarize: "summarize the document",
|
|
596
|
+
alpha: "alpha stage",
|
|
597
|
+
beta: "beta stage",
|
|
598
|
+
gamma: "gamma stage",
|
|
599
|
+
analyze: "analyze the incident",
|
|
600
|
+
reviseReport: "revise the report",
|
|
601
|
+
childResearch: "child research",
|
|
602
|
+
childDraft: "child draft",
|
|
603
|
+
revisionEffects: "apply the revision effects",
|
|
604
|
+
branchAlpha: "branch alpha",
|
|
605
|
+
subtreeAlpha: "subtree alpha",
|
|
606
|
+
innerAlpha: "inner alpha work",
|
|
607
|
+
v0Relic: "v0 relic stage",
|
|
608
|
+
futureStage: "future stage",
|
|
609
|
+
draftSummary: "draft the summary",
|
|
610
|
+
polishIntro: "polish the intro",
|
|
611
|
+
sharedStage: "shared stage",
|
|
612
|
+
crossCheck: "cross-check the citations",
|
|
613
|
+
assessTone: "assess the tone"
|
|
614
|
+
};
|
|
615
|
+
function buildM2CassetteFixtures() {
|
|
616
|
+
const fixtures = [];
|
|
617
|
+
{
|
|
618
|
+
const j = new FixtureJournal();
|
|
619
|
+
j.agentOp({
|
|
620
|
+
prompt: PROMPTS.alpha,
|
|
621
|
+
hashVersion: 1,
|
|
622
|
+
value: "alpha out",
|
|
623
|
+
usage: usageOf(100, 10)
|
|
624
|
+
});
|
|
625
|
+
j.agentOp({
|
|
626
|
+
prompt: PROMPTS.beta,
|
|
627
|
+
hashVersion: 1,
|
|
628
|
+
status: "error",
|
|
629
|
+
error: {
|
|
630
|
+
code: "agent",
|
|
631
|
+
message: "upstream disconnected",
|
|
632
|
+
retryable: true,
|
|
633
|
+
data: { kind: "transport" }
|
|
634
|
+
},
|
|
635
|
+
usage: usageOf(10, 0)
|
|
636
|
+
});
|
|
637
|
+
j.agentOp({
|
|
638
|
+
prompt: PROMPTS.gamma,
|
|
639
|
+
hashVersion: 1,
|
|
640
|
+
status: "cancelled",
|
|
641
|
+
usage: usageOf(5, 0)
|
|
642
|
+
});
|
|
643
|
+
fixtures.push({
|
|
644
|
+
id: "v1-journal-on-v2",
|
|
645
|
+
note: "DEF-1: a journal without the new statuses and kinds resumes on the v2 engine; ok replays, error and cancelled rerun, byte-identical to the round-1 table.",
|
|
646
|
+
entries: j.entries
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
{
|
|
650
|
+
const j = new FixtureJournal();
|
|
651
|
+
j.agentOp({
|
|
652
|
+
prompt: PROMPTS.analyze,
|
|
653
|
+
value: "analysis: rollback recommended",
|
|
654
|
+
usage: usageOf(120, 30)
|
|
655
|
+
});
|
|
656
|
+
const gate = j.external({
|
|
657
|
+
key: "escalation-report",
|
|
658
|
+
schema: DECISION_SCHEMA,
|
|
659
|
+
prompt: "Escalation decision required"
|
|
660
|
+
});
|
|
661
|
+
j.resolution({
|
|
662
|
+
target: gate.seq,
|
|
663
|
+
by: "external",
|
|
664
|
+
value: { decision: "rollback" }
|
|
665
|
+
});
|
|
666
|
+
j.resolution({
|
|
667
|
+
target: gate.seq,
|
|
668
|
+
by: "timeout",
|
|
669
|
+
value: { decision: "proceed-default" }
|
|
670
|
+
});
|
|
671
|
+
fixtures.push({
|
|
672
|
+
id: "timeout-vs-live-race",
|
|
673
|
+
note: "DEF-4: the live decision wins in journal order; the timeout attempt lands as a journaled noop whose effects are never re-issued.",
|
|
674
|
+
entries: j.entries
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
{
|
|
678
|
+
const j = new FixtureJournal();
|
|
679
|
+
const r1 = j.external({ key: "report-1" });
|
|
680
|
+
const r2 = j.external({ key: "report-2" });
|
|
681
|
+
const r3 = j.external({ key: "report-3" });
|
|
682
|
+
j.resolution({
|
|
683
|
+
target: r2.seq,
|
|
684
|
+
by: "operator",
|
|
685
|
+
value: { action: "retry" }
|
|
686
|
+
});
|
|
687
|
+
const dec = j.decision({
|
|
688
|
+
decisionType: "escalation-class",
|
|
689
|
+
payload: {
|
|
690
|
+
action: "retry",
|
|
691
|
+
appliesTo: [
|
|
692
|
+
r1.seq,
|
|
693
|
+
r2.seq,
|
|
694
|
+
r3.seq
|
|
695
|
+
]
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
j.resolution({
|
|
699
|
+
target: r1.seq,
|
|
700
|
+
by: "class_decision",
|
|
701
|
+
decisionRef: dec.seq,
|
|
702
|
+
value: { action: "retry" }
|
|
703
|
+
});
|
|
704
|
+
j.resolution({
|
|
705
|
+
target: r2.seq,
|
|
706
|
+
by: "class_decision",
|
|
707
|
+
decisionRef: dec.seq,
|
|
708
|
+
value: { action: "retry" }
|
|
709
|
+
});
|
|
710
|
+
j.resolution({
|
|
711
|
+
target: r3.seq,
|
|
712
|
+
by: "class_decision",
|
|
713
|
+
decisionRef: dec.seq,
|
|
714
|
+
value: { action: "retry" }
|
|
715
|
+
});
|
|
716
|
+
fixtures.push({
|
|
717
|
+
id: "class-decision-fanout",
|
|
718
|
+
note: "DEF-4: a class-level decision closes three suspended reports, one already closed individually: two applied, one noop with decisionRef preserved. The decision fact itself gains a live consumer in M4.",
|
|
719
|
+
entries: j.entries
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
{
|
|
723
|
+
const j = new FixtureJournal();
|
|
724
|
+
const spawn = j.danglingAgent({ prompt: PROMPTS.reviseReport });
|
|
725
|
+
const subtree = agentScope("", spawn.seq);
|
|
726
|
+
j.agentOp({
|
|
727
|
+
prompt: PROMPTS.childResearch,
|
|
728
|
+
scope: subtree,
|
|
729
|
+
value: "notes",
|
|
730
|
+
usage: usageOf(80, 15)
|
|
731
|
+
});
|
|
732
|
+
j.agentOp({
|
|
733
|
+
prompt: PROMPTS.childDraft,
|
|
734
|
+
scope: subtree,
|
|
735
|
+
value: "draft",
|
|
736
|
+
usage: usageOf(90, 25)
|
|
737
|
+
});
|
|
738
|
+
j.abandon({
|
|
739
|
+
target: spawn.seq,
|
|
740
|
+
authorizedBy: spawn.seq,
|
|
741
|
+
reason: "plan revision: cancel_task"
|
|
742
|
+
});
|
|
743
|
+
fixtures.push({
|
|
744
|
+
id: "abandon-then-crash-then-resume",
|
|
745
|
+
note: "DEF-4: crash strictly after the abandon and before any effects; resume derives skipped for the whole subtree (skipped, never orphaned) and re-issues only the revision effects.",
|
|
746
|
+
entries: j.entries
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
{
|
|
750
|
+
const j = new FixtureJournal();
|
|
751
|
+
const spawnA = j.danglingAgent({ prompt: PROMPTS.branchAlpha });
|
|
752
|
+
const suspA = j.external({
|
|
753
|
+
key: "alpha-gate",
|
|
754
|
+
scope: agentScope("", spawnA.seq)
|
|
755
|
+
});
|
|
756
|
+
j.abandon({
|
|
757
|
+
target: spawnA.seq,
|
|
758
|
+
authorizedBy: spawnA.seq,
|
|
759
|
+
reason: "cancelled"
|
|
760
|
+
});
|
|
761
|
+
j.resolution({
|
|
762
|
+
target: suspA.seq,
|
|
763
|
+
by: "external",
|
|
764
|
+
value: { go: true }
|
|
765
|
+
});
|
|
766
|
+
const suspB = j.external({
|
|
767
|
+
key: "beta-gate",
|
|
768
|
+
schema: GO_SCHEMA
|
|
769
|
+
});
|
|
770
|
+
j.resolution({
|
|
771
|
+
target: suspB.seq,
|
|
772
|
+
by: "external",
|
|
773
|
+
value: { go: true }
|
|
774
|
+
});
|
|
775
|
+
j.abandon({
|
|
776
|
+
target: suspB.seq,
|
|
777
|
+
authorizedBy: spawnA.seq,
|
|
778
|
+
reason: "late cancel"
|
|
779
|
+
});
|
|
780
|
+
fixtures.push({
|
|
781
|
+
id: "abandon-vs-resolution-race",
|
|
782
|
+
note: "DEF-4: a resolution after a covering abandon is a noop (target_abandoned); the reverse order yields an applied resolution and a noop abandon (already_resolved).",
|
|
783
|
+
entries: j.entries
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
{
|
|
787
|
+
const j = new FixtureJournal();
|
|
788
|
+
const gate = j.external({
|
|
789
|
+
key: "deploy-approval",
|
|
790
|
+
schema: APPROVED_SCHEMA,
|
|
791
|
+
prompt: "Approve the deployment?"
|
|
792
|
+
});
|
|
793
|
+
j.resolution({
|
|
794
|
+
target: gate.seq,
|
|
795
|
+
by: "external",
|
|
796
|
+
value: { approved: "yes" }
|
|
797
|
+
});
|
|
798
|
+
j.resolution({
|
|
799
|
+
target: gate.seq,
|
|
800
|
+
by: "operator",
|
|
801
|
+
value: { approved: true }
|
|
802
|
+
});
|
|
803
|
+
fixtures.push({
|
|
804
|
+
id: "offline-invalid-then-valid",
|
|
805
|
+
note: "DEF-4: the schema-invalid offline resolution classifies invalid and never closes; the valid one applies; resume consumes the valid value deterministically.",
|
|
806
|
+
entries: j.entries
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
{
|
|
810
|
+
const j = new FixtureJournal();
|
|
811
|
+
const spawn = j.agentOp({
|
|
812
|
+
prompt: PROMPTS.subtreeAlpha,
|
|
813
|
+
value: "alpha done",
|
|
814
|
+
usage: usageOf(150, 30)
|
|
815
|
+
});
|
|
816
|
+
const inner = j.agentOp({
|
|
817
|
+
prompt: PROMPTS.innerAlpha,
|
|
818
|
+
scope: agentScope("", spawn.seq),
|
|
819
|
+
value: "inner out",
|
|
820
|
+
usage: usageOf(70, 10)
|
|
821
|
+
});
|
|
822
|
+
j.abandon({
|
|
823
|
+
target: spawn.seq,
|
|
824
|
+
authorizedBy: spawn.seq,
|
|
825
|
+
reason: "first revision"
|
|
826
|
+
});
|
|
827
|
+
j.abandon({
|
|
828
|
+
target: inner.seq,
|
|
829
|
+
authorizedBy: spawn.seq,
|
|
830
|
+
reason: "second revision overlaps"
|
|
831
|
+
});
|
|
832
|
+
fixtures.push({
|
|
833
|
+
id: "double-abandon-idempotent",
|
|
834
|
+
note: "DEF-4: the second abandon over an already-covered target folds to noop; abandon beats the terminal ok status; live and replayed states identical, no repayment.",
|
|
835
|
+
entries: j.entries
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
{
|
|
839
|
+
const j = new FixtureJournal();
|
|
840
|
+
j.agentOp({
|
|
841
|
+
prompt: PROMPTS.v0Relic,
|
|
842
|
+
hashVersion: 0,
|
|
843
|
+
value: "relic out",
|
|
844
|
+
usage: usageOf(40, 5)
|
|
845
|
+
});
|
|
846
|
+
fixtures.push({
|
|
847
|
+
id: "reject-version-too-old",
|
|
848
|
+
note: "DEF-6: hashVersion 0 sits outside the [1,2] window: JournalCompatibilityError HASH_VERSION_TOO_OLD with zero side effects; deriverV0Synthetic from @rulvar/compat reopens the window via extraDerivers.",
|
|
849
|
+
entries: j.entries
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
{
|
|
853
|
+
const j = new FixtureJournal();
|
|
854
|
+
j.agentOp({
|
|
855
|
+
prompt: PROMPTS.futureStage,
|
|
856
|
+
hashVersion: 3,
|
|
857
|
+
value: "future out",
|
|
858
|
+
usage: usageOf(40, 5)
|
|
859
|
+
});
|
|
860
|
+
fixtures.push({
|
|
861
|
+
id: "reject-version-from-future",
|
|
862
|
+
note: "DEF-6: a hashVersion 3 entry on the v2 engine: HASH_VERSION_TOO_NEW at load with zero side effects; the lease-acquire repetition of the scan re-records with queue mode in M5.",
|
|
863
|
+
entries: j.entries
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
return fixtures;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The frozen v1 journal (docs/11, section "Frozen journal fixtures"): a
|
|
870
|
+
* round-1 JSONL file with kinds agent, step, rand, external, approval and
|
|
871
|
+
* the legacy `v: 1` field (no hashVersion member). Returned as raw
|
|
872
|
+
* JSON-ready objects, one per line.
|
|
873
|
+
*/
|
|
874
|
+
function buildFrozenV1JournalRaw() {
|
|
875
|
+
const j = new FixtureJournal();
|
|
876
|
+
j.agentOp({
|
|
877
|
+
prompt: PROMPTS.draftSummary,
|
|
878
|
+
hashVersion: 1,
|
|
879
|
+
value: "summary text, first draft",
|
|
880
|
+
usage: usageOf(220, 80)
|
|
881
|
+
});
|
|
882
|
+
j.stepOp({
|
|
883
|
+
label: "persist-draft",
|
|
884
|
+
value: {
|
|
885
|
+
written: true,
|
|
886
|
+
path: "drafts/summary.md"
|
|
887
|
+
},
|
|
888
|
+
hashVersion: 1
|
|
889
|
+
});
|
|
890
|
+
j.randNow(17067456e5, 1);
|
|
891
|
+
j.external({
|
|
892
|
+
key: "editor-approval",
|
|
893
|
+
schema: APPROVED_SCHEMA,
|
|
894
|
+
prompt: "Approve the draft?",
|
|
895
|
+
hashVersion: 1
|
|
896
|
+
});
|
|
897
|
+
j.approvalSuspended({
|
|
898
|
+
toolName: "publish",
|
|
899
|
+
toolInput: { channel: "blog" },
|
|
900
|
+
hashVersion: 1
|
|
901
|
+
});
|
|
902
|
+
j.agentOp({
|
|
903
|
+
prompt: PROMPTS.polishIntro,
|
|
904
|
+
hashVersion: 1,
|
|
905
|
+
value: "intro pass one",
|
|
906
|
+
usage: usageOf(60, 12)
|
|
907
|
+
});
|
|
908
|
+
j.agentOp({
|
|
909
|
+
prompt: PROMPTS.polishIntro,
|
|
910
|
+
hashVersion: 1,
|
|
911
|
+
value: "intro pass two",
|
|
912
|
+
usage: usageOf(61, 13)
|
|
913
|
+
});
|
|
914
|
+
return j.entries.map((entry) => {
|
|
915
|
+
const { hashVersion: _hashVersion, ...rest } = entry;
|
|
916
|
+
return {
|
|
917
|
+
v: 1,
|
|
918
|
+
...rest
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
/** The docs/03 section 1.5 worked example, frozen as executable data. */
|
|
923
|
+
const WORKED_EXAMPLE_INPUT = {
|
|
924
|
+
kind: "agent",
|
|
925
|
+
agentType: "reviewer",
|
|
926
|
+
modelSpec: {
|
|
927
|
+
kind: "model",
|
|
928
|
+
model: "anthropic:claude-sonnet-4",
|
|
929
|
+
effort: "high"
|
|
930
|
+
},
|
|
931
|
+
prompt: "Review the attached diff for correctness.",
|
|
932
|
+
schemaHash: "f1342f68c9dbb49e8056d0414479659414776dfa4c599b3bebd166c8fdc416ba",
|
|
933
|
+
toolsetHash: "d2c59d7e8cb64de34366877e8764eab84d615942f14167d8715a15d8dbff105c",
|
|
934
|
+
isolation: "none"
|
|
935
|
+
};
|
|
936
|
+
/**
|
|
937
|
+
* v2 golden identity fixtures: worked examples per spawn kind (M2-T12).
|
|
938
|
+
* The keys freeze the hashVersion 2 profile; the v1 members freeze the
|
|
939
|
+
* effort-insensitive projection and the incomparable domain.
|
|
940
|
+
*/
|
|
941
|
+
function buildV2GoldenIdentity() {
|
|
942
|
+
return {
|
|
943
|
+
note: "FROZEN v2 identity contract (DEF-6). Any diff requires a hashVersion-bump changeset.",
|
|
944
|
+
workedExampleKey: "66ef15922e576a8f6884b28176c8c21fee9b4d3bb98c76592ed6ca1d3c8f1062",
|
|
945
|
+
emptySchemaHash: EMPTY_SCHEMA_HASH,
|
|
946
|
+
emptyToolsetHash: EMPTY_TOOLSET_HASH,
|
|
947
|
+
perKind: [
|
|
948
|
+
{
|
|
949
|
+
name: "agent (docs/03 1.5 worked example)",
|
|
950
|
+
input: WORKED_EXAMPLE_INPUT
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
name: "agent (fake model, no effort)",
|
|
954
|
+
input: fakeAgentIdentity(PROMPTS.draftSummary)
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
name: "child",
|
|
958
|
+
input: {
|
|
959
|
+
kind: "child",
|
|
960
|
+
workflow: "sub-flow",
|
|
961
|
+
args: { topic: "rulvar" }
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
name: "step",
|
|
966
|
+
input: {
|
|
967
|
+
kind: "step",
|
|
968
|
+
key: "persist-draft",
|
|
969
|
+
deps: []
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: "step (deps)",
|
|
974
|
+
input: {
|
|
975
|
+
kind: "step",
|
|
976
|
+
key: "fetch",
|
|
977
|
+
deps: [{ page: 2 }]
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
name: "external",
|
|
982
|
+
input: {
|
|
983
|
+
kind: "external",
|
|
984
|
+
key: "editor-approval"
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
name: "approval",
|
|
989
|
+
input: {
|
|
990
|
+
kind: "approval",
|
|
991
|
+
toolName: "publish",
|
|
992
|
+
input: { channel: "blog" }
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
name: "rand now",
|
|
997
|
+
input: {
|
|
998
|
+
kind: "rand",
|
|
999
|
+
subtype: "now"
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
name: "rand keyed",
|
|
1004
|
+
input: {
|
|
1005
|
+
kind: "rand",
|
|
1006
|
+
subtype: "random",
|
|
1007
|
+
key: "jitter"
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
].map((example) => ({
|
|
1011
|
+
name: example.name,
|
|
1012
|
+
input: example.input,
|
|
1013
|
+
key: deriveContentKey(example.input)
|
|
1014
|
+
})),
|
|
1015
|
+
v1: {
|
|
1016
|
+
agentEffortInsensitive: {
|
|
1017
|
+
withEffort: keyFor(fakeAgentIdentity(PROMPTS.draftSummary, "high"), 1),
|
|
1018
|
+
withoutEffort: keyFor(fakeAgentIdentity(PROMPTS.draftSummary), 1)
|
|
1019
|
+
},
|
|
1020
|
+
incomparableKinds: [
|
|
1021
|
+
"decision",
|
|
1022
|
+
"plan.revision",
|
|
1023
|
+
"plan.decision",
|
|
1024
|
+
"ledger.op",
|
|
1025
|
+
"node.link",
|
|
1026
|
+
"termination.init",
|
|
1027
|
+
"termination.denied"
|
|
1028
|
+
]
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/cassettes/record-live.ts
|
|
1034
|
+
/**
|
|
1035
|
+
* Live-recorded cassettes (M3-T11): the DEF-1 live set (escalate-replay,
|
|
1036
|
+
* crash-between-report-and-decision, flavor-b-timeout) plus the
|
|
1037
|
+
* re-recorded M2 synthetic DEF-1 subset (abandon-subtree,
|
|
1038
|
+
* memoize-classifier), produced through the REAL runtime: engine runs
|
|
1039
|
+
* over FakeAdapter for everything with a live producer, kernel write
|
|
1040
|
+
* APIs (Replayer) for the orchestrator-subtree shape whose spawning
|
|
1041
|
+
* producer arrives with mode (c) in M6/M7. Timestamps and span ids are
|
|
1042
|
+
* normalized deterministically after recording (matching never reads
|
|
1043
|
+
* them); regeneration is DELIBERATE per the frozen-fixture policy
|
|
1044
|
+
* (docs/11).
|
|
1045
|
+
*/
|
|
1046
|
+
const BASE_MS = Date.parse("2026-02-01T00:00:00.000Z");
|
|
1047
|
+
const SPAN = "fixture-span";
|
|
1048
|
+
function stampOf(seq) {
|
|
1049
|
+
return new Date(BASE_MS + seq * 1e3).toISOString();
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Wall-clock and span normalization: matching consumes neither, and
|
|
1053
|
+
* frozen bytes must not depend on the recording machine.
|
|
1054
|
+
*/
|
|
1055
|
+
function normalizeEntries(entries) {
|
|
1056
|
+
return entries.map((entry) => ({
|
|
1057
|
+
...entry,
|
|
1058
|
+
spanId: SPAN,
|
|
1059
|
+
startedAt: stampOf(entry.seq),
|
|
1060
|
+
...entry.endedAt === void 0 ? {} : { endedAt: stampOf(entry.seq) },
|
|
1061
|
+
...entry.deadlineAt === void 0 ? {} : { deadlineAt: new Date(BASE_MS + entry.seq * 1e3 + 6e4).toISOString() }
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
/** The deterministic escalate request every recording uses. */
|
|
1065
|
+
const RECORDED_ESCALATE_ARGS = {
|
|
1066
|
+
kind: "scope_bigger",
|
|
1067
|
+
scopeDelta: "the billing migration spans nine services, not one",
|
|
1068
|
+
revisedEstimate: {
|
|
1069
|
+
usd: 40,
|
|
1070
|
+
turns: 90
|
|
1071
|
+
},
|
|
1072
|
+
blockers: ["schema ownership is unclear"]
|
|
1073
|
+
};
|
|
1074
|
+
const ESCALATE_REPLAY_PROMPTS = {
|
|
1075
|
+
work: "migrate the billing system",
|
|
1076
|
+
retry: "retry: split the billing migration by service"
|
|
1077
|
+
};
|
|
1078
|
+
/** The workflow of the escalate-replay cassette; tests import it. */
|
|
1079
|
+
function escalateReplayWorkflow() {
|
|
1080
|
+
return defineWorkflow({ name: "escalate-replay" }, async (ctx) => {
|
|
1081
|
+
try {
|
|
1082
|
+
await ctx.agent(ESCALATE_REPLAY_PROMPTS.work, { escalation: {} });
|
|
1083
|
+
return "no escalation";
|
|
1084
|
+
} catch {
|
|
1085
|
+
return await ctx.agent(ESCALATE_REPLAY_PROMPTS.retry);
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const CRASH_BETWEEN_PROMPT = "stabilize the flaky import pipeline";
|
|
1090
|
+
/** Phase 1 (recorded): the report lands, the process dies before any decision. */
|
|
1091
|
+
function crashBetweenPhase1Workflow() {
|
|
1092
|
+
return defineWorkflow({ name: "crash-between-report-and-decision" }, async (ctx) => {
|
|
1093
|
+
await ctx.agent(CRASH_BETWEEN_PROMPT, {
|
|
1094
|
+
escalation: {},
|
|
1095
|
+
result: "full"
|
|
1096
|
+
});
|
|
1097
|
+
return "reported";
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const FLAVOR_B_PROMPT = "reconcile the ledger discrepancies";
|
|
1101
|
+
const FLAVOR_B_OPTIONS = {
|
|
1102
|
+
flavor: "B",
|
|
1103
|
+
deadlineMs: 25,
|
|
1104
|
+
defaultDecision: {
|
|
1105
|
+
kind: "cancel",
|
|
1106
|
+
reason: "nobody decided before the deadline"
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
/** The workflow of the flavor-b-timeout cassette; tests import it. */
|
|
1110
|
+
function flavorBTimeoutWorkflow() {
|
|
1111
|
+
return defineWorkflow({ name: "flavor-b-timeout" }, async (ctx) => {
|
|
1112
|
+
return (await ctx.agent(FLAVOR_B_PROMPT, {
|
|
1113
|
+
escalation: FLAVOR_B_OPTIONS,
|
|
1114
|
+
result: "full"
|
|
1115
|
+
})).status;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
const CLASSIFY_SCHEMA = {
|
|
1119
|
+
type: "object",
|
|
1120
|
+
additionalProperties: false,
|
|
1121
|
+
required: ["label"],
|
|
1122
|
+
properties: { label: { type: "string" } }
|
|
1123
|
+
};
|
|
1124
|
+
/** The workflow of the re-recorded memoize-classifier cassette. */
|
|
1125
|
+
function memoizeClassifierWorkflow() {
|
|
1126
|
+
return defineWorkflow({ name: "memoize-classifier" }, async (ctx) => {
|
|
1127
|
+
await ctx.agent(PROMPTS.classify, {
|
|
1128
|
+
schema: CLASSIFY_SCHEMA,
|
|
1129
|
+
memoizeOutcome: true,
|
|
1130
|
+
result: "full"
|
|
1131
|
+
});
|
|
1132
|
+
await ctx.agent(PROMPTS.summarize, {
|
|
1133
|
+
memoizeOutcome: true,
|
|
1134
|
+
result: "full"
|
|
1135
|
+
});
|
|
1136
|
+
return "classified";
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
async function recordEngineRun(options) {
|
|
1140
|
+
const journal = new InMemoryStore();
|
|
1141
|
+
const outcome = await createEngine({
|
|
1142
|
+
adapters: [new FakeAdapter({ agents: options.agents })],
|
|
1143
|
+
stores: { journal },
|
|
1144
|
+
defaults: { routing: {
|
|
1145
|
+
loop: FAKE_MODEL_REF,
|
|
1146
|
+
extract: FAKE_MODEL_REF
|
|
1147
|
+
} },
|
|
1148
|
+
...options.onEscalation === void 0 ? {} : { onEscalation: options.onEscalation }
|
|
1149
|
+
}).run(options.workflow, void 0, { runId: "record" }).result;
|
|
1150
|
+
if (outcome.status !== "ok") throw new Error(`cassette recording run ended '${outcome.status}': ${outcome.error?.message ?? ""}`);
|
|
1151
|
+
return normalizeEntries(await journal.load("record"));
|
|
1152
|
+
}
|
|
1153
|
+
/** A realistic validated report for the kernel-recorded escalated child. */
|
|
1154
|
+
function subtreeChildReport() {
|
|
1155
|
+
return {
|
|
1156
|
+
kind: "scope_bigger",
|
|
1157
|
+
scopeDelta: "the child branch needs the whole schema registry",
|
|
1158
|
+
revisedEstimate: {
|
|
1159
|
+
usd: 12,
|
|
1160
|
+
turns: 30
|
|
1161
|
+
},
|
|
1162
|
+
blockers: [],
|
|
1163
|
+
proposedDecomposition: [],
|
|
1164
|
+
costToDate: {
|
|
1165
|
+
usd: 0,
|
|
1166
|
+
turns: 2
|
|
1167
|
+
},
|
|
1168
|
+
salvage: {
|
|
1169
|
+
transcriptRef: "record/t-child",
|
|
1170
|
+
artifacts: []
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* The orchestrator-subtree shape: written through the KERNEL write APIs
|
|
1176
|
+
* (the same calls mode (c) spawning uses from M6), since no script-mode
|
|
1177
|
+
* producer can spawn agents under an agent scope yet; re-recorded again
|
|
1178
|
+
* live in M7 with the orchestrator producers (docs/10, cassette plan).
|
|
1179
|
+
*/
|
|
1180
|
+
async function recordAbandonSubtree() {
|
|
1181
|
+
let tick = 0;
|
|
1182
|
+
const replayer = new Replayer({
|
|
1183
|
+
runId: "record",
|
|
1184
|
+
store: new InMemoryStore(),
|
|
1185
|
+
now: () => BASE_MS + tick++ * 1e3
|
|
1186
|
+
});
|
|
1187
|
+
const parent = await replayer.appendRunning({
|
|
1188
|
+
scope: "",
|
|
1189
|
+
key: deriveContentKey(fakeAgentIdentity(PROMPTS.branchWork)),
|
|
1190
|
+
kind: "agent",
|
|
1191
|
+
spanId: SPAN
|
|
1192
|
+
});
|
|
1193
|
+
const subtree = agentScope("", parent.seq);
|
|
1194
|
+
const okRunning = await replayer.appendRunning({
|
|
1195
|
+
scope: subtree,
|
|
1196
|
+
key: deriveContentKey(fakeAgentIdentity(PROMPTS.childOk)),
|
|
1197
|
+
kind: "agent",
|
|
1198
|
+
spanId: SPAN
|
|
1199
|
+
});
|
|
1200
|
+
await replayer.appendTerminal(okRunning.seq, {
|
|
1201
|
+
status: "ok",
|
|
1202
|
+
value: "child ok out",
|
|
1203
|
+
usage: usageOf(100, 20),
|
|
1204
|
+
servedBy: FAKE_MODEL_REF
|
|
1205
|
+
});
|
|
1206
|
+
const escalatedRunning = await replayer.appendRunning({
|
|
1207
|
+
scope: subtree,
|
|
1208
|
+
key: deriveContentKey(fakeAgentIdentity(PROMPTS.childEscalated)),
|
|
1209
|
+
kind: "agent",
|
|
1210
|
+
spanId: SPAN
|
|
1211
|
+
});
|
|
1212
|
+
await replayer.appendTerminal(escalatedRunning.seq, {
|
|
1213
|
+
status: "escalated",
|
|
1214
|
+
escalation: subtreeChildReport(),
|
|
1215
|
+
usage: usageOf(200, 40),
|
|
1216
|
+
servedBy: FAKE_MODEL_REF
|
|
1217
|
+
});
|
|
1218
|
+
await replayer.appendRunning({
|
|
1219
|
+
scope: subtree,
|
|
1220
|
+
key: deriveContentKey(fakeAgentIdentity(PROMPTS.childHanging)),
|
|
1221
|
+
kind: "agent",
|
|
1222
|
+
spanId: SPAN
|
|
1223
|
+
});
|
|
1224
|
+
const decision = await replayer.appendSinglePhase({
|
|
1225
|
+
scope: "",
|
|
1226
|
+
key: "",
|
|
1227
|
+
kind: "decision",
|
|
1228
|
+
status: "ok",
|
|
1229
|
+
spanId: SPAN,
|
|
1230
|
+
value: {
|
|
1231
|
+
decisionType: "escalation.decision",
|
|
1232
|
+
targetRef: escalatedRunning.seq,
|
|
1233
|
+
decision: {
|
|
1234
|
+
kind: "cancel",
|
|
1235
|
+
reason: "owner cancelled the branch"
|
|
1236
|
+
},
|
|
1237
|
+
countsAgainstLimit: true
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
await replayer.abandonBranch({
|
|
1241
|
+
target: parent.seq,
|
|
1242
|
+
authorizedBy: decision.seq,
|
|
1243
|
+
reason: "cancel_task"
|
|
1244
|
+
});
|
|
1245
|
+
return normalizeEntries(replayer.snapshot());
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Records the five live cassettes. Deterministic by construction:
|
|
1249
|
+
* scripted FakeAdapter responders, fixed runId, normalized stamps.
|
|
1250
|
+
*/
|
|
1251
|
+
/**
|
|
1252
|
+
* The v1 flow re-run under explicit high effort (DEF-6, M4-T08): the
|
|
1253
|
+
* body matches the frozen v1 journal's call sequence; the assessTone
|
|
1254
|
+
* call is the one genuinely new spawn and records with hashVersion 2
|
|
1255
|
+
* and canonical effort in identity.
|
|
1256
|
+
*/
|
|
1257
|
+
function effortShiftWorkflow() {
|
|
1258
|
+
return defineWorkflow({ name: "v1-flow" }, async (ctx) => {
|
|
1259
|
+
return {
|
|
1260
|
+
draft: await ctx.agent(PROMPTS.draftSummary, { effort: "high" }),
|
|
1261
|
+
saved: await ctx.step("persist-draft", () => ({
|
|
1262
|
+
written: true,
|
|
1263
|
+
path: "drafts/summary.md"
|
|
1264
|
+
})),
|
|
1265
|
+
stampMs: ctx.now(),
|
|
1266
|
+
intro1: await ctx.agent(PROMPTS.polishIntro, { effort: "high" }),
|
|
1267
|
+
intro2: await ctx.agent(PROMPTS.polishIntro, { effort: "high" }),
|
|
1268
|
+
tone: await ctx.agent(PROMPTS.assessTone, { effort: "high" }),
|
|
1269
|
+
approved: (await ctx.awaitExternal("editor-approval", {
|
|
1270
|
+
schema: APPROVED_SCHEMA,
|
|
1271
|
+
prompt: "Approve the draft?"
|
|
1272
|
+
})).approved
|
|
1273
|
+
};
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
/** Locates the frozen v1 journal from both src (vitest) and dist (script) layouts. */
|
|
1277
|
+
function frozenV1JournalPath() {
|
|
1278
|
+
const candidates = [new URL("../../fixtures/frozen/v1-journal.jsonl", import.meta.url), new URL("../fixtures/frozen/v1-journal.jsonl", import.meta.url)];
|
|
1279
|
+
for (const candidate of candidates) {
|
|
1280
|
+
const path = fileURLToPath(candidate);
|
|
1281
|
+
if (existsSync(path)) return path;
|
|
1282
|
+
}
|
|
1283
|
+
throw new Error("frozen v1-journal.jsonl not found next to the recorder");
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* effort-defaults-shift (DEF-6; docs/10 M4 gating row): the frozen v1
|
|
1287
|
+
* prefix (recorded without effort) is closed offline the way an
|
|
1288
|
+
* operator would (the external resolves, the approval flow is
|
|
1289
|
+
* abandoned under its authority), then the SAME flow resumes LIVE under
|
|
1290
|
+
* a config requesting high effort with the completed effort semantics:
|
|
1291
|
+
* every v1 entry matches (the v1 predicate strips effort), and the one
|
|
1292
|
+
* new spawn records canonical effort in v2 identity.
|
|
1293
|
+
*/
|
|
1294
|
+
async function recordEffortDefaultsShift() {
|
|
1295
|
+
const RUN = "RUNV1";
|
|
1296
|
+
const v1Entries = readFileSync(frozenV1JournalPath(), "utf8").split("\n").filter((line) => line.trim() !== "").map((line) => normalizeEntry(JSON.parse(line)));
|
|
1297
|
+
const journal = new InMemoryStore();
|
|
1298
|
+
for (const entry of v1Entries) await journal.append(RUN, entry);
|
|
1299
|
+
await journal.putMeta({
|
|
1300
|
+
runId: RUN,
|
|
1301
|
+
status: "suspended",
|
|
1302
|
+
updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1303
|
+
workflowName: "v1-flow"
|
|
1304
|
+
});
|
|
1305
|
+
const offline = new Replayer({
|
|
1306
|
+
runId: RUN,
|
|
1307
|
+
store: journal,
|
|
1308
|
+
priorEntries: v1Entries
|
|
1309
|
+
});
|
|
1310
|
+
const resolved = await offline.resolveSuspended(5, {
|
|
1311
|
+
by: "external",
|
|
1312
|
+
value: { approved: true }
|
|
1313
|
+
});
|
|
1314
|
+
if (!resolved.applied) throw new Error("offline resolution of the v1 external did not apply");
|
|
1315
|
+
if (!(await offline.abandonBranch({
|
|
1316
|
+
target: 6,
|
|
1317
|
+
authorizedBy: resolved.seq,
|
|
1318
|
+
reason: "approval flow superseded by the operator"
|
|
1319
|
+
})).applied) throw new Error("offline abandon of the v1 approval did not apply");
|
|
1320
|
+
const outcome = await createEngine({
|
|
1321
|
+
adapters: [new FakeAdapter({ agents: { "*": "tone assessed" } })],
|
|
1322
|
+
stores: { journal },
|
|
1323
|
+
defaults: { routing: {
|
|
1324
|
+
loop: FAKE_MODEL_REF,
|
|
1325
|
+
extract: FAKE_MODEL_REF
|
|
1326
|
+
} }
|
|
1327
|
+
}).resume(RUN, effortShiftWorkflow()).result;
|
|
1328
|
+
if (outcome.status !== "ok") throw new Error(`effort-defaults-shift recording ended '${outcome.status}': ${outcome.error?.message ?? ""}`);
|
|
1329
|
+
return normalizeEntries(await journal.load(RUN));
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* A live engine over one durable store, for multi-leg recordings with
|
|
1333
|
+
* offline kernel writes between the legs (the M8 server/worker shape).
|
|
1334
|
+
*/
|
|
1335
|
+
function liveEngine(store, agents) {
|
|
1336
|
+
return createEngine({
|
|
1337
|
+
adapters: [new FakeAdapter({ agents })],
|
|
1338
|
+
stores: { journal: store },
|
|
1339
|
+
defaults: { routing: {
|
|
1340
|
+
loop: FAKE_MODEL_REF,
|
|
1341
|
+
extract: FAKE_MODEL_REF
|
|
1342
|
+
} }
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
/** An offline kernel writer over the loaded priors (docs/03, section 8). */
|
|
1346
|
+
async function offlineReplayer(store, runId) {
|
|
1347
|
+
let tick = 500;
|
|
1348
|
+
return new Replayer({
|
|
1349
|
+
runId,
|
|
1350
|
+
store,
|
|
1351
|
+
priorEntries: await store.load(runId),
|
|
1352
|
+
now: () => BASE_MS + tick++ * 1e3
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
/** Polls the store until the external suspension is durable. */
|
|
1356
|
+
async function waitForSuspension(store, runId, key) {
|
|
1357
|
+
for (let i = 0; i < 400; i += 1) {
|
|
1358
|
+
if ((await store.load(runId)).some((entry) => entry.status === "suspended" && entry.value?.key === key)) return;
|
|
1359
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1360
|
+
}
|
|
1361
|
+
throw new Error(`recording: the '${key}' suspension never landed`);
|
|
1362
|
+
}
|
|
1363
|
+
function suspendedSeqOf(entries, key) {
|
|
1364
|
+
const suspended = entries.find((entry) => entry.status === "suspended" && entry.value?.key === key);
|
|
1365
|
+
if (suspended === void 0) throw new Error(`recording: no suspended entry for external key '${key}'`);
|
|
1366
|
+
return suspended.seq;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* DEF-4 timeout-vs-live-race, LIVE form: the run suspends on the
|
|
1370
|
+
* decision, the LIVE resolution wins through RunHandle.resolveExternal,
|
|
1371
|
+
* and the late timer attempt lands through the offline kernel writer as
|
|
1372
|
+
* the journaled noop (first-wins; docs/03, 8.6).
|
|
1373
|
+
*/
|
|
1374
|
+
async function recordTimeoutVsLiveRace() {
|
|
1375
|
+
const store = new InMemoryStore();
|
|
1376
|
+
const wf = defineWorkflow({ name: "timeout-race" }, async (ctx) => {
|
|
1377
|
+
return {
|
|
1378
|
+
analysis: await ctx.agent(PROMPTS.analyze),
|
|
1379
|
+
decision: (await ctx.awaitExternal("escalation-report", {
|
|
1380
|
+
schema: DECISION_SCHEMA,
|
|
1381
|
+
prompt: "Escalation decision required"
|
|
1382
|
+
})).decision
|
|
1383
|
+
};
|
|
1384
|
+
});
|
|
1385
|
+
const handle = liveEngine(store, { "*": "analysis: rollback recommended" }).run(wf, void 0, { runId: "record" });
|
|
1386
|
+
await waitForSuspension(store, "record", "escalation-report");
|
|
1387
|
+
const settle = await handle.result;
|
|
1388
|
+
if (settle.status !== "suspended") throw new Error(`timeout-vs-live-race: expected the suspension settle, got '${settle.status}'`);
|
|
1389
|
+
if (!(await handle.resolveExternal("escalation-report", { decision: "rollback" })).applied) throw new Error("timeout-vs-live-race: the live resolution must win");
|
|
1390
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1391
|
+
const target = suspendedSeqOf(await store.load("record"), "escalation-report");
|
|
1392
|
+
if ((await replayer.resolveSuspended(target, {
|
|
1393
|
+
by: "timeout",
|
|
1394
|
+
value: { decision: "abort" }
|
|
1395
|
+
})).applied) throw new Error("timeout-vs-live-race: the timeout attempt must land as a noop");
|
|
1396
|
+
const outcome = await liveEngine(store, { "*": "analysis: rollback recommended" }).resume("record", wf).result;
|
|
1397
|
+
if (outcome.status !== "ok") throw new Error(`timeout-vs-live-race: the resume ended '${outcome.status}'`);
|
|
1398
|
+
return normalizeEntries(await store.load("record"));
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* DEF-4 class-decision-fanout, LIVE form: three sequential suspensions;
|
|
1402
|
+
* the first closes individually (applied by external); ONE class-level
|
|
1403
|
+
* decision entry closes the remaining two by class_decision with the
|
|
1404
|
+
* decisionRef, and the late class attempt against the first lands noop
|
|
1405
|
+
* (docs/03, 8.6; the plan-side class producer has its own cassette,
|
|
1406
|
+
* class-storm-single-turn).
|
|
1407
|
+
*/
|
|
1408
|
+
async function recordClassDecisionFanout() {
|
|
1409
|
+
const store = new InMemoryStore();
|
|
1410
|
+
const wf = defineWorkflow({ name: "fanout" }, async (ctx) => {
|
|
1411
|
+
const one = await ctx.awaitExternal("report-1");
|
|
1412
|
+
const two = await ctx.awaitExternal("report-2");
|
|
1413
|
+
const three = await ctx.awaitExternal("report-3");
|
|
1414
|
+
return [
|
|
1415
|
+
one.action,
|
|
1416
|
+
two.action,
|
|
1417
|
+
three.action
|
|
1418
|
+
];
|
|
1419
|
+
});
|
|
1420
|
+
const first = liveEngine(store, {}).run(wf, void 0, { runId: "record" });
|
|
1421
|
+
await waitForSuspension(store, "record", "report-1");
|
|
1422
|
+
const settle1 = await first.result;
|
|
1423
|
+
if (settle1.status !== "suspended") throw new Error(`class-decision-fanout: expected the report-1 settle, got '${settle1.status}'`);
|
|
1424
|
+
if (!(await first.resolveExternal("report-1", { action: "retry" })).applied) throw new Error("class-decision-fanout: the individual close must apply");
|
|
1425
|
+
const leg2 = await liveEngine(store, {}).resume("record", wf).result;
|
|
1426
|
+
if (leg2.status !== "suspended") throw new Error(`class-decision-fanout: expected the report-2 settle, got '${leg2.status}'`);
|
|
1427
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1428
|
+
const decision = await replayer.appendSinglePhase({
|
|
1429
|
+
scope: "",
|
|
1430
|
+
key: "",
|
|
1431
|
+
kind: "decision",
|
|
1432
|
+
status: "ok",
|
|
1433
|
+
spanId: SPAN,
|
|
1434
|
+
value: {
|
|
1435
|
+
decisionType: "escalation.class",
|
|
1436
|
+
decision: { kind: "retry" },
|
|
1437
|
+
coverage: "all pending reports of this class"
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
const entriesNow = await store.load("record");
|
|
1441
|
+
if (!(await replayer.resolveSuspended(suspendedSeqOf(entriesNow, "report-2"), {
|
|
1442
|
+
by: "class_decision",
|
|
1443
|
+
value: { action: "retry" },
|
|
1444
|
+
decisionRef: decision.seq
|
|
1445
|
+
})).applied) throw new Error("class-decision-fanout: the class close of report-2 must apply");
|
|
1446
|
+
if ((await replayer.resolveSuspended(suspendedSeqOf(entriesNow, "report-1"), {
|
|
1447
|
+
by: "class_decision",
|
|
1448
|
+
value: { action: "retry" },
|
|
1449
|
+
decisionRef: decision.seq
|
|
1450
|
+
})).applied) throw new Error("class-decision-fanout: the class attempt on report-1 must land noop");
|
|
1451
|
+
const secondSettle = await liveEngine(store, {}).resume("record", wf).result;
|
|
1452
|
+
if (secondSettle.status !== "suspended") throw new Error(`class-decision-fanout: expected report-3 to suspend, got '${secondSettle.status}'`);
|
|
1453
|
+
if (!(await (await offlineReplayer(store, "record")).resolveSuspended(suspendedSeqOf(await store.load("record"), "report-3"), {
|
|
1454
|
+
by: "class_decision",
|
|
1455
|
+
value: { action: "retry" },
|
|
1456
|
+
decisionRef: decision.seq
|
|
1457
|
+
})).applied) throw new Error("class-decision-fanout: the class close of report-3 must apply");
|
|
1458
|
+
const final = await liveEngine(store, {}).resume("record", wf).result;
|
|
1459
|
+
if (final.status !== "ok") throw new Error(`class-decision-fanout: the final resume ended '${final.status}'`);
|
|
1460
|
+
return normalizeEntries(await store.load("record"));
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* DEF-4 abandon-then-crash-then-resume, LIVE form: the paid branch is
|
|
1464
|
+
* abandoned offline (decision plus abandon through the kernel writer),
|
|
1465
|
+
* the crash cut drops the effects, and the resume derives skipped for
|
|
1466
|
+
* the branch while paying the revision effects exactly once.
|
|
1467
|
+
*/
|
|
1468
|
+
async function recordAbandonThenCrashThenResume() {
|
|
1469
|
+
const store = new InMemoryStore();
|
|
1470
|
+
const wf = defineWorkflow({ name: "crash-resume" }, async (ctx) => {
|
|
1471
|
+
if ((await ctx.agent(PROMPTS.reviseReport, { result: "full" })).status !== "skipped") return "branch unexpectedly ran";
|
|
1472
|
+
return ctx.agent(PROMPTS.revisionEffects);
|
|
1473
|
+
});
|
|
1474
|
+
const scratch = new InMemoryStore();
|
|
1475
|
+
const outcome1 = await liveEngine(scratch, { "*": "branch out" }).run(wf, void 0, { runId: "record" }).result;
|
|
1476
|
+
if (outcome1.status !== "ok") throw new Error(`abandon-then-crash: life 1 ended '${outcome1.status}'`);
|
|
1477
|
+
const scratchEntries = await scratch.load("record");
|
|
1478
|
+
const branchTerminal = scratchEntries.find((entry) => entry.kind === "agent" && entry.ref !== void 0);
|
|
1479
|
+
if (branchTerminal === void 0) throw new Error("abandon-then-crash: the branch terminal is missing");
|
|
1480
|
+
for (const meta of await scratch.listRuns()) if (meta.runId === "record") await store.putMeta({
|
|
1481
|
+
...meta,
|
|
1482
|
+
status: "suspended"
|
|
1483
|
+
});
|
|
1484
|
+
for (const entry of scratchEntries) if (entry.seq <= branchTerminal.seq) await store.append("record", entry);
|
|
1485
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1486
|
+
const branchRoot = (await store.load("record")).find((entry) => entry.kind === "agent" && entry.ref === void 0);
|
|
1487
|
+
if (branchRoot === void 0) throw new Error("abandon-then-crash: the branch root is missing");
|
|
1488
|
+
if (!(await replayer.abandonBranch({
|
|
1489
|
+
target: branchRoot.seq,
|
|
1490
|
+
authorizedBy: branchRoot.seq,
|
|
1491
|
+
reason: "plan revision"
|
|
1492
|
+
})).applied) throw new Error("abandon-then-crash: the abandon must apply");
|
|
1493
|
+
const outcome2 = await liveEngine(store, { "*": "fresh live output" }).resume("record", wf).result;
|
|
1494
|
+
if (outcome2.status !== "ok" || outcome2.value !== "fresh live output") throw new Error(`abandon-then-crash: the resume must pay the effects once, got '${outcome2.status}'`);
|
|
1495
|
+
return normalizeEntries(await store.load("record"));
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* DEF-4 abandon-vs-resolution-race, LIVE form: both orders through the
|
|
1499
|
+
* kernel writer over one journal: an abandon covering a suspension makes
|
|
1500
|
+
* the late resolution a noop (target_abandoned); the reverse order
|
|
1501
|
+
* applies the resolution and the late abandon folds noop.
|
|
1502
|
+
*/
|
|
1503
|
+
async function recordAbandonVsResolutionRace() {
|
|
1504
|
+
const store = new InMemoryStore();
|
|
1505
|
+
const wf = defineWorkflow({ name: "race-directions" }, async (ctx) => {
|
|
1506
|
+
const alpha = await ctx.agent(PROMPTS.branchAlpha, { result: "full" });
|
|
1507
|
+
const beta = await ctx.awaitExternal("beta-gate", { schema: GO_SCHEMA });
|
|
1508
|
+
return {
|
|
1509
|
+
alphaStatus: alpha.status,
|
|
1510
|
+
go: beta.go
|
|
1511
|
+
};
|
|
1512
|
+
});
|
|
1513
|
+
const settle1 = await liveEngine(store, { "*": "alpha branch out" }).run(wf, void 0, { runId: "record" }).result;
|
|
1514
|
+
if (settle1.status !== "suspended") throw new Error(`abandon-vs-resolution: expected the beta suspension, got '${settle1.status}'`);
|
|
1515
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1516
|
+
const loaded = await store.load("record");
|
|
1517
|
+
const alphaRoot = loaded.find((entry) => entry.kind === "agent" && entry.ref === void 0);
|
|
1518
|
+
if (alphaRoot === void 0) throw new Error("abandon-vs-resolution: the alpha root is missing");
|
|
1519
|
+
const innerApproval = await replayer.appendSuspended({
|
|
1520
|
+
scope: agentScope("", alphaRoot.seq),
|
|
1521
|
+
key: "",
|
|
1522
|
+
kind: "approval",
|
|
1523
|
+
spanId: SPAN,
|
|
1524
|
+
value: {
|
|
1525
|
+
toolName: "deploy",
|
|
1526
|
+
input: { env: "prod" }
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
if (!(await replayer.abandonBranch({
|
|
1530
|
+
target: alphaRoot.seq,
|
|
1531
|
+
authorizedBy: alphaRoot.seq,
|
|
1532
|
+
reason: "abandon first"
|
|
1533
|
+
})).applied) throw new Error("abandon-vs-resolution: the covering abandon must apply");
|
|
1534
|
+
if ((await replayer.resolveSuspended(innerApproval.seq, {
|
|
1535
|
+
by: "external",
|
|
1536
|
+
value: { approved: true }
|
|
1537
|
+
})).applied) throw new Error("abandon-vs-resolution: the covered resolution must land noop");
|
|
1538
|
+
const betaSeq = suspendedSeqOf(loaded, "beta-gate");
|
|
1539
|
+
if (!(await replayer.resolveSuspended(betaSeq, {
|
|
1540
|
+
by: "external",
|
|
1541
|
+
value: { go: true }
|
|
1542
|
+
})).applied) throw new Error("abandon-vs-resolution: the beta resolution must apply");
|
|
1543
|
+
if ((await replayer.abandonBranch({
|
|
1544
|
+
target: betaSeq,
|
|
1545
|
+
authorizedBy: alphaRoot.seq,
|
|
1546
|
+
reason: "abandon second"
|
|
1547
|
+
})).applied) throw new Error("abandon-vs-resolution: the late abandon must fold noop");
|
|
1548
|
+
return normalizeEntries(await store.load("record"));
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* DEF-4 offline-invalid-then-valid, LIVE form (the M8 server machinery
|
|
1552
|
+
* end to end): the run suspends on a schema-validated external; the
|
|
1553
|
+
* offline writer appends an INVALID then a VALID resolution; the resume
|
|
1554
|
+
* consumes the valid value with zero live calls inside the suspension.
|
|
1555
|
+
*/
|
|
1556
|
+
async function recordOfflineInvalidThenValid() {
|
|
1557
|
+
const store = new InMemoryStore();
|
|
1558
|
+
const wf = defineWorkflow({ name: "invalid-then-valid" }, async (ctx) => {
|
|
1559
|
+
return (await ctx.awaitExternal("deploy-approval", {
|
|
1560
|
+
schema: APPROVED_SCHEMA,
|
|
1561
|
+
prompt: "Approve the deployment?"
|
|
1562
|
+
})).approved;
|
|
1563
|
+
});
|
|
1564
|
+
const settle1 = await liveEngine(store, {}).run(wf, void 0, { runId: "record" }).result;
|
|
1565
|
+
if (settle1.status !== "suspended") throw new Error(`offline-invalid-then-valid: expected suspended, got '${settle1.status}'`);
|
|
1566
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1567
|
+
const target = suspendedSeqOf(await store.load("record"), "deploy-approval");
|
|
1568
|
+
if ((await replayer.resolveSuspended(target, {
|
|
1569
|
+
by: "external",
|
|
1570
|
+
value: { approved: "yes-ish" }
|
|
1571
|
+
})).applied) throw new Error("offline-invalid-then-valid: the invalid payload must not close");
|
|
1572
|
+
if (!(await replayer.resolveSuspended(target, {
|
|
1573
|
+
by: "external",
|
|
1574
|
+
value: { approved: true }
|
|
1575
|
+
})).applied) throw new Error("offline-invalid-then-valid: the valid payload must apply");
|
|
1576
|
+
const outcome = await liveEngine(store, {}).resume("record", wf).result;
|
|
1577
|
+
if (outcome.status !== "ok" || outcome.value !== true) throw new Error(`offline-invalid-then-valid: the resume ended '${outcome.status}'`);
|
|
1578
|
+
return normalizeEntries(await store.load("record"));
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* DEF-4 double-abandon-idempotent, LIVE form: two covering abandons over
|
|
1582
|
+
* overlapping targets through the kernel writer; the second folds noop,
|
|
1583
|
+
* the terminal ok inside the coverage derives skipped, and the ledger
|
|
1584
|
+
* pays nothing on replay.
|
|
1585
|
+
*/
|
|
1586
|
+
async function recordDoubleAbandonIdempotent() {
|
|
1587
|
+
const store = new InMemoryStore();
|
|
1588
|
+
const wf = defineWorkflow({ name: "double-abandon" }, async (ctx) => {
|
|
1589
|
+
return (await ctx.agent(PROMPTS.subtreeAlpha, { result: "full" })).status;
|
|
1590
|
+
});
|
|
1591
|
+
const settle1 = await liveEngine(store, { "*": "alpha done" }).run(wf, void 0, { runId: "record" }).result;
|
|
1592
|
+
if (settle1.status !== "ok") throw new Error(`double-abandon: life 1 ended '${settle1.status}'`);
|
|
1593
|
+
const replayer = await offlineReplayer(store, "record");
|
|
1594
|
+
const root = (await store.load("record")).find((entry) => entry.kind === "agent" && entry.ref === void 0);
|
|
1595
|
+
if (root === void 0) throw new Error("double-abandon: the alpha root is missing");
|
|
1596
|
+
if (!(await replayer.abandonBranch({
|
|
1597
|
+
target: root.seq,
|
|
1598
|
+
authorizedBy: root.seq,
|
|
1599
|
+
reason: "first cancel"
|
|
1600
|
+
})).applied) throw new Error("double-abandon: the first abandon must apply");
|
|
1601
|
+
if ((await replayer.abandonBranch({
|
|
1602
|
+
target: root.seq,
|
|
1603
|
+
authorizedBy: root.seq,
|
|
1604
|
+
reason: "second cancel overlaps"
|
|
1605
|
+
})).applied) throw new Error("double-abandon: the second abandon must fold noop");
|
|
1606
|
+
return normalizeEntries(await store.load("record"));
|
|
1607
|
+
}
|
|
1608
|
+
async function recordLiveCassettes() {
|
|
1609
|
+
const fixtures = [];
|
|
1610
|
+
fixtures.push({
|
|
1611
|
+
id: "effort-defaults-shift",
|
|
1612
|
+
note: "DEF-6 (recorded through the live runtime in M4-T08): the frozen v1 prefix recorded without effort, closed offline, then resumed live under explicit high effort with the completed effort semantics; every v1 entry matches (the v1 predicate strips effort) and the one new spawn carries canonical effort in v2 identity.",
|
|
1613
|
+
entries: await recordEffortDefaultsShift()
|
|
1614
|
+
});
|
|
1615
|
+
fixtures.push({
|
|
1616
|
+
id: "abandon-subtree",
|
|
1617
|
+
note: "DEF-1 (re-recorded through the kernel write APIs in M3): abandon over a subtree with ok, escalated, and a hanging running entry, authorized by the owner cancel decision; all derive skipped, zero live calls, zero spend increment.",
|
|
1618
|
+
entries: await recordAbandonSubtree()
|
|
1619
|
+
});
|
|
1620
|
+
fixtures.push({
|
|
1621
|
+
id: "memoize-classifier",
|
|
1622
|
+
note: "DEF-1 (re-recorded through the live runtime in M3): memoizeOutcome pins the task-class failure (schema mismatch) for replay; the transport-class failure (rate limit) reruns and is the expected strict miss.",
|
|
1623
|
+
entries: await recordEngineRun({
|
|
1624
|
+
workflow: memoizeClassifierWorkflow(),
|
|
1625
|
+
agents: {
|
|
1626
|
+
"summarize the document": () => fakeWireError({
|
|
1627
|
+
code: "rate-limit",
|
|
1628
|
+
message: "429 too many requests",
|
|
1629
|
+
retryable: true,
|
|
1630
|
+
data: {
|
|
1631
|
+
kind: "rate-limit",
|
|
1632
|
+
retryAfterMs: 1e3
|
|
1633
|
+
}
|
|
1634
|
+
}),
|
|
1635
|
+
"*": "this is not the requested JSON, just prose"
|
|
1636
|
+
}
|
|
1637
|
+
}).then((entries) => {
|
|
1638
|
+
return entries;
|
|
1639
|
+
})
|
|
1640
|
+
});
|
|
1641
|
+
fixtures.push({
|
|
1642
|
+
id: "escalate-replay",
|
|
1643
|
+
note: "DEF-1 live set: the worker finishes escalated with a report, the parent decides retry (journaled escalation.decision), and the respawn completes; replay-strict resume yields zero live calls, the byte-identical report, and the decision from the entry.",
|
|
1644
|
+
entries: await recordEngineRun({
|
|
1645
|
+
workflow: escalateReplayWorkflow(),
|
|
1646
|
+
agents: {
|
|
1647
|
+
"migrate the billing": () => fakeToolCalls({
|
|
1648
|
+
name: "escalate",
|
|
1649
|
+
args: RECORDED_ESCALATE_ARGS
|
|
1650
|
+
}),
|
|
1651
|
+
"retry: split the billing": "migration retried per service"
|
|
1652
|
+
},
|
|
1653
|
+
onEscalation: () => ({
|
|
1654
|
+
kind: "retry",
|
|
1655
|
+
amendedPrompt: "split by service"
|
|
1656
|
+
})
|
|
1657
|
+
})
|
|
1658
|
+
});
|
|
1659
|
+
fixtures.push({
|
|
1660
|
+
id: "crash-between-report-and-decision",
|
|
1661
|
+
note: "DEF-1 live set: the terminal escalated entry landed, the process died before any decision; the first resume replays escalated and pays for the decision live exactly once; the second resume replays both with zero live calls.",
|
|
1662
|
+
entries: await recordEngineRun({
|
|
1663
|
+
workflow: crashBetweenPhase1Workflow(),
|
|
1664
|
+
agents: { "stabilize the flaky": () => fakeToolCalls({
|
|
1665
|
+
name: "escalate",
|
|
1666
|
+
args: RECORDED_ESCALATE_ARGS
|
|
1667
|
+
}) }
|
|
1668
|
+
})
|
|
1669
|
+
});
|
|
1670
|
+
fixtures.push({
|
|
1671
|
+
id: "timeout-vs-live-race",
|
|
1672
|
+
note: "DEF-4 (re-recorded through the live producers in M9): the live resolution wins through RunHandle.resolveExternal; the late timer attempt lands through the offline kernel writer as the journaled noop whose effects never re-issue (docs/03, 8.6).",
|
|
1673
|
+
entries: await recordTimeoutVsLiveRace()
|
|
1674
|
+
});
|
|
1675
|
+
fixtures.push({
|
|
1676
|
+
id: "class-decision-fanout",
|
|
1677
|
+
note: "DEF-4 (re-recorded through the live producers in M9): report-1 closes individually; ONE class-level decision closes the remaining reports by class_decision with the decisionRef; the late class attempt on report-1 lands noop; the plan-side class producer has its own cassette (class-storm-single-turn).",
|
|
1678
|
+
entries: await recordClassDecisionFanout()
|
|
1679
|
+
});
|
|
1680
|
+
fixtures.push({
|
|
1681
|
+
id: "abandon-then-crash-then-resume",
|
|
1682
|
+
note: "DEF-4 (re-recorded through the live producers in M9): the paid branch is abandoned through the offline kernel writer (decision plus abandon), the crash cut drops the effects, and the resume derives skipped while paying the effects exactly once.",
|
|
1683
|
+
entries: await recordAbandonThenCrashThenResume()
|
|
1684
|
+
});
|
|
1685
|
+
fixtures.push({
|
|
1686
|
+
id: "abandon-vs-resolution-race",
|
|
1687
|
+
note: "DEF-4 (re-recorded through the live producers in M9): both orders over one journal; an abandon covering a suspension makes the late resolution a noop target_abandoned; the reverse order applies the resolution and the late abandon folds noop.",
|
|
1688
|
+
entries: await recordAbandonVsResolutionRace()
|
|
1689
|
+
});
|
|
1690
|
+
fixtures.push({
|
|
1691
|
+
id: "offline-invalid-then-valid",
|
|
1692
|
+
note: "DEF-4 (re-recorded through the live producers in M9, the M8 offline machinery end to end): the offline writer appends an INVALID then a VALID resolution against the schema-validated suspension; the resume consumes the valid value.",
|
|
1693
|
+
entries: await recordOfflineInvalidThenValid()
|
|
1694
|
+
});
|
|
1695
|
+
fixtures.push({
|
|
1696
|
+
id: "double-abandon-idempotent",
|
|
1697
|
+
note: "DEF-4 (re-recorded through the live producers in M9): two covering abandons over one target; the second folds noop, the terminal ok inside the coverage derives skipped, and replay pays nothing.",
|
|
1698
|
+
entries: await recordDoubleAbandonIdempotent()
|
|
1699
|
+
});
|
|
1700
|
+
fixtures.push({
|
|
1701
|
+
id: "flavor-b-timeout",
|
|
1702
|
+
note: "DEF-1 live set: the escalate tool suspends the agent with a journaled deadline; the timer appends the resolution by timeout applying the defaultDecision (first-wins); dispose and the terminal escalated entry follow as effects; resume replays the closing resolution and the terminal entry with no re-suspension.",
|
|
1703
|
+
entries: await recordEngineRun({
|
|
1704
|
+
workflow: flavorBTimeoutWorkflow(),
|
|
1705
|
+
agents: { "reconcile the ledger": () => fakeToolCalls({
|
|
1706
|
+
name: "escalate",
|
|
1707
|
+
args: RECORDED_ESCALATE_ARGS
|
|
1708
|
+
}) }
|
|
1709
|
+
})
|
|
1710
|
+
});
|
|
1711
|
+
return fixtures;
|
|
1712
|
+
}
|
|
1713
|
+
//#endregion
|
|
1714
|
+
//#region src/cassettes/m6-orchestrator.ts
|
|
1715
|
+
const M6_ORCH_RUN_ID = "m6-orchestrator-crash";
|
|
1716
|
+
const M6_ORCH_GOAL = "m6 cassette: gather two facts";
|
|
1717
|
+
const M6_ORCH_PROFILES = { worker: { description: "does one task" } };
|
|
1718
|
+
function agentTypeOf(call) {
|
|
1719
|
+
return (call.req.providerOptions?.rulvar)?.agentType ?? "";
|
|
1720
|
+
}
|
|
1721
|
+
/** Extracts spawn handles from the tool results the model saw. */
|
|
1722
|
+
function handlesInRequest(req) {
|
|
1723
|
+
const handles = [];
|
|
1724
|
+
for (const msg of req.messages) for (const part of msg.parts) if (part.type === "tool-result") {
|
|
1725
|
+
const result = part.result;
|
|
1726
|
+
if (typeof result?.handle === "number") handles.push(result.handle);
|
|
1727
|
+
}
|
|
1728
|
+
return handles;
|
|
1729
|
+
}
|
|
1730
|
+
/** Fixes wall clock and spans; everything else is deterministic already. */
|
|
1731
|
+
function normalizeM6Entries(entries) {
|
|
1732
|
+
return entries.map((entry) => ({
|
|
1733
|
+
...entry,
|
|
1734
|
+
spanId: "fixture-span",
|
|
1735
|
+
startedAt: "2026-02-01T00:00:00.000Z",
|
|
1736
|
+
...entry.endedAt === void 0 ? {} : { endedAt: "2026-02-01T00:00:00.000Z" }
|
|
1737
|
+
}));
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Phase 1: record the pre-crash journal. The transcripts store carries
|
|
1741
|
+
* the boundary checkpoint the resume restores from; the recorder keeps
|
|
1742
|
+
* it in memory because the cassette pins only journal bytes (checkpoint
|
|
1743
|
+
* blobs are engine-internal at-least-once state, docs/03 section 11).
|
|
1744
|
+
*/
|
|
1745
|
+
async function recordOrchestratorCrash() {
|
|
1746
|
+
let orchestratorTurn = 0;
|
|
1747
|
+
const adapter = new FakeAdapter({ agents: { "*": (call) => {
|
|
1748
|
+
if (agentTypeOf(call) === "worker") return `paid: ${call.prompt}`;
|
|
1749
|
+
orchestratorTurn += 1;
|
|
1750
|
+
if (orchestratorTurn === 1) return fakeToolCalls({
|
|
1751
|
+
name: "spawn_agent",
|
|
1752
|
+
args: {
|
|
1753
|
+
agentType: "worker",
|
|
1754
|
+
prompt: "expensive A"
|
|
1755
|
+
}
|
|
1756
|
+
}, {
|
|
1757
|
+
name: "spawn_agent",
|
|
1758
|
+
args: {
|
|
1759
|
+
agentType: "worker",
|
|
1760
|
+
prompt: "expensive B"
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
return fakeWireError({
|
|
1764
|
+
code: "agent",
|
|
1765
|
+
message: "simulated crash",
|
|
1766
|
+
retryable: false
|
|
1767
|
+
});
|
|
1768
|
+
} } });
|
|
1769
|
+
const store = new InMemoryStore();
|
|
1770
|
+
const transcripts = new InMemoryTranscriptStore();
|
|
1771
|
+
const engine = createEngine({
|
|
1772
|
+
adapters: [adapter],
|
|
1773
|
+
stores: {
|
|
1774
|
+
journal: store,
|
|
1775
|
+
transcripts
|
|
1776
|
+
},
|
|
1777
|
+
defaults: {
|
|
1778
|
+
routing: {
|
|
1779
|
+
loop: FAKE_MODEL_REF,
|
|
1780
|
+
orchestrate: FAKE_MODEL_REF
|
|
1781
|
+
},
|
|
1782
|
+
profiles: M6_ORCH_PROFILES
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
1785
|
+
const wf = makeOrchestratorWorkflow(M6_ORCH_GOAL, {});
|
|
1786
|
+
const outcome = await engine.run(wf, void 0, { runId: M6_ORCH_RUN_ID }).result.then((settled) => settled);
|
|
1787
|
+
if (outcome.status !== "error") throw new Error(`the recorder expected a crashed run, got '${outcome.status}'`);
|
|
1788
|
+
const entries = await store.load(M6_ORCH_RUN_ID);
|
|
1789
|
+
const orchestratorTerminal = entries.find((entry) => entry.kind === "agent" && !entry.scope.startsWith("agent:") && entry.status !== "running" && entry.status !== "suspended");
|
|
1790
|
+
if (orchestratorTerminal === void 0) throw new Error("the recorder found no orchestrator terminal to cut");
|
|
1791
|
+
const cut = entries.filter((entry) => entry.seq < orchestratorTerminal.seq);
|
|
1792
|
+
const checkpoints = {};
|
|
1793
|
+
for (const ref of await transcripts.list(M6_ORCH_RUN_ID)) {
|
|
1794
|
+
if (!ref.includes("/ckpt/")) continue;
|
|
1795
|
+
const blob = await transcripts.get(ref);
|
|
1796
|
+
if (blob !== null) checkpoints[ref] = Buffer.from(blob).toString("base64");
|
|
1797
|
+
}
|
|
1798
|
+
return {
|
|
1799
|
+
entries: normalizeM6Entries(cut),
|
|
1800
|
+
checkpoints
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/vcr.ts
|
|
1805
|
+
/**
|
|
1806
|
+
* VCR cassettes at the adapter boundary (M5-T04; docs/09, section 5.2;
|
|
1807
|
+
* docs/11, section 5): `record` wraps live adapters and captures
|
|
1808
|
+
* request/event pairs into a redacted JSONL cassette keyed by a hash of
|
|
1809
|
+
* the canonical wire-contract request; `replay` serves recorded streams
|
|
1810
|
+
* back with `onMiss: 'throw'` (hermetic CI) or `'passthrough'` (mixed
|
|
1811
|
+
* live/recorded development runs). Because the boundary speaks the L0
|
|
1812
|
+
* wire contract, cassettes are vendor-neutral by construction.
|
|
1813
|
+
*
|
|
1814
|
+
* Redaction happens at record time and secrets MUST never reach the
|
|
1815
|
+
* committed cassette bytes: the built-in policy masks authorization
|
|
1816
|
+
* material (bearer tokens, api-key-shaped strings) in every stored
|
|
1817
|
+
* string, and a `redact` hook composes on top. The request HASH is
|
|
1818
|
+
* computed over the raw canonical request (minus the engine-populated
|
|
1819
|
+
* `providerOptions.rulvar` telemetry namespace, which is never
|
|
1820
|
+
* identity), so replay matching is redaction-independent while stored
|
|
1821
|
+
* bytes stay clean. Cassettes record the hashVersion they were produced
|
|
1822
|
+
* under (DEF-6).
|
|
1823
|
+
*/
|
|
1824
|
+
/**
|
|
1825
|
+
* Built-in redaction: authorization material never reaches cassette
|
|
1826
|
+
* bytes (docs/11, section 5.2). Deliberately aggressive; compose a
|
|
1827
|
+
* custom hook for payload-specific secrets.
|
|
1828
|
+
*/
|
|
1829
|
+
function defaultRedact(value) {
|
|
1830
|
+
return value.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]").replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [REDACTED]").replace(/\b(api[-_]?key|authorization|x-api-key)(["':\s=]+)((?!Bearer\b)[^\s"',;]+)/gi, "$1$2[REDACTED]");
|
|
1831
|
+
}
|
|
1832
|
+
function walkStrings(value, fn) {
|
|
1833
|
+
if (typeof value === "string") return fn(value);
|
|
1834
|
+
if (Array.isArray(value)) return value.map((item) => walkStrings(item, fn));
|
|
1835
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, walkStrings(item, fn)]));
|
|
1836
|
+
return value;
|
|
1837
|
+
}
|
|
1838
|
+
/** Deterministic canonical JSON: sorted keys, no whitespace. */
|
|
1839
|
+
function canonicalJson(value) {
|
|
1840
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
1841
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
1842
|
+
return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* The cassette key: a hash of the canonical wire-contract request. The
|
|
1846
|
+
* engine-populated telemetry namespace is excluded (docs/04, section
|
|
1847
|
+
* 1.8: never identity); everything else the adapter would send keys the
|
|
1848
|
+
* row.
|
|
1849
|
+
*/
|
|
1850
|
+
function requestHash(req) {
|
|
1851
|
+
const { providerOptions, ...rest } = req;
|
|
1852
|
+
const filtered = providerOptions === void 0 ? {} : Object.fromEntries(Object.entries(providerOptions).filter(([namespace]) => namespace !== "rulvar"));
|
|
1853
|
+
const withoutTelemetry = Object.keys(filtered).length === 0 ? rest : {
|
|
1854
|
+
...rest,
|
|
1855
|
+
providerOptions: filtered
|
|
1856
|
+
};
|
|
1857
|
+
return createHash("sha256").update(canonicalJson(withoutTelemetry), "utf8").digest("hex");
|
|
1858
|
+
}
|
|
1859
|
+
function headerLine() {
|
|
1860
|
+
return JSON.stringify({
|
|
1861
|
+
v: 1,
|
|
1862
|
+
kind: "rulvar-vcr",
|
|
1863
|
+
hashVersion: CURRENT_HASH_VERSION,
|
|
1864
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Wraps live adapters for recording: every completed stream appends one
|
|
1869
|
+
* redacted row to the cassette JSONL. The wrapped adapters are drop-in:
|
|
1870
|
+
* same ids, providers, caps, and event streams.
|
|
1871
|
+
*/
|
|
1872
|
+
function record(options) {
|
|
1873
|
+
const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
|
|
1874
|
+
if (!existsSync(options.cassette)) writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
|
|
1875
|
+
return options.adapters.map((adapter) => ({
|
|
1876
|
+
...adapter,
|
|
1877
|
+
id: adapter.id,
|
|
1878
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
1879
|
+
caps: (model) => adapter.caps(model),
|
|
1880
|
+
async *stream(req, signal) {
|
|
1881
|
+
const events = [];
|
|
1882
|
+
for await (const event of adapter.stream(req, signal)) {
|
|
1883
|
+
events.push(event);
|
|
1884
|
+
yield event;
|
|
1885
|
+
}
|
|
1886
|
+
const row = {
|
|
1887
|
+
adapterId: adapter.id,
|
|
1888
|
+
...adapter.provider === void 0 ? {} : { provider: adapter.provider },
|
|
1889
|
+
requestHash: requestHash(req),
|
|
1890
|
+
request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
|
|
1891
|
+
events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
|
|
1892
|
+
caps: adapter.caps(req.model),
|
|
1893
|
+
model: req.model
|
|
1894
|
+
};
|
|
1895
|
+
appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
|
|
1896
|
+
}
|
|
1897
|
+
}));
|
|
1898
|
+
}
|
|
1899
|
+
/** Typed hermetic-miss error; onMiss: 'throw' raises it on any unrecorded request. */
|
|
1900
|
+
var VcrMissError = class extends Error {
|
|
1901
|
+
requestHash;
|
|
1902
|
+
constructor(adapterId, hash) {
|
|
1903
|
+
super(`VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
|
|
1904
|
+
this.name = "VcrMissError";
|
|
1905
|
+
this.requestHash = hash;
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
/** Parses a cassette file (one header line plus one JSON row per line). */
|
|
1909
|
+
function readCassette(path) {
|
|
1910
|
+
const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim() !== "");
|
|
1911
|
+
const header = JSON.parse(lines[0] ?? "{}");
|
|
1912
|
+
if (header.kind !== "rulvar-vcr") throw new ConfigError(`${path} is not a rulvar VCR cassette`);
|
|
1913
|
+
return {
|
|
1914
|
+
header,
|
|
1915
|
+
rows: lines.slice(1).map((line) => JSON.parse(line))
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Builds replay adapters from a cassette. `onMiss: 'throw'` is the
|
|
1920
|
+
* hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
|
|
1921
|
+
* matching live adapter in `adapters` (a development convenience only,
|
|
1922
|
+
* docs/11 section 5.1).
|
|
1923
|
+
*/
|
|
1924
|
+
function replay(options) {
|
|
1925
|
+
const { rows } = readCassette(options.cassette);
|
|
1926
|
+
const byAdapter = /* @__PURE__ */ new Map();
|
|
1927
|
+
for (const row of rows) {
|
|
1928
|
+
const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
|
|
1929
|
+
forAdapter.set(row.requestHash, row);
|
|
1930
|
+
byAdapter.set(row.adapterId, forAdapter);
|
|
1931
|
+
}
|
|
1932
|
+
const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
|
|
1933
|
+
return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
|
|
1934
|
+
const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
|
|
1935
|
+
const passthrough = live.get(adapterId);
|
|
1936
|
+
const someRow = [...recorded.values()][0];
|
|
1937
|
+
const capsByModel = /* @__PURE__ */ new Map();
|
|
1938
|
+
for (const row of recorded.values()) capsByModel.set(row.model, row.caps);
|
|
1939
|
+
return {
|
|
1940
|
+
id: adapterId,
|
|
1941
|
+
...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
|
|
1942
|
+
caps: (model) => {
|
|
1943
|
+
const snapshot = capsByModel.get(model) ?? passthrough?.caps(model);
|
|
1944
|
+
if (snapshot === void 0) throw new ConfigError(`VCR replay adapter '${adapterId}' has no caps snapshot for model '${model}'`);
|
|
1945
|
+
return snapshot;
|
|
1946
|
+
},
|
|
1947
|
+
async *stream(req, signal) {
|
|
1948
|
+
const hash = requestHash(req);
|
|
1949
|
+
const row = recorded.get(hash);
|
|
1950
|
+
if (row !== void 0) {
|
|
1951
|
+
for (const event of row.events) yield event;
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (options.onMiss === "passthrough" && passthrough !== void 0) {
|
|
1955
|
+
yield* passthrough.stream(req, signal);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
throw new VcrMissError(adapterId, hash);
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
//#endregion
|
|
1964
|
+
export { FAKE_MODEL, FAKE_MODEL_REF, FakeAdapter, M6_ORCH_GOAL, M6_ORCH_PROFILES, M6_ORCH_RUN_ID, VcrMissError, buildFrozenV1JournalRaw, buildM2CassetteFixtures, buildV2GoldenIdentity, createTestEngine, defaultRedact, fakeToolCalls, fakeWireError, handlesInRequest, normalizeM6Entries, readCassette, record, recordLiveCassettes, recordOrchestratorCrash, replay, replayRun, requestHash };
|