harnery 0.10.0 → 0.12.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 (44) hide show
  1. package/README.md +1 -1
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/policy.d.ts +6 -0
  5. package/dist/commands/policy.d.ts.map +1 -0
  6. package/dist/commands/policy.js +42 -0
  7. package/dist/commands/workflow.d.ts.map +1 -1
  8. package/dist/commands/workflow.js +62 -2
  9. package/dist/core/policy/evaluate.d.ts +14 -0
  10. package/dist/core/policy/evaluate.d.ts.map +1 -0
  11. package/dist/core/policy/evaluate.js +238 -0
  12. package/dist/core/policy/file.d.ts +3 -0
  13. package/dist/core/policy/file.d.ts.map +1 -0
  14. package/dist/core/policy/file.js +20 -0
  15. package/dist/core/policy/index.d.ts +5 -0
  16. package/dist/core/policy/index.d.ts.map +1 -0
  17. package/dist/core/policy/index.js +3 -0
  18. package/dist/core/policy/types.d.ts +91 -0
  19. package/dist/core/policy/types.d.ts.map +1 -0
  20. package/dist/core/policy/types.js +1 -0
  21. package/dist/core/workflow/engine.d.ts +5 -0
  22. package/dist/core/workflow/engine.d.ts.map +1 -1
  23. package/dist/core/workflow/engine.js +412 -47
  24. package/dist/core/workflow/index.d.ts +5 -0
  25. package/dist/core/workflow/index.d.ts.map +1 -0
  26. package/dist/core/workflow/index.js +3 -0
  27. package/dist/core/workflow/proof.d.ts +49 -0
  28. package/dist/core/workflow/proof.d.ts.map +1 -0
  29. package/dist/core/workflow/proof.js +375 -0
  30. package/dist/core/workflow/types.d.ts +180 -0
  31. package/dist/core/workflow/types.d.ts.map +1 -1
  32. package/dist/core/workflow/types.js +1 -1
  33. package/package.json +16 -6
  34. package/src/commander.ts +2 -0
  35. package/src/commands/policy.ts +50 -0
  36. package/src/commands/workflow.ts +82 -2
  37. package/src/core/policy/evaluate.ts +311 -0
  38. package/src/core/policy/file.ts +20 -0
  39. package/src/core/policy/index.ts +26 -0
  40. package/src/core/policy/types.ts +107 -0
  41. package/src/core/workflow/engine.ts +452 -53
  42. package/src/core/workflow/index.ts +44 -0
  43. package/src/core/workflow/proof.ts +485 -0
  44. package/src/core/workflow/types.ts +210 -0
@@ -21,16 +21,36 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
+ import { snapshotRepo } from "../context/index.ts";
25
+ import type { ExternalMutationRequest, PolicyDecision, PolicyRequest } from "../policy/index.ts";
26
+ import {
27
+ evaluatePolicy,
28
+ normalizePolicy,
29
+ PolicyDeniedError,
30
+ policyDigest,
31
+ summarizePolicyRequest,
32
+ } from "../policy/index.ts";
24
33
  import { type BillingProbe, probeBilling } from "./billing.ts";
34
+ import {
35
+ buildWorkflowProof,
36
+ createEvidenceRecord,
37
+ digestResult,
38
+ normalizeWorkflowMeta,
39
+ writeWorkflowProof,
40
+ } from "./proof.ts";
25
41
  import type {
26
42
  AgentOpts,
27
43
  EngineOpts,
28
44
  HarnessName,
29
45
  RunReport,
30
46
  SpawnResult,
47
+ WorkflowAgentProof,
31
48
  WorkflowContext,
49
+ WorkflowEvidenceRecord,
32
50
  WorkflowModule,
51
+ WorkflowProof,
33
52
  } from "./types.ts";
53
+ import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
34
54
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
35
55
 
36
56
  const DEFAULT_MAX_AGENTS = 50;
@@ -38,6 +58,20 @@ const DEFAULT_CONCURRENCY = 4;
38
58
  const DEFAULT_MAX_ATTEMPTS = 2;
39
59
  const DEFAULT_TIMEOUT_MS = 300_000;
40
60
  const DEFAULT_MAX_TURNS = 25;
61
+ const DEFAULT_POLICY_ASK_TIMEOUT_MS = 60_000;
62
+ const MAX_POLICY_DECISIONS = 50;
63
+
64
+ export class WorkflowRunError extends Error {
65
+ readonly runId: string;
66
+ readonly proofPath: string;
67
+
68
+ constructor(message: string, runId: string, proofPath: string, cause?: unknown) {
69
+ super(message, cause === undefined ? undefined : { cause });
70
+ this.name = "WorkflowRunError";
71
+ this.runId = runId;
72
+ this.proofPath = proofPath;
73
+ }
74
+ }
41
75
 
42
76
  export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport> {
43
77
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
@@ -45,18 +79,25 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
45
79
  if (typeof mod.default !== "function") {
46
80
  throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
47
81
  }
48
- const name = mod.meta?.name ?? scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
82
+ const fallbackName = scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
83
+ const meta = normalizeWorkflowMeta(mod.meta, fallbackName);
84
+ const name = meta.name;
49
85
 
50
86
  const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
51
87
  const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
52
88
  mkdirSync(runDir, { recursive: true });
53
89
  const journalPath = join(runDir, "journal.jsonl");
90
+ const proofPath = join(runDir, "proof.json");
54
91
 
55
92
  const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
56
93
  const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
57
94
  const cwd = opts.cwd ?? opts.coordRoot;
58
95
  const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
59
96
  const defaultHarness: HarnessName = opts.defaultHarness ?? "claude-code";
97
+ const isolation = opts.isolation ?? "shared";
98
+ const networkAccess = opts.networkAccess ?? "unknown";
99
+ const policy = opts.policy ? normalizePolicy(opts.policy, { baseDir: cwd }) : undefined;
100
+ const repoBefore = snapshotRepo(cwd);
60
101
 
61
102
  // Per-child fixed context overhead: children spawn in `cwd` and load its
62
103
  // repo-instructions file into their system prompt, cache-writing it once
@@ -77,12 +118,21 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
77
118
  let agentsSpawned = 0;
78
119
  let agentsCached = 0;
79
120
  let costUsd = 0;
121
+ let reservedCostUsd = 0;
80
122
  let currentStage = "";
81
123
  let agentSeq = 0;
124
+ let evidenceSeq = 0;
125
+ let policySeq = 0;
82
126
  const billingProbed = new Map<HarnessName, BillingProbe>();
127
+ const agentProofs = new Map<string, WorkflowAgentProof>();
128
+ const evidenceRecords: WorkflowEvidenceRecord[] = [];
129
+ const policyDecisions: PolicyDecision[] = [];
130
+ const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
83
131
 
84
132
  const journal = (event: string, data: Record<string, unknown>): void => {
85
133
  const line = JSON.stringify({
134
+ schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
135
+ run_id: runId,
86
136
  ts: new Date().toISOString(),
87
137
  event,
88
138
  stage: currentStage,
@@ -109,7 +159,98 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
109
159
  waiters.shift()?.();
110
160
  };
111
161
 
162
+ // Policy checks that affect the shared cost reservation ledger serialize.
163
+ // Spawns still run concurrently after authorization; only the last-moment
164
+ // check-and-reserve section is exclusive.
165
+ let policyGate = Promise.resolve();
166
+ const withDispatchPolicyGate = async <T>(work: () => Promise<T>): Promise<T> => {
167
+ const previous = policyGate;
168
+ let open!: () => void;
169
+ policyGate = new Promise<void>((resolveGate) => {
170
+ open = resolveGate;
171
+ });
172
+ await previous;
173
+ try {
174
+ return await work();
175
+ } finally {
176
+ open();
177
+ }
178
+ };
179
+
180
+ const authorizePolicyRequest = async (rawRequest: PolicyRequest): Promise<PolicyDecision> => {
181
+ if (!policy) {
182
+ throw new Error("external mutation authorization requires a host policy");
183
+ }
184
+ if (policyDecisions.length >= MAX_POLICY_DECISIONS) {
185
+ throw new Error(
186
+ `workflow policy decision cap reached (${MAX_POLICY_DECISIONS}); split the workflow or reduce protected actions`,
187
+ );
188
+ }
189
+ const request = summarizePolicyRequest(rawRequest);
190
+ const evaluation = evaluatePolicy(policy, request);
191
+ const id = `p${++policySeq}`;
192
+ const checkedAt = new Date().toISOString();
193
+ journal("policy.check", {
194
+ id,
195
+ policy: policy.name,
196
+ policy_sha256: policyDigest(policy),
197
+ request,
198
+ verdict: evaluation.verdict,
199
+ rules: evaluation.rules,
200
+ });
201
+
202
+ let verdict: "allow" | "deny" = evaluation.verdict === "allow" ? "allow" : "deny";
203
+ let resolvedBy: PolicyDecision["resolved_by"] = "policy";
204
+ let reason = evaluation.reason;
205
+ if (evaluation.verdict === "ask") {
206
+ resolvedBy = "fail_closed";
207
+ if (!opts.resolvePolicyAsk) {
208
+ reason = `${evaluation.reason}; no host approval resolver is configured`;
209
+ } else {
210
+ try {
211
+ const resolution = await withTimeout(
212
+ Promise.resolve(opts.resolvePolicyAsk(request, evaluation)),
213
+ opts.policyAskTimeoutMs ?? DEFAULT_POLICY_ASK_TIMEOUT_MS,
214
+ );
215
+ if (!resolution || (resolution.verdict !== "allow" && resolution.verdict !== "deny")) {
216
+ reason = `${evaluation.reason}; host returned an invalid approval resolution`;
217
+ } else {
218
+ verdict = resolution.verdict;
219
+ resolvedBy = "host";
220
+ reason = boundedPolicyReason(resolution.reason ?? evaluation.reason);
221
+ }
222
+ } catch (error) {
223
+ reason = `${evaluation.reason}; approval failed closed: ${(error as Error).message}`;
224
+ }
225
+ }
226
+ }
227
+
228
+ const decision: PolicyDecision = {
229
+ id,
230
+ checked_at: checkedAt,
231
+ policy: policy.name,
232
+ phase: request.phase,
233
+ initial_verdict: evaluation.verdict,
234
+ verdict,
235
+ resolved_by: resolvedBy,
236
+ reason: boundedPolicyReason(reason),
237
+ rule_codes: evaluation.rules.map((rule) => rule.code),
238
+ request,
239
+ };
240
+ policyDecisions.push(decision);
241
+ journal("policy.resolve", { ...decision });
242
+ if (decision.verdict === "deny") {
243
+ throw new PolicyDeniedError(
244
+ `policy ${JSON.stringify(policy.name)} denied ${request.phase} ${JSON.stringify(request.action)}: ${decision.reason}`,
245
+ decision.id,
246
+ );
247
+ }
248
+ return decision;
249
+ };
250
+
112
251
  const agent = async (prompt: string, agentOpts: AgentOpts = {}): Promise<unknown> => {
252
+ let reservedForDispatch = 0;
253
+ let spawnCountClaimed = false;
113
254
  const harness = agentOpts.harness ?? defaultHarness;
114
255
  const spawner = opts.spawners[harness];
115
256
  if (!spawner) {
@@ -119,7 +260,19 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
119
260
  }
120
261
  const id = `a${++agentSeq}`;
121
262
  const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
263
+ const proofLabel = agentOpts.label ?? id;
122
264
  const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
265
+ const agentProof: WorkflowAgentProof = {
266
+ id,
267
+ label: proofLabel,
268
+ stage: currentStage || undefined,
269
+ harness,
270
+ model: agentOpts.model,
271
+ status: "failed",
272
+ attempts: 0,
273
+ duration_ms: 0,
274
+ };
275
+ agentProofs.set(id, agentProof);
123
276
 
124
277
  // Call identity for resume: same stage + harness + model + effort + turns + schema
125
278
  // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
@@ -127,63 +280,141 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
127
280
  const cached = resumeCache.get(key);
128
281
  if (cached) {
129
282
  agentsCached++;
130
- journal("agent.cached", { id, label, key, kind: cached.kind });
283
+ agentProof.status = "cached";
284
+ agentProof.result = digestResult(cached.value, cached.kind);
285
+ journal("agent.cached", {
286
+ id,
287
+ label,
288
+ key,
289
+ harness,
290
+ model: agentOpts.model ?? null,
291
+ kind: cached.kind,
292
+ });
131
293
  log(
132
294
  `[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`,
133
295
  );
134
296
  return cached.value;
135
297
  }
136
298
 
299
+ if (policy) {
300
+ const estimateRequest = summarizePolicyRequest({
301
+ phase: "dispatch",
302
+ action: "spawn agent",
303
+ path: cwd,
304
+ harness,
305
+ model: agentOpts.model,
306
+ effort: agentOpts.effort,
307
+ max_attempts: maxAttempts,
308
+ max_turns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
309
+ timeout_ms: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
310
+ prompt_bytes: Buffer.byteLength(prompt),
311
+ isolation,
312
+ network_access: networkAccess,
313
+ current_cost_usd: round4(costUsd + reservedCostUsd),
314
+ projected_cost_usd: null,
315
+ });
316
+ let projectedCost: number | null = null;
317
+ if (policy.max_cost_usd !== undefined && opts.estimateDispatchCost) {
318
+ try {
319
+ const candidate = await opts.estimateDispatchCost(estimateRequest);
320
+ if (candidate !== null && (!Number.isFinite(candidate) || candidate < 0)) {
321
+ throw new Error("cost estimator returned a non-finite or negative value");
322
+ }
323
+ projectedCost = candidate;
324
+ } catch (error) {
325
+ journal("policy.cost_estimate_failed", {
326
+ harness,
327
+ model: agentOpts.model ?? null,
328
+ error: boundedPolicyReason((error as Error).message),
329
+ });
330
+ }
331
+ }
332
+ try {
333
+ await withDispatchPolicyGate(async () => {
334
+ assertAgentCapacity(agentsSpawned, maxAgents);
335
+ const dispatchRequest = {
336
+ ...estimateRequest,
337
+ current_cost_usd: round4(costUsd + reservedCostUsd),
338
+ projected_cost_usd: projectedCost,
339
+ };
340
+ await authorizePolicyRequest(dispatchRequest);
341
+ if (policy.max_cost_usd !== undefined) {
342
+ reservedForDispatch =
343
+ projectedCost ?? Math.max(0, policy.max_cost_usd - dispatchRequest.current_cost_usd);
344
+ reservedCostUsd += reservedForDispatch;
345
+ }
346
+ agentsSpawned++;
347
+ spawnCountClaimed = true;
348
+ });
349
+ } catch (error) {
350
+ agentProof.error = (error as Error).message;
351
+ throw error;
352
+ }
353
+ } else {
354
+ try {
355
+ assertAgentCapacity(agentsSpawned, maxAgents);
356
+ } catch (error) {
357
+ agentProof.error = (error as Error).message;
358
+ throw error;
359
+ }
360
+ }
361
+
137
362
  // Billing safeguard: on a harness's FIRST spawn this run, classify which
138
363
  // auth its children will use and refuse the silent-override state (an
139
364
  // exported API key shadowing a stored subscription login) unless the
140
365
  // caller explicitly opted into API billing. Cached agents never reach
141
366
  // this — no spawn, no billing.
142
- if (!billingProbed.has(harness)) {
143
- const probe = (opts.probeBilling ?? probeBilling)(harness);
144
- billingProbed.set(harness, probe);
145
- journal("billing.probe", {
146
- harness,
147
- mode: opts.subscriptionOnly ? "subscription" : probe.mode,
148
- api_key_source: probe.apiKeySource,
149
- login: probe.login,
150
- subscription_only: Boolean(opts.subscriptionOnly),
151
- });
152
- if (opts.subscriptionOnly) {
153
- if (probe.login === "absent") {
367
+ try {
368
+ if (!billingProbed.has(harness)) {
369
+ const probe = (opts.probeBilling ?? probeBilling)(harness);
370
+ billingProbed.set(harness, probe);
371
+ journal("billing.probe", {
372
+ harness,
373
+ mode: opts.subscriptionOnly ? "subscription" : probe.mode,
374
+ api_key_source: probe.apiKeySource,
375
+ login: probe.login,
376
+ subscription_only: Boolean(opts.subscriptionOnly),
377
+ });
378
+ if (opts.subscriptionOnly) {
379
+ if (probe.login === "absent") {
380
+ throw new Error(
381
+ `subscription-only: no stored login detected for ${harness}; ` +
382
+ `log the harness CLI in (or drop --subscription-only for a key-only host)`,
383
+ );
384
+ }
385
+ log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
386
+ } else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
154
387
  throw new Error(
155
- `subscription-only: no stored login detected for ${harness}; ` +
156
- `log the harness CLI in (or drop --subscription-only for a key-only host)`,
388
+ `${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
389
+ `overrides your subscription auth, so children would bill per-token API rates. ` +
390
+ `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
391
+ `child envs, or pass --allow-api-billing if API billing is intended`,
392
+ );
393
+ } else if (probe.mode === "api-key") {
394
+ log(
395
+ `[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
396
+ `children bill per-token rates`,
397
+ );
398
+ } else if (probe.mode === "api-key-override") {
399
+ log(
400
+ `[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
157
401
  );
402
+ } else {
403
+ log(`[billing] ${harness}: subscription login`);
158
404
  }
159
- log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
160
- } else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
161
- throw new Error(
162
- `${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
163
- `overrides your subscription auth, so children would bill per-token API rates. ` +
164
- `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
165
- `child envs, or pass --allow-api-billing if API billing is intended`,
166
- );
167
- } else if (probe.mode === "api-key") {
168
- log(
169
- `[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
170
- `children bill per-token rates`,
171
- );
172
- } else if (probe.mode === "api-key-override") {
173
- log(
174
- `[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
175
- );
176
- } else {
177
- log(`[billing] ${harness}: subscription login`);
178
405
  }
406
+ } catch (error) {
407
+ reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
408
+ reservedForDispatch = 0;
409
+ if (spawnCountClaimed) {
410
+ agentsSpawned--;
411
+ spawnCountClaimed = false;
412
+ }
413
+ agentProof.error = (error as Error).message;
414
+ throw error;
179
415
  }
180
416
 
181
- if (agentsSpawned >= maxAgents) {
182
- throw new Error(
183
- `workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
184
- );
185
- }
186
- agentsSpawned++;
417
+ if (!spawnCountClaimed) agentsSpawned++;
187
418
 
188
419
  await acquire();
189
420
  try {
@@ -199,6 +430,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
199
430
 
200
431
  let attemptPrompt = prompt;
201
432
  let last: SpawnResult | null = null;
433
+ let agentCostUsd = 0;
202
434
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
203
435
  last = await spawner({
204
436
  prompt: attemptPrompt,
@@ -210,6 +442,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
210
442
  runId,
211
443
  subscriptionOnly: opts.subscriptionOnly,
212
444
  });
445
+ agentProof.attempts = attempt;
446
+ agentProof.duration_ms += last.durationMs;
447
+ agentCostUsd += last.costUsd ?? 0;
213
448
  costUsd += last.costUsd ?? 0;
214
449
 
215
450
  if (!last.ok) {
@@ -217,6 +452,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
217
452
  continue; // spawn-level failure: retry with the original prompt
218
453
  }
219
454
  if (!agentOpts.schema) {
455
+ agentProof.status = "succeeded";
456
+ agentProof.cost_usd =
457
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
458
+ agentProof.session_id = last.sessionId;
459
+ agentProof.result = digestResult(last.text, "text");
220
460
  journal("agent.end", {
221
461
  id,
222
462
  key,
@@ -236,6 +476,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
236
476
  ? [parsed.error]
237
477
  : validateAgainstSchema(parsed.value, agentOpts.schema);
238
478
  if (problems.length === 0) {
479
+ agentProof.status = "succeeded";
480
+ agentProof.cost_usd =
481
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
482
+ agentProof.session_id = last.sessionId;
483
+ agentProof.result = digestResult(parsed.value, "json");
239
484
  journal("agent.end", {
240
485
  id,
241
486
  key,
@@ -261,9 +506,17 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
261
506
  const reason = last?.ok
262
507
  ? `schema validation failed after ${maxAttempts} attempt(s)`
263
508
  : (last?.error ?? "spawn failed");
509
+ agentProof.cost_usd =
510
+ agentCostUsd > 0 || last?.costUsd !== undefined ? agentCostUsd : undefined;
511
+ agentProof.session_id = last?.sessionId;
512
+ agentProof.error = reason;
264
513
  journal("agent.failed", { id, error: reason });
265
- throw new Error(`agent ${id} (${label}): ${reason}`);
514
+ throw new Error(`agent ${proofLabel}: ${reason}`);
515
+ } catch (error) {
516
+ agentProof.error ??= (error as Error).message;
517
+ throw error;
266
518
  } finally {
519
+ reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
267
520
  release();
268
521
  }
269
522
  };
@@ -288,12 +541,91 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
288
541
  log(`[${name}] ── stage: ${title}`);
289
542
  };
290
543
 
291
- const ctx: WorkflowContext = { agent, parallel, stage, log };
544
+ const evidence = (input: Parameters<WorkflowContext["evidence"]>[0]): string => {
545
+ const record = createEvidenceRecord({
546
+ value: input,
547
+ sequence: evidenceSeq + 1,
548
+ acceptanceIds,
549
+ stage: currentStage || undefined,
550
+ });
551
+ evidenceSeq++;
552
+ evidenceRecords.push(record);
553
+ journal("evidence.recorded", { ...record });
554
+ return record.id;
555
+ };
556
+
557
+ const authorize = async (input: ExternalMutationRequest): Promise<PolicyDecision> => {
558
+ const network =
559
+ input.network === true || input.service !== undefined || input.target !== undefined
560
+ ? "enabled"
561
+ : "disabled";
562
+ return authorizePolicyRequest({
563
+ phase: "external_mutation",
564
+ action: input.action,
565
+ path: input.path ? resolve(cwd, input.path) : undefined,
566
+ isolation,
567
+ network_access: network,
568
+ service: input.service,
569
+ target: input.target,
570
+ current_cost_usd: round4(costUsd),
571
+ });
572
+ };
573
+
574
+ const ctx: WorkflowContext = { agent, parallel, stage, log, evidence, authorize };
292
575
 
293
576
  const t0 = Date.now();
294
- journal("run.start", { name, script: absScript, max_agents: maxAgents, concurrency });
577
+ const startedAt = new Date(t0).toISOString();
578
+ journal("run.start", {
579
+ name,
580
+ script: absScript,
581
+ objective: meta.objective ?? null,
582
+ acceptance: meta.acceptance,
583
+ max_agents: maxAgents,
584
+ concurrency,
585
+ policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
586
+ isolation,
587
+ network_access: networkAccess,
588
+ });
295
589
  try {
296
590
  const result = await mod.default(ctx);
591
+ const endedAt = new Date().toISOString();
592
+ const durationMs = Date.now() - t0;
593
+ journal("run.end", {
594
+ ok: true,
595
+ agents: agentsSpawned,
596
+ cached: agentsCached,
597
+ cost_usd: round4(costUsd),
598
+ duration_ms: durationMs,
599
+ });
600
+ let proof: WorkflowProof;
601
+ try {
602
+ proof = buildWorkflowProof({
603
+ runId,
604
+ meta,
605
+ status: "succeeded",
606
+ startedAt,
607
+ endedAt,
608
+ durationMs,
609
+ journalPath,
610
+ before: repoBefore,
611
+ after: snapshotRepo(cwd),
612
+ agents: Array.from(agentProofs.values()),
613
+ evidence: evidenceRecords,
614
+ harnessEvidence: opts.harnessEvidence,
615
+ policy: policy
616
+ ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
617
+ : undefined,
618
+ result,
619
+ });
620
+ writeWorkflowProof(proofPath, proof);
621
+ } catch (error) {
622
+ throw new WorkflowRunError(
623
+ `workflow completed but its proof packet could not be written: ${(error as Error).message}`,
624
+ runId,
625
+ proofPath,
626
+ error,
627
+ );
628
+ }
297
629
  const report: RunReport = {
298
630
  runId,
299
631
  name,
@@ -301,31 +633,59 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
301
633
  agentsSpawned,
302
634
  agentsCached,
303
635
  costUsd: round4(costUsd),
304
- durationMs: Date.now() - t0,
636
+ durationMs,
305
637
  journalPath,
638
+ proofPath,
639
+ acceptance: proof.acceptance.summary,
306
640
  contextTokensPerChildEstimate,
307
641
  billing: Array.from(billingProbed.values()).map((p) => ({
308
642
  harness: p.harness,
309
643
  mode: opts.subscriptionOnly ? "subscription" : p.mode,
310
644
  })),
645
+ policy: proof.policy?.summary,
311
646
  };
312
- journal("run.end", {
313
- ok: true,
314
- agents: agentsSpawned,
315
- cached: agentsCached,
316
- cost_usd: report.costUsd,
317
- duration_ms: report.durationMs,
318
- });
319
647
  return report;
320
648
  } catch (err) {
649
+ if (err instanceof WorkflowRunError) throw err;
650
+ const endedAt = new Date().toISOString();
651
+ const durationMs = Date.now() - t0;
321
652
  journal("run.end", {
322
653
  ok: false,
323
654
  error: (err as Error).message,
324
655
  agents: agentsSpawned,
325
656
  cached: agentsCached,
326
657
  cost_usd: round4(costUsd),
658
+ duration_ms: durationMs,
327
659
  });
328
- throw err;
660
+ try {
661
+ const proof = buildWorkflowProof({
662
+ runId,
663
+ meta,
664
+ status: "failed",
665
+ startedAt,
666
+ endedAt,
667
+ durationMs,
668
+ journalPath,
669
+ before: repoBefore,
670
+ after: snapshotRepo(cwd),
671
+ agents: Array.from(agentProofs.values()),
672
+ evidence: evidenceRecords,
673
+ harnessEvidence: opts.harnessEvidence,
674
+ policy: policy
675
+ ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
676
+ : undefined,
677
+ error: (err as Error).message,
678
+ });
679
+ writeWorkflowProof(proofPath, proof);
680
+ } catch (proofError) {
681
+ throw new WorkflowRunError(
682
+ `${(err as Error).message}; proof packet write also failed: ${(proofError as Error).message}`,
683
+ runId,
684
+ proofPath,
685
+ err,
686
+ );
687
+ }
688
+ throw new WorkflowRunError((err as Error).message, runId, proofPath, err);
329
689
  }
330
690
  }
331
691
 
@@ -401,3 +761,42 @@ function loadResumeCache(
401
761
  function round4(n: number): number {
402
762
  return Math.round(n * 10_000) / 10_000;
403
763
  }
764
+
765
+ function assertAgentCapacity(agentsSpawned: number, maxAgents: number): void {
766
+ if (agentsSpawned >= maxAgents) {
767
+ throw new Error(
768
+ `workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
769
+ );
770
+ }
771
+ }
772
+
773
+ function boundedPolicyReason(value: string): string {
774
+ const normalized = Array.from(value, (character) => {
775
+ const code = character.charCodeAt(0);
776
+ return code < 32 || code === 127 ? " " : character;
777
+ })
778
+ .join("")
779
+ .replace(/\s+/g, " ")
780
+ .trim();
781
+ return normalized.length > 2_000 ? `${normalized.slice(0, 1_999)}…` : normalized;
782
+ }
783
+
784
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
785
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
786
+ throw new Error("policyAskTimeoutMs must be a positive finite number");
787
+ }
788
+ let timer: ReturnType<typeof setTimeout> | undefined;
789
+ try {
790
+ return await Promise.race([
791
+ promise,
792
+ new Promise<never>((_, reject) => {
793
+ timer = setTimeout(
794
+ () => reject(new Error(`approval timed out after ${timeoutMs}ms`)),
795
+ timeoutMs,
796
+ );
797
+ }),
798
+ ]);
799
+ } finally {
800
+ if (timer) clearTimeout(timer);
801
+ }
802
+ }