@cat-factory/executor-harness 1.106.0 → 1.108.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.
@@ -1,159 +1,32 @@
1
- import { mkdir, stat, writeFile } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
- import type { ReferenceScreenshotSpec, ReferenceScreenshotsSpec } from './job.js'
1
+ import type { ImageManifestSpec } from './job.js'
4
2
  import type { Logger } from './logger.js'
5
- import { CONTEXT_DIR, excludeContextDir } from './pi.js'
3
+ import { CONTEXT_DIR } from './pi.js'
4
+ import { type ContextImageOutcome, materializeContextImages } from './context-images.js'
6
5
 
7
6
  // ---------------------------------------------------------------------------
8
- // REFERENCE DESIGNS on disk: download the images the backend resolved for this task into
9
- // `.cat-context/reference-screenshots/`, the directory the UI-tester prompt has always named and
10
- // nothing wrote.
7
+ // REFERENCE DESIGNS on disk for a CAPTURING kind: the images a UI tester compares its own
8
+ // screenshots against, in `.cat-context/reference-screenshots/` the directory the UI-tester
9
+ // prompt has always named and nothing wrote.
11
10
  //
12
- // The harness MATERIALISES and never decides: which artifact is the reference for which view, and
13
- // what each file is called, are backend answers that ride the job body. What lives here is the
14
- // transfer and its failure reporting: a reference the container could not fetch is NAMED to the
15
- // agent rather than silently missing, because an absent file and a design that has no such screen
16
- // look identical on disk.
11
+ // The transfer itself lives in `context-images.ts`, shared with the design-picture delivery. What
12
+ // stays here is what makes this manifest a CAPTURE instruction: the directory, and the prompt block
13
+ // telling the tester to name each screenshot after the view it was handed, including the views it
14
+ // was handed no image for.
17
15
  // ---------------------------------------------------------------------------
18
16
 
19
17
  /** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
20
18
  export const REFERENCE_SCREENSHOT_SUBDIR = 'reference-screenshots'
21
19
 
22
- /** Per-image ceiling, matching the platform's own upload ceiling (16 MiB). */
23
- const MAX_REFERENCE_BYTES = 16 * 1024 * 1024
24
-
25
- /** Per-image request timeout. */
26
- const REQUEST_TIMEOUT_MS = 20_000
27
-
28
- /**
29
- * Wall-clock ceiling on the WHOLE pass.
30
- *
31
- * Downloading is activity-silent from the watchdog's point of view (no agent stream, no output),
32
- * and `JOB_INACTIVITY_MS` (10 min) is what kills a job that stops producing. Rather than heartbeat
33
- * a transfer that should take seconds, the pass is bounded far below that: a slow or wedged blob
34
- * backend costs the run its references (stated to the agent) instead of costing it the run.
35
- */
36
- const TOTAL_BUDGET_MS = 90_000
37
-
38
- /** How many images are fetched at once. Small on purpose: this is a shared blob backend. */
39
- const CONCURRENCY = 4
40
-
41
- /** The cause reported for a view the backend resolved but never sent this job a file for. */
42
- const OMITTED_REASON = 'not sent to this container (reference limit)'
43
-
44
- /** What the pass has on disk, and what it does not. */
45
- export interface ReferenceScreenshotOutcome {
46
- written: { fileName: string; view: string }[]
47
- /**
48
- * One entry per reference that is NOT on disk, with the cause stated in `reason`. Covers both
49
- * halves of that absence, because the agent's job is the same either way (capture the view under
50
- * its own name, with nothing to compare against): a transfer that failed, and a view the cap
51
- * dropped before this container was ever asked to fetch it.
52
- */
53
- missing: { view: string; reason: string }[]
54
- /** Where the written files live, relative to the checkout root. */
55
- dir: string
56
- }
57
-
58
20
  /** The relative directory the references are written to (what the prompt points the agent at). */
59
21
  export const REFERENCE_SCREENSHOT_DIR = `${CONTEXT_DIR}/${REFERENCE_SCREENSHOT_SUBDIR}`
60
22
 
