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
@@ -0,0 +1,367 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import { workflowSubscriptionOnly } from "../core/config.ts";
4
+ import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
5
+ import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
6
+ import type { PolicyIsolation } from "../core/policy/index.ts";
7
+ import { loadPolicyFile } from "../core/policy/index.ts";
8
+ import {
9
+ acceptWorkItem,
10
+ cancelWorkItem,
11
+ createWorkItem,
12
+ listWorkItems,
13
+ readWorkItem,
14
+ reconcileAllWorkItems,
15
+ reconcileWorkItem,
16
+ reopenWorkItem,
17
+ runWorkItem,
18
+ type WorkRecord,
19
+ } from "../core/work/index.ts";
20
+ import { WorkflowParkedError } from "../core/workflow/index.ts";
21
+
22
+ interface WorkCreateOpts {
23
+ id?: string;
24
+ accept?: string[];
25
+ dependsOn?: string;
26
+ maxAttempts?: string;
27
+ sourceKind?: "human" | "workflow" | "external";
28
+ sourceRef?: string;
29
+ actor?: string;
30
+ json?: boolean;
31
+ }
32
+
33
+ interface WorkRunOpts {
34
+ harness?: string;
35
+ maxAgents?: string;
36
+ concurrency?: string;
37
+ cwd?: string;
38
+ subscriptionOnly?: boolean;
39
+ allowApiBilling?: boolean;
40
+ policy?: string;
41
+ isolation?: PolicyIsolation;
42
+ approvalTo?: string;
43
+ actor?: string;
44
+ json?: boolean;
45
+ }
46
+
47
+ interface GovernanceOpts {
48
+ actor?: string;
49
+ reason?: string;
50
+ json?: boolean;
51
+ }
52
+
53
+ export function registerWorkCommand(program: Command, emit: EmitContext): void {
54
+ const registry = createBuiltinHarnessRegistry();
55
+ const harnesses = registry.ids();
56
+ const work = program
57
+ .command("work")
58
+ .description("Track durable objectives across bounded workflow attempts.");
59
+
60
+ work
61
+ .command("create <title> <workflow>")
62
+ .description("Create an immutable durable-work intent linked to a workflow script.")
63
+ .requiredOption("--objective <text>", "Objective that must survive execution attempts")
64
+ .option("--id <work-id>", "Stable work id (generated by default)")
65
+ .option("--accept <text>", "Acceptance statement; repeat for more than one", collect, [])
66
+ .option("--depends-on <ids>", "Comma-separated prerequisite work ids")
67
+ .option("--max-attempts <n>", "Attempt ceiling (default 3)")
68
+ .option("--source-kind <kind>", "human | workflow | external", "human")
69
+ .option("--source-ref <ref>", "Bounded source reference")
70
+ .option("--actor <name>", "Actor recorded in the creation receipt")
71
+ .option("--json", "Emit the complete work record as JSON")
72
+ .action((title: string, workflowPath: string, opts: WorkCreateOpts & { objective: string }) => {
73
+ withWorkRoot(emit, (coordRoot) => {
74
+ const record = createWorkItem({
75
+ coordRoot,
76
+ id: opts.id,
77
+ title,
78
+ objective: opts.objective,
79
+ workflowPath,
80
+ acceptance: opts.accept,
81
+ dependencies: splitIds(opts.dependsOn),
82
+ maxAttempts: opts.maxAttempts ? Number.parseInt(opts.maxAttempts, 10) : undefined,
83
+ source: opts.sourceKind ? { kind: opts.sourceKind, ref: opts.sourceRef } : undefined,
84
+ actor: opts.actor,
85
+ });
86
+ emitWork(record, opts.json, emit);
87
+ });
88
+ });
89
+
90
+ work
91
+ .command("list")
92
+ .description("List durable work with its currently derived state.")
93
+ .option("--state <state>", "Filter by derived state")
94
+ .option("--json", "Emit complete work records as JSON")
95
+ .action((opts: { state?: string; json?: boolean }) => {
96
+ withWorkRoot(emit, (coordRoot) => {
97
+ const records = listWorkItems(coordRoot).filter(
98
+ (record) => !opts.state || record.projection.state === opts.state,
99
+ );
100
+ if (opts.json) {
101
+ emit.config({ format: "json" });
102
+ emit.data(records);
103
+ } else if (records.length === 0) {
104
+ emit.text("no durable work\n");
105
+ } else {
106
+ emit.text(`${records.map(renderWorkRow).join("\n")}\n`);
107
+ }
108
+ });
109
+ });
110
+
111
+ work
112
+ .command("show <work-id>")
113
+ .description("Show one work item, its attempts, and the next explicit action.")
114
+ .option("--json", "Emit the complete work record as JSON")
115
+ .action((workId: string, opts: { json?: boolean }) => {
116
+ withWorkRoot(emit, (coordRoot) => {
117
+ emitWork(readWorkItem(coordRoot, workId), opts.json, emit, true);
118
+ });
119
+ });
120
+
121
+ work
122
+ .command("reconcile [work-id]")
123
+ .description("Derive state from durable evidence without starting or retrying work.")
124
+ .option("--actor <name>", "Actor recorded on changed reconciliation receipts")
125
+ .option("--json", "Emit reconciled work records as JSON")
126
+ .action((workId: string | undefined, opts: { actor?: string; json?: boolean }) => {
127
+ withWorkRoot(emit, (coordRoot) => {
128
+ const records = workId
129
+ ? [reconcileWorkItem(coordRoot, workId, opts.actor)]
130
+ : reconcileAllWorkItems(coordRoot, opts.actor);
131
+ if (opts.json) {
132
+ emit.config({ format: "json" });
133
+ emit.data(workId ? records[0] : records);
134
+ } else if (records.length === 0) {
135
+ emit.text("no durable work\n");
136
+ } else {
137
+ emit.text(`${records.map(renderWorkRow).join("\n")}\n`);
138
+ }
139
+ });
140
+ });
141
+
142
+ registerRunCommand(work, "run", false, registry, harnesses, emit);
143
+ registerRunCommand(work, "retry", true, registry, harnesses, emit);
144
+ registerGovernanceCommand(work, "accept", emit);
145
+ registerGovernanceCommand(work, "cancel", emit);
146
+ registerGovernanceCommand(work, "reopen", emit);
147
+ }
148
+
149
+ function registerRunCommand(
150
+ work: Command,
151
+ name: "run" | "retry",
152
+ retry: boolean,
153
+ registry: ReturnType<typeof createBuiltinHarnessRegistry>,
154
+ harnesses: string[],
155
+ emit: EmitContext,
156
+ ): void {
157
+ work
158
+ .command(`${name} <work-id>`)
159
+ .description(
160
+ retry
161
+ ? "Start a new bounded attempt after a blocked run."
162
+ : "Start ready work or resume its resolved parked attempt.",
163
+ )
164
+ .option("--max-agents <n>", "Total-agent ceiling for the workflow")
165
+ .option("--concurrency <n>", "Concurrent-subagent cap")
166
+ .option("--cwd <dir>", "Working directory children spawn in")
167
+ .option("--harness <name>", `Default harness: ${harnesses.join(" | ")}`)
168
+ .option("--subscription-only", "Require stored harness-login billing")
169
+ .option("--allow-api-billing", "Permit API-key override billing")
170
+ .option("--policy <file>", "Host policy JSON/JSONC")
171
+ .option("--isolation <mode>", "shared | worktree | sandbox | remote")
172
+ .option("--approval-to <address>", "Address durable ASK requests")
173
+ .option("--actor <name>", "Actor recorded in attempt receipts")
174
+ .option("--json", "Emit the workflow report or parked result as JSON")
175
+ .action(async (workId: string, opts: WorkRunOpts) => {
176
+ await withWorkRootAsync(emit, async (coordRoot) => {
177
+ if (opts.harness && !registry.get(opts.harness)) {
178
+ throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
179
+ }
180
+ try {
181
+ const report = await runWorkItem({
182
+ coordRoot,
183
+ workId,
184
+ retry,
185
+ actor: opts.actor,
186
+ engine: {
187
+ spawners: registry.spawners(),
188
+ defaultHarness: opts.harness,
189
+ maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
190
+ concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
191
+ cwd: opts.cwd,
192
+ subscriptionOnly:
193
+ opts.subscriptionOnly === true ? true : workflowSubscriptionOnly(coordRoot),
194
+ allowApiBilling: opts.allowApiBilling,
195
+ harnessEvidence: Object.fromEntries(
196
+ registry
197
+ .list()
198
+ .map((adapter) => [
199
+ adapter.profile.id,
200
+ { toolEvidence: adapter.profile.capabilities.toolEvidence },
201
+ ]),
202
+ ),
203
+ policy: opts.policy ? loadPolicyFile(opts.policy) : undefined,
204
+ approvalMode: "park",
205
+ approvalAddressee: opts.approvalTo,
206
+ isolation: opts.isolation,
207
+ networkAccess: "enabled",
208
+ },
209
+ });
210
+ if (opts.json) {
211
+ emit.config({ format: "json" });
212
+ emit.data(report);
213
+ } else {
214
+ emit.text(
215
+ `work ${workId}: run ${report.runId} finished\nproof: ${report.proofPath}\n` +
216
+ `next: harn work reconcile ${workId}\n`,
217
+ );
218
+ }
219
+ } catch (error) {
220
+ if (error instanceof WorkflowParkedError) {
221
+ if (opts.json) {
222
+ emit.config({ format: "json" });
223
+ emit.data({
224
+ status: "parked",
225
+ workId,
226
+ runId: error.runId,
227
+ approvalId: error.approvalId,
228
+ journalPath: error.journalPath,
229
+ });
230
+ } else {
231
+ emit.text(
232
+ `work ${workId}: run ${error.runId} parked\napproval: ${error.approvalId}\n` +
233
+ `next: resolve the approval, then harn work run ${workId}\n`,
234
+ );
235
+ }
236
+ return;
237
+ }
238
+ throw error;
239
+ }
240
+ });
241
+ });
242
+ }
243
+
244
+ function registerGovernanceCommand(
245
+ work: Command,
246
+ name: "accept" | "cancel" | "reopen",
247
+ emit: EmitContext,
248
+ ): void {
249
+ work
250
+ .command(`${name} <work-id>`)
251
+ .description(
252
+ name === "accept"
253
+ ? "Explicitly accept reviewed proof and complete the objective."
254
+ : name === "cancel"
255
+ ? "Explicitly cancel unfinished durable work."
256
+ : "Explicitly reopen terminal or blocked work for a future attempt.",
257
+ )
258
+ .option("--actor <name>", "Actor recorded in the governance receipt")
259
+ .option("--reason <text>", "Bounded decision reason")
260
+ .option("--json", "Emit the complete work record as JSON")
261
+ .action((workId: string, opts: GovernanceOpts) => {
262
+ withWorkRoot(emit, (coordRoot) => {
263
+ const fn =
264
+ name === "accept" ? acceptWorkItem : name === "cancel" ? cancelWorkItem : reopenWorkItem;
265
+ emitWork(fn(coordRoot, workId, opts), opts.json, emit, true);
266
+ });
267
+ });
268
+ }
269
+
270
+ function emitWork(
271
+ record: WorkRecord,
272
+ json: boolean | undefined,
273
+ emit: EmitContext,
274
+ detail = false,
275
+ ) {
276
+ if (json) {
277
+ emit.config({ format: "json" });
278
+ emit.data(record);
279
+ return;
280
+ }
281
+ if (!detail) {
282
+ emit.text(`${renderWorkRow(record)}\n`);
283
+ return;
284
+ }
285
+ const projection = record.projection;
286
+ const lines = [
287
+ `${record.intent.id}: ${record.intent.title}`,
288
+ `state: ${projection.state}`,
289
+ `objective: ${record.intent.objective}`,
290
+ `reason: ${projection.reason}`,
291
+ `next: ${projection.next_action}`,
292
+ `attempts: ${projection.attempts_used}/${record.intent.max_attempts}`,
293
+ ];
294
+ if (record.intent.dependencies.length)
295
+ lines.push(`depends on: ${record.intent.dependencies.join(", ")}`);
296
+ if (record.intent.acceptance.length) {
297
+ lines.push("acceptance:", ...record.intent.acceptance.map((item) => ` - ${item}`));
298
+ }
299
+ if (projection.attempts.length) {
300
+ lines.push(
301
+ "attempts:",
302
+ ...projection.attempts.map(
303
+ (attempt) => ` ${attempt.number}. ${attempt.run_id} ${attempt.status}`,
304
+ ),
305
+ );
306
+ }
307
+ if (projection.approval_id) lines.push(`approval: ${projection.approval_id}`);
308
+ if (projection.proof_path) lines.push(`proof: ${projection.proof_path}`);
309
+ emit.text(`${lines.join("\n")}\n`);
310
+ }
311
+
312
+ function renderWorkRow(record: WorkRecord): string {
313
+ const projection = record.projection;
314
+ return `${record.intent.id} ${projection.state.padEnd(17)} ${projection.attempts_used}/${record.intent.max_attempts} ${projection.next_action.padEnd(21)} ${record.intent.title}`;
315
+ }
316
+
317
+ function withWorkRoot(emit: EmitContext, fn: (coordRoot: string) => void): void {
318
+ const coordRoot = findCoordRoot();
319
+ if (!coordRoot) {
320
+ emit.error({
321
+ code: "no_coord_root",
322
+ message: "no .harnery/ coordination root found; run `init` first",
323
+ });
324
+ emit.setExitCode(1);
325
+ return;
326
+ }
327
+ try {
328
+ fn(coordRoot);
329
+ } catch (error) {
330
+ emit.error({ code: "work_failed", message: (error as Error).message });
331
+ emit.setExitCode(1);
332
+ }
333
+ }
334
+
335
+ async function withWorkRootAsync(
336
+ emit: EmitContext,
337
+ fn: (coordRoot: string) => Promise<void>,
338
+ ): Promise<void> {
339
+ const coordRoot = findCoordRoot();
340
+ if (!coordRoot) {
341
+ emit.error({
342
+ code: "no_coord_root",
343
+ message: "no .harnery/ coordination root found; run `init` first",
344
+ });
345
+ emit.setExitCode(1);
346
+ return;
347
+ }
348
+ try {
349
+ await fn(coordRoot);
350
+ } catch (error) {
351
+ emit.error({ code: "work_failed", message: (error as Error).message });
352
+ emit.setExitCode(1);
353
+ }
354
+ }
355
+
356
+ function splitIds(value?: string): string[] {
357
+ return value
358
+ ? value
359
+ .split(",")
360
+ .map((item) => item.trim())
361
+ .filter(Boolean)
362
+ : [];
363
+ }
364
+
365
+ function collect(value: string, previous: string[]): string[] {
366
+ return [...previous, value];
367
+ }
@@ -5,6 +5,8 @@ import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
5
5
  import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
