@cat-factory/executor-harness 1.102.0 → 1.104.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/README.md +27 -0
- package/dist/agent-shared.d.ts +7 -5
- package/dist/agent-shared.js +6 -4
- package/dist/coding-agent.d.ts +9 -1
- package/dist/coding-agent.js +4 -0
- package/dist/context-manifests.d.ts +69 -0
- package/dist/context-manifests.js +110 -0
- package/dist/job.d.ts +9 -12
- package/dist/job.js +4 -40
- package/dist/pi-workspace.d.ts +7 -1
- package/dist/pi-workspace.js +18 -1
- package/dist/pi.d.ts +20 -0
- package/dist/pi.js +21 -13
- package/dist/reference-screenshots.d.ts +71 -0
- package/dist/reference-screenshots.js +261 -0
- package/package.json +4 -4
- package/src/agent-shared.ts +14 -5
- package/src/coding-agent.ts +13 -0
- package/src/context-manifests.ts +156 -0
- package/src/job.ts +22 -48
- package/src/pi-workspace.ts +25 -2
- package/src/pi.ts +29 -11
- package/src/reference-screenshots.ts +295 -0
package/src/job.ts
CHANGED
|
@@ -22,6 +22,13 @@ import {
|
|
|
22
22
|
type SkillSpec,
|
|
23
23
|
} from './agent-capabilities.js'
|
|
24
24
|
import { type TestSecretSpec, parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js'
|
|
25
|
+
import {
|
|
26
|
+
parseContextFiles,
|
|
27
|
+
parseReferenceScreenshots,
|
|
28
|
+
type ContextFileSpec,
|
|
29
|
+
type ReferenceScreenshotSpec,
|
|
30
|
+
type ReferenceScreenshotsSpec,
|
|
31
|
+
} from './context-manifests.js'
|
|
25
32
|
|
|
26
33
|
// Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
|
|
27
34
|
// body field like any other; only its VALIDATION moved out).
|
|
@@ -30,6 +37,10 @@ export type { TestSecretSpec }
|
|
|
30
37
|
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
31
38
|
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
32
39
|
|
|
40
|
+
// Same rule for the two staged-file manifests: their shapes and their defensive parsing moved to
|
|
41
|
+
// `context-manifests.ts`, but they remain job body fields, so this stays the import site.
|
|
42
|
+
export type { ContextFileSpec, ReferenceScreenshotSpec, ReferenceScreenshotsSpec }
|
|
43
|
+
|
|
33
44
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
34
45
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
35
46
|
// `ghToken`, `sessionToken` and `subscriptionToken` are secrets: they are
|
|
@@ -610,19 +621,6 @@ export interface AgentBootstrapSpec {
|
|
|
610
621
|
fromScratch?: boolean
|
|
611
622
|
}
|
|
612
623
|
|
|
613
|
-
/**
|
|
614
|
-
* A linked-context file the backend prepared (requirements / RFC / PRD / tracker issue)
|
|
615
|
-
* for the harness to materialise under CONTEXT_DIR in the checkout, so the agent can read
|
|
616
|
-
* it on demand. The harness can't reach Jira/GitHub itself, so all such context is fetched
|
|
617
|
-
* and shipped here up front. `path` is sanitised to a safe basename on parse.
|
|
618
|
-
*/
|
|
619
|
-
export interface ContextFileSpec {
|
|
620
|
-
path: string
|
|
621
|
-
title: string
|
|
622
|
-
url: string
|
|
623
|
-
content: string
|
|
624
|
-
}
|
|
625
|
-
|
|
626
624
|
/** How an explore agent's reply is consumed. */
|
|
627
625
|
export interface AgentOutputSpec {
|
|
628
626
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -712,6 +710,13 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
712
710
|
* The agent reads them on demand; they are kept out of any commit. Absent ⇒ none.
|
|
713
711
|
*/
|
|
714
712
|
contextFiles?: ContextFileSpec[]
|
|
713
|
+
/**
|
|
714
|
+
* The task's reference design images, downloaded into `.cat-context/reference-screenshots/`
|
|
715
|
+
* before the agent runs (see {@link ReferenceScreenshotsSpec}). Sent only for a kind that
|
|
716
|
+
* CAPTURES views and only when the task actually has references, so absent is the normal case
|
|
717
|
+
* and means the agent names its own views.
|
|
718
|
+
*/
|
|
719
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
715
720
|
/**
|
|
716
721
|
* Private package-registry auth (npm private orgs, GitHub Packages), rendered into
|
|
717
722
|
* `~/.npmrc` before the run so the checkout's installs — the agent's own and the
|
|
@@ -1015,41 +1020,6 @@ function parseAgentBootstrapSpec(value: unknown): AgentBootstrapSpec | undefined
|
|
|
1015
1020
|
}
|
|
1016
1021
|
}
|
|
1017
1022
|
|
|
1018
|
-
/**
|
|
1019
|
-
* Sanitise a body-supplied context filename to a safe basename within CONTEXT_DIR:
|
|
1020
|
-
* strip any directory part, allow only `[A-Za-z0-9._-]`, and reject empties / dotfiles
|
|
1021
|
-
* / `..` so a hostile value can't escape the directory or clobber repo files.
|
|
1022
|
-
*/
|
|
1023
|
-
function sanitizeContextFileName(value: unknown): string | undefined {
|
|
1024
|
-
if (typeof value !== 'string') return undefined
|
|
1025
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
1026
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1027
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
1028
|
-
return cleaned
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/** Parse the linked-context files, dropping any malformed/unsafe entry. */
|
|
1032
|
-
function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
1033
|
-
if (!Array.isArray(value)) return []
|
|
1034
|
-
const files: ContextFileSpec[] = []
|
|
1035
|
-
const used = new Set<string>()
|
|
1036
|
-
for (const entry of value) {
|
|
1037
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1038
|
-
const e = entry as Record<string, unknown>
|
|
1039
|
-
const path = sanitizeContextFileName(e.path)
|
|
1040
|
-
if (!path || used.has(path)) continue
|
|
1041
|
-
if (typeof e.content !== 'string') continue
|
|
1042
|
-
used.add(path)
|
|
1043
|
-
files.push({
|
|
1044
|
-
path,
|
|
1045
|
-
title: typeof e.title === 'string' ? e.title : path,
|
|
1046
|
-
url: typeof e.url === 'string' ? e.url : '',
|
|
1047
|
-
content: e.content,
|
|
1048
|
-
})
|
|
1049
|
-
}
|
|
1050
|
-
return files
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
1023
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
1054
1024
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
1055
1025
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1230,6 +1200,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1230
1200
|
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
1231
1201
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1232
1202
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
1203
|
+
referenceScreenshots: parseReferenceScreenshots(o.referenceScreenshots),
|
|
1233
1204
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1234
1205
|
skills: parseSkillSpecs(o.skills),
|
|
1235
1206
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
@@ -1272,6 +1243,7 @@ interface ParsedAgentJobParts {
|
|
|
1272
1243
|
referenceBranches: ReturnType<typeof parseReferenceBranches>
|
|
1273
1244
|
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1274
1245
|
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1246
|
+
referenceScreenshots: ReturnType<typeof parseReferenceScreenshots>
|
|
1275
1247
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1276
1248
|
skills: ReturnType<typeof parseSkillSpecs>
|
|
1277
1249
|
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
@@ -1329,6 +1301,7 @@ function assembleAgentJob(
|
|
|
1329
1301
|
referenceBranches,
|
|
1330
1302
|
bootstrap,
|
|
1331
1303
|
contextFiles,
|
|
1304
|
+
referenceScreenshots,
|
|
1332
1305
|
packageRegistries,
|
|
1333
1306
|
skills,
|
|
1334
1307
|
mcpServers,
|
|
@@ -1356,6 +1329,7 @@ function assembleAgentJob(
|
|
|
1356
1329
|
...(bootstrap ? { bootstrap } : {}),
|
|
1357
1330
|
...(output ? { output } : {}),
|
|
1358
1331
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1332
|
+
...(referenceScreenshots ? { referenceScreenshots } : {}),
|
|
1359
1333
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1360
1334
|
...(skills ? { skills } : {}),
|
|
1361
1335
|
...(mcpServers ? { mcpServers } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import type { RepoSpec } from './job.js'
|
|
4
|
+
import type { RepoSpec, ReferenceScreenshotsSpec } from './job.js'
|
|
5
|
+
import { deliverReferenceScreenshots } from './reference-screenshots.js'
|
|
5
6
|
import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
|
|
6
7
|
import { readEffortReport } from './effort.js'
|
|
7
8
|
import { log } from './logger.js'
|
|
@@ -212,6 +213,12 @@ export interface AgentRunSpec {
|
|
|
212
213
|
* from AGENTS.md, so the agent reads them on demand. Absent ⇒ none.
|
|
213
214
|
*/
|
|
214
215
|
contextFiles?: ContextFileInfo[]
|
|
216
|
+
/**
|
|
217
|
+
* The task's reference design images. Downloaded into `.cat-context/reference-screenshots/`
|
|
218
|
+
* before the run and named in the agent's prompt, so a capturing agent can compare against them
|
|
219
|
+
* and use their view names. Absent ⇒ nothing is downloaded and nothing is said.
|
|
220
|
+
*/
|
|
221
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
215
222
|
/**
|
|
216
223
|
* The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
|
|
217
224
|
* the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
|
|
@@ -283,6 +290,21 @@ export async function runAgentInWorkspace(
|
|
|
283
290
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
284
291
|
const contextFiles = spec.contextFiles ?? []
|
|
285
292
|
await materializeContextFiles(spec.dir, contextFiles)
|
|
293
|
+
// The task's reference designs, fetched into `.cat-context/reference-screenshots/` for the kinds
|
|
294
|
+
// that capture views. Delivered here (beside the linked context, before either harness path
|
|
295
|
+
// branches) so the Pi and subscription runs are handed the SAME directory and the SAME view
|
|
296
|
+
// names; a per-path copy is how one of them would end up silently without it.
|
|
297
|
+
//
|
|
298
|
+
// This runs once per PASS, not once per job: a coding flow re-enters its workspace for every
|
|
299
|
+
// repair round. That is safe because the delivery is idempotent over the checkout (a file
|
|
300
|
+
// already on disk is counted, never re-fetched), so a later round costs a stat per reference and
|
|
301
|
+
// cannot report a view an earlier round successfully delivered as absent. A view that MISSED is
|
|
302
|
+
// retried, which is the behaviour worth having: the next round is a fresh chance at a blob
|
|
303
|
+
// backend that was briefly down.
|
|
304
|
+
const referenceGuidance = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, {
|
|
305
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
306
|
+
log: opts.log ?? log,
|
|
307
|
+
})
|
|
286
308
|
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
287
309
|
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
288
310
|
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
@@ -306,7 +328,7 @@ export async function runAgentInWorkspace(
|
|
|
306
328
|
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
307
329
|
cwd: spec.dir,
|
|
308
330
|
model: spec.model,
|
|
309
|
-
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles)
|
|
331
|
+
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${referenceGuidance}`,
|
|
310
332
|
userPrompt: spec.userPrompt,
|
|
311
333
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
312
334
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
@@ -376,6 +398,7 @@ export async function runAgentInWorkspace(
|
|
|
376
398
|
serviceDirectory: spec.serviceDirectory,
|
|
377
399
|
contextFiles,
|
|
378
400
|
hasBlueprints,
|
|
401
|
+
...(referenceGuidance ? { referenceGuidance } : {}),
|
|
379
402
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
380
403
|
})
|
|
381
404
|
// Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
|
package/src/pi.ts
CHANGED
|
@@ -218,6 +218,13 @@ export async function writeAgentsContext(
|
|
|
218
218
|
* every turn) pointing at files that don't exist. Absent/false ⇒ the note is omitted.
|
|
219
219
|
*/
|
|
220
220
|
hasBlueprints?: boolean
|
|
221
|
+
/**
|
|
222
|
+
* The reference-design block composed by `referenceScreenshotGuidance`: which files the run
|
|
223
|
+
* was handed and which it could not fetch. Composed by the caller (it is the only side that
|
|
224
|
+
* knows what actually landed on disk) and appended verbatim. Absent/'' ⇒ nothing is said,
|
|
225
|
+
* which is the normal case: only a capturing kind is sent references at all.
|
|
226
|
+
*/
|
|
227
|
+
referenceGuidance?: string
|
|
221
228
|
} = {},
|
|
222
229
|
): Promise<void> {
|
|
223
230
|
const dir = join(homedir(), '.pi', 'agent')
|
|
@@ -246,9 +253,13 @@ export async function writeAgentsContext(
|
|
|
246
253
|
// (see the note above `writeAgentsContext`): it comes solely from the backend `spec-aware`
|
|
247
254
|
// trait, so a spec-aware run no longer carries it twice.
|
|
248
255
|
const blueprint = opts.hasBlueprints ? BLUEPRINT_GUIDANCE : ''
|
|
256
|
+
// The reference designs the harness downloaded for a capturing kind, listed with their view
|
|
257
|
+
// names (and the ones that could not be fetched). Last, beside the linked-context list it is the
|
|
258
|
+
// sibling of: both point the agent at files already on disk.
|
|
259
|
+
const references = opts.referenceGuidance ?? ''
|
|
249
260
|
await writeFile(
|
|
250
261
|
join(dir, 'AGENTS.md'),
|
|
251
|
-
`${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
|
|
262
|
+
`${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}${references}`,
|
|
252
263
|
'utf8',
|
|
253
264
|
)
|
|
254
265
|
}
|
|
@@ -312,9 +323,22 @@ export async function materializeContextFiles(
|
|
|
312
323
|
const dir = join(cwd, CONTEXT_DIR)
|
|
313
324
|
await mkdir(dir, { recursive: true })
|
|
314
325
|
for (const f of files) await writeFile(join(dir, f.path), f.content, 'utf8')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
await excludeContextDir(cwd)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Add the LOCAL git exclude entry for {@link CONTEXT_DIR}, so nothing the harness materialises
|
|
331
|
+
* there can be committed into the agent's PR by a `git add -A`.
|
|
332
|
+
*
|
|
333
|
+
* The exclude pattern has no leading slash, so it matches `.cat-context/` at any depth, covering
|
|
334
|
+
* the monorepo case where cwd is a service subdirectory below the repo root. Best-effort: a
|
|
335
|
+
* scaffold-from-scratch checkout has no `.git` yet, and the files then simply stay untracked.
|
|
336
|
+
*
|
|
337
|
+
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
338
|
+
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
339
|
+
* repository with nothing failing.
|
|
340
|
+
*/
|
|
341
|
+
export async function excludeContextDir(cwd: string): Promise<void> {
|
|
318
342
|
const gitRoot = await findGitRoot(cwd)
|
|
319
343
|
if (!gitRoot) return
|
|
320
344
|
try {
|
|
@@ -356,13 +380,7 @@ export async function materializeSkillResources(
|
|
|
356
380
|
await writeFile(dest, r.content, 'utf8')
|
|
357
381
|
}
|
|
358
382
|
}
|
|
359
|
-
|
|
360
|
-
if (!gitRoot) return
|
|
361
|
-
try {
|
|
362
|
-
await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8')
|
|
363
|
-
} catch {
|
|
364
|
-
// No writable .git/info; the files simply stay untracked.
|
|
365
|
-
}
|
|
383
|
+
await excludeContextDir(cwd)
|
|
366
384
|
}
|
|
367
385
|
|
|
368
386
|
/** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ReferenceScreenshotSpec, ReferenceScreenshotsSpec } from './job.js'
|
|
4
|
+
import type { Logger } from './logger.js'
|
|
5
|
+
import { CONTEXT_DIR, excludeContextDir } from './pi.js'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
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.
|
|
11
|
+
//
|
|
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.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
|
|
20
|
+
export const REFERENCE_SCREENSHOT_SUBDIR = 'reference-screenshots'
|
|
21
|
+
|
|
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
|
+
/** The relative directory the references are written to (what the prompt points the agent at). */
|
|
59
|
+
export const REFERENCE_SCREENSHOT_DIR = `${CONTEXT_DIR}/${REFERENCE_SCREENSHOT_SUBDIR}`
|
|
60
|
+
|
|
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(
|
|
77
|
+
cwd: string,
|
|
78
|
+
spec: ReferenceScreenshotsSpec,
|
|
79
|
+
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
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The whole delivery in one call: download the manifest (when there is one) and answer the prompt
|
|
161
|
+
* block naming what landed, reporting any miss to the operator on the way.
|
|
162
|
+
*
|
|
163
|
+
* One entry point so an agent-running flow cannot end up doing half of it. A miss is stated to the
|
|
164
|
+
* AGENT in its prompt (it still has to capture that view) AND logged here, because a reference that
|
|
165
|
+
* never arrives is otherwise invisible in the run's output: the gallery simply pairs against
|
|
166
|
+
* nothing, months later, with no line anywhere saying why.
|
|
167
|
+
*/
|
|
168
|
+
export async function deliverReferenceScreenshots(
|
|
169
|
+
cwd: string,
|
|
170
|
+
spec: ReferenceScreenshotsSpec | undefined,
|
|
171
|
+
options: { signal?: AbortSignal; log: Logger; fetchImpl?: typeof fetch },
|
|
172
|
+
): Promise<string> {
|
|
173
|
+
if (!spec) return ''
|
|
174
|
+
const outcome = await materializeReferenceScreenshots(cwd, spec, options)
|
|
175
|
+
if (outcome.missing.length) {
|
|
176
|
+
options.log.warn('agent: some reference designs are not on disk', {
|
|
177
|
+
written: outcome.written.length,
|
|
178
|
+
missing: outcome.missing.length,
|
|
179
|
+
reasons: outcome.missing.map((file) => file.reason).slice(0, 5),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return referenceScreenshotGuidance(outcome)
|
|
183
|
+
}
|
|
184
|
+
|
|
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
|
+
/**
|
|
265
|
+
* The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
|
|
266
|
+
*
|
|
267
|
+
* States the MISSES beside the files, because the whole point of writing this directory is that
|
|
268
|
+
* the tester captures the same views the gate will pair against: a reference that did not arrive
|
|
269
|
+
* is a view the agent should still capture (under that name) rather than one that does not exist.
|
|
270
|
+
*
|
|
271
|
+
* The "on disk" sentence is bound to the files that ARE on disk, and appears only with them. A
|
|
272
|
+
* block that asserts a populated directory when the pass wrote nothing (every transfer failed, or
|
|
273
|
+
* the directory could not be created at all) sends the agent looking for a path that may not even
|
|
274
|
+
* exist, and reads as a platform bug at exactly the moment the platform is already degraded.
|
|
275
|
+
*/
|
|
276
|
+
export function referenceScreenshotGuidance(outcome: ReferenceScreenshotOutcome): string {
|
|
277
|
+
if (!outcome.written.length && !outcome.missing.length) return ''
|
|
278
|
+
const onDisk = outcome.written.length
|
|
279
|
+
? `\n\nThese are on disk, one file per view:\n${outcome.written
|
|
280
|
+
.map((file) => `- \`${outcome.dir}/${file.fileName}\`: ${file.view}`)
|
|
281
|
+
.join('\n')}`
|
|
282
|
+
: ''
|
|
283
|
+
const absent = outcome.missing.length
|
|
284
|
+
? `\n\nThese views have NO reference image in this container, for the reason given. Capture them
|
|
285
|
+
anyway, under exactly these names. There is simply nothing here to compare against:\n${outcome.missing
|
|
286
|
+
.map((file) => `- ${file.view}: NOT on disk (${file.reason})`)
|
|
287
|
+
.join('\n')}`
|
|
288
|
+
: ''
|
|
289
|
+
return `
|
|
290
|
+
|
|
291
|
+
## Reference designs (capture these views)
|
|
292
|
+
Capture the views named below and name each screenshot's \`view\` EXACTLY as given, so the platform
|
|
293
|
+
can pair your capture with its reference. Capture any other view the task needs under a name of
|
|
294
|
+
your own.${onDisk}${absent}`
|
|
295
|
+
}
|