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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fsyncParentDirectory, stableDigest } from "./durable-record.ts";
|
|
4
|
+
|
|
5
|
+
// Limit for the JSON record body. The trailing newline delimiter is outside the
|
|
6
|
+
// record and is not counted by readers after splitting journal lines.
|
|
7
|
+
export const WORKFLOW_JOURNAL_EVENT_BYTES = 16 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Key under which a shrunk record names the fields it had to drop. */
|
|
10
|
+
export const WORKFLOW_JOURNAL_OMITTED = "omitted_fields";
|
|
11
|
+
|
|
12
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
13
|
+
|
|
14
|
+
export interface WorkflowJournalOmission {
|
|
15
|
+
field: string;
|
|
16
|
+
bytes: number;
|
|
17
|
+
sha256: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function workflowJournalPath(coordRoot: string, runId: string): string {
|
|
21
|
+
if (!RUN_ID.test(runId)) throw new Error(`invalid workflow run id ${JSON.stringify(runId)}`);
|
|
22
|
+
return join(resolve(coordRoot), ".harnery", "workflows", runId, "journal.jsonl");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Append one journal record, always.
|
|
27
|
+
*
|
|
28
|
+
* A record that would exceed what a reader accepts has its largest fields
|
|
29
|
+
* replaced by a digest and a byte count until it fits, and it names what it
|
|
30
|
+
* dropped. Size never raises.
|
|
31
|
+
*
|
|
32
|
+
* Refusing an oversized record would let a valid run fail on its own opening
|
|
33
|
+
* line: `run.start` carries the workflow's declared metadata plus the frozen
|
|
34
|
+
* work and attempt context, and Harnery's own validators permit those to exceed
|
|
35
|
+
* this limit by construction. Losing detail from a record is recoverable.
|
|
36
|
+
* Losing the run that was writing it is not.
|
|
37
|
+
*/
|
|
38
|
+
export function appendWorkflowJournalEvent(
|
|
39
|
+
coordRoot: string,
|
|
40
|
+
runId: string,
|
|
41
|
+
event: string,
|
|
42
|
+
data: Record<string, unknown>,
|
|
43
|
+
): void {
|
|
44
|
+
const path = workflowJournalPath(coordRoot, runId);
|
|
45
|
+
const record = fitWorkflowJournalRecord(
|
|
46
|
+
{ schema_version: 1, run_id: runId, ts: new Date().toISOString(), event },
|
|
47
|
+
data,
|
|
48
|
+
);
|
|
49
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
51
|
+
const existed = existsSync(path);
|
|
52
|
+
const fd = openSync(path, "a", 0o600);
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(fd, line, "utf8");
|
|
55
|
+
fsyncSync(fd);
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
if (!existed) fsyncParentDirectory(path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shrink a record to the reader's limit by dropping its largest fields first.
|
|
64
|
+
* The envelope is never dropped, so a record keeps its run id, timestamp, and
|
|
65
|
+
* event name however much detail it loses. Exported for tests.
|
|
66
|
+
*/
|
|
67
|
+
export function fitWorkflowJournalRecord(
|
|
68
|
+
envelope: Record<string, unknown>,
|
|
69
|
+
data: Record<string, unknown>,
|
|
70
|
+
): Record<string, unknown> {
|
|
71
|
+
const candidate = { ...envelope, ...data };
|
|
72
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) <= WORKFLOW_JOURNAL_EVENT_BYTES) {
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
const kept: Record<string, unknown> = { ...data };
|
|
76
|
+
const omitted: WorkflowJournalOmission[] = [];
|
|
77
|
+
const bySize = Object.keys(data)
|
|
78
|
+
.map((field) => ({ field, bytes: Buffer.byteLength(JSON.stringify(data[field]) ?? "") }))
|
|
79
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
80
|
+
|
|
81
|
+
for (const { field, bytes } of bySize) {
|
|
82
|
+
omitted.push({ field, bytes, sha256: stableDigest(data[field]) });
|
|
83
|
+
delete kept[field];
|
|
84
|
+
const next = { ...envelope, ...kept, [WORKFLOW_JOURNAL_OMITTED]: omitted };
|
|
85
|
+
if (Buffer.byteLength(JSON.stringify(next)) <= WORKFLOW_JOURNAL_EVENT_BYTES) return next;
|
|
86
|
+
}
|
|
87
|
+
// Every field dropped and still over: the omission list is itself the excess.
|
|
88
|
+
// Keep the envelope and a count so the record stays parseable and honest.
|
|
89
|
+
return { ...envelope, [WORKFLOW_JOURNAL_OMITTED]: omitted.length };
|
|
90
|
+
}
|
|
@@ -62,6 +62,14 @@ export function buildClaudeInvocation(req: SpawnRequest): HarnessInvocation {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function normalizeClaudeResult(raw: HarnessRawResult): SpawnResult {
|
|
65
|
+
if (raw.timedOut) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
text: "",
|
|
69
|
+
durationMs: raw.durationMs,
|
|
70
|
+
error: `claude timed out after ${raw.durationMs}ms and was killed`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
65
73
|
if (raw.exitCode === 127) {
|
|
66
74
|
return {
|
|
67
75
|
ok: false,
|
|
@@ -61,6 +61,14 @@ export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): Ha
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function normalizeCodexResult(raw: HarnessRawResult): SpawnResult {
|
|
64
|
+
if (raw.timedOut) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
text: "",
|
|
68
|
+
durationMs: raw.durationMs,
|
|
69
|
+
error: `codex timed out after ${raw.durationMs}ms and was killed`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
64
72
|
if (raw.exitCode === 127) {
|
|
65
73
|
return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("codex") };
|
|
66
74
|
}
|
|
@@ -74,6 +74,14 @@ export function buildCursorInvocation(req: SpawnRequest): HarnessInvocation {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export function normalizeCursorResult(raw: HarnessRawResult): SpawnResult {
|
|
77
|
+
if (raw.timedOut) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
text: "",
|
|
81
|
+
durationMs: raw.durationMs,
|
|
82
|
+
error: `cursor timed out after ${raw.durationMs}ms and was killed`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
77
85
|
if (raw.exitCode === 127) {
|
|
78
86
|
return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("cursor") };
|
|
79
87
|
}
|
|
@@ -427,6 +427,18 @@ export interface WorkflowAttemptContext {
|
|
|
427
427
|
readonly number: number;
|
|
428
428
|
readonly trigger: "initial" | "retry";
|
|
429
429
|
readonly prior?: Readonly<WorkflowAttemptPriorContext>;
|
|
430
|
+
/** Open findings an operator raised against a prior attempt. Present on any
|
|
431
|
+
* trigger, because a reopen with findings starts a fresh initial attempt.
|
|
432
|
+
* Distinct from `prior`, which is evidence the run itself produced. */
|
|
433
|
+
readonly findings?: readonly Readonly<WorkflowOperatorFinding>[];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** One correction an operator raised against work they judged wrong. Authored
|
|
437
|
+
* by a human, not derived from proof, and carried until explicitly disposed. */
|
|
438
|
+
export interface WorkflowOperatorFinding {
|
|
439
|
+
readonly id: string;
|
|
440
|
+
readonly actor: string;
|
|
441
|
+
readonly statement: string;
|
|
430
442
|
}
|
|
431
443
|
|
|
432
444
|
export interface EngineOpts {
|
|
@@ -123,16 +123,26 @@ export async function probe(
|
|
|
123
123
|
if (input.writable_roots.length === 0) {
|
|
124
124
|
unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// Allocation always selects writable_roots[0] (validateRequest pins the selected root
|
|
127
|
+
// to it), so probe's authority-coverage verdict must be judged against that same root.
|
|
128
|
+
let selectedRoot: ValidatedRoot | undefined;
|
|
129
|
+
input.writable_roots.forEach((root, index) => {
|
|
127
130
|
try {
|
|
128
|
-
validateConfiguredRoot(root);
|
|
131
|
+
const validated = validateConfiguredRoot(root);
|
|
132
|
+
if (index === 0) selectedRoot = validated;
|
|
129
133
|
} catch (error) {
|
|
130
134
|
unsupported.push(reason("writable_root_invalid", (error as Error).message));
|
|
131
135
|
}
|
|
132
|
-
}
|
|
136
|
+
});
|
|
133
137
|
if (version.ok) {
|
|
134
138
|
try {
|
|
135
|
-
inspectSourceRepository(input.requested_cwd);
|
|
139
|
+
const repo = inspectSourceRepository(input.requested_cwd);
|
|
140
|
+
if (selectedRoot) {
|
|
141
|
+
const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
|
|
142
|
+
if (outside) {
|
|
143
|
+
unsupported.push(reason("repository_authority_outside_writable_root", outside));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
136
146
|
} catch (error) {
|
|
137
147
|
unsupported.push(reason("repository_unsupported", (error as Error).message));
|
|
138
148
|
}
|
|
@@ -209,12 +219,8 @@ async function allocate(
|
|
|
209
219
|
repo.commonDir.realpath,
|
|
210
220
|
"Git common directory",
|
|
211
221
|
);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
!containsPath(root.realpath, repo.commonDir.realpath)
|
|
215
|
-
) {
|
|
216
|
-
throw new Error("source repository and Git common dir must be inside the writable root");
|
|
217
|
-
}
|
|
222
|
+
const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
|
|
223
|
+
if (authorityOutside) throw new Error(authorityOutside);
|
|
218
224
|
const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
|
|
219
225
|
const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
|
|
220
226
|
if (!containsPath(workspaceRoot, activeRoot)) {
|
|
@@ -1192,6 +1198,25 @@ async function applyIntegrationUnderLease(
|
|
|
1192
1198
|
}
|
|
1193
1199
|
revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
|
|
1194
1200
|
inspectIntegrationTarget(current.target_root, plan.binding);
|
|
1201
|
+
// The fast-forward moves the target branch ref, which for a submodule or linked
|
|
1202
|
+
// worktree lives in the common directory outside the checkout tree. allowed_paths is
|
|
1203
|
+
// the real write authority (allocate and cleanup already gate the common directory on
|
|
1204
|
+
// it), so re-authorize both the target checkout and its common directory here rather
|
|
1205
|
+
// than trusting containment alone.
|
|
1206
|
+
const applyCommonDir = plan.binding.repository?.common_dir.realpath;
|
|
1207
|
+
if (!applyCommonDir) {
|
|
1208
|
+
throw new Error("integration binding is missing Git common-dir authority");
|
|
1209
|
+
}
|
|
1210
|
+
assertAllowedPathAuthority(
|
|
1211
|
+
claim.request.allowed_paths,
|
|
1212
|
+
current.target_root,
|
|
1213
|
+
"integration target path",
|
|
1214
|
+
);
|
|
1215
|
+
assertAllowedPathAuthority(
|
|
1216
|
+
claim.request.allowed_paths,
|
|
1217
|
+
applyCommonDir,
|
|
1218
|
+
"integration Git common directory",
|
|
1219
|
+
);
|
|
1195
1220
|
git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
|
|
1196
1221
|
const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
|
|
1197
1222
|
const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
|
|
@@ -1287,20 +1312,17 @@ function inspectSourceRepository(cwd: string): {
|
|
|
1287
1312
|
if (!containsPath(sourceRoot, requestedCwd.realpath)) {
|
|
1288
1313
|
throw new Error("requested working directory is outside the resolved Git worktree");
|
|
1289
1314
|
}
|
|
1290
|
-
|
|
1291
|
-
throw new Error("symlink Git directories are unsupported");
|
|
1292
|
-
}
|
|
1293
|
-
if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
|
|
1294
|
-
throw new Error("linked worktree and submodule source repositories are unsupported");
|
|
1295
|
-
}
|
|
1296
|
-
const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
|
|
1315
|
+
const pointedGitDir = resolveGitDirPointer(sourceRoot);
|
|
1297
1316
|
const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
|
|
1298
1317
|
const commonDir = realpathSync(
|
|
1299
1318
|
resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])),
|
|
1300
1319
|
);
|
|
1301
|
-
if (
|
|
1320
|
+
if (pointedGitDir !== gitDir) {
|
|
1302
1321
|
throw new Error("resolved Git authority does not match the requested checkout");
|
|
1303
1322
|
}
|
|
1323
|
+
if (!containsPath(commonDir, gitDir)) {
|
|
1324
|
+
throw new Error("resolved Git directory is not inside its common directory");
|
|
1325
|
+
}
|
|
1304
1326
|
const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
1305
1327
|
if (!branch.ok || !branch.out) throw new Error("detached integration targets are unsupported");
|
|
1306
1328
|
const targetRef = `refs/heads/${branch.out}`;
|
|
@@ -1321,6 +1343,47 @@ function inspectSourceRepository(cwd: string): {
|
|
|
1321
1343
|
};
|
|
1322
1344
|
}
|
|
1323
1345
|
|
|
1346
|
+
// Resolve the `.git` entry at a checkout root to the real Git directory it names,
|
|
1347
|
+
// without trusting the ambient environment. `.git` is a real directory in a plain
|
|
1348
|
+
// checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
|
|
1349
|
+
// worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
|
|
1350
|
+
// device and inode, and a symlink lets the target move under a pinned pointer.
|
|
1351
|
+
//
|
|
1352
|
+
// The caller cross-checks the result against `rev-parse --git-dir`. That is a
|
|
1353
|
+
// consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
|
|
1354
|
+
// pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
|
|
1355
|
+
// It cannot be otherwise, because a doctored pointer and a submodule's pointer are
|
|
1356
|
+
// the same construct. What the equality buys is that this resolution and Git's own
|
|
1357
|
+
// never silently diverge, which is defence in depth over the `GIT_*` stripping in
|
|
1358
|
+
// `isolatedGitEnvironment`. The real authority check is containment plus
|
|
1359
|
+
// `allowed_paths` on the resolved common directory.
|
|
1360
|
+
function resolveGitDirPointer(sourceRoot: string): string {
|
|
1361
|
+
const dotGit = join(sourceRoot, ".git");
|
|
1362
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
1363
|
+
try {
|
|
1364
|
+
stats = lstatSync(dotGit);
|
|
1365
|
+
} catch {
|
|
1366
|
+
throw new Error("requested checkout has no .git entry");
|
|
1367
|
+
}
|
|
1368
|
+
if (stats.isSymbolicLink()) {
|
|
1369
|
+
throw new Error("symlink Git directories are unsupported");
|
|
1370
|
+
}
|
|
1371
|
+
if (stats.isDirectory()) {
|
|
1372
|
+
return realpathSync(dotGit);
|
|
1373
|
+
}
|
|
1374
|
+
if (!stats.isFile()) {
|
|
1375
|
+
throw new Error(".git must be a directory or a gitdir pointer file");
|
|
1376
|
+
}
|
|
1377
|
+
const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1378
|
+
const prefix = "gitdir:";
|
|
1379
|
+
if (!pointer.startsWith(prefix)) {
|
|
1380
|
+
throw new Error(".git file is not a gitdir pointer");
|
|
1381
|
+
}
|
|
1382
|
+
const target = pointer.slice(prefix.length).trim();
|
|
1383
|
+
if (!target) throw new Error(".git gitdir pointer is empty");
|
|
1384
|
+
return realpathSync(resolve(sourceRoot, target));
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1324
1387
|
function inspectFrozenSourceAuthority(
|
|
1325
1388
|
sourceRoot: GitRepositoryBinding["source_root"],
|
|
1326
1389
|
commonDir: GitRepositoryBinding["common_dir"],
|
|
@@ -1680,6 +1743,28 @@ function validFrozenFilesystemPath(
|
|
|
1680
1743
|
);
|
|
1681
1744
|
}
|
|
1682
1745
|
|
|
1746
|
+
// Explain, in the caller's terms, why a checkout's Git authority falls outside a
|
|
1747
|
+
// writable root — naming the offending path so a submodule or linked-worktree user
|
|
1748
|
+
// who declared only the inner checkout knows exactly what to widen. Returns undefined
|
|
1749
|
+
// when both the source checkout and its common directory are inside the root.
|
|
1750
|
+
function describeAuthorityOutsideRoot(
|
|
1751
|
+
repo: ReturnType<typeof inspectSourceRepository>,
|
|
1752
|
+
rootRealpath: string,
|
|
1753
|
+
): string | undefined {
|
|
1754
|
+
if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
|
|
1755
|
+
return (
|
|
1756
|
+
`Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
|
|
1757
|
+
`which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
|
|
1758
|
+
`checkouts keep their Git authority in the enclosing repository, and allocating a ` +
|
|
1759
|
+
`worktree writes there; declare that repository (or a parent of it) as the writable root.`
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
|
|
1763
|
+
return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
|
|
1764
|
+
}
|
|
1765
|
+
return undefined;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1683
1768
|
function assertAllowedPathAuthority(
|
|
1684
1769
|
allowedPaths: WorkspaceAllocationRequest["allowed_paths"],
|
|
1685
1770
|
candidate: string,
|
|
@@ -1783,10 +1868,15 @@ function capabilityDigestForClaim(claim: WorkspaceClaim): string {
|
|
|
1783
1868
|
function acquireRepositoryLease(coordRoot: string, claim: WorkspaceClaim): () => void {
|
|
1784
1869
|
const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
|
|
1785
1870
|
mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1871
|
+
// Mutual exclusion keys on the Git common directory alone. `git worktree add`,
|
|
1872
|
+
// `prune`, and the shared-`config` migration all write to the common directory's
|
|
1873
|
+
// administrative area, and once worktrees and submodules are allowed, several
|
|
1874
|
+
// distinct source checkouts (a superproject and its linked worktrees; a submodule
|
|
1875
|
+
// and a worktree of it) share one common directory under different — possibly
|
|
1876
|
+
// nested — writable roots. Including the writable root in the key would hand those
|
|
1877
|
+
// agents different locks and let their admin-area writes race. The writable root
|
|
1878
|
+
// stays in the lease metadata for diagnostics, not in the exclusion key.
|
|
1879
|
+
const key = stableDigest({ common: claim.repository.common_dir.identity });
|
|
1790
1880
|
return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
|
|
1791
1881
|
}
|
|
1792
1882
|
|
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
stableDigest,
|
|
18
18
|
writeImmutableJson,
|
|
19
19
|
} from "../durable-record.ts";
|
|
20
|
+
|
|
21
|
+
export { appendWorkflowJournalEvent } from "../journal.ts";
|
|
22
|
+
|
|
20
23
|
import type {
|
|
21
24
|
GitRepositoryBinding,
|
|
22
25
|
IntegrationApplyAttempt,
|
|
@@ -34,7 +37,7 @@ import type {
|
|
|
34
37
|
} from "./types.ts";
|
|
35
38
|
|
|
36
39
|
const JSON_LIMIT = 512 * 1024;
|
|
37
|
-
const
|
|
40
|
+
const WORKSPACE_EVENT_BYTES = 32 * 1024;
|
|
38
41
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
39
42
|
|
|
40
43
|
export interface WorkspaceClaim {
|
|
@@ -181,7 +184,11 @@ export function appendWorkspaceEvent(
|
|
|
181
184
|
event: string,
|
|
182
185
|
data: Record<string, unknown> = {},
|
|
183
186
|
): WorkspaceProviderEvent {
|
|
184
|
-
if (
|
|
187
|
+
if (
|
|
188
|
+
!event ||
|
|
189
|
+
event.length > 100 ||
|
|
190
|
+
Buffer.byteLength(JSON.stringify(data)) > WORKSPACE_EVENT_BYTES / 2
|
|
191
|
+
) {
|
|
185
192
|
throw new Error("workspace provider event is invalid or too large");
|
|
186
193
|
}
|
|
187
194
|
const path = workspaceEventsPath(coordRoot, claim.provider_id, claim.binding_id);
|
|
@@ -204,7 +211,7 @@ export function appendWorkspaceEvent(
|
|
|
204
211
|
record_sha256: stableDigest(basis),
|
|
205
212
|
};
|
|
206
213
|
const line = `${canonicalJson(record)}\n`;
|
|
207
|
-
if (Buffer.byteLength(line) >
|
|
214
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES)
|
|
208
215
|
throw new Error("workspace provider event is too large");
|
|
209
216
|
const fd = openSync(path, "a", 0o600);
|
|
210
217
|
try {
|
|
@@ -232,7 +239,7 @@ export function readWorkspaceEvents(
|
|
|
232
239
|
.split("\n")
|
|
233
240
|
.filter(Boolean)
|
|
234
241
|
.map((line, index) => {
|
|
235
|
-
if (Buffer.byteLength(line) >
|
|
242
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
236
243
|
throw new Error(`workspace event ${index + 1} is too large`);
|
|
237
244
|
}
|
|
238
245
|
let record: WorkspaceProviderEvent;
|
|
@@ -270,33 +277,6 @@ function recordsDigestPlaceholder(): null {
|
|
|
270
277
|
return null;
|
|
271
278
|
}
|
|
272
279
|
|
|
273
|
-
export function appendWorkflowJournalEvent(
|
|
274
|
-
coordRoot: string,
|
|
275
|
-
runId: string,
|
|
276
|
-
event: string,
|
|
277
|
-
data: Record<string, unknown>,
|
|
278
|
-
): void {
|
|
279
|
-
const path = join(workflowRunDir(coordRoot, runId), "journal.jsonl");
|
|
280
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
281
|
-
const existed = existsSync(path);
|
|
282
|
-
const line = `${JSON.stringify({
|
|
283
|
-
schema_version: 1,
|
|
284
|
-
run_id: runId,
|
|
285
|
-
ts: new Date().toISOString(),
|
|
286
|
-
event,
|
|
287
|
-
...data,
|
|
288
|
-
})}\n`;
|
|
289
|
-
if (Buffer.byteLength(line) > EVENT_LIMIT) throw new Error("workflow journal event is too large");
|
|
290
|
-
const fd = openSync(path, "a", 0o600);
|
|
291
|
-
try {
|
|
292
|
-
writeFileSync(fd, line, "utf8");
|
|
293
|
-
fsyncSync(fd);
|
|
294
|
-
} finally {
|
|
295
|
-
closeSync(fd);
|
|
296
|
-
}
|
|
297
|
-
if (!existed) fsyncParentDirectory(path);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
280
|
export type WorkflowSupplement =
|
|
301
281
|
| WorkspaceBinding
|
|
302
282
|
| IntegrationReviewRecord
|
|
@@ -327,7 +307,7 @@ export function appendCleanupAttempt(
|
|
|
327
307
|
record_sha256: stableDigest(basis),
|
|
328
308
|
};
|
|
329
309
|
const line = `${canonicalJson(attempt)}\n`;
|
|
330
|
-
if (Buffer.byteLength(line) >
|
|
310
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
331
311
|
throw new Error("workspace cleanup attempt is too large");
|
|
332
312
|
}
|
|
333
313
|
const fd = openSync(path, "a", 0o600);
|
|
@@ -388,7 +368,7 @@ function appendChainedRecord(
|
|
|
388
368
|
label: string,
|
|
389
369
|
): void {
|
|
390
370
|
const line = `${canonicalJson(record)}\n`;
|
|
391
|
-
if (Buffer.byteLength(line) >
|
|
371
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
392
372
|
throw new Error(`${label} is too large`);
|
|
393
373
|
}
|
|
394
374
|
const fd = openSync(path, "a", 0o600);
|
|
@@ -295,6 +295,135 @@ export class Browser {
|
|
|
295
295
|
return buf.length;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Capture a full-page screenshot from an explicit capture viewport and
|
|
300
|
+
* evaluate the caller's final evidence expression immediately before the
|
|
301
|
+
* pixels are written. Playwright normally manages the full-page viewport
|
|
302
|
+
* internally, which leaves callers unable to inspect fixed/sticky geometry
|
|
303
|
+
* in the state the PNG actually renders.
|
|
304
|
+
*/
|
|
305
|
+
async screenshotWithEvaluation<T = unknown>(
|
|
306
|
+
path: string,
|
|
307
|
+
evaluation: string,
|
|
308
|
+
opts: { fullPage?: boolean } = {},
|
|
309
|
+
): Promise<{
|
|
310
|
+
bytes: number;
|
|
311
|
+
evaluation: T;
|
|
312
|
+
viewport: { width: number; height: number };
|
|
313
|
+
evidence: {
|
|
314
|
+
converged: boolean;
|
|
315
|
+
reason: string;
|
|
316
|
+
passes: number;
|
|
317
|
+
max_passes: number;
|
|
318
|
+
max_dimension: number;
|
|
319
|
+
max_pixels: number;
|
|
320
|
+
original_viewport: { width: number; height: number };
|
|
321
|
+
evaluated_viewport: { width: number; height: number };
|
|
322
|
+
document_extent_before_evaluation: { width: number; height: number };
|
|
323
|
+
document_extent_after_evaluation: { width: number; height: number };
|
|
324
|
+
document_extent_after_screenshot: { width: number; height: number };
|
|
325
|
+
screenshot: { width: number; height: number; bytes: number };
|
|
326
|
+
};
|
|
327
|
+
}> {
|
|
328
|
+
const page = this.currentPage;
|
|
329
|
+
const fullPage = opts.fullPage ?? true;
|
|
330
|
+
const maxPasses = 4;
|
|
331
|
+
const maxDimension = 32_000;
|
|
332
|
+
const maxPixels = 128_000_000;
|
|
333
|
+
const documentExtent = () =>
|
|
334
|
+
page.evaluate(() => ({
|
|
335
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
336
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
|
|
337
|
+
}));
|
|
338
|
+
const originalViewport = page.viewportSize();
|
|
339
|
+
if (!originalViewport) {
|
|
340
|
+
throw new Error("Capture-state evaluation requires a page with an explicit viewport.");
|
|
341
|
+
}
|
|
342
|
+
let captureViewport = originalViewport;
|
|
343
|
+
let passes = fullPage ? 0 : 1;
|
|
344
|
+
let extentBeforeEvaluation = await documentExtent();
|
|
345
|
+
let bounded = true;
|
|
346
|
+
if (fullPage) {
|
|
347
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
348
|
+
passes = pass + 1;
|
|
349
|
+
const nextViewport = {
|
|
350
|
+
width: originalViewport.width,
|
|
351
|
+
height: Math.max(originalViewport.height, Math.ceil(extentBeforeEvaluation.height)),
|
|
352
|
+
};
|
|
353
|
+
bounded =
|
|
354
|
+
nextViewport.width <= maxDimension &&
|
|
355
|
+
nextViewport.height <= maxDimension &&
|
|
356
|
+
nextViewport.width * nextViewport.height <= maxPixels;
|
|
357
|
+
if (!bounded) break;
|
|
358
|
+
if (
|
|
359
|
+
nextViewport.width === captureViewport.width &&
|
|
360
|
+
nextViewport.height === captureViewport.height &&
|
|
361
|
+
extentBeforeEvaluation.width <= captureViewport.width &&
|
|
362
|
+
extentBeforeEvaluation.height === captureViewport.height
|
|
363
|
+
) {
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
captureViewport = nextViewport;
|
|
367
|
+
await page.setViewportSize(captureViewport);
|
|
368
|
+
extentBeforeEvaluation = await documentExtent();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const evaluationResult = await page.evaluate<T>(evaluation);
|
|
373
|
+
const extentAfterEvaluation = await documentExtent();
|
|
374
|
+
const buf = await page.screenshot({ path, fullPage: fullPage && bounded, type: "png" });
|
|
375
|
+
const extentAfterScreenshot = await documentExtent();
|
|
376
|
+
const screenshot = {
|
|
377
|
+
width: buf.readUInt32BE(16),
|
|
378
|
+
height: buf.readUInt32BE(20),
|
|
379
|
+
bytes: buf.length,
|
|
380
|
+
};
|
|
381
|
+
const exact = fullPage
|
|
382
|
+
? bounded &&
|
|
383
|
+
captureViewport.width === extentBeforeEvaluation.width &&
|
|
384
|
+
captureViewport.height === extentBeforeEvaluation.height &&
|
|
385
|
+
captureViewport.width === extentAfterEvaluation.width &&
|
|
386
|
+
captureViewport.height === extentAfterEvaluation.height &&
|
|
387
|
+
captureViewport.width === extentAfterScreenshot.width &&
|
|
388
|
+
captureViewport.height === extentAfterScreenshot.height &&
|
|
389
|
+
captureViewport.width === screenshot.width &&
|
|
390
|
+
captureViewport.height === screenshot.height
|
|
391
|
+
: captureViewport.width === screenshot.width &&
|
|
392
|
+
captureViewport.height === screenshot.height;
|
|
393
|
+
const reason = !bounded
|
|
394
|
+
? "capture_bounds_exceeded"
|
|
395
|
+
: exact
|
|
396
|
+
? "capture_viewport_converged"
|
|
397
|
+
: "capture_viewport_non_convergent";
|
|
398
|
+
return {
|
|
399
|
+
bytes: buf.length,
|
|
400
|
+
evaluation: evaluationResult,
|
|
401
|
+
viewport: captureViewport,
|
|
402
|
+
evidence: {
|
|
403
|
+
converged: exact,
|
|
404
|
+
reason,
|
|
405
|
+
passes,
|
|
406
|
+
max_passes: maxPasses,
|
|
407
|
+
max_dimension: maxDimension,
|
|
408
|
+
max_pixels: maxPixels,
|
|
409
|
+
original_viewport: originalViewport,
|
|
410
|
+
evaluated_viewport: captureViewport,
|
|
411
|
+
document_extent_before_evaluation: extentBeforeEvaluation,
|
|
412
|
+
document_extent_after_evaluation: extentAfterEvaluation,
|
|
413
|
+
document_extent_after_screenshot: extentAfterScreenshot,
|
|
414
|
+
screenshot,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
} finally {
|
|
418
|
+
if (
|
|
419
|
+
captureViewport.width !== originalViewport.width ||
|
|
420
|
+
captureViewport.height !== originalViewport.height
|
|
421
|
+
) {
|
|
422
|
+
await page.setViewportSize(originalViewport);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
298
427
|
/**
|
|
299
428
|
* Plain-text snapshot of the document body. Suitable as a coarse "what's
|
|
300
429
|
* on screen" signal for LLM iteration loops. For richer extraction, use
|
package/src/lib/exec.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface ExecResult {
|
|
|
13
13
|
stdout: string;
|
|
14
14
|
stderr: string;
|
|
15
15
|
exitCode: number;
|
|
16
|
+
/** True when this process killed the child for exceeding `opts.timeout`. A
|
|
17
|
+
* child that handles the signal cleanly still exits 0, so the exit code alone
|
|
18
|
+
* cannot distinguish a kill from an ordinary finish. */
|
|
19
|
+
timedOut?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface ExecOpts {
|
|
@@ -46,7 +50,11 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
|
|
|
46
50
|
proc.stderr?.on("data", (d: Buffer) => err.push(d));
|
|
47
51
|
|
|
48
52
|
const timeout = opts.timeout ?? 30_000;
|
|
49
|
-
|
|
53
|
+
let timedOut = false;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
proc.kill();
|
|
57
|
+
}, timeout);
|
|
50
58
|
|
|
51
59
|
const finish = (exitCode: number, errOverride?: string): void => {
|
|
52
60
|
clearTimeout(timer);
|
|
@@ -57,6 +65,7 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
|
|
|
57
65
|
stdout: shouldTrim ? stdout.trim() : stdout.replace(/\n$/, ""),
|
|
58
66
|
stderr: shouldTrim ? stderr.trim() : stderr.replace(/\n$/, ""),
|
|
59
67
|
exitCode,
|
|
68
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
60
69
|
});
|
|
61
70
|
};
|
|
62
71
|
|