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.
- package/README.md +1 -1
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/policy.d.ts +6 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +42 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +62 -2
- package/dist/core/policy/evaluate.d.ts +14 -0
- package/dist/core/policy/evaluate.d.ts.map +1 -0
- package/dist/core/policy/evaluate.js +238 -0
- package/dist/core/policy/file.d.ts +3 -0
- package/dist/core/policy/file.d.ts.map +1 -0
- package/dist/core/policy/file.js +20 -0
- package/dist/core/policy/index.d.ts +5 -0
- package/dist/core/policy/index.d.ts.map +1 -0
- package/dist/core/policy/index.js +3 -0
- package/dist/core/policy/types.d.ts +91 -0
- package/dist/core/policy/types.d.ts.map +1 -0
- package/dist/core/policy/types.js +1 -0
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +412 -47
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +49 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +375 -0
- package/dist/core/workflow/types.d.ts +180 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/package.json +16 -6
- package/src/commander.ts +2 -0
- package/src/commands/policy.ts +50 -0
- package/src/commands/workflow.ts +82 -2
- package/src/core/policy/evaluate.ts +311 -0
- package/src/core/policy/file.ts +20 -0
- package/src/core/policy/index.ts +26 -0
- package/src/core/policy/types.ts +107 -0
- package/src/core/workflow/engine.ts +452 -53
- package/src/core/workflow/index.ts +44 -0
- package/src/core/workflow/proof.ts +485 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
305
|
+
catch (error) {
|
|
306
|
+
agentProof.error = error.message;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
try {
|
|
312
|
+
assertAgentCapacity(agentsSpawned, maxAgents);
|
|
148
313
|
}
|
|
149
|
-
|
|
150
|
-
|
|
314
|
+
catch (error) {
|
|
315
|
+
agentProof.error = error.message;
|
|
316
|
+
throw error;
|
|
151
317
|
}
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|