@markstanden/opencode-test-container 0.0.0-stage → 0.1.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.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/container/Containerfile +24 -0
  4. package/container/README.md +29 -0
  5. package/dist/adapters/opencode/events.d.ts +90 -0
  6. package/dist/adapters/opencode/events.js +213 -0
  7. package/dist/adapters/opencode/runtime.d.ts +87 -0
  8. package/dist/adapters/opencode/runtime.js +136 -0
  9. package/dist/adapters/podman/container.d.ts +62 -0
  10. package/dist/adapters/podman/container.js +119 -0
  11. package/dist/adapters/podman/image.d.ts +50 -0
  12. package/dist/adapters/podman/image.js +124 -0
  13. package/dist/adapters/process.d.ts +38 -0
  14. package/dist/adapters/process.js +71 -0
  15. package/dist/campaign/assess.d.ts +45 -0
  16. package/dist/campaign/assess.js +208 -0
  17. package/dist/campaign/close.d.ts +17 -0
  18. package/dist/campaign/close.js +112 -0
  19. package/dist/campaign/evidence-guard.d.ts +24 -0
  20. package/dist/campaign/evidence-guard.js +49 -0
  21. package/dist/campaign/freeze.d.ts +94 -0
  22. package/dist/campaign/freeze.js +297 -0
  23. package/dist/campaign/inventory.d.ts +62 -0
  24. package/dist/campaign/inventory.js +192 -0
  25. package/dist/campaign/judge.d.ts +209 -0
  26. package/dist/campaign/judge.js +1285 -0
  27. package/dist/campaign/ledger.d.ts +64 -0
  28. package/dist/campaign/ledger.js +149 -0
  29. package/dist/campaign/prepare.d.ts +129 -0
  30. package/dist/campaign/prepare.js +483 -0
  31. package/dist/campaign/primary.d.ts +96 -0
  32. package/dist/campaign/primary.js +189 -0
  33. package/dist/campaign/run.d.ts +151 -0
  34. package/dist/campaign/run.js +562 -0
  35. package/dist/campaign/status.d.ts +76 -0
  36. package/dist/campaign/status.js +237 -0
  37. package/dist/cli/otc.d.ts +51 -0
  38. package/dist/cli/otc.js +435 -0
  39. package/dist/contracts/canonical.d.ts +21 -0
  40. package/dist/contracts/canonical.js +44 -0
  41. package/dist/contracts/types.d.ts +261 -0
  42. package/dist/contracts/types.js +9 -0
  43. package/dist/contracts/validate.d.ts +72 -0
  44. package/dist/contracts/validate.js +1021 -0
  45. package/dist/contracts/version.d.ts +44 -0
  46. package/dist/contracts/version.js +147 -0
  47. package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
  48. package/dist/evaluators/docs/analysis/accounting.js +237 -0
  49. package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
  50. package/dist/evaluators/docs/analysis/blinding.js +80 -0
  51. package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
  52. package/dist/evaluators/docs/analysis/bounds.js +31 -0
  53. package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
  54. package/dist/evaluators/docs/analysis/budget.js +305 -0
  55. package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
  56. package/dist/evaluators/docs/analysis/closure.js +251 -0
  57. package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
  58. package/dist/evaluators/docs/analysis/decision.js +161 -0
  59. package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
  60. package/dist/evaluators/docs/analysis/freeze.js +395 -0
  61. package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
  62. package/dist/evaluators/docs/analysis/guardrails.js +194 -0
  63. package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
  64. package/dist/evaluators/docs/analysis/journal.js +475 -0
  65. package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
  66. package/dist/evaluators/docs/analysis/ledger.js +145 -0
  67. package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
  68. package/dist/evaluators/docs/analysis/measures.js +1069 -0
  69. package/dist/evaluators/docs/analysis/model.d.ts +236 -0
  70. package/dist/evaluators/docs/analysis/model.js +740 -0
  71. package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
  72. package/dist/evaluators/docs/analysis/pairing.js +95 -0
  73. package/dist/evaluators/docs/analysis/records.d.ts +229 -0
  74. package/dist/evaluators/docs/analysis/records.js +935 -0
  75. package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
  76. package/dist/evaluators/docs/analysis/recovery.js +507 -0
  77. package/dist/evaluators/docs/analysis/render.d.ts +235 -0
  78. package/dist/evaluators/docs/analysis/render.js +566 -0
  79. package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
  80. package/dist/evaluators/docs/analysis/replay.js +1272 -0
  81. package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
  82. package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
  83. package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
  84. package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
  85. package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
  86. package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
  87. package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
  88. package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
  89. package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
  90. package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
  91. package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
  92. package/dist/evaluators/docs/analysis/selector.js +123 -0
  93. package/dist/evaluators/docs/assess.d.ts +129 -0
  94. package/dist/evaluators/docs/assess.js +433 -0
  95. package/dist/evaluators/docs/campaign.d.ts +133 -0
  96. package/dist/evaluators/docs/campaign.js +222 -0
  97. package/dist/evaluators/docs/index.d.ts +25 -0
  98. package/dist/evaluators/docs/index.js +25 -0
  99. package/dist/evaluators/docs/journal.d.ts +150 -0
  100. package/dist/evaluators/docs/journal.js +475 -0
  101. package/dist/evaluators/docs/scoring.d.ts +367 -0
  102. package/dist/evaluators/docs/scoring.js +662 -0
  103. package/dist/evaluators/skills/assess.d.ts +94 -0
  104. package/dist/evaluators/skills/assess.js +207 -0
  105. package/dist/index.d.ts +46 -0
  106. package/dist/index.js +29 -0
  107. package/dist/legacy/prod014.d.ts +86 -0
  108. package/dist/legacy/prod014.js +209 -0
  109. package/dist/reporting/report.d.ts +79 -0
  110. package/dist/reporting/report.js +242 -0
  111. package/docs/architecture.md +106 -0
  112. package/docs/capability-matrix.md +84 -0
  113. package/docs/offline-rehearsal.md +75 -0
  114. package/docs/parity-record.md +81 -0
  115. package/docs/pr-description.md +103 -0
  116. package/docs/schema.md +97 -0
  117. package/examples/skills/README.md +13 -0
  118. package/examples/skills/campaign.json +73 -0
  119. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  120. package/examples/skills/suite.json +29 -0
  121. package/package.json +45 -5
