harnery 0.26.0 → 0.28.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 (116) hide show
  1. package/dist/commands/browse.d.ts.map +1 -1
  2. package/dist/commands/browse.js +27 -1
  3. package/dist/commands/harness.d.ts.map +1 -1
  4. package/dist/commands/harness.js +2 -0
  5. package/dist/commands/work.d.ts +2 -0
  6. package/dist/commands/work.d.ts.map +1 -1
  7. package/dist/commands/work.js +47 -4
  8. package/dist/commands/workflow.d.ts.map +1 -1
  9. package/dist/commands/workflow.js +64 -0
  10. package/dist/core/harnesses/attest-projection.d.ts +44 -0
  11. package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
  12. package/dist/core/harnesses/attest-projection.js +113 -0
  13. package/dist/core/harnesses/attest.d.ts +9 -0
  14. package/dist/core/harnesses/attest.d.ts.map +1 -1
  15. package/dist/core/harnesses/attest.js +14 -1
  16. package/dist/core/harnesses/attestation.d.ts +1 -1
  17. package/dist/core/harnesses/attestation.d.ts.map +1 -1
  18. package/dist/core/harnesses/attestation.js +7 -1
  19. package/dist/core/harnesses/bench.d.ts.map +1 -1
  20. package/dist/core/harnesses/bench.js +13 -0
  21. package/dist/core/harnesses/profiles.d.ts +7 -0
  22. package/dist/core/harnesses/profiles.d.ts.map +1 -1
  23. package/dist/core/harnesses/profiles.js +9 -0
  24. package/dist/core/harnesses/types.d.ts +19 -1
  25. package/dist/core/harnesses/types.d.ts.map +1 -1
  26. package/dist/core/harnesses/types.js +1 -0
  27. package/dist/core/work/index.d.ts +1 -1
  28. package/dist/core/work/index.d.ts.map +1 -1
  29. package/dist/core/work/index.js +1 -1
  30. package/dist/core/work/runner.d.ts.map +1 -1
  31. package/dist/core/work/runner.js +8 -1
  32. package/dist/core/work/state.d.ts +24 -1
  33. package/dist/core/work/state.d.ts.map +1 -1
  34. package/dist/core/work/state.js +108 -14
  35. package/dist/core/workflow/approvals.d.ts.map +1 -1
  36. package/dist/core/workflow/approvals.js +3 -6
  37. package/dist/core/workflow/attempt-context.d.ts.map +1 -1
  38. package/dist/core/workflow/attempt-context.js +38 -1
  39. package/dist/core/workflow/engine.d.ts.map +1 -1
  40. package/dist/core/workflow/engine.js +61 -19
  41. package/dist/core/workflow/index.d.ts +1 -1
  42. package/dist/core/workflow/index.d.ts.map +1 -1
  43. package/dist/core/workflow/index.js +1 -1
  44. package/dist/core/workflow/journal.d.ts +30 -0
  45. package/dist/core/workflow/journal.d.ts.map +1 -0
  46. package/dist/core/workflow/journal.js +70 -0
  47. package/dist/core/workflow/proof.d.ts +3 -1
  48. package/dist/core/workflow/proof.d.ts.map +1 -1
  49. package/dist/core/workflow/proof.js +1 -0
  50. package/dist/core/workflow/sandbox-projection.d.ts +57 -0
  51. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
  52. package/dist/core/workflow/sandbox-projection.js +95 -0
  53. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  54. package/dist/core/workflow/spawn-claude.js +14 -1
  55. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  56. package/dist/core/workflow/spawn-codex.js +18 -2
  57. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  58. package/dist/core/workflow/spawn-cursor.js +14 -1
  59. package/dist/core/workflow/types.d.ts +62 -0
  60. package/dist/core/workflow/types.d.ts.map +1 -1
  61. package/dist/core/workflow/workspaces/index.d.ts +2 -0
  62. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  63. package/dist/core/workflow/workspaces/index.js +1 -0
  64. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
  65. package/dist/core/workflow/workspaces/inspect.js +14 -0
  66. package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
  67. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
  68. package/dist/core/workflow/workspaces/reclaim.js +88 -0
  69. package/dist/core/workflow/workspaces/state.d.ts +1 -1
  70. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  71. package/dist/core/workflow/workspaces/state.js +9 -30
  72. package/dist/lib/browser/client.d.ts +50 -0
  73. package/dist/lib/browser/client.d.ts.map +1 -1
  74. package/dist/lib/browser/client.js +103 -0
  75. package/dist/lib/exec.d.ts +4 -0
  76. package/dist/lib/exec.d.ts.map +1 -1
  77. package/dist/lib/exec.js +6 -1
  78. package/dist/lib/tunnel/error-page.d.ts +15 -0
  79. package/dist/lib/tunnel/error-page.d.ts.map +1 -0
  80. package/dist/lib/tunnel/error-page.js +98 -0
  81. package/dist/lib/tunnel/gate.d.ts +1 -12
  82. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  83. package/dist/lib/tunnel/gate.js +59 -4
  84. package/package.json +1 -1
  85. package/src/commands/browse.ts +33 -1
  86. package/src/commands/harness.ts +6 -0
  87. package/src/commands/work.ts +80 -13
  88. package/src/commands/workflow.ts +74 -0
  89. package/src/core/harnesses/attest-projection.ts +151 -0
  90. package/src/core/harnesses/attest.ts +23 -1
  91. package/src/core/harnesses/attestation.ts +7 -1
  92. package/src/core/harnesses/bench.ts +16 -0
  93. package/src/core/harnesses/profiles.ts +11 -0
  94. package/src/core/harnesses/types.ts +20 -0
  95. package/src/core/work/index.ts +1 -0
  96. package/src/core/work/runner.ts +8 -0
  97. package/src/core/work/state.ts +177 -27
  98. package/src/core/workflow/approvals.ts +3 -6
  99. package/src/core/workflow/attempt-context.ts +47 -1
  100. package/src/core/workflow/engine.ts +106 -33
  101. package/src/core/workflow/index.ts +1 -0
  102. package/src/core/workflow/journal.ts +90 -0
  103. package/src/core/workflow/proof.ts +4 -0
  104. package/src/core/workflow/sandbox-projection.ts +150 -0
  105. package/src/core/workflow/spawn-claude.ts +18 -1
  106. package/src/core/workflow/spawn-codex.ts +25 -2
  107. package/src/core/workflow/spawn-cursor.ts +18 -1
  108. package/src/core/workflow/types.ts +66 -0
  109. package/src/core/workflow/workspaces/index.ts +2 -0
  110. package/src/core/workflow/workspaces/inspect.ts +15 -0
  111. package/src/core/workflow/workspaces/reclaim.ts +109 -0
  112. package/src/core/workflow/workspaces/state.ts +13 -33
  113. package/src/lib/browser/client.ts +129 -0
  114. package/src/lib/exec.ts +10 -1
  115. package/src/lib/tunnel/error-page.ts +114 -0
  116. package/src/lib/tunnel/gate.ts +66 -3