61
- /**
62
- * Download the manifest's images into the checkout and report what landed.
63
- *
64
- * IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
65
- * workspace once per repair round, so this pass runs several times over one checkout. A file
66
- * already on disk is counted and never re-fetched, which keeps a later round from spending the
67
- * budget again AND from reporting a view as absent that pass 1 successfully delivered. A view that
68
- * MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
69
- *
70
- * Never throws: references are an aid to a comparison, not a precondition for running, so a
71
- * backend outage degrades a UI run to "name your own views" (the documented fallback) rather than
72
- * failing it. Every miss is carried out on {@link ReferenceScreenshotOutcome.missing} so the caller
73
- * can say so in the prompt, which is the difference between a design the platform failed to hand
74
- * over and one that has no such screen.
75
- */
76
- export async function materializeReferenceScreenshots(
23
+ /** Download this job's capture references. See {@link materializeContextImages}. */
24
+ export function materializeReferenceScreenshots(
77
25
  cwd: string,
78
- spec: ReferenceScreenshotsSpec,
26
+ spec: ImageManifestSpec,
79
27
  options: { signal?: AbortSignal; fetchImpl?: typeof fetch } = {},
80
- ): Promise<ReferenceScreenshotOutcome> {
81
- const dir = join(cwd, CONTEXT_DIR, REFERENCE_SCREENSHOT_SUBDIR)
82
- const outcome: ReferenceScreenshotOutcome = {
83
- written: [],
84
- // The backend's own dropped views are missing before a single byte is fetched, and for a cause
85
- // no transfer could have changed.
86
- missing: spec.omitted.map((view) => ({ view, reason: OMITTED_REASON })),
87
- dir: REFERENCE_SCREENSHOT_DIR,
88
- }
89
- try {
90
- await mkdir(dir, { recursive: true })
91
- } catch (error) {
92
- // Nowhere to write: report every reference as missed rather than half of them, since none of
93
- // them can land and the cause is the same for all.
94
- for (const file of spec.files)
95
- outcome.missing.push({ view: file.view, reason: describe(error) })
96
- return sortByManifest(outcome, spec)
97
- }
98
- const deadline = Date.now() + TOTAL_BUDGET_MS
99
- const queue = [...spec.files]
100
- const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
101
- for (;;) {
102
- const file = queue.shift()
103
- if (!file) return
104
- // An earlier pass over this same checkout already delivered it. Checked before the budget so
105
- // a fully-delivered set costs one stat per file and no network at all, however long an
106
- // earlier round took.
107
- if (await alreadyOnDisk(dir, file.fileName)) {
108
- outcome.written.push({ fileName: file.fileName, view: file.view })
109
- continue
110
- }
111
- if (Date.now() >= deadline) {
112
- outcome.missing.push({ view: file.view, reason: 'reference download budget exhausted' })
113
- continue
114
- }
115
- const failure = await downloadOne(dir, spec, file, options)
116
- if (failure) outcome.missing.push({ view: file.view, reason: failure })
117
- else outcome.written.push({ fileName: file.fileName, view: file.view })
118
- }
119
- })
120
- await Promise.all(workers)
121
- // Even a partial set must not reach the agent's PR (same rule as every other context file).
122
- await excludeContextDir(cwd)
123
- return sortByManifest(outcome, spec)
124
- }
125
-
126
- /**
127
- * Order both lists the way the BACKEND composed the set (its own gallery order) rather than the
128
- * order the transfers happened to finish in, so the list the agent reads is stable across rounds.
129
- * The dropped views trail the sent ones, having no position in the manifest to sort by.
130
- */
131
- function sortByManifest(
132
- outcome: ReferenceScreenshotOutcome,
133
- spec: ReferenceScreenshotsSpec,
134
- ): ReferenceScreenshotOutcome {
135
- const rank = new Map(spec.files.map((file, index) => [file.view, index] as const))
136
- const at = (view: string) => rank.get(view) ?? Number.MAX_SAFE_INTEGER
137
- outcome.written.sort((a, b) => at(a.view) - at(b.view))
138
- outcome.missing.sort((a, b) => at(a.view) - at(b.view))
139
- return outcome
140
- }
141
-
142
- /**
143
- * Whether a previous pass over this checkout already wrote this reference.
144
- *
145
- * Non-empty is the test, not mere existence: a zero-length file is what a half-written transfer
146
- * leaves behind, and treating it as delivered would hand the agent a blank image it reads as a
147
- * design with nothing on the screen (the same case {@link downloadOne} refuses to write).
148
- */
149
- async function alreadyOnDisk(dir: string, fileName: string): Promise<boolean> {
150
- try {
151
- return (await stat(join(dir, fileName))).size > 0
152
- } catch {
153
- // silent-catch-ok: absence is the ordinary answer here (first pass over the checkout), and any
154
- // other stat failure is answered the same way — by attempting the download.
155
- return false
156
- }
28
+ ): Promise<ContextImageOutcome> {
29
+ return materializeContextImages(cwd, REFERENCE_SCREENSHOT_SUBDIR, spec, options)
157
30
  }
158
31
 
159
32
  /**
@@ -167,7 +40,7 @@ async function alreadyOnDisk(dir: string, fileName: string): Promise<boolean> {
167
40
  */
