harnery 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/commands/browse.d.ts.map +1 -1
  2. package/dist/commands/browse.js +27 -1
  3. package/dist/commands/work.d.ts +2 -0
  4. package/dist/commands/work.d.ts.map +1 -1
  5. package/dist/commands/work.js +74 -5
  6. package/dist/core/harnesses/types.d.ts +5 -0
  7. package/dist/core/harnesses/types.d.ts.map +1 -1
  8. package/dist/core/work/index.d.ts +1 -1
  9. package/dist/core/work/index.d.ts.map +1 -1
  10. package/dist/core/work/index.js +1 -1
  11. package/dist/core/work/runner.d.ts.map +1 -1
  12. package/dist/core/work/runner.js +8 -1
  13. package/dist/core/work/state.d.ts +24 -1
  14. package/dist/core/work/state.d.ts.map +1 -1
  15. package/dist/core/work/state.js +108 -14
  16. package/dist/core/workflow/approvals.d.ts.map +1 -1
  17. package/dist/core/workflow/approvals.js +3 -6
  18. package/dist/core/workflow/attempt-context.d.ts.map +1 -1
  19. package/dist/core/workflow/attempt-context.js +38 -1
  20. package/dist/core/workflow/engine.d.ts.map +1 -1
  21. package/dist/core/workflow/engine.js +20 -19
  22. package/dist/core/workflow/journal.d.ts +30 -0
  23. package/dist/core/workflow/journal.d.ts.map +1 -0
  24. package/dist/core/workflow/journal.js +70 -0
  25. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  26. package/dist/core/workflow/spawn-claude.js +8 -0
  27. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  28. package/dist/core/workflow/spawn-codex.js +8 -0
  29. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  30. package/dist/core/workflow/spawn-cursor.js +8 -0
  31. package/dist/core/workflow/types.d.ts +11 -0
  32. package/dist/core/workflow/types.d.ts.map +1 -1
  33. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  34. package/dist/core/workflow/workspaces/local-git.js +101 -20
  35. package/dist/core/workflow/workspaces/state.d.ts +1 -1
  36. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  37. package/dist/core/workflow/workspaces/state.js +9 -30
  38. package/dist/lib/browser/client.d.ts +50 -0
  39. package/dist/lib/browser/client.d.ts.map +1 -1
  40. package/dist/lib/browser/client.js +103 -0
  41. package/dist/lib/exec.d.ts +4 -0
  42. package/dist/lib/exec.d.ts.map +1 -1
  43. package/dist/lib/exec.js +6 -1
  44. package/package.json +1 -1
  45. package/src/commands/browse.ts +33 -1
  46. package/src/commands/work.ts +109 -14
  47. package/src/core/harnesses/types.ts +5 -0
  48. package/src/core/work/index.ts +1 -0
  49. package/src/core/work/runner.ts +8 -0
  50. package/src/core/work/state.ts +177 -27
  51. package/src/core/workflow/approvals.ts +3 -6
  52. package/src/core/workflow/attempt-context.ts +47 -1
  53. package/src/core/workflow/engine.ts +48 -33
  54. package/src/core/workflow/journal.ts +90 -0
  55. package/src/core/workflow/spawn-claude.ts +8 -0
  56. package/src/core/workflow/spawn-codex.ts +8 -0
  57. package/src/core/workflow/spawn-cursor.ts +8 -0
  58. package/src/core/workflow/types.ts +12 -0
  59. package/src/core/workflow/workspaces/local-git.ts +112 -22
  60. package/src/core/workflow/workspaces/state.ts +13 -33
  61. package/src/lib/browser/client.ts +129 -0
  62. package/src/lib/exec.ts +10 -1
@@ -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
- for (const root of input.writable_roots) {
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
- if (
213
- !containsPath(root.realpath, repo.sourceRoot.realpath) ||
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
- if (lstatSync(join(sourceRoot, ".git")).isSymbolicLink()) {
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 (gitDir !== requestedGitDir || commonDir !== requestedGitDir) {
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
- const key = stableDigest({
1787
- common: claim.repository.common_dir.identity,
1788
- root: claim.writable_root.identity,
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 EVENT_LIMIT = 32 * 1024;
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 (!event || event.length > 100 || Buffer.byteLength(JSON.stringify(data)) > EVENT_LIMIT / 2) {
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) > EVENT_LIMIT)
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) > EVENT_LIMIT) {
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) > EVENT_LIMIT) {
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) > EVENT_LIMIT) {
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
- const timer = setTimeout(() => proc.kill(), timeout);
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