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
@@ -28,29 +28,52 @@ import { createHash, randomBytes } from "node:crypto";
28
28
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
29
29
  import { isAbsolute, join, resolve } from "node:path";
30
30
  import { pathToFileURL } from "node:url";
31
+ import { snapshotRepo } from "../context/index.js";
32
+ import { evaluatePolicy, normalizePolicy, PolicyDeniedError, policyDigest, summarizePolicyRequest, } from "../policy/index.js";
31
33
  import { probeBilling } from "./billing.js";
34
+ import { buildWorkflowProof, createEvidenceRecord, digestResult, normalizeWorkflowMeta, writeWorkflowProof, } from "./proof.js";
35
+ import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.js";
32
36
  import { parseStageOutput, validateAgainstSchema } from "./validate.js";
33
37
  const DEFAULT_MAX_AGENTS = 50;
34
38
  const DEFAULT_CONCURRENCY = 4;
35
39
  const DEFAULT_MAX_ATTEMPTS = 2;
36
40
  const DEFAULT_TIMEOUT_MS = 300_000;
37
41
  const DEFAULT_MAX_TURNS = 25;
42
+ const DEFAULT_POLICY_ASK_TIMEOUT_MS = 60_000;
43
+ const MAX_POLICY_DECISIONS = 50;
44
+ export class WorkflowRunError extends Error {
45
+ runId;
46
+ proofPath;
47
+ constructor(message, runId, proofPath, cause) {
48
+ super(message, cause === undefined ? undefined : { cause });
49
+ this.name = "WorkflowRunError";
50
+ this.runId = runId;
51
+ this.proofPath = proofPath;
52
+ }
53
+ }
38
54
  export async function runWorkflow(scriptPath, opts) {
39
55
  const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
40
56
  const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(absScript).href)));
41
57
  if (typeof mod.default !== "function") {
42
58
  throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
43
59
  }
44
- const name = mod.meta?.name ?? scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
60
+ const fallbackName = scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
61
+ const meta = normalizeWorkflowMeta(mod.meta, fallbackName);
62
+ const name = meta.name;
45
63
  const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
46
64
  const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
47
65
  mkdirSync(runDir, { recursive: true });
48
66
  const journalPath = join(runDir, "journal.jsonl");
67
+ const proofPath = join(runDir, "proof.json");
49
68
  const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
50
69
  const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
51
70
  const cwd = opts.cwd ?? opts.coordRoot;
52
71
  const log = opts.onLog ?? ((line) => process.stderr.write(`${line}\n`));
53
72
  const defaultHarness = opts.defaultHarness ?? "claude-code";
73
+ const isolation = opts.isolation ?? "shared";
74
+ const networkAccess = opts.networkAccess ?? "unknown";
75
+ const policy = opts.policy ? normalizePolicy(opts.policy, { baseDir: cwd }) : undefined;
76
+ const repoBefore = snapshotRepo(cwd);
54
77
  // Per-child fixed context overhead: children spawn in `cwd` and load its
55
78
  // repo-instructions file into their system prompt, cache-writing it once
56
79
  // per child. A fan-out multiplies this, so surface it BEFORE the burn.