168
41
  export async function deliverReferenceScreenshots(
169
42
  cwd: string,
170
- spec: ReferenceScreenshotsSpec | undefined,
43
+ spec: ImageManifestSpec | undefined,
171
44
  options: { signal?: AbortSignal; log: Logger; fetchImpl?: typeof fetch },
172
45
  ): Promise<string> {
173
46
  if (!spec) return ''
@@ -182,85 +55,6 @@ export async function deliverReferenceScreenshots(
182
55
  return referenceScreenshotGuidance(outcome)
183
56
  }
184
57
 
185
- /** Fetch and write one reference, answering a failure reason or undefined on success. */
186
- async function downloadOne(
187
- dir: string,
188
- spec: ReferenceScreenshotsSpec,
189
- file: ReferenceScreenshotSpec,
190
- options: { signal?: AbortSignal; fetchImpl?: typeof fetch },
191
- ): Promise<string | undefined> {
192
- const fetchImpl = options.fetchImpl ?? fetch
193
- const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
194
- const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
195
- try {
196
- const response = await fetchImpl(`${spec.url}/${encodeURIComponent(file.artifactId)}`, {
197
- headers: { authorization: `Bearer ${spec.token}` },
198
- signal,
199
- })
200
- if (!response.ok) return `HTTP ${response.status}`
201
- const bytes = await readBounded(response, MAX_REFERENCE_BYTES)
202
- if (bytes === 'too-large') return 'reference exceeds size limit'
203
- // A zero-length body is a miss, not a file: written out it would be an image the agent opens,
204
- // finds empty, and reads as a design with nothing on the screen.
205
- if (!bytes.byteLength) return 'empty response'
206
- await writeFile(join(dir, file.fileName), bytes)
207
- return undefined
208
- } catch (error) {
209
- return describe(error)
210
- }
211
- }
212
-
213
- /**
214
- * Read a response body, refusing one that goes past `limit` WITHOUT buffering all of it first.
215
- *
216
- * The ceiling has to bound the transfer and not just the write. Buffering the whole body and then
217
- * measuring it means an oversized (or endless) response is already resident, times the pass's
218
- * concurrency, by the time it is rejected — which is the container's memory, in a run whose whole
219
- * point is that it has not started working yet. So the declared length is refused up front where
220
- * it is honest, and the stream is counted as it arrives and cancelled the moment it crosses the
221
- * line, which is what makes a chunked or lying body cost no more than a truthful one.
222
- */
223
- async function readBounded(response: Response, limit: number): Promise<Uint8Array | 'too-large'> {
224
- const declared = Number(response.headers.get('content-length'))
225
- if (Number.isFinite(declared) && declared > limit) return 'too-large'
226
- const body = response.body
227
- if (!body) {
228
- // No stream to count (a mocked or already-buffered response): fall back to measuring after the
229
- // fact, which is sound because there is nothing left to stop arriving.
230
- const bytes = new Uint8Array(await response.arrayBuffer())
231
- return bytes.byteLength > limit ? 'too-large' : bytes
232
- }
233
- const reader = body.getReader()
234
- const chunks: Uint8Array[] = []
235
- let total = 0
236
- try {
237
- for (;;) {
238
- const { done, value } = await reader.read()
239
- if (done) break
240
- total += value.byteLength
241
- if (total > limit) {
242
- await reader.cancel()
243
- return 'too-large'
244
- }
245
- chunks.push(value)
246
- }
247
- } finally {
248
- reader.releaseLock()
249
- }
250
- const bytes = new Uint8Array(total)
251
- let offset = 0
252
- for (const chunk of chunks) {
253
- bytes.set(chunk, offset)
254
- offset += chunk.byteLength
255
- }
256
- return bytes
257
- }
258
-
259
- /** A one-line cause for a failed transfer (never the token, which only rides a header). */
260
- function describe(error: unknown): string {
261
- return error instanceof Error ? error.message : String(error)
262
- }
263
-
264
58
  /**
265
59
  * The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
266
60
  *
@@ -273,7 +67,7 @@ function describe(error: unknown): string {
273
67
  * the directory could not be created at all) sends the agent looking for a path that may not even
274
68
  * exist, and reads as a platform bug at exactly the moment the platform is already degraded.
275
69
  */
276
- export function referenceScreenshotGuidance(outcome: ReferenceScreenshotOutcome): string {
70
+ export function referenceScreenshotGuidance(outcome: ContextImageOutcome): string {
277
71
  if (!outcome.written.length && !outcome.missing.length) return ''
278
72
  const onDisk = outcome.written.length
279
73
  ? `\n\nThese are on disk, one file per view:\n${outcome.written