@fitlab-ai/agent-infra 0.8.0 → 0.8.1
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/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/run/index.js +96 -5
- package/dist/lib/sandbox/capture.js +113 -4
- package/dist/lib/sandbox/commands/create.js +3 -3
- package/dist/lib/sandbox/commands/enter.js +7 -3
- package/dist/lib/sandbox/credentials.js +42 -3
- package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/dist/lib/task/commands/log.js +36 -14
- package/dist/lib/task/commands/status.js +144 -45
- package/lib/run/index.ts +119 -6
- package/lib/sandbox/capture.ts +136 -5
- package/lib/sandbox/commands/create.ts +3 -3
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/credentials.ts +57 -3
- package/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/lib/task/commands/log.ts +36 -14
- package/lib/task/commands/status.ts +200 -58
- package/package.json +4 -3
- package/templates/.agents/rules/next-step-output.en.md +1 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
- package/templates/.agents/rules/pr-sync.github.en.md +3 -3
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
- package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { commandForEngine } from "../../sandbox/shell.js";
|
|
3
5
|
import { resolveTaskRef } from "../resolve-ref.js";
|
|
4
6
|
import { enumerateArtifacts } from "../artifacts.js";
|
|
5
7
|
import { parseTaskFrontmatter, extractTitle } from "../frontmatter.js";
|
|
6
8
|
import { loadShortIdByTaskId } from "../short-id.js";
|
|
9
|
+
import { parseActivityLog, pairEntries } from "./log.js";
|
|
7
10
|
const USAGE = `Usage: ai task status <N | #N | TASK-id>
|
|
8
11
|
|
|
9
|
-
Prints an aggregated "health check" view for a task: header, metadata,
|
|
10
|
-
artifacts
|
|
12
|
+
Prints an aggregated "health check" view for a task: header, metadata,
|
|
13
|
+
artifacts, workflow/runtime execution state, and git branch state.
|
|
11
14
|
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
|
|
12
15
|
|
|
13
|
-
Git
|
|
14
|
-
|
|
16
|
+
Git rows are best-effort: a failed git call degrades that row to '-' without
|
|
17
|
+
failing the command.
|
|
15
18
|
`;
|
|
16
19
|
const DASH = '-';
|
|
17
20
|
function makeRunner(cwd) {
|
|
18
21
|
return (file, args) => execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
19
22
|
}
|
|
20
|
-
// Run `run` and swallow any failure into null, so a single failing git
|
|
23
|
+
// Run `run` and swallow any failure into null, so a single failing git call
|
|
21
24
|
// degrades only its own field instead of aborting the whole view.
|
|
22
25
|
function tryRun(run, file, args) {
|
|
23
26
|
try {
|
|
@@ -37,9 +40,7 @@ const METADATA_KEYS = [
|
|
|
37
40
|
'branch',
|
|
38
41
|
'assigned_to',
|
|
39
42
|
'created_at',
|
|
40
|
-
'updated_at'
|
|
41
|
-
'issue_number',
|
|
42
|
-
'pr_status'
|
|
43
|
+
'updated_at'
|
|
43
44
|
];
|
|
44
45
|
function collectMetadata(fm) {
|
|
45
46
|
return METADATA_KEYS.map((key) => [key, fm[key] ? fm[key] : DASH]);
|
|
@@ -136,39 +137,123 @@ function collectGit(frontmatterBranch, run) {
|
|
|
136
137
|
}
|
|
137
138
|
return { current, frontmatter, match, exists, uncommitted, aheadBehind };
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
140
|
+
const STALE_MS = 60 * 60 * 1000;
|
|
141
|
+
function parseActivityTime(value) {
|
|
142
|
+
const epoch = Date.parse(value.replace(' ', 'T'));
|
|
143
|
+
return Number.isFinite(epoch) ? epoch : Number.NaN;
|
|
144
|
+
}
|
|
145
|
+
function collectWorkflow(content, now = new Date()) {
|
|
146
|
+
const parsed = parseActivityLog(content);
|
|
147
|
+
if (!parsed.sectionFound || parsed.entries.length === 0) {
|
|
148
|
+
return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
|
|
149
|
+
}
|
|
150
|
+
const rows = pairEntries(parsed.entries);
|
|
151
|
+
const latest = rows.at(-1);
|
|
152
|
+
if (!latest) {
|
|
153
|
+
return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
|
|
154
|
+
}
|
|
155
|
+
const inProgress = latest.started !== '' && latest.done === '';
|
|
156
|
+
const state = inProgress ? 'in-progress' : latest.done ? 'idle' : 'unknown';
|
|
157
|
+
let stale = DASH;
|
|
158
|
+
if (inProgress) {
|
|
159
|
+
const started = parseActivityTime(latest.started);
|
|
160
|
+
stale = Number.isFinite(started) ? (now.getTime() - started > STALE_MS ? 'yes' : 'no') : 'unknown';
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
state,
|
|
164
|
+
step: latest.step || DASH,
|
|
165
|
+
agent: latest.agent || DASH,
|
|
166
|
+
startedAt: latest.started || DASH,
|
|
167
|
+
doneAt: latest.done || DASH,
|
|
168
|
+
stale
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function latestRunRecord(taskDir) {
|
|
172
|
+
const runsDir = path.join(taskDir, 'runs');
|
|
173
|
+
if (!fs.existsSync(runsDir))
|
|
174
|
+
return null;
|
|
175
|
+
const candidates = [];
|
|
176
|
+
for (const entry of fs.readdirSync(runsDir)) {
|
|
177
|
+
if (!entry.endsWith('.json'))
|
|
178
|
+
continue;
|
|
179
|
+
const filePath = path.join(runsDir, entry);
|
|
180
|
+
const stat = fs.statSync(filePath);
|
|
181
|
+
if (stat.isFile())
|
|
182
|
+
candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
|
|
183
|
+
}
|
|
184
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
try {
|
|
187
|
+
const data = JSON.parse(fs.readFileSync(candidate.path, 'utf8'));
|
|
188
|
+
if (typeof data?.run_id === 'string' &&
|
|
189
|
+
typeof data?.engine === 'string' &&
|
|
190
|
+
typeof data?.container === 'string' &&
|
|
191
|
+
typeof data?.run_dir === 'string') {
|
|
192
|
+
return {
|
|
193
|
+
run_id: data.run_id,
|
|
194
|
+
engine: data.engine,
|
|
195
|
+
container: data.container,
|
|
196
|
+
run_dir: data.run_dir,
|
|
197
|
+
status_file: typeof data.status_file === 'string' ? data.status_file : `${data.run_dir}/status`,
|
|
198
|
+
log_file: typeof data.log_file === 'string' ? data.log_file : `${data.run_dir}/output.log`
|
|
199
|
+
};
|
|
153
200
|
}
|
|
154
201
|
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore malformed local records and try the next newest file.
|
|
204
|
+
}
|
|
155
205
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
function readRuntimeFile(record, filePath, run) {
|
|
209
|
+
const command = commandForEngine(record.engine, 'docker', ['exec', record.container, 'cat', filePath]);
|
|
210
|
+
const output = tryRun(run, command.cmd, command.args);
|
|
211
|
+
return output === null ? null : output.trim();
|
|
212
|
+
}
|
|
213
|
+
function runtimeValue(record, name, run) {
|
|
214
|
+
const output = readRuntimeFile(record, `${record.run_dir}/${name}`, run);
|
|
215
|
+
return output ? output : DASH;
|
|
216
|
+
}
|
|
217
|
+
function collectRuntime(taskDir, workflow, run) {
|
|
218
|
+
const record = latestRunRecord(taskDir);
|
|
219
|
+
if (!record) {
|
|
220
|
+
return workflow.state === 'in-progress'
|
|
221
|
+
? {
|
|
222
|
+
mode: 'unmanaged',
|
|
223
|
+
status: 'inferred-from-workflow',
|
|
224
|
+
run: DASH,
|
|
225
|
+
tmux: DASH,
|
|
226
|
+
startedAt: DASH,
|
|
227
|
+
finishedAt: DASH,
|
|
228
|
+
exitCode: DASH,
|
|
229
|
+
log: DASH
|
|
168
230
|
}
|
|
169
|
-
|
|
231
|
+
: {
|
|
232
|
+
mode: 'none',
|
|
233
|
+
status: DASH,
|
|
234
|
+
run: DASH,
|
|
235
|
+
tmux: DASH,
|
|
236
|
+
startedAt: DASH,
|
|
237
|
+
finishedAt: DASH,
|
|
238
|
+
exitCode: DASH,
|
|
239
|
+
log: DASH
|
|
240
|
+
};
|
|
170
241
|
}
|
|
171
|
-
|
|
242
|
+
const status = readRuntimeFile(record, record.status_file, run)?.trim() || 'unknown';
|
|
243
|
+
const session = runtimeValue(record, 'session', run);
|
|
244
|
+
const window = runtimeValue(record, 'window', run);
|
|
245
|
+
const pane = runtimeValue(record, 'pane', run);
|
|
246
|
+
const tmux = session !== DASH && window !== DASH && pane !== DASH ? `${session}:${window}:${pane}` : DASH;
|
|
247
|
+
return {
|
|
248
|
+
mode: 'managed-tmux',
|
|
249
|
+
status,
|
|
250
|
+
run: record.run_id,
|
|
251
|
+
tmux,
|
|
252
|
+
startedAt: runtimeValue(record, 'started_at', run),
|
|
253
|
+
finishedAt: runtimeValue(record, 'finished_at', run),
|
|
254
|
+
exitCode: runtimeValue(record, 'exit_code', run),
|
|
255
|
+
log: record.log_file
|
|
256
|
+
};
|
|
172
257
|
}
|
|
173
258
|
// Indent each label/value pair by two spaces and pad labels to a common width so
|
|
174
259
|
// every section reads as an aligned "key value" block.
|
|
@@ -189,6 +274,24 @@ function renderStatus(model) {
|
|
|
189
274
|
else {
|
|
190
275
|
lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
|
|
191
276
|
}
|
|
277
|
+
lines.push('', 'Workflow', ...renderPairs([
|
|
278
|
+
['state', model.workflow.state],
|
|
279
|
+
['step', model.workflow.step],
|
|
280
|
+
['agent', model.workflow.agent],
|
|
281
|
+
['started_at', model.workflow.startedAt],
|
|
282
|
+
['done_at', model.workflow.doneAt],
|
|
283
|
+
['stale', model.workflow.stale]
|
|
284
|
+
]));
|
|
285
|
+
lines.push('', 'Runtime', ...renderPairs([
|
|
286
|
+
['mode', model.runtime.mode],
|
|
287
|
+
['status', model.runtime.status],
|
|
288
|
+
['run', model.runtime.run],
|
|
289
|
+
['tmux', model.runtime.tmux],
|
|
290
|
+
['started_at', model.runtime.startedAt],
|
|
291
|
+
['finished_at', model.runtime.finishedAt],
|
|
292
|
+
['exit_code', model.runtime.exitCode],
|
|
293
|
+
['log', model.runtime.log]
|
|
294
|
+
]));
|
|
192
295
|
lines.push('', 'Git', ...renderPairs([
|
|
193
296
|
['current', model.git.current],
|
|
194
297
|
['frontmatter', model.git.frontmatter],
|
|
@@ -197,11 +300,6 @@ function renderStatus(model) {
|
|
|
197
300
|
['uncommitted', model.git.uncommitted],
|
|
198
301
|
['ahead/behind', model.git.aheadBehind]
|
|
199
302
|
]));
|
|
200
|
-
const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
|
|
201
|
-
lines.push('', 'Platform', ...renderPairs([
|
|
202
|
-
[issueLabel, model.platform.issue],
|
|
203
|
-
['pr', model.platform.pr]
|
|
204
|
-
]));
|
|
205
303
|
return lines;
|
|
206
304
|
}
|
|
207
305
|
function status(args = []) {
|
|
@@ -221,19 +319,20 @@ function status(args = []) {
|
|
|
221
319
|
const fm = parseTaskFrontmatter(content);
|
|
222
320
|
const run = makeRunner(resolved.repoRoot);
|
|
223
321
|
const artifacts = enumerateArtifacts(resolved.taskDir);
|
|
322
|
+
const workflow = collectWorkflow(content);
|
|
224
323
|
const model = {
|
|
225
324
|
taskId: resolved.taskId,
|
|
226
325
|
shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
|
|
227
326
|
title: extractTitle(content),
|
|
228
|
-
issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
|
|
229
327
|
metadata: collectMetadata(fm),
|
|
230
328
|
artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
|
|
231
|
-
|
|
232
|
-
|
|
329
|
+
workflow,
|
|
330
|
+
runtime: collectRuntime(resolved.taskDir, workflow, run),
|
|
331
|
+
git: collectGit(fm.branch ?? '', run)
|
|
233
332
|
};
|
|
234
333
|
for (const line of renderStatus(model)) {
|
|
235
334
|
process.stdout.write(`${line}\n`);
|
|
236
335
|
}
|
|
237
336
|
}
|
|
238
|
-
export { status, makeRunner, collectMetadata, groupArtifacts, collectGit,
|
|
337
|
+
export { status, makeRunner, collectMetadata, groupArtifacts, collectGit, collectWorkflow, collectRuntime, renderStatus, METADATA_KEYS };
|
|
239
338
|
//# sourceMappingURL=status.js.map
|
package/lib/run/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { loadServerConfig } from '../server/config.ts';
|
|
2
4
|
import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
|
|
3
|
-
import { runInSandbox } from '../sandbox/capture.ts';
|
|
5
|
+
import { createRunId, runInSandbox, type SandboxRunMetadata } from '../sandbox/capture.ts';
|
|
6
|
+
import { loadShortIdByTaskId, normalizeShortIdInput } from '../task/short-id.ts';
|
|
4
7
|
import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
|
|
5
8
|
import { getSkillRunSpec } from './skills.ts';
|
|
6
9
|
import { runHostCommand, type RunProcessResult } from './host.ts';
|
|
@@ -16,19 +19,27 @@ export type SandboxRunRequest = {
|
|
|
16
19
|
taskRef: string;
|
|
17
20
|
branch: string;
|
|
18
21
|
command: string[];
|
|
22
|
+
runId?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SandboxRunResult = RunProcessResult & {
|
|
26
|
+
run?: SandboxRunMetadata;
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
export type RunSkillOptions = {
|
|
22
30
|
command?: Record<string, unknown>;
|
|
23
31
|
repoRoot?: string;
|
|
24
32
|
runHost?: (command: string[]) => Promise<RunProcessResult>;
|
|
25
|
-
runSandbox?: (request: SandboxRunRequest) => Promise<
|
|
33
|
+
runSandbox?: (request: SandboxRunRequest) => Promise<SandboxRunResult>;
|
|
26
34
|
writeStdout?: (chunk: string) => void;
|
|
27
35
|
writeStderr?: (chunk: string) => void;
|
|
28
36
|
};
|
|
29
37
|
|
|
30
38
|
const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
|
|
31
39
|
|
|
40
|
+
Task skills are scheduled inside the sandbox tmux session; ai run returns once
|
|
41
|
+
the tmux window is created.
|
|
42
|
+
|
|
32
43
|
Examples:
|
|
33
44
|
ai run create-task "describe the task" --tui codex
|
|
34
45
|
ai run code-task #7 --tui codex`;
|
|
@@ -78,6 +89,99 @@ function assertAllowedByConfig(skill: string, commandConfig: Record<string, unkn
|
|
|
78
89
|
}
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
93
|
+
|
|
94
|
+
function readShortIdLength(repoRoot: string): number {
|
|
95
|
+
try {
|
|
96
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
|
|
97
|
+
const value = cfg?.task?.shortIdLength;
|
|
98
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 1) return value;
|
|
99
|
+
} catch {
|
|
100
|
+
// Use the project default when config is absent in lightweight tests.
|
|
101
|
+
}
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readActiveShortIdRegistry(repoRoot: string): Record<string, string> {
|
|
106
|
+
const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
109
|
+
return data && typeof data === 'object' && data.ids && typeof data.ids === 'object' ? data.ids : {};
|
|
110
|
+
} catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ActiveTaskIdentity = {
|
|
116
|
+
taskId: string;
|
|
117
|
+
taskDir: string;
|
|
118
|
+
taskRef: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function resolveActiveTaskIdentity(taskRef: string, repoRoot: string): ActiveTaskIdentity | null {
|
|
122
|
+
let taskId: string | null = null;
|
|
123
|
+
let resolvedTaskRef = taskRef;
|
|
124
|
+
|
|
125
|
+
if (TASK_ID_RE.test(taskRef)) {
|
|
126
|
+
taskId = taskRef;
|
|
127
|
+
resolvedTaskRef = loadShortIdByTaskId(repoRoot).get(taskId) ?? taskRef;
|
|
128
|
+
} else {
|
|
129
|
+
const normalized = normalizeShortIdInput(taskRef, { shortIdLength: readShortIdLength(repoRoot) });
|
|
130
|
+
if (normalized.kind !== 'shortId') return null;
|
|
131
|
+
resolvedTaskRef = normalized.value;
|
|
132
|
+
taskId = readActiveShortIdRegistry(repoRoot)[normalized.value.slice(1)] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!taskId) return null;
|
|
136
|
+
const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
|
|
137
|
+
if (!fs.existsSync(path.join(taskDir, 'task.md'))) return null;
|
|
138
|
+
return { taskId, taskDir, taskRef: resolvedTaskRef };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatLocalTimestamp(date: Date = new Date()): string {
|
|
142
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
143
|
+
const year = date.getFullYear();
|
|
144
|
+
const month = pad(date.getMonth() + 1);
|
|
145
|
+
const day = pad(date.getDate());
|
|
146
|
+
const hour = pad(date.getHours());
|
|
147
|
+
const minute = pad(date.getMinutes());
|
|
148
|
+
const second = pad(date.getSeconds());
|
|
149
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
150
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
151
|
+
const offsetHour = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
152
|
+
const offsetMinute = pad(Math.abs(offsetMinutes) % 60);
|
|
153
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMinute}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeRunRecord(params: {
|
|
157
|
+
identity: ActiveTaskIdentity;
|
|
158
|
+
run: SandboxRunMetadata;
|
|
159
|
+
branch: string;
|
|
160
|
+
command: string[];
|
|
161
|
+
}): void {
|
|
162
|
+
const runsDir = path.join(params.identity.taskDir, 'runs');
|
|
163
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
164
|
+
const record = {
|
|
165
|
+
version: 1,
|
|
166
|
+
run_id: params.run.runId,
|
|
167
|
+
task_id: params.identity.taskId,
|
|
168
|
+
task_ref: params.identity.taskRef,
|
|
169
|
+
branch: params.branch,
|
|
170
|
+
engine: params.run.engine,
|
|
171
|
+
container: params.run.container,
|
|
172
|
+
run_dir: params.run.runDir,
|
|
173
|
+
status_file: `${params.run.runDir}/status`,
|
|
174
|
+
log_file: `${params.run.runDir}/output.log`,
|
|
175
|
+
created_at: formatLocalTimestamp(),
|
|
176
|
+
command: params.command
|
|
177
|
+
};
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
path.join(runsDir, `${params.run.runId}.json`),
|
|
180
|
+
`${JSON.stringify(record, null, 2)}\n`,
|
|
181
|
+
'utf8'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
81
185
|
export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
|
|
82
186
|
const parsed = parseRunArgs(args);
|
|
83
187
|
const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
|
|
@@ -96,13 +200,22 @@ export async function runSkill(args: string[], options: RunSkillOptions = {}): P
|
|
|
96
200
|
|
|
97
201
|
const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
|
|
98
202
|
const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
|
|
99
|
-
const
|
|
100
|
-
const
|
|
203
|
+
const identity = resolveActiveTaskIdentity(parsed.taskRef, repoRoot);
|
|
204
|
+
const runId = identity ? createRunId() : undefined;
|
|
205
|
+
const writeStdout = options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk));
|
|
206
|
+
const writeStderr = options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk));
|
|
207
|
+
const runSandbox =
|
|
208
|
+
options.runSandbox ??
|
|
209
|
+
((request: SandboxRunRequest) => runInSandbox(request));
|
|
210
|
+
const result = await runSandbox({ taskRef: parsed.taskRef, branch, command, runId });
|
|
101
211
|
if (result.stdout) {
|
|
102
|
-
|
|
212
|
+
writeStdout(result.stdout);
|
|
103
213
|
}
|
|
104
214
|
if (result.stderr) {
|
|
105
|
-
|
|
215
|
+
writeStderr(result.stderr);
|
|
216
|
+
}
|
|
217
|
+
if ((result.exitCode ?? (result.signal ? 1 : 0)) === 0 && identity && result.run) {
|
|
218
|
+
writeRunRecord({ identity, run: result.run, branch, command });
|
|
106
219
|
}
|
|
107
220
|
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
108
221
|
}
|
package/lib/sandbox/capture.ts
CHANGED
|
@@ -22,22 +22,45 @@ export type SandboxCaptureResult = {
|
|
|
22
22
|
signal: NodeJS.Signals | null;
|
|
23
23
|
stdout: string;
|
|
24
24
|
stderr: string;
|
|
25
|
+
run?: SandboxRunMetadata;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SandboxRunMetadata = {
|
|
29
|
+
runId: string;
|
|
30
|
+
engine: string;
|
|
31
|
+
container: string;
|
|
32
|
+
runDir: string;
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
export type SandboxCaptureOptions = {
|
|
28
36
|
engine?: string;
|
|
29
37
|
repoRoot?: string;
|
|
38
|
+
runId?: string;
|
|
30
39
|
containerCandidates?: string[];
|
|
31
40
|
rows?: SandboxRow[];
|
|
32
41
|
startContainer?: (name: string) => void;
|
|
33
42
|
spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
|
|
34
43
|
};
|
|
35
44
|
|
|
36
|
-
async function spawnCapture(
|
|
45
|
+
async function spawnCapture(
|
|
46
|
+
file: string,
|
|
47
|
+
args: string[]
|
|
48
|
+
): Promise<SandboxCaptureResult> {
|
|
37
49
|
return new Promise((resolve, reject) => {
|
|
38
50
|
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
51
|
let stdout = '';
|
|
40
52
|
let stderr = '';
|
|
53
|
+
let settled = false;
|
|
54
|
+
const rejectOnce = (error: unknown): void => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
reject(error);
|
|
58
|
+
};
|
|
59
|
+
const resolveOnce = (result: SandboxCaptureResult): void => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
resolve(result);
|
|
63
|
+
};
|
|
41
64
|
child.stdout.setEncoding('utf8');
|
|
42
65
|
child.stderr.setEncoding('utf8');
|
|
43
66
|
child.stdout.on('data', (chunk) => {
|
|
@@ -46,11 +69,106 @@ async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptur
|
|
|
46
69
|
child.stderr.on('data', (chunk) => {
|
|
47
70
|
stderr += chunk;
|
|
48
71
|
});
|
|
49
|
-
child.on('error',
|
|
50
|
-
child.on('close', (exitCode, signal) =>
|
|
72
|
+
child.on('error', rejectOnce);
|
|
73
|
+
child.on('close', (exitCode, signal) => {
|
|
74
|
+
resolveOnce({ exitCode, signal, stdout, stderr });
|
|
75
|
+
});
|
|
51
76
|
});
|
|
52
77
|
}
|
|
53
78
|
|
|
79
|
+
function shellQuote(value: string): string {
|
|
80
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createRunId(date: Date = new Date()): string {
|
|
84
|
+
return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRunScript(params: {
|
|
88
|
+
command: string[];
|
|
89
|
+
runDir: string;
|
|
90
|
+
runId: string;
|
|
91
|
+
}): string {
|
|
92
|
+
const command = params.command.map(shellQuote).join(' ');
|
|
93
|
+
const runDir = shellQuote(params.runDir);
|
|
94
|
+
const runId = shellQuote(params.runId);
|
|
95
|
+
return `#!/bin/sh
|
|
96
|
+
set -u
|
|
97
|
+
cd /workspace
|
|
98
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
|
|
99
|
+
printf '%s\\n' running > ${runDir}/status
|
|
100
|
+
${command}
|
|
101
|
+
code=$?
|
|
102
|
+
printf '%s\\n' "$code" > ${runDir}/exit_code
|
|
103
|
+
date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
|
|
104
|
+
if [ "$code" -eq 0 ]; then
|
|
105
|
+
printf '%s\\n' completed > ${runDir}/status
|
|
106
|
+
else
|
|
107
|
+
printf '%s\\n' failed > ${runDir}/status
|
|
108
|
+
fi
|
|
109
|
+
printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
|
|
110
|
+
exec bash -l
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildTmuxLauncher(params: {
|
|
115
|
+
request: SandboxCaptureRequest;
|
|
116
|
+
runId: string;
|
|
117
|
+
}): string {
|
|
118
|
+
const session = 'work';
|
|
119
|
+
const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
|
|
120
|
+
const runRoot = '/tmp/agent-infra-runs';
|
|
121
|
+
const runDir = `${runRoot}/${params.runId}`;
|
|
122
|
+
const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
|
|
123
|
+
const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
|
|
124
|
+
const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
|
|
125
|
+
|
|
126
|
+
return `set -eu
|
|
127
|
+
session=${shellQuote(session)}
|
|
128
|
+
window=${shellQuote(window)}
|
|
129
|
+
run_id=${shellQuote(params.runId)}
|
|
130
|
+
run_dir=${shellQuote(runDir)}
|
|
131
|
+
task_ref=${shellQuote(params.request.taskRef)}
|
|
132
|
+
branch=${shellQuote(params.request.branch)}
|
|
133
|
+
|
|
134
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
135
|
+
mkdir -p "$run_dir"
|
|
136
|
+
printf '%s\\n' pending > "$run_dir/status"
|
|
137
|
+
printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
|
|
138
|
+
printf '%s\\n' "$branch" > "$run_dir/branch"
|
|
139
|
+
printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
|
|
140
|
+
printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
|
|
141
|
+
chmod +x "$run_dir/run.sh"
|
|
142
|
+
|
|
143
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
144
|
+
printf '%s\\n' "tmux is not installed in this sandbox" >&2
|
|
145
|
+
exit 127
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
if ! tmux has-session -t "$session" 2>/dev/null; then
|
|
149
|
+
tmux new-session -d -s "$session" -n shell
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [ -n "\${TZ:-}" ]; then
|
|
153
|
+
tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
|
|
157
|
+
printf '%s\\n' "$session" > "$run_dir/session"
|
|
158
|
+
printf '%s\\n' "$window" > "$run_dir/window"
|
|
159
|
+
printf '%s\\n' "$pane" > "$run_dir/pane"
|
|
160
|
+
tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
|
|
161
|
+
tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
|
|
162
|
+
|
|
163
|
+
cat <<EOF
|
|
164
|
+
Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
|
|
165
|
+
Attach with: ai sandbox enter $task_ref
|
|
166
|
+
Status file: $run_dir/status
|
|
167
|
+
Output log: $run_dir/output.log
|
|
168
|
+
EOF
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
54
172
|
export async function runInSandbox(
|
|
55
173
|
request: SandboxCaptureRequest,
|
|
56
174
|
options: SandboxCaptureOptions = {}
|
|
@@ -73,12 +191,25 @@ export async function runInSandbox(
|
|
|
73
191
|
if (!found.running) {
|
|
74
192
|
(options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
|
|
75
193
|
}
|
|
194
|
+
const runId = options.runId ?? createRunId();
|
|
195
|
+
const runDir = `/tmp/agent-infra-runs/${runId}`;
|
|
76
196
|
const dockerArgs = [
|
|
77
197
|
'exec',
|
|
78
198
|
...terminalEnvFlags(),
|
|
79
199
|
...hostTimezoneEnvFlags(),
|
|
80
200
|
found.name,
|
|
81
|
-
|
|
201
|
+
'bash',
|
|
202
|
+
'-lc',
|
|
203
|
+
buildTmuxLauncher({ request, runId })
|
|
82
204
|
];
|
|
83
|
-
|
|
205
|
+
const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
|
|
206
|
+
return {
|
|
207
|
+
...result,
|
|
208
|
+
run: {
|
|
209
|
+
runId,
|
|
210
|
+
engine,
|
|
211
|
+
container: found.name,
|
|
212
|
+
runDir
|
|
213
|
+
}
|
|
214
|
+
};
|
|
84
215
|
}
|
|
@@ -1438,9 +1438,9 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1438
1438
|
if (claudeCodeEntry) {
|
|
1439
1439
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1440
1440
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1441
|
-
// prepareClaudeCredentials wrote
|
|
1442
|
-
//
|
|
1443
|
-
// claude-code entry was removed
|
|
1441
|
+
// prepareClaudeCredentials wrote OAuth credentials or confirmed
|
|
1442
|
+
// provider/API settings are enough for Claude Code. If no usable
|
|
1443
|
+
// auth was present, the claude-code entry was removed above.
|
|
1444
1444
|
}
|
|
1445
1445
|
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1446
1446
|
if (codexEntry) {
|
|
@@ -9,6 +9,7 @@ import { detectEngine } from '../engine.ts';
|
|
|
9
9
|
import {
|
|
10
10
|
formatCredentialWarnings,
|
|
11
11
|
formatRemaining,
|
|
12
|
+
hasClaudeProviderAuth,
|
|
12
13
|
reconcileClaudeCredentials,
|
|
13
14
|
redactCommandError,
|
|
14
15
|
validateClaudeCredentialsEnvOverride
|
|
@@ -93,8 +94,12 @@ export function runSandboxInteractive(params: {
|
|
|
93
94
|
|
|
94
95
|
export function formatCredentialSyncStatus(
|
|
95
96
|
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
96
|
-
isTTY = process.stderr.isTTY
|
|
97
|
+
isTTY = process.stderr.isTTY,
|
|
98
|
+
providerAuthAvailable = false
|
|
97
99
|
): string | null {
|
|
100
|
+
if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
98
103
|
if (result.status === 'STALE_ACCESS') {
|
|
99
104
|
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
100
105
|
}
|
|
@@ -158,8 +163,9 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
158
163
|
if (config.tools.includes('claude-code')) {
|
|
159
164
|
try {
|
|
160
165
|
// Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
|
|
166
|
+
const providerAuthAvailable = hasClaudeProviderAuth(config.home);
|
|
161
167
|
const result = reconcileClaudeCredentials(config.home);
|
|
162
|
-
const message = formatCredentialSyncStatus(result);
|
|
168
|
+
const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
|
|
163
169
|
if (message) {
|
|
164
170
|
process.stderr.write(message);
|
|
165
171
|
}
|