specrails-core 6.3.0 → 6.5.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/dist/agent-runtime/capabilities.js +22 -5
- package/dist/agent-runtime/cli-executor.js +8 -3
- package/dist/agent-runtime/cli.d.ts +1 -1
- package/dist/agent-runtime/cli.js +50 -3
- package/dist/agent-runtime/compact/environment.d.ts +49 -4
- package/dist/agent-runtime/compact/environment.js +191 -8
- package/dist/agent-runtime/config.js +68 -53
- package/dist/agent-runtime/engine/compiler.js +10 -2
- package/dist/agent-runtime/engine/contracts.d.ts +16 -0
- package/dist/agent-runtime/engine/pieces/control.js +11 -2
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +69 -3
- package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
- package/dist/agent-runtime/engine/pieces/verify.js +105 -30
- package/dist/agent-runtime/executor-types.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +9 -55
- package/dist/agent-runtime/prompts.d.ts +1 -1
- package/dist/agent-runtime/prompts.js +18 -12
- package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
- package/dist/agent-runtime/session/application/active-session.js +701 -0
- package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
- package/dist/agent-runtime/session/application/delegation.js +171 -0
- package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
- package/dist/agent-runtime/session/application/output-buffer.js +65 -0
- package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
- package/dist/agent-runtime/session/application/session-service.js +256 -0
- package/dist/agent-runtime/session/application/timers.d.ts +15 -0
- package/dist/agent-runtime/session/application/timers.js +35 -0
- package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
- package/dist/agent-runtime/session/domain/descriptor.js +32 -0
- package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
- package/dist/agent-runtime/session/domain/errors.js +16 -0
- package/dist/agent-runtime/session/domain/events.d.ts +147 -0
- package/dist/agent-runtime/session/domain/events.js +24 -0
- package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
- package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
- package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
- package/dist/agent-runtime/session/domain/interruption.js +39 -0
- package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
- package/dist/agent-runtime/session/domain/policy.js +104 -0
- package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
- package/dist/agent-runtime/session/domain/snapshot.js +234 -0
- package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
- package/dist/agent-runtime/session/domain/state-machines.js +41 -0
- package/dist/agent-runtime/session/domain/types.d.ts +130 -0
- package/dist/agent-runtime/session/domain/types.js +15 -0
- package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
- package/dist/agent-runtime/session/domain/usage.js +86 -0
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
- package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
- package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
- package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
- package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
- package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
- package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
- package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
- package/dist/agent-runtime/session/drivers/common/process.js +116 -0
- package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
- package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
- package/dist/agent-runtime/session/drivers/registry.js +38 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
- package/dist/agent-runtime/session/host/protocol.js +88 -0
- package/dist/agent-runtime/session/host/server.d.ts +57 -0
- package/dist/agent-runtime/session/host/server.js +229 -0
- package/dist/agent-runtime/session/index.d.ts +45 -0
- package/dist/agent-runtime/session/index.js +74 -0
- package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
- package/dist/agent-runtime/session/journal/schema.js +44 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
- package/dist/agent-runtime/session/ports.d.ts +192 -0
- package/dist/agent-runtime/session/ports.js +1 -0
- package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
- package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
- package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
- package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
- package/dist/agent-runtime/session/testing/index.d.ts +9 -0
- package/dist/agent-runtime/session/testing/index.js +9 -0
- package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
- package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
- package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
- package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
- package/dist/agent-runtime/verification-proposals.d.ts +9 -0
- package/dist/agent-runtime/verification-proposals.js +69 -0
- package/dist/agent-runtime/verification-repair.d.ts +26 -0
- package/dist/agent-runtime/verification-repair.js +88 -0
- package/dist/installer/util/registry.js +3 -3
- package/dist/shared/specrails-home.d.ts +21 -0
- package/dist/shared/specrails-home.js +36 -0
- package/integration-contract.json +43 -2
- package/package.json +11 -1
- package/schemas/agent-runtime.schema.json +9 -7
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ProcessSpawner, ProcessSpec } from '../drivers/common/process.js';
|
|
2
|
+
/** One recorded transcript row (see the spike harness in the OpenSpec change). */
|
|
3
|
+
export interface FixtureRow {
|
|
4
|
+
t: number;
|
|
5
|
+
dir: 'in' | 'out' | 'ctl';
|
|
6
|
+
json?: Record<string, unknown> | null;
|
|
7
|
+
raw?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadFixture(file: string): FixtureRow[];
|
|
10
|
+
export interface ReplaySession {
|
|
11
|
+
readonly spec: ProcessSpec;
|
|
12
|
+
/** Lines the driver wrote to stdin, parsed. */
|
|
13
|
+
readonly written: Array<Record<string, unknown>>;
|
|
14
|
+
readonly inputEnded: boolean;
|
|
15
|
+
readonly terminated: boolean;
|
|
16
|
+
/** Resolves when the replay is blocked waiting for the driver, or finished. */
|
|
17
|
+
idle(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Replays a recorded provider transcript as a ProcessSpawner:
|
|
21
|
+
* - `out` rows are emitted in order (time compressed);
|
|
22
|
+
* - before passing an `in` row, replay waits until the driver wrote a line,
|
|
23
|
+
* then maps recorded ids/uuids to the ones the driver used;
|
|
24
|
+
* - a `ctl kill` row waits for `terminate()` (output after it is what the
|
|
25
|
+
* provider printed while stopping); `closeStdin` waits for `endInput()`;
|
|
26
|
+
* - after the last row the process stays alive until terminated, like a
|
|
27
|
+
* resident provider, unless the transcript recorded a clean exit.
|
|
28
|
+
*/
|
|
29
|
+
export declare class FixtureReplayer {
|
|
30
|
+
private readonly rows;
|
|
31
|
+
private readonly options;
|
|
32
|
+
readonly sessions: ReplaySession[];
|
|
33
|
+
constructor(rows: FixtureRow[], options?: {
|
|
34
|
+
exitAtEnd?: boolean;
|
|
35
|
+
});
|
|
36
|
+
static fromFile(file: string, options?: {
|
|
37
|
+
exitAtEnd?: boolean;
|
|
38
|
+
}): FixtureReplayer;
|
|
39
|
+
get last(): ReplaySession;
|
|
40
|
+
readonly spawner: ProcessSpawner;
|
|
41
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export function loadFixture(file) {
|
|
3
|
+
return readFileSync(file, 'utf8').split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
4
|
+
}
|
|
5
|
+
/** Fields whose recorded values are replaced by the values the driver actually wrote. */
|
|
6
|
+
const REMAPPED = ['id', 'uuid', 'command_uuid'];
|
|
7
|
+
/**
|
|
8
|
+
* Replays a recorded provider transcript as a ProcessSpawner:
|
|
9
|
+
* - `out` rows are emitted in order (time compressed);
|
|
10
|
+
* - before passing an `in` row, replay waits until the driver wrote a line,
|
|
11
|
+
* then maps recorded ids/uuids to the ones the driver used;
|
|
12
|
+
* - a `ctl kill` row waits for `terminate()` (output after it is what the
|
|
13
|
+
* provider printed while stopping); `closeStdin` waits for `endInput()`;
|
|
14
|
+
* - after the last row the process stays alive until terminated, like a
|
|
15
|
+
* resident provider, unless the transcript recorded a clean exit.
|
|
16
|
+
*/
|
|
17
|
+
export class FixtureReplayer {
|
|
18
|
+
rows;
|
|
19
|
+
options;
|
|
20
|
+
sessions = [];
|
|
21
|
+
constructor(rows, options = {}) {
|
|
22
|
+
this.rows = rows;
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
static fromFile(file, options) {
|
|
26
|
+
return new FixtureReplayer(loadFixture(file), options);
|
|
27
|
+
}
|
|
28
|
+
get last() {
|
|
29
|
+
const session = this.sessions.at(-1);
|
|
30
|
+
if (!session)
|
|
31
|
+
throw new Error('fixture replayer: nothing spawned');
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
spawner = (spec, handlers) => {
|
|
35
|
+
const rows = this.rows;
|
|
36
|
+
const ids = new Map();
|
|
37
|
+
const written = [];
|
|
38
|
+
let inputsSeen = 0;
|
|
39
|
+
let index = 0;
|
|
40
|
+
let inputOpen = true;
|
|
41
|
+
let terminated = false;
|
|
42
|
+
let exit = null;
|
|
43
|
+
let resolveExit;
|
|
44
|
+
const exited = new Promise((resolve) => { resolveExit = resolve; });
|
|
45
|
+
let idleWaiters = [];
|
|
46
|
+
let blocked = false;
|
|
47
|
+
const exitAtEnd = this.options.exitAtEnd ?? false;
|
|
48
|
+
const remap = (value) => {
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return value.map(remap);
|
|
51
|
+
if (value && typeof value === 'object') {
|
|
52
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key,
|
|
53
|
+
REMAPPED.includes(key) && ids.has(JSON.stringify(inner)) ? ids.get(JSON.stringify(inner)) : remap(inner)]));
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
const finish = (result) => {
|
|
58
|
+
if (exit)
|
|
59
|
+
return;
|
|
60
|
+
exit = result;
|
|
61
|
+
blocked = true;
|
|
62
|
+
resolveExit(result);
|
|
63
|
+
flushIdle();
|
|
64
|
+
};
|
|
65
|
+
const flushIdle = () => { const waiters = idleWaiters; idleWaiters = []; for (const waiter of waiters)
|
|
66
|
+
waiter(); };
|
|
67
|
+
const step = () => {
|
|
68
|
+
if (exit)
|
|
69
|
+
return;
|
|
70
|
+
blocked = false;
|
|
71
|
+
while (index < rows.length) {
|
|
72
|
+
const row = rows[index];
|
|
73
|
+
if (row.dir === 'in') {
|
|
74
|
+
if (written.length <= inputsSeen) {
|
|
75
|
+
blocked = true;
|
|
76
|
+
flushIdle();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const actual = written[inputsSeen];
|
|
80
|
+
for (const key of REMAPPED) {
|
|
81
|
+
const recorded = row.json?.[key];
|
|
82
|
+
if (recorded !== undefined && actual[key] !== undefined)
|
|
83
|
+
ids.set(JSON.stringify(recorded), actual[key]);
|
|
84
|
+
}
|
|
85
|
+
inputsSeen += 1;
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (row.dir === 'ctl') {
|
|
90
|
+
const control = row.json ?? {};
|
|
91
|
+
if ('kill' in control) {
|
|
92
|
+
if (!terminated) {
|
|
93
|
+
blocked = true;
|
|
94
|
+
flushIdle();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
index += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if ('closeStdin' in control) {
|
|
101
|
+
if (inputOpen) {
|
|
102
|
+
blocked = true;
|
|
103
|
+
flushIdle();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
index += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if ('end' in control) {
|
|
110
|
+
const match = /exit code=(\S+) sig=(\S+)/.exec(String(control.end));
|
|
111
|
+
const code = match && match[1] !== 'null' ? Number(match[1]) : null;
|
|
112
|
+
const signal = match && match[2] !== 'null' ? match[2] : null;
|
|
113
|
+
// A harness timeout means the real process was still alive: stay resident.
|
|
114
|
+
if (terminated || exitAtEnd || (signal !== 'SIGKILL' && signal !== 'SIGTERM')) {
|
|
115
|
+
finish({ exitCode: code, signal });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
index += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
index += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
index += 1;
|
|
125
|
+
if (row.json)
|
|
126
|
+
handlers.onLine(JSON.stringify(remap(row.json)));
|
|
127
|
+
else if (row.raw)
|
|
128
|
+
handlers.onLine(row.raw);
|
|
129
|
+
setImmediate(step);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (terminated || exitAtEnd)
|
|
133
|
+
finish({ exitCode: terminated ? null : 0, signal: terminated ? 'SIGTERM' : null });
|
|
134
|
+
else {
|
|
135
|
+
blocked = true;
|
|
136
|
+
flushIdle();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const session = {
|
|
140
|
+
spec,
|
|
141
|
+
written,
|
|
142
|
+
get inputEnded() { return !inputOpen; },
|
|
143
|
+
get terminated() { return terminated; },
|
|
144
|
+
idle: () => (blocked || exit ? Promise.resolve() : new Promise((resolve) => { idleWaiters.push(resolve); })),
|
|
145
|
+
};
|
|
146
|
+
this.sessions.push(session);
|
|
147
|
+
setImmediate(step);
|
|
148
|
+
return {
|
|
149
|
+
pid: 4242,
|
|
150
|
+
exited,
|
|
151
|
+
write(line) {
|
|
152
|
+
if (!inputOpen || exit)
|
|
153
|
+
return false;
|
|
154
|
+
written.push(JSON.parse(line));
|
|
155
|
+
if (blocked)
|
|
156
|
+
setImmediate(step);
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
endInput() {
|
|
160
|
+
inputOpen = false;
|
|
161
|
+
if (blocked)
|
|
162
|
+
setImmediate(step);
|
|
163
|
+
},
|
|
164
|
+
terminate() {
|
|
165
|
+
terminated = true;
|
|
166
|
+
if (blocked)
|
|
167
|
+
setImmediate(step);
|
|
168
|
+
return exited;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test kit for hosts and driver authors (package export `./agent-runtime/session/testing`).
|
|
3
|
+
* Framework-free: usable from any test runner.
|
|
4
|
+
*/
|
|
5
|
+
export { DRIVER_CONFORMANCE, type ConformanceCheck, type ConformanceHarness } from './driver-conformance.js';
|
|
6
|
+
export { FixtureReplayer, loadFixture, type FixtureRow, type ReplaySession } from './fixture-replayer.js';
|
|
7
|
+
export { MemoryJournal } from './memory-journal.js';
|
|
8
|
+
export { FakeClock, SequentialIds } from './fake-clock.js';
|
|
9
|
+
export { ScriptedDriverFactory, ScriptedDriverSession, catalogOf, descriptor } from './scripted-driver.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test kit for hosts and driver authors (package export `./agent-runtime/session/testing`).
|
|
3
|
+
* Framework-free: usable from any test runner.
|
|
4
|
+
*/
|
|
5
|
+
export { DRIVER_CONFORMANCE } from './driver-conformance.js';
|
|
6
|
+
export { FixtureReplayer, loadFixture } from './fixture-replayer.js';
|
|
7
|
+
export { MemoryJournal } from './memory-journal.js';
|
|
8
|
+
export { FakeClock, SequentialIds } from './fake-clock.js';
|
|
9
|
+
export { ScriptedDriverFactory, ScriptedDriverSession, catalogOf, descriptor } from './scripted-driver.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SessionEvent, SessionEventEnvelope } from '../domain/events.js';
|
|
2
|
+
import { type SessionSnapshot } from '../domain/snapshot.js';
|
|
3
|
+
import type { UsageBaseline } from '../domain/usage.js';
|
|
4
|
+
import type { AppendOptions, SessionJournal, SessionRecord, SessionSummary } from '../ports.js';
|
|
5
|
+
/**
|
|
6
|
+
* In-memory SessionJournal with the same contract as the SQLite adapter:
|
|
7
|
+
* gap-free sequences, atomic appends, baselines stored with their events.
|
|
8
|
+
* `failNextAppend` simulates a failed transaction (nothing becomes visible).
|
|
9
|
+
*/
|
|
10
|
+
export declare class MemoryJournal implements SessionJournal {
|
|
11
|
+
private readonly sessions;
|
|
12
|
+
private readonly baselines;
|
|
13
|
+
failNextAppend: Error | null;
|
|
14
|
+
createSession(record: SessionRecord): void;
|
|
15
|
+
getSession(sessionId: string): SessionRecord | null;
|
|
16
|
+
append(sessionId: string, events: SessionEvent[], options?: AppendOptions): SessionEventEnvelope[];
|
|
17
|
+
read(sessionId: string, afterSeq: number, limit: number): {
|
|
18
|
+
events: SessionEventEnvelope[];
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
};
|
|
21
|
+
snapshot(sessionId: string): SessionSnapshot | null;
|
|
22
|
+
list(filter: 'open' | 'closed' | 'all'): SessionSummary[];
|
|
23
|
+
baseline(providerRef: string): UsageBaseline | null;
|
|
24
|
+
/** Test helper: every committed event of a session. */
|
|
25
|
+
events(sessionId: string): SessionEventEnvelope[];
|
|
26
|
+
private require;
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { emptySnapshot, foldEvents } from '../domain/snapshot.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-memory SessionJournal with the same contract as the SQLite adapter:
|
|
4
|
+
* gap-free sequences, atomic appends, baselines stored with their events.
|
|
5
|
+
* `failNextAppend` simulates a failed transaction (nothing becomes visible).
|
|
6
|
+
*/
|
|
7
|
+
export class MemoryJournal {
|
|
8
|
+
sessions = new Map();
|
|
9
|
+
baselines = new Map();
|
|
10
|
+
failNextAppend = null;
|
|
11
|
+
createSession(record) {
|
|
12
|
+
if (this.sessions.has(record.sessionId))
|
|
13
|
+
throw new Error(`Session ${record.sessionId} exists`);
|
|
14
|
+
this.sessions.set(record.sessionId, { record: { ...record }, events: [], snapshot: emptySnapshot(record.sessionId) });
|
|
15
|
+
}
|
|
16
|
+
getSession(sessionId) {
|
|
17
|
+
return this.sessions.get(sessionId)?.record ?? null;
|
|
18
|
+
}
|
|
19
|
+
append(sessionId, events, options) {
|
|
20
|
+
const entry = this.require(sessionId);
|
|
21
|
+
if (this.failNextAppend) {
|
|
22
|
+
const error = this.failNextAppend;
|
|
23
|
+
this.failNextAppend = null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
const envelopes = events.map((event, index) => ({ sessionId, seq: entry.snapshot.lastSeq + index + 1, event: structuredClone(event) }));
|
|
27
|
+
const snapshot = foldEvents(entry.snapshot, envelopes);
|
|
28
|
+
entry.events.push(...envelopes);
|
|
29
|
+
entry.snapshot = snapshot;
|
|
30
|
+
if (options?.baseline)
|
|
31
|
+
this.baselines.set(options.baseline.providerRef, { ...options.baseline.value });
|
|
32
|
+
return envelopes.map((envelope) => structuredClone(envelope));
|
|
33
|
+
}
|
|
34
|
+
read(sessionId, afterSeq, limit) {
|
|
35
|
+
const all = this.require(sessionId).events.filter((envelope) => envelope.seq > afterSeq);
|
|
36
|
+
return { events: structuredClone(all.slice(0, limit)), hasMore: all.length > limit };
|
|
37
|
+
}
|
|
38
|
+
snapshot(sessionId) {
|
|
39
|
+
const entry = this.sessions.get(sessionId);
|
|
40
|
+
return entry ? structuredClone(entry.snapshot) : null;
|
|
41
|
+
}
|
|
42
|
+
list(filter) {
|
|
43
|
+
return [...this.sessions.values()]
|
|
44
|
+
.filter(({ snapshot }) => filter === 'all' || snapshot.status === filter)
|
|
45
|
+
.map(({ record, snapshot }) => ({
|
|
46
|
+
sessionId: record.sessionId,
|
|
47
|
+
driver: record.driver,
|
|
48
|
+
model: snapshot.model,
|
|
49
|
+
status: snapshot.status,
|
|
50
|
+
phase: snapshot.phase,
|
|
51
|
+
lastSeq: snapshot.lastSeq,
|
|
52
|
+
createdAt: record.createdAt,
|
|
53
|
+
metadata: { ...record.metadata },
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
baseline(providerRef) {
|
|
57
|
+
const value = this.baselines.get(providerRef);
|
|
58
|
+
return value ? { ...value } : null;
|
|
59
|
+
}
|
|
60
|
+
/** Test helper: every committed event of a session. */
|
|
61
|
+
events(sessionId) {
|
|
62
|
+
return structuredClone(this.require(sessionId).events);
|
|
63
|
+
}
|
|
64
|
+
require(sessionId) {
|
|
65
|
+
const entry = this.sessions.get(sessionId);
|
|
66
|
+
if (!entry)
|
|
67
|
+
throw new Error(`Unknown session ${sessionId}`);
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DriverCapabilities, DriverDescriptor } from '../domain/types.js';
|
|
2
|
+
import type { CloseReason, DriverCatalog, DriverEvent, DriverEventSink, DriverFactory, DriverInput, DriverOpenSpec, DriverSession } from '../ports.js';
|
|
3
|
+
export declare function descriptor(id: string, capabilities?: Partial<DriverCapabilities>): DriverDescriptor;
|
|
4
|
+
/** One opened provider process, driven by the test through `emit`. */
|
|
5
|
+
export declare class ScriptedDriverSession implements DriverSession {
|
|
6
|
+
readonly spec: DriverOpenSpec;
|
|
7
|
+
private readonly sink;
|
|
8
|
+
readonly sent: DriverInput[];
|
|
9
|
+
readonly calls: string[];
|
|
10
|
+
closed: CloseReason | null;
|
|
11
|
+
/** What `stopSubagents` resolves with. */
|
|
12
|
+
stopResult: string[] | 'process';
|
|
13
|
+
/** Events emitted while closing (e.g. Claude's stopped/killed notifications on SIGTERM). */
|
|
14
|
+
onClose: DriverEvent[];
|
|
15
|
+
constructor(spec: DriverOpenSpec, sink: DriverEventSink);
|
|
16
|
+
emit(...events: DriverEvent[]): void;
|
|
17
|
+
send(input: DriverInput): Promise<void>;
|
|
18
|
+
interrupt(): Promise<void>;
|
|
19
|
+
stopSubagents(ids?: string[]): Promise<string[] | 'process'>;
|
|
20
|
+
close(reason: CloseReason): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare class ScriptedDriverFactory implements DriverFactory {
|
|
23
|
+
readonly descriptor: DriverDescriptor;
|
|
24
|
+
readonly sessions: ScriptedDriverSession[];
|
|
25
|
+
openError: Error | null;
|
|
26
|
+
constructor(descriptor: DriverDescriptor);
|
|
27
|
+
open(spec: DriverOpenSpec, sink: DriverEventSink): Promise<DriverSession>;
|
|
28
|
+
get last(): ScriptedDriverSession;
|
|
29
|
+
}
|
|
30
|
+
export declare function catalogOf(...factories: DriverFactory[]): DriverCatalog;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function descriptor(id, capabilities = {}) {
|
|
2
|
+
return {
|
|
3
|
+
id,
|
|
4
|
+
displayName: id,
|
|
5
|
+
capabilities: {
|
|
6
|
+
resident: true,
|
|
7
|
+
nativeInputQueue: true,
|
|
8
|
+
subagents: 'supported',
|
|
9
|
+
subagentDisable: true,
|
|
10
|
+
subagentModel: true,
|
|
11
|
+
subagentEffort: true,
|
|
12
|
+
autonomousContinuation: true,
|
|
13
|
+
steer: true,
|
|
14
|
+
toolFiltering: true,
|
|
15
|
+
usage: { costUsd: 'session-cumulative', tokens: 'per-turn' },
|
|
16
|
+
...capabilities,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** One opened provider process, driven by the test through `emit`. */
|
|
21
|
+
export class ScriptedDriverSession {
|
|
22
|
+
spec;
|
|
23
|
+
sink;
|
|
24
|
+
sent = [];
|
|
25
|
+
calls = [];
|
|
26
|
+
closed = null;
|
|
27
|
+
/** What `stopSubagents` resolves with. */
|
|
28
|
+
stopResult = 'process';
|
|
29
|
+
/** Events emitted while closing (e.g. Claude's stopped/killed notifications on SIGTERM). */
|
|
30
|
+
onClose = [];
|
|
31
|
+
constructor(spec, sink) {
|
|
32
|
+
this.spec = spec;
|
|
33
|
+
this.sink = sink;
|
|
34
|
+
}
|
|
35
|
+
emit(...events) {
|
|
36
|
+
if (this.closed)
|
|
37
|
+
throw new Error('scripted driver: emit after close');
|
|
38
|
+
for (const event of events)
|
|
39
|
+
this.sink(event);
|
|
40
|
+
}
|
|
41
|
+
async send(input) { this.calls.push(`send:${input.inputId}`); this.sent.push(input); }
|
|
42
|
+
async interrupt() { this.calls.push('interrupt'); }
|
|
43
|
+
async stopSubagents(ids) { this.calls.push(`stopSubagents:${ids?.join(',') ?? '*'}`); return this.stopResult; }
|
|
44
|
+
async close(reason) {
|
|
45
|
+
if (this.closed)
|
|
46
|
+
return;
|
|
47
|
+
this.calls.push(`close:${reason}`);
|
|
48
|
+
for (const event of this.onClose)
|
|
49
|
+
this.sink(event);
|
|
50
|
+
this.closed = reason;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class ScriptedDriverFactory {
|
|
54
|
+
descriptor;
|
|
55
|
+
sessions = [];
|
|
56
|
+
openError = null;
|
|
57
|
+
constructor(descriptor) {
|
|
58
|
+
this.descriptor = descriptor;
|
|
59
|
+
}
|
|
60
|
+
async open(spec, sink) {
|
|
61
|
+
if (this.openError)
|
|
62
|
+
throw this.openError;
|
|
63
|
+
const session = new ScriptedDriverSession(spec, sink);
|
|
64
|
+
this.sessions.push(session);
|
|
65
|
+
return session;
|
|
66
|
+
}
|
|
67
|
+
get last() {
|
|
68
|
+
const session = this.sessions.at(-1);
|
|
69
|
+
if (!session)
|
|
70
|
+
throw new Error('scripted driver: no session opened');
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function catalogOf(...factories) {
|
|
75
|
+
const map = new Map(factories.map((factory) => [factory.descriptor.id, factory]));
|
|
76
|
+
return { get: (id) => map.get(id), descriptors: () => factories.map((factory) => factory.descriptor) };
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PipelineContext, VerificationCommand } from '../pipeline/pipeline-state.js';
|
|
2
|
+
/** The package script a command runs (`npm test`, `npm run x`, `pnpm [run] x`, `yarn [run] x`), when it runs exactly one with no extra arguments. */
|
|
3
|
+
export declare function packageScriptOf(command: Pick<VerificationCommand, 'command' | 'args'>): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* The proposals to append to `planned` (already scope-defaulted). `normalize`
|
|
6
|
+
* applies the caller's scope default; proposals are expected to be
|
|
7
|
+
* structurally valid.
|
|
8
|
+
*/
|
|
9
|
+
export declare function admitVerificationProposals(context: PipelineContext, planned: readonly VerificationCommand[], proposals: readonly VerificationCommand[], normalize: (command: VerificationCommand) => VerificationCommand): VerificationCommand[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Which agent-proposed verification commands the host adds to its plan.
|
|
2
|
+
//
|
|
3
|
+
// A repository without configured checks takes the proposals as its checks
|
|
4
|
+
// (validated like any configured command). A repository with configured
|
|
5
|
+
// checks keeps them and may gain only runs of a package script the
|
|
6
|
+
// repository itself declares, such as `npm run test:e2e`: a change whose
|
|
7
|
+
// acceptance needs browser or integration tests then gets host evidence for
|
|
8
|
+
// them, without the agent introducing arbitrary commands next to the
|
|
9
|
+
// configured ones.
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
/** The package script a command runs (`npm test`, `npm run x`, `pnpm [run] x`, `yarn [run] x`), when it runs exactly one with no extra arguments. */
|
|
13
|
+
export function packageScriptOf(command) {
|
|
14
|
+
const [first, second, ...rest] = command.args;
|
|
15
|
+
if (command.command === 'npm') {
|
|
16
|
+
if (first === 'test' && second === undefined)
|
|
17
|
+
return 'test';
|
|
18
|
+
if ((first === 'run' || first === 'run-script') && second && !rest.length)
|
|
19
|
+
return second;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (command.command === 'pnpm' || command.command === 'yarn') {
|
|
23
|
+
if (first === 'run' && second && !rest.length)
|
|
24
|
+
return second;
|
|
25
|
+
if (first && second === undefined && !first.startsWith('-'))
|
|
26
|
+
return first;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
function declaresScript(directory, script) {
|
|
31
|
+
const file = path.join(directory, 'package.json');
|
|
32
|
+
if (!existsSync(file))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
const scripts = JSON.parse(readFileSync(file, 'utf8')).scripts;
|
|
36
|
+
return !!scripts && typeof scripts[script] === 'string';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const planKey = (command) => JSON.stringify([command.repositoryId, command.cwd ?? '', command.command, command.args]);
|
|
43
|
+
/**
|
|
44
|
+
* The proposals to append to `planned` (already scope-defaulted). `normalize`
|
|
45
|
+
* applies the caller's scope default; proposals are expected to be
|
|
46
|
+
* structurally valid.
|
|
47
|
+
*/
|
|
48
|
+
export function admitVerificationProposals(context, planned, proposals, normalize) {
|
|
49
|
+
const configured = new Set(planned.map(command => command.repositoryId));
|
|
50
|
+
const seen = new Set(planned.map(planKey));
|
|
51
|
+
const admitted = [];
|
|
52
|
+
for (const raw of proposals) {
|
|
53
|
+
const repository = context.repositories.find(repo => repo.id === raw.repositoryId);
|
|
54
|
+
if (!repository)
|
|
55
|
+
continue;
|
|
56
|
+
const command = normalize(raw);
|
|
57
|
+
if (configured.has(raw.repositoryId)) {
|
|
58
|
+
const script = packageScriptOf(command);
|
|
59
|
+
if (!script || !declaresScript(path.resolve(repository.path, command.cwd ?? '.'), script))
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const key = planKey(command);
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
admitted.push(command);
|
|
67
|
+
}
|
|
68
|
+
return admitted;
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { spawnSync } from 'node:child_process';
|
|
2
|
+
import type { CommandReceipt, PipelineContext, VerificationReceipt } from '../pipeline/pipeline-state.js';
|
|
3
|
+
import { type InstallOutcome } from './compact/environment.js';
|
|
4
|
+
import type { HostBlocker } from './engine/contracts.js';
|
|
5
|
+
import { type GuardrailSettings } from './guardrails.js';
|
|
6
|
+
export type { HostBlocker } from './engine/contracts.js';
|
|
7
|
+
export declare function installRoots(context: PipelineContext, commands: readonly CommandReceipt[]): string[];
|
|
8
|
+
/** A directory as the log should name it: relative to the checkout that contains it. */
|
|
9
|
+
export declare function checkoutRelative(context: PipelineContext, directory: string): string;
|
|
10
|
+
/** The legacy host-facing sentence for a blocker; engine v2 hosts read the structured blocker instead. */
|
|
11
|
+
export declare function hostPreconditionMessage(reason: string, command: string, args: readonly string[], directory: string): string;
|
|
12
|
+
/** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
|
|
13
|
+
export declare function preconditionBlock(context: PipelineContext, receipt: VerificationReceipt): HostBlocker | undefined;
|
|
14
|
+
export interface EnvironmentRepair {
|
|
15
|
+
installs: InstallOutcome[];
|
|
16
|
+
refused?: HostBlocker;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Runs the planned installs for the failed commands' roots once, narrating each
|
|
20
|
+
* step through `note` exactly as the legacy graph logs it (`[environment] npm
|
|
21
|
+
* install (app)`, `Environment: installed …`). An install the host cannot
|
|
22
|
+
* complete for a precondition reason becomes `refused`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?: typeof spawnSync): EnvironmentRepair;
|
|
25
|
+
/** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
|
|
26
|
+
export declare function boundedBlocker(value: unknown): HostBlocker | undefined;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hostPrecondition, installEnvironment } from './compact/environment.js';
|
|
4
|
+
import { guardrailEnabled } from './guardrails.js';
|
|
5
|
+
const BLOCKER_KINDS = new Set(['network', 'credential', 'environment-variable', 'toolchain', 'setup', 'environment', 'scope']);
|
|
6
|
+
const BLOCKER_TEXT_LIMIT = 2_000;
|
|
7
|
+
/** Where a missing dependency install belongs: the nearest directory with a lockfile or manifest above the failing command, else the repository root. */
|
|
8
|
+
const INSTALL_MARKERS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
|
|
9
|
+
export function installRoots(context, commands) {
|
|
10
|
+
const roots = [];
|
|
11
|
+
for (const command of commands) {
|
|
12
|
+
const repository = context.repositories.find(repo => repo.id === command.repositoryId);
|
|
13
|
+
if (!repository)
|
|
14
|
+
continue;
|
|
15
|
+
let directory = command.cwd;
|
|
16
|
+
let chosen = repository.path;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const relative = path.relative(repository.path, directory);
|
|
19
|
+
if (relative.startsWith('..') || path.isAbsolute(relative))
|
|
20
|
+
break;
|
|
21
|
+
if (INSTALL_MARKERS.some(name => existsSync(path.join(directory, name)))) {
|
|
22
|
+
chosen = directory;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
if (!relative)
|
|
26
|
+
break;
|
|
27
|
+
directory = path.dirname(directory);
|
|
28
|
+
}
|
|
29
|
+
roots.push(chosen);
|
|
30
|
+
}
|
|
31
|
+
// Every repository root stays a candidate: its plan is a no-op when it is already installed.
|
|
32
|
+
return [...new Set([...roots, ...context.repositories.map(repository => repository.path)])];
|
|
33
|
+
}
|
|
34
|
+
/** A directory as the log should name it: relative to the checkout that contains it. */
|
|
35
|
+
export function checkoutRelative(context, directory) {
|
|
36
|
+
const repository = context.repositories.find(repo => { const relative = path.relative(repo.path, directory); return !relative.startsWith('..') && !path.isAbsolute(relative); });
|
|
37
|
+
return repository ? path.relative(repository.path, directory).split(path.sep).join('/') || '.' : directory;
|
|
38
|
+
}
|
|
39
|
+
/** The legacy host-facing sentence for a blocker; engine v2 hosts read the structured blocker instead. */
|
|
40
|
+
export function hostPreconditionMessage(reason, command, args, directory) {
|
|
41
|
+
return `Verification cannot run in this environment: ${reason} (\`${[command, ...args].join(' ')}\` in ${directory}). This is not a defect in the change, so no correction round was started. Make it available to Specrails (for example in the login shell profile Specrails loads, or with a refreshed registry token), then resume.`;
|
|
42
|
+
}
|
|
43
|
+
/** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
|
|
44
|
+
export function preconditionBlock(context, receipt) {
|
|
45
|
+
for (const command of receipt.commands) {
|
|
46
|
+
if (command.exitCode === 0)
|
|
47
|
+
continue;
|
|
48
|
+
const precondition = hostPrecondition(command.output);
|
|
49
|
+
if (precondition)
|
|
50
|
+
return boundedBlocker({ kind: precondition.kind, reason: precondition.reason, command: command.command, args: command.args, cwd: checkoutRelative(context, command.cwd),
|
|
51
|
+
requiredAction: precondition.requiredAction, ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}) });
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs the planned installs for the failed commands' roots once, narrating each
|
|
57
|
+
* step through `note` exactly as the legacy graph logs it (`[environment] npm
|
|
58
|
+
* install (app)`, `Environment: installed …`). An install the host cannot
|
|
59
|
+
* complete for a precondition reason becomes `refused`.
|
|
60
|
+
*/
|
|
61
|
+
export function repairEnvironment(context, receipt, guardrails, note, spawn) {
|
|
62
|
+
const failed = receipt.commands.filter(command => command.exitCode !== 0);
|
|
63
|
+
const installs = installEnvironment(installRoots(context, failed), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
|
|
64
|
+
...(spawn ? { spawn } : {}), onEvent: event => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
|
|
65
|
+
const refused = installs.find(outcome => outcome.precondition);
|
|
66
|
+
if (!refused)
|
|
67
|
+
return { installs };
|
|
68
|
+
const precondition = hostPrecondition(refused.detail) ?? { kind: 'environment', reason: refused.precondition, requiredAction: 'Repair the verification environment, then retry the run.' };
|
|
69
|
+
const cwd = checkoutRelative(context, refused.root);
|
|
70
|
+
const command = [refused.command, ...refused.args].join(' ');
|
|
71
|
+
return { installs, refused: boundedBlocker({ kind: precondition.kind, reason: `installing its dependencies failed because ${refused.precondition}`, command: refused.command, args: refused.args, cwd,
|
|
72
|
+
// A network blocker on an install names the exact command to repeat with connectivity; other kinds keep the classifier's action.
|
|
73
|
+
requiredAction: precondition.kind === 'network' ? `Run \`${command}\` in ${cwd} with network access, then retry the run.` : precondition.requiredAction }) };
|
|
74
|
+
}
|
|
75
|
+
/** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
|
|
76
|
+
export function boundedBlocker(value) {
|
|
77
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
78
|
+
return undefined;
|
|
79
|
+
const raw = value;
|
|
80
|
+
if (typeof raw.kind !== 'string' || !BLOCKER_KINDS.has(raw.kind))
|
|
81
|
+
return undefined;
|
|
82
|
+
const bounded = (field, limit = BLOCKER_TEXT_LIMIT) => typeof field === 'string' ? field.slice(0, limit) : '';
|
|
83
|
+
const args = Array.isArray(raw.args) ? raw.args.slice(0, 16).map(arg => bounded(arg, 256)) : [];
|
|
84
|
+
// A correction role reports free-text `evidence` instead of a host reason.
|
|
85
|
+
const reason = bounded(raw.reason) || bounded(raw.evidence);
|
|
86
|
+
return { kind: raw.kind, reason, command: bounded(raw.command, 512), args, cwd: bounded(raw.cwd, 512), requiredAction: bounded(raw.requiredAction),
|
|
87
|
+
...(typeof raw.evidenceId === 'string' && raw.evidenceId ? { evidenceId: bounded(raw.evidenceId, 128) } : {}) };
|
|
88
|
+
}
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* Everything in this module is synchronous to fit the installer's sync flow.
|
|
18
18
|
*/
|
|
19
19
|
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
20
|
-
import os from 'node:os';
|
|
21
20
|
import path from 'node:path';
|
|
21
|
+
import { userHome } from '../../shared/specrails-home.js';
|
|
22
22
|
/** Registry schema version. A reader that sees a higher value MUST treat all
|
|
23
23
|
* entries as absent (legacy fallback), never mis-parse. */
|
|
24
24
|
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
@@ -190,9 +190,9 @@ function isValidProjectEntry(key, value, home) {
|
|
|
190
190
|
export function slugify(name) {
|
|
191
191
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
192
192
|
}
|
|
193
|
-
/** `$HOME` for the registry, overridable for tests. */
|
|
193
|
+
/** `$HOME` for the registry, overridable for tests (shared with session journals). */
|
|
194
194
|
function resolveHome(home) {
|
|
195
|
-
return home
|
|
195
|
+
return userHome(home);
|
|
196
196
|
}
|
|
197
197
|
/** Absolute path to `registry.json`. */
|
|
198
198
|
export function registryPath(home) {
|