specrails-core 6.6.1 → 6.6.3
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/dist/agent-runtime/compact/developer.js +4 -4
- package/dist/agent-runtime/compact/environment.d.ts +23 -7
- package/dist/agent-runtime/compact/environment.js +76 -45
- package/dist/agent-runtime/engine/pieces/openspec.js +11 -1
- package/dist/agent-runtime/engine/pieces/role-turn.js +2 -2
- package/dist/agent-runtime/engine/pieces/verify.js +1 -1
- package/dist/agent-runtime/graph/archive-preflight.d.ts +32 -0
- package/dist/agent-runtime/graph/archive-preflight.js +98 -0
- package/dist/agent-runtime/graph/nodes.js +1 -1
- package/dist/agent-runtime/verification-repair.d.ts +2 -3
- package/dist/agent-runtime/verification-repair.js +10 -2
- package/package.json +1 -1
|
@@ -755,7 +755,7 @@ export async function runCompactDeveloper(env, options = {}) {
|
|
|
755
755
|
files.add(file);
|
|
756
756
|
// Manifests written by this group get their dependencies installed by the host, so verify checks the code and not the environment.
|
|
757
757
|
if (on(env, 'environment-repair') && [...groupFiles].some(file => /(?:^|[\\/])(?:package\.json|requirements\.txt|pyproject\.toml|go\.mod|Cargo\.toml)$/.test(file)))
|
|
758
|
-
for (const outcome of installEnvironment(env.toolset.roots, { onEvent: env.onEvent, lockfileRepair: on(env, 'lockfile-repair') }))
|
|
758
|
+
for (const outcome of await installEnvironment(env.toolset.roots, { onEvent: env.onEvent, lockfileRepair: on(env, 'lockfile-repair') }))
|
|
759
759
|
verification.push(outcome.detail);
|
|
760
760
|
for (const file of strings(result.tests, 100, 500))
|
|
761
761
|
tests.add(file);
|
|
@@ -765,7 +765,7 @@ export async function runCompactDeveloper(env, options = {}) {
|
|
|
765
765
|
// SAME group fix a failure while its context is fresh (one bounded round),
|
|
766
766
|
// instead of discovering five broken suites at the end of the step.
|
|
767
767
|
if (on(env, 'verify-per-group') && groupFiles.size && !correction) {
|
|
768
|
-
const check = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
768
|
+
const check = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
769
769
|
if (check.ran && !check.ok) {
|
|
770
770
|
env.onEvent?.({ kind: 'text', text: `Group ${group.index} check failed (${check.command}); fixing in place before the next group.` });
|
|
771
771
|
const fixed = await fixGroupInPlace(env, group, check.output, budget, evidence, extraTools, languages);
|
|
@@ -773,7 +773,7 @@ export async function runCompactDeveloper(env, options = {}) {
|
|
|
773
773
|
files.add(file);
|
|
774
774
|
groupFiles.add(file);
|
|
775
775
|
}
|
|
776
|
-
const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
776
|
+
const again = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
777
777
|
env.onEvent?.({ kind: 'text', text: again.ok ? `Group ${group.index} check passed after the fix.` : `Group ${group.index} check still failing (${again.command}); the host verify will report it.` });
|
|
778
778
|
verification.push(`group ${group.index} check ${again.ok ? 'passed' : 'failed'}: ${again.command ?? ''}`);
|
|
779
779
|
}
|
|
@@ -793,7 +793,7 @@ export async function runCompactDeveloper(env, options = {}) {
|
|
|
793
793
|
files.add(file);
|
|
794
794
|
groupFiles.add(file);
|
|
795
795
|
}
|
|
796
|
-
const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
796
|
+
const again = await runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
797
797
|
const still = unreachedGroupTests(env.toolset.roots, unreached);
|
|
798
798
|
env.onEvent?.({ kind: 'text', text: still.length ? `Group ${group.index}: ${still.join(', ')} still not run by the test command; the host verify will report it.` : again.ok ? `Group ${group.index}: tests wired in and the check passed.` : `Group ${group.index}: tests wired in but the check now fails (${again.command}); the host verify will report it.` });
|
|
799
799
|
verification.push(`group ${group.index} test reachability ${still.length ? `unresolved: ${still.join(', ')}` : 'ok'}${again.ran && !again.ok ? `; check failed: ${again.command ?? ''}` : ''}`);
|
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from 'node:child_process';
|
|
2
|
+
/** The parts of a finished host command the environment logic reads. */
|
|
3
|
+
export interface HostRun {
|
|
4
|
+
status: number | null;
|
|
5
|
+
signal: NodeJS.Signals | null;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
error?: Error;
|
|
9
|
+
}
|
|
10
|
+
/** Runs one host command. Injectable for tests; a synchronous result is accepted too. */
|
|
11
|
+
export type HostSpawn = (command: string, args: readonly string[], options: SpawnSyncOptionsWithStringEncoding) => HostRun | SpawnSyncReturns<string> | Promise<HostRun>;
|
|
12
|
+
/**
|
|
13
|
+
* Async counterpart of `spawnSync` with the same result shape: `status` null
|
|
14
|
+
* and an `ETIMEDOUT` error after `timeout` (the child is sent `killSignal`),
|
|
15
|
+
* stdout/stderr kept as bounded tails instead of failing with ENOBUFS.
|
|
16
|
+
*/
|
|
17
|
+
export declare const hostSpawn: HostSpawn;
|
|
2
18
|
/** `timeoutMs` overrides the default bound for one plan (a browser download is far larger than a dependency install). `drift`: installed direct dependencies the install must bring back inside their declared ranges. */
|
|
3
19
|
export interface EnvironmentInstall {
|
|
4
20
|
ecosystem: 'node' | 'python' | 'go' | 'rust';
|
|
@@ -66,7 +82,7 @@ export declare function describeDrift(drift: readonly DriftedDependency[]): stri
|
|
|
66
82
|
export declare function plannedInstalls(root: string, failureOutput?: string): EnvironmentInstall[];
|
|
67
83
|
/** Runs each planned install once, bounded; never throws. */
|
|
68
84
|
export interface InstallIo {
|
|
69
|
-
spawn?:
|
|
85
|
+
spawn?: HostSpawn;
|
|
70
86
|
timeoutMs?: number;
|
|
71
87
|
failureOutput?: string;
|
|
72
88
|
lockfileRepair?: boolean;
|
|
@@ -79,7 +95,7 @@ export interface InstallIo {
|
|
|
79
95
|
/** The installs for one root; the failure-driven plan by default. */
|
|
80
96
|
plan?: (root: string) => EnvironmentInstall[];
|
|
81
97
|
}
|
|
82
|
-
export declare function installEnvironment(roots: readonly string[], io?: InstallIo): InstallOutcome[]
|
|
98
|
+
export declare function installEnvironment(roots: readonly string[], io?: InstallIo): Promise<InstallOutcome[]>;
|
|
83
99
|
type PlaywrightBrowser = 'chromium' | 'firefox' | 'webkit';
|
|
84
100
|
/** Browsers the repository's Playwright config runs (by project names, devices or browserName); chromium when it names none. */
|
|
85
101
|
export declare function configuredPlaywrightBrowsers(root: string): PlaywrightBrowser[];
|
|
@@ -89,7 +105,7 @@ export declare function configuredPlaywrightBrowsers(root: string): PlaywrightBr
|
|
|
89
105
|
* locations without downloading; an unreadable answer means "unknown", never
|
|
90
106
|
* an install.
|
|
91
107
|
*/
|
|
92
|
-
export declare function missingPlaywrightBrowsers(root: string, spawn?:
|
|
108
|
+
export declare function missingPlaywrightBrowsers(root: string, spawn?: HostSpawn): Promise<PlaywrightBrowser[]>;
|
|
93
109
|
/**
|
|
94
110
|
* Host preparation before an agent edits a repository: the dependencies its
|
|
95
111
|
* manifest declares but are not installed, then the browser builds its
|
|
@@ -98,7 +114,7 @@ export declare function missingPlaywrightBrowsers(root: string, spawn?: typeof s
|
|
|
98
114
|
* idempotent, project-local work happens here; other ecosystems keep their
|
|
99
115
|
* failure-driven repair at verification.
|
|
100
116
|
*/
|
|
101
|
-
export declare function prepareEnvironment(roots: readonly string[], io?: Omit<InstallIo, 'plan' | 'failureOutput'>): InstallOutcome[]
|
|
117
|
+
export declare function prepareEnvironment(roots: readonly string[], io?: Omit<InstallIo, 'plan' | 'failureOutput'>): Promise<InstallOutcome[]>;
|
|
102
118
|
/**
|
|
103
119
|
* A Node preload that answers IPv6 lookups with nothing when the host has an
|
|
104
120
|
* IPv4 address, so dual-stack clients connect over IPv4. Written once to the
|
|
@@ -124,7 +140,7 @@ interface GroupCheckOutcome {
|
|
|
124
140
|
* still fresh instead of surfacing as five failed suites at the end.
|
|
125
141
|
*/
|
|
126
142
|
export declare function runGroupCheck(roots: readonly string[], io?: {
|
|
127
|
-
spawn?:
|
|
143
|
+
spawn?: HostSpawn;
|
|
128
144
|
timeoutMs?: number;
|
|
129
145
|
onEvent?: (event: {
|
|
130
146
|
kind: 'tool-start' | 'tool-end' | 'text';
|
|
@@ -132,5 +148,5 @@ export declare function runGroupCheck(roots: readonly string[], io?: {
|
|
|
132
148
|
detail?: string;
|
|
133
149
|
text?: string;
|
|
134
150
|
}) => void;
|
|
135
|
-
}): GroupCheckOutcome
|
|
151
|
+
}): Promise<GroupCheckOutcome>;
|
|
136
152
|
export {};
|
|
@@ -3,10 +3,62 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import semver from 'semver';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/** Bytes of each stream retained (the tail): installs can print far more than any caller reads. */
|
|
7
|
+
const OUTPUT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Async counterpart of `spawnSync` with the same result shape: `status` null
|
|
10
|
+
* and an `ETIMEDOUT` error after `timeout` (the child is sent `killSignal`),
|
|
11
|
+
* stdout/stderr kept as bounded tails instead of failing with ENOBUFS.
|
|
12
|
+
*/
|
|
13
|
+
export const hostSpawn = (command, args, options) => new Promise(resolve => {
|
|
14
|
+
let settled = false, timedOut = false, stdout = '', stderr = '';
|
|
15
|
+
const limit = options.maxBuffer ?? OUTPUT_TAIL_BYTES;
|
|
16
|
+
const finish = (result) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
if (timer)
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
resolve(result);
|
|
23
|
+
};
|
|
24
|
+
let child;
|
|
25
|
+
try {
|
|
26
|
+
child = crossSpawn(command, [...args], { cwd: options.cwd, env: options.env, windowsHide: options.windowsHide ?? true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
resolve({ status: null, signal: null, stdout: '', stderr: '', error: error });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const timer = options.timeout ? setTimeout(() => {
|
|
33
|
+
timedOut = true;
|
|
34
|
+
try {
|
|
35
|
+
child.kill(options.killSignal ?? 'SIGTERM');
|
|
36
|
+
}
|
|
37
|
+
catch { /* already gone */ }
|
|
38
|
+
}, options.timeout) : undefined;
|
|
39
|
+
const tail = (current, chunk) => {
|
|
40
|
+
const next = current + chunk;
|
|
41
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
42
|
+
};
|
|
43
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk) => { stdout = tail(stdout, chunk); });
|
|
44
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk) => { stderr = tail(stderr, chunk); });
|
|
45
|
+
child.on('error', error => finish({ status: null, signal: null, stdout, stderr, error }));
|
|
46
|
+
child.on('close', (code, signal) => finish({
|
|
47
|
+
status: timedOut ? null : code, signal, stdout, stderr,
|
|
48
|
+
...(timedOut ? { error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) } : {}),
|
|
49
|
+
}));
|
|
50
|
+
});
|
|
51
|
+
const defaultSpawn = hostSpawn;
|
|
52
|
+
const failedRun = (error) => ({ status: null, signal: null, stdout: '', stderr: '', error: error });
|
|
53
|
+
async function runHost(spawn, command, args, options) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await spawn(command, args, options);
|
|
56
|
+
return { status: result.status, signal: result.signal, stdout: String(result.stdout ?? ''), stderr: String(result.stderr ?? ''), ...(result.error ? { error: result.error } : {}) };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return failedRun(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
10
62
|
// A Playwright test run on a host whose browser cache was never populated (a
|
|
11
63
|
// fresh machine, a new Playwright version): the package is installed, the
|
|
12
64
|
// browser build it pins is not. `playwright install` is the documented fix.
|
|
@@ -263,21 +315,14 @@ function relaxManifestPin(root, name, version) {
|
|
|
263
315
|
writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n');
|
|
264
316
|
return changed;
|
|
265
317
|
}
|
|
266
|
-
export function installEnvironment(roots, io = {}) {
|
|
318
|
+
export async function installEnvironment(roots, io = {}) {
|
|
267
319
|
const spawn = io.spawn ?? defaultSpawn;
|
|
268
320
|
const outcomes = [];
|
|
269
321
|
for (const root of roots) {
|
|
270
322
|
for (const plan of io.plan ? io.plan(root) : plannedInstalls(root, io.failureOutput ?? '')) {
|
|
271
323
|
io.onEvent?.({ kind: 'tool-start', tool: plan.command, detail: `${plan.args.join(' ')} (${path.basename(root)})` });
|
|
272
|
-
const run = () => {
|
|
273
|
-
|
|
274
|
-
return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
return { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
let result = run();
|
|
324
|
+
const run = () => runHost(spawn, plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
|
|
325
|
+
let result = await run();
|
|
281
326
|
// A model that writes package-lock.json by hand invents integrity hashes
|
|
282
327
|
// (npm EINTEGRITY) or pins versions that never existed (ETARGET/E404 on a
|
|
283
328
|
// locked entry). The lock is not candidate content: drop it and let npm
|
|
@@ -289,7 +334,7 @@ export function installEnvironment(roots, io = {}) {
|
|
|
289
334
|
}
|
|
290
335
|
catch { /* the retry below then repeats the failure honestly */ }
|
|
291
336
|
io.onEvent?.({ kind: 'text', text: `Environment: ${path.basename(lock)} did not match the registry; removed it and retrying ${plan.command} ${plan.args.join(' ')}` });
|
|
292
|
-
result = run();
|
|
337
|
+
result = await run();
|
|
293
338
|
}
|
|
294
339
|
// A pin the registry has never published ("No matching version found for
|
|
295
340
|
// @babel/preset-env@7.23.0"): the model invented the version. Relax that
|
|
@@ -297,7 +342,7 @@ export function installEnvironment(roots, io = {}) {
|
|
|
297
342
|
const unpublished = plan.ecosystem === 'node' && result.status !== 0 ? /No matching version found for ((?:@[\w.-]+\/)?[\w.-]+)@([^\s.]+(?:\.[^\s.]+)*)\.?(?:\n|$)/.exec(String(result.stderr)) : null;
|
|
298
343
|
if (unpublished && relaxManifestPin(root, unpublished[1], unpublished[2])) {
|
|
299
344
|
io.onEvent?.({ kind: 'text', text: `Environment: ${unpublished[1]}@${unpublished[2]} is not published; relaxed the pin in package.json and retrying ${plan.command} ${plan.args.join(' ')}` });
|
|
300
|
-
result = run();
|
|
345
|
+
result = await run();
|
|
301
346
|
}
|
|
302
347
|
// Playwright's downloader tries IPv6 first; on a host whose DNS returns
|
|
303
348
|
// an AAAA record without an IPv6 route, Node's family auto-selection
|
|
@@ -306,12 +351,7 @@ export function installEnvironment(roots, io = {}) {
|
|
|
306
351
|
const preload = ipv4FirstPreload();
|
|
307
352
|
if (preload) {
|
|
308
353
|
io.onEvent?.({ kind: 'text', text: `Environment: the browser download timed out; retrying ${plan.command} ${plan.args.join(' ')} over IPv4` });
|
|
309
|
-
|
|
310
|
-
result = spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`.trim() } });
|
|
311
|
-
}
|
|
312
|
-
catch (error) {
|
|
313
|
-
result = { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
|
|
314
|
-
}
|
|
354
|
+
result = await runHost(spawn, plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`.trim() } });
|
|
315
355
|
}
|
|
316
356
|
}
|
|
317
357
|
io.onEvent?.({ kind: 'tool-end', tool: plan.command });
|
|
@@ -365,20 +405,14 @@ function playwrightCli(root) {
|
|
|
365
405
|
* locations without downloading; an unreadable answer means "unknown", never
|
|
366
406
|
* an install.
|
|
367
407
|
*/
|
|
368
|
-
export function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
|
|
408
|
+
export async function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
|
|
369
409
|
if (!declaresPlaywright(root))
|
|
370
410
|
return [];
|
|
371
411
|
const cli = playwrightCli(root);
|
|
372
412
|
if (!cli)
|
|
373
413
|
return [];
|
|
374
414
|
const browsers = configuredPlaywrightBrowsers(root);
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
result = spawn(cli.command, [...cli.args, 'install', '--dry-run', ...browsers], { cwd: root, encoding: 'utf8', timeout: 30_000, windowsHide: true, env: process.env });
|
|
378
|
-
}
|
|
379
|
-
catch {
|
|
380
|
-
return [];
|
|
381
|
-
}
|
|
415
|
+
const result = await runHost(spawn, cli.command, [...cli.args, 'install', '--dry-run', ...browsers], { cwd: root, encoding: 'utf8', timeout: 30_000, windowsHide: true, env: process.env });
|
|
382
416
|
if (result.status !== 0)
|
|
383
417
|
return [];
|
|
384
418
|
const locations = [...String(result.stdout ?? '').matchAll(/Install location:\s+(.+?)\s*$/gm)].map(match => match[1]);
|
|
@@ -392,14 +426,17 @@ export function missingPlaywrightBrowsers(root, spawn = defaultSpawn) {
|
|
|
392
426
|
* idempotent, project-local work happens here; other ecosystems keep their
|
|
393
427
|
* failure-driven repair at verification.
|
|
394
428
|
*/
|
|
395
|
-
export function prepareEnvironment(roots, io = {}) {
|
|
429
|
+
export async function prepareEnvironment(roots, io = {}) {
|
|
396
430
|
const spawn = io.spawn ?? defaultSpawn;
|
|
397
|
-
const dependencies = installEnvironment(roots, { ...io, plan: root => plannedInstalls(root).filter(plan => plan.ecosystem === 'node') });
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
431
|
+
const dependencies = await installEnvironment(roots, { ...io, plan: root => plannedInstalls(root).filter(plan => plan.ecosystem === 'node') });
|
|
432
|
+
// The browser plan needs the async dry-run, so it is resolved per root first.
|
|
433
|
+
const browserPlans = new Map();
|
|
434
|
+
for (const root of roots) {
|
|
435
|
+
const missing = await missingPlaywrightBrowsers(root, spawn);
|
|
436
|
+
const cli = missing.length ? playwrightCli(root) : undefined;
|
|
437
|
+
browserPlans.set(root, cli ? [{ ecosystem: 'node', root, command: cli.command, args: [...cli.args, 'install', ...missing], timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${missing.join(', ')}` }] : []);
|
|
438
|
+
}
|
|
439
|
+
const browsers = await installEnvironment(roots, { ...io, plan: root => browserPlans.get(root) ?? [] });
|
|
403
440
|
return [...dependencies, ...browsers];
|
|
404
441
|
}
|
|
405
442
|
const NETWORK_TIMEOUT = /timed out after \d+ ?ms|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i;
|
|
@@ -460,7 +497,7 @@ export function detectCheckCommand(root) {
|
|
|
460
497
|
* groups (`verify-per-group`) so a broken group is fixed while its context is
|
|
461
498
|
* still fresh instead of surfacing as five failed suites at the end.
|
|
462
499
|
*/
|
|
463
|
-
export function runGroupCheck(roots, io = {}) {
|
|
500
|
+
export async function runGroupCheck(roots, io = {}) {
|
|
464
501
|
const spawn = io.spawn ?? defaultSpawn;
|
|
465
502
|
for (const root of roots) {
|
|
466
503
|
const check = detectCheckCommand(root);
|
|
@@ -468,13 +505,7 @@ export function runGroupCheck(roots, io = {}) {
|
|
|
468
505
|
continue;
|
|
469
506
|
const label = `${check.command} ${check.args.join(' ')}`;
|
|
470
507
|
io.onEvent?.({ kind: 'tool-start', tool: check.command, detail: `${check.args.join(' ')} (${path.basename(root)}, group check)` });
|
|
471
|
-
|
|
472
|
-
try {
|
|
473
|
-
result = spawn(check.command, check.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 3 * 60_000, killSignal: 'SIGKILL', windowsHide: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0' }, maxBuffer: 8 * 1024 * 1024 });
|
|
474
|
-
}
|
|
475
|
-
catch (error) {
|
|
476
|
-
result = { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
|
|
477
|
-
}
|
|
508
|
+
const result = await runHost(spawn, check.command, check.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 3 * 60_000, killSignal: 'SIGKILL', windowsHide: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0' }, maxBuffer: 8 * 1024 * 1024 });
|
|
478
509
|
io.onEvent?.({ kind: 'tool-end', tool: check.command });
|
|
479
510
|
const timedOut = result.error?.message?.includes('ETIMEDOUT') || result.signal === 'SIGKILL';
|
|
480
511
|
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.replace(/\u001b\[[0-9;]*m/g, '').trim().slice(-6000);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { archivedOpenSpecChange, archiveOpenSpecChange } from '../../graph/artifacts.js';
|
|
2
|
+
import { describeArchivePreflight, previewOpenSpecArchive } from '../../graph/archive-preflight.js';
|
|
2
3
|
import { OpenSpecCommandError, resolveOpenSpecCli, runOpenSpec } from '../../openspec.js';
|
|
3
4
|
import { EngineError } from '../contracts.js';
|
|
4
5
|
import { boundedText, json, paramsSchema, text } from './shared.js';
|
|
@@ -39,7 +40,16 @@ export function openSpecPieces(bindings) {
|
|
|
39
40
|
const item = report.items?.find(entry => entry.id === change && entry.type === 'change');
|
|
40
41
|
if (!item || typeof item.valid !== 'boolean')
|
|
41
42
|
throw new EngineError('openspec_invalid_output', 'OpenSpec validation did not report the requested change');
|
|
42
|
-
|
|
43
|
+
// The planning role only learns why it was sent back through history.
|
|
44
|
+
const note = (suffix, value) => [{ id: context.frame.attemptId + ':' + suffix, transition: context.frame.transition, attempt: context.frame.attempt, ordinal: 0, nodePath: context.frame.nodePath, text: boundedText(value) }];
|
|
45
|
+
if (!exited || !item.valid) {
|
|
46
|
+
const issues = (item.issues ?? []).filter(issue => issue.level === 'ERROR').slice(0, 20).map(issue => `- ${issue.path ? issue.path + ': ' : ''}${issue.message ?? 'invalid'}`);
|
|
47
|
+
return { outcome: 'fail', output: json(report), history: note('validate', [`OpenSpec validation failed for change ${change}.`, ...issues].join('\n')) };
|
|
48
|
+
}
|
|
49
|
+
const preflight = await previewOpenSpecArchive(deps.context.artifactRoot, change, context.signal);
|
|
50
|
+
if (!preflight.valid)
|
|
51
|
+
return { outcome: 'fail', output: json({ ...report, archivePreflight: preflight }), history: note('archive-preflight', describeArchivePreflight(change, preflight)) };
|
|
52
|
+
return { outcome: 'pass', output: json(report) };
|
|
43
53
|
},
|
|
44
54
|
}, {
|
|
45
55
|
descriptor: { kind: 'openspec-archive', paramsSchema: { ...schema, properties: { ...schema.properties, requiresVerified: { type: 'boolean' }, reviewedCandidate: { type: 'string' }, approvedCandidate: { type: 'string' } } }, outcomes: ['next', 'failed'], effect: 'write', requiresAI: false },
|
|
@@ -35,7 +35,7 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
35
35
|
const note = (line) => context.progress({ type: 'verification-output', payload: { text: line } });
|
|
36
36
|
const environment = options.environmentRetry
|
|
37
37
|
? environmentNote(options.environmentRetry.installs, 'retry')
|
|
38
|
-
: hostRepairs ? environmentNote(prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
|
|
38
|
+
: hostRepairs ? environmentNote(await prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
|
|
39
39
|
const schema = params.structuredOutput;
|
|
40
40
|
const validate = schema ? ajv.compile(schema) : undefined;
|
|
41
41
|
const roleState = deps.roleState(context);
|
|
@@ -79,7 +79,7 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
79
79
|
if (blocker) {
|
|
80
80
|
const roots = deps.context.repositories.map(repository => repository.path);
|
|
81
81
|
const io = environmentIo(deps, note);
|
|
82
|
-
const installs = [...installEnvironment(roots, { ...io, failureOutput: blocker }), ...prepareEnvironment(roots, io)];
|
|
82
|
+
const installs = [...await installEnvironment(roots, { ...io, failureOutput: blocker }), ...await prepareEnvironment(roots, io)];
|
|
83
83
|
if (installs.some(install => install.ok)) {
|
|
84
84
|
note('[environment] The agent reported a missing dependency or tool; the host installed it and runs the turn again.');
|
|
85
85
|
return executeRoleTurn(deps, params, context, { ...options, environmentRetry: { installs } });
|
|
@@ -177,7 +177,7 @@ export function verifyPiece(bindings) {
|
|
|
177
177
|
const drift = environment.drift.flatMap(item => item.drift.map(entry => ({ root: checkoutRelative(deps.context, item.root), ...entry }))).slice(0, 20);
|
|
178
178
|
if (drift.length)
|
|
179
179
|
progress(`[environment] Installed dependencies do not satisfy their declared ranges (${describeDrift(drift)}); the host reinstalls them before judging the failure.`);
|
|
180
|
-
const repair = repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
|
|
180
|
+
const repair = await repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
|
|
181
181
|
environmentRepair = { attempted: true, reverified: false, installs: repair.installs.map(install => ({ command: install.command, args: install.args, root: checkoutRelative(deps.context, install.root), ok: install.ok, detail: boundedText(install.detail, 1_000) })), ...(drift.length ? { drift } : {}) };
|
|
182
182
|
if (repair.refused)
|
|
183
183
|
blocker = repair.refused;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ArchiveRequirementFinding {
|
|
2
|
+
path: string;
|
|
3
|
+
line: number;
|
|
4
|
+
requirement: string;
|
|
5
|
+
}
|
|
6
|
+
export type ArchivePreflight = {
|
|
7
|
+
valid: true;
|
|
8
|
+
} | {
|
|
9
|
+
valid: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
findings: ArchiveRequirementFinding[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Requirements OpenSpec rejects when it rebuilds a main spec on archive: its spec parser reads only
|
|
15
|
+
* the first non-empty body line before any heading and needs SHALL or MUST there. Delta validation
|
|
16
|
+
* skips `**Key**:` metadata lines, so a delta (or a pre-existing main-spec requirement) can pass
|
|
17
|
+
* `openspec validate` and still abort the archive.
|
|
18
|
+
*/
|
|
19
|
+
export declare function requirementsWithoutKeyword(markdown: string): Array<{
|
|
20
|
+
line: number;
|
|
21
|
+
requirement: string;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Dry-runs `openspec archive` on a disposable copy of `openspec/`, so a change that validates but
|
|
25
|
+
* cannot be archived is caught while its author can still edit the plan. Nothing in the
|
|
26
|
+
* artifact root is written.
|
|
27
|
+
*/
|
|
28
|
+
export declare function previewOpenSpecArchive(artifactRoot: string, change: string, signal?: AbortSignal): Promise<ArchivePreflight>;
|
|
29
|
+
/** Plain-text guidance for the planning role's history. */
|
|
30
|
+
export declare function describeArchivePreflight(change: string, preflight: Extract<ArchivePreflight, {
|
|
31
|
+
valid: false;
|
|
32
|
+
}>): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { artifactPath, OpenSpecCommandError, resolveOpenSpecCli, runOpenSpec } from '../openspec.js';
|
|
5
|
+
const ANSI = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
|
|
6
|
+
/**
|
|
7
|
+
* Requirements OpenSpec rejects when it rebuilds a main spec on archive: its spec parser reads only
|
|
8
|
+
* the first non-empty body line before any heading and needs SHALL or MUST there. Delta validation
|
|
9
|
+
* skips `**Key**:` metadata lines, so a delta (or a pre-existing main-spec requirement) can pass
|
|
10
|
+
* `openspec validate` and still abort the archive.
|
|
11
|
+
*/
|
|
12
|
+
export function requirementsWithoutKeyword(markdown) {
|
|
13
|
+
const lines = markdown.split('\n'), findings = [];
|
|
14
|
+
for (let index = 0; index < lines.length; index++) {
|
|
15
|
+
const header = lines[index].match(/^###\s+Requirement:\s*(.*)$/);
|
|
16
|
+
if (!header)
|
|
17
|
+
continue;
|
|
18
|
+
let body;
|
|
19
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
20
|
+
const text = lines[next].trim();
|
|
21
|
+
if (text.startsWith('#'))
|
|
22
|
+
break;
|
|
23
|
+
if (text) {
|
|
24
|
+
body = next;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const text = body === undefined ? header[1] : lines[body];
|
|
29
|
+
if (!text.includes('SHALL') && !text.includes('MUST'))
|
|
30
|
+
findings.push({ line: (body ?? index) + 1, requirement: header[1].trim() });
|
|
31
|
+
}
|
|
32
|
+
return findings;
|
|
33
|
+
}
|
|
34
|
+
function assertNoSymlinks(root) {
|
|
35
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
36
|
+
if (entry.isSymbolicLink())
|
|
37
|
+
throw new Error('OpenSpec archive refuses symlink trees');
|
|
38
|
+
if (entry.isDirectory())
|
|
39
|
+
assertNoSymlinks(path.join(root, entry.name));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function capabilityFindings(artifactRoot, change) {
|
|
43
|
+
const deltas = path.join(artifactRoot, 'openspec/changes', change, 'specs');
|
|
44
|
+
if (!existsSync(deltas))
|
|
45
|
+
return [];
|
|
46
|
+
const findings = [];
|
|
47
|
+
for (const capability of readdirSync(deltas).sort()) {
|
|
48
|
+
for (const relative of [`openspec/changes/${change}/specs/${capability}/spec.md`, `openspec/specs/${capability}/spec.md`]) {
|
|
49
|
+
const file = path.join(artifactRoot, relative);
|
|
50
|
+
if (!existsSync(file) || !lstatSync(file).isFile())
|
|
51
|
+
continue;
|
|
52
|
+
for (const finding of requirementsWithoutKeyword(readFileSync(file, 'utf8')))
|
|
53
|
+
findings.push({ path: relative, ...finding });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Dry-runs `openspec archive` on a disposable copy of `openspec/`, so a change that validates but
|
|
60
|
+
* cannot be archived is caught while its author can still edit the plan. Nothing in the
|
|
61
|
+
* artifact root is written.
|
|
62
|
+
*/
|
|
63
|
+
export async function previewOpenSpecArchive(artifactRoot, change, signal) {
|
|
64
|
+
const source = artifactPath(artifactRoot, 'openspec');
|
|
65
|
+
assertNoSymlinks(source);
|
|
66
|
+
const staging = mkdtempSync(path.join(os.tmpdir(), 'specrails-archive-preflight-'));
|
|
67
|
+
try {
|
|
68
|
+
cpSync(source, path.join(staging, 'openspec'), { recursive: true });
|
|
69
|
+
let output;
|
|
70
|
+
try {
|
|
71
|
+
output = await runOpenSpec(resolveOpenSpecCli(), staging, ['archive', change, '--yes'], signal);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!(error instanceof OpenSpecCommandError) || error.exitCode === undefined || error.exitCode < 1)
|
|
75
|
+
throw error;
|
|
76
|
+
output = `${error.stdout}\n${error.message}`;
|
|
77
|
+
}
|
|
78
|
+
const archiveRoot = path.join(staging, 'openspec/changes/archive');
|
|
79
|
+
const archived = existsSync(archiveRoot) && readdirSync(archiveRoot).some(name => name.endsWith('-' + change));
|
|
80
|
+
if (archived && !existsSync(path.join(staging, 'openspec/changes', change)))
|
|
81
|
+
return { valid: true };
|
|
82
|
+
const reason = output.replace(ANSI, '').split('\n').map(line => line.trimEnd()).filter(Boolean).join('\n').slice(-2000);
|
|
83
|
+
return { valid: false, reason, findings: capabilityFindings(artifactRoot, change) };
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
rmSync(staging, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Plain-text guidance for the planning role's history. */
|
|
90
|
+
export function describeArchivePreflight(change, preflight) {
|
|
91
|
+
const located = preflight.findings.map(finding => `- ${finding.path}:${finding.line} "${finding.requirement}"`).join('\n');
|
|
92
|
+
return [
|
|
93
|
+
`OpenSpec validates change ${change}, but archiving it would fail. OpenSpec's archive output:`,
|
|
94
|
+
preflight.reason,
|
|
95
|
+
...(located ? ['Requirements whose first body line has no SHALL or MUST (OpenSpec reads only that line when it rebuilds a main spec):', located,
|
|
96
|
+
'Put the SHALL/MUST statement on the line right after "### Requirement: …". For a requirement that only exists in openspec/specs, restate it under "## MODIFIED Requirements" in this change\'s delta with the corrected first line.'] : []),
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
@@ -356,7 +356,7 @@ function verifyNode(deps) {
|
|
|
356
356
|
const drift = environment.drift.flatMap(item => item.drift);
|
|
357
357
|
if (drift.length)
|
|
358
358
|
note('developer', `Installed dependencies do not satisfy their declared ranges (${describeDrift(drift)}); the host reinstalls them before judging the failure.`);
|
|
359
|
-
const repair = repairEnvironment(context, receipt, config.guardrails, text => note('developer', text));
|
|
359
|
+
const repair = await repairEnvironment(context, receipt, config.guardrails, text => note('developer', text));
|
|
360
360
|
if (repair.refused)
|
|
361
361
|
return hostBlocked(blockerMessage(repair.refused));
|
|
362
362
|
if (repair.installs.some(outcome => outcome.ok)) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { spawnSync } from 'node:child_process';
|
|
2
1
|
import { type CommandReceipt, type PipelineContext, type VerificationReceipt, type VerificationSelfMutation } from '../pipeline/pipeline-state.js';
|
|
3
|
-
import { type DriftedDependency, type InstallOutcome } from './compact/environment.js';
|
|
2
|
+
import { type DriftedDependency, type HostSpawn, type InstallOutcome } from './compact/environment.js';
|
|
4
3
|
import type { HostBlocker } from './engine/contracts.js';
|
|
5
4
|
import { type GuardrailSettings } from './guardrails.js';
|
|
6
5
|
export type { HostBlocker } from './engine/contracts.js';
|
|
@@ -50,6 +49,6 @@ export interface EnvironmentRepair {
|
|
|
50
49
|
* install (app)`, `Environment: installed …`). An install the host cannot
|
|
51
50
|
* complete for a precondition reason becomes `refused`.
|
|
52
51
|
*/
|
|
53
|
-
export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?:
|
|
52
|
+
export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?: HostSpawn): Promise<EnvironmentRepair>;
|
|
54
53
|
/** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
|
|
55
54
|
export declare function boundedBlocker(value: unknown): HostBlocker | undefined;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// Host-owned repair of a failed verification, shared by the legacy graph
|
|
2
|
+
// (`graph/nodes.ts`) and the engine v2 `verify` piece.
|
|
3
|
+
//
|
|
4
|
+
// A failed check is not always a defect in the change: a fresh worktree has no
|
|
5
|
+
// node_modules, a new machine has no Playwright browser, a registry token has
|
|
6
|
+
// expired. The host classifies the failure first (a precondition it cannot
|
|
7
|
+
// satisfy becomes a structured blocker), installs what it can once, and only a
|
|
8
|
+
// failure that survives that becomes correction feedback.
|
|
1
9
|
import { existsSync } from 'node:fs';
|
|
2
10
|
import path from 'node:path';
|
|
3
11
|
import { describeModifiedFiles } from '../pipeline/pipeline-state.js';
|
|
@@ -112,10 +120,10 @@ export function adoptedOutputNote(mutation) {
|
|
|
112
120
|
* install (app)`, `Environment: installed …`). An install the host cannot
|
|
113
121
|
* complete for a precondition reason becomes `refused`.
|
|
114
122
|
*/
|
|
115
|
-
export function repairEnvironment(context, receipt, guardrails, note, spawn) {
|
|
123
|
+
export async function repairEnvironment(context, receipt, guardrails, note, spawn) {
|
|
116
124
|
const failed = receipt.commands.filter(command => command.exitCode !== 0);
|
|
117
125
|
const roots = [...new Set([...installRoots(context, failed), ...driftedFailureRoots(context, receipt).map(item => item.root)])];
|
|
118
|
-
const installs = installEnvironment(roots, { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
|
|
126
|
+
const installs = await installEnvironment(roots, { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
|
|
119
127
|
...(spawn ? { spawn } : {}), onEvent: event => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
|
|
120
128
|
const refused = installs.find(outcome => outcome.precondition);
|
|
121
129
|
if (!refused)
|
package/package.json
CHANGED