harnery 0.27.0 → 0.29.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 (62) hide show
  1. package/dist/commands/browse.d.ts.map +1 -1
  2. package/dist/commands/browse.js +27 -1
  3. package/dist/commands/work.d.ts +2 -0
  4. package/dist/commands/work.d.ts.map +1 -1
  5. package/dist/commands/work.js +74 -5
  6. package/dist/core/harnesses/types.d.ts +5 -0
  7. package/dist/core/harnesses/types.d.ts.map +1 -1
  8. package/dist/core/work/index.d.ts +1 -1
  9. package/dist/core/work/index.d.ts.map +1 -1
  10. package/dist/core/work/index.js +1 -1
  11. package/dist/core/work/runner.d.ts.map +1 -1
  12. package/dist/core/work/runner.js +8 -1
  13. package/dist/core/work/state.d.ts +24 -1
  14. package/dist/core/work/state.d.ts.map +1 -1
  15. package/dist/core/work/state.js +108 -14
  16. package/dist/core/workflow/approvals.d.ts.map +1 -1
  17. package/dist/core/workflow/approvals.js +3 -6
  18. package/dist/core/workflow/attempt-context.d.ts.map +1 -1
  19. package/dist/core/workflow/attempt-context.js +38 -1
  20. package/dist/core/workflow/engine.d.ts.map +1 -1
  21. package/dist/core/workflow/engine.js +20 -19
  22. package/dist/core/workflow/journal.d.ts +30 -0
  23. package/dist/core/workflow/journal.d.ts.map +1 -0
  24. package/dist/core/workflow/journal.js +70 -0
  25. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  26. package/dist/core/workflow/spawn-claude.js +8 -0
  27. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  28. package/dist/core/workflow/spawn-codex.js +8 -0
  29. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  30. package/dist/core/workflow/spawn-cursor.js +8 -0
  31. package/dist/core/workflow/types.d.ts +11 -0
  32. package/dist/core/workflow/types.d.ts.map +1 -1
  33. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  34. package/dist/core/workflow/workspaces/local-git.js +101 -20
  35. package/dist/core/workflow/workspaces/state.d.ts +1 -1
  36. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  37. package/dist/core/workflow/workspaces/state.js +9 -30
  38. package/dist/lib/browser/client.d.ts +50 -0
  39. package/dist/lib/browser/client.d.ts.map +1 -1
  40. package/dist/lib/browser/client.js +103 -0
  41. package/dist/lib/exec.d.ts +4 -0
  42. package/dist/lib/exec.d.ts.map +1 -1
  43. package/dist/lib/exec.js +6 -1
  44. package/package.json +1 -1
  45. package/src/commands/browse.ts +33 -1
  46. package/src/commands/work.ts +109 -14
  47. package/src/core/harnesses/types.ts +5 -0
  48. package/src/core/work/index.ts +1 -0
  49. package/src/core/work/runner.ts +8 -0
  50. package/src/core/work/state.ts +177 -27
  51. package/src/core/workflow/approvals.ts +3 -6
  52. package/src/core/workflow/attempt-context.ts +47 -1
  53. package/src/core/workflow/engine.ts +48 -33
  54. package/src/core/workflow/journal.ts +90 -0
  55. package/src/core/workflow/spawn-claude.ts +8 -0
  56. package/src/core/workflow/spawn-codex.ts +8 -0
  57. package/src/core/workflow/spawn-cursor.ts +8 -0
  58. package/src/core/workflow/types.ts +12 -0
  59. package/src/core/workflow/workspaces/local-git.ts +112 -22
  60. package/src/core/workflow/workspaces/state.ts +13 -33
  61. package/src/lib/browser/client.ts +129 -0
  62. package/src/lib/exec.ts +10 -1
@@ -18,6 +18,7 @@ import {
18
18
  assertWorkflowUnchanged,
19
19
  boundedActor,
20
20
  newWorkflowRunId,
21
+ openOperatorFindings,
21
22
  readWorkItemIgnoringLease,
22
23
  type WorkAttempt,
23
24
  } from "./state.ts";
