@vostride/agent-qa-runner-local 0.0.1 → 0.2.2
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/LICENSE.md +105 -0
- package/NOTICE.md +11 -0
- package/dist/index.cjs +1112 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +311 -0
- package/dist/index.d.ts +311 -0
- package/dist/index.js +1073 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
// src/fs-action-cache.ts
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
3
|
+
import { join as join2 } from "path";
|
|
4
|
+
|
|
5
|
+
// src/branch-resolver.ts
|
|
6
|
+
import { execFileSync } from "child_process";
|
|
7
|
+
import { readFileSync, statSync } from "fs";
|
|
8
|
+
import { join, resolve } from "path";
|
|
9
|
+
import { slugBranch } from "@vostride/agent-qa-schema";
|
|
10
|
+
var HEAD_REF_RE = /^ref:\s*refs\/heads\/(.+?)\s*$/m;
|
|
11
|
+
var GITDIR_RE = /^gitdir:\s*(.+)$/m;
|
|
12
|
+
function resolveBranch(opts = {}) {
|
|
13
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
14
|
+
const readHead = opts.readHead ?? defaultReadHead;
|
|
15
|
+
const runSymbolicRef = opts.runSymbolicRef ?? defaultRunSymbolicRef;
|
|
16
|
+
const defaultBranch = parseSymbolicRef(safe(() => runSymbolicRef(cwd))) ?? opts.configuredDefault ?? "main";
|
|
17
|
+
const currentBranch = parseHeadBranch(safe(() => readHead(cwd))) ?? defaultBranch;
|
|
18
|
+
return { currentBranch, defaultBranch };
|
|
19
|
+
}
|
|
20
|
+
function parseHeadBranch(head) {
|
|
21
|
+
if (head === null) return null;
|
|
22
|
+
const match = HEAD_REF_RE.exec(head);
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
function parseSymbolicRef(output) {
|
|
26
|
+
if (output === null) return null;
|
|
27
|
+
const trimmed = output.trim();
|
|
28
|
+
if (trimmed === "") return null;
|
|
29
|
+
const name = trimmed.slice(trimmed.lastIndexOf("/") + 1);
|
|
30
|
+
return name.length > 0 ? name : null;
|
|
31
|
+
}
|
|
32
|
+
function defaultReadHead(cwd) {
|
|
33
|
+
try {
|
|
34
|
+
const gitPath = join(cwd, ".git");
|
|
35
|
+
const stat = statSync(gitPath);
|
|
36
|
+
if (stat.isDirectory()) {
|
|
37
|
+
return readFileSync(join(gitPath, "HEAD"), "utf-8");
|
|
38
|
+
}
|
|
39
|
+
const pointer = readFileSync(gitPath, "utf-8");
|
|
40
|
+
const match = GITDIR_RE.exec(pointer);
|
|
41
|
+
if (match === null) return null;
|
|
42
|
+
const gitdir = resolve(cwd, match[1].trim());
|
|
43
|
+
return readFileSync(join(gitdir, "HEAD"), "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
var DEFAULT_GIT_SYMBOLIC_REF_TIMEOUT_MS = 5e3;
|
|
49
|
+
function runGitSymbolicRef(cwd, options = {}) {
|
|
50
|
+
const exec = options.exec ?? execFileSync;
|
|
51
|
+
const timeout = options.timeoutMs ?? DEFAULT_GIT_SYMBOLIC_REF_TIMEOUT_MS;
|
|
52
|
+
try {
|
|
53
|
+
return exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: "utf-8",
|
|
56
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
+
timeout
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function defaultRunSymbolicRef(cwd) {
|
|
64
|
+
return runGitSymbolicRef(cwd);
|
|
65
|
+
}
|
|
66
|
+
function safe(read) {
|
|
67
|
+
try {
|
|
68
|
+
return read();
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/write-policy.ts
|
|
75
|
+
function shouldWrite(policy) {
|
|
76
|
+
if (policy.disableCache) return false;
|
|
77
|
+
const protectedNow = policy.protectedBranches.includes(policy.currentBranch);
|
|
78
|
+
return !protectedNow || policy.saveCache || policy.isCI;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/fs-action-cache.ts
|
|
82
|
+
var CACHE_SCHEMA_VERSION = 2;
|
|
83
|
+
var FsActionCache = class {
|
|
84
|
+
root;
|
|
85
|
+
policy;
|
|
86
|
+
constructor(config) {
|
|
87
|
+
this.root = config.root;
|
|
88
|
+
this.policy = config.policy;
|
|
89
|
+
}
|
|
90
|
+
async get(key) {
|
|
91
|
+
if (this.policy.disableCache) return null;
|
|
92
|
+
const onCurrent = await this.readEntry(this.policy.currentBranch, key);
|
|
93
|
+
if (onCurrent !== null) return onCurrent;
|
|
94
|
+
if (this.policy.currentBranch !== this.policy.defaultBranch) {
|
|
95
|
+
return this.readEntry(this.policy.defaultBranch, key);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
async set(key, value) {
|
|
100
|
+
if (!shouldWrite(this.policy)) return;
|
|
101
|
+
const dir = this.branchDir(this.policy.currentBranch);
|
|
102
|
+
await mkdir(dir, { recursive: true });
|
|
103
|
+
const envelope = { schemaVersion: CACHE_SCHEMA_VERSION, value };
|
|
104
|
+
await writeFile(this.entryPath(dir, key), `${JSON.stringify(envelope, null, 2)}
|
|
105
|
+
`, "utf-8");
|
|
106
|
+
}
|
|
107
|
+
async invalidate(key) {
|
|
108
|
+
const dir = this.branchDir(this.policy.currentBranch);
|
|
109
|
+
try {
|
|
110
|
+
await unlink(this.entryPath(dir, key));
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.code !== "ENOENT") throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
branchDir(branch) {
|
|
116
|
+
return join2(this.root, slugBranch(branch));
|
|
117
|
+
}
|
|
118
|
+
entryPath(dir, key) {
|
|
119
|
+
return join2(dir, `${key}.json`);
|
|
120
|
+
}
|
|
121
|
+
async readEntry(branch, key) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = await readFile(this.entryPath(this.branchDir(branch), key), "utf-8");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err.code === "ENOENT") return null;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
let envelope;
|
|
130
|
+
try {
|
|
131
|
+
envelope = JSON.parse(raw);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (envelope?.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
136
|
+
return envelope.value ?? null;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/storage-state-store.ts
|
|
141
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
142
|
+
import { join as join3 } from "path";
|
|
143
|
+
import { sha256hex16 } from "@vostride/agent-qa-schema";
|
|
144
|
+
var STORAGE_STATES_SUBDIR = "storage-states";
|
|
145
|
+
var FsStorageStateStore = class {
|
|
146
|
+
root;
|
|
147
|
+
constructor(config) {
|
|
148
|
+
this.root = config.root;
|
|
149
|
+
}
|
|
150
|
+
async save(name, state) {
|
|
151
|
+
const dir = this.storageStatesDir();
|
|
152
|
+
await mkdir2(dir, { recursive: true });
|
|
153
|
+
const serialized = JSON.stringify(state);
|
|
154
|
+
await writeFile2(this.entryPath(name), `${serialized}
|
|
155
|
+
`, "utf-8");
|
|
156
|
+
return { name, fingerprint: sha256hex16(serialized) };
|
|
157
|
+
}
|
|
158
|
+
async load(name) {
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = await readFile2(this.entryPath(name), "utf-8");
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err.code === "ENOENT") return void 0;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(raw);
|
|
168
|
+
} catch {
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
storageStatesDir() {
|
|
173
|
+
return join3(this.root, STORAGE_STATES_SUBDIR);
|
|
174
|
+
}
|
|
175
|
+
entryPath(name) {
|
|
176
|
+
return join3(this.storageStatesDir(), `${slugBranch(name)}.json`);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/event-log.ts
|
|
181
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync2 } from "fs";
|
|
182
|
+
import { join as join4 } from "path";
|
|
183
|
+
var RUNS_SUBDIR = "runs";
|
|
184
|
+
var EVENTS_FILE = "events.jsonl";
|
|
185
|
+
var FsEventLog = class {
|
|
186
|
+
dir;
|
|
187
|
+
file;
|
|
188
|
+
constructor(config) {
|
|
189
|
+
this.dir = join4(config.root, RUNS_SUBDIR, slugBranch(config.runId));
|
|
190
|
+
this.file = join4(this.dir, EVENTS_FILE);
|
|
191
|
+
}
|
|
192
|
+
append(envelope) {
|
|
193
|
+
mkdirSync(this.dir, { recursive: true });
|
|
194
|
+
appendFileSync(this.file, `${JSON.stringify(envelope)}
|
|
195
|
+
`);
|
|
196
|
+
}
|
|
197
|
+
read(fromSeq) {
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = readFileSync2(this.file, "utf-8");
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code === "ENOENT") return [];
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const line of raw.split("\n")) {
|
|
207
|
+
if (line.length === 0) continue;
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = JSON.parse(line);
|
|
211
|
+
} catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (parsed.seq >= fromSeq) out.push(parsed);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// src/handoff-store.ts
|
|
221
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
222
|
+
import { join as join5 } from "path";
|
|
223
|
+
import { guardCompletedRunHandoff } from "@vostride/agent-qa-schema";
|
|
224
|
+
var HANDOFF_FILE = "handoff.json";
|
|
225
|
+
var FsHandoffStore = class {
|
|
226
|
+
dir;
|
|
227
|
+
file;
|
|
228
|
+
constructor(config) {
|
|
229
|
+
this.dir = join5(config.root, RUNS_SUBDIR, slugBranch(config.runId));
|
|
230
|
+
this.file = join5(this.dir, HANDOFF_FILE);
|
|
231
|
+
}
|
|
232
|
+
save(handoff) {
|
|
233
|
+
mkdirSync2(this.dir, { recursive: true });
|
|
234
|
+
writeFileSync(this.file, `${JSON.stringify(handoff)}
|
|
235
|
+
`, "utf-8");
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
function loadCompletedRunHandoff(filePath) {
|
|
239
|
+
const raw = readFileSync3(filePath, "utf-8");
|
|
240
|
+
return guardCompletedRunHandoff(JSON.parse(raw));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/run-artifact-store.ts
|
|
244
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
245
|
+
import { join as join6 } from "path";
|
|
246
|
+
import { RUN_ARTIFACT_SCHEMA_VERSION } from "@vostride/agent-qa-schema";
|
|
247
|
+
var ARTIFACT_FILE = "artifact.json";
|
|
248
|
+
var FsRunArtifactStore = class {
|
|
249
|
+
dir;
|
|
250
|
+
file;
|
|
251
|
+
constructor(config) {
|
|
252
|
+
this.dir = join6(config.root, RUNS_SUBDIR, slugBranch(config.runId));
|
|
253
|
+
this.file = join6(this.dir, ARTIFACT_FILE);
|
|
254
|
+
}
|
|
255
|
+
stage(document) {
|
|
256
|
+
const persisted = this.merge({ schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION }, document);
|
|
257
|
+
this.write(persisted);
|
|
258
|
+
}
|
|
259
|
+
finalize(patch) {
|
|
260
|
+
const base = this.readExisting() ?? { schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION };
|
|
261
|
+
const persisted = this.merge(base, patch);
|
|
262
|
+
this.write(persisted);
|
|
263
|
+
}
|
|
264
|
+
/** Shallow-merge a stage/finalize input onto the base, always dropping the top-level runtime. */
|
|
265
|
+
merge(base, input) {
|
|
266
|
+
const { runtime: _dropRuntime, ...rest } = input;
|
|
267
|
+
const merged = {
|
|
268
|
+
...base,
|
|
269
|
+
...rest,
|
|
270
|
+
schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION
|
|
271
|
+
};
|
|
272
|
+
delete merged.runtime;
|
|
273
|
+
return merged;
|
|
274
|
+
}
|
|
275
|
+
readExisting() {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(readFileSync4(this.file, "utf-8"));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (err.code === "ENOENT") return void 0;
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
write(document) {
|
|
284
|
+
mkdirSync3(this.dir, { recursive: true });
|
|
285
|
+
writeFileSync2(this.file, `${JSON.stringify(document)}
|
|
286
|
+
`, "utf-8");
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// src/evidence-artifact-store.ts
|
|
291
|
+
import { createHash, randomUUID } from "crypto";
|
|
292
|
+
import {
|
|
293
|
+
closeSync,
|
|
294
|
+
existsSync,
|
|
295
|
+
fsyncSync,
|
|
296
|
+
mkdirSync as mkdirSync4,
|
|
297
|
+
openSync,
|
|
298
|
+
readFileSync as readFileSync5,
|
|
299
|
+
renameSync,
|
|
300
|
+
unlinkSync,
|
|
301
|
+
writeFileSync as writeFileSync3
|
|
302
|
+
} from "fs";
|
|
303
|
+
import { dirname, join as join7 } from "path";
|
|
304
|
+
import { chmodSync, copyFileSync, fstatSync, readSync, realpathSync, statSync as statSync2 } from "fs";
|
|
305
|
+
import { isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
306
|
+
import { RECORDING_REF_PREFIX } from "@vostride/agent-qa-schema";
|
|
307
|
+
import { EvidenceArtifactRefSchema, EvidenceDocumentSchema } from "@vostride/agent-qa-schema";
|
|
308
|
+
var RUN_EVIDENCE_BLOBS_SUBDIR = "blobs";
|
|
309
|
+
var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
310
|
+
var DIRECTORY_SYNC_TOLERATED_CODES = ["EINVAL", "EPERM", "ENOTSUP", "EOPNOTSUPP", "EBADF", "EISDIR"];
|
|
311
|
+
var FsEvidenceArtifactStore = class {
|
|
312
|
+
dir;
|
|
313
|
+
durabilityRoot;
|
|
314
|
+
constructor(config) {
|
|
315
|
+
this.dir = join7(config.root, RUNS_SUBDIR, slugBranch(config.runId), RUN_EVIDENCE_BLOBS_SUBDIR);
|
|
316
|
+
this.durabilityRoot = dirname(join7(config.root, "."));
|
|
317
|
+
}
|
|
318
|
+
persistDecisionScreenshot(bytes) {
|
|
319
|
+
const sha = createHash("sha256").update(bytes).digest("hex");
|
|
320
|
+
mkdirSync4(this.dir, { recursive: true });
|
|
321
|
+
writeFileSync3(join7(this.dir, sha), bytes);
|
|
322
|
+
return { id: `decision-screenshot:${sha}`, sha };
|
|
323
|
+
}
|
|
324
|
+
persistEvidenceDocument(document) {
|
|
325
|
+
const bytes = Buffer.from(JSON.stringify(document));
|
|
326
|
+
const parsed = EvidenceDocumentSchema.parse(JSON.parse(bytes.toString("utf8")));
|
|
327
|
+
if (parsed.kind === "variables") throw new Error("Variable evidence is stored inline");
|
|
328
|
+
const sha = createHash("sha256").update(bytes).digest("hex");
|
|
329
|
+
const prefix = parsed.kind === "aria" ? "aria-state:" : `${parsed.kind}-evidence:`;
|
|
330
|
+
const ref = EvidenceArtifactRefSchema.parse({ id: `${prefix}${sha}`, sha });
|
|
331
|
+
mkdirSync4(this.dir, { recursive: true });
|
|
332
|
+
const target = join7(this.dir, sha);
|
|
333
|
+
const existing = this.getBlob(sha);
|
|
334
|
+
if (existing !== void 0 && Buffer.from(existing).equals(bytes)) {
|
|
335
|
+
this.syncPath(target);
|
|
336
|
+
this.syncDirectories();
|
|
337
|
+
return ref;
|
|
338
|
+
}
|
|
339
|
+
const temporary = join7(this.dir, `.${sha}.${randomUUID()}.tmp`);
|
|
340
|
+
const fd = openSync(temporary, "wx", 384);
|
|
341
|
+
let opened = true;
|
|
342
|
+
try {
|
|
343
|
+
writeFileSync3(fd, bytes);
|
|
344
|
+
fsyncSync(fd);
|
|
345
|
+
closeSync(fd);
|
|
346
|
+
opened = false;
|
|
347
|
+
renameSync(temporary, target);
|
|
348
|
+
this.syncDirectories();
|
|
349
|
+
return ref;
|
|
350
|
+
} finally {
|
|
351
|
+
if (opened) closeSync(fd);
|
|
352
|
+
try {
|
|
353
|
+
unlinkSync(temporary);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error.code !== "ENOENT") throw error;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
syncDirectories() {
|
|
360
|
+
for (let path = this.dir; ; path = dirname(path)) {
|
|
361
|
+
try {
|
|
362
|
+
this.syncPath(path);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const code = error.code;
|
|
365
|
+
if (!DIRECTORY_SYNC_TOLERATED_CODES.some((tolerated) => tolerated === code)) throw error;
|
|
366
|
+
}
|
|
367
|
+
if (path === this.durabilityRoot || dirname(path) === path) return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
syncPath(path) {
|
|
371
|
+
const fd = openSync(path, "r");
|
|
372
|
+
try {
|
|
373
|
+
fsyncSync(fd);
|
|
374
|
+
} finally {
|
|
375
|
+
closeSync(fd);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
hasBlob(sha) {
|
|
379
|
+
if (!SHA256_HEX_RE.test(sha)) return false;
|
|
380
|
+
return existsSync(join7(this.dir, sha));
|
|
381
|
+
}
|
|
382
|
+
persistRecording(stagingPath) {
|
|
383
|
+
this.assertRecordingStagingPath(stagingPath);
|
|
384
|
+
let recordingTemp;
|
|
385
|
+
try {
|
|
386
|
+
const byteLength = statSync2(stagingPath).size;
|
|
387
|
+
if (byteLength === 0) throw new Error("Cannot persist an empty recording");
|
|
388
|
+
const hash = createHash("sha256");
|
|
389
|
+
const fd = openSync(stagingPath, "r");
|
|
390
|
+
try {
|
|
391
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
392
|
+
let total = 0;
|
|
393
|
+
for (; ; ) {
|
|
394
|
+
const count = readSync(fd, chunk, 0, chunk.length, null);
|
|
395
|
+
if (count === 0) break;
|
|
396
|
+
total += count;
|
|
397
|
+
hash.update(chunk.subarray(0, count));
|
|
398
|
+
}
|
|
399
|
+
if (total !== byteLength) throw new Error("Recording changed while hashing");
|
|
400
|
+
} finally {
|
|
401
|
+
closeSync(fd);
|
|
402
|
+
}
|
|
403
|
+
const sha = hash.digest("hex");
|
|
404
|
+
const result = {
|
|
405
|
+
ref: { id: `${RECORDING_REF_PREFIX}${sha}`, sha },
|
|
406
|
+
byteLength
|
|
407
|
+
};
|
|
408
|
+
mkdirSync4(this.dir, { recursive: true });
|
|
409
|
+
const target = join7(this.dir, sha);
|
|
410
|
+
if (this.blobSize(sha) !== void 0) {
|
|
411
|
+
this.syncPath(target);
|
|
412
|
+
this.syncDirectories();
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
recordingTemp = join7(this.dir, `.${sha}.${randomUUID()}.tmp`);
|
|
416
|
+
try {
|
|
417
|
+
renameSync(stagingPath, recordingTemp);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error.code !== "EXDEV") throw error;
|
|
420
|
+
copyFileSync(stagingPath, recordingTemp);
|
|
421
|
+
}
|
|
422
|
+
chmodSync(recordingTemp, 384);
|
|
423
|
+
this.syncPath(recordingTemp);
|
|
424
|
+
renameSync(recordingTemp, target);
|
|
425
|
+
this.syncDirectories();
|
|
426
|
+
return result;
|
|
427
|
+
} finally {
|
|
428
|
+
try {
|
|
429
|
+
if (recordingTemp !== void 0) this.unlinkRecordingStaging(recordingTemp);
|
|
430
|
+
} finally {
|
|
431
|
+
this.unlinkRecordingStaging(stagingPath);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
blobSize(sha) {
|
|
436
|
+
if (!SHA256_HEX_RE.test(sha)) return void 0;
|
|
437
|
+
try {
|
|
438
|
+
return statSync2(join7(this.dir, sha)).size;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error.code === "ENOENT") return void 0;
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
readBlobRange(sha, start, end) {
|
|
445
|
+
if (!SHA256_HEX_RE.test(sha)) return void 0;
|
|
446
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) return void 0;
|
|
447
|
+
let fd;
|
|
448
|
+
try {
|
|
449
|
+
fd = openSync(join7(this.dir, sha), "r");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error.code === "ENOENT") return void 0;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const size = fstatSync(fd).size;
|
|
456
|
+
if (start >= size) return void 0;
|
|
457
|
+
const length = Math.min(end, size - 1) - start + 1;
|
|
458
|
+
const bytes = new Uint8Array(length);
|
|
459
|
+
let offset = 0;
|
|
460
|
+
while (offset < length) {
|
|
461
|
+
const count = readSync(fd, bytes, offset, length - offset, start + offset);
|
|
462
|
+
if (count === 0) throw new Error("Recording blob ended before the requested range");
|
|
463
|
+
offset += count;
|
|
464
|
+
}
|
|
465
|
+
return bytes;
|
|
466
|
+
} finally {
|
|
467
|
+
closeSync(fd);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
discardRecording(stagingPath) {
|
|
471
|
+
this.assertRecordingStagingPath(stagingPath);
|
|
472
|
+
this.unlinkRecordingStaging(stagingPath);
|
|
473
|
+
}
|
|
474
|
+
assertRecordingStagingPath(stagingPath) {
|
|
475
|
+
const inside = (dir, path) => {
|
|
476
|
+
const tail = relative(dir, path);
|
|
477
|
+
return tail === "" || tail !== ".." && !tail.startsWith(`..${sep}`) && !isAbsolute(tail);
|
|
478
|
+
};
|
|
479
|
+
if (inside(resolve2(this.dir), resolve2(stagingPath))) {
|
|
480
|
+
throw new Error("Recording staging path must be outside the evidence blob directory");
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
if (inside(realpathSync(this.dir), realpathSync(stagingPath))) {
|
|
484
|
+
throw new Error("Recording staging path must be outside the evidence blob directory");
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (error.code !== "ENOENT") throw error;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
unlinkRecordingStaging(path) {
|
|
491
|
+
try {
|
|
492
|
+
unlinkSync(path);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (error.code !== "ENOENT") throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
getBlob(sha) {
|
|
498
|
+
if (!SHA256_HEX_RE.test(sha)) return void 0;
|
|
499
|
+
try {
|
|
500
|
+
return new Uint8Array(readFileSync5(join7(this.dir, sha)));
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (err.code === "ENOENT") return void 0;
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// src/screenshot-compressor.ts
|
|
509
|
+
import sharp from "sharp";
|
|
510
|
+
var STARTING_QUALITY = 70;
|
|
511
|
+
var QUALITY_REDUCTION = 0.8;
|
|
512
|
+
var MIN_QUALITY = 10;
|
|
513
|
+
async function compressScreenshot(buffer, opts, logger) {
|
|
514
|
+
const originalKB = (buffer.length / 1024).toFixed(1);
|
|
515
|
+
const meta = await sharp(buffer).metadata();
|
|
516
|
+
const srcWidth = meta.width ?? opts.actionSpaceWidth;
|
|
517
|
+
const srcHeight = meta.height ?? 0;
|
|
518
|
+
const targetEdge = opts.effectiveResolution;
|
|
519
|
+
let resizePipeline = sharp(buffer);
|
|
520
|
+
if (srcWidth > targetEdge || srcHeight > targetEdge) {
|
|
521
|
+
resizePipeline = resizePipeline.resize({
|
|
522
|
+
width: targetEdge,
|
|
523
|
+
height: targetEdge,
|
|
524
|
+
fit: "inside",
|
|
525
|
+
withoutEnlargement: true,
|
|
526
|
+
kernel: "lanczos3"
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const afterResize = await resizePipeline.toBuffer({ resolveWithObject: true });
|
|
530
|
+
const resizedWidth = afterResize.info.width;
|
|
531
|
+
const resizedHeight = afterResize.info.height;
|
|
532
|
+
if (!opts.maxBytes) {
|
|
533
|
+
return { buffer: afterResize.data, imageWidth: resizedWidth, imageHeight: resizedHeight };
|
|
534
|
+
}
|
|
535
|
+
const maxBytes = opts.maxBytes;
|
|
536
|
+
const targetKB = (maxBytes / 1024).toFixed(1);
|
|
537
|
+
let quality = STARTING_QUALITY;
|
|
538
|
+
while (quality > MIN_QUALITY) {
|
|
539
|
+
const { data: data2, info: info2 } = await sharp(afterResize.data).jpeg({ quality, mozjpeg: true }).toBuffer({ resolveWithObject: true });
|
|
540
|
+
if (info2.size <= maxBytes) {
|
|
541
|
+
logger?.debug("Compressed screenshot", {
|
|
542
|
+
originalKB: parseFloat(originalKB),
|
|
543
|
+
compressedKB: parseFloat((info2.size / 1024).toFixed(1)),
|
|
544
|
+
targetKB: parseFloat(targetKB),
|
|
545
|
+
quality
|
|
546
|
+
});
|
|
547
|
+
return { buffer: data2, imageWidth: resizedWidth, imageHeight: resizedHeight };
|
|
548
|
+
}
|
|
549
|
+
quality = Math.max(MIN_QUALITY, Math.round(quality * QUALITY_REDUCTION));
|
|
550
|
+
}
|
|
551
|
+
const { data, info } = await sharp(afterResize.data).jpeg({ quality: MIN_QUALITY, mozjpeg: true }).toBuffer({ resolveWithObject: true });
|
|
552
|
+
logger?.debug("Compressed screenshot (exceeded target)", {
|
|
553
|
+
originalKB: parseFloat(originalKB),
|
|
554
|
+
compressedKB: parseFloat((info.size / 1024).toFixed(1)),
|
|
555
|
+
targetKB: parseFloat(targetKB),
|
|
556
|
+
quality: MIN_QUALITY
|
|
557
|
+
});
|
|
558
|
+
return { buffer: data, imageWidth: resizedWidth, imageHeight: resizedHeight };
|
|
559
|
+
}
|
|
560
|
+
var SharpScreenshotCompressor = class {
|
|
561
|
+
constructor(logger) {
|
|
562
|
+
this.logger = logger;
|
|
563
|
+
}
|
|
564
|
+
async compress(screenshot, options) {
|
|
565
|
+
return compressScreenshot(screenshot, options, this.logger);
|
|
566
|
+
}
|
|
567
|
+
async dimensions(screenshot) {
|
|
568
|
+
const meta = await sharp(screenshot).metadata();
|
|
569
|
+
return { width: meta.width ?? 0, height: meta.height ?? 0 };
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
async function alignToWindow(buffer, opts) {
|
|
573
|
+
return sharp(buffer).resize({ width: opts.width, height: opts.height, kernel: "lanczos3", fit: "fill" }).png().toBuffer();
|
|
574
|
+
}
|
|
575
|
+
async function getImageDimensions(buffer) {
|
|
576
|
+
const meta = await sharp(buffer).metadata();
|
|
577
|
+
if (!meta.width || !meta.height) return void 0;
|
|
578
|
+
return { width: meta.width, height: meta.height };
|
|
579
|
+
}
|
|
580
|
+
async function flattenOntoWhite(buffer) {
|
|
581
|
+
const meta = await sharp(buffer).metadata();
|
|
582
|
+
if (!meta.hasAlpha) return buffer;
|
|
583
|
+
return sharp(buffer).flatten({ background: "#ffffff" }).png().toBuffer();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/build-ordinal.ts
|
|
587
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
588
|
+
import {
|
|
589
|
+
closeSync as closeSync2,
|
|
590
|
+
fsyncSync as fsyncSync2,
|
|
591
|
+
linkSync,
|
|
592
|
+
mkdirSync as mkdirSync5,
|
|
593
|
+
openSync as openSync2,
|
|
594
|
+
readFileSync as readFileSync6,
|
|
595
|
+
renameSync as renameSync2,
|
|
596
|
+
rmSync,
|
|
597
|
+
writeFileSync as writeFileSync4
|
|
598
|
+
} from "fs";
|
|
599
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
600
|
+
var BUILD_ORDINALS_FILE = "build-ordinals.json";
|
|
601
|
+
var BuildOrdinalReservationError = class extends Error {
|
|
602
|
+
name = "BuildOrdinalReservationError";
|
|
603
|
+
code;
|
|
604
|
+
product;
|
|
605
|
+
value;
|
|
606
|
+
constructor(code, product, value) {
|
|
607
|
+
const message = code === "invalid-state" ? `${BUILD_ORDINALS_FILE} must contain a JSON object` : code === "missing-counter" ? `Missing build ordinal counter for product ${JSON.stringify(product)}` : code === "invalid-counter" ? `Build ordinal counter for product ${JSON.stringify(product)} must be a non-negative safe integer` : `Build ordinal counter for product ${JSON.stringify(product)} is exhausted`;
|
|
608
|
+
super(message);
|
|
609
|
+
this.code = code;
|
|
610
|
+
this.product = product;
|
|
611
|
+
this.value = value;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
var BUILD_ORDINALS_LOCK_FILE = ".build-ordinals.lock";
|
|
615
|
+
var COUNTER_LOCK_POLL_MS = 5;
|
|
616
|
+
var COUNTER_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
617
|
+
function fsyncDirectory(dir) {
|
|
618
|
+
let fd;
|
|
619
|
+
try {
|
|
620
|
+
fd = openSync2(dir, "r");
|
|
621
|
+
fsyncSync2(fd);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
const code = error.code;
|
|
624
|
+
if (code !== "EINVAL" && code !== "EISDIR" && code !== "EPERM" && code !== "ENOTSUP") {
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
} finally {
|
|
628
|
+
if (fd !== void 0) closeSync2(fd);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function durableExclusiveFile(file, text) {
|
|
632
|
+
const fd = openSync2(file, "wx");
|
|
633
|
+
try {
|
|
634
|
+
writeFileSync4(fd, text, "utf8");
|
|
635
|
+
fsyncSync2(fd);
|
|
636
|
+
} finally {
|
|
637
|
+
closeSync2(fd);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function parsedLockOwner(file) {
|
|
641
|
+
try {
|
|
642
|
+
const value = JSON.parse(readFileSync6(file, "utf8"));
|
|
643
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
644
|
+
const owner = value;
|
|
645
|
+
return typeof owner.pid === "number" && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && owner.token !== "" ? { pid: owner.pid, token: owner.token } : void 0;
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (error.code === "ENOENT") return void 0;
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function processIsAlive(pid) {
|
|
652
|
+
try {
|
|
653
|
+
process.kill(pid, 0);
|
|
654
|
+
return true;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const code = error.code;
|
|
657
|
+
if (code === "ESRCH") return false;
|
|
658
|
+
if (code === "EPERM") return true;
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function withCounterLock(root, operation) {
|
|
663
|
+
mkdirSync5(root, { recursive: true });
|
|
664
|
+
const lock = join8(root, BUILD_ORDINALS_LOCK_FILE);
|
|
665
|
+
const owner = { pid: process.pid, token: randomUUID2() };
|
|
666
|
+
const ownerFile = `${lock}.owner-${owner.token}`;
|
|
667
|
+
durableExclusiveFile(ownerFile, `${JSON.stringify(owner)}
|
|
668
|
+
`);
|
|
669
|
+
let acquired = false;
|
|
670
|
+
try {
|
|
671
|
+
while (!acquired) {
|
|
672
|
+
try {
|
|
673
|
+
linkSync(ownerFile, lock);
|
|
674
|
+
fsyncDirectory(root);
|
|
675
|
+
acquired = true;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (error.code !== "EEXIST") throw error;
|
|
678
|
+
const current = parsedLockOwner(lock);
|
|
679
|
+
if (current !== void 0 && !processIsAlive(current.pid)) {
|
|
680
|
+
const quarantine = `${lock}.dead-${current.token}-${randomUUID2()}`;
|
|
681
|
+
try {
|
|
682
|
+
renameSync2(lock, quarantine);
|
|
683
|
+
fsyncDirectory(root);
|
|
684
|
+
rmSync(quarantine, { force: true });
|
|
685
|
+
fsyncDirectory(root);
|
|
686
|
+
continue;
|
|
687
|
+
} catch (recoveryError) {
|
|
688
|
+
if (recoveryError.code === "ENOENT") continue;
|
|
689
|
+
throw recoveryError;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
Atomics.wait(COUNTER_LOCK_WAIT, 0, 0, COUNTER_LOCK_POLL_MS);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return operation();
|
|
696
|
+
} finally {
|
|
697
|
+
if (acquired && parsedLockOwner(lock)?.token === owner.token) {
|
|
698
|
+
rmSync(lock, { force: true });
|
|
699
|
+
fsyncDirectory(root);
|
|
700
|
+
}
|
|
701
|
+
rmSync(ownerFile, { force: true });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function persistOrdinals(file, map, beforePersist) {
|
|
705
|
+
const temporary = `${file}.tmp-${randomUUID2()}`;
|
|
706
|
+
let fd;
|
|
707
|
+
try {
|
|
708
|
+
fd = openSync2(temporary, "wx");
|
|
709
|
+
writeFileSync4(fd, `${JSON.stringify(map)}
|
|
710
|
+
`, "utf8");
|
|
711
|
+
fsyncSync2(fd);
|
|
712
|
+
closeSync2(fd);
|
|
713
|
+
fd = void 0;
|
|
714
|
+
beforePersist?.();
|
|
715
|
+
renameSync2(temporary, file);
|
|
716
|
+
fsyncDirectory(dirname2(file));
|
|
717
|
+
} finally {
|
|
718
|
+
if (fd !== void 0) closeSync2(fd);
|
|
719
|
+
rmSync(temporary, { force: true });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function nextBuildOrdinal(opts) {
|
|
723
|
+
return withCounterLock(opts.root, () => {
|
|
724
|
+
const file = join8(opts.root, BUILD_ORDINALS_FILE);
|
|
725
|
+
const map = readOrdinals(file, opts.product, true);
|
|
726
|
+
const current = Object.hasOwn(map, opts.product) ? map[opts.product] : 0;
|
|
727
|
+
if (current === Number.MAX_SAFE_INTEGER) {
|
|
728
|
+
throw new BuildOrdinalReservationError("counter-exhausted", opts.product, current);
|
|
729
|
+
}
|
|
730
|
+
const next = current + 1;
|
|
731
|
+
map[opts.product] = next;
|
|
732
|
+
persistOrdinals(file, map);
|
|
733
|
+
return next;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
async function reserveBuildOrdinalForProducts(opts) {
|
|
737
|
+
const products = Object.freeze([...new Set(opts.products)].sort());
|
|
738
|
+
if (products.length === 0) return void 0;
|
|
739
|
+
return withCounterLock(opts.root, () => {
|
|
740
|
+
const file = join8(opts.root, BUILD_ORDINALS_FILE);
|
|
741
|
+
const map = readOrdinals(file, products[0], false);
|
|
742
|
+
let maximum = 0;
|
|
743
|
+
for (const product of products) {
|
|
744
|
+
if (!Object.hasOwn(map, product)) {
|
|
745
|
+
throw new BuildOrdinalReservationError("missing-counter", product);
|
|
746
|
+
}
|
|
747
|
+
const current = map[product];
|
|
748
|
+
if (current === Number.MAX_SAFE_INTEGER) {
|
|
749
|
+
throw new BuildOrdinalReservationError("counter-exhausted", product, current);
|
|
750
|
+
}
|
|
751
|
+
maximum = Math.max(maximum, current);
|
|
752
|
+
}
|
|
753
|
+
const ordinal = maximum + 1;
|
|
754
|
+
for (const product of products) map[product] = ordinal;
|
|
755
|
+
const reservation = Object.freeze({ products, ordinal });
|
|
756
|
+
persistOrdinals(file, map, () => opts.beforePersist?.(reservation));
|
|
757
|
+
return ordinal;
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
function readOrdinals(file, product, missingIsEmpty) {
|
|
761
|
+
let raw;
|
|
762
|
+
try {
|
|
763
|
+
raw = readFileSync6(file, "utf-8");
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (error.code === "ENOENT") {
|
|
766
|
+
if (missingIsEmpty) return /* @__PURE__ */ Object.create(null);
|
|
767
|
+
throw new BuildOrdinalReservationError("missing-counter", product);
|
|
768
|
+
}
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
771
|
+
let parsed;
|
|
772
|
+
try {
|
|
773
|
+
parsed = JSON.parse(raw);
|
|
774
|
+
} catch {
|
|
775
|
+
throw new BuildOrdinalReservationError("invalid-state", product);
|
|
776
|
+
}
|
|
777
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
778
|
+
throw new BuildOrdinalReservationError("invalid-state", product, parsed);
|
|
779
|
+
}
|
|
780
|
+
const map = /* @__PURE__ */ Object.create(null);
|
|
781
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
782
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
783
|
+
throw new BuildOrdinalReservationError("invalid-counter", key, value);
|
|
784
|
+
}
|
|
785
|
+
map[key] = value;
|
|
786
|
+
}
|
|
787
|
+
return map;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/build-tag-map.ts
|
|
791
|
+
import { readFileSync as readFileSync7, renameSync as renameSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
792
|
+
import { join as join9 } from "path";
|
|
793
|
+
var BUILD_TAGS_FILE = "build-tags.json";
|
|
794
|
+
async function recordBuildTag(opts) {
|
|
795
|
+
const map = readBuildTagMap({ root: opts.root });
|
|
796
|
+
const productTags = Object.prototype.hasOwnProperty.call(map, opts.product) ? map[opts.product] : void 0;
|
|
797
|
+
if (productTags && Object.prototype.hasOwnProperty.call(productTags, opts.tag)) return;
|
|
798
|
+
const nextMap = {
|
|
799
|
+
...map,
|
|
800
|
+
[opts.product]: {
|
|
801
|
+
...productTags,
|
|
802
|
+
[opts.tag]: opts.ordinal
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
const file = join9(opts.root, BUILD_TAGS_FILE);
|
|
806
|
+
const tmp = `${file}.tmp`;
|
|
807
|
+
writeFileSync5(tmp, `${JSON.stringify(nextMap)}
|
|
808
|
+
`, "utf-8");
|
|
809
|
+
renameSync3(tmp, file);
|
|
810
|
+
}
|
|
811
|
+
function resolveBuildTag(opts) {
|
|
812
|
+
return readBuildTagMap({ root: opts.root })[opts.product]?.[opts.tag];
|
|
813
|
+
}
|
|
814
|
+
function latestBuildOrdinal(opts) {
|
|
815
|
+
const parsed = readJsonRecord(join9(opts.root, BUILD_ORDINALS_FILE));
|
|
816
|
+
const value = parsed?.[opts.product];
|
|
817
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
818
|
+
}
|
|
819
|
+
function readBuildTagMap(opts) {
|
|
820
|
+
const parsed = readJsonRecord(join9(opts.root, BUILD_TAGS_FILE));
|
|
821
|
+
if (!parsed) return {};
|
|
822
|
+
const products = [];
|
|
823
|
+
for (const [product, tags] of Object.entries(parsed)) {
|
|
824
|
+
if (!isRecord(tags)) continue;
|
|
825
|
+
const validTags = Object.fromEntries(
|
|
826
|
+
Object.entries(tags).filter(
|
|
827
|
+
(entry) => typeof entry[1] === "number" && Number.isFinite(entry[1])
|
|
828
|
+
)
|
|
829
|
+
);
|
|
830
|
+
products.push([product, validTags]);
|
|
831
|
+
}
|
|
832
|
+
return Object.fromEntries(products);
|
|
833
|
+
}
|
|
834
|
+
function readJsonRecord(file) {
|
|
835
|
+
try {
|
|
836
|
+
const parsed = JSON.parse(readFileSync7(file, "utf-8"));
|
|
837
|
+
return isRecord(parsed) ? parsed : void 0;
|
|
838
|
+
} catch {
|
|
839
|
+
return void 0;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function isRecord(value) {
|
|
843
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// src/hook-executor.ts
|
|
847
|
+
import { execFile } from "child_process";
|
|
848
|
+
import { readFile as readFile3, mkdtemp, mkdir as mkdir3, rm, cp } from "fs/promises";
|
|
849
|
+
import { join as join10, basename } from "path";
|
|
850
|
+
import { tmpdir } from "os";
|
|
851
|
+
import {
|
|
852
|
+
RUNTIME_IMAGE_MAP,
|
|
853
|
+
parseEnvFile,
|
|
854
|
+
AUTH_STATE_HOOK_CONTAINER_STORAGE_STATE_PATH,
|
|
855
|
+
AUTH_STATE_HOOK_STORAGE_STATE_FILENAME,
|
|
856
|
+
AUTH_STATE_HOOK_WORKSPACE_DIR,
|
|
857
|
+
buildAuthStateHookEnv,
|
|
858
|
+
stripReservedAuthStateHookEnv,
|
|
859
|
+
redactSecretValue
|
|
860
|
+
} from "@vostride/agent-qa-runner";
|
|
861
|
+
var DEFAULT_DOCKER_BIN = "docker";
|
|
862
|
+
var DockerHookExecutor = class {
|
|
863
|
+
dockerBin;
|
|
864
|
+
pullPolicy;
|
|
865
|
+
constructor(config = {}) {
|
|
866
|
+
this.dockerBin = config.dockerBin ?? DEFAULT_DOCKER_BIN;
|
|
867
|
+
this.pullPolicy = config.pullPolicy ?? "if-not-present";
|
|
868
|
+
}
|
|
869
|
+
async execute(hook, opts) {
|
|
870
|
+
const dockerBin = this.dockerBin;
|
|
871
|
+
const pullPolicy = this.pullPolicy;
|
|
872
|
+
const start = Date.now();
|
|
873
|
+
const image = RUNTIME_IMAGE_MAP[hook.runtime];
|
|
874
|
+
if (pullPolicy === "always" || pullPolicy === "if-not-present") {
|
|
875
|
+
const needsPull = pullPolicy === "always" || !await imageExists(dockerBin, image);
|
|
876
|
+
if (needsPull) {
|
|
877
|
+
await pullImage(dockerBin, image);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
let workDir;
|
|
881
|
+
try {
|
|
882
|
+
workDir = await mkdtemp(join10(tmpdir(), "agent-qa-hook-"));
|
|
883
|
+
await mkdir3(join10(workDir, "tmp"), { recursive: true });
|
|
884
|
+
await cp(hook.file, join10(workDir, basename(hook.file)));
|
|
885
|
+
for (const dep of hook.deps) {
|
|
886
|
+
await cp(dep, join10(workDir, basename(dep)));
|
|
887
|
+
}
|
|
888
|
+
if (hook.packageFile) {
|
|
889
|
+
await cp(hook.packageFile, join10(workDir, basename(hook.packageFile)));
|
|
890
|
+
}
|
|
891
|
+
if (opts.authState) {
|
|
892
|
+
const authStateDir = join10(workDir, AUTH_STATE_HOOK_WORKSPACE_DIR);
|
|
893
|
+
await mkdir3(authStateDir, { recursive: true });
|
|
894
|
+
await cp(opts.authState.storageStatePath, join10(authStateDir, AUTH_STATE_HOOK_STORAGE_STATE_FILENAME));
|
|
895
|
+
}
|
|
896
|
+
const args = buildDockerArgs({
|
|
897
|
+
image,
|
|
898
|
+
workDir,
|
|
899
|
+
hook,
|
|
900
|
+
env: opts.env,
|
|
901
|
+
secretEnv: opts.secretEnv,
|
|
902
|
+
authState: opts.authState
|
|
903
|
+
});
|
|
904
|
+
const cmd = getHookCommand(hook.runtime, basename(hook.file));
|
|
905
|
+
args.push(...cmd);
|
|
906
|
+
const { stdout, stderr, exitCode } = await execDocker(dockerBin, args, hook.timeout);
|
|
907
|
+
let variables = {};
|
|
908
|
+
try {
|
|
909
|
+
const envContent = await readFile3(join10(workDir, "tmp", "agent-qa.env"), "utf-8");
|
|
910
|
+
variables = parseEnvFile(envContent);
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
variables = stripReservedAuthStateHookEnv(filterSecretVariables(variables, opts.secretEnv));
|
|
914
|
+
const redactedStdout = redactSecretValue(stdout, opts.secretRedactor);
|
|
915
|
+
const redactedStderr = redactSecretValue(stderr, opts.secretRedactor);
|
|
916
|
+
const output = redactedStdout.trim();
|
|
917
|
+
return {
|
|
918
|
+
success: exitCode === 0,
|
|
919
|
+
variables,
|
|
920
|
+
output: output || redactedStderr,
|
|
921
|
+
stdout: redactedStdout,
|
|
922
|
+
stderr: redactedStderr,
|
|
923
|
+
duration: Date.now() - start,
|
|
924
|
+
error: exitCode !== 0 ? redactSecretValue(`Hook exited with code ${exitCode}: ${stderr}`, opts.secretRedactor) : void 0
|
|
925
|
+
};
|
|
926
|
+
} catch (err) {
|
|
927
|
+
return {
|
|
928
|
+
success: false,
|
|
929
|
+
variables: {},
|
|
930
|
+
output: "",
|
|
931
|
+
stdout: "",
|
|
932
|
+
stderr: "",
|
|
933
|
+
duration: Date.now() - start,
|
|
934
|
+
error: redactSecretValue(err.message, opts.secretRedactor)
|
|
935
|
+
};
|
|
936
|
+
} finally {
|
|
937
|
+
if (workDir) {
|
|
938
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
async function checkDockerAvailable(dockerBin = DEFAULT_DOCKER_BIN) {
|
|
945
|
+
return new Promise((resolve3) => {
|
|
946
|
+
execFile(dockerBin, ["info", "--format", "{{.ID}}"], { timeout: 5e3 }, (err) => {
|
|
947
|
+
resolve3(!err);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
function getHookCommand(runtime, entryFile) {
|
|
952
|
+
switch (runtime) {
|
|
953
|
+
case "node":
|
|
954
|
+
return ["node", entryFile];
|
|
955
|
+
case "bun":
|
|
956
|
+
return ["bun", entryFile];
|
|
957
|
+
case "python":
|
|
958
|
+
return ["python3", entryFile];
|
|
959
|
+
case "bash":
|
|
960
|
+
return ["bash", entryFile];
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function buildDockerArgs(input) {
|
|
964
|
+
const args = [
|
|
965
|
+
"run",
|
|
966
|
+
"--rm",
|
|
967
|
+
"--init",
|
|
968
|
+
"-v",
|
|
969
|
+
`${input.workDir}:/workspace`,
|
|
970
|
+
"-w",
|
|
971
|
+
"/workspace",
|
|
972
|
+
"--memory",
|
|
973
|
+
"512m",
|
|
974
|
+
"--cpus",
|
|
975
|
+
"1",
|
|
976
|
+
"--pids-limit",
|
|
977
|
+
"256",
|
|
978
|
+
"--read-only",
|
|
979
|
+
"-v",
|
|
980
|
+
`${input.workDir}/tmp:/tmp`
|
|
981
|
+
];
|
|
982
|
+
if (!input.hook.network) {
|
|
983
|
+
args.push("--network", "none");
|
|
984
|
+
}
|
|
985
|
+
const envVars = { ...input.env };
|
|
986
|
+
Object.assign(envVars, input.secretEnv);
|
|
987
|
+
Object.assign(envVars, buildAuthStateHookEnv(
|
|
988
|
+
input.authState,
|
|
989
|
+
AUTH_STATE_HOOK_CONTAINER_STORAGE_STATE_PATH
|
|
990
|
+
));
|
|
991
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
992
|
+
args.push("-e", `${k}=${v}`);
|
|
993
|
+
}
|
|
994
|
+
args.push(input.image);
|
|
995
|
+
return args;
|
|
996
|
+
}
|
|
997
|
+
function filterSecretVariables(variables, secretEnv) {
|
|
998
|
+
const secretValues = new Set(Object.values(secretEnv));
|
|
999
|
+
if (secretValues.size === 0) return variables;
|
|
1000
|
+
return Object.fromEntries(
|
|
1001
|
+
Object.entries(variables).filter(([_key, value]) => !secretValues.has(value))
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
function imageExists(dockerBin, image) {
|
|
1005
|
+
return new Promise((resolve3) => {
|
|
1006
|
+
execFile(dockerBin, ["image", "inspect", image], { timeout: 1e4 }, (err) => {
|
|
1007
|
+
resolve3(!err);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
function pullImage(dockerBin, image) {
|
|
1012
|
+
return new Promise((resolve3, reject) => {
|
|
1013
|
+
execFile(dockerBin, ["pull", image], { timeout: 12e4 }, (err) => {
|
|
1014
|
+
if (err) reject(new Error(`Failed to pull image ${image}: ${err.message}`));
|
|
1015
|
+
else resolve3();
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
function execDocker(dockerBin, args, timeout) {
|
|
1020
|
+
return new Promise((resolve3) => {
|
|
1021
|
+
let stdout = "";
|
|
1022
|
+
let stderr = "";
|
|
1023
|
+
let resolved = false;
|
|
1024
|
+
const _proc = execFile(dockerBin, args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, out, errOut) => {
|
|
1025
|
+
if (resolved) return;
|
|
1026
|
+
resolved = true;
|
|
1027
|
+
stdout = out ?? "";
|
|
1028
|
+
stderr = errOut ?? "";
|
|
1029
|
+
if (err && "killed" in err && err.killed) {
|
|
1030
|
+
resolve3({ stdout, stderr: `Hook timed out after ${timeout}ms`, exitCode: 124 });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const exitCode = err?.code ?? 0;
|
|
1034
|
+
resolve3({ stdout, stderr, exitCode: typeof exitCode === "number" ? exitCode : 1 });
|
|
1035
|
+
});
|
|
1036
|
+
void _proc;
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
export {
|
|
1040
|
+
ARTIFACT_FILE,
|
|
1041
|
+
BUILD_ORDINALS_FILE,
|
|
1042
|
+
BUILD_TAGS_FILE,
|
|
1043
|
+
BuildOrdinalReservationError,
|
|
1044
|
+
CACHE_SCHEMA_VERSION,
|
|
1045
|
+
DockerHookExecutor,
|
|
1046
|
+
EVENTS_FILE,
|
|
1047
|
+
FsActionCache,
|
|
1048
|
+
FsEventLog,
|
|
1049
|
+
FsEvidenceArtifactStore,
|
|
1050
|
+
FsHandoffStore,
|
|
1051
|
+
FsRunArtifactStore,
|
|
1052
|
+
FsStorageStateStore,
|
|
1053
|
+
HANDOFF_FILE,
|
|
1054
|
+
RUNS_SUBDIR,
|
|
1055
|
+
RUN_EVIDENCE_BLOBS_SUBDIR,
|
|
1056
|
+
STORAGE_STATES_SUBDIR,
|
|
1057
|
+
SharpScreenshotCompressor,
|
|
1058
|
+
alignToWindow,
|
|
1059
|
+
checkDockerAvailable,
|
|
1060
|
+
flattenOntoWhite,
|
|
1061
|
+
getImageDimensions,
|
|
1062
|
+
latestBuildOrdinal,
|
|
1063
|
+
loadCompletedRunHandoff,
|
|
1064
|
+
nextBuildOrdinal,
|
|
1065
|
+
readBuildTagMap,
|
|
1066
|
+
recordBuildTag,
|
|
1067
|
+
reserveBuildOrdinalForProducts,
|
|
1068
|
+
resolveBranch,
|
|
1069
|
+
resolveBuildTag,
|
|
1070
|
+
shouldWrite,
|
|
1071
|
+
slugBranch
|
|
1072
|
+
};
|
|
1073
|
+
//# sourceMappingURL=index.js.map
|