harnery 0.12.2 → 0.14.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 (54) hide show
  1. package/README.md +2 -1
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/work.d.ts +4 -0
  5. package/dist/commands/work.d.ts.map +1 -0
  6. package/dist/commands/work.js +281 -0
  7. package/dist/commands/workflow.d.ts.map +1 -1
  8. package/dist/commands/workflow.js +193 -0
  9. package/dist/core/policy/types.d.ts +1 -1
  10. package/dist/core/policy/types.d.ts.map +1 -1
  11. package/dist/core/work/index.d.ts +3 -0
  12. package/dist/core/work/index.d.ts.map +1 -0
  13. package/dist/core/work/index.js +2 -0
  14. package/dist/core/work/read.d.ts +2 -0
  15. package/dist/core/work/read.d.ts.map +1 -0
  16. package/dist/core/work/read.js +1 -0
  17. package/dist/core/work/runner.d.ts +10 -0
  18. package/dist/core/work/runner.d.ts.map +1 -0
  19. package/dist/core/work/runner.js +63 -0
  20. package/dist/core/work/state.d.ts +109 -0
  21. package/dist/core/work/state.d.ts.map +1 -0
  22. package/dist/core/work/state.js +698 -0
  23. package/dist/core/workflow/approvals.d.ts +71 -0
  24. package/dist/core/workflow/approvals.d.ts.map +1 -0
  25. package/dist/core/workflow/approvals.js +288 -0
  26. package/dist/core/workflow/engine.d.ts +6 -0
  27. package/dist/core/workflow/engine.d.ts.map +1 -1
  28. package/dist/core/workflow/engine.js +243 -43
  29. package/dist/core/workflow/index.d.ts +6 -2
  30. package/dist/core/workflow/index.d.ts.map +1 -1
  31. package/dist/core/workflow/index.js +3 -1
  32. package/dist/core/workflow/proof.d.ts +1 -0
  33. package/dist/core/workflow/proof.d.ts.map +1 -1
  34. package/dist/core/workflow/proof.js +1 -0
  35. package/dist/core/workflow/run-state.d.ts +44 -0
  36. package/dist/core/workflow/run-state.d.ts.map +1 -0
  37. package/dist/core/workflow/run-state.js +233 -0
  38. package/dist/core/workflow/types.d.ts +16 -0
  39. package/dist/core/workflow/types.d.ts.map +1 -1
  40. package/package.json +11 -1
  41. package/src/commander.ts +2 -0
  42. package/src/commands/work.ts +367 -0
  43. package/src/commands/workflow.ts +226 -0
  44. package/src/core/policy/types.ts +1 -1
  45. package/src/core/work/index.ts +24 -0
  46. package/src/core/work/read.ts +15 -0
  47. package/src/core/work/runner.ts +81 -0
  48. package/src/core/work/state.ts +900 -0
  49. package/src/core/workflow/approvals.ts +393 -0
  50. package/src/core/workflow/engine.ts +288 -44
  51. package/src/core/workflow/index.ts +31 -1
  52. package/src/core/workflow/proof.ts +2 -0
  53. package/src/core/workflow/run-state.ts +290 -0
  54. package/src/core/workflow/types.ts +16 -0
@@ -22,7 +22,12 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "n
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { snapshotRepo } from "../context/index.ts";
25
- import type { ExternalMutationRequest, PolicyDecision, PolicyRequest } from "../policy/index.ts";
25
+ import type {
26
+ ExternalMutationRequest,
27
+ NormalizedPolicy,
28
+ PolicyDecision,
29
+ PolicyRequest,
30
+ } from "../policy/index.ts";
26
31
  import {
27
32
  evaluatePolicy,
28
33
  normalizePolicy,
@@ -30,6 +35,7 @@ import {
30
35
  policyDigest,
31
36
  summarizePolicyRequest,
32
37
  } from "../policy/index.ts";
38
+ import { assertWorkflowRunId, createWorkflowApproval } from "./approvals.ts";
33
39
  import { type BillingProbe, probeBilling } from "./billing.ts";
34
40
  import {
35
41
  buildWorkflowProof,
@@ -38,6 +44,13 @@ import {
38
44
  normalizeWorkflowMeta,
39
45
  writeWorkflowProof,
40
46
  } from "./proof.ts";
47
+ import {
48
+ acquireWorkflowResumeLease,
49
+ assertWorkflowRunResumable,
50
+ assertWorkflowScriptUnchanged,
51
+ workflowScriptDigest,
52
+ writeWorkflowRunManifest,
53
+ } from "./run-state.ts";
41
54
  import type {
42
55
  AgentOpts,
43
56
  EngineOpts,
@@ -73,8 +86,69 @@ export class WorkflowRunError extends Error {
73
86
  }
74
87
  }
75
88
 
89
+ export class WorkflowParkedError extends Error {
90
+ readonly runId: string;
91
+ readonly approvalId: string;
92
+ readonly journalPath: string;
93
+
94
+ constructor(message: string, runId: string, approvalId: string, journalPath: string) {
95
+ super(message);
96
+ this.name = "WorkflowParkedError";
97
+ this.runId = runId;
98
+ this.approvalId = approvalId;
99
+ this.journalPath = journalPath;
100
+ }
101
+ }
102
+
76
103
  export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport> {
104
+ if (opts.runId && opts.resumeRunId) {
105
+ throw new Error("runId and resumeRunId are mutually exclusive");
106
+ }
107
+ if (opts.runId) assertWorkflowRunId(opts.runId);
108
+ if (opts.workItemId && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(opts.workItemId)) {
109
+ throw new Error(`invalid work item id ${JSON.stringify(opts.workItemId)}`);
110
+ }
111
+ if (opts.resumeRunId && opts.resumeFrom) {
112
+ throw new Error("resumeRunId and resumeFrom are mutually exclusive");
113
+ }
114
+ const resumeState = opts.resumeRunId
115
+ ? assertWorkflowRunResumable(opts.coordRoot, opts.resumeRunId)
116
+ : undefined;
117
+ if (
118
+ resumeState !== undefined &&
119
+ opts.workItemId !== undefined &&
120
+ resumeState.manifest.work_item_id !== opts.workItemId
121
+ ) {
122
+ throw new Error(`workflow run ${opts.resumeRunId} belongs to a different work item`);
123
+ }
77
124
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
125
+ if (resumeState) {
126
+ if (resolve(resumeState.manifest.script.path) !== resolve(absScript)) {
127
+ throw new Error(`workflow run ${opts.resumeRunId} belongs to a different script`);
128
+ }
129
+ assertWorkflowScriptUnchanged(resumeState.manifest);
130
+ }
131
+ const releaseResumeLease = opts.resumeRunId
132
+ ? acquireWorkflowResumeLease(opts.coordRoot, opts.resumeRunId)
133
+ : undefined;
134
+ try {
135
+ if (resumeState) {
136
+ // Recheck under the exclusive lease. Another process may have completed
137
+ // the run between the optimistic read and lease acquisition.
138
+ assertWorkflowRunResumable(opts.coordRoot, resumeState.manifest.run_id);
139
+ }
140
+ return await executeWorkflow(scriptPath, absScript, opts, resumeState);
141
+ } finally {
142
+ releaseResumeLease?.();
143
+ }
144
+ }
145
+
146
+ async function executeWorkflow(
147
+ scriptPath: string,
148
+ absScript: string,
149
+ opts: EngineOpts,
150
+ resumeState: ReturnType<typeof assertWorkflowRunResumable> | undefined,
151
+ ): Promise<RunReport> {
78
152
  const mod = (await import(pathToFileURL(absScript).href)) as WorkflowModule;
79
153
  if (typeof mod.default !== "function") {
80
154
  throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
@@ -83,21 +157,63 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
83
157
  const meta = normalizeWorkflowMeta(mod.meta, fallbackName);
84
158
  const name = meta.name;
85
159
 
86
- const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
160
+ const runId =
161
+ opts.resumeRunId ??
162
+ opts.runId ??
163
+ `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
87
164
  const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
88
165
  mkdirSync(runDir, { recursive: true });
89
166
  const journalPath = join(runDir, "journal.jsonl");
90
167
  const proofPath = join(runDir, "proof.json");
91
168
 
92
- const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
93
- const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
94
- const cwd = opts.cwd ?? opts.coordRoot;
169
+ const frozen = resumeState?.manifest.execution;
170
+ const maxAgents = frozen?.max_agents ?? opts.maxAgents ?? DEFAULT_MAX_AGENTS;
171
+ const concurrency = frozen?.concurrency ?? opts.concurrency ?? DEFAULT_CONCURRENCY;
172
+ assertPositiveWorkflowBound(maxAgents, "maxAgents");
173
+ assertPositiveWorkflowBound(concurrency, "concurrency");
174
+ const cwd = frozen?.cwd ?? opts.cwd ?? opts.coordRoot;
95
175
  const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
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);
176
+ const defaultHarness: HarnessName =
177
+ frozen?.default_harness ?? opts.defaultHarness ?? "claude-code";
178
+ const isolation = frozen?.isolation ?? opts.isolation ?? "shared";
179
+ const networkAccess = frozen?.network_access ?? opts.networkAccess ?? "unknown";
180
+ const policy =
181
+ frozen?.policy ?? (opts.policy ? normalizePolicy(opts.policy, { baseDir: cwd }) : undefined);
182
+ const approvalMode = frozen?.approval_mode ?? opts.approvalMode ?? "deny";
183
+ const approvalAddressee = frozen?.approval_addressee ?? opts.approvalAddressee ?? "operator";
184
+ const subscriptionOnly = frozen?.subscription_only ?? Boolean(opts.subscriptionOnly);
185
+ const allowApiBilling = frozen?.allow_api_billing ?? Boolean(opts.allowApiBilling);
186
+ const repoBefore = resumeState?.manifest.repository_before ?? snapshotRepo(cwd);
187
+ const startedAt = resumeState?.manifest.started_at ?? new Date().toISOString();
188
+ const t0 = resumeState ? Date.parse(startedAt) : Date.now();
189
+
190
+ if (!resumeState) {
191
+ writeWorkflowRunManifest({
192
+ coordRoot: opts.coordRoot,
193
+ manifest: {
194
+ schema_version: 1,
195
+ run_id: runId,
196
+ work_item_id: opts.workItemId,
197
+ name,
198
+ started_at: startedAt,
199
+ script: { path: absScript, sha256: workflowScriptDigest(absScript) },
200
+ repository_before: repoBefore,
201
+ execution: {
202
+ cwd,
203
+ default_harness: defaultHarness,
204
+ max_agents: maxAgents,
205
+ concurrency,
206
+ subscription_only: subscriptionOnly,
207
+ allow_api_billing: allowApiBilling,
208
+ approval_mode: approvalMode,
209
+ approval_addressee: approvalAddressee,
210
+ isolation,
211
+ network_access: networkAccess,
212
+ policy: policy ? (policy as NormalizedPolicy) : undefined,
213
+ },
214
+ },
215
+ });
216
+ }
101
217
 
102
218
  // Per-child fixed context overhead: children spawn in `cwd` and load its
103
219
  // repo-instructions file into their system prompt, cache-writing it once
@@ -111,13 +227,16 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
111
227
  }
112
228
 
113
229
  // Resume: journaled results of a prior run, keyed by agent-call identity.
114
- const resumeCache = opts.resumeFrom
115
- ? loadResumeCache(opts.coordRoot, opts.resumeFrom)
230
+ const resumeSource = opts.resumeRunId ?? opts.resumeFrom;
231
+ const resumeCache = resumeSource
232
+ ? loadResumeCache(opts.coordRoot, resumeSource)
116
233
  : new Map<string, { kind: "json" | "text"; value: unknown }>();
117
234
 
118
- let agentsSpawned = 0;
235
+ const history = opts.resumeRunId ? loadRunHistory(journalPath) : undefined;
236
+
237
+ let agentsSpawned = history?.agentsSpawned ?? 0;
119
238
  let agentsCached = 0;
120
- let costUsd = 0;
239
+ let costUsd = history?.costUsd ?? 0;
121
240
  let reservedCostUsd = 0;
122
241
  let currentStage = "";
123
242
  let agentSeq = 0;
@@ -126,7 +245,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
126
245
  const billingProbed = new Map<HarnessName, BillingProbe>();
127
246
  const agentProofs = new Map<string, WorkflowAgentProof>();
128
247
  const evidenceRecords: WorkflowEvidenceRecord[] = [];
129
- const policyDecisions: PolicyDecision[] = [];
248
+ const policyDecisions = new Map<string, PolicyDecision>(
249
+ history?.policyDecisions.map((decision) => [decision.id, decision]),
250
+ );
130
251
  const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
131
252
 
132
253
  const journal = (event: string, data: Record<string, unknown>): void => {
@@ -181,7 +302,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
181
302
  if (!policy) {
182
303
  throw new Error("external mutation authorization requires a host policy");
183
304
  }
184
- if (policyDecisions.length >= MAX_POLICY_DECISIONS) {
305
+ if (policyDecisions.size >= MAX_POLICY_DECISIONS && !policyDecisions.has(`p${policySeq + 1}`)) {
185
306
  throw new Error(
186
307
  `workflow policy decision cap reached (${MAX_POLICY_DECISIONS}); split the workflow or reduce protected actions`,
187
308
  );
@@ -204,9 +325,8 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
204
325
  let reason = evaluation.reason;
205
326
  if (evaluation.verdict === "ask") {
206
327
  resolvedBy = "fail_closed";
207
- if (!opts.resolvePolicyAsk) {
208
- reason = `${evaluation.reason}; no host approval resolver is configured`;
209
- } else {
328
+ let immediateResolution = false;
329
+ if (opts.resolvePolicyAsk) {
210
330
  try {
211
331
  const resolution = await withTimeout(
212
332
  Promise.resolve(opts.resolvePolicyAsk(request, evaluation)),
@@ -218,10 +338,57 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
218
338
  verdict = resolution.verdict;
219
339
  resolvedBy = "host";
220
340
  reason = boundedPolicyReason(resolution.reason ?? evaluation.reason);
341
+ immediateResolution = true;
221
342
  }
222
343
  } catch (error) {
223
344
  reason = `${evaluation.reason}; approval failed closed: ${(error as Error).message}`;
224
345
  }
346
+ } else {
347
+ reason = `${evaluation.reason}; no host approval resolver is configured`;
348
+ }
349
+
350
+ if (!immediateResolution && approvalMode === "park") {
351
+ const stored = createWorkflowApproval({
352
+ coordRoot: opts.coordRoot,
353
+ runId,
354
+ decisionId: id,
355
+ addressedTo: approvalAddressee,
356
+ policy: { name: policy.name, sha256: policyDigest(policy) },
357
+ request,
358
+ evaluation,
359
+ });
360
+ if (stored.approval.status === "pending") {
361
+ if (stored.created) {
362
+ journal("approval.requested", {
363
+ approval_id: stored.approval.request.id,
364
+ decision_id: id,
365
+ addressed_to: stored.approval.request.addressed_to,
366
+ request_sha256: stored.approval.request.request_sha256,
367
+ });
368
+ }
369
+ journal("run.parked", {
370
+ approval_id: stored.approval.request.id,
371
+ decision_id: id,
372
+ phase: request.phase,
373
+ action: request.action,
374
+ });
375
+ throw new WorkflowParkedError(
376
+ `workflow parked for approval ${stored.approval.request.id}: ${evaluation.reason}`,
377
+ runId,
378
+ stored.approval.request.id,
379
+ journalPath,
380
+ );
381
+ }
382
+ verdict = stored.approval.decision!.verdict;
383
+ resolvedBy = "approval";
384
+ reason = boundedPolicyReason(stored.approval.decision!.reason ?? evaluation.reason);
385
+ journal("approval.consumed", {
386
+ approval_id: stored.approval.request.id,
387
+ decision_id: id,
388
+ verdict,
389
+ actor: stored.approval.decision!.actor,
390
+ decided_at: stored.approval.decision!.decided_at,
391
+ });
225
392
  }
226
393
  }
227
394
 
@@ -237,7 +404,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
237
404
  rule_codes: evaluation.rules.map((rule) => rule.code),
238
405
  request,
239
406
  };
240
- policyDecisions.push(decision);
407
+ policyDecisions.set(decision.id, decision);
241
408
  journal("policy.resolve", { ...decision });
242
409
  if (decision.verdict === "deny") {
243
410
  throw new PolicyDeniedError(
@@ -279,6 +446,10 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
279
446
  const key = agentCallKey(currentStage, harness, agentOpts, prompt);
280
447
  const cached = resumeCache.get(key);
281
448
  if (cached) {
449
+ // Exact-run replay skips dispatch authorization because no dispatch
450
+ // occurs, but it must still reserve the original policy slot so a later
451
+ // durable ASK resolves against the same pN identity.
452
+ if (policy && opts.resumeRunId) policySeq++;
282
453
  agentsCached++;
283
454
  agentProof.status = "cached";
284
455
  agentProof.result = digestResult(cached.value, cached.kind);
@@ -291,7 +462,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
291
462
  kind: cached.kind,
292
463
  });
293
464
  log(
294
- `[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`,
465
+ `[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${resumeSource})`,
295
466
  );
296
467
  return cached.value;
297
468
  }
@@ -370,12 +541,12 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
370
541
  billingProbed.set(harness, probe);
371
542
  journal("billing.probe", {
372
543
  harness,
373
- mode: opts.subscriptionOnly ? "subscription" : probe.mode,
544
+ mode: subscriptionOnly ? "subscription" : probe.mode,
374
545
  api_key_source: probe.apiKeySource,
375
546
  login: probe.login,
376
- subscription_only: Boolean(opts.subscriptionOnly),
547
+ subscription_only: subscriptionOnly,
377
548
  });
378
- if (opts.subscriptionOnly) {
549
+ if (subscriptionOnly) {
379
550
  if (probe.login === "absent") {
380
551
  throw new Error(
381
552
  `subscription-only: no stored login detected for ${harness}; ` +
@@ -383,7 +554,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
383
554
  );
384
555
  }
385
556
  log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
386
- } else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
557
+ } else if (probe.mode === "api-key-override" && !allowApiBilling) {
387
558
  throw new Error(
388
559
  `${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
389
560
  `overrides your subscription auth, so children would bill per-token API rates. ` +
@@ -440,7 +611,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
440
611
  maxTurns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
441
612
  cwd,
442
613
  runId,
443
- subscriptionOnly: opts.subscriptionOnly,
614
+ subscriptionOnly,
444
615
  });
445
616
  agentProof.attempts = attempt;
446
617
  agentProof.duration_ms += last.durationMs;
@@ -462,6 +633,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
462
633
  key,
463
634
  attempts: attempt,
464
635
  cost_usd: last.costUsd,
636
+ total_cost_usd: agentCostUsd,
465
637
  duration_ms: last.durationMs,
466
638
  session_id: last.sessionId,
467
639
  result_kind: "text",
@@ -486,6 +658,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
486
658
  key,
487
659
  attempts: attempt,
488
660
  cost_usd: last.costUsd,
661
+ total_cost_usd: agentCostUsd,
489
662
  duration_ms: last.durationMs,
490
663
  session_id: last.sessionId,
491
664
  result_kind: "json",
@@ -572,21 +745,27 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
572
745
  };
573
746
 
574
747
  const ctx: WorkflowContext = { agent, parallel, stage, log, evidence, authorize };
575
-
576
- const t0 = Date.now();
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
- });
589
748
  try {
749
+ if (resumeState) {
750
+ journal("run.resume", {
751
+ approval_id: resumeState.approvalId,
752
+ script: absScript,
753
+ resumed_at: new Date().toISOString(),
754
+ });
755
+ } else {
756
+ journal("run.start", {
757
+ name,
758
+ work_item_id: opts.workItemId ?? null,
759
+ script: absScript,
760
+ objective: meta.objective ?? null,
761
+ acceptance: meta.acceptance,
762
+ max_agents: maxAgents,
763
+ concurrency,
764
+ policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
765
+ isolation,
766
+ network_access: networkAccess,
767
+ });
768
+ }
590
769
  const result = await mod.default(ctx);
591
770
  const endedAt = new Date().toISOString();
592
771
  const durationMs = Date.now() - t0;
@@ -601,6 +780,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
601
780
  try {
602
781
  proof = buildWorkflowProof({
603
782
  runId,
783
+ workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
604
784
  meta,
605
785
  status: "succeeded",
606
786
  startedAt,
@@ -613,7 +793,12 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
613
793
  evidence: evidenceRecords,
614
794
  harnessEvidence: opts.harnessEvidence,
615
795
  policy: policy
616
- ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
796
+ ? {
797
+ config: policy,
798
+ decisions: Array.from(policyDecisions.values()),
799
+ isolation,
800
+ networkAccess,
801
+ }
617
802
  : undefined,
618
803
  result,
619
804
  });
@@ -628,6 +813,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
628
813
  }
629
814
  const report: RunReport = {
630
815
  runId,
816
+ workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
631
817
  name,
632
818
  result,
633
819
  agentsSpawned,
@@ -640,13 +826,13 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
640
826
  contextTokensPerChildEstimate,
641
827
  billing: Array.from(billingProbed.values()).map((p) => ({
642
828
  harness: p.harness,
643
- mode: opts.subscriptionOnly ? "subscription" : p.mode,
829
+ mode: subscriptionOnly ? "subscription" : p.mode,
644
830
  })),
645
831
  policy: proof.policy?.summary,
646
832
  };
647
833
  return report;
648
834
  } catch (err) {
649
- if (err instanceof WorkflowRunError) throw err;
835
+ if (err instanceof WorkflowParkedError || err instanceof WorkflowRunError) throw err;
650
836
  const endedAt = new Date().toISOString();
651
837
  const durationMs = Date.now() - t0;
652
838
  journal("run.end", {
@@ -660,6 +846,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
660
846
  try {
661
847
  const proof = buildWorkflowProof({
662
848
  runId,
849
+ workItemId: opts.workItemId ?? resumeState?.manifest.work_item_id,
663
850
  meta,
664
851
  status: "failed",
665
852
  startedAt,
@@ -672,7 +859,12 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
672
859
  evidence: evidenceRecords,
673
860
  harnessEvidence: opts.harnessEvidence,
674
861
  policy: policy
675
- ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
862
+ ? {
863
+ config: policy,
864
+ decisions: Array.from(policyDecisions.values()),
865
+ isolation,
866
+ networkAccess,
867
+ }
676
868
  : undefined,
677
869
  error: (err as Error).message,
678
870
  });
@@ -758,6 +950,52 @@ function loadResumeCache(
758
950
  return cache;
759
951
  }
760
952
 
953
+ function loadRunHistory(path: string): {
954
+ agentsSpawned: number;
955
+ costUsd: number;
956
+ policyDecisions: PolicyDecision[];
957
+ } {
958
+ const agentIds = new Set<string>();
959
+ const agentCosts = new Map<string, number>();
960
+ const decisions = new Map<string, PolicyDecision>();
961
+ for (const line of readFileSync(path, "utf8").split("\n")) {
962
+ if (!line.trim()) continue;
963
+ const event = JSON.parse(line) as {
964
+ event?: string;
965
+ id?: string;
966
+ cost_usd?: number;
967
+ total_cost_usd?: number;
968
+ verdict?: unknown;
969
+ resolved_by?: unknown;
970
+ request?: unknown;
971
+ };
972
+ if (event.event === "agent.start" && typeof event.id === "string") {
973
+ agentIds.add(event.id);
974
+ }
975
+ if (event.event === "agent.end" && typeof event.id === "string") {
976
+ const cost = event.total_cost_usd ?? event.cost_usd;
977
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
978
+ agentCosts.set(event.id, cost);
979
+ }
980
+ }
981
+ if (
982
+ event.event === "policy.resolve" &&
983
+ typeof event.id === "string" &&
984
+ (event.verdict === "allow" || event.verdict === "deny") &&
985
+ typeof event.resolved_by === "string" &&
986
+ event.request &&
987
+ typeof event.request === "object"
988
+ ) {
989
+ decisions.set(event.id, event as PolicyDecision);
990
+ }
991
+ }
992
+ return {
993
+ agentsSpawned: agentIds.size,
994
+ costUsd: round4(Array.from(agentCosts.values()).reduce((sum, value) => sum + value, 0)),
995
+ policyDecisions: Array.from(decisions.values()),
996
+ };
997
+ }
998
+
761
999
  function round4(n: number): number {
762
1000
  return Math.round(n * 10_000) / 10_000;
763
1001
  }
@@ -770,6 +1008,12 @@ function assertAgentCapacity(agentsSpawned: number, maxAgents: number): void {
770
1008
  }
771
1009
  }
772
1010
 
1011
+ function assertPositiveWorkflowBound(value: number, field: string): void {
1012
+ if (!Number.isSafeInteger(value) || value <= 0) {
1013
+ throw new Error(`${field} must be a positive safe integer`);
1014
+ }
1015
+ }
1016
+
773
1017
  function boundedPolicyReason(value: string): string {
774
1018
  const normalized = Array.from(value, (character) => {
775
1019
  const code = character.charCodeAt(0);
@@ -1,4 +1,22 @@
1
- export { runWorkflow, WorkflowRunError } from "./engine.ts";
1
+ export type {
2
+ CreateWorkflowApprovalInput,
3
+ ResolveWorkflowApprovalInput,
4
+ WorkflowApproval,
5
+ WorkflowApprovalDecision,
6
+ WorkflowApprovalRequest,
7
+ WorkflowApprovalStatus,
8
+ } from "./approvals.ts";
9
+ export {
10
+ assertWorkflowRunId,
11
+ createWorkflowApproval,
12
+ listWorkflowApprovals,
13
+ readWorkflowApproval,
14
+ renderWorkflowApproval,
15
+ resolveWorkflowApproval,
16
+ WORKFLOW_APPROVAL_SCHEMA_VERSION,
17
+ workflowApprovalId,
18
+ } from "./approvals.ts";
19
+ export { runWorkflow, WorkflowParkedError, WorkflowRunError } from "./engine.ts";
2
20
  export {
3
21
  buildWorkflowProof,
4
22
  createEvidenceRecord,
@@ -9,6 +27,17 @@ export {
9
27
  rollupAcceptance,
10
28
  writeWorkflowProof,
11
29
  } from "./proof.ts";
30
+ export type { WorkflowRunManifest } from "./run-state.ts";
31
+ export {
32
+ acquireWorkflowResumeLease,
33
+ assertWorkflowRunResumable,
34
+ assertWorkflowScriptUnchanged,
35
+ readWorkflowRunManifest,
36
+ WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION,
37
+ workflowRunManifestPath,
38
+ workflowScriptDigest,
39
+ writeWorkflowRunManifest,
40
+ } from "./run-state.ts";
12
41
  export type {
13
42
  AcceptanceCriterion,
14
43
  AcceptanceResult,
@@ -29,6 +58,7 @@ export type {
29
58
  SpawnResult,
30
59
  StageSchema,
31
60
  WorkflowAgentProof,
61
+ WorkflowApprovalMode,
32
62
  WorkflowContext,
33
63
  WorkflowEvidenceInput,
34
64
  WorkflowEvidenceRecord,
@@ -58,6 +58,7 @@ export interface NormalizedWorkflowMeta {
58
58
 
59
59
  export interface BuildWorkflowProofInput {
60
60
  runId: string;
61
+ workItemId?: string;
61
62
  meta: NormalizedWorkflowMeta;
62
63
  status: "succeeded" | "failed";
63
64
  startedAt: string;
@@ -213,6 +214,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
213
214
  schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
214
215
  run: {
215
216
  id: input.runId,
217
+ work_item_id: input.workItemId,
216
218
  name: input.meta.name,
217
219
  status: input.status,
218
220
  started_at: input.startedAt,