@@ -81,11 +82,18 @@ export async function runWorkItem(input: RunWorkItemInput): Promise<RunReport> {
81
82
  }
82
83
  prior = priorContext(coordRoot, latest);
83
84
  }
85
+ const open = openOperatorFindings(coordRoot, input.workId);
86
+ const findings = open.findings.map((finding) => ({
87
+ id: finding.id,
88
+ actor: open.actor,
89
+ statement: finding.statement,
90
+ }));
84
91
  const attemptContext: WorkflowAttemptContext = {
85
92
  schema_version: WORKFLOW_ATTEMPT_CONTEXT_SCHEMA_VERSION,
86
93
  number: attempt,
87
94
  trigger,
88
95
  ...(prior ? { prior } : {}),
96
+ ...(findings.length === 0 ? {} : { findings }),
89
97
  };
90
98
  const runId = newWorkflowRunId();
91
99
  appendWorkEvent(coordRoot, input.workId, {
@@ -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,
@@ -73,7 +74,6 @@ import type {
73
74
  WorkflowSandboxProjectionEvidence,
74
75
  WorkflowWorkContext,
75
76
  } from "./types.ts";
76
- import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
77
77
  import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
78
78
  import { freezeWorkflowWorkContext } from "./work-context.ts";
79
79
  import {
@@ -81,7 +81,6 @@ import {
81
81
  attestWorkspaceFailure,
82
82
  resolveWorkspaceBinding,
83
83
  } from "./workspaces/execution.ts";
84
- import { appendWorkflowJournalEvent } from "./workspaces/state.ts";
85
84
  import type {
86
85
  WorkspaceAttestation,
87
86
  WorkspaceBinding,
@@ -526,15 +525,22 @@ async function executeWorkflow(
526
525
  const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
527
526
 
528
527
  const journal = (event: string, data: Record<string, unknown>): void => {
529
- const line = JSON.stringify({
530
- schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
531
- run_id: runId,
532
- ts: new Date().toISOString(),
533
- event,
534
- 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", {
535
539
  ...data,
540
+ result_kind: resultKind,
541
+ result_digest: digestResult(result, resultKind),
542
+ result,
536
543
  });
537
- appendFileSync(journalPath, `${line}\n`, "utf8");
538
544
  };
539
545
 
540
546
  // Bounded concurrency gate shared by every spawn in the run — direct
@@ -913,17 +919,19 @@ async function executeWorkflow(
913
919
  agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
914
920
  agentProof.session_id = last.sessionId;
915
921
  agentProof.result = digestResult(last.text, "text");
916
- journal("agent.end", {
917
- id,
918
- key,
919
- attempts: attempt,
920
- cost_usd: last.costUsd,
921
- total_cost_usd: agentCostUsd,
922
- duration_ms: last.durationMs,
923
- session_id: last.sessionId,
924
- result_kind: "text",
925
- result: last.text,
926
- });
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
+ );
927
935
  return last.text;
928
936
  }
929
937
 
@@ -938,17 +946,19 @@ async function executeWorkflow(
938
946
  agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
939
947
  agentProof.session_id = last.sessionId;
940
948
  agentProof.result = digestResult(parsed.value, "json");
941
- journal("agent.end", {
942
- id,
943
- key,
944
- attempts: attempt,
945
- cost_usd: last.costUsd,
946
- total_cost_usd: agentCostUsd,
947
- duration_ms: last.durationMs,
948
- session_id: last.sessionId,
949
- result_kind: "json",
950
- result: parsed.value,
951
- });
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
+ );
952
962
  return parsed.value;
953
963
  }
954
964
 
@@ -1296,7 +1306,12 @@ function loadResumeCache(
1296
1306
  result_kind?: "json" | "text";
1297
1307
  result?: unknown;
1298
1308
  };
1299
- 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
+ ) {
1300
1315
  cache.set(e.key, { kind: e.result_kind, value: e.result });
1301
1316
  }
1302
1317
  } catch {