6
6
  import type { PolicyIsolation } from "../core/policy/index.ts";
7
7
  import { loadPolicyFile } from "../core/policy/index.ts";
8
+ import type { WorkflowApprovalStatus } from "../core/workflow/approvals.ts";
9
+ import { WorkflowParkedError } from "../core/workflow/engine.ts";
8
10
 
9
11
  /**
10
12
  * `workflow run <script>`: execute a workflow script — bounded, schema-gated,
@@ -26,6 +28,7 @@ interface WorkflowRunOpts {
26
28
  allowApiBilling?: boolean;
27
29
  policy?: string;
28
30
  isolation?: PolicyIsolation;
31
+ approvalTo?: string;
29
32
  json?: boolean;
30
33
  }
31
34
 
@@ -33,6 +36,17 @@ interface WorkflowProofOpts {
33
36
  json?: boolean;
34
37
  }
35
38
 
39
+ interface WorkflowApprovalListOpts {
40
+ status?: WorkflowApprovalStatus;
41
+ json?: boolean;
42
+ }
43
+
44
+ interface WorkflowApprovalDecisionOpts {
45
+ actor?: string;
46
+ reason?: string;
47
+ json?: boolean;
48
+ }
49
+
36
50
  export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
37
51
  const registry = createBuiltinHarnessRegistry();
38
52
  const harnesses = registry.ids();
@@ -75,6 +89,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
75
89
  "--isolation <mode>",
76
90
  "Host-created execution boundary: shared | worktree | sandbox | remote (default shared)",
77
91
  )
92
+ .option("--approval-to <address>", "Address durable ASK requests (default: operator)")
78
93
  .option("--json", "Emit the full RunReport as JSON")
79
94
  .action(async (script: string, opts: WorkflowRunOpts) => {
80
95
  const coordRoot = findCoordRoot();
@@ -134,6 +149,8 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
134
149
  ]),
135
150
  ),
136
151
  policy: opts.policy ? loadPolicyFile(opts.policy) : undefined,
152
+ approvalMode: "park",
153
+ approvalAddressee: opts.approvalTo,
137
154
  isolation: opts.isolation,
138
155
  // CLI harness subprocesses inherit the host network. A policy that
139
156
  // forbids network must therefore deny dispatch unless a future host
@@ -159,6 +176,24 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
159
176
  `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
160
177
  );
161
178
  } catch (err) {
179
+ if (err instanceof WorkflowParkedError) {
180
+ if (opts.json) {
181
+ emit.config({ format: "json" });
182
+ emit.data({
183
+ status: "parked",
184
+ runId: err.runId,
185
+ approvalId: err.approvalId,
186
+ journalPath: err.journalPath,
187
+ });
188
+ } else {
189
+ emit.text(
190
+ `run ${err.runId} parked\napproval: ${err.approvalId}\n` +
191
+ `journal: ${err.journalPath}\n` +
192
+ `resume after resolution: harn workflow resume ${err.runId}\n`,
193
+ );
194
+ }
195
+ return;
196
+ }
162
197
  const proofPath =
163
198
  typeof err === "object" && err !== null && "proofPath" in err
164
199
  ? String(err.proofPath)
@@ -171,6 +206,197 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
171
206
  }
172
207
  });
173
208
 
209
+ workflow
210
+ .command("resume <run-id>")
211
+ .description("Resume a parked workflow after its durable approval has been resolved.")
212
+ .option("--json", "Emit the full RunReport or parked result as JSON")
213
+ .action(async (runId: string, opts: { json?: boolean }) => {
214
+ const coordRoot = findCoordRoot();
215
+ if (!coordRoot) {
216
+ emit.error({
217
+ code: "no_coord_root",
218
+ message: "no .harnery/ coordination root found; run `init` first",
219
+ });
220
+ process.exit(1);
221
+ }
222
+ try {
223
+ const { assertWorkflowRunResumable, runWorkflow } = await import(
224
+ "../core/workflow/index.ts"
225
+ );
226
+ const { manifest } = assertWorkflowRunResumable(coordRoot, runId);
227
+ const report = await runWorkflow(manifest.script.path, {
228
+ coordRoot,
229
+ spawners: registry.spawners(),
230
+ resumeRunId: runId,
231
+ harnessEvidence: Object.fromEntries(
232
+ registry
233
+ .list()
234
+ .map((adapter) => [
235
+ adapter.profile.id,
236
+ { toolEvidence: adapter.profile.capabilities.toolEvidence },
237
+ ]),
238
+ ),
239
+ });
240
+ if (opts.json) {
241
+ emit.config({ format: "json" });
242
+ emit.data(report);
243
+ return;
244
+ }
245
+ const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
246
+ emit.text(
247
+ `run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
248
+ `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n` +
249
+ `acceptance: ${report.acceptance.satisfied} satisfied, ${report.acceptance.unsatisfied} unsatisfied, ` +
250
+ `${report.acceptance.unknown} unknown\n` +
251
+ `journal: ${report.journalPath}\nproof: ${report.proofPath}\n` +
252
+ `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
253
+ );
254
+ } catch (err) {
255
+ if (err instanceof WorkflowParkedError) {
256
+ if (opts.json) {
257
+ emit.config({ format: "json" });
258
+ emit.data({
259
+ status: "parked",
260
+ runId: err.runId,
261
+ approvalId: err.approvalId,
262
+ journalPath: err.journalPath,
263
+ });
264
+ } else {
265
+ emit.text(
266
+ `run ${err.runId} parked again\napproval: ${err.approvalId}\n` +
267
+ `journal: ${err.journalPath}\n`,
268
+ );
269
+ }
270
+ return;
271
+ }
272
+ const proofPath =
273
+ typeof err === "object" && err !== null && "proofPath" in err
274
+ ? String(err.proofPath)
275
+ : undefined;
276
+ emit.error({
277
+ code: "workflow_resume_failed",
278
+ message: `${(err as Error).message}${proofPath ? `\nproof: ${proofPath}` : ""}`,
279
+ });
280
+ process.exit(1);
281
+ }
282
+ });
283
+
284
+ const approvals = workflow
285
+ .command("approvals")
286
+ .description("Inspect and resolve durable workflow policy approvals.");
287
+
288
+ approvals
289
+ .command("list")
290
+ .description("List durable workflow approvals.")
291
+ .option("--status <status>", "Filter: pending | approved | denied")
292
+ .option("--json", "Emit approval records as JSON")
293
+ .action(async (opts: WorkflowApprovalListOpts) => {
294
+ const coordRoot = findCoordRoot();
295
+ if (!coordRoot) {
296
+ emit.error({ code: "no_coord_root", message: "no .harnery/ coordination root found" });
297
+ process.exit(1);
298
+ }
299
+ if (opts.status && !(["pending", "approved", "denied"] as const).includes(opts.status)) {
300
+ emit.error({
301
+ code: "bad_approval_status",
302
+ message: `unknown approval status ${JSON.stringify(opts.status)}`,
303
+ });
304
+ process.exit(1);
305
+ }
306
+ const { listWorkflowApprovals } = await import("../core/workflow/approvals.ts");
307
+ const records = listWorkflowApprovals(coordRoot, { status: opts.status });
308
+ if (opts.json) {
309
+ emit.config({ format: "json" });
310
+ emit.data(records);
311
+ return;
312
+ }
313
+ if (records.length === 0) {
314
+ emit.text("no workflow approvals\n");
315
+ return;
316
+ }
317
+ emit.text(
318
+ `${records
319
+ .map(
320
+ ({ request, status }) =>
321
+ `${request.id} ${status.padEnd(8)} ${request.run_id} ${request.request.phase} ${request.request.action} -> ${request.addressed_to}`,
322
+ )
323
+ .join("\n")}\n`,
324
+ );
325
+ });
326
+
327
+ approvals
328
+ .command("show <approval-id>")
329
+ .description("Show one durable workflow approval.")
330
+ .option("--json", "Emit the approval record as JSON")
331
+ .action(async (approvalId: string, opts: { json?: boolean }) => {
332
+ const coordRoot = findCoordRoot();
333
+ if (!coordRoot) {
334
+ emit.error({ code: "no_coord_root", message: "no .harnery/ coordination root found" });
335
+ process.exit(1);
336
+ }
337
+ try {
338
+ const { readWorkflowApproval, renderWorkflowApproval } = await import(
339
+ "../core/workflow/approvals.ts"
340
+ );
341
+ const approval = readWorkflowApproval(coordRoot, approvalId);
342
+ if (opts.json) {
343
+ emit.config({ format: "json" });
344
+ emit.data(approval);
345
+ } else {
346
+ emit.text(renderWorkflowApproval(approval));
347
+ }
348
+ } catch (err) {
349
+ emit.error({ code: "workflow_approval_read_failed", message: (err as Error).message });
350
+ process.exit(1);
351
+ }
352
+ });
353
+
354
+ const registerApprovalDecision = (command: "approve" | "deny", verdict: "allow" | "deny") => {
355
+ approvals
356
+ .command(`${command} <approval-id>`)
357
+ .description(`${command === "approve" ? "Approve" : "Deny"} a pending workflow approval.`)
358
+ .option("--actor <name>", "Decision actor recorded in the receipt")
359
+ .option("--reason <text>", "Bounded decision reason")
360
+ .option("--json", "Emit the resolved approval as JSON")
361
+ .action(async (approvalId: string, opts: WorkflowApprovalDecisionOpts) => {
362
+ const coordRoot = findCoordRoot();
363
+ if (!coordRoot) {
364
+ emit.error({ code: "no_coord_root", message: "no .harnery/ coordination root found" });
365
+ process.exit(1);
366
+ }
367
+ try {
368
+ const { resolveWorkflowApproval, renderWorkflowApproval } = await import(
369
+ "../core/workflow/approvals.ts"
370
+ );
371
+ const resolved = resolveWorkflowApproval({
372
+ coordRoot,
373
+ approvalId,
374
+ verdict,
375
+ actor: opts.actor ?? process.env.USER ?? process.env.USERNAME ?? "operator",
376
+ reason: opts.reason,
377
+ });
378
+ if (opts.json) {
379
+ emit.config({ format: "json" });
380
+ emit.data(resolved);
381
+ } else {
382
+ emit.text(
383
+ `${renderWorkflowApproval(resolved.approval)}` +
384
+ `${resolved.applied ? "decision recorded" : "decision already recorded"}\n` +
385
+ `resume: harn workflow resume ${resolved.approval.request.run_id}\n`,
386
+ );
387
+ }
388
+ } catch (err) {
389
+ emit.error({
390
+ code: "workflow_approval_resolution_failed",
391
+ message: (err as Error).message,
392
+ });
393
+ process.exit(1);
394
+ }
395
+ });
396
+ };
397
+ registerApprovalDecision("approve", "allow");
398
+ registerApprovalDecision("deny", "deny");
399
+
174
400
  workflow
175
401
  .command("proof <run-id>")
176
402
  .description("Show the bounded proof packet for a completed workflow run.")
@@ -86,7 +86,7 @@ export interface PolicyDecision {
86
86
  phase: PolicyPhase;
87
87
  initial_verdict: PolicyVerdict;
88
88
  verdict: "allow" | "deny";
89
- resolved_by: "policy" | "host" | "fail_closed";
89
+ resolved_by: "policy" | "host" | "approval" | "fail_closed";
90
90
  reason: string;
91
91
  rule_codes: string[];
92
92
  request: PolicyRequestSummary;
@@ -0,0 +1,24 @@
1
+ export { type RunWorkItemInput, runWorkItem } from "./runner.ts";
2
+ export {
3
+ acceptWorkItem,
4
+ assertWorkId,
5
+ type CreateWorkItemInput,
6
+ cancelWorkItem,
7
+ createWorkItem,
8
+ listWorkItems,
9
+ newWorkId,
10
+ readWorkItem,
11
+ reconcileAllWorkItems,
12
+ reconcileWorkItem,
13
+ reopenWorkItem,
14
+ WORK_EVENT_SCHEMA_VERSION,
15
+ WORK_INTENT_SCHEMA_VERSION,
16
+ type WorkAttempt,
17
+ type WorkEvent,
18
+ type WorkEventType,
19
+ type WorkIntent,
20
+ type WorkNextAction,
21
+ type WorkProjection,
22
+ type WorkRecord,
23
+ type WorkState,
24
+ } from "./state.ts";
@@ -0,0 +1,15 @@
1
+ export {
2
+ assertWorkId,
3
+ listWorkItems,
4
+ readWorkItem,
5
+ WORK_EVENT_SCHEMA_VERSION,
6
+ WORK_INTENT_SCHEMA_VERSION,
7
+ type WorkAttempt,
8
+ type WorkEvent,
9
+ type WorkEventType,
10
+ type WorkIntent,
11
+ type WorkNextAction,
12
+ type WorkProjection,
13
+ type WorkRecord,
14
+ type WorkState,
15
+ } from "./state.ts";