singleton-pipeline 0.4.0-beta.1 → 0.4.0-beta.13
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/CHANGELOG.md +49 -0
- package/README.md +170 -129
- package/docs/reference.md +63 -18
- package/package.json +3 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/new.js +455 -109
- package/packages/cli/src/commands/repl.js +86 -89
- package/packages/cli/src/executor/debug-loop.js +587 -0
- package/packages/cli/src/executor/inputs.js +202 -0
- package/packages/cli/src/executor/outputs.js +140 -0
- package/packages/cli/src/executor/preflight.js +459 -0
- package/packages/cli/src/executor/replay-loop.js +172 -0
- package/packages/cli/src/executor/run-report.js +189 -0
- package/packages/cli/src/executor/run-setup.js +93 -0
- package/packages/cli/src/executor/security-review.js +108 -0
- package/packages/cli/src/executor/snapshot-manager.js +335 -0
- package/packages/cli/src/executor/step-runner.js +266 -0
- package/packages/cli/src/executor.js +233 -2228
- package/packages/cli/src/index.js +1 -1
- package/packages/cli/src/runners/claude.js +6 -3
- package/packages/cli/src/runners/codex.js +6 -3
- package/packages/cli/src/runners/copilot.js +25 -9
- package/packages/cli/src/runners/opencode.js +1 -1
- package/packages/cli/src/shell.js +244 -54
- package/packages/cli/src/timeline.js +54 -20
- package/packages/server/package.json +1 -1
- package/packages/web/package.json +1 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const SNAPSHOT_SKIP_DIRS = new Set([
|
|
7
|
+
'.git',
|
|
8
|
+
'.singleton',
|
|
9
|
+
'.opencode',
|
|
10
|
+
'.idea',
|
|
11
|
+
'.vscode',
|
|
12
|
+
'node_modules',
|
|
13
|
+
'dist',
|
|
14
|
+
'build',
|
|
15
|
+
'.next',
|
|
16
|
+
'.cache',
|
|
17
|
+
'coverage',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const SNAPSHOT_MAX_FILE_BYTES = 1024 * 1024;
|
|
21
|
+
const SNAPSHOT_BINARY_PROBE_BYTES = 8192;
|
|
22
|
+
|
|
23
|
+
function runCommand(cmd, args, { cwd }) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
26
|
+
let stdout = '';
|
|
27
|
+
let stderr = '';
|
|
28
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
29
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
30
|
+
child.on('error', reject);
|
|
31
|
+
child.on('close', (code) => {
|
|
32
|
+
if (code !== 0) {
|
|
33
|
+
reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
resolve({ stdout, stderr });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function snapshotProjectFiles(root, rel = '', out = new Map()) {
|
|
42
|
+
const abs = path.join(root, rel);
|
|
43
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (SNAPSHOT_SKIP_DIRS.has(entry.name)) continue;
|
|
47
|
+
await snapshotProjectFiles(root, path.join(rel, entry.name), out);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!entry.isFile()) continue;
|
|
51
|
+
const entryRel = path.join(rel, entry.name);
|
|
52
|
+
const entryAbs = path.join(root, entryRel);
|
|
53
|
+
const stat = await fs.stat(entryAbs);
|
|
54
|
+
out.set(entryRel, `${stat.size}:${Math.floor(stat.mtimeMs)}`);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function detectGitRepo(cwd) {
|
|
60
|
+
try {
|
|
61
|
+
await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function gitFilterIgnoredPaths(root, relPaths) {
|
|
69
|
+
if (!relPaths.length) return new Set();
|
|
70
|
+
const posix = relPaths.map((p) => p.split(path.sep).join('/'));
|
|
71
|
+
const ignored = new Set();
|
|
72
|
+
await new Promise((resolve) => {
|
|
73
|
+
const child = spawn('git', ['check-ignore', '--stdin'], {
|
|
74
|
+
cwd: root,
|
|
75
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
76
|
+
});
|
|
77
|
+
let stdout = '';
|
|
78
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
79
|
+
child.on('error', () => resolve());
|
|
80
|
+
child.on('close', () => {
|
|
81
|
+
for (const line of stdout.split('\n')) {
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (trimmed) ignored.add(trimmed);
|
|
84
|
+
}
|
|
85
|
+
resolve();
|
|
86
|
+
});
|
|
87
|
+
child.stdin.write(posix.join('\n'));
|
|
88
|
+
child.stdin.end();
|
|
89
|
+
});
|
|
90
|
+
if (!ignored.size) return new Set();
|
|
91
|
+
const result = new Set();
|
|
92
|
+
for (let i = 0; i < relPaths.length; i++) {
|
|
93
|
+
if (ignored.has(posix[i])) result.add(relPaths[i]);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseGitStatusPorcelain(raw) {
|
|
99
|
+
const files = new Map();
|
|
100
|
+
const records = String(raw || '').split('\0').filter(Boolean);
|
|
101
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
102
|
+
const record = records[i];
|
|
103
|
+
if (record.length < 4) continue;
|
|
104
|
+
const status = record.slice(0, 2);
|
|
105
|
+
const relPath = record.slice(3).split('/').join(path.sep);
|
|
106
|
+
const topLevel = relPath.split(path.sep)[0];
|
|
107
|
+
if (SNAPSHOT_SKIP_DIRS.has(topLevel)) {
|
|
108
|
+
if (status[0] === 'R' || status[0] === 'C') i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (relPath) files.set(relPath, status);
|
|
112
|
+
if (status[0] === 'R' || status[0] === 'C') i += 1;
|
|
113
|
+
}
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function captureGitStatus(root, gitRepo) {
|
|
118
|
+
if (!gitRepo) return null;
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await runCommand('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: root });
|
|
121
|
+
return parseGitStatusPorcelain(stdout);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function detectGitStatusChanges(beforeStatus, afterStatus, root) {
|
|
128
|
+
if (!beforeStatus || !afterStatus) return [];
|
|
129
|
+
const changed = [];
|
|
130
|
+
const paths = new Set([...beforeStatus.keys(), ...afterStatus.keys()]);
|
|
131
|
+
for (const relPath of paths) {
|
|
132
|
+
if (beforeStatus.get(relPath) === afterStatus.get(relPath)) continue;
|
|
133
|
+
changed.push({
|
|
134
|
+
relPath,
|
|
135
|
+
absPath: path.join(root, relPath),
|
|
136
|
+
kind: 'deliverable',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return changed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function isProbablyBinaryFile(absPath) {
|
|
143
|
+
let fd;
|
|
144
|
+
try {
|
|
145
|
+
fd = await fs.open(absPath, 'r');
|
|
146
|
+
const buf = Buffer.alloc(SNAPSHOT_BINARY_PROBE_BYTES);
|
|
147
|
+
const { bytesRead } = await fd.read(buf, 0, SNAPSHOT_BINARY_PROBE_BYTES, 0);
|
|
148
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
149
|
+
if (buf[i] === 0) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
} finally {
|
|
155
|
+
if (fd) await fd.close().catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function collectSnapshotCandidates(root, rel = '', out = []) {
|
|
160
|
+
const abs = path.join(root, rel);
|
|
161
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
if (SNAPSHOT_SKIP_DIRS.has(entry.name)) continue;
|
|
165
|
+
await collectSnapshotCandidates(root, path.join(rel, entry.name), out);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!entry.isFile()) continue;
|
|
169
|
+
const entryRel = path.join(rel, entry.name);
|
|
170
|
+
const entryAbs = path.join(root, entryRel);
|
|
171
|
+
let size = 0;
|
|
172
|
+
try {
|
|
173
|
+
const stat = await fs.stat(entryAbs);
|
|
174
|
+
size = stat.size;
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push({ relPath: entryRel, absPath: entryAbs, size });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function createStepSnapshot({ root, snapshotDir, gitRepo, maxFileBytes = SNAPSHOT_MAX_FILE_BYTES }) {
|
|
184
|
+
await fs.mkdir(snapshotDir, { recursive: true });
|
|
185
|
+
const candidates = await collectSnapshotCandidates(root);
|
|
186
|
+
const ignored = gitRepo
|
|
187
|
+
? await gitFilterIgnoredPaths(root, candidates.map((c) => c.relPath))
|
|
188
|
+
: new Set();
|
|
189
|
+
|
|
190
|
+
const captured = new Set();
|
|
191
|
+
const skippedLarge = [];
|
|
192
|
+
const skippedBinary = [];
|
|
193
|
+
const skippedIgnored = [];
|
|
194
|
+
|
|
195
|
+
for (const { relPath, absPath, size } of candidates) {
|
|
196
|
+
if (ignored.has(relPath)) {
|
|
197
|
+
skippedIgnored.push(relPath);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (size > maxFileBytes) {
|
|
201
|
+
skippedLarge.push(relPath);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (await isProbablyBinaryFile(absPath)) {
|
|
205
|
+
skippedBinary.push(relPath);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const dest = path.join(snapshotDir, relPath);
|
|
209
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
210
|
+
try {
|
|
211
|
+
await fs.copyFile(absPath, dest, fsConstants.COPYFILE_FICLONE);
|
|
212
|
+
} catch {
|
|
213
|
+
try {
|
|
214
|
+
await fs.copyFile(absPath, dest);
|
|
215
|
+
} catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
captured.add(relPath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { snapshotDir, captured, skippedLarge, skippedBinary, skippedIgnored };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function summarizeSnapshotCoverage(snapshot) {
|
|
226
|
+
if (!snapshot) return null;
|
|
227
|
+
return {
|
|
228
|
+
captured: snapshot.captured?.size || 0,
|
|
229
|
+
skippedLarge: snapshot.skippedLarge?.length || 0,
|
|
230
|
+
skippedBinary: snapshot.skippedBinary?.length || 0,
|
|
231
|
+
skippedIgnored: snapshot.skippedIgnored?.length || 0,
|
|
232
|
+
restorable: !(
|
|
233
|
+
snapshot.skippedLarge?.length ||
|
|
234
|
+
snapshot.skippedBinary?.length ||
|
|
235
|
+
snapshot.skippedIgnored?.length
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function formatSnapshotCoverage(snapshot) {
|
|
241
|
+
const coverage = summarizeSnapshotCoverage(snapshot);
|
|
242
|
+
if (!coverage) return [];
|
|
243
|
+
return [
|
|
244
|
+
`captured ${coverage.captured} file${coverage.captured === 1 ? '' : 's'}`,
|
|
245
|
+
`skipped large ${coverage.skippedLarge}`,
|
|
246
|
+
`skipped binary ${coverage.skippedBinary}`,
|
|
247
|
+
`skipped gitignored ${coverage.skippedIgnored}`,
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class SnapshotManager {
|
|
252
|
+
constructor({ root, gitRepo = false }) {
|
|
253
|
+
this.root = root;
|
|
254
|
+
this.gitRepo = gitRepo;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static async create({ root, gitRepo = null } = {}) {
|
|
258
|
+
return new SnapshotManager({
|
|
259
|
+
root,
|
|
260
|
+
gitRepo: gitRepo ?? await detectGitRepo(root),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async captureState() {
|
|
265
|
+
const state = await snapshotProjectFiles(this.root);
|
|
266
|
+
state.gitStatus = await captureGitStatus(this.root, this.gitRepo);
|
|
267
|
+
return state;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async createRestoreSnapshot({ snapshotDir, maxFileBytes = SNAPSHOT_MAX_FILE_BYTES }) {
|
|
271
|
+
return createStepSnapshot({
|
|
272
|
+
root: this.root,
|
|
273
|
+
snapshotDir,
|
|
274
|
+
gitRepo: this.gitRepo,
|
|
275
|
+
maxFileBytes,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
detectChanges(before, after) {
|
|
280
|
+
const localChanges = detectSnapshotChanges(before, after, this.root);
|
|
281
|
+
const changesByPath = new Map(localChanges.map((change) => [change.relPath, change]));
|
|
282
|
+
for (const change of detectGitStatusChanges(before.gitStatus, after.gitStatus, this.root)) {
|
|
283
|
+
if (!changesByPath.has(change.relPath)) changesByPath.set(change.relPath, change);
|
|
284
|
+
}
|
|
285
|
+
return [...changesByPath.values()];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async restore({ snapshot, originalPaths, changes }) {
|
|
289
|
+
return restoreStepSnapshot({
|
|
290
|
+
root: this.root,
|
|
291
|
+
snapshot,
|
|
292
|
+
originalPaths,
|
|
293
|
+
changes,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function detectSnapshotChanges(before, after, root) {
|
|
299
|
+
const changed = [];
|
|
300
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
301
|
+
for (const relPath of paths) {
|
|
302
|
+
const beforeSig = before.get(relPath);
|
|
303
|
+
const afterSig = after.get(relPath);
|
|
304
|
+
if (beforeSig === afterSig) continue;
|
|
305
|
+
changed.push({
|
|
306
|
+
relPath,
|
|
307
|
+
absPath: path.join(root, relPath),
|
|
308
|
+
kind: 'deliverable',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return changed;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function restoreStepSnapshot({ root, snapshot, originalPaths, changes }) {
|
|
315
|
+
const restored = [];
|
|
316
|
+
const removed = [];
|
|
317
|
+
const skipped = [];
|
|
318
|
+
for (const change of changes) {
|
|
319
|
+
const relPath = change?.relPath;
|
|
320
|
+
if (!relPath) continue;
|
|
321
|
+
const absPath = path.join(root, relPath);
|
|
322
|
+
if (snapshot.captured.has(relPath)) {
|
|
323
|
+
const src = path.join(snapshot.snapshotDir, relPath);
|
|
324
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
325
|
+
await fs.copyFile(src, absPath);
|
|
326
|
+
restored.push(relPath);
|
|
327
|
+
} else if (originalPaths.has(relPath)) {
|
|
328
|
+
skipped.push(relPath);
|
|
329
|
+
} else {
|
|
330
|
+
await fs.rm(absPath, { recursive: true, force: true });
|
|
331
|
+
removed.push(relPath);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { restored, removed, skipped };
|
|
335
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assertWriteAllowed } from '../security/policy.js';
|
|
4
|
+
import { buildUserMessage } from './inputs.js';
|
|
5
|
+
import {
|
|
6
|
+
assertRunArtifactWriteAllowed,
|
|
7
|
+
isInsidePath,
|
|
8
|
+
parseOutputs,
|
|
9
|
+
rewriteInternalSink,
|
|
10
|
+
summarizeParsedOutputs,
|
|
11
|
+
writeRawOutputArtifact,
|
|
12
|
+
} from './outputs.js';
|
|
13
|
+
|
|
14
|
+
function formatStepRuntimeMeta({ provider, model, permissionMode, securityProfile }) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
if (provider) parts.push(provider);
|
|
17
|
+
if (model) parts.push(model);
|
|
18
|
+
if (securityProfile) parts.push(`security:${securityProfile}`);
|
|
19
|
+
if (permissionMode) parts.push(`perm:${permissionMode}`);
|
|
20
|
+
return parts.join(' · ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateParsedOutputs(parsed, outputNames) {
|
|
24
|
+
const warnings = [];
|
|
25
|
+
for (const name of outputNames) {
|
|
26
|
+
const value = String(parsed[name] || '').trim();
|
|
27
|
+
if (!value) warnings.push(`output "${name}" is empty`);
|
|
28
|
+
}
|
|
29
|
+
return warnings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validatePostRunChanges({ changes, securityPolicy, step, cwd }) {
|
|
33
|
+
const violations = [];
|
|
34
|
+
for (const change of changes) {
|
|
35
|
+
try {
|
|
36
|
+
assertWriteAllowed(change.absPath, {
|
|
37
|
+
root: cwd,
|
|
38
|
+
agentName: step.agent,
|
|
39
|
+
outputName: 'direct project change',
|
|
40
|
+
policy: securityPolicy,
|
|
41
|
+
});
|
|
42
|
+
} catch (err) {
|
|
43
|
+
violations.push({
|
|
44
|
+
path: change.relPath,
|
|
45
|
+
reason: err.message,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return violations;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runStepAttempt({
|
|
53
|
+
attempt,
|
|
54
|
+
debug,
|
|
55
|
+
stepDir,
|
|
56
|
+
cwd,
|
|
57
|
+
step,
|
|
58
|
+
outputNames,
|
|
59
|
+
inputs,
|
|
60
|
+
securityPolicy,
|
|
61
|
+
systemPrompt,
|
|
62
|
+
workspaceInfo,
|
|
63
|
+
timeline,
|
|
64
|
+
timelineIndex,
|
|
65
|
+
verbose,
|
|
66
|
+
runner,
|
|
67
|
+
provider,
|
|
68
|
+
model,
|
|
69
|
+
runnerAgent,
|
|
70
|
+
permissionMode,
|
|
71
|
+
inputValues,
|
|
72
|
+
registry,
|
|
73
|
+
fileWrites,
|
|
74
|
+
snapshotManager,
|
|
75
|
+
currentSnapshot,
|
|
76
|
+
shell,
|
|
77
|
+
handlePostRunViolations,
|
|
78
|
+
failStep,
|
|
79
|
+
}) {
|
|
80
|
+
const attemptDir = debug && stepDir && attempt > 1 ? path.join(stepDir, `attempt-${attempt}`) : stepDir;
|
|
81
|
+
if (attemptDir) await fs.mkdir(attemptDir, { recursive: true });
|
|
82
|
+
const userMessage = buildUserMessage(inputs, outputNames, workspaceInfo, securityPolicy);
|
|
83
|
+
timeline.setRunning(
|
|
84
|
+
timelineIndex,
|
|
85
|
+
formatStepRuntimeMeta({
|
|
86
|
+
provider,
|
|
87
|
+
model: model || '',
|
|
88
|
+
permissionMode,
|
|
89
|
+
securityProfile: securityPolicy.profile,
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (verbose) {
|
|
94
|
+
timeline.log(`── system prompt ──`);
|
|
95
|
+
for (const l of systemPrompt.split('\n').slice(0, 8)) timeline.logMuted(l);
|
|
96
|
+
timeline.log(`── user message ──`);
|
|
97
|
+
for (const l of userMessage.split('\n').slice(0, 12)) timeline.logMuted(l);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
const stepBeforeSnapshot = currentSnapshot;
|
|
102
|
+
let result;
|
|
103
|
+
try {
|
|
104
|
+
result = await runner.run({
|
|
105
|
+
cwd,
|
|
106
|
+
projectRoot: cwd,
|
|
107
|
+
currentDir: cwd,
|
|
108
|
+
systemPrompt,
|
|
109
|
+
userPrompt: userMessage,
|
|
110
|
+
model,
|
|
111
|
+
runnerAgent,
|
|
112
|
+
permissionMode,
|
|
113
|
+
securityPolicy,
|
|
114
|
+
verbose,
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const failedSeconds = (Date.now() - started) / 1000;
|
|
118
|
+
return {
|
|
119
|
+
failed: true,
|
|
120
|
+
error: err,
|
|
121
|
+
elapsedSeconds: failedSeconds,
|
|
122
|
+
attemptTurns: 0,
|
|
123
|
+
attemptCost: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const elapsedSeconds = (Date.now() - started) / 1000;
|
|
128
|
+
const text = result.text;
|
|
129
|
+
const metadata = result.metadata || {};
|
|
130
|
+
const attemptTurns = Number(metadata.turns || 0);
|
|
131
|
+
const attemptCost = Number(metadata.costUsd || 0);
|
|
132
|
+
const stepWritesStart = fileWrites.length;
|
|
133
|
+
|
|
134
|
+
if (verbose) {
|
|
135
|
+
timeline.log(`── output ──`);
|
|
136
|
+
for (const l of text.split('\n').slice(0, 20)) timeline.logMuted(l);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parsed = parseOutputs(text, outputNames);
|
|
140
|
+
const outputWarnings = validateParsedOutputs(parsed, outputNames);
|
|
141
|
+
const parsedOutputSummary = summarizeParsedOutputs(parsed, outputNames);
|
|
142
|
+
let rawOutputPath = null;
|
|
143
|
+
if (debug && (outputWarnings.length || outputNames.length > 1)) {
|
|
144
|
+
rawOutputPath = await writeRawOutputArtifact({
|
|
145
|
+
stepDir: attemptDir,
|
|
146
|
+
step,
|
|
147
|
+
text,
|
|
148
|
+
reason: outputWarnings.length
|
|
149
|
+
? `Output warning(s): ${outputWarnings.join(', ')}`
|
|
150
|
+
: 'Debug raw output capture',
|
|
151
|
+
timeline,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const name of outputNames) {
|
|
156
|
+
registry[`${step.agent}.${name}`] = parsed[name];
|
|
157
|
+
let sink = step.outputs[name];
|
|
158
|
+
|
|
159
|
+
if (typeof sink === 'string') {
|
|
160
|
+
for (const [id, val] of Object.entries(inputValues)) {
|
|
161
|
+
sink = sink.replaceAll(`$INPUT:${id}`, val);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (attemptDir) sink = rewriteInternalSink(sink, { cwd, stepDir: attemptDir });
|
|
166
|
+
|
|
167
|
+
if (typeof sink === 'string' && sink.startsWith('$FILES:')) {
|
|
168
|
+
const baseDir = sink.slice('$FILES:'.length).trim();
|
|
169
|
+
const absBase = path.isAbsolute(baseDir) ? baseDir : path.join(cwd, baseDir);
|
|
170
|
+
const isRunArtifactSink = attemptDir && isInsidePath(absBase, attemptDir);
|
|
171
|
+
const rawJson = parsed[name].replace(/^```[a-z]*\n?/m, '').replace(/```\s*$/m, '').trim();
|
|
172
|
+
let manifest;
|
|
173
|
+
try { manifest = JSON.parse(rawJson); } catch {
|
|
174
|
+
await writeRawOutputArtifact({
|
|
175
|
+
stepDir: attemptDir,
|
|
176
|
+
step,
|
|
177
|
+
text,
|
|
178
|
+
reason: `Invalid $FILES JSON for output "${name}"`,
|
|
179
|
+
timeline,
|
|
180
|
+
});
|
|
181
|
+
failStep(timeline, timelineIndex, 'invalid $FILES JSON', `Step "${step.agent}" returned invalid JSON for $FILES output "${name}".`);
|
|
182
|
+
}
|
|
183
|
+
for (const entry of (Array.isArray(manifest) ? manifest : [])) {
|
|
184
|
+
const absOut = path.resolve(absBase, entry.path);
|
|
185
|
+
if (isRunArtifactSink) {
|
|
186
|
+
assertRunArtifactWriteAllowed(absOut, absBase, step.agent, name);
|
|
187
|
+
} else {
|
|
188
|
+
assertWriteAllowed(absOut, {
|
|
189
|
+
root: cwd,
|
|
190
|
+
agentName: step.agent,
|
|
191
|
+
outputName: name,
|
|
192
|
+
policy: securityPolicy,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
|
196
|
+
await fs.writeFile(absOut, entry.content);
|
|
197
|
+
fileWrites.push({
|
|
198
|
+
absPath: absOut,
|
|
199
|
+
relPath: path.relative(cwd, absOut),
|
|
200
|
+
kind: path.relative(cwd, absOut).startsWith('.singleton' + path.sep) ? 'intermediate' : 'deliverable',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} else if (typeof sink === 'string' && sink.startsWith('$FILE:')) {
|
|
204
|
+
const outPath = sink.slice('$FILE:'.length).trim();
|
|
205
|
+
const absOut = path.isAbsolute(outPath) ? outPath : path.resolve(cwd, outPath);
|
|
206
|
+
if (attemptDir && isInsidePath(absOut, attemptDir)) {
|
|
207
|
+
assertRunArtifactWriteAllowed(absOut, attemptDir, step.agent, name);
|
|
208
|
+
} else {
|
|
209
|
+
assertWriteAllowed(absOut, {
|
|
210
|
+
root: cwd,
|
|
211
|
+
agentName: step.agent,
|
|
212
|
+
outputName: name,
|
|
213
|
+
policy: securityPolicy,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
await fs.mkdir(path.dirname(absOut), { recursive: true });
|
|
217
|
+
await fs.writeFile(absOut, parsed[name]);
|
|
218
|
+
fileWrites.push({
|
|
219
|
+
absPath: absOut,
|
|
220
|
+
relPath: path.relative(cwd, absOut),
|
|
221
|
+
kind: path.relative(cwd, absOut).startsWith('.singleton' + path.sep) ? 'intermediate' : 'deliverable',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const attemptWrites = fileWrites.slice(stepWritesStart);
|
|
227
|
+
let stepChanges = [];
|
|
228
|
+
let stepAfterSnapshot = currentSnapshot;
|
|
229
|
+
if (stepBeforeSnapshot) {
|
|
230
|
+
stepAfterSnapshot = await snapshotManager.captureState();
|
|
231
|
+
stepChanges = snapshotManager.detectChanges(stepBeforeSnapshot, stepAfterSnapshot);
|
|
232
|
+
const violations = validatePostRunChanges({
|
|
233
|
+
changes: stepChanges,
|
|
234
|
+
securityPolicy,
|
|
235
|
+
step,
|
|
236
|
+
cwd,
|
|
237
|
+
});
|
|
238
|
+
await handlePostRunViolations({
|
|
239
|
+
violations,
|
|
240
|
+
step,
|
|
241
|
+
securityPolicy,
|
|
242
|
+
timeline,
|
|
243
|
+
timelineIndex,
|
|
244
|
+
shell,
|
|
245
|
+
cwd,
|
|
246
|
+
failStep,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
failed: false,
|
|
252
|
+
attemptDir,
|
|
253
|
+
text,
|
|
254
|
+
elapsedSeconds,
|
|
255
|
+
attemptTurns,
|
|
256
|
+
attemptCost,
|
|
257
|
+
stepWritesStart,
|
|
258
|
+
attemptWrites,
|
|
259
|
+
parsed,
|
|
260
|
+
outputWarnings,
|
|
261
|
+
parsedOutputSummary,
|
|
262
|
+
rawOutputPath,
|
|
263
|
+
stepChanges,
|
|
264
|
+
stepAfterSnapshot,
|
|
265
|
+
};
|
|
266
|
+
}
|