harnery 0.12.1 → 0.13.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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/workflow.d.ts.map +1 -1
  3. package/dist/commands/workflow.js +193 -0
  4. package/dist/core/agents/coord-client.d.ts +8 -0
  5. package/dist/core/agents/coord-client.d.ts.map +1 -1
  6. package/dist/core/agents/coord-client.js +18 -2
  7. package/dist/core/policy/types.d.ts +1 -1
  8. package/dist/core/policy/types.d.ts.map +1 -1
  9. package/dist/core/scratch/index.d.ts.map +1 -1
  10. package/dist/core/scratch/index.js +3 -3
  11. package/dist/core/workflow/approvals.d.ts +71 -0
  12. package/dist/core/workflow/approvals.d.ts.map +1 -0
  13. package/dist/core/workflow/approvals.js +288 -0
  14. package/dist/core/workflow/engine.d.ts +6 -0
  15. package/dist/core/workflow/engine.d.ts.map +1 -1
  16. package/dist/core/workflow/engine.js +224 -43
  17. package/dist/core/workflow/index.d.ts +6 -2
  18. package/dist/core/workflow/index.d.ts.map +1 -1
  19. package/dist/core/workflow/index.js +3 -1
  20. package/dist/core/workflow/run-state.d.ts +43 -0
  21. package/dist/core/workflow/run-state.d.ts.map +1 -0
  22. package/dist/core/workflow/run-state.js +231 -0
  23. package/dist/core/workflow/types.d.ts +9 -0
  24. package/dist/core/workflow/types.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/workflow.ts +226 -0
  27. package/src/core/agents/coord-client.ts +19 -2
  28. package/src/core/policy/types.ts +1 -1
  29. package/src/core/scratch/index.ts +3 -2
  30. package/src/core/workflow/approvals.ts +393 -0
  31. package/src/core/workflow/engine.ts +268 -44
  32. package/src/core/workflow/index.ts +31 -1
  33. package/src/core/workflow/run-state.ts +287 -0
  34. package/src/core/workflow/types.ts +9 -0
