harnery 0.27.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.
Files changed (59) 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 +47 -4
  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/state.d.ts +1 -1
  34. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  35. package/dist/core/workflow/workspaces/state.js +9 -30
  36. package/dist/lib/browser/client.d.ts +50 -0
  37. package/dist/lib/browser/client.d.ts.map +1 -1
  38. package/dist/lib/browser/client.js +103 -0
  39. package/dist/lib/exec.d.ts +4 -0
  40. package/dist/lib/exec.d.ts.map +1 -1
  41. package/dist/lib/exec.js +6 -1
  42. package/package.json +1 -1
  43. package/src/commands/browse.ts +33 -1
  44. package/src/commands/work.ts +80 -13
  45. package/src/core/harnesses/types.ts +5 -0
  46. package/src/core/work/index.ts +1 -0
  47. package/src/core/work/runner.ts +8 -0
  48. package/src/core/work/state.ts +177 -27
  49. package/src/core/workflow/approvals.ts +3 -6
  50. package/src/core/workflow/attempt-context.ts +47 -1
  51. package/src/core/workflow/engine.ts +48 -33
  52. package/src/core/workflow/journal.ts +90 -0
  53. package/src/core/workflow/spawn-claude.ts +8 -0
  54. package/src/core/workflow/spawn-codex.ts +8 -0
  55. package/src/core/workflow/spawn-cursor.ts +8 -0
  56. package/src/core/workflow/types.ts +12 -0
  57. package/src/core/workflow/workspaces/state.ts +13 -33
  58. package/src/lib/browser/client.ts +129 -0
  59. package/src/lib/exec.ts +10 -1
@@ -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 {
@@ -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