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.
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/harness.d.ts.map +1 -1
- package/dist/commands/harness.js +2 -0
- package/dist/commands/work.d.ts +2 -0
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +47 -4
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +64 -0
- package/dist/core/harnesses/attest-projection.d.ts +44 -0
- package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
- package/dist/core/harnesses/attest-projection.js +113 -0
- package/dist/core/harnesses/attest.d.ts +9 -0
- package/dist/core/harnesses/attest.d.ts.map +1 -1
- package/dist/core/harnesses/attest.js +14 -1
- package/dist/core/harnesses/attestation.d.ts +1 -1
- package/dist/core/harnesses/attestation.d.ts.map +1 -1
- package/dist/core/harnesses/attestation.js +7 -1
- package/dist/core/harnesses/bench.d.ts.map +1 -1
- package/dist/core/harnesses/bench.js +13 -0
- package/dist/core/harnesses/profiles.d.ts +7 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -1
- package/dist/core/harnesses/profiles.js +9 -0
- package/dist/core/harnesses/types.d.ts +19 -1
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/harnesses/types.js +1 -0
- 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 +61 -19
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -1
- 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/proof.d.ts +3 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +1 -0
- package/dist/core/workflow/sandbox-projection.d.ts +57 -0
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
- package/dist/core/workflow/sandbox-projection.js +95 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +14 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +18 -2
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +14 -1
- package/dist/core/workflow/types.d.ts +62 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.d.ts +2 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.js +1 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/inspect.js +14 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/reclaim.js +88 -0
- 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/dist/lib/tunnel/error-page.d.ts +15 -0
- package/dist/lib/tunnel/error-page.d.ts.map +1 -0
- package/dist/lib/tunnel/error-page.js +98 -0
- package/dist/lib/tunnel/gate.d.ts +1 -12
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +59 -4
- package/package.json +1 -1
- package/src/commands/browse.ts +33 -1
- package/src/commands/harness.ts +6 -0
- package/src/commands/work.ts +80 -13
- package/src/commands/workflow.ts +74 -0
- package/src/core/harnesses/attest-projection.ts +151 -0
- package/src/core/harnesses/attest.ts +23 -1
- package/src/core/harnesses/attestation.ts +7 -1
- package/src/core/harnesses/bench.ts +16 -0
- package/src/core/harnesses/profiles.ts +11 -0
- package/src/core/harnesses/types.ts +20 -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 +106 -33
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/journal.ts +90 -0
- package/src/core/workflow/proof.ts +4 -0
- package/src/core/workflow/sandbox-projection.ts +150 -0
- package/src/core/workflow/spawn-claude.ts +18 -1
- package/src/core/workflow/spawn-codex.ts +25 -2
- package/src/core/workflow/spawn-cursor.ts +18 -1
- package/src/core/workflow/types.ts +66 -0
- package/src/core/workflow/workspaces/index.ts +2 -0
- package/src/core/workflow/workspaces/inspect.ts +15 -0
- package/src/core/workflow/workspaces/reclaim.ts +109 -0
- 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/lib/tunnel/error-page.ts +114 -0
- package/src/lib/tunnel/gate.ts +66 -3
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,
|
|
@@ -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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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 (
|
|
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 {
|