@fitlab-ai/agent-infra 0.11.7 → 0.11.8
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/bin/internal-cli.js +8 -2
- package/dist/lib/agent-clients/adapters/codex.js +1 -1
- package/dist/lib/agent-clients/config.js +11 -3
- package/dist/lib/defaults.json +6 -3
- package/dist/lib/internal/cli-route-inventory.js +3 -0
- package/dist/lib/internal/platform-pr-review.js +20 -3
- package/dist/lib/internal/task-operation-registry.js +3 -1
- package/dist/lib/internal/task-platform-recovery.js +56 -0
- package/dist/lib/merge.js +25 -5
- package/dist/lib/platform/completion-backfill.js +2 -1
- package/dist/lib/platform/in-label-sync.js +12 -1
- package/dist/lib/platform/issue-comments.js +58 -4
- package/dist/lib/platform/issues.js +105 -5
- package/dist/lib/platform/operation-coordinator.js +74 -0
- package/dist/lib/platform/pr-review.js +107 -1
- package/dist/lib/platform/pr-summary.js +79 -57
- package/dist/lib/platform/pull-requests.js +96 -4
- package/dist/lib/sandbox/commands/create.js +6 -3
- package/dist/lib/sandbox/commands/ls.js +4 -3
- package/dist/lib/sandbox/commands/rm.js +1 -1
- package/dist/lib/sandbox/config.js +22 -4
- package/dist/lib/sandbox/git-metadata.js +50 -0
- package/dist/lib/sandbox/index.js +1 -1
- package/dist/lib/sandbox/mounts.js +12 -0
- package/dist/lib/sandbox/recovery.js +11 -0
- package/dist/lib/sandbox/removal.js +58 -20
- package/dist/lib/sandbox/tools.js +29 -7
- package/dist/lib/task/activity-intent.js +4 -3
- package/dist/lib/task/create-service.js +5 -3
- package/dist/lib/task/create.js +22 -5
- package/dist/lib/task/finalization-artifacts.js +4 -1
- package/dist/lib/task/finalization.js +157 -24
- package/dist/lib/task/lifecycle.js +38 -2
- package/dist/lib/task/platform-operation-journal.js +152 -0
- package/dist/lib/task/platform-operation-recovery.js +459 -0
- package/dist/lib/task/workflow-warning-intents.js +7 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex.ts +1 -1
- package/lib/agent-clients/config.ts +9 -3
- package/lib/defaults.json +6 -3
- package/lib/internal/cli-route-inventory.ts +2 -0
- package/lib/internal/platform-pr-review.ts +14 -3
- package/lib/internal/task-operation-registry.ts +3 -1
- package/lib/internal/task-platform-recovery.ts +53 -0
- package/lib/merge.ts +26 -6
- package/lib/platform/completion-backfill.ts +2 -1
- package/lib/platform/in-label-sync.ts +12 -0
- package/lib/platform/issue-comments.ts +59 -3
- package/lib/platform/issues.ts +103 -5
- package/lib/platform/operation-coordinator.ts +96 -0
- package/lib/platform/pr-review.ts +113 -1
- package/lib/platform/pr-summary.ts +22 -1
- package/lib/platform/pull-requests.ts +105 -5
- package/lib/sandbox/commands/create.ts +7 -8
- package/lib/sandbox/commands/ls.ts +4 -3
- package/lib/sandbox/commands/rm.ts +1 -1
- package/lib/sandbox/config.ts +23 -5
- package/lib/sandbox/git-metadata.ts +66 -0
- package/lib/sandbox/index.ts +1 -1
- package/lib/sandbox/mounts.ts +14 -0
- package/lib/sandbox/recovery.ts +13 -0
- package/lib/sandbox/removal.ts +57 -19
- package/lib/sandbox/tool-types.ts +1 -1
- package/lib/sandbox/tools.ts +36 -7
- package/lib/task/activity-intent.ts +4 -3
- package/lib/task/create-service.ts +5 -3
- package/lib/task/create.ts +17 -5
- package/lib/task/finalization-artifacts.ts +4 -1
- package/lib/task/finalization.ts +154 -21
- package/lib/task/lifecycle.ts +35 -3
- package/lib/task/platform-operation-journal.ts +230 -0
- package/lib/task/platform-operation-recovery.ts +435 -0
- package/lib/task/workflow-warning-intents.ts +9 -1
- package/package.json +1 -1
- package/templates/.agents/README.en.md +19 -17
- package/templates/.agents/README.zh-CN.md +18 -16
- package/templates/.agents/rules/README.en.md +1 -1
- package/templates/.agents/rules/README.zh-CN.md +1 -1
- package/templates/.agents/rules/task-management.en.md +12 -0
- package/templates/.agents/rules/task-management.zh-CN.md +12 -0
- package/templates/.agents/skills/cancel-task/SKILL.en.md +4 -2
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +4 -2
- package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-pr/SKILL.en.md +11 -11
- package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +11 -11
- package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
- package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
- package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
- package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +19 -6
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
import { inspectPlatformCommentOperation, normalizeCommentContent, syncPlatformComment } from '../platform/issue-comments.ts';
|
|
7
|
+
import { inspectPlatformIssue, syncPlatformIssue } from '../platform/issues.ts';
|
|
8
|
+
import { resolvePlatformProviderContext } from '../platform/context.ts';
|
|
9
|
+
import { providerOperationContext, providerError, unsupportedProviderOperation } from '../platform/provider-bridge.ts';
|
|
10
|
+
import { inLabelMappingDigest } from '../platform/in-label-sync.ts';
|
|
11
|
+
import { bindPlatformPullRequest, inspectPlatformPullRequest, recoverCreatedPullRequest, syncPlatformPullRequest } from '../platform/pull-requests.ts';
|
|
12
|
+
import { syncPullRequestSummary } from '../platform/pr-summary.ts';
|
|
13
|
+
import { publishPrReview, readReviewBodyFile } from '../platform/pr-review.ts';
|
|
14
|
+
import type { PlatformClient } from '../platform/context.ts';
|
|
15
|
+
import { parseTaskFrontmatter } from './frontmatter.ts';
|
|
16
|
+
import { taskIssueIdentity } from '../platform/task-identities.ts';
|
|
17
|
+
import { parseResourceIdentity, resourceIdentityEquals } from '../platform/resource-identity.ts';
|
|
18
|
+
import { operationId } from './platform-operation-journal.ts';
|
|
19
|
+
import { canonicalizeSummaryBody } from '../platform/comment-safety.ts';
|
|
20
|
+
import { platformResult } from '../platform/types.ts';
|
|
21
|
+
import type { PlatformResult } from '../platform/types.ts';
|
|
22
|
+
import { resolveTaskRef } from './resolve-ref.ts';
|
|
23
|
+
import { readPlatformOperationJournal, recordPlatformOperation } from './platform-operation-journal.ts';
|
|
24
|
+
|
|
25
|
+
type RecoveryOptions = Readonly<{ agent: string; client?: PlatformClient; cwd?: string; limit?: number; excludeId?: string }>;
|
|
26
|
+
type RecoveryResult = Readonly<{
|
|
27
|
+
status: 'applied' | 'no-op' | 'blocked' | 'failed';
|
|
28
|
+
changed: boolean;
|
|
29
|
+
recovered: readonly string[];
|
|
30
|
+
pending: readonly string[];
|
|
31
|
+
error: { code: string; message: string; retryable: boolean } | null;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
function labelsMatchOwnedPrefix(actual: readonly string[], expected: readonly string[], prefix: 'status:' | 'in:'): boolean {
|
|
35
|
+
return actual.filter((label) => label.startsWith(prefix)).sort().join('\0')
|
|
36
|
+
=== expected.filter((label) => label.startsWith(prefix)).sort().join('\0');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fieldsMatchExpected(
|
|
40
|
+
actual: Readonly<Record<string, string | number | null>>,
|
|
41
|
+
expected: Readonly<Record<string, string | number | null>>
|
|
42
|
+
): boolean {
|
|
43
|
+
return Object.entries(expected).every(([name, value]) => actual[name] === value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function result(status: RecoveryResult['status'], recovered: string[], pending: string[], error: RecoveryResult['error'] = null): RecoveryResult {
|
|
47
|
+
return { status, changed: recovered.length > 0, recovered, pending, error };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function summaryPayload(taskDir: string, taskId: string): { body: string; sha256: string } | null {
|
|
51
|
+
const file = path.join(taskDir, '.delivery-summary.json');
|
|
52
|
+
if (!fs.existsSync(file)) return null;
|
|
53
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { taskId?: unknown; body?: unknown; sha256?: unknown };
|
|
54
|
+
const canonical = typeof data.body === 'string' ? canonicalizeSummaryBody(data.body) : null;
|
|
55
|
+
if (data.taskId !== taskId || typeof data.body !== 'string' || typeof data.sha256 !== 'string'
|
|
56
|
+
|| !canonical?.ok || canonical.value !== data.body
|
|
57
|
+
|| createHash('sha256').update(data.body).digest('hex') !== data.sha256) {
|
|
58
|
+
throw Object.assign(new Error('delivery summary staging record is invalid'), { code: 'SUMMARY_STAGING_INVALID' });
|
|
59
|
+
}
|
|
60
|
+
return { body: data.body, sha256: data.sha256 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function replayComment(taskId: string, taskDir: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
|
|
64
|
+
if (operation.kind === 'task-comment') {
|
|
65
|
+
return syncPlatformComment(taskId, { kind: 'task', agent, cwd, client, dependency: operation.dependency, skipQueue: true });
|
|
66
|
+
}
|
|
67
|
+
if (operation.kind === 'artifact-comment') {
|
|
68
|
+
return syncPlatformComment(taskId, { kind: 'artifact', artifact: operation.target, agent, cwd, client, dependency: operation.dependency, skipQueue: true });
|
|
69
|
+
}
|
|
70
|
+
if (operation.kind === 'summary-comment') {
|
|
71
|
+
const summary = summaryPayload(taskDir, taskId);
|
|
72
|
+
if (!summary) return platformResult('failed', { error: { code: 'SUMMARY_STAGING_MISSING', message: 'delivery summary staging record is unavailable', retryable: false } });
|
|
73
|
+
return syncPlatformComment(taskId, {
|
|
74
|
+
kind: 'summary', body: summary.body, agent, cwd,
|
|
75
|
+
summaryAuthorization: { sha256: summary.sha256 }, dependency: operation.dependency, client, skipQueue: true
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (operation.kind === 'cancel-comment') {
|
|
79
|
+
return syncPlatformComment(taskId, { kind: 'cancel', agent, cwd, client, dependency: operation.dependency, skipQueue: true });
|
|
80
|
+
}
|
|
81
|
+
return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_UNSUPPORTED', message: `operation kind '${operation.kind}' has no registered recovery handler`, retryable: false } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function currentIssueMetadataOperation(taskId: string, taskMdPath: string, repoRoot: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number]) {
|
|
85
|
+
if (operation.kind !== 'issue-metadata' || !operation.issueMetadata) return null;
|
|
86
|
+
const content = fs.readFileSync(taskMdPath, 'utf8');
|
|
87
|
+
const frontmatter = parseTaskFrontmatter(content);
|
|
88
|
+
const identity = taskIssueIdentity(frontmatter);
|
|
89
|
+
if (!identity) return null;
|
|
90
|
+
let fromDiffFiles: string[] | undefined;
|
|
91
|
+
let mappingDigest: string | undefined;
|
|
92
|
+
if (operation.issueMetadata.inLabels === 'from-diff') {
|
|
93
|
+
const taskBase = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
|
|
94
|
+
if (!taskBase || (operation.issueMetadata.base && operation.issueMetadata.base !== taskBase)) return null;
|
|
95
|
+
try {
|
|
96
|
+
fromDiffFiles = execFileSync('git', ['diff', `${taskBase}...HEAD`, '--name-only'], {
|
|
97
|
+
cwd: repoRoot, encoding: 'utf8'
|
|
98
|
+
}).trim().split(/\r?\n/).filter(Boolean).sort();
|
|
99
|
+
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { labels?: { in?: unknown } };
|
|
100
|
+
const mapping = inLabelMappingDigest(config.labels?.in);
|
|
101
|
+
if (!mapping.ok) return null;
|
|
102
|
+
mappingDigest = mapping.digest;
|
|
103
|
+
} catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
const currentMetadata = {
|
|
106
|
+
...operation.issueMetadata,
|
|
107
|
+
...(fromDiffFiles ? { fromDiffFiles } : {}),
|
|
108
|
+
...(mappingDigest ? { inLabelMappingDigest: mappingDigest } : {})
|
|
109
|
+
};
|
|
110
|
+
const target = JSON.stringify(identity);
|
|
111
|
+
const expectedDigest = createHash('sha256').update(JSON.stringify({
|
|
112
|
+
...currentMetadata,
|
|
113
|
+
task: taskId,
|
|
114
|
+
taskContent: createHash('sha256').update(content).digest('hex')
|
|
115
|
+
})).digest('hex');
|
|
116
|
+
return { kind: 'issue-metadata' as const, target, expectedDigest, issueMetadata: currentMetadata, id: operationId({ kind: 'issue-metadata', target, expectedDigest }) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function replayIssueMetadata(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
|
|
120
|
+
if (!operation.issueMetadata) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Issue metadata recovery parameters are missing', retryable: false } });
|
|
121
|
+
let expectedIdentity;
|
|
122
|
+
try { expectedIdentity = parseResourceIdentity(JSON.parse(operation.target), 'journal Issue identity'); }
|
|
123
|
+
catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Journal Issue identity is invalid', retryable: false } }); }
|
|
124
|
+
const before = await inspectPlatformIssue(taskId, { cwd, client });
|
|
125
|
+
if (before.status !== 'no-op' || before.error || !before.issue
|
|
126
|
+
|| !resourceIdentityEquals(before.issue.identity, expectedIdentity)) {
|
|
127
|
+
return platformResult('blocked', { error: before.error ?? { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue identity differs from the journal target', retryable: false } });
|
|
128
|
+
}
|
|
129
|
+
const sync = await syncPlatformIssue(taskId, { ...operation.issueMetadata, agent, cwd, client, dependency: operation.dependency, skipQueue: true });
|
|
130
|
+
if ((sync.status !== 'applied' && sync.status !== 'no-op') || sync.error) return sync;
|
|
131
|
+
const after = await inspectPlatformIssue(taskId, { cwd, client });
|
|
132
|
+
if (after.status !== 'no-op' || after.error || !after.issue
|
|
133
|
+
|| !resourceIdentityEquals(after.issue.identity, expectedIdentity)) {
|
|
134
|
+
return platformResult('blocked', { error: after.error ?? { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Issue identity changed during metadata recovery', retryable: false } });
|
|
135
|
+
}
|
|
136
|
+
const issue = after.issue;
|
|
137
|
+
const metadata = operation.issueMetadata;
|
|
138
|
+
const failedOperation = sync.operations.find((item) => item.status === 'failed' || item.status === 'skipped');
|
|
139
|
+
if (failedOperation) return platformResult('blocked', { error: {
|
|
140
|
+
code: failedOperation.reasonCode || 'PLATFORM_OPERATION_METADATA_UNCONFIRMED',
|
|
141
|
+
message: `Issue metadata recovery did not confirm operation '${failedOperation.name}'`, retryable: true
|
|
142
|
+
} });
|
|
143
|
+
const unconfirmed = sync.operations.some((item) => {
|
|
144
|
+
if (!('value' in item) || item.value === undefined) return false;
|
|
145
|
+
if (item.name === 'labels:status' || item.name === 'labels:in') {
|
|
146
|
+
const prefix = item.name === 'labels:status' ? 'status:' : 'in:';
|
|
147
|
+
return !labelsMatchOwnedPrefix(issue!.labels, item.value as string[], prefix);
|
|
148
|
+
}
|
|
149
|
+
if (item.name === 'assignees') return [...issue!.assignees].sort().join('\0') !== [...item.value as string[]].sort().join('\0');
|
|
150
|
+
if (item.name === 'milestone') return issue!.milestone !== item.value;
|
|
151
|
+
if (item.name === 'requirements') return issue!.body !== item.value;
|
|
152
|
+
if (item.name === 'state') return issue!.state !== item.value;
|
|
153
|
+
if (item.name === 'issue-type') return issue!.issueType !== item.value;
|
|
154
|
+
if (item.name === 'fields') return !fieldsMatchExpected(issue!.fields, item.value as Record<string, string | number | null>);
|
|
155
|
+
return false;
|
|
156
|
+
}) || (metadata.state !== undefined && issue!.state !== metadata.state)
|
|
157
|
+
|| (metadata.assignees === 'none' && issue!.assignees.length !== 0)
|
|
158
|
+
|| (metadata.milestone === 'none' && issue!.milestone !== null);
|
|
159
|
+
if (unconfirmed) return platformResult('blocked', { error: {
|
|
160
|
+
code: 'PLATFORM_OPERATION_METADATA_UNCONFIRMED',
|
|
161
|
+
message: 'Issue metadata differs from the requested recovery target after reread', retryable: true
|
|
162
|
+
} });
|
|
163
|
+
return platformResult('no-op', { changed: sync.changed || after.changed });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function verifyIssueCreate(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], cwd: string, client?: PlatformClient): Promise<PlatformResult> {
|
|
167
|
+
if (!operation.issueCreate) return platformResult('failed', { error: {
|
|
168
|
+
code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Issue creation recovery identity is missing', retryable: false
|
|
169
|
+
} });
|
|
170
|
+
const current = await inspectPlatformIssue(taskId, { cwd, client });
|
|
171
|
+
if (!current.issue) return platformResult('blocked', { error: current.error ?? {
|
|
172
|
+
code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE',
|
|
173
|
+
message: 'Issue creation outcome is unknown and the task has no bound Issue identity; bind the verified remote Issue before continuing',
|
|
174
|
+
retryable: true
|
|
175
|
+
} });
|
|
176
|
+
if (current.issue.title !== operation.issueCreate.title
|
|
177
|
+
|| createHash('sha256').update(current.issue.body).digest('hex') !== operation.issueCreate.bodyDigest) return platformResult('blocked', { error: {
|
|
178
|
+
code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue does not match the queued creation intent', retryable: false
|
|
179
|
+
} });
|
|
180
|
+
return platformResult('no-op');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function replayPullRequestSummary(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
|
|
184
|
+
let identity;
|
|
185
|
+
try { identity = parseResourceIdentity(JSON.parse(operation.target), 'queued pull-request summary identity'); }
|
|
186
|
+
catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Queued pull-request summary identity is invalid', retryable: false } }); }
|
|
187
|
+
const resolved = await resolvePlatformProviderContext({ cwd, client });
|
|
188
|
+
if (!resolved.ok) return resolved.context;
|
|
189
|
+
const listed = resolved.value.provider.comments?.list
|
|
190
|
+
? await resolved.value.provider.comments.list({ context: providerOperationContext(resolved.value), parent: identity })
|
|
191
|
+
: unsupportedProviderOperation(resolved.value.provider, 'comments.list');
|
|
192
|
+
if (!listed.ok) return platformResult(listed.error.retryable ? 'blocked' : 'failed', { error: providerError(listed.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') });
|
|
193
|
+
const marker = `<!-- sync-pr:${taskId}:summary -->`;
|
|
194
|
+
const summaries = listed.value.filter((comment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === marker);
|
|
195
|
+
if (summaries.length > 1) return platformResult('blocked', { error: {
|
|
196
|
+
code: 'PR_SUMMARY_MARKER_AMBIGUOUS', message: 'Multiple pull-request comments contain the queued summary marker', retryable: false
|
|
197
|
+
} });
|
|
198
|
+
if (summaries.length === 1 && createHash('sha256').update(normalizeCommentContent(summaries[0]!.body)).digest('hex') === operation.expectedDigest) {
|
|
199
|
+
return platformResult('no-op');
|
|
200
|
+
}
|
|
201
|
+
if (!operation.pullRequestSummary) return platformResult('blocked', { error: {
|
|
202
|
+
code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request summary recovery content is missing', retryable: false
|
|
203
|
+
} });
|
|
204
|
+
const bound = await inspectPlatformPullRequest(taskId, { cwd, client });
|
|
205
|
+
if (!bound.pullRequest || !resourceIdentityEquals(bound.pullRequest.identity, identity)) return platformResult('blocked', { error: bound.error ?? {
|
|
206
|
+
code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound pull request differs from the queued summary target', retryable: false
|
|
207
|
+
} });
|
|
208
|
+
const replayed = await syncPullRequestSummary(taskId, {
|
|
209
|
+
agent,
|
|
210
|
+
cwd,
|
|
211
|
+
client,
|
|
212
|
+
body: operation.pullRequestSummary.body,
|
|
213
|
+
changeReportFile: operation.pullRequestSummary.changeReportFile,
|
|
214
|
+
primaryResult: 'no_op',
|
|
215
|
+
strict: true,
|
|
216
|
+
skipQueue: true,
|
|
217
|
+
lockAlreadyHeld: true
|
|
218
|
+
});
|
|
219
|
+
if ((replayed.status === 'applied' || replayed.status === 'no-op') && !replayed.error && replayed.warnings.length === 0) {
|
|
220
|
+
return platformResult(replayed.status, { changed: replayed.changed });
|
|
221
|
+
}
|
|
222
|
+
return platformResult('blocked', { error: replayed.error ?? {
|
|
223
|
+
code: 'PR_SUMMARY_RECOVERY_UNCONFIRMED', message: 'Pull-request summary replay did not confirm the requested remote state', retryable: true
|
|
224
|
+
} });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function replayPullRequestReview(operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
|
|
228
|
+
const intent = operation.pullRequestReview;
|
|
229
|
+
if (!intent) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request review recovery parameters are missing', retryable: false } });
|
|
230
|
+
let target;
|
|
231
|
+
try { target = parseResourceIdentity(JSON.parse(operation.target), 'queued pull-request review identity'); }
|
|
232
|
+
catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Queued pull-request review identity is invalid', retryable: false } }); }
|
|
233
|
+
if (!intent.resource || !resourceIdentityEquals(target, intent.resource)) return platformResult('failed', { error: {
|
|
234
|
+
code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Pull-request review identity differs from the journal target', retryable: false
|
|
235
|
+
} });
|
|
236
|
+
let body: string;
|
|
237
|
+
try { body = readReviewBodyFile(intent.scope, intent.round, intent.artifactFile, intent.bodyDigest, cwd); }
|
|
238
|
+
catch { return platformResult('failed', { error: {
|
|
239
|
+
code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Canonical pull-request review body is unavailable or does not match the queued digest', retryable: false
|
|
240
|
+
} }); }
|
|
241
|
+
return publishPrReview({
|
|
242
|
+
cwd,
|
|
243
|
+
client,
|
|
244
|
+
agent,
|
|
245
|
+
prNumber: intent.prNumber,
|
|
246
|
+
expectedResource: target,
|
|
247
|
+
expectedProviderScopeId: intent.providerScopeId,
|
|
248
|
+
identity: { scope: intent.scope, round: intent.round, commitSha: intent.commitSha, ...(intent.resource ? { resource: intent.resource } : {}) },
|
|
249
|
+
event: intent.event,
|
|
250
|
+
body,
|
|
251
|
+
recoveryArtifact: intent.artifactFile,
|
|
252
|
+
skipQueue: true
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function replayPullRequest(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string): Promise<PlatformResult> {
|
|
257
|
+
const intent = operation.pullRequest;
|
|
258
|
+
if (!intent) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request recovery parameters are missing', retryable: false } });
|
|
259
|
+
if (intent.action === 'bind') {
|
|
260
|
+
if (!intent.prToken) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request bind identity is missing', retryable: false } });
|
|
261
|
+
return bindPlatformPullRequest(taskId, { agent, cwd, pr: intent.prToken, skipQueue: true });
|
|
262
|
+
}
|
|
263
|
+
if (intent.action === 'sync') {
|
|
264
|
+
const synced = await syncPlatformPullRequest(taskId, {
|
|
265
|
+
agent, cwd, metadata: intent.metadata === true, closingIssue: intent.closingIssue === true, primaryResult: 'no_op', skipQueue: true
|
|
266
|
+
});
|
|
267
|
+
if (synced.warnings.length > 0 || synced.result?.endsWith('_with_warnings')) {
|
|
268
|
+
return platformResult('blocked', { error: { code: 'PLATFORM_OPERATION_PR_SYNC_UNCONFIRMED', message: 'Pull-request metadata sync remains degraded', retryable: true } });
|
|
269
|
+
}
|
|
270
|
+
return synced;
|
|
271
|
+
}
|
|
272
|
+
return recoverCreatedPullRequest(taskId, { agent, cwd, base: intent.baseRef || '', head: intent.headRef || '', skipQueue: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function recoverPlatformOperations(
|
|
276
|
+
taskRef: string,
|
|
277
|
+
selection: 'required' | 'deferred' | 'all',
|
|
278
|
+
options: RecoveryOptions
|
|
279
|
+
): Promise<RecoveryResult> {
|
|
280
|
+
const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
|
|
281
|
+
if (!resolved.ok) return result('failed', [], [], { code: resolved.code, message: resolved.message, retryable: false });
|
|
282
|
+
let journal;
|
|
283
|
+
try { journal = readPlatformOperationJournal(resolved.taskId, resolved.repoRoot); }
|
|
284
|
+
catch (error) {
|
|
285
|
+
const value = error as { code?: string; message?: string };
|
|
286
|
+
return result('failed', [], [], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_INVALID', message: value.message || String(error), retryable: false });
|
|
287
|
+
}
|
|
288
|
+
const candidates = journal.operations.filter((operation) => operation.id !== options.excludeId
|
|
289
|
+
&& (operation.state === 'queued' || operation.state === 'pending' || operation.state === 'unknown'
|
|
290
|
+
|| (operation.state === 'failed' && operation.lastCode !== 'PLATFORM_OPERATION_SUPERSEDED')))
|
|
291
|
+
.filter((operation) => selection === 'all' || operation.dependency === selection)
|
|
292
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
|
|
293
|
+
if (!candidates.length) return result('no-op', [], []);
|
|
294
|
+
const limit = options.limit === undefined ? candidates.length : Math.max(1, options.limit);
|
|
295
|
+
const recovered: string[] = [];
|
|
296
|
+
const pending: string[] = [];
|
|
297
|
+
const selected = candidates.slice(0, limit);
|
|
298
|
+
for (const [index, operation] of selected.entries()) {
|
|
299
|
+
if (operation.state === 'failed') {
|
|
300
|
+
pending.push(operation.id);
|
|
301
|
+
return result('blocked', recovered, pending, {
|
|
302
|
+
code: operation.lastCode || 'PLATFORM_OPERATION_FAILED',
|
|
303
|
+
message: 'A previous platform operation failed permanently; later writes remain queued',
|
|
304
|
+
retryable: false
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (operation.attempts >= operation.maxAttempts) {
|
|
308
|
+
pending.push(operation.id, ...selected.slice(index + 1).map((next) => next.id));
|
|
309
|
+
pending.push(...candidates.slice(limit).map((next) => next.id));
|
|
310
|
+
return result('blocked', recovered, pending, {
|
|
311
|
+
code: operation.lastCode || 'PLATFORM_OPERATION_RETRY_LIMIT_REACHED',
|
|
312
|
+
message: 'A previous platform operation exhausted its recovery attempts; later writes remain queued',
|
|
313
|
+
retryable: false
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (operation.kind !== 'issue-create') {
|
|
317
|
+
try {
|
|
318
|
+
recordPlatformOperation({
|
|
319
|
+
taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind,
|
|
320
|
+
target: operation.target, expectedDigest: operation.expectedDigest,
|
|
321
|
+
dependency: operation.dependency, state: 'pending',
|
|
322
|
+
...(operation.issueMetadata ? { issueMetadata: operation.issueMetadata } : {}),
|
|
323
|
+
...(operation.issueCreate ? { issueCreate: operation.issueCreate } : {}),
|
|
324
|
+
...(operation.pullRequest ? { pullRequest: operation.pullRequest } : {}),
|
|
325
|
+
...(operation.pullRequestSummary ? { pullRequestSummary: operation.pullRequestSummary } : {}),
|
|
326
|
+
...(operation.pullRequestReview ? { pullRequestReview: operation.pullRequestReview } : {})
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const value = error as { code?: string; message?: string };
|
|
330
|
+
return result('blocked', recovered, [...pending, operation.id], {
|
|
331
|
+
code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED',
|
|
332
|
+
message: value.message || String(error), retryable: true
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
let targetOperation = operation;
|
|
337
|
+
if (operation.kind === 'issue-metadata') {
|
|
338
|
+
const current = currentIssueMetadataOperation(resolved.taskId, resolved.taskMdPath, resolved.repoRoot, operation);
|
|
339
|
+
if (!current) {
|
|
340
|
+
pending.push(operation.id);
|
|
341
|
+
return result('blocked', recovered, pending, { code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE', message: 'Current Issue metadata target cannot be reconstructed safely', retryable: true });
|
|
342
|
+
}
|
|
343
|
+
if (current.target !== operation.target) {
|
|
344
|
+
return result('blocked', recovered, [...pending, operation.id], { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue identity differs from the journal target', retryable: false });
|
|
345
|
+
}
|
|
346
|
+
if (current.id !== operation.id) {
|
|
347
|
+
try {
|
|
348
|
+
recordPlatformOperation({ taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind, target: operation.target,
|
|
349
|
+
expectedDigest: operation.expectedDigest, issueMetadata: operation.issueMetadata, dependency: operation.dependency,
|
|
350
|
+
state: 'failed', lastCode: 'PLATFORM_OPERATION_SUPERSEDED' });
|
|
351
|
+
targetOperation = { ...operation, expectedDigest: current.expectedDigest, issueMetadata: current.issueMetadata, id: current.id };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const value = error as { code?: string; message?: string };
|
|
354
|
+
return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (operation.kind === 'task-comment' || operation.kind === 'artifact-comment'
|
|
359
|
+
|| operation.kind === 'summary-comment' || operation.kind === 'cancel-comment') {
|
|
360
|
+
const commentOptions = operation.kind === 'task-comment'
|
|
361
|
+
? { kind: 'task' as const, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency }
|
|
362
|
+
: operation.kind === 'artifact-comment'
|
|
363
|
+
? { kind: 'artifact' as const, artifact: operation.target, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency }
|
|
364
|
+
: operation.kind === 'summary-comment' ? (() => {
|
|
365
|
+
const summary = summaryPayload(resolved.taskDir, resolved.taskId);
|
|
366
|
+
return summary ? { kind: 'summary' as const, body: summary.body, agent: options.agent, cwd: resolved.repoRoot, summaryAuthorization: { sha256: summary.sha256 }, dependency: operation.dependency } : null;
|
|
367
|
+
})() : { kind: 'cancel' as const, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency };
|
|
368
|
+
const current = commentOptions ? inspectPlatformCommentOperation(resolved.taskId, commentOptions) : null;
|
|
369
|
+
if (!current) {
|
|
370
|
+
pending.push(operation.id);
|
|
371
|
+
return result('blocked', recovered, pending, { code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE', message: 'Current comment target cannot be reconstructed safely', retryable: true });
|
|
372
|
+
}
|
|
373
|
+
if (current.id !== operation.id) {
|
|
374
|
+
try {
|
|
375
|
+
recordPlatformOperation({ taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind, target: operation.target,
|
|
376
|
+
expectedDigest: operation.expectedDigest, dependency: operation.dependency, state: 'failed', lastCode: 'PLATFORM_OPERATION_SUPERSEDED' });
|
|
377
|
+
targetOperation = { ...operation, expectedDigest: current.expectedDigest, id: current.id };
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const value = error as { code?: string; message?: string };
|
|
380
|
+
return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
let remote: PlatformResult;
|
|
385
|
+
try {
|
|
386
|
+
remote = operation.kind === 'issue-create'
|
|
387
|
+
? await verifyIssueCreate(resolved.taskId, operation, resolved.repoRoot)
|
|
388
|
+
: operation.kind === 'pull-request-summary'
|
|
389
|
+
? await replayPullRequestSummary(resolved.taskId, operation, options.agent, resolved.repoRoot, options.client)
|
|
390
|
+
: operation.kind === 'pull-request-review'
|
|
391
|
+
? await replayPullRequestReview(operation, options.agent, resolved.repoRoot, options.client)
|
|
392
|
+
: operation.kind === 'issue-metadata'
|
|
393
|
+
? await replayIssueMetadata(resolved.taskId, targetOperation, options.agent, resolved.repoRoot)
|
|
394
|
+
: operation.kind === 'pull-request'
|
|
395
|
+
? await replayPullRequest(resolved.taskId, targetOperation, options.agent, resolved.repoRoot)
|
|
396
|
+
: await replayComment(resolved.taskId, resolved.taskDir, operation, options.agent, resolved.repoRoot);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const value = error as { code?: string; message?: string };
|
|
400
|
+
remote = platformResult('blocked', { error: { code: value.code || 'PLATFORM_OPERATION_RECOVERY_FAILED', message: value.message || String(error), retryable: true } });
|
|
401
|
+
}
|
|
402
|
+
const succeeded = (remote.status === 'applied' || remote.status === 'no-op') && !remote.error;
|
|
403
|
+
try {
|
|
404
|
+
recordPlatformOperation({
|
|
405
|
+
taskRef: resolved.taskId,
|
|
406
|
+
cwd: resolved.repoRoot,
|
|
407
|
+
kind: operation.kind,
|
|
408
|
+
target: operation.target,
|
|
409
|
+
expectedDigest: targetOperation.expectedDigest,
|
|
410
|
+
...(targetOperation.issueMetadata ? { issueMetadata: targetOperation.issueMetadata } : {}),
|
|
411
|
+
...(operation.issueCreate ? { issueCreate: operation.issueCreate } : {}),
|
|
412
|
+
...(operation.pullRequest ? { pullRequest: operation.pullRequest } : {}),
|
|
413
|
+
...(operation.pullRequestSummary ? { pullRequestSummary: operation.pullRequestSummary } : {}),
|
|
414
|
+
...(operation.pullRequestReview ? { pullRequestReview: operation.pullRequestReview } : {}),
|
|
415
|
+
dependency: operation.dependency,
|
|
416
|
+
state: succeeded ? 'succeeded' : remote.status === 'failed' && remote.error?.retryable === false ? 'failed' : 'unknown',
|
|
417
|
+
lastCode: remote.error?.code ?? null
|
|
418
|
+
});
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const value = error as { code?: string; message?: string };
|
|
421
|
+
return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
|
|
422
|
+
}
|
|
423
|
+
if (!succeeded) {
|
|
424
|
+
pending.push(targetOperation.id);
|
|
425
|
+
return result(remote.status === 'failed' ? 'failed' : 'blocked', recovered, pending, remote.error ?? { code: 'PLATFORM_OPERATION_RECOVERY_UNKNOWN', message: 'Platform operation outcome remains unknown', retryable: true });
|
|
426
|
+
}
|
|
427
|
+
recovered.push(targetOperation.id);
|
|
428
|
+
}
|
|
429
|
+
const remaining = candidates.slice(limit).map((operation) => operation.id);
|
|
430
|
+
pending.push(...remaining);
|
|
431
|
+
return result(pending.length ? 'blocked' : recovered.length ? 'applied' : 'no-op', recovered, pending, pending.length ? { code: 'PLATFORM_OPERATION_RECOVERY_PENDING', message: 'Platform operations remain pending after the recovery budget', retryable: true } : null);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export { fieldsMatchExpected, labelsMatchOwnedPrefix, recoverPlatformOperations };
|
|
435
|
+
export type { RecoveryOptions, RecoveryResult };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
import { resolveTaskRef } from './resolve-ref.ts';
|
|
4
5
|
import { parseTaskFrontmatter } from './frontmatter.ts';
|
|
@@ -142,7 +143,14 @@ function projectFinalizationWarning(
|
|
|
142
143
|
if (!resolved.ok) return { status: 'failed', changed: false, error: { code: resolved.code, message: resolved.message } };
|
|
143
144
|
let prepared = resolved.state === 'completed';
|
|
144
145
|
if (!prepared && resolved.state === 'active') {
|
|
145
|
-
try {
|
|
146
|
+
try {
|
|
147
|
+
const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
148
|
+
prepared = parseTaskFrontmatter(taskContent).status === 'completed';
|
|
149
|
+
if (!prepared) {
|
|
150
|
+
const lifecycle = JSON.parse(fs.readFileSync(path.join(resolved.taskDir, '.task-lifecycle.json'), 'utf8')) as Record<string, unknown>;
|
|
151
|
+
prepared = lifecycle.taskId === resolved.taskId && lifecycle.intent === 'complete';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
146
154
|
catch { prepared = false; }
|
|
147
155
|
}
|
|
148
156
|
if (!prepared) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitlab-ai/agent-infra",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.8",
|
|
4
4
|
"description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -252,7 +252,7 @@ ai agent-client configure
|
|
|
252
252
|
|
|
253
253
|
TraeCode CLI uses `.agents/skills/` as the canonical Skill source. agent-infra generates lightweight slash-command wrappers under `.traecli/commands/`; each wrapper reads the corresponding shared Skill and forwards `$ARGUMENTS` when that Skill declares arguments. It does not mirror Skill packages into `.trae/skills/`, which remains user-owned for intentional Trae-specific overrides.
|
|
254
254
|
|
|
255
|
-
`agentClients` is the sole source of truth for built-in client state. The top-level array must contain all five built-in clients in the fixed canonical order; `sandbox.tools` lists
|
|
255
|
+
`agentClients` is the sole source of truth for built-in client state. The top-level array must contain all five built-in clients in the fixed canonical order; `sandbox.tools.ids` lists non-client tools such as `agent-infra` and custom tools, while `sandbox.tools.definitions` maps custom tool IDs to their definitions. A legacy `tuis` field or a built-in client id in `sandbox.tools.ids` is rejected, and configuration is never rewritten automatically.
|
|
256
256
|
|
|
257
257
|
### Side effects of disabling an Agent Client
|
|
258
258
|
|
|
@@ -260,7 +260,7 @@ When you disable a built-in client, reconciliation stops maintaining its seed co
|
|
|
260
260
|
|
|
261
261
|
### Relation to other config fields
|
|
262
262
|
|
|
263
|
-
- `sandbox.tools`
|
|
263
|
+
- `sandbox.tools.ids` lists non-client tools such as `agent-infra` and custom tools; custom definitions belong in `sandbox.tools.definitions`. Built-in client installation belongs in `agentClients[].installInSandbox`.
|
|
264
264
|
- `agentClients` is independent from `customTUIs` (see below). Custom TUI command files are preserved even when their directory is under a disabled built-in client's path prefix.
|
|
265
265
|
|
|
266
266
|
## Custom TUI Configuration
|
|
@@ -313,13 +313,13 @@ Namespaced custom TUI:
|
|
|
313
313
|
|
|
314
314
|
## Sandbox Custom Tools
|
|
315
315
|
|
|
316
|
-
`customTUIs` generates slash-command files but does not change the sandbox image. To install a non-npm CLI or tool (pip / cargo / curl-based / pre-built binary) into the sandbox image and live-mount its credentials, declare it under `sandbox.
|
|
316
|
+
`customTUIs` generates slash-command files but does not change the sandbox image. To install a non-npm CLI or tool (pip / cargo / curl-based / pre-built binary) into the sandbox image and live-mount its credentials, declare it under an ID key in `sandbox.tools.definitions` in `.agents/.airc.json`. Built-in Agent Clients are installed according to `agentClients[].installInSandbox`; `agent-infra` remains a non-client entry in `sandbox.tools.ids` and only provides the in-sandbox `ai` / `agent-infra` CLI.
|
|
317
317
|
|
|
318
318
|
### Required fields
|
|
319
319
|
|
|
320
320
|
| Field | Meaning |
|
|
321
321
|
|-------|---------|
|
|
322
|
-
|
|
|
322
|
+
| Definition object key | Lowercase tool ID matching `^[a-z0-9][a-z0-9-]*$`. Every selected custom tool in `sandbox.tools.ids` needs a matching definition. Definitions not listed in `ids` may remain unused. Keys must not collide with a built-in id. |
|
|
323
323
|
| `install` | Install descriptor. `{ "type": "npm", "cmd": "<npm package spec>" }` runs `npm install -g <cmd>`. `{ "type": "shell", "cmd": "<shell>" }` runs the shell command(s) as `devuser` during image build. `cmd` must be non-empty. |
|
|
324
324
|
|
|
325
325
|
Minimal entry — the contract for getting a tool into the image is just these two fields:
|
|
@@ -327,13 +327,14 @@ Minimal entry — the contract for getting a tool into the image is just these t
|
|
|
327
327
|
```json
|
|
328
328
|
{
|
|
329
329
|
"sandbox": {
|
|
330
|
-
"tools":
|
|
331
|
-
|
|
332
|
-
{
|
|
333
|
-
"
|
|
334
|
-
|
|
330
|
+
"tools": {
|
|
331
|
+
"ids": ["my-shell-tool"],
|
|
332
|
+
"definitions": {
|
|
333
|
+
"my-shell-tool": {
|
|
334
|
+
"install": { "type": "shell", "cmd": "curl -fsSL https://example.com/install.sh | bash" }
|
|
335
|
+
}
|
|
335
336
|
}
|
|
336
|
-
|
|
337
|
+
}
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
```
|
|
@@ -354,24 +355,25 @@ Add only the fields your tool actually needs. Omit them and the loader fills sen
|
|
|
354
355
|
| `hostLiveMounts` | (none) | Share host credentials live (e.g. OAuth tokens) with the container. Read-write. |
|
|
355
356
|
| `postSetupCmds` | (none) | Run commands inside the container after first setup (e.g. symlinks). |
|
|
356
357
|
|
|
357
|
-
> **`sandboxBase` is not user-configurable.** The loader always assigns `~/.agent-infra/sandboxes/<id>` so `ai sandbox rm` / `prune` can find tool state. Any `sandboxBase` value in
|
|
358
|
+
> **`sandboxBase` is not user-configurable.** The loader always assigns `~/.agent-infra/sandboxes/<id>` so `ai sandbox rm` / `prune` can find tool state. Any `sandboxBase` value in a definition is silently ignored.
|
|
358
359
|
|
|
359
360
|
Real-world example — `anthropic-claude` as a user-defined id with binary name `claude` and host credential live-mount:
|
|
360
361
|
|
|
361
362
|
```json
|
|
362
363
|
{
|
|
363
364
|
"sandbox": {
|
|
364
|
-
"tools":
|
|
365
|
-
|
|
366
|
-
{
|
|
367
|
-
"
|
|
365
|
+
"tools": {
|
|
366
|
+
"ids": ["anthropic-claude"],
|
|
367
|
+
"definitions": {
|
|
368
|
+
"anthropic-claude": {
|
|
368
369
|
"install": { "type": "npm", "cmd": "@anthropic-ai/claude-code@stable" },
|
|
369
370
|
"versionCmd": "claude --version",
|
|
370
371
|
"hostLiveMounts": [
|
|
371
372
|
{ "hostPath": "~/.claude/.credentials.json", "containerSubpath": ".credentials.json" }
|
|
372
373
|
]
|
|
374
|
+
}
|
|
373
375
|
}
|
|
374
|
-
|
|
376
|
+
}
|
|
375
377
|
}
|
|
376
378
|
}
|
|
377
379
|
```
|
|
@@ -386,7 +388,7 @@ Real-world example — `anthropic-claude` as a user-defined id with binary name
|
|
|
386
388
|
|
|
387
389
|
### Interaction with `sandbox.dockerfile`
|
|
388
390
|
|
|
389
|
-
When you set `sandbox.dockerfile` to point at your own Dockerfile, agent-infra still passes both `AI_TOOL_PACKAGES` (space-separated npm package specs) and `AI_TOOLS_SHELL_INSTALL_B64` (base64-encoded shell install script) as `--build-arg`. Your custom Dockerfile decides whether to consume them; if it does not declare the matching `ARG`, the shell installs
|
|
391
|
+
When you set `sandbox.dockerfile` to point at your own Dockerfile, agent-infra still passes both `AI_TOOL_PACKAGES` (space-separated npm package specs) and `AI_TOOLS_SHELL_INSTALL_B64` (base64-encoded shell install script) as `--build-arg`. Your custom Dockerfile decides whether to consume them; if it does not declare the matching `ARG`, the shell installs declared in `sandbox.tools.definitions` are silently skipped — taking over the Dockerfile means taking over the install path.
|
|
390
392
|
|
|
391
393
|
## Skill Authoring Conventions
|
|
392
394
|
|