@@ -16,6 +16,7 @@ import { hostname } from "node:os";
16
16
  import { dirname, isAbsolute, join, resolve } from "node:path";
17
17
  import { readWorkflowApproval } from "../workflow/approvals.ts";
18
18
  import { stableDigest } from "../workflow/durable-record.ts";
19
+ import { WORKFLOW_JOURNAL_EVENT_BYTES, workflowJournalPath } from "../workflow/journal.ts";
19
20
  import { readWorkflowProof } from "../workflow/proof.ts";
20
21
  import { readWorkflowRunManifest, workflowScriptDigest } from "../workflow/run-state.ts";
21
22
  import { isWorkspaceBoundExecutionEvidence } from "../workflow/workspaces/validate.ts";
@@ -98,18 +99,35 @@ export interface WorkEvent {
98
99
  proof_sha256?: string;
99
100
  terminal_attestation_sha256?: string;
100
101
  accepted_unknowns?: string[];
102
+ /** Operator corrections raised with a reopen. Authored by a human against a
103
+ * prior attempt, carried into the next attempt, and open until disposed. */
104
+ findings?: WorkOperatorFinding[];
105
+ /** How an operator disposed of each open finding at acceptance. */
106
+ dispositions?: WorkFindingDisposition[];
101
107
  state?: WorkState;
102
108
  next_action?: WorkNextAction;
103
109
  }
104
110
 
111
+ export interface WorkOperatorFinding {
112
+ id: string;
113
+ statement: string;
114
+ }
115
+
116
+ export interface WorkFindingDisposition {
117
+ id: string;
118
+ outcome: "fixed" | "deferred";
119
+ reason?: string;
120
+ }
121
+
105
122
  export interface WorkAttempt {
106
123
  number: number;
107
124
  run_id: string;
108
125
  started_at: string;
109
126
  trigger?: WorkAttemptTrigger;
110
- status: "running" | "parked" | "succeeded" | "failed" | "lost";
127
+ status: "running" | "parked" | "succeeded" | "failed" | "lost" | "journal_unreadable";
111
128
  approval_id?: string;
112
129
  proof_path?: string;
130
+ journal_error?: string;
113
131
  }
114
132
 
115
133
  export interface WorkProjection {
@@ -270,7 +288,12 @@ export function reconcileAllWorkItems(coordRoot: string, actor?: string): WorkRe
270
288
  export function acceptWorkItem(
271
289
  coordRoot: string,
272
290
  workId: string,
273
- input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] } = {},
291
+ input: {
292
+ actor?: string;
293
+ reason?: string;
294
+ acceptedUnknowns?: readonly string[];
295
+ dispositions?: readonly WorkFindingDisposition[];
296
+ } = {},
274
297
  ): WorkRecord {
275
298
  return appendGovernanceEvent(coordRoot, workId, "work.accepted", ["in_review"], input);
276
299
  }
