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.
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/work.d.ts +2 -0
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +74 -5
- package/dist/core/harnesses/types.d.ts +5 -0
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/work/index.d.ts +1 -1
- package/dist/core/work/index.d.ts.map +1 -1
- package/dist/core/work/index.js +1 -1
- package/dist/core/work/runner.d.ts.map +1 -1
- package/dist/core/work/runner.js +8 -1
- package/dist/core/work/state.d.ts +24 -1
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +108 -14
- package/dist/core/workflow/approvals.d.ts.map +1 -1
- package/dist/core/workflow/approvals.js +3 -6
- package/dist/core/workflow/attempt-context.d.ts.map +1 -1
- package/dist/core/workflow/attempt-context.js +38 -1
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +20 -19
- package/dist/core/workflow/journal.d.ts +30 -0
- package/dist/core/workflow/journal.d.ts.map +1 -0
- package/dist/core/workflow/journal.js +70 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +8 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +8 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +8 -0
- package/dist/core/workflow/types.d.ts +11 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +101 -20
- package/dist/core/workflow/workspaces/state.d.ts +1 -1
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +9 -30
- package/dist/lib/browser/client.d.ts +50 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +103 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +6 -1
- package/package.json +1 -1
- package/src/commands/browse.ts +33 -1
- package/src/commands/work.ts +109 -14
- package/src/core/harnesses/types.ts +5 -0
- package/src/core/work/index.ts +1 -0
- package/src/core/work/runner.ts +8 -0
- package/src/core/work/state.ts +177 -27
- package/src/core/workflow/approvals.ts +3 -6
- package/src/core/workflow/attempt-context.ts +47 -1
- package/src/core/workflow/engine.ts +48 -33
- package/src/core/workflow/journal.ts +90 -0
- package/src/core/workflow/spawn-claude.ts +8 -0
- package/src/core/workflow/spawn-codex.ts +8 -0
- package/src/core/workflow/spawn-cursor.ts +8 -0
- package/src/core/workflow/types.ts +12 -0
- package/src/core/workflow/workspaces/local-git.ts +112 -22
- package/src/core/workflow/workspaces/state.ts +13 -33
- package/src/lib/browser/client.ts +129 -0
- package/src/lib/exec.ts +10 -1
package/src/core/work/runner.ts
CHANGED
|
@@ -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, {
|
package/src/core/work/state.ts
CHANGED
|
@@ -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: {
|
|
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 =
|
|
552
|
+
const journalPath = workflowJournalPath(coordRoot, runId);
|
|
498
553
|
if (existsSync(journalPath)) {
|
|
499
554
|
let parked: string | undefined;
|
|
500
555
|
let resumed = false;
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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: {
|
|
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(
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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(
|
|
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) >
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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 (
|
|
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 {
|