@fitlab-ai/agent-infra 0.11.5 → 0.11.6
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/lib/merge.js +62 -10
- package/dist/lib/platform/github-provider.js +9 -14
- package/dist/lib/platform/github-release-notes.js +80 -11
- package/dist/lib/platform/none-provider.js +1 -1
- package/dist/lib/platform/provider-contract.d.ts +39 -16
- package/dist/lib/platform/provider-contract.js +1 -1
- package/dist/lib/platform/provider-validation.js +83 -13
- package/dist/lib/platform/release-notes.js +7 -8
- package/dist/lib/sandbox/capture.js +1 -1
- package/dist/lib/sandbox/commands/create.js +46 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/start.js +1 -1
- package/dist/lib/sandbox/config.js +10 -0
- package/dist/lib/sandbox/control/lifecycle.js +18 -13
- package/dist/lib/sandbox/task-resolver.js +62 -2
- package/dist/lib/task/resolve-ref.js +3 -3
- package/dist/lib/task/write.js +121 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/merge.ts +59 -10
- package/lib/platform/github-provider.ts +11 -18
- package/lib/platform/github-release-notes.ts +89 -14
- package/lib/platform/none-provider.ts +1 -1
- package/lib/platform/provider-contract.ts +38 -12
- package/lib/platform/provider-validation.ts +83 -12
- package/lib/platform/release-notes.ts +11 -12
- package/lib/sandbox/capture.ts +1 -1
- package/lib/sandbox/commands/create.ts +52 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/start.ts +1 -1
- package/lib/sandbox/config.ts +12 -0
- package/lib/sandbox/control/lifecycle.ts +22 -13
- package/lib/sandbox/task-resolver.ts +51 -2
- package/lib/task/resolve-ref.ts +3 -3
- package/lib/task/write.ts +115 -1
- package/package.json +1 -1
- package/templates/.agents/rules/evidence-reporting.en.md +1 -2
- package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
- package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
|
@@ -18,6 +18,7 @@ import { createSandboxCapabilityPlan, runSandboxHooks, runBoundedSandboxHookComm
|
|
|
18
18
|
import { resolveTools, tmpfsSeedStagingPath, tmpfsSeedTargetPath, toolConfigDirCandidates } from '../tools.js';
|
|
19
19
|
import { assertFreshSandboxReady, hydrateTmpfsSeedEntries, prepareTmpfsMounts, startSandboxControlBroker } from '../recovery.js';
|
|
20
20
|
import { acquireSandboxControlReplacement, assertSandboxControlCutoverSnapshot, beginSandboxControlReplacement, captureSandboxControlCutoverSnapshot, commitSandboxControlReplacement, quiesceSandboxControlRoot, recoverSandboxControlReplacement, readSandboxControlManifest } from '../control/lifecycle.js';
|
|
21
|
+
import { atomicWriteJson } from '../control/state.js';
|
|
21
22
|
import { inspectSandboxControlContainer } from '../control/container-identity.js';
|
|
22
23
|
import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
|
|
23
24
|
import { sandboxCoreBindMounts } from '../mounts.js';
|
|
@@ -652,7 +653,24 @@ function readImageLabels(config, engine) {
|
|
|
652
653
|
config.imageName
|
|
653
654
|
]));
|
|
654
655
|
}
|
|
655
|
-
|
|
656
|
+
function readProjectInitCommandDigest(controlRoot) {
|
|
657
|
+
const markerPath = path.join(controlRoot, 'project-init.json');
|
|
658
|
+
if (!fs.existsSync(markerPath))
|
|
659
|
+
return null;
|
|
660
|
+
try {
|
|
661
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
662
|
+
return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
|
|
663
|
+
? marker.commandSha256
|
|
664
|
+
: null;
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function writeProjectInitCommandDigest(controlRoot, commandSha256) {
|
|
671
|
+
atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
|
|
672
|
+
}
|
|
673
|
+
export async function create(args, { runProjectInitCommand = true } = {}) {
|
|
656
674
|
const { values, positionals } = parseArgs({
|
|
657
675
|
args,
|
|
658
676
|
allowPositionals: true,
|
|
@@ -720,6 +738,7 @@ export async function create(args) {
|
|
|
720
738
|
identity: target.workspace
|
|
721
739
|
});
|
|
722
740
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
741
|
+
let worktreeCreated = false;
|
|
723
742
|
const shareCommon = shareCommonDir(effectiveConfig);
|
|
724
743
|
const shareBranch = shareBranchDir(effectiveConfig, branch);
|
|
725
744
|
const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
|
|
@@ -821,6 +840,7 @@ export async function create(args) {
|
|
|
821
840
|
toEnginePath(engine, worktree),
|
|
822
841
|
branch
|
|
823
842
|
]);
|
|
843
|
+
worktreeCreated = true;
|
|
824
844
|
}
|
|
825
845
|
else {
|
|
826
846
|
message(`Creating branch '${branch}' from '${baseBranch}'...`);
|
|
@@ -834,6 +854,7 @@ export async function create(args) {
|
|
|
834
854
|
toEnginePath(engine, worktree),
|
|
835
855
|
baseBranch
|
|
836
856
|
]);
|
|
857
|
+
worktreeCreated = true;
|
|
837
858
|
}
|
|
838
859
|
return `Worktree ready at ${worktree}`;
|
|
839
860
|
}
|
|
@@ -886,6 +907,7 @@ export async function create(args) {
|
|
|
886
907
|
let replacementCutover = null;
|
|
887
908
|
try {
|
|
888
909
|
const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
|
|
910
|
+
const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
|
|
889
911
|
const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
|
|
890
912
|
const previousManifest = fs.existsSync(controlPaths.manifestPath)
|
|
891
913
|
? readSandboxControlManifest(controlPaths.manifestPath)
|
|
@@ -1295,6 +1317,29 @@ export async function create(args) {
|
|
|
1295
1317
|
throw new Error(afterStartFailure.message
|
|
1296
1318
|
?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
|
|
1297
1319
|
}
|
|
1320
|
+
const initCommand = effectiveConfig.initCommand;
|
|
1321
|
+
const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
|
|
1322
|
+
const shouldRunProjectInitCommand = runProjectInitCommand
|
|
1323
|
+
&& initCommand !== null
|
|
1324
|
+
&& initCommandDigest !== null
|
|
1325
|
+
&& (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
|
|
1326
|
+
let projectInitCompleted = !worktreeCreated
|
|
1327
|
+
&& initCommandDigest !== null
|
|
1328
|
+
&& previousProjectInitCommandDigest === initCommandDigest;
|
|
1329
|
+
if (shouldRunProjectInitCommand && initCommand !== null) {
|
|
1330
|
+
try {
|
|
1331
|
+
runVerboseEngine(engine, 'docker', [
|
|
1332
|
+
'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
|
|
1333
|
+
]);
|
|
1334
|
+
projectInitCompleted = true;
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
throw new Error(`Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (projectInitCompleted && initCommandDigest !== null) {
|
|
1341
|
+
writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
|
|
1342
|
+
}
|
|
1298
1343
|
replacementLease.release();
|
|
1299
1344
|
replacementLeaseHeld = false;
|
|
1300
1345
|
return 'Container started';
|
|
@@ -109,7 +109,7 @@ export async function enter(args) {
|
|
|
109
109
|
allowRecreate: parsed.recreate,
|
|
110
110
|
recreate: async () => {
|
|
111
111
|
const { create } = await import('./create.js');
|
|
112
|
-
await create([target.requestedRef, '--no-refresh']);
|
|
112
|
+
await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
|
|
113
113
|
}
|
|
114
114
|
});
|
|
115
115
|
const container = ready.container;
|
|
@@ -65,7 +65,7 @@ export async function start(args) {
|
|
|
65
65
|
forceRecreate: parsed.recreate,
|
|
66
66
|
recreate: async () => {
|
|
67
67
|
const { create } = await import('./create.js');
|
|
68
|
-
await create([target.requestedRef, '--no-refresh']);
|
|
68
|
+
await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
const message = ready.path === 'healthy'
|
|
@@ -14,6 +14,7 @@ const DEFAULTS = Object.freeze({
|
|
|
14
14
|
tools: ['agent-infra'],
|
|
15
15
|
refreshIntervalDays: 7,
|
|
16
16
|
dockerfile: null,
|
|
17
|
+
initCommand: null,
|
|
17
18
|
vm: {
|
|
18
19
|
cpu: null,
|
|
19
20
|
memory: null,
|
|
@@ -44,6 +45,7 @@ function cloneDefaults() {
|
|
|
44
45
|
tools: [...DEFAULTS.tools],
|
|
45
46
|
refreshIntervalDays: DEFAULTS.refreshIntervalDays,
|
|
46
47
|
dockerfile: DEFAULTS.dockerfile,
|
|
48
|
+
initCommand: DEFAULTS.initCommand,
|
|
47
49
|
vm: { ...DEFAULTS.vm }
|
|
48
50
|
};
|
|
49
51
|
}
|
|
@@ -77,6 +79,13 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
|
|
|
77
79
|
? [...sandbox.runtimes]
|
|
78
80
|
: defaults.runtimes;
|
|
79
81
|
const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
|
|
82
|
+
if (sandbox.initCommand !== undefined &&
|
|
83
|
+
(typeof sandbox.initCommand !== 'string' || sandbox.initCommand.trim().length === 0)) {
|
|
84
|
+
throw new Error('sandbox.initCommand must be a non-empty string when configured');
|
|
85
|
+
}
|
|
86
|
+
const initCommand = typeof sandbox.initCommand === 'string'
|
|
87
|
+
? sandbox.initCommand
|
|
88
|
+
: null;
|
|
80
89
|
if (!dockerfile) {
|
|
81
90
|
let enginesNode;
|
|
82
91
|
try {
|
|
@@ -118,6 +127,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
|
|
|
118
127
|
},
|
|
119
128
|
refreshIntervalDays: asNonNegativeIntegerOrDefault(sandbox.refreshIntervalDays, defaults.refreshIntervalDays),
|
|
120
129
|
dockerfile,
|
|
130
|
+
initCommand,
|
|
121
131
|
vm: {
|
|
122
132
|
cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
|
|
123
133
|
memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
|
|
@@ -1152,17 +1152,25 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
|
|
|
1152
1152
|
if (!manifest && brokerLive)
|
|
1153
1153
|
throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
|
|
1154
1154
|
const platform = options.platform ?? process.platform;
|
|
1155
|
+
const processControl = options.executionProcessControl ?? {
|
|
1156
|
+
isAlive: (execution) => executionAlive(execution, platform, identityProbe),
|
|
1157
|
+
terminate: (execution, phase) => phase === 'graceful'
|
|
1158
|
+
? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
|
|
1159
|
+
: terminateSandboxControlExecution(execution, {
|
|
1160
|
+
platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
|
|
1161
|
+
})
|
|
1162
|
+
};
|
|
1155
1163
|
let executions = manifest ? readExecutions(manifest) : [];
|
|
1156
1164
|
const owner = brokerLive ? broker : statusLive ? statusOwner : null;
|
|
1157
1165
|
if (!owner) {
|
|
1158
1166
|
if (manifest && !broker && !status)
|
|
1159
1167
|
throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
|
|
1160
1168
|
for (const execution of executions) {
|
|
1161
|
-
|
|
1169
|
+
processControl.terminate(execution, 'graceful');
|
|
1162
1170
|
}
|
|
1163
1171
|
const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
|
|
1164
1172
|
while (Date.now() < softDeadlineAt) {
|
|
1165
|
-
if (!executions.some((execution) =>
|
|
1173
|
+
if (!executions.some((execution) => processControl.isAlive(execution)))
|
|
1166
1174
|
break;
|
|
1167
1175
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1168
1176
|
}
|
|
@@ -1171,29 +1179,26 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
|
|
|
1171
1179
|
if (Date.now() >= deadlineAt)
|
|
1172
1180
|
throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
|
|
1173
1181
|
for (const execution of executions) {
|
|
1174
|
-
if (!
|
|
1175
|
-
platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
|
|
1176
|
-
})) {
|
|
1182
|
+
if (!processControl.terminate(execution, 'force')) {
|
|
1177
1183
|
throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
|
|
1178
1184
|
}
|
|
1179
1185
|
}
|
|
1180
|
-
if (executions.some((execution) =>
|
|
1186
|
+
if (executions.some((execution) => processControl.isAlive(execution))) {
|
|
1181
1187
|
throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
|
|
1182
1188
|
}
|
|
1183
1189
|
return broker || status ? 'stale' : 'missing';
|
|
1184
1190
|
}
|
|
1185
|
-
const remaining = () => Math.max(0, deadlineAt - Date.now());
|
|
1186
1191
|
signalOwner(owner, platform, false);
|
|
1187
1192
|
for (const execution of executions) {
|
|
1188
|
-
|
|
1193
|
+
processControl.terminate(execution, 'graceful');
|
|
1189
1194
|
}
|
|
1190
1195
|
const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
|
|
1191
1196
|
while (Date.now() < softDeadlineAt) {
|
|
1192
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1197
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
|
|
1193
1198
|
break;
|
|
1194
1199
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1195
1200
|
}
|
|
1196
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1201
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
|
|
1197
1202
|
return 'stopped';
|
|
1198
1203
|
}
|
|
1199
1204
|
if (manifest)
|
|
@@ -1201,17 +1206,17 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
|
|
|
1201
1206
|
if (Date.now() >= deadlineAt)
|
|
1202
1207
|
throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
|
|
1203
1208
|
for (const execution of executions) {
|
|
1204
|
-
|
|
1209
|
+
processControl.terminate(execution, 'force');
|
|
1205
1210
|
}
|
|
1206
1211
|
signalOwner(owner, platform, true);
|
|
1207
1212
|
while (Date.now() < deadlineAt) {
|
|
1208
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1213
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
|
|
1209
1214
|
return 'stopped';
|
|
1210
1215
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1211
1216
|
}
|
|
1212
1217
|
if (ownerLive(owner, identityProbe))
|
|
1213
1218
|
throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
|
|
1214
|
-
if (executions.some((execution) =>
|
|
1219
|
+
if (executions.some((execution) => processControl.isAlive(execution)))
|
|
1215
1220
|
throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
|
|
1216
1221
|
return 'stopped';
|
|
1217
1222
|
}
|
|
@@ -21,7 +21,9 @@ function stripQuotes(value) {
|
|
|
21
21
|
}
|
|
22
22
|
function readTaskContent(repoRoot, taskId) {
|
|
23
23
|
for (const state of TASK_WORKSPACE_STATES) {
|
|
24
|
-
const taskPath =
|
|
24
|
+
const taskPath = state === 'archive'
|
|
25
|
+
? path.join(repoRoot, '.agents', 'workspace', state, ...findArchiveTaskParts(repoRoot, taskId), 'local', 'task.md')
|
|
26
|
+
: path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
25
27
|
if (fs.existsSync(taskPath)) {
|
|
26
28
|
return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
|
|
27
29
|
}
|
|
@@ -29,7 +31,7 @@ function readTaskContent(repoRoot, taskId) {
|
|
|
29
31
|
throw new Error(`Task not found: ${taskId}`);
|
|
30
32
|
}
|
|
31
33
|
function taskWorkspaceCandidates(repoRoot, taskId) {
|
|
32
|
-
|
|
34
|
+
const hot = TASK_WORKSPACE_STATES.filter((state) => state !== 'archive').flatMap((state) => {
|
|
33
35
|
const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
34
36
|
if (!fs.existsSync(taskMd))
|
|
35
37
|
return [];
|
|
@@ -41,6 +43,37 @@ function taskWorkspaceCandidates(repoRoot, taskId) {
|
|
|
41
43
|
taskMd
|
|
42
44
|
}];
|
|
43
45
|
});
|
|
46
|
+
const parts = findArchiveTaskParts(repoRoot, taskId);
|
|
47
|
+
if (parts.length === 0)
|
|
48
|
+
return hot;
|
|
49
|
+
const taskMd = path.join(repoRoot, '.agents', 'workspace', 'archive', ...parts, 'local', 'task.md');
|
|
50
|
+
if (!fs.existsSync(taskMd))
|
|
51
|
+
return hot;
|
|
52
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
53
|
+
return [...hot, {
|
|
54
|
+
taskId,
|
|
55
|
+
branch: resolveBranchFromTaskContent(content, taskId),
|
|
56
|
+
state: 'archive',
|
|
57
|
+
taskMd
|
|
58
|
+
}];
|
|
59
|
+
}
|
|
60
|
+
function findArchiveTaskParts(repoRoot, taskId) {
|
|
61
|
+
const archiveRoot = path.join(repoRoot, '.agents', 'workspace', 'archive');
|
|
62
|
+
if (!fs.existsSync(archiveRoot))
|
|
63
|
+
return [];
|
|
64
|
+
for (const year of fs.readdirSync(archiveRoot).sort().reverse()) {
|
|
65
|
+
if (!/^\d{4}$/.test(year))
|
|
66
|
+
continue;
|
|
67
|
+
for (const month of fs.readdirSync(path.join(archiveRoot, year)).sort().reverse()) {
|
|
68
|
+
if (!/^\d{2}$/.test(month))
|
|
69
|
+
continue;
|
|
70
|
+
for (const day of fs.readdirSync(path.join(archiveRoot, year, month)).sort().reverse()) {
|
|
71
|
+
if (/^\d{2}$/.test(day) && fs.existsSync(path.join(archiveRoot, year, month, day, taskId, 'local', 'task.md')))
|
|
72
|
+
return [year, month, day, taskId];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
44
77
|
}
|
|
45
78
|
function resolveBranchFromTaskContent(content, taskId) {
|
|
46
79
|
const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
|
|
@@ -81,6 +114,33 @@ export function resolveTaskWorkspace(taskId, repoRoot) {
|
|
|
81
114
|
}
|
|
82
115
|
export function listTaskWorkspaces(repoRoot, state) {
|
|
83
116
|
const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
|
|
117
|
+
if (state === 'archive') {
|
|
118
|
+
if (!fs.existsSync(stateRoot))
|
|
119
|
+
return [];
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const year of fs.readdirSync(stateRoot).sort()) {
|
|
122
|
+
if (!/^\d{4}$/.test(year))
|
|
123
|
+
continue;
|
|
124
|
+
for (const month of fs.readdirSync(path.join(stateRoot, year)).sort()) {
|
|
125
|
+
if (!/^\d{2}$/.test(month))
|
|
126
|
+
continue;
|
|
127
|
+
for (const day of fs.readdirSync(path.join(stateRoot, year, month)).sort()) {
|
|
128
|
+
if (!/^\d{2}$/.test(day))
|
|
129
|
+
continue;
|
|
130
|
+
for (const taskId of fs.readdirSync(path.join(stateRoot, year, month, day)).sort()) {
|
|
131
|
+
if (!TASK_ID_RE.test(taskId))
|
|
132
|
+
continue;
|
|
133
|
+
const taskMd = path.join(stateRoot, year, month, day, taskId, 'local', 'task.md');
|
|
134
|
+
if (!fs.existsSync(taskMd))
|
|
135
|
+
continue;
|
|
136
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
137
|
+
rows.push({ taskId, branch: resolveBranchFromTaskContent(content, taskId), state, taskMd });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
84
144
|
if (!fs.existsSync(stateRoot))
|
|
85
145
|
return [];
|
|
86
146
|
return fs.readdirSync(stateRoot, { withFileTypes: true })
|
|
@@ -41,7 +41,7 @@ function listSortedNumeric(dir, width) {
|
|
|
41
41
|
.reverse();
|
|
42
42
|
}
|
|
43
43
|
function findInArchive(repoRoot, taskId) {
|
|
44
|
-
// archive-tasks
|
|
44
|
+
// archive-tasks stores local material at .agents/workspace/archive/YYYY/MM/DD/{taskId}/local/task.md
|
|
45
45
|
// where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
|
|
46
46
|
// the task id's creation date. So we cannot derive the path from taskId alone;
|
|
47
47
|
// walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
|
|
@@ -51,7 +51,7 @@ function findInArchive(repoRoot, taskId) {
|
|
|
51
51
|
for (const month of listSortedNumeric(yearDir, 2)) {
|
|
52
52
|
const monthDir = path.join(yearDir, month);
|
|
53
53
|
for (const day of listSortedNumeric(monthDir, 2)) {
|
|
54
|
-
const candidate = path.join(monthDir, day, taskId, 'task.md');
|
|
54
|
+
const candidate = path.join(monthDir, day, taskId, 'local', 'task.md');
|
|
55
55
|
if (fs.existsSync(candidate))
|
|
56
56
|
return candidate;
|
|
57
57
|
}
|
|
@@ -125,7 +125,7 @@ function enumerateAllTaskDirs(repoRoot) {
|
|
|
125
125
|
for (const day of listSortedNumeric(monthDir, 2).reverse()) {
|
|
126
126
|
const dayDir = path.join(monthDir, day);
|
|
127
127
|
for (const taskId of fs.readdirSync(dayDir).sort())
|
|
128
|
-
add(taskId, path.join(dayDir, taskId), 'archive');
|
|
128
|
+
add(taskId, path.join(dayDir, taskId, 'local'), 'archive');
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
}
|
package/dist/lib/task/write.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import { isValidAgentInfraVersion, VERSION } from '../version.js';
|
|
4
5
|
import { parseTypedTaskFrontmatter, updateTaskFrontmatter } from './frontmatter.js';
|
|
5
6
|
import { resolveTaskRef } from './resolve-ref.js';
|
|
@@ -53,7 +54,7 @@ function errorDetails(error, fallback) {
|
|
|
53
54
|
message: error instanceof Error ? error.message : String(error)
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
|
-
function
|
|
57
|
+
function writeTaskCore(request, options = {}) {
|
|
57
58
|
const resolved = options.taskLocation
|
|
58
59
|
? {
|
|
59
60
|
ok: true,
|
|
@@ -258,5 +259,124 @@ function writeTask(request, options = {}) {
|
|
|
258
259
|
}
|
|
259
260
|
return { ...successBase, status: 'applied', changed: true };
|
|
260
261
|
}
|
|
262
|
+
function archiveContentsHash(localDir) {
|
|
263
|
+
const files = [];
|
|
264
|
+
const walk = (dir) => {
|
|
265
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
266
|
+
if (entry.name.includes('\n') || entry.name.includes('\r') || entry.name === 'manifest.md') {
|
|
267
|
+
throw new Error(`Archive source path is not valid: ${path.join(dir, entry.name)}`);
|
|
268
|
+
}
|
|
269
|
+
const target = path.join(dir, entry.name);
|
|
270
|
+
const stat = fs.lstatSync(target);
|
|
271
|
+
if (stat.isSymbolicLink())
|
|
272
|
+
throw new Error(`Archive source cannot contain symbolic links: ${target}`);
|
|
273
|
+
if (stat.isDirectory())
|
|
274
|
+
walk(target);
|
|
275
|
+
else if (stat.isFile()) {
|
|
276
|
+
if (entry.name !== 'contents.sha256')
|
|
277
|
+
files.push(target);
|
|
278
|
+
}
|
|
279
|
+
else
|
|
280
|
+
throw new Error(`Archive source cannot contain special files: ${target}`);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
walk(localDir);
|
|
284
|
+
const rows = files
|
|
285
|
+
.map((file) => ({ file, relative: path.relative(localDir, file).split(path.sep).join('/') }))
|
|
286
|
+
.sort((left, right) => Buffer.compare(Buffer.from(left.relative), Buffer.from(right.relative)))
|
|
287
|
+
.map(({ file, relative }) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${relative}`);
|
|
288
|
+
return `${rows.join('\n')}${rows.length ? '\n' : ''}`;
|
|
289
|
+
}
|
|
290
|
+
function writeArchiveContentsHash(localDir) {
|
|
291
|
+
const contents = archiveContentsHash(localDir);
|
|
292
|
+
const hashPath = path.join(localDir, 'contents.sha256');
|
|
293
|
+
const tempPath = path.join(localDir, `.contents.sha256.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
294
|
+
try {
|
|
295
|
+
fs.writeFileSync(tempPath, contents, { flag: 'wx' });
|
|
296
|
+
fs.renameSync(tempPath, hashPath);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
try {
|
|
300
|
+
fs.unlinkSync(tempPath);
|
|
301
|
+
}
|
|
302
|
+
catch (cleanupError) {
|
|
303
|
+
if (cleanupError.code !== 'ENOENT') {
|
|
304
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function restoreArchivedTask(taskMdPath, contents, mode) {
|
|
311
|
+
const tempPath = `${taskMdPath}.${process.pid}.${Math.random().toString(36).slice(2)}.rollback`;
|
|
312
|
+
fs.writeFileSync(tempPath, contents, { flag: 'wx', mode });
|
|
313
|
+
try {
|
|
314
|
+
fs.renameSync(tempPath, taskMdPath);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
try {
|
|
318
|
+
fs.unlinkSync(tempPath);
|
|
319
|
+
}
|
|
320
|
+
catch (cleanupError) {
|
|
321
|
+
if (cleanupError.code !== 'ENOENT') {
|
|
322
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function writeTask(request, options = {}) {
|
|
329
|
+
const resolved = options.taskLocation
|
|
330
|
+
? {
|
|
331
|
+
ok: true,
|
|
332
|
+
repoRoot: options.taskLocation.repoRoot,
|
|
333
|
+
taskId: options.taskLocation.taskId,
|
|
334
|
+
taskDir: path.dirname(options.taskLocation.taskMdPath),
|
|
335
|
+
taskMdPath: options.taskLocation.taskMdPath,
|
|
336
|
+
state: options.taskLocation.state
|
|
337
|
+
}
|
|
338
|
+
: resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
|
|
339
|
+
if (!resolved.ok) {
|
|
340
|
+
return failure(request, { taskId: resolved.taskId, taskMdPath: null, actualState: null }, resolved.code, resolved.message);
|
|
341
|
+
}
|
|
342
|
+
if (resolved.state !== 'archive' || request.expectedState !== 'archive' || request.dryRun) {
|
|
343
|
+
return writeTaskCore(request, { ...options, taskLocation: {
|
|
344
|
+
repoRoot: resolved.repoRoot,
|
|
345
|
+
taskId: resolved.taskId,
|
|
346
|
+
taskMdPath: resolved.taskMdPath,
|
|
347
|
+
state: resolved.state
|
|
348
|
+
} });
|
|
349
|
+
}
|
|
350
|
+
const identity = { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, actualState: resolved.state };
|
|
351
|
+
try {
|
|
352
|
+
const originalTask = fs.readFileSync(resolved.taskMdPath);
|
|
353
|
+
const originalMode = fs.statSync(resolved.taskMdPath).mode;
|
|
354
|
+
archiveContentsHash(resolved.taskDir);
|
|
355
|
+
const result = writeTaskCore(request, { ...options, taskLocation: {
|
|
356
|
+
repoRoot: resolved.repoRoot,
|
|
357
|
+
taskId: resolved.taskId,
|
|
358
|
+
taskMdPath: resolved.taskMdPath,
|
|
359
|
+
state: resolved.state
|
|
360
|
+
} });
|
|
361
|
+
if (result.status === 'applied') {
|
|
362
|
+
try {
|
|
363
|
+
writeArchiveContentsHash(resolved.taskDir);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
try {
|
|
367
|
+
restoreArchivedTask(resolved.taskMdPath, originalTask, originalMode);
|
|
368
|
+
}
|
|
369
|
+
catch (rollbackError) {
|
|
370
|
+
throw new Error(`Archive checksum update failed: ${error instanceof Error ? error.message : String(error)}; task rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
371
|
+
}
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
return failure(request, identity, 'TASK_READ_FAILED', error instanceof Error ? error.message : String(error));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
261
381
|
export { writeTask, captureTaskWriteMetadata, canonicalTimestamp };
|
|
262
382
|
//# sourceMappingURL=write.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"protocolVersion": 3,
|
|
3
|
-
"packageVersion": "0.11.
|
|
4
|
-
"sourceInputHash": "
|
|
5
|
-
"internalExecutableBuildHash": "
|
|
3
|
+
"packageVersion": "0.11.6",
|
|
4
|
+
"sourceInputHash": "402048e73650c5ac4edfb1bc4b48ca3fceb7b9068790b1321281c4994121e80c",
|
|
5
|
+
"internalExecutableBuildHash": "47ecd04ac30183443b2b86f83d1668b8bdf9e59162cc9e2cedd333d3011843e9"
|
|
6
6
|
}
|
package/lib/merge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import { removeDirRecursive } from './remove-dir.ts';
|
|
4
5
|
import { validateCurrentTaskContract } from './task/current-contract.ts';
|
|
5
6
|
|
|
@@ -107,7 +108,7 @@ function extractTitle(content: string): string | null {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
function normalizeTaskRecord(taskDir: string, taskFile: string, dateParts: DateParts): TaskRecord {
|
|
110
|
-
const taskId = path.basename(taskDir);
|
|
111
|
+
const taskId = path.basename(taskDir) === 'local' ? path.basename(path.dirname(taskDir)) : path.basename(taskDir);
|
|
111
112
|
const content = fs.readFileSync(taskFile, 'utf8');
|
|
112
113
|
const completedAt = extractField(content, 'completed_at');
|
|
113
114
|
const updatedAt = extractField(content, 'updated_at');
|
|
@@ -155,7 +156,7 @@ function scanSourceTasks(sourceDir: string): TaskRecord[] {
|
|
|
155
156
|
continue;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
const taskDir = path.join(dayDir, taskEntry.name);
|
|
159
|
+
const taskDir = path.join(dayDir, taskEntry.name, 'local');
|
|
159
160
|
const taskFile = path.join(taskDir, 'task.md');
|
|
160
161
|
if (!fs.existsSync(taskFile)) {
|
|
161
162
|
continue;
|
|
@@ -251,7 +252,7 @@ function collectArchiveEntries(archiveDir: string): ArchiveManifestEntry[] {
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
const taskDir = path.join(dayDir, taskEntry.name);
|
|
254
|
-
const taskFile = path.join(taskDir, 'task.md');
|
|
255
|
+
const taskFile = path.join(taskDir, 'local', 'task.md');
|
|
255
256
|
const relativePath = `${yearEntry.name}/${monthEntry.name}/${dayEntry.name}/${taskEntry.name}/`;
|
|
256
257
|
let title = taskEntry.name;
|
|
257
258
|
let type = 'unknown';
|
|
@@ -624,10 +625,12 @@ function validateSourceRoot(sourceRoot: string): void {
|
|
|
624
625
|
function validateTaskDirectory(
|
|
625
626
|
sourceRoot: string,
|
|
626
627
|
taskDir: string,
|
|
627
|
-
seenTaskIds: Map<string, string
|
|
628
|
+
seenTaskIds: Map<string, string>,
|
|
629
|
+
archived = false
|
|
628
630
|
): void {
|
|
629
631
|
const taskId = path.basename(taskDir);
|
|
630
|
-
const
|
|
632
|
+
const contentDir = archived ? path.join(taskDir, 'local') : taskDir;
|
|
633
|
+
const taskFile = path.join(contentDir, 'task.md');
|
|
631
634
|
const taskDirStat = sourcePathStat(sourceRoot, taskDir);
|
|
632
635
|
if (!taskDirStat || !taskDirStat.isDirectory()) {
|
|
633
636
|
invalidSource(`${sourceRelativePath(sourceRoot, taskDir)} must be a directory`);
|
|
@@ -646,6 +649,33 @@ function validateTaskDirectory(
|
|
|
646
649
|
invalidSource(`${sourceRelativePath(sourceRoot, taskFile)} id '${String(contract.metadata.id ?? 'missing')}' does not match directory '${taskId}'`);
|
|
647
650
|
}
|
|
648
651
|
validateTaskTree(sourceRoot, taskDir);
|
|
652
|
+
if (archived) {
|
|
653
|
+
for (const entry of fs.readdirSync(taskDir, { withFileTypes: true })) {
|
|
654
|
+
if (!['local', 'github', 'derived'].includes(entry.name) || !entry.isDirectory()) {
|
|
655
|
+
invalidSource(`${sourceRelativePath(sourceRoot, path.join(taskDir, entry.name))} is not an allowed archive source directory`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
validateArchiveSource(sourceRoot, contentDir);
|
|
659
|
+
const githubDir = path.join(taskDir, 'github');
|
|
660
|
+
if (fs.existsSync(githubDir)) {
|
|
661
|
+
for (const capture of fs.readdirSync(githubDir, { withFileTypes: true })) {
|
|
662
|
+
const captureDir = path.join(githubDir, capture.name);
|
|
663
|
+
if (!capture.isDirectory() || !/^capture-[A-Za-z0-9._-]+$/.test(capture.name) || !fs.existsSync(path.join(captureDir, 'capture.md'))) {
|
|
664
|
+
invalidSource(`${sourceRelativePath(sourceRoot, captureDir)} is not a valid GitHub capture`);
|
|
665
|
+
}
|
|
666
|
+
validateArchiveSource(sourceRoot, captureDir);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const derivedDir = path.join(taskDir, 'derived');
|
|
670
|
+
if (fs.existsSync(derivedDir)) {
|
|
671
|
+
for (const view of fs.readdirSync(derivedDir, { withFileTypes: true })) {
|
|
672
|
+
const viewDir = path.join(derivedDir, view.name);
|
|
673
|
+
if (!view.isDirectory() || !fs.existsSync(path.join(viewDir, 'inputs.md'))) {
|
|
674
|
+
invalidSource(`${sourceRelativePath(sourceRoot, viewDir)} requires inputs.md`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
649
679
|
|
|
650
680
|
const previousPath = seenTaskIds.get(taskId);
|
|
651
681
|
if (previousPath) {
|
|
@@ -654,6 +684,26 @@ function validateTaskDirectory(
|
|
|
654
684
|
seenTaskIds.set(taskId, sourceRelativePath(sourceRoot, taskDir));
|
|
655
685
|
}
|
|
656
686
|
|
|
687
|
+
function validateArchiveSource(sourceRoot: string, sourceDir: string): void {
|
|
688
|
+
const hashFile = path.join(sourceDir, 'contents.sha256');
|
|
689
|
+
const hashStat = sourcePathStat(sourceRoot, hashFile);
|
|
690
|
+
if (!hashStat?.isFile()) invalidSource(`${sourceRelativePath(sourceRoot, sourceDir)} requires contents.sha256`);
|
|
691
|
+
const files: string[] = [];
|
|
692
|
+
const walk = (dir: string) => {
|
|
693
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
694
|
+
const target = path.join(dir, entry.name);
|
|
695
|
+
if (entry.name === 'manifest.md') invalidSource(`${sourceRelativePath(sourceRoot, target)} is forbidden inside a TASK`);
|
|
696
|
+
if (entry.isDirectory()) walk(target);
|
|
697
|
+
else if (entry.isFile() && target !== hashFile) files.push(target);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
walk(sourceDir);
|
|
701
|
+
const expected = files.sort((a, b) => Buffer.compare(Buffer.from(path.relative(sourceDir, a).split(path.sep).join('/')), Buffer.from(path.relative(sourceDir, b).split(path.sep).join('/'))))
|
|
702
|
+
.map((file) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${path.relative(sourceDir, file).split(path.sep).join('/')}`);
|
|
703
|
+
const actual = fs.readFileSync(hashFile, 'utf8').trimEnd().split(/\r?\n/).filter(Boolean);
|
|
704
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual)) invalidSource(`${sourceRelativePath(sourceRoot, hashFile)} does not match local files`);
|
|
705
|
+
}
|
|
706
|
+
|
|
657
707
|
function validateMutableSection(
|
|
658
708
|
sourceRoot: string,
|
|
659
709
|
section: MutableSection,
|
|
@@ -730,9 +780,6 @@ function validateArchiveSection(
|
|
|
730
780
|
const monthDir = monthPath;
|
|
731
781
|
for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
|
|
732
782
|
const dayPath = path.join(monthDir, dayEntry.name);
|
|
733
|
-
if (allowManifest(monthDir, dayEntry)) {
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
783
|
const dayStat = sourcePathStat(sourceRoot, dayPath);
|
|
737
784
|
if (!dayStat?.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
|
|
738
785
|
invalidSource(`${sourceRelativePath(sourceRoot, dayPath)} is not a DD directory or manifest.md`);
|
|
@@ -745,7 +792,7 @@ function validateArchiveSection(
|
|
|
745
792
|
if (!taskStat?.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
|
|
746
793
|
invalidSource(`${sourceRelativePath(sourceRoot, taskPath)} is not a TASK-YYYYMMDD-HHMMSS directory`);
|
|
747
794
|
}
|
|
748
|
-
validateTaskDirectory(sourceRoot, taskPath, seenTaskIds);
|
|
795
|
+
validateTaskDirectory(sourceRoot, taskPath, seenTaskIds, true);
|
|
749
796
|
}
|
|
750
797
|
}
|
|
751
798
|
}
|
|
@@ -904,9 +951,10 @@ function mergeArchiveSection(sourceArchive: string, localArchive: string, report
|
|
|
904
951
|
continue;
|
|
905
952
|
}
|
|
906
953
|
|
|
954
|
+
const sourceTaskDir = path.dirname(task.taskDir);
|
|
907
955
|
const destinationDir = path.join(localArchive, task.relativePath);
|
|
908
956
|
fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
|
|
909
|
-
fs.cpSync(
|
|
957
|
+
fs.cpSync(sourceTaskDir, destinationDir, { recursive: true });
|
|
910
958
|
recordArchive(report, 'copied', {
|
|
911
959
|
action: 'copied',
|
|
912
960
|
symbol: '✓',
|
|
@@ -1045,6 +1093,7 @@ async function cmdMerge(args: string[]): Promise<void> {
|
|
|
1045
1093
|
const report = createReport(resolvedSource, backupRootRelative);
|
|
1046
1094
|
detectSourceMode(resolvedSource);
|
|
1047
1095
|
validateSourceWorkspace(resolvedSource);
|
|
1096
|
+
validateArchiveSection(workspaceDir, new Map());
|
|
1048
1097
|
|
|
1049
1098
|
for (const section of ALL_SECTIONS) {
|
|
1050
1099
|
fs.mkdirSync(path.join(workspaceDir, section), { recursive: true });
|