@@ -292,7 +315,7 @@ export function cancelWorkItem(
292
315
  export function reopenWorkItem(
293
316
  coordRoot: string,
294
317
  workId: string,
295
- input: { actor?: string; reason?: string } = {},
318
+ input: { actor?: string; reason?: string; findings?: readonly string[] } = {},
296
319
  ): WorkRecord {
297
320
  return appendGovernanceEvent(
298
321
  coordRoot,
@@ -303,6 +326,28 @@ export function reopenWorkItem(
303
326
  );
304
327
  }
305
328
 
329
+ /** Findings raised by the most recent reopen that no later acceptance disposed
330
+ * of. Empty when the work was never reopened with findings. */
331
+ export function openOperatorFindings(
332
+ coordRoot: string,
333
+ workId: string,
334
+ ): { actor: string; findings: WorkOperatorFinding[] } {
335
+ const events = readWorkEvents(coordRoot, workId);
336
+ let actor = "operator";
337
+ let open: WorkOperatorFinding[] = [];
338
+ for (const event of events) {
339
+ if (event.event === "work.reopened" && event.findings?.length) {
340
+ actor = event.actor;
341
+ open = event.findings.map((finding) => ({ ...finding }));
342
+ }
343
+ if (event.event === "work.accepted" && event.dispositions?.length) {
344
+ const disposed = new Set(event.dispositions.map((entry) => entry.id));
345
+ open = open.filter((finding) => !disposed.has(finding.id));
346
+ }
347
+ }
348
+ return { actor, findings: open };
349
+ }
350
+
306
351
  export function assertWorkId(workId: string): void {
307
352
  if (!WORK_ID.test(workId)) throw new Error(`invalid work id ${JSON.stringify(workId)}`);
308
353
  }
@@ -418,6 +463,16 @@ function deriveWorkProjection(
418
463
  };
419
464
  }
420
465
  const attemptsRemaining = intent.max_attempts - attempts.length;
466
+ if (latest.status === "journal_unreadable") {
467
+ return {
468
+ ...base,
469
+ state: "blocked",
470
+ reason: `workflow attempt ${latest.number} journal is unreadable: ${
471
+ latest.journal_error ?? "unknown journal read error"
472
+ }`,
473
+ next_action: attemptsRemaining > 0 ? "retry" : "none",
474
+ };
475
+ }
421
476
  return {
422
477
  ...base,
423
478
  state: "blocked",
@@ -494,21 +549,36 @@ function inspectAttempt(
494
549
  : "failed";
495
550
  return attempt;
496
551
  }
497
- const journalPath = join(coordRoot, ".harnery", "workflows", runId, "journal.jsonl");
552
+ const journalPath = workflowJournalPath(coordRoot, runId);
498
553
  if (existsSync(journalPath)) {
499
554
  let parked: string | undefined;
500
555
  let resumed = false;
501
- for (const line of readBoundedLines(journalPath, MAX_EVENTS_BYTES, "workflow journal")) {
502
- const value = parseObject(line, `workflow run ${runId} journal`);
503
- if (value.event === "run.parked" && typeof value.approval_id === "string") {
504
- parked = value.approval_id;
505
- resumed = false;
556
+ try {
557
+ for (const line of readBoundedLines(
558
+ journalPath,
559
+ MAX_EVENTS_BYTES,
560
+ WORKFLOW_JOURNAL_EVENT_BYTES,
561
+ "workflow journal",
562
+ )) {
563
+ const value = parseObject(line, `workflow run ${runId} journal`);
564
+ if (value.event === "run.parked" && typeof value.approval_id === "string") {
565
+ parked = value.approval_id;
566
+ resumed = false;
567
+ }
568
+ if (value.event === "run.resume") resumed = true;
569
+ }
570
+ if (parked && !resumed) {
571
+ attempt.status = "parked";
572
+ attempt.approval_id = parked;
573
+ return attempt;
574
+ }
575
+ } catch (error) {
576
+ if (workflowResumeLeaseIsLive(coordRoot, runId)) {
577
+ attempt.status = "running";
578
+ } else {
579
+ attempt.status = "journal_unreadable";
580
+ attempt.journal_error = boundedJournalError((error as Error).message);
506
581
  }
507
- if (value.event === "run.resume") resumed = true;
508
- }
509
- if (parked && !resumed) {
510
- attempt.status = "parked";
511
- attempt.approval_id = parked;
512
582
  return attempt;
513
583
  }
514
584
  }
@@ -537,7 +607,13 @@ function appendGovernanceEvent(
537
607
  workId: string,
538
608
  event: "work.accepted" | "work.cancelled" | "work.reopened",
539
609
  allowed: WorkState[],
540
- input: { actor?: string; reason?: string; acceptedUnknowns?: readonly string[] },
610
+ input: {
611
+ actor?: string;
612
+ reason?: string;
613
+ acceptedUnknowns?: readonly string[];
614
+ findings?: readonly string[];
615
+ dispositions?: readonly WorkFindingDisposition[];
616
+ },
541
617
  ): WorkRecord {
542
618
  const releaseWork = event === "work.cancelled" ? undefined : acquireWorkLease(coordRoot, workId);
543
619
  const releaseEvent = acquireWorkEventLease(coordRoot, workId);
@@ -554,11 +630,19 @@ function appendGovernanceEvent(
554
630
  event === "work.accepted"
555
631
  ? acceptanceAuthority(coordRoot, current, input.acceptedUnknowns ?? [])
556
632
  : undefined;
633
+ const dispositions =
634
+ event === "work.accepted"
635
+ ? findingAuthority(coordRoot, workId, input.dispositions ?? [])
636
+ : undefined;
637
+ const findings =
638
+ event === "work.reopened" ? normalizeRaisedFindings(input.findings) : undefined;
557
639
  appendWorkEventUnderLease(coordRoot, workId, {
558
640
  event,
559
641
  actor: boundedActor(input.actor),
560
642
  reason: boundedOptional(input.reason, "work reason", MAX_REASON),
561
643
  ...acceptance,
644
+ ...(findings ? { findings } : {}),
645
+ ...(dispositions ? { dispositions } : {}),
562
646
  });
563
647
  } finally {
564
648
  releaseEvent();
@@ -567,6 +651,59 @@ function appendGovernanceEvent(
567
651
  return reconcileWorkItem(coordRoot, workId, input.actor);
568
652
  }
569
653
 
654
+ /** Acceptance fails closed while an operator finding is open. The operator who
655
+ * raised it decides whether it was addressed; the team cannot mark its own. */
656
+ function findingAuthority(
657
+ coordRoot: string,
658
+ workId: string,
659
+ dispositions: readonly WorkFindingDisposition[],
660
+ ): WorkFindingDisposition[] {
661
+ const open = openOperatorFindings(coordRoot, workId).findings;
662
+ if (open.length === 0) {
663
+ if (dispositions.length > 0) {
664
+ throw new Error(`work item ${workId} has no open operator finding to dispose`);
665
+ }
666
+ return [];
667
+ }
668
+ const byId = new Map(dispositions.map((entry) => [entry.id, entry]));
669
+ const undisposed = open.filter((finding) => !byId.has(finding.id));
670
+ if (undisposed.length > 0) {
671
+ throw new Error(
672
+ `work item ${workId} cannot be accepted while operator findings are undisposed: ${undisposed
673
+ .map((finding) => finding.id)
674
+ .join(", ")}`,
675
+ );
676
+ }
677
+ const known = new Set(open.map((finding) => finding.id));
678
+ return dispositions.map((entry) => {
679
+ if (!known.has(entry.id)) {
680
+ throw new Error(`work item ${workId} has no open operator finding ${entry.id}`);
681
+ }
682
+ if (entry.outcome !== "fixed" && entry.outcome !== "deferred") {
683
+ throw new Error(`operator finding ${entry.id} disposition must be fixed or deferred`);
684
+ }
685
+ if (entry.outcome === "deferred" && !entry.reason?.trim()) {
686
+ throw new Error(`deferring operator finding ${entry.id} requires a reason`);
687
+ }
688
+ return {
689
+ id: entry.id,
690
+ outcome: entry.outcome,
691
+ ...(entry.reason ? { reason: boundedString(entry.reason, "disposition reason", 500) } : {}),
692
+ };
693
+ });
694
+ }
695
+
696
+ function normalizeRaisedFindings(
697
+ statements: readonly string[] | undefined,
698
+ ): WorkOperatorFinding[] | undefined {
699
+ if (!statements || statements.length === 0) return undefined;
700
+ if (statements.length > 20) throw new Error("a reopen may raise at most 20 findings");
701
+ return statements.map((statement, index) => ({
702
+ id: `f${index + 1}`,
703
+ statement: boundedString(statement, `finding[${index}] statement`, 1_000),
704
+ }));
705
+ }
706
+
570
707
  function acceptanceAuthority(
571
708
  coordRoot: string,
572
709
  current: WorkRecord,
@@ -712,16 +849,19 @@ function readWorkEvents(coordRoot: string, workId: string): WorkEvent[] {
712
849
  assertWorkId(workId);
713
850
  const path = join(workDir(coordRoot, workId), "events.jsonl");
714
851
  if (!existsSync(path)) return [];
715
- const events = readBoundedLines(path, MAX_EVENTS_BYTES, `work item ${workId} events`).map(
716
- (line, index) => {
717
- const event = parseObject(
718
- line,
719
- `work item ${workId} event ${index + 1}`,
720
- ) as unknown as WorkEvent;
721
- validateWorkEvent(event, workId, index + 1);
722
- return event;
723
- },
724
- );
852
+ const events = readBoundedLines(
853
+ path,
854
+ MAX_EVENTS_BYTES,
855
+ MAX_EVENT_BYTES,
856
+ `work item ${workId} events`,
857
+ ).map((line, index) => {
858
+ const event = parseObject(
859
+ line,
860
+ `work item ${workId} event ${index + 1}`,
861
+ ) as unknown as WorkEvent;
862
+ validateWorkEvent(event, workId, index + 1);
863
+ return event;
864
+ });
725
865
  validateWorkHistory(events, workId);
726
866
  return events;
727
867
  }
@@ -1026,7 +1166,12 @@ function writePrivateJson(path: string, value: unknown, maxBytes: number): void
1026
1166
  chmodSync(path, 0o600);
1027
1167
  }
1028
1168
 
1029
- function readBoundedLines(path: string, maxBytes: number, field: string): string[] {
1169
+ function readBoundedLines(
1170
+ path: string,
1171
+ maxBytes: number,
1172
+ maxRecordBytes: number,
1173
+ field: string,
1174
+ ): string[] {
1030
1175
  const size = statSync(path).size;
1031
1176
  if (size > maxBytes) throw new Error(`${field} exceeds ${maxBytes} bytes`);
1032
1177
  const body = readFileSync(path, "utf8");
@@ -1035,12 +1180,17 @@ function readBoundedLines(path: string, maxBytes: number, field: string): string
1035
1180
  const lines = body.split("\n").filter(Boolean);
1036
1181
  if (lines.length > MAX_EVENTS) throw new Error(`${field} exceeds ${MAX_EVENTS} records`);
1037
1182
  for (const line of lines) {
1038
- if (Buffer.byteLength(line) > MAX_EVENT_BYTES)
1183
+ if (Buffer.byteLength(line) > maxRecordBytes)
1039
1184
  throw new Error(`${field} contains an oversized record`);
1040
1185
  }
1041
1186
  return lines;
1042
1187
  }
1043
1188
 
1189
+ function boundedJournalError(message: string): string {
1190
+ const normalized = message.trim() || "unknown journal read error";
1191
+ return normalized.length > MAX_REASON ? normalized.slice(0, MAX_REASON) : normalized;
1192
+ }
1193
+
1044
1194
  function parseObject(input: string, field: string): Record<string, unknown> {
1045
1195
  try {
1046
1196
  const value = JSON.parse(input) as unknown;
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import type { PolicyEvaluation, PolicyRequestSummary, PolicyVerdict } from "../policy/index.ts";
15
+ import { appendWorkflowJournalEvent } from "./journal.ts";
15
16
 
16
17
  export const WORKFLOW_APPROVAL_SCHEMA_VERSION = 1 as const;
17
18
 
@@ -222,19 +223,15 @@ function appendResolutionJournal(coordRoot: string, approval: WorkflowApproval):
222
223
  "journal.jsonl",
223
224
  );
224
225
  if (!existsSync(journalPath) || !approval.decision) return;
225
- const event = {
226
- schema_version: 1,
227
- run_id: approval.request.run_id,
226
+ appendWorkflowJournalEvent(coordRoot, approval.request.run_id, "approval.resolved", {
228
227
  ts: approval.decision.decided_at,
229
- event: "approval.resolved",
230
228
  stage: "",
231
229
  approval_id: approval.request.id,
232
230
  decision_id: approval.request.decision_id,
233
231
  verdict: approval.decision.verdict,
234
232
  actor: approval.decision.actor,
235
233
  reason: approval.decision.reason ?? null,
236
- };
237
- writeFileSync(journalPath, `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
234
+ });
238
235
  }
239
236
 
240
237
  function approvalRequestDigest(
@@ -4,6 +4,7 @@ import type {
4
4
  WorkflowAttemptFailureCause,
5
5
  WorkflowAttemptPriorContext,
6
6
  WorkflowAttemptUnresolvedCriterion,
7
+ WorkflowOperatorFinding,
7
8
  } from "./types.ts";
8
9
  import { WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION } from "./types.ts";
9
10
 
@@ -13,7 +14,12 @@ const MAX_ATTEMPTS = 100;
13
14
  const MAX_ERROR = 2_000;
14
15
  const MAX_CRITERIA = 50;
15
16
  const MAX_STATEMENT = 500;
16
- const CONTEXT_FIELDS = new Set(["schema_version", "number", "trigger", "prior"]);
17
+ const MAX_FINDINGS = 20;
18
+ const MAX_FINDING_STATEMENT = 1_000;
19
+ const MAX_FINDING_ACTOR = 100;
20
+ const FINDING_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
21
+ const CONTEXT_FIELDS = new Set(["schema_version", "number", "trigger", "prior", "findings"]);
22
+ const FINDING_FIELDS = new Set(["id", "actor", "statement"]);
17
23
  const PRIOR_FIELDS = new Set(["run_id", "causes", "error", "acceptance", "unresolved"]);
18
24
  const SUMMARY_FIELDS = new Set(["satisfied", "unsatisfied", "unknown", "total"]);
19
25
  const CRITERION_FIELDS = new Set(["id", "statement", "status"]);
@@ -34,6 +40,7 @@ export function normalizeWorkflowAttemptContext(value: unknown): WorkflowAttempt
34
40
  if (input.trigger !== "initial" && input.trigger !== "retry") {
35
41
  throw new Error('workflow attempt context trigger must be "initial" or "retry"');
36
42
  }
43
+ const findings = normalizeFindings(input.findings);
37
44
  if (input.trigger === "initial") {
38
45
  if (input.prior !== undefined) {
39
46
  throw new Error("initial workflow attempt context cannot contain prior evidence");
@@ -42,6 +49,7 @@ export function normalizeWorkflowAttemptContext(value: unknown): WorkflowAttempt
42
49
  schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
43
50
  number,
44
51
  trigger: "initial",
52
+ ...(findings.length === 0 ? {} : { findings }),
45
53
  };
46
54
  }
47
55
  if (number < 2) throw new Error("retry workflow attempt context number must be at least 2");
@@ -53,9 +61,43 @@ export function normalizeWorkflowAttemptContext(value: unknown): WorkflowAttempt
53
61
  number,
54
62
  trigger: "retry",
55
63
  prior: normalizePrior(input.prior),
64
+ ...(findings.length === 0 ? {} : { findings }),
56
65
  };
57
66
  }
58
67
 
68
+ function normalizeFindings(value: unknown): WorkflowOperatorFinding[] {
69
+ if (value === undefined) return [];
70
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_FINDINGS) {
71
+ throw new Error(
72
+ `workflow attempt findings must contain 1 to ${MAX_FINDINGS} entries when present`,
73
+ );
74
+ }
75
+ const ids = new Set<string>();
76
+ return value.map((candidate, index) => {
77
+ const input = objectValue(candidate, `workflow attempt finding[${index}]`);
78
+ rejectExtraFields(input, FINDING_FIELDS, `workflow attempt finding[${index}]`);
79
+ const id = requiredString(input.id, `workflow attempt finding[${index}].id`, 64);
80
+ if (!FINDING_ID.test(id)) {
81
+ throw new Error(`invalid workflow attempt finding id ${JSON.stringify(id)}`);
82
+ }
83
+ if (ids.has(id)) throw new Error(`duplicate workflow attempt finding id ${JSON.stringify(id)}`);
84
+ ids.add(id);
85
+ return {
86
+ id,
87
+ actor: requiredString(
88
+ input.actor,
89
+ `workflow attempt finding[${index}].actor`,
90
+ MAX_FINDING_ACTOR,
91
+ ),
92
+ statement: requiredString(
93
+ input.statement,
94
+ `workflow attempt finding[${index}].statement`,
95
+ MAX_FINDING_STATEMENT,
96
+ ),
97
+ };
98
+ });
99
+ }
100
+
59
101
  export function freezeWorkflowAttemptContext(value: unknown): Readonly<WorkflowAttemptContext> {
60
102
  const normalized = normalizeWorkflowAttemptContext(value);
61
103
  if (normalized.prior) {
@@ -65,6 +107,10 @@ export function freezeWorkflowAttemptContext(value: unknown): Readonly<WorkflowA
65
107
  Object.freeze(normalized.prior.unresolved);
66
108
  Object.freeze(normalized.prior);
67
109
  }
110
+ if (normalized.findings) {
111
+ for (const finding of normalized.findings) Object.freeze(finding);
112
+ Object.freeze(normalized.findings);
113
+ }
68
114
  return Object.freeze(normalized);
69
115
  }
70
116
 
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createHash, randomBytes } from "node:crypto";
21
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
21
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
22
22
  import { isAbsolute, join, resolve } from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { snapshotRepo } from "../context/index.ts";
@@ -39,6 +39,7 @@ import { assertWorkflowRunId, createWorkflowApproval } from "./approvals.ts";
39
39
  import { freezeWorkflowAttemptContext } from "./attempt-context.ts";
40
40
  import { type BillingProbe, probeBilling } from "./billing.ts";
41
41
  import { stableDigest } from "./durable-record.ts";
42
+ import { appendWorkflowJournalEvent } from "./journal.ts";
42
43
  import {
43
44
  buildWorkflowProof,
44
45
  createEvidenceRecord,
@@ -53,12 +54,15 @@ import {
53
54
  workflowScriptDigest,
54
55
  writeWorkflowRunManifest,
55
56
  } from "./run-state.ts";
57
+ import { assertProjectionWithinWorkspace, resolveGitGrantRoots } from "./sandbox-projection.ts";
56
58
  import { normalizeWorkflowSpecialists, resolveSpecialistAssignment } from "./specialists.ts";
57
59
  import type {
58
60
  AgentOpts,
59
61
  EngineOpts,
62
+ GitAdministrativeGrant,
60
63
  HarnessName,
61
64
  RunReport,
65
+ SpawnFilesystemPolicy,
62
66
  SpawnResult,
63
67
  StageSchema,
64
68
  WorkflowAgentProof,
@@ -67,9 +71,9 @@ import type {
67
71
  WorkflowEvidenceRecord,
68
72
  WorkflowModule,
69
73
  WorkflowProof,
74
+ WorkflowSandboxProjectionEvidence,
70
75
  WorkflowWorkContext,
71
76
  } from "./types.ts";
72
- import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
73
77
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
74
78
  import { freezeWorkflowWorkContext } from "./work-context.ts";
75
79
  import {
@@ -77,7 +81,6 @@ import {
77
81
  attestWorkspaceFailure,
78
82
  resolveWorkspaceBinding,
79
83
  } from "./workspaces/execution.ts";
80
- import { appendWorkflowJournalEvent } from "./workspaces/state.ts";
81
84
  import type {
82
85
  WorkspaceAttestation,
83
86
  WorkspaceBinding,
@@ -85,6 +88,21 @@ import type {
85
88
  } from "./workspaces/types.ts";
86
89
  import { isWorkspaceAttestation } from "./workspaces/validate.ts";
87
90
 
91
+ /** Proof record of the filesystem projection a run applied (ADR 0039/0040).
92
+ * Absent when no policy was in force, which reads as "no projection applied"
93
+ * rather than "an empty one". */
94
+ function sandboxProjectionEvidence(
95
+ policy: SpawnFilesystemPolicy | undefined,
96
+ gitWrite: GitAdministrativeGrant,
97
+ ): WorkflowSandboxProjectionEvidence | undefined {
98
+ if (!policy) return undefined;
99
+ return {
100
+ mode: policy.mode,
101
+ writable_roots: [...(policy.writableRoots ?? [])],
102
+ git_grant: gitWrite,
103
+ };
104
+ }
105
+
88
106
  const DEFAULT_MAX_AGENTS = 50;
89
107
  const DEFAULT_CONCURRENCY = 4;
90
108
  const DEFAULT_MAX_ATTEMPTS = 2;
@@ -317,6 +335,10 @@ async function executeWorkflow(
317
335
  evidence: [],
318
336
  harnessEvidence: opts.harnessEvidence,
319
337
  harnessAttestations: opts.harnessAttestations,
338
+ sandboxProjection: sandboxProjectionEvidence(
339
+ opts.filesystemPolicy,
340
+ opts.gitWrite ?? "none",
341
+ ),
320
342
  policy: policy
321
343
  ? {
322
344
  config: policy,
@@ -342,6 +364,38 @@ async function executeWorkflow(
342
364
  }
343
365
  const effectiveIsolation = workspaceFallback?.effective_isolation ?? isolation;
344
366
  const executionCwd = workspaceBinding?.active_root ?? cwd;
367
+ // Validate the projection once, before any child launches, so a policy that
368
+ // would reach outside the provider's validated root fails the run rather than
369
+ // silently widening one child's write access (ADR 0039).
370
+ const requestedPolicy = opts.filesystemPolicy;
371
+ if (requestedPolicy?.writableRoots?.length) {
372
+ if (!workspaceBinding) {
373
+ throw new Error(
374
+ "a filesystem policy with writable roots requires an isolated workspace; none is bound to this run",
375
+ );
376
+ }
377
+ assertProjectionWithinWorkspace(
378
+ "workflow",
379
+ workspaceBinding.writable_root.realpath,
380
+ requestedPolicy.writableRoots,
381
+ );
382
+ }
383
+ // The Git grant is resolved after containment on purpose: caller-supplied
384
+ // roots must stay inside the workspace, while these come from the verified
385
+ // binding and are the one sanctioned way out of it (ADR 0040).
386
+ const gitWrite = opts.gitWrite ?? "none";
387
+ const gitGrantRoots = resolveGitGrantRoots(gitWrite, workspaceBinding);
388
+ if (gitGrantRoots.length > 0 && !requestedPolicy) {
389
+ throw new Error(
390
+ `gitWrite "${gitWrite}" has no effect without a filesystem policy to carry it; set filesystemPolicy or drop the grant`,
391
+ );
392
+ }
393
+ const filesystemPolicy: SpawnFilesystemPolicy | undefined = requestedPolicy
394
+ ? {
395
+ ...requestedPolicy,
396
+ writableRoots: [...(requestedPolicy.writableRoots ?? []), ...gitGrantRoots],
397
+ }
398
+ : undefined;
345
399
  const executionRepoBefore = resumeState?.manifest.repository_before ?? snapshotRepo(executionCwd);
346
400
  if (!resumeState) {
347
401
  writeWorkflowRunManifest({
@@ -471,15 +525,22 @@ async function executeWorkflow(
471
525
  const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
472
526
 
473
527
  const journal = (event: string, data: Record<string, unknown>): void => {
474
- const line = JSON.stringify({
475
- schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
476
- run_id: runId,
477
- ts: new Date().toISOString(),
478
- event,
479
- stage: currentStage,
528
+ appendWorkflowJournalEvent(opts.coordRoot, runId, event, { stage: currentStage, ...data });
529
+ };
530
+ const journalAgentEnd = (
531
+ data: Record<string, unknown>,
532
+ resultKind: "text" | "json",
533
+ result: unknown,
534
+ ): void => {
535
+ // The writer shrinks an oversized record itself and names what it dropped,
536
+ // so no per-call fallback is needed here. A digest rides along regardless so
537
+ // a shrunk record can still be tied back to the exact result.
538
+ journal("agent.end", {
480
539
  ...data,
540
+ result_kind: resultKind,
541
+ result_digest: digestResult(result, resultKind),
542
+ result,
481
543
  });
482
- appendFileSync(journalPath, `${line}\n`, "utf8");
483
544
  };
484
545
 
485
546
  // Bounded concurrency gate shared by every spawn in the run — direct
@@ -841,6 +902,7 @@ async function executeWorkflow(
841
902
  cwd: executionCwd,
842
903
  runId,
843
904
  subscriptionOnly,
905
+ filesystemPolicy,
844
906
  });
845
907
  agentProof.attempts = attempt;
846
908
  agentProof.duration_ms += last.durationMs;
@@ -857,17 +919,19 @@ async function executeWorkflow(
857
919
  agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
858
920
  agentProof.session_id = last.sessionId;
859
921
  agentProof.result = digestResult(last.text, "text");
860
- journal("agent.end", {
861
- id,
862
- key,
863
- attempts: attempt,
864
- cost_usd: last.costUsd,
865
- total_cost_usd: agentCostUsd,
866
- duration_ms: last.durationMs,
867
- session_id: last.sessionId,
868
- result_kind: "text",
869
- result: last.text,
870
- });
922
+ journalAgentEnd(
923
+ {
924
+ id,
925
+ key,
926
+ attempts: attempt,
927
+ cost_usd: last.costUsd,
928
+ total_cost_usd: agentCostUsd,
929
+ duration_ms: last.durationMs,
930
+ session_id: last.sessionId,
931
+ },
932
+ "text",
933
+ last.text,
934
+ );
871
935
  return last.text;
872
936
  }
873
937
 
@@ -882,17 +946,19 @@ async function executeWorkflow(
882
946
  agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
883
947
  agentProof.session_id = last.sessionId;
884
948
  agentProof.result = digestResult(parsed.value, "json");
885
- journal("agent.end", {
886
- id,
887
- key,
888
- attempts: attempt,
889
- cost_usd: last.costUsd,
890
- total_cost_usd: agentCostUsd,
891
- duration_ms: last.durationMs,
892
- session_id: last.sessionId,
893
- result_kind: "json",
894
- result: parsed.value,
895
- });
949
+ journalAgentEnd(
950
+ {
951
+ id,
952
+ key,
953
+ attempts: attempt,
954
+ cost_usd: last.costUsd,
955
+ total_cost_usd: agentCostUsd,
956
+ duration_ms: last.durationMs,
957
+ session_id: last.sessionId,
958
+ },
959
+ "json",
960
+ parsed.value,
961
+ );
896
962
  return parsed.value;
897
963
  }
898
964
 
@@ -1056,6 +1122,7 @@ async function executeWorkflow(
1056
1122
  evidence: evidenceRecords,
1057
1123
  harnessEvidence: opts.harnessEvidence,
1058
1124
  harnessAttestations: opts.harnessAttestations,
1125
+ sandboxProjection: sandboxProjectionEvidence(filesystemPolicy, gitWrite),
1059
1126
  policy: policy
1060
1127
  ? {
1061
1128
  config: policy,
@@ -1136,6 +1203,7 @@ async function executeWorkflow(
1136
1203
  evidence: evidenceRecords,
1137
1204
  harnessEvidence: opts.harnessEvidence,
1138
1205
  harnessAttestations: opts.harnessAttestations,
1206
+ sandboxProjection: sandboxProjectionEvidence(filesystemPolicy, gitWrite),
1139
1207
  policy: policy
1140
1208
  ? {
1141
1209
  config: policy,
@@ -1238,7 +1306,12 @@ function loadResumeCache(
1238
1306
  result_kind?: "json" | "text";
1239
1307
  result?: unknown;
1240
1308
  };
1241
- if (e.event === "agent.end" && e.key && e.result_kind !== undefined) {
1309
+ if (
1310
+ e.event === "agent.end" &&
1311
+ e.key &&
1312
+ (e.result_kind === "json" || e.result_kind === "text") &&
1313
+ "result" in e
1314
+ ) {
1242
1315
  cache.set(e.key, { kind: e.result_kind, value: e.result });
1243
1316
  }
1244
1317
  } catch {