@@ -0,0 +1,287 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { hostname } from "node:os";
15
+ import { dirname, isAbsolute, join, resolve } from "node:path";
16
+ import type { RepoSnapshot } from "../context/index.ts";
17
+ import {
18
+ type NormalizedPolicy,
19
+ normalizePolicy,
20
+ type PolicyIsolation,
21
+ type PolicyNetworkAccess,
22
+ policyDigest,
23
+ } from "../policy/index.ts";
24
+ import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
25
+
26
+ export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
27
+
28
+ const MANIFEST_LIMIT_BYTES = 256 * 1024;
29
+ const FOREIGN_LEASE_STALE_MS = 24 * 60 * 60 * 1_000;
30
+
31
+ export interface WorkflowRunManifest {
32
+ schema_version: typeof WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION;
33
+ run_id: string;
34
+ name: string;
35
+ started_at: string;
36
+ script: { path: string; sha256: string };
37
+ repository_before: RepoSnapshot;
38
+ execution: {
39
+ cwd: string;
40
+ default_harness: string;
41
+ max_agents: number;
42
+ concurrency: number;
43
+ subscription_only: boolean;
44
+ allow_api_billing: boolean;
45
+ approval_mode: "deny" | "park";
46
+ approval_addressee: string;
47
+ isolation: PolicyIsolation;
48
+ network_access: PolicyNetworkAccess;
49
+ policy?: NormalizedPolicy;
50
+ };
51
+ }
52
+
53
+ export interface WriteWorkflowRunManifestInput {
54
+ coordRoot: string;
55
+ manifest: WorkflowRunManifest;
56
+ }
57
+
58
+ export function workflowScriptDigest(scriptPath: string): string {
59
+ return createHash("sha256").update(readFileSync(scriptPath)).digest("hex");
60
+ }
61
+
62
+ export function writeWorkflowRunManifest(input: WriteWorkflowRunManifestInput): string {
63
+ assertWorkflowRunId(input.manifest.run_id);
64
+ const path = workflowRunManifestPath(input.coordRoot, input.manifest.run_id);
65
+ if (existsSync(path))
66
+ throw new Error(`workflow run ${input.manifest.run_id} already has a manifest`);
67
+ const body = `${JSON.stringify(input.manifest, null, 2)}\n`;
68
+ if (Buffer.byteLength(body) > MANIFEST_LIMIT_BYTES) {
69
+ throw new Error(`workflow run manifest exceeds ${MANIFEST_LIMIT_BYTES} bytes`);
70
+ }
71
+ mkdirSync(dirname(path), { recursive: true });
72
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
73
+ try {
74
+ writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
75
+ renameSync(temporary, path);
76
+ chmodSync(path, 0o600);
77
+ } finally {
78
+ if (existsSync(temporary)) unlinkSync(temporary);
79
+ }
80
+ return path;
81
+ }
82
+
83
+ export function readWorkflowRunManifest(coordRoot: string, runId: string): WorkflowRunManifest {
84
+ assertWorkflowRunId(runId);
85
+ const path = workflowRunManifestPath(coordRoot, runId);
86
+ if (!existsSync(path)) throw new Error(`workflow run ${runId} has no resume manifest at ${path}`);
87
+ const bytes = statSync(path).size;
88
+ if (bytes <= 0 || bytes > MANIFEST_LIMIT_BYTES) {
89
+ throw new Error(`workflow run manifest at ${path} has invalid size ${bytes}`);
90
+ }
91
+ let manifest: WorkflowRunManifest;
92
+ try {
93
+ manifest = JSON.parse(readFileSync(path, "utf8")) as WorkflowRunManifest;
94
+ } catch (error) {
95
+ throw new Error(`cannot parse workflow run manifest at ${path}: ${(error as Error).message}`);
96
+ }
97
+ if (
98
+ manifest.schema_version !== WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION ||
99
+ manifest.run_id !== runId ||
100
+ typeof manifest.name !== "string" ||
101
+ manifest.name.length === 0 ||
102
+ manifest.name.length > 200 ||
103
+ !validTimestamp(manifest.started_at) ||
104
+ !manifest.script?.path ||
105
+ !isAbsolute(manifest.script.path) ||
106
+ !/^[a-f0-9]{64}$/.test(manifest.script.sha256) ||
107
+ !validRepoSnapshot(manifest.repository_before) ||
108
+ !validExecution(manifest.execution)
109
+ ) {
110
+ throw new Error(`workflow run manifest at ${path} has an unsupported or mismatched schema`);
111
+ }
112
+ return manifest;
113
+ }
114
+
115
+ export function assertWorkflowRunResumable(
116
+ coordRoot: string,
117
+ runId: string,
118
+ ): { manifest: WorkflowRunManifest; approvalId: string } {
119
+ const manifest = readWorkflowRunManifest(coordRoot, runId);
120
+ const journalPath = join(coordRoot, ".harnery", "workflows", runId, "journal.jsonl");
121
+ if (!existsSync(journalPath)) throw new Error(`workflow run ${runId} has no journal`);
122
+ let approvalId: string | undefined;
123
+ let terminal = false;
124
+ for (const line of readFileSync(journalPath, "utf8").split("\n")) {
125
+ if (!line.trim()) continue;
126
+ try {
127
+ const event = JSON.parse(line) as { event?: string; approval_id?: string };
128
+ if (event.event === "run.parked" && typeof event.approval_id === "string") {
129
+ approvalId = event.approval_id;
130
+ }
131
+ if (event.event === "run.end") terminal = true;
132
+ } catch {
133
+ throw new Error(`workflow run ${runId} has a malformed journal line`);
134
+ }
135
+ }
136
+ if (terminal) throw new Error(`workflow run ${runId} is already terminal`);
137
+ if (!approvalId) throw new Error(`workflow run ${runId} is not parked on an approval`);
138
+ const approval = readWorkflowApproval(coordRoot, approvalId);
139
+ if (approval.request.run_id !== runId) {
140
+ throw new Error(`workflow run ${runId} has a mismatched parked approval`);
141
+ }
142
+ if (approval.status === "pending") {
143
+ throw new Error(`workflow run ${runId} is still waiting on approval ${approvalId}`);
144
+ }
145
+ return { manifest, approvalId };
146
+ }
147
+
148
+ export function assertWorkflowScriptUnchanged(manifest: WorkflowRunManifest): void {
149
+ const path = resolve(manifest.script.path);
150
+ if (!existsSync(path)) throw new Error(`workflow script no longer exists at ${path}`);
151
+ const actual = workflowScriptDigest(path);
152
+ if (actual !== manifest.script.sha256) {
153
+ throw new Error(
154
+ `workflow script changed since run ${manifest.run_id} parked; start a new run instead of reusing its approval`,
155
+ );
156
+ }
157
+ }
158
+
159
+ export function workflowRunManifestPath(coordRoot: string, runId: string): string {
160
+ assertWorkflowRunId(runId);
161
+ return join(coordRoot, ".harnery", "workflows", runId, "run.json");
162
+ }
163
+
164
+ /** Acquire an exclusive, crash-recoverable lease for one resume segment. */
165
+ export function acquireWorkflowResumeLease(coordRoot: string, runId: string): () => void {
166
+ assertWorkflowRunId(runId);
167
+ const path = join(coordRoot, ".harnery", "workflows", runId, "resume.lock");
168
+ const owner = { pid: process.pid, host: hostname(), created_at: new Date().toISOString() };
169
+ mkdirSync(dirname(path), { recursive: true });
170
+
171
+ const acquire = (): boolean => {
172
+ try {
173
+ const fd = openSync(path, "wx", 0o600);
174
+ try {
175
+ writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
176
+ } finally {
177
+ closeSync(fd);
178
+ }
179
+ chmodSync(path, 0o600);
180
+ return true;
181
+ } catch (error) {
182
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
183
+ return false;
184
+ }
185
+ };
186
+
187
+ if (!acquire()) {
188
+ const existing = readResumeLease(path);
189
+ if (existing && resumeLeaseIsLive(existing)) {
190
+ throw new Error(
191
+ `workflow run ${runId} is already being resumed by pid ${existing.pid} on ${existing.host}`,
192
+ );
193
+ }
194
+ unlinkSync(path);
195
+ if (!acquire())
196
+ throw new Error(`workflow run ${runId} resume lease raced with another process`);
197
+ }
198
+
199
+ return () => {
200
+ try {
201
+ const existing = readResumeLease(path);
202
+ if (existing?.pid === owner.pid && existing.host === owner.host) unlinkSync(path);
203
+ } catch {
204
+ // Best effort. A stale lease is recoverable on the next resume attempt.
205
+ }
206
+ };
207
+ }
208
+
209
+ function readResumeLease(path: string): { pid: number; host: string; created_at: string } | null {
210
+ try {
211
+ const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
212
+ return typeof value.pid === "number" &&
213
+ Number.isSafeInteger(value.pid) &&
214
+ value.pid > 0 &&
215
+ typeof value.host === "string" &&
216
+ typeof value.created_at === "string"
217
+ ? { pid: value.pid, host: value.host, created_at: value.created_at }
218
+ : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function resumeLeaseIsLive(lease: { pid: number; host: string; created_at: string }): boolean {
225
+ if (lease.host !== hostname()) {
226
+ const age = Date.now() - Date.parse(lease.created_at);
227
+ return Number.isFinite(age) && age < FOREIGN_LEASE_STALE_MS;
228
+ }
229
+ try {
230
+ process.kill(lease.pid, 0);
231
+ return true;
232
+ } catch (error) {
233
+ return (error as NodeJS.ErrnoException).code === "EPERM";
234
+ }
235
+ }
236
+
237
+ function validExecution(value: WorkflowRunManifest["execution"]): boolean {
238
+ if (!value || typeof value !== "object") return false;
239
+ return (
240
+ isAbsolute(value.cwd) &&
241
+ typeof value.default_harness === "string" &&
242
+ value.default_harness.length > 0 &&
243
+ positiveSafeInteger(value.max_agents) &&
244
+ positiveSafeInteger(value.concurrency) &&
245
+ typeof value.subscription_only === "boolean" &&
246
+ typeof value.allow_api_billing === "boolean" &&
247
+ (value.approval_mode === "deny" || value.approval_mode === "park") &&
248
+ typeof value.approval_addressee === "string" &&
249
+ value.approval_addressee.length > 0 &&
250
+ value.approval_addressee.length <= 200 &&
251
+ ["shared", "worktree", "sandbox", "remote"].includes(value.isolation) &&
252
+ ["enabled", "disabled", "unknown"].includes(value.network_access) &&
253
+ validFrozenPolicy(value.policy, value.cwd)
254
+ );
255
+ }
256
+
257
+ function validFrozenPolicy(policy: NormalizedPolicy | undefined, cwd: string): boolean {
258
+ if (policy === undefined) return true;
259
+ try {
260
+ const normalized = normalizePolicy(policy, { baseDir: cwd });
261
+ return policyDigest(normalized) === policyDigest(policy);
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ function validRepoSnapshot(value: RepoSnapshot): boolean {
268
+ return (
269
+ Boolean(value) &&
270
+ typeof value === "object" &&
271
+ isAbsolute(value.cwd) &&
272
+ (value.root === undefined || isAbsolute(value.root)) &&
273
+ (value.branch === undefined || typeof value.branch === "string") &&
274
+ (value.head === undefined || typeof value.head === "string") &&
275
+ Array.isArray(value.dirty_paths) &&
276
+ value.dirty_paths.every((path) => typeof path === "string") &&
277
+ (value.dirty_paths_truncated === undefined || typeof value.dirty_paths_truncated === "boolean")
278
+ );
279
+ }
280
+
281
+ function positiveSafeInteger(value: unknown): value is number {
282
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
283
+ }
284
+
285
+ function validTimestamp(value: unknown): value is string {
286
+ return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
287
+ }
@@ -26,6 +26,7 @@ export type EvidenceStatus = "passed" | "failed" | "observed" | "unknown";
26
26
  export type EvidenceSource = "workflow" | "engine";
27
27
  export type AcceptanceStatus = "satisfied" | "unsatisfied" | "unknown";
28
28
  export type WorkflowRunStatus = "succeeded" | "failed";
29
+ export type WorkflowApprovalMode = "deny" | "park";
29
30
 
30
31
  export interface AcceptanceCriterion {
31
32
  /** Stable identifier referenced by evidence, for example `tests-pass`. */
@@ -310,6 +311,10 @@ export interface EngineOpts {
310
311
  * agent() calls whose (stage, prompt, model, maxTurns, schema) key matches a
311
312
  * completed prior agent return the journaled result without spawning. */
312
313
  resumeFrom?: string;
314
+ /** Continue a parked run in its original directory after its durable
315
+ * approval has been resolved. The frozen run manifest supplies execution
316
+ * options and the original repository-before snapshot. */
317
+ resumeRunId?: string;
313
318
  /** Total-agent ceiling for the run (default 50): the runaway backstop. */
314
319
  maxAgents?: number;
315
320
  /** Concurrent-subagent cap for parallel() (default 4). */
@@ -340,6 +345,10 @@ export interface EngineOpts {
340
345
  estimateDispatchCost?: DispatchCostEstimator;
341
346
  /** Maximum wait for an ASK resolver (default 60 seconds). */
342
347
  policyAskTimeoutMs?: number;
348
+ /** Missing or unavailable ASK resolver behavior (library default: deny). */
349
+ approvalMode?: WorkflowApprovalMode;
350
+ /** Bounded address recorded on newly parked approval requests. */
351
+ approvalAddressee?: string;
343
352
  /** Execution boundary created by the host (default shared). */
344
353
  isolation?: PolicyIsolation;
345
354
  /** Network state of spawned harness subprocesses (default unknown). */