@@ -0,0 +1,562 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { sha256Bytes, sha256Of } from "../contracts/canonical.js";
6
+ import { decodeTrail, captureProblems, } from "../adapters/opencode/events.js";
7
+ import { opencodeRunArgs, providerOverlay, sessionEnv, writeConfigHome, } from "../adapters/opencode/runtime.js";
8
+ import { CONTAINER_CONFIG_HOME, CONTAINER_JOURNAL, CONTAINER_SUBJECTS, CONTAINER_WORKSPACE, normaliseMounts, runContainer, } from "../adapters/podman/container.js";
9
+ import { deliveryCheck, loadFrozen } from "./freeze.js";
10
+ import { stageTree } from "./inventory.js";
11
+ import { executePrimaryCheck } from "./primary.js";
12
+ import { appendLedgerEvent, readLedger, startedSlots, trialStarts, } from "./ledger.js";
13
+ import { loadPreparedManifest, } from "./prepare.js";
14
+ /** Path of one trial's evidence directory. */
15
+ export function trialDirectory(root, slotId) {
16
+ return join(root, "evidence", "trials", slotId);
17
+ }
18
+ /** Find one schedule slot by id. */
19
+ function slotOf(campaign, slotId) {
20
+ return campaign.schedule.slots.find((slot) => slot.slotId === slotId);
21
+ }
22
+ /** Find one staged arm by id. */
23
+ function armOf(manifest, armId) {
24
+ return manifest.subjects.find((arm) => arm.armId === armId);
25
+ }
26
+ /** A staged arm in the normalised campaign form. */
27
+ function campaignArm(campaign, armId) {
28
+ return campaign.subjects.find((arm) => arm.armId === armId);
29
+ }
30
+ /** Copy the arm's instruction overlays into the workspace. */
31
+ function stageInstructions(root, arm, workspace) {
32
+ const problems = [];
33
+ for (const instruction of arm.instructions) {
34
+ const target = join(workspace, instruction.as);
35
+ if (existsSync(target)) {
36
+ problems.push(`instruction ${instruction.id} collides with ${instruction.as}`);
37
+ continue;
38
+ }
39
+ mkdirSync(dirname(target), { recursive: true });
40
+ copyFileSync(join(root, "prepare", instruction.path), target);
41
+ }
42
+ return problems;
43
+ }
44
+ /** Copy the arm's skill bundles into the read-only config home. */
45
+ function stageSkills(root, arm, configHome) {
46
+ const problems = [];
47
+ for (const skill of arm.skills) {
48
+ const staged = stageTree(join(root, "prepare", skill.path), join(configHome, "opencode", "skills", skill.id));
49
+ if (!staged.ok) {
50
+ problems.push(`skill ${skill.id}: ${staged.problem}`);
51
+ }
52
+ }
53
+ return problems;
54
+ }
55
+ /** The mounts for one trial. */
56
+ function trialMounts(options) {
57
+ const mounts = [
58
+ {
59
+ source: options.workspace,
60
+ target: CONTAINER_WORKSPACE,
61
+ readOnly: false,
62
+ },
63
+ {
64
+ source: options.configHome,
65
+ target: CONTAINER_CONFIG_HOME,
66
+ readOnly: true,
67
+ },
68
+ {
69
+ source: options.subjects,
70
+ target: CONTAINER_SUBJECTS,
71
+ readOnly: true,
72
+ },
73
+ ];
74
+ if (options.journal !== null) {
75
+ mounts.push({
76
+ source: options.journal,
77
+ target: CONTAINER_JOURNAL,
78
+ readOnly: false,
79
+ });
80
+ }
81
+ return normaliseMounts(mounts);
82
+ }
83
+ /** Write the 0600 trial environment file and return its names. */
84
+ function writeTrialEnvironment(trialDir, campaign, journal, passthrough) {
85
+ const env = sessionEnv(process.env, {
86
+ configHome: CONTAINER_CONFIG_HOME,
87
+ dataHome: "/tmp/agent-home/.local/share",
88
+ cacheHome: "/tmp/agent-home/.cache",
89
+ overlay: providerOverlay(campaign),
90
+ passthrough,
91
+ });
92
+ if (journal !== null) {
93
+ env.DOCS_OPERATION_JOURNAL = `${CONTAINER_JOURNAL}/docs-operations.jsonl`;
94
+ env.DOCS_OPERATION_JOURNAL_KEY = randomBytes(32).toString("hex");
95
+ writeFileSync(join(trialDir, "journal-key.txt"), env.DOCS_OPERATION_JOURNAL_KEY, { encoding: "utf8", mode: 0o600 });
96
+ }
97
+ const names = [
98
+ "HOME",
99
+ "XDG_CONFIG_HOME",
100
+ "XDG_DATA_HOME",
101
+ "XDG_CACHE_HOME",
102
+ "OPENCODE_CONFIG_CONTENT",
103
+ ...(journal === null
104
+ ? []
105
+ : ["DOCS_OPERATION_JOURNAL", "DOCS_OPERATION_JOURNAL_KEY"]),
106
+ ...passthrough,
107
+ ];
108
+ const envFile = join(trialDir, "trial.env");
109
+ writeFileSync(envFile, `${names
110
+ .filter((name) => env[name] !== undefined)
111
+ .map((name) => `${name}=${env[name] ?? ""}`)
112
+ .join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
113
+ return { envFile, names };
114
+ }
115
+ /** Stage the workspace, config home and journal for one trial. */
116
+ function stageTrialPayload(root, campaign, armValue, arm, testCase, trialDir) {
117
+ mkdirSync(trialDir, { recursive: true });
118
+ const workspace = join(trialDir, "workspace");
119
+ const stagedWorkspace = stageTree(join(root, "prepare", "workspaces", testCase.caseId), workspace);
120
+ if (!stagedWorkspace.ok) {
121
+ return { ok: false, problems: [stagedWorkspace.problem] };
122
+ }
123
+ const instructionProblems = stageInstructions(root, arm, workspace);
124
+ if (instructionProblems.length > 0) {
125
+ return { ok: false, problems: instructionProblems };
126
+ }
127
+ const configHome = writeConfigHome(trialDir, campaign, armValue);
128
+ const skillProblems = stageSkills(root, arm, configHome);
129
+ if (skillProblems.length > 0) {
130
+ return { ok: false, problems: skillProblems };
131
+ }
132
+ const journal = testCase.kind === "docs-task" ? join(trialDir, "journal") : null;
133
+ if (journal !== null)
134
+ mkdirSync(journal, { recursive: true });
135
+ return { ok: true, workspace, configHome, journal };
136
+ }
137
+ /**
138
+ * Build the container plan for one trial without dispatching it. Dry runs and
139
+ * tests use the same plan the real runner executes.
140
+ * @param options The resolved trial inputs.
141
+ * @returns The validated plan, or the problems found while staging.
142
+ * @param options.root The root value.
143
+ * @param options.manifest The manifest value.
144
+ * @param options.frozen The frozen value.
145
+ * @param options.slot The slot value.
146
+ * @param options.testCase The testCase value.
147
+ * @param options.arm The arm value.
148
+ * @param options.trialDir The trialDir value.
149
+ * @param options.passthrough The passthrough value.
150
+ */
151
+ export function planTrial(options) {
152
+ const { root, manifest, frozen, slot, testCase, arm, trialDir } = options;
153
+ const campaign = manifest.campaign;
154
+ const armValue = campaignArm(campaign, slot.armId);
155
+ if (armValue === undefined) {
156
+ return {
157
+ ok: false,
158
+ problems: [`arm ${slot.armId} is not in the manifest`],
159
+ };
160
+ }
161
+ const payload = stageTrialPayload(root, campaign, armValue, arm, testCase, trialDir);
162
+ if (!payload.ok)
163
+ return payload;
164
+ const { workspace, configHome, journal } = payload;
165
+ const configPath = join(configHome, "opencode", "opencode.json");
166
+ const configSha256 = sha256Bytes(readFileSync(configPath));
167
+ const prompt = readPrompt(root, testCase.caseId);
168
+ if (prompt === null) {
169
+ return {
170
+ ok: false,
171
+ problems: [`prompt for ${testCase.caseId} is missing`],
172
+ };
173
+ }
174
+ const argv = [
175
+ "opencode",
176
+ ...opencodeRunArgs({
177
+ model: campaign.runtime.provider.model,
178
+ title: `otc-${campaign.campaignId}-${slot.slotId}`,
179
+ prompt,
180
+ printLogs: process.env.OTC_PRINT_LOGS === "1",
181
+ }),
182
+ ];
183
+ const passthrough = [
184
+ ...(campaign.runtime.passthroughEnv ?? []),
185
+ ...(options.passthrough ?? []),
186
+ ];
187
+ const { envFile, names } = writeTrialEnvironment(trialDir, campaign, journal, passthrough);
188
+ const plan = {
189
+ imageId: frozen.imageId,
190
+ mounts: trialMounts({
191
+ workspace,
192
+ configHome,
193
+ subjects: join(root, "prepare", "subjects", arm.armId),
194
+ journal,
195
+ }),
196
+ workdir: CONTAINER_WORKSPACE,
197
+ network: campaign.runtime.network,
198
+ argv,
199
+ envNames: [...new Set(names)],
200
+ envFile,
201
+ timeoutMs: campaign.runtime.timeoutMs,
202
+ };
203
+ return { ok: true, plan, argv, configSha256 };
204
+ }
205
+ /** Read one prepared prompt. */
206
+ function readPrompt(root, caseId) {
207
+ try {
208
+ return readFileSync(join(root, "prepare", "prompts", `${caseId}.txt`), "utf8");
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ /** Capture integrity problems from one completed container result. */
215
+ function captureIntegrity(result, trail) {
216
+ const integrity = captureProblems(trail);
217
+ if (result.status !== 0) {
218
+ integrity.push(`container exited ${String(result.status)}`);
219
+ }
220
+ if (result.timedOut)
221
+ integrity.push("container timed out");
222
+ if (result.error !== null) {
223
+ integrity.push(`container error: ${result.error}`);
224
+ }
225
+ return integrity;
226
+ }
227
+ /** Write all evidence for one completed trial. */
228
+ function writeTrialEvidence(trialDir, result, trail, meta, answerInWorkspace) {
229
+ writeFileSync(join(trialDir, "events.jsonl"), result.stdout, "utf8");
230
+ writeFileSync(join(trialDir, "answer.md"), trail.text, "utf8");
231
+ if (answerInWorkspace) {
232
+ const answerPath = join(trialDir, "workspace", "answer.md");
233
+ if (!existsSync(answerPath)) {
234
+ writeFileSync(answerPath, trail.text, "utf8");
235
+ }
236
+ }
237
+ writeFileSync(join(trialDir, "tools.jsonl"), trail.calls.length === 0
238
+ ? ""
239
+ : `${trail.calls.map((call) => JSON.stringify(call)).join("\n")}\n`, "utf8");
240
+ writeFileSync(join(trialDir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
241
+ writeFileSync(join(trialDir, "usage.json"), `${JSON.stringify(trail.usage, null, 2)}\n`, "utf8");
242
+ if (result.stderr !== "") {
243
+ writeFileSync(join(trialDir, "stderr.txt"), result.stderr, "utf8");
244
+ }
245
+ }
246
+ /** Execute one claimed trial and record its evidence. */
247
+ async function executeSlot(options) {
248
+ const { root, manifest, frozen, slot, planned, trialDir, execute, now } = options;
249
+ const startedAt = now();
250
+ const launch = {
251
+ schemaVersion: 1,
252
+ campaignId: manifest.campaignId,
253
+ slotId: slot.slotId,
254
+ caseId: slot.caseId,
255
+ armId: slot.armId,
256
+ repetitionId: slot.repetitionId,
257
+ imageId: frozen.imageId,
258
+ promptSha256: manifest.cases.find((entry) => entry.caseId === slot.caseId)
259
+ ?.promptSha256 ?? "unknown",
260
+ configSha256: planned.configSha256,
261
+ startedAt,
262
+ argv: planned.argv,
263
+ envNames: planned.plan.envNames,
264
+ };
265
+ writeFileSync(join(trialDir, "launch.json"), `${JSON.stringify(launch, null, 2)}\n`, "utf8");
266
+ appendLedgerEvent(root, {
267
+ event: "trial-start",
268
+ campaignId: manifest.campaignId,
269
+ slotId: slot.slotId,
270
+ trialId: slot.slotId,
271
+ caseId: slot.caseId,
272
+ armId: slot.armId,
273
+ repetitionId: slot.repetitionId,
274
+ imageId: frozen.imageId,
275
+ promptSha256: launch.promptSha256,
276
+ }, startedAt);
277
+ const started = Date.now();
278
+ const result = await execute(planned.plan);
279
+ if (planned.plan.envFile !== null) {
280
+ rmSync(planned.plan.envFile, { force: true });
281
+ }
282
+ const completedAt = now();
283
+ const trail = decodeTrail(result.stdout);
284
+ const integrity = captureIntegrity(result, trail);
285
+ const meta = {
286
+ schemaVersion: 1,
287
+ campaignId: manifest.campaignId,
288
+ slotId: slot.slotId,
289
+ caseId: slot.caseId,
290
+ armId: slot.armId,
291
+ repetitionId: slot.repetitionId,
292
+ exit: result.status,
293
+ signal: result.signal,
294
+ timedOut: result.timedOut,
295
+ error: result.error,
296
+ durationMs: Date.now() - started,
297
+ captureValid: integrity.length === 0,
298
+ captureProblems: integrity,
299
+ textParts: trail.textParts,
300
+ lastPartKind: trail.lastPartKind,
301
+ parseFailures: trail.parseFailures,
302
+ events: trail.events,
303
+ promptSha256: launch.promptSha256,
304
+ answerSha256: sha256Bytes(trail.text),
305
+ imageId: frozen.imageId,
306
+ completedAt,
307
+ };
308
+ writeTrialEvidence(trialDir, result, trail, meta, options.testCase.kind === "docs-task");
309
+ appendLedgerEvent(root, {
310
+ event: "trial-end",
311
+ campaignId: manifest.campaignId,
312
+ slotId: slot.slotId,
313
+ trialId: slot.slotId,
314
+ captureValid: meta.captureValid,
315
+ exit: meta.exit,
316
+ }, completedAt);
317
+ const preparedCase = manifest.cases.find((entry) => entry.caseId === slot.caseId);
318
+ if (options.testCase.kind !== "docs-task" ||
319
+ options.testCase.primaryCheck === undefined ||
320
+ preparedCase === undefined) {
321
+ return { meta, primaryProblems: [] };
322
+ }
323
+ const checked = await executePrimaryCheck({
324
+ root,
325
+ testCase: options.testCase,
326
+ prepared: preparedCase,
327
+ trialDir,
328
+ workspace: join(trialDir, "workspace"),
329
+ imageId: frozen.imageId,
330
+ run: (plan) => execute(plan),
331
+ now,
332
+ });
333
+ if (checked.record !== null) {
334
+ appendLedgerEvent(root, {
335
+ event: "primary-check",
336
+ campaignId: manifest.campaignId,
337
+ slotId: slot.slotId,
338
+ checkerId: checked.record.id,
339
+ status: checked.record.status,
340
+ exit: checked.record.exit,
341
+ sourceHash: checked.record.sourceHash,
342
+ }, now());
343
+ }
344
+ return {
345
+ meta,
346
+ primaryProblems: checked.problem === null ? [] : [checked.problem],
347
+ };
348
+ }
349
+ /** Resolve the selected slots, refusing an unknown selector. */
350
+ function selectedSlots(campaign, slotIds) {
351
+ if (slotIds === undefined)
352
+ return campaign.schedule.slots;
353
+ return slotIds.map((slotId) => {
354
+ const found = slotOf(campaign, slotId);
355
+ if (found === undefined)
356
+ throw new Error(`unknown slotId ${slotId}`);
357
+ return found;
358
+ });
359
+ }
360
+ /** The capture problems a slot's meta records. */
361
+ function metaProblems(slotId, meta) {
362
+ if (meta.captureValid)
363
+ return [];
364
+ return [
365
+ `slot ${slotId} capture is invalid: ${meta.captureProblems.join("; ")}`,
366
+ ];
367
+ }
368
+ /** Whether the campaign stops after this trial. */
369
+ function shouldStop(manifest, meta) {
370
+ return (manifest.campaign.stopOnFirstFailure === true &&
371
+ (!meta.captureValid || meta.exit !== 0));
372
+ }
373
+ /** Why a slot may not be dispatched now, or null. */
374
+ function slotPrecondition(root, manifest, slot, already) {
375
+ if (already.has(slot.slotId)) {
376
+ return {
377
+ ran: false,
378
+ refused: true,
379
+ problems: [`slot ${slot.slotId} has already started`],
380
+ stop: false,
381
+ };
382
+ }
383
+ if (trialStarts(readLedger(root).events).length >=
384
+ manifest.campaign.budgets.trials) {
385
+ return {
386
+ ran: false,
387
+ refused: false,
388
+ problems: [
389
+ `trial budget exhausted (${manifest.campaign.budgets.trials}) before slot ${slot.slotId}`,
390
+ ],
391
+ stop: true,
392
+ };
393
+ }
394
+ if (existsSync(trialDirectory(root, slot.slotId))) {
395
+ return {
396
+ ran: false,
397
+ refused: true,
398
+ problems: [
399
+ `slot ${slot.slotId} has a trial directory without a matching ledger start`,
400
+ ],
401
+ stop: false,
402
+ };
403
+ }
404
+ return null;
405
+ }
406
+ /** Attempt one slot: preconditions, planning, dispatch and evidence. */
407
+ async function dispatchSlot(options) {
408
+ const { root, manifest, frozen, slot, already, dryRun, execute, now } = options;
409
+ const precondition = slotPrecondition(root, manifest, slot, already);
410
+ if (precondition !== null)
411
+ return precondition;
412
+ const testCase = manifest.suite.cases.find((entry) => entry.caseId === slot.caseId);
413
+ const arm = armOf(manifest, slot.armId);
414
+ if (testCase === undefined || arm === undefined) {
415
+ return {
416
+ ran: false,
417
+ refused: false,
418
+ problems: [`slot ${slot.slotId} has no staged case or arm`],
419
+ stop: false,
420
+ };
421
+ }
422
+ const trialDir = trialDirectory(root, slot.slotId);
423
+ const planDirectory = dryRun
424
+ ? mkdtempSync(join(tmpdir(), "otc-plan-"))
425
+ : trialDir;
426
+ const planned = planTrial({
427
+ root,
428
+ manifest,
429
+ frozen,
430
+ slot,
431
+ testCase,
432
+ arm,
433
+ trialDir: planDirectory,
434
+ ...(options.passthrough === undefined
435
+ ? {}
436
+ : { passthrough: options.passthrough }),
437
+ });
438
+ if (!planned.ok) {
439
+ if (dryRun)
440
+ rmSync(planDirectory, { recursive: true, force: true });
441
+ return {
442
+ ran: false,
443
+ refused: false,
444
+ problems: planned.problems,
445
+ stop: false,
446
+ };
447
+ }
448
+ if (dryRun) {
449
+ rmSync(planDirectory, { recursive: true, force: true });
450
+ return { ran: true, refused: false, problems: [], stop: false };
451
+ }
452
+ const meta = await executeSlot({
453
+ root,
454
+ manifest,
455
+ frozen,
456
+ slot,
457
+ testCase,
458
+ planned,
459
+ trialDir,
460
+ execute,
461
+ now,
462
+ });
463
+ return {
464
+ ran: true,
465
+ refused: false,
466
+ problems: [
467
+ ...metaProblems(slot.slotId, meta.meta),
468
+ ...meta.primaryProblems,
469
+ ],
470
+ stop: shouldStop(manifest, meta.meta),
471
+ };
472
+ }
473
+ /** The validated state a run needs, or the refusal. */
474
+ function runState(root, current) {
475
+ const prepared = loadPreparedManifest(root);
476
+ if (!prepared.ok)
477
+ return { ok: false, problems: [prepared.problem] };
478
+ const frozen = loadFrozen(root);
479
+ if (!frozen.ok)
480
+ return { ok: false, problems: [frozen.problem] };
481
+ const differences = current === undefined
482
+ ? deliveryCheck(root)
483
+ : deliveryCheck(root, current);
484
+ if (differences.length > 0) {
485
+ return {
486
+ ok: false,
487
+ problems: ["delivery check failed", ...differences],
488
+ };
489
+ }
490
+ const ledger = readLedger(root);
491
+ if (ledger.state === "malformed") {
492
+ return {
493
+ ok: false,
494
+ problems: [
495
+ `attempt ledger is malformed: ${ledger.problems.join("; ")}`,
496
+ ],
497
+ };
498
+ }
499
+ if (ledger.state === "closed") {
500
+ return {
501
+ ok: false,
502
+ problems: ["campaign is closed; no further dispatch is permitted"],
503
+ };
504
+ }
505
+ return {
506
+ ok: true,
507
+ manifest: prepared.manifest,
508
+ frozen: frozen.record,
509
+ ledger,
510
+ };
511
+ }
512
+ /**
513
+ * Execute one or more scheduled slots. A launch receipt and ledger event are
514
+ * written before the container starts, so an interrupted dispatch remains
515
+ * visibly claimed rather than looking blank.
516
+ * @param root The frozen campaign root.
517
+ * @param options Slot selection and dry-run choice.
518
+ * @param options.slotIds The selected slots.
519
+ * @param options.dryRun Plan without dispatch.
520
+ * @param options.passthrough Named environment values to pass through.
521
+ * @param deps Injected process runner and clock.
522
+ * @returns What ran and what was refused.
523
+ */
524
+ export async function runCampaign(root, options = {}, deps = {}) {
525
+ const state = runState(root, deps.tool);
526
+ if (!state.ok) {
527
+ return { ok: false, ran: [], refused: [], problems: state.problems };
528
+ }
529
+ const manifest = state.manifest;
530
+ const selected = selectedSlots(manifest.campaign, options.slotIds);
531
+ const already = new Set(startedSlots(state.ledger.events));
532
+ const now = deps.now ?? (() => new Date().toISOString());
533
+ const execute = deps.runContainer ?? runContainer;
534
+ const ran = [];
535
+ const refused = [];
536
+ const problems = [];
537
+ for (const slot of selected) {
538
+ const outcome = await dispatchSlot({
539
+ root,
540
+ manifest,
541
+ frozen: state.frozen,
542
+ slot,
543
+ already,
544
+ dryRun: options.dryRun === true,
545
+ passthrough: options.passthrough,
546
+ execute,
547
+ now,
548
+ });
549
+ if (outcome.ran)
550
+ ran.push(slot.slotId);
551
+ if (outcome.refused)
552
+ refused.push(slot.slotId);
553
+ problems.push(...outcome.problems);
554
+ if (outcome.stop)
555
+ break;
556
+ }
557
+ return { ok: problems.length === 0, ran, refused, problems };
558
+ }
559
+ /** The canonical identity of a trial's immutable launch receipt. */
560
+ export function launchSha256(launch) {
561
+ return sha256Of(launch);
562
+ }
@@ -0,0 +1,76 @@
1
+ import type { LifecycleState } from "../contracts/types.ts";
2
+ import { type ToolIdentity } from "../contracts/version.ts";
3
+ /** A suggested next command, derived from checked state. */
4
+ export interface NextAction {
5
+ /** The command value. */
6
+ command: string;
7
+ /** The reason value. */
8
+ reason: string;
9
+ }
10
+ /** The read-only status document. */
11
+ export interface StatusReport {
12
+ /** The schema version value. */
13
+ schemaVersion: 1;
14
+ /** The state value. */
15
+ state: LifecycleState;
16
+ /** The campaign id value. */
17
+ campaignId: string | null;
18
+ /** The suite id value. */
19
+ suiteId: string | null;
20
+ /** The root value. */
21
+ root: string;
22
+ /** The image id value. */
23
+ imageId: string | null;
24
+ /** The frozen at value. */
25
+ frozenAt: string | null;
26
+ /** The closed at value. */
27
+ closedAt: string | null;
28
+ /** The counts value. */
29
+ counts: {
30
+ slots: number;
31
+ started: number;
32
+ completed: number;
33
+ assessed: number;
34
+ pass: number;
35
+ fail: number;
36
+ unknown: number;
37
+ };
38
+ /** The budgets value. */
39
+ budgets: {
40
+ trials: number;
41
+ used: number;
42
+ remaining: number;
43
+ } | null;
44
+ /** The executing tool identity. */
45
+ tool: ToolIdentity | null;
46
+ /** The judgement counts value. */
47
+ judgement: {
48
+ /** Slots with a checked selection. */
49
+ judged: number;
50
+ /** Completed slots still awaiting a usable judgement. */
51
+ pending: number;
52
+ /** Completed slots whose attempts ended without a usable verdict. */
53
+ failed: number;
54
+ /** Every recorded judge attempt. */
55
+ attempts: number;
56
+ /** Attempts that ended without a usable verdict. */
57
+ invalid: number;
58
+ /** Durable judge and recovery claims not yet closed. */
59
+ unsettled: number;
60
+ /** Packet and selection integrity diagnostics. */
61
+ problems: string[];
62
+ } | null;
63
+ /** The next actions value. */
64
+ nextActions: NextAction[];
65
+ /** The problems value. */
66
+ problems: string[];
67
+ }
68
+ /**
69
+ * Observe a campaign root without writing, building, probing or dispatching.
70
+ * Every next action is derived from the manifest and checked evidence.
71
+ * @param root The campaign root.
72
+ * @returns The status document.
73
+ */
74
+ export declare function statusOf(root: string): StatusReport;
75
+ /** Status for a slot's trial directory, used by reports and diagnostics. */
76
+ export declare function slotTrialExists(root: string, slotId: string): boolean;