@@ -66,11 +89,20 @@ export async function runWorkflow(scriptPath, opts) {
66
89
  let agentsSpawned = 0;
67
90
  let agentsCached = 0;
68
91
  let costUsd = 0;
92
+ let reservedCostUsd = 0;
69
93
  let currentStage = "";
70
94
  let agentSeq = 0;
95
+ let evidenceSeq = 0;
96
+ let policySeq = 0;
71
97
  const billingProbed = new Map();
98
+ const agentProofs = new Map();
99
+ const evidenceRecords = [];
100
+ const policyDecisions = [];
101
+ const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
72
102
  const journal = (event, data) => {
73
103
  const line = JSON.stringify({
104
+ schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
105
+ run_id: runId,
74
106
  ts: new Date().toISOString(),
75
107
  event,
76
108
  stage: currentStage,
@@ -95,7 +127,90 @@ export async function runWorkflow(scriptPath, opts) {
95
127
  inFlight--;
96
128
  waiters.shift()?.();
97
129
  };
130
+ // Policy checks that affect the shared cost reservation ledger serialize.
131
+ // Spawns still run concurrently after authorization; only the last-moment
132
+ // check-and-reserve section is exclusive.
133
+ let policyGate = Promise.resolve();
134
+ const withDispatchPolicyGate = async (work) => {
135
+ const previous = policyGate;
136
+ let open;
137
+ policyGate = new Promise((resolveGate) => {
138
+ open = resolveGate;
139
+ });
140
+ await previous;
141
+ try {
142
+ return await work();
143
+ }
144
+ finally {
145
+ open();
146
+ }
147
+ };
148
+ const authorizePolicyRequest = async (rawRequest) => {
149
+ if (!policy) {
150
+ throw new Error("external mutation authorization requires a host policy");
151
+ }
152
+ if (policyDecisions.length >= MAX_POLICY_DECISIONS) {
153
+ throw new Error(`workflow policy decision cap reached (${MAX_POLICY_DECISIONS}); split the workflow or reduce protected actions`);
154
+ }
155
+ const request = summarizePolicyRequest(rawRequest);
156
+ const evaluation = evaluatePolicy(policy, request);
157
+ const id = `p${++policySeq}`;
158
+ const checkedAt = new Date().toISOString();
159
+ journal("policy.check", {
160
+ id,
161
+ policy: policy.name,
162
+ policy_sha256: policyDigest(policy),
163
+ request,
164
+ verdict: evaluation.verdict,
165
+ rules: evaluation.rules,
166
+ });
167
+ let verdict = evaluation.verdict === "allow" ? "allow" : "deny";
168
+ let resolvedBy = "policy";
169
+ let reason = evaluation.reason;
170
+ if (evaluation.verdict === "ask") {
171
+ resolvedBy = "fail_closed";
172
+ if (!opts.resolvePolicyAsk) {
173
+ reason = `${evaluation.reason}; no host approval resolver is configured`;
174
+ }
175
+ else {
176
+ try {
177
+ const resolution = await withTimeout(Promise.resolve(opts.resolvePolicyAsk(request, evaluation)), opts.policyAskTimeoutMs ?? DEFAULT_POLICY_ASK_TIMEOUT_MS);
178
+ if (!resolution || (resolution.verdict !== "allow" && resolution.verdict !== "deny")) {
179
+ reason = `${evaluation.reason}; host returned an invalid approval resolution`;
180
+ }
181
+ else {
182
+ verdict = resolution.verdict;
183
+ resolvedBy = "host";
184
+ reason = boundedPolicyReason(resolution.reason ?? evaluation.reason);
185
+ }
186
+ }
187
+ catch (error) {
188
+ reason = `${evaluation.reason}; approval failed closed: ${error.message}`;
189
+ }
190
+ }
191
+ }
192
+ const decision = {
193
+ id,
194
+ checked_at: checkedAt,
195
+ policy: policy.name,
196
+ phase: request.phase,
197
+ initial_verdict: evaluation.verdict,
198
+ verdict,
199
+ resolved_by: resolvedBy,
200
+ reason: boundedPolicyReason(reason),
201
+ rule_codes: evaluation.rules.map((rule) => rule.code),
202
+ request,
203
+ };
204
+ policyDecisions.push(decision);
205
+ journal("policy.resolve", { ...decision });
206
+ if (decision.verdict === "deny") {
207
+ throw new PolicyDeniedError(`policy ${JSON.stringify(policy.name)} denied ${request.phase} ${JSON.stringify(request.action)}: ${decision.reason}`, decision.id);
208
+ }
209
+ return decision;
210
+ };
98
211
  const agent = async (prompt, agentOpts = {}) => {
212
+ let reservedForDispatch = 0;
213
+ let spawnCountClaimed = false;
99
214
  const harness = agentOpts.harness ?? defaultHarness;
100
215
  const spawner = opts.spawners[harness];
101
216
  if (!spawner) {
@@ -103,60 +218,157 @@ export async function runWorkflow(scriptPath, opts) {
103
218
  }
104
219
  const id = `a${++agentSeq}`;
105
220
  const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
221
+ const proofLabel = agentOpts.label ?? id;
106
222
  const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
223
+ const agentProof = {
224
+ id,
225
+ label: proofLabel,
226
+ stage: currentStage || undefined,
227
+ harness,
228
+ model: agentOpts.model,
229
+ status: "failed",
230
+ attempts: 0,
231
+ duration_ms: 0,
232
+ };
233
+ agentProofs.set(id, agentProof);
107
234
  // Call identity for resume: same stage + harness + model + effort + turns + schema
108
235
  // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
109
236
  const key = agentCallKey(currentStage, harness, agentOpts, prompt);
110
237
  const cached = resumeCache.get(key);
111
238
  if (cached) {
112
239
  agentsCached++;
113
- journal("agent.cached", { id, label, key, kind: cached.kind });
240
+ agentProof.status = "cached";
241
+ agentProof.result = digestResult(cached.value, cached.kind);
242
+ journal("agent.cached", {
243
+ id,
244
+ label,
245
+ key,
246
+ harness,
247
+ model: agentOpts.model ?? null,
248
+ kind: cached.kind,
249
+ });
114
250
  log(`[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`);
115
251
  return cached.value;
116
252
  }
117
- // Billing safeguard: on a harness's FIRST spawn this run, classify which
118
- // auth its children will use and refuse the silent-override state (an
119
- // exported API key shadowing a stored subscription login) unless the
120
- // caller explicitly opted into API billing. Cached agents never reach
121
- // this — no spawn, no billing.
122
- if (!billingProbed.has(harness)) {
123
- const probe = (opts.probeBilling ?? probeBilling)(harness);
124
- billingProbed.set(harness, probe);
125
- journal("billing.probe", {
253
+ if (policy) {
254
+ const estimateRequest = summarizePolicyRequest({
255
+ phase: "dispatch",
256
+ action: "spawn agent",
257
+ path: cwd,
126
258
  harness,
127
- mode: opts.subscriptionOnly ? "subscription" : probe.mode,
128
- api_key_source: probe.apiKeySource,
129
- login: probe.login,
130
- subscription_only: Boolean(opts.subscriptionOnly),
259
+ model: agentOpts.model,
260
+ effort: agentOpts.effort,
261
+ max_attempts: maxAttempts,
262
+ max_turns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
263
+ timeout_ms: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
264
+ prompt_bytes: Buffer.byteLength(prompt),
265
+ isolation,
266
+ network_access: networkAccess,
267
+ current_cost_usd: round4(costUsd + reservedCostUsd),
268
+ projected_cost_usd: null,
131
269
  });
132
- if (opts.subscriptionOnly) {
133
- if (probe.login === "absent") {
134
- throw new Error(`subscription-only: no stored login detected for ${harness}; ` +
135
- `log the harness CLI in (or drop --subscription-only for a key-only host)`);
270
+ let projectedCost = null;
271
+ if (policy.max_cost_usd !== undefined && opts.estimateDispatchCost) {
272
+ try {
273
+ const candidate = await opts.estimateDispatchCost(estimateRequest);
274
+ if (candidate !== null && (!Number.isFinite(candidate) || candidate < 0)) {
275
+ throw new Error("cost estimator returned a non-finite or negative value");
276
+ }
277
+ projectedCost = candidate;
278
+ }
279
+ catch (error) {
280
+ journal("policy.cost_estimate_failed", {
281
+ harness,
282
+ model: agentOpts.model ?? null,
283
+ error: boundedPolicyReason(error.message),
284
+ });
136
285
  }
137
- log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
138
286
  }
139
- else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
140
- throw new Error(`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
141
- `overrides your subscription auth, so children would bill per-token API rates. ` +
142
- `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
143
- `child envs, or pass --allow-api-billing if API billing is intended`);
287
+ try {
288
+ await withDispatchPolicyGate(async () => {
289
+ assertAgentCapacity(agentsSpawned, maxAgents);
290
+ const dispatchRequest = {
291
+ ...estimateRequest,
292
+ current_cost_usd: round4(costUsd + reservedCostUsd),
293
+ projected_cost_usd: projectedCost,
294
+ };
295
+ await authorizePolicyRequest(dispatchRequest);
296
+ if (policy.max_cost_usd !== undefined) {
297
+ reservedForDispatch =
298
+ projectedCost ?? Math.max(0, policy.max_cost_usd - dispatchRequest.current_cost_usd);
299
+ reservedCostUsd += reservedForDispatch;
300
+ }
301
+ agentsSpawned++;
302
+ spawnCountClaimed = true;
303
+ });
144
304
  }
145
- else if (probe.mode === "api-key") {
146
- log(`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
147
- `children bill per-token rates`);
305
+ catch (error) {
306
+ agentProof.error = error.message;
307
+ throw error;
308
+ }
309
+ }
310
+ else {
311
+ try {
312
+ assertAgentCapacity(agentsSpawned, maxAgents);
148
313
  }
149
- else if (probe.mode === "api-key-override") {
150
- log(`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`);
314
+ catch (error) {
315
+ agentProof.error = error.message;
316
+ throw error;
151
317
  }
152
- else {
153
- log(`[billing] ${harness}: subscription login`);
318
+ }
319
+ // Billing safeguard: on a harness's FIRST spawn this run, classify which
320
+ // auth its children will use and refuse the silent-override state (an
321
+ // exported API key shadowing a stored subscription login) unless the
322
+ // caller explicitly opted into API billing. Cached agents never reach
323
+ // this — no spawn, no billing.
324
+ try {
325
+ if (!billingProbed.has(harness)) {
326
+ const probe = (opts.probeBilling ?? probeBilling)(harness);
327
+ billingProbed.set(harness, probe);
328
+ journal("billing.probe", {
329
+ harness,
330
+ mode: opts.subscriptionOnly ? "subscription" : probe.mode,
331
+ api_key_source: probe.apiKeySource,
332
+ login: probe.login,
333
+ subscription_only: Boolean(opts.subscriptionOnly),
334
+ });
335
+ if (opts.subscriptionOnly) {
336
+ if (probe.login === "absent") {
337
+ throw new Error(`subscription-only: no stored login detected for ${harness}; ` +
338
+ `log the harness CLI in (or drop --subscription-only for a key-only host)`);
339
+ }
340
+ log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
341
+ }
342
+ else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
343
+ throw new Error(`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
344
+ `overrides your subscription auth, so children would bill per-token API rates. ` +
345
+ `Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
346
+ `child envs, or pass --allow-api-billing if API billing is intended`);
347
+ }
348
+ else if (probe.mode === "api-key") {
349
+ log(`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
350
+ `children bill per-token rates`);
351
+ }
352
+ else if (probe.mode === "api-key-override") {
353
+ log(`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`);
354
+ }
355
+ else {
356
+ log(`[billing] ${harness}: subscription login`);
357
+ }
154
358
  }
155
359
  }
156
- if (agentsSpawned >= maxAgents) {
157
- throw new Error(`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`);
360
+ catch (error) {
361
+ reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
362
+ reservedForDispatch = 0;
363
+ if (spawnCountClaimed) {
364
+ agentsSpawned--;
365
+ spawnCountClaimed = false;
366
+ }
367
+ agentProof.error = error.message;
368
+ throw error;
158
369
  }
159
- agentsSpawned++;
370
+ if (!spawnCountClaimed)
371
+ agentsSpawned++;
160
372
  await acquire();
161
373
  try {
162
374
  journal("agent.start", {
@@ -170,6 +382,7 @@ export async function runWorkflow(scriptPath, opts) {
170
382
  log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${harness}] ${label}`);
171
383
  let attemptPrompt = prompt;
172
384
  let last = null;
385
+ let agentCostUsd = 0;
173
386
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
174
387
  last = await spawner({
175
388
  prompt: attemptPrompt,
@@ -181,12 +394,20 @@ export async function runWorkflow(scriptPath, opts) {
181
394
  runId,
182
395
  subscriptionOnly: opts.subscriptionOnly,
183
396
  });
397
+ agentProof.attempts = attempt;
398
+ agentProof.duration_ms += last.durationMs;
399
+ agentCostUsd += last.costUsd ?? 0;
184
400
  costUsd += last.costUsd ?? 0;
185
401
  if (!last.ok) {
186
402
  journal("agent.attempt_failed", { id, attempt, error: last.error });
187
403
  continue; // spawn-level failure: retry with the original prompt
188
404
  }
189
405
  if (!agentOpts.schema) {
406
+ agentProof.status = "succeeded";
407
+ agentProof.cost_usd =
408
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
409
+ agentProof.session_id = last.sessionId;
410
+ agentProof.result = digestResult(last.text, "text");
190
411
  journal("agent.end", {
191
412
  id,
192
413
  key,
@@ -204,6 +425,11 @@ export async function runWorkflow(scriptPath, opts) {
204
425
  ? [parsed.error]
205
426
  : validateAgainstSchema(parsed.value, agentOpts.schema);
206
427
  if (problems.length === 0) {
428
+ agentProof.status = "succeeded";
429
+ agentProof.cost_usd =
430
+ agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
431
+ agentProof.session_id = last.sessionId;
432
+ agentProof.result = digestResult(parsed.value, "json");
207
433
  journal("agent.end", {
208
434
  id,
209
435
  key,
@@ -227,10 +453,19 @@ export async function runWorkflow(scriptPath, opts) {
227
453
  const reason = last?.ok
228
454
  ? `schema validation failed after ${maxAttempts} attempt(s)`
229
455
  : (last?.error ?? "spawn failed");
456
+ agentProof.cost_usd =
457
+ agentCostUsd > 0 || last?.costUsd !== undefined ? agentCostUsd : undefined;
458
+ agentProof.session_id = last?.sessionId;
459
+ agentProof.error = reason;
230
460
  journal("agent.failed", { id, error: reason });
231
- throw new Error(`agent ${id} (${label}): ${reason}`);
461
+ throw new Error(`agent ${proofLabel}: ${reason}`);
462
+ }
463
+ catch (error) {
464
+ agentProof.error ??= error.message;
465
+ throw error;
232
466
  }
233
467
  finally {
468
+ reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
234
469
  release();
235
470
  }
236
471
  };
@@ -248,11 +483,83 @@ export async function runWorkflow(scriptPath, opts) {
248
483
  journal("stage.start", { title });
249
484
  log(`[${name}] ── stage: ${title}`);
250
485
  };
251
- const ctx = { agent, parallel, stage, log };
486
+ const evidence = (input) => {
487
+ const record = createEvidenceRecord({
488
+ value: input,
489
+ sequence: evidenceSeq + 1,
490
+ acceptanceIds,
491
+ stage: currentStage || undefined,
492
+ });
493
+ evidenceSeq++;
494
+ evidenceRecords.push(record);
495
+ journal("evidence.recorded", { ...record });
496
+ return record.id;
497
+ };
498
+ const authorize = async (input) => {
499
+ const network = input.network === true || input.service !== undefined || input.target !== undefined
500
+ ? "enabled"
501
+ : "disabled";
502
+ return authorizePolicyRequest({
503
+ phase: "external_mutation",
504
+ action: input.action,
505
+ path: input.path ? resolve(cwd, input.path) : undefined,
506
+ isolation,
507
+ network_access: network,
508
+ service: input.service,
509
+ target: input.target,
510
+ current_cost_usd: round4(costUsd),
511
+ });
512
+ };
513
+ const ctx = { agent, parallel, stage, log, evidence, authorize };
252
514
  const t0 = Date.now();
253
- journal("run.start", { name, script: absScript, max_agents: maxAgents, concurrency });
515
+ const startedAt = new Date(t0).toISOString();
516
+ journal("run.start", {
517
+ name,
518
+ script: absScript,
519
+ objective: meta.objective ?? null,
520
+ acceptance: meta.acceptance,
521
+ max_agents: maxAgents,
522
+ concurrency,
523
+ policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
524
+ isolation,
525
+ network_access: networkAccess,
526
+ });
254
527
  try {
255
528
  const result = await mod.default(ctx);
529
+ const endedAt = new Date().toISOString();
530
+ const durationMs = Date.now() - t0;
531
+ journal("run.end", {
532
+ ok: true,
533
+ agents: agentsSpawned,
534
+ cached: agentsCached,
535
+ cost_usd: round4(costUsd),
536
+ duration_ms: durationMs,
537
+ });
538
+ let proof;
539
+ try {
540
+ proof = buildWorkflowProof({
541
+ runId,
542
+ meta,
543
+ status: "succeeded",
544
+ startedAt,
545
+ endedAt,
546
+ durationMs,
547
+ journalPath,
548
+ before: repoBefore,
549
+ after: snapshotRepo(cwd),
550
+ agents: Array.from(agentProofs.values()),
551
+ evidence: evidenceRecords,
552
+ harnessEvidence: opts.harnessEvidence,
553
+ policy: policy
554
+ ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
555
+ : undefined,
556
+ result,
557
+ });
558
+ writeWorkflowProof(proofPath, proof);
559
+ }
560
+ catch (error) {
561
+ throw new WorkflowRunError(`workflow completed but its proof packet could not be written: ${error.message}`, runId, proofPath, error);
562
+ }
256
563
  const report = {
257
564
  runId,
258
565
  name,
@@ -260,32 +567,57 @@ export async function runWorkflow(scriptPath, opts) {
260
567
  agentsSpawned,
261
568
  agentsCached,
262
569
  costUsd: round4(costUsd),
263
- durationMs: Date.now() - t0,
570
+ durationMs,
264
571
  journalPath,
572
+ proofPath,
573
+ acceptance: proof.acceptance.summary,
265
574
  contextTokensPerChildEstimate,
266
575
  billing: Array.from(billingProbed.values()).map((p) => ({
267
576
  harness: p.harness,
268
577
  mode: opts.subscriptionOnly ? "subscription" : p.mode,
269
578
  })),
579
+ policy: proof.policy?.summary,
270
580
  };
271
- journal("run.end", {
272
- ok: true,
273
- agents: agentsSpawned,
274
- cached: agentsCached,
275
- cost_usd: report.costUsd,
276
- duration_ms: report.durationMs,
277
- });
278
581
  return report;
279
582
  }
280
583
  catch (err) {
584
+ if (err instanceof WorkflowRunError)
585
+ throw err;
586
+ const endedAt = new Date().toISOString();
587
+ const durationMs = Date.now() - t0;
281
588
  journal("run.end", {
282
589
  ok: false,
283
590
  error: err.message,
284
591
  agents: agentsSpawned,
285
592
  cached: agentsCached,
286
593
  cost_usd: round4(costUsd),
594
+ duration_ms: durationMs,
287
595
  });
288
- throw err;
596
+ try {
597
+ const proof = buildWorkflowProof({
598
+ runId,
599
+ meta,
600
+ status: "failed",
601
+ startedAt,
602
+ endedAt,
603
+ durationMs,
604
+ journalPath,
605
+ before: repoBefore,
606
+ after: snapshotRepo(cwd),
607
+ agents: Array.from(agentProofs.values()),
608
+ evidence: evidenceRecords,
609
+ harnessEvidence: opts.harnessEvidence,
610
+ policy: policy
611
+ ? { config: policy, decisions: policyDecisions, isolation, networkAccess }
612
+ : undefined,
613
+ error: err.message,
614
+ });
615
+ writeWorkflowProof(proofPath, proof);
616
+ }
617
+ catch (proofError) {
618
+ throw new WorkflowRunError(`${err.message}; proof packet write also failed: ${proofError.message}`, runId, proofPath, err);
619
+ }
620
+ throw new WorkflowRunError(err.message, runId, proofPath, err);
289
621
  }
290
622
  }
291
623
  /** Stable identity for one agent() call, for the resume cache. The ORIGINAL
@@ -347,3 +679,36 @@ function loadResumeCache(coordRoot, resumeFrom) {
347
679
  function round4(n) {
348
680
  return Math.round(n * 10_000) / 10_000;
349
681
  }
682
+ function assertAgentCapacity(agentsSpawned, maxAgents) {
683
+ if (agentsSpawned >= maxAgents) {
684
+ throw new Error(`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`);
685
+ }
686
+ }
687
+ function boundedPolicyReason(value) {
688
+ const normalized = Array.from(value, (character) => {
689
+ const code = character.charCodeAt(0);
690
+ return code < 32 || code === 127 ? " " : character;
691
+ })
692
+ .join("")
693
+ .replace(/\s+/g, " ")
694
+ .trim();
695
+ return normalized.length > 2_000 ? `${normalized.slice(0, 1_999)}…` : normalized;
696
+ }
697
+ async function withTimeout(promise, timeoutMs) {
698
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
699
+ throw new Error("policyAskTimeoutMs must be a positive finite number");
700
+ }
701
+ let timer;
702
+ try {
703
+ return await Promise.race([
704
+ promise,
705
+ new Promise((_, reject) => {
706
+ timer = setTimeout(() => reject(new Error(`approval timed out after ${timeoutMs}ms`)), timeoutMs);
707
+ }),
708
+ ]);
709
+ }
710
+ finally {
711
+ if (timer)
712
+ clearTimeout(timer);
713
+ }
714
+ }
@@ -0,0 +1,5 @@
1
+ export { runWorkflow, WorkflowRunError } from "./engine.js";
2
+ export { buildWorkflowProof, createEvidenceRecord, digestResult, normalizeWorkflowMeta, readWorkflowProof, renderWorkflowProof, rollupAcceptance, writeWorkflowProof, } from "./proof.js";
3
+ export type { AcceptanceCriterion, AcceptanceResult, AcceptanceStatus, AcceptanceSummary, AgentOpts, EngineOpts, EvidenceKind, EvidenceSource, EvidenceStatus, HarnessEvidenceCapability, HarnessEvidenceCoverage, HarnessName, ResultDigest, RunReport, Spawner, SpawnRequest, SpawnResult, StageSchema, WorkflowAgentProof, WorkflowContext, WorkflowEvidenceInput, WorkflowEvidenceRecord, WorkflowMeta, WorkflowModule, WorkflowPolicyProof, WorkflowProof, WorkflowProofUnknown, WorkflowRepoEvidence, WorkflowRepoSnapshot, WorkflowRunStatus, } from "./types.js";
4
+ export { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,YAAY,EACZ,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,SAAS,EACT,UAAU,EACV,YAAY,EACZ,cAAc,EACd,cAAc,EACd,yBAAyB,EACzB,uBAAuB,EACvB,WAAW,EACX,YAAY,EACZ,SAAS,EACT,OAAO,EACP,YAAY,EACZ,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { runWorkflow, WorkflowRunError } from "./engine.js";
2
+ export { buildWorkflowProof, createEvidenceRecord, digestResult, normalizeWorkflowMeta, readWorkflowProof, renderWorkflowProof, rollupAcceptance, writeWorkflowProof, } from "./proof.js";
3
+ export { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.js";
@@ -0,0 +1,49 @@
1
+ import type { RepoSnapshot } from "../context/index.js";
2
+ import type { NormalizedPolicy, PolicyDecision, PolicyIsolation, PolicyNetworkAccess } from "../policy/index.js";
3
+ import type { AcceptanceCriterion, AcceptanceResult, AcceptanceSummary, HarnessEvidenceCapability, ResultDigest, WorkflowAgentProof, WorkflowEvidenceInput, WorkflowEvidenceRecord, WorkflowMeta, WorkflowProof } from "./types.js";
4
+ export interface NormalizedWorkflowMeta {
5
+ name: string;
6
+ description?: string;
7
+ objective?: string;
8
+ acceptance: AcceptanceCriterion[];
9
+ }
10
+ export interface BuildWorkflowProofInput {
11
+ runId: string;
12
+ meta: NormalizedWorkflowMeta;
13
+ status: "succeeded" | "failed";
14
+ startedAt: string;
15
+ endedAt: string;
16
+ durationMs: number;
17
+ journalPath: string;
18
+ before: RepoSnapshot;
19
+ after: RepoSnapshot;
20
+ agents: WorkflowAgentProof[];
21
+ evidence: WorkflowEvidenceRecord[];
22
+ harnessEvidence?: Readonly<Record<string, HarnessEvidenceCapability | undefined>>;
23
+ policy?: {
24
+ config: Readonly<NormalizedPolicy>;
25
+ decisions: readonly PolicyDecision[];
26
+ isolation: PolicyIsolation;
27
+ networkAccess: PolicyNetworkAccess;
28
+ };
29
+ result?: unknown;
30
+ error?: string;
31
+ }
32
+ export declare function normalizeWorkflowMeta(meta: WorkflowMeta | undefined, fallbackName: string): NormalizedWorkflowMeta;
33
+ export declare function createEvidenceRecord(input: {
34
+ value: WorkflowEvidenceInput;
35
+ sequence: number;
36
+ acceptanceIds: ReadonlySet<string>;
37
+ stage?: string;
38
+ recordedAt?: string;
39
+ }): WorkflowEvidenceRecord;
40
+ export declare function rollupAcceptance(criteria: AcceptanceCriterion[], evidence: WorkflowEvidenceRecord[]): {
41
+ criteria: AcceptanceResult[];
42
+ summary: AcceptanceSummary;
43
+ };
44
+ export declare function digestResult(value: unknown, kind?: "text" | "json"): ResultDigest;
45
+ export declare function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProof;
46
+ export declare function writeWorkflowProof(path: string, proof: WorkflowProof): void;
47
+ export declare function readWorkflowProof(coordRoot: string, runId: string): WorkflowProof;
48
+ export declare function renderWorkflowProof(proof: WorkflowProof): string;
49
+ //# sourceMappingURL=proof.d.ts.map