taskchef 7.28.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +21 -1
- package/bin/taskchef.js +11 -1
- package/docs/dashboard-lifecycle.md +17 -0
- package/docs/spec.md +37 -0
- package/docs/workflows.md +5 -1
- package/index.js +14 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +7 -3
- package/skills/taskchef-bootstrap/references/project-index.md +21 -3
- package/skills/taskchef-copilot/SKILL.md +2 -0
- package/skills/taskchef-dashboard/SKILL.md +4 -0
- package/skills/taskchef-delegate/SKILL.md +3 -2
- package/src/backups.js +359 -0
- package/src/cli.js +180 -16
- package/src/config-audit.js +99 -0
- package/src/config-mutations.js +98 -0
- package/src/dashboard/dist/app.js +32 -32
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/react/App.tsx +16 -0
- package/src/dashboard/react/components/ModelSettings.test.tsx +14 -0
- package/src/dashboard/react/components/ModelSettings.tsx +14 -1
- package/src/dashboard/react/types.ts +10 -0
- package/src/dashboard-manager.js +16 -0
- package/src/dashboard.js +76 -24
- package/src/state-store.js +81 -0
- package/src/usage-tracker.js +192 -23
- package/src/usage.js +40 -12
- package/src/workspace.js +639 -37
package/src/backups.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { sha256, semanticHash, taskChefError } from "./state-store.js";
|
|
16
|
+
import { TASKCHEF_VERSION } from "./version.js";
|
|
17
|
+
|
|
18
|
+
export const BACKUP_FILES = ["taskchef.json", "tasks.jsonl", "AGENTS.md", "config-audit.jsonl"];
|
|
19
|
+
const MAX_BACKUP_BYTES = 512 * 1024 * 1024;
|
|
20
|
+
const MAX_SOURCE_FILE_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
async function privateDirectory(directory) {
|
|
23
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
const details = await lstat(directory);
|
|
25
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
26
|
+
throw taskChefError("BACKUP_FAILED", `backup path is not a real directory: ${directory}`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
|
|
29
|
+
throw taskChefError("BACKUP_FAILED", `backup path is not owned by the current user: ${directory}`);
|
|
30
|
+
}
|
|
31
|
+
await chmod(directory, 0o700);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function filePayload(workspace, name, validateFile) {
|
|
35
|
+
const filePath = path.join(workspace, name);
|
|
36
|
+
try {
|
|
37
|
+
const details = await lstat(filePath);
|
|
38
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
39
|
+
throw taskChefError("BACKUP_FAILED", `managed state is not a regular file: ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
if (details.size > MAX_SOURCE_FILE_BYTES) {
|
|
42
|
+
throw taskChefError("BACKUP_FAILED", `managed state exceeds the backup size limit: ${filePath}`);
|
|
43
|
+
}
|
|
44
|
+
const bytes = await readFile(filePath);
|
|
45
|
+
let valid = true;
|
|
46
|
+
try {
|
|
47
|
+
if (name === "taskchef.json") {
|
|
48
|
+
const config = JSON.parse(bytes.toString("utf8"));
|
|
49
|
+
valid = config?.schemaVersion === 2 && Array.isArray(config.projects);
|
|
50
|
+
} else if (name.endsWith(".jsonl")) {
|
|
51
|
+
for (const line of bytes.toString("utf8").trimEnd().split("\n")) {
|
|
52
|
+
if (line) JSON.parse(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (validateFile) await validateFile(name, bytes);
|
|
56
|
+
} catch {
|
|
57
|
+
valid = false;
|
|
58
|
+
}
|
|
59
|
+
return { present: true, size: bytes.length, sha256: sha256(bytes), bytes, valid };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code === "ENOENT") return { present: false, size: 0, sha256: null, bytes: null };
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readManifest(snapshotPath) {
|
|
67
|
+
const manifest = JSON.parse(await readFile(path.join(snapshotPath, "manifest.json"), "utf8"));
|
|
68
|
+
if (manifest.schemaVersion !== 1 || manifest.workspace.length === 0) {
|
|
69
|
+
throw new Error("unsupported backup manifest");
|
|
70
|
+
}
|
|
71
|
+
return manifest;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function verifyBackup(workspace, id) {
|
|
75
|
+
if (!/^[0-9TZ.-]+-[0-9a-f-]{36}$/i.test(id)) throw new Error("invalid backup ID");
|
|
76
|
+
const snapshotPath = path.join(workspace, "backups", id);
|
|
77
|
+
let manifest = null;
|
|
78
|
+
try {
|
|
79
|
+
const snapshotDetails = await lstat(snapshotPath);
|
|
80
|
+
if (!snapshotDetails.isDirectory() || snapshotDetails.isSymbolicLink()
|
|
81
|
+
|| (snapshotDetails.mode & 0o077) !== 0
|
|
82
|
+
|| (typeof process.getuid === "function" && snapshotDetails.uid !== process.getuid())) {
|
|
83
|
+
throw new Error("backup snapshot permissions or ownership are unsafe");
|
|
84
|
+
}
|
|
85
|
+
manifest = await readManifest(snapshotPath);
|
|
86
|
+
if (manifest.workspace !== workspace) throw new Error("backup belongs to a different workspace");
|
|
87
|
+
if (manifest.id !== id) throw new Error("backup manifest ID does not match its directory");
|
|
88
|
+
const snapshotNames = await readdir(snapshotPath);
|
|
89
|
+
const allowedNames = new Set(["manifest.json", ...BACKUP_FILES.filter(
|
|
90
|
+
(name) => manifest.files?.[name]?.present,
|
|
91
|
+
)]);
|
|
92
|
+
const unexpected = snapshotNames.find((name) => !allowedNames.has(name));
|
|
93
|
+
if (unexpected) throw new Error(`backup contains an unexpected payload: ${unexpected}`);
|
|
94
|
+
for (const name of BACKUP_FILES) {
|
|
95
|
+
const expected = manifest.files[name];
|
|
96
|
+
if (!expected) throw new Error(`manifest is missing ${name}`);
|
|
97
|
+
if (!expected.present) continue;
|
|
98
|
+
const details = await lstat(path.join(snapshotPath, name));
|
|
99
|
+
if (!details.isFile() || details.isSymbolicLink() || (details.mode & 0o077) !== 0
|
|
100
|
+
|| (typeof process.getuid === "function" && details.uid !== process.getuid())) {
|
|
101
|
+
throw new Error(`backup payload permissions are unsafe: ${name}`);
|
|
102
|
+
}
|
|
103
|
+
const bytes = await readFile(path.join(snapshotPath, name));
|
|
104
|
+
if (bytes.length !== expected.size || sha256(bytes) !== expected.sha256) {
|
|
105
|
+
throw new Error(`backup payload failed verification: ${name}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const manifestDetails = await lstat(path.join(snapshotPath, "manifest.json"));
|
|
109
|
+
if (!manifestDetails.isFile() || manifestDetails.isSymbolicLink()
|
|
110
|
+
|| (manifestDetails.mode & 0o077) !== 0
|
|
111
|
+
|| (typeof process.getuid === "function" && manifestDetails.uid !== process.getuid())) {
|
|
112
|
+
throw new Error("backup manifest permissions are unsafe");
|
|
113
|
+
}
|
|
114
|
+
if (manifest.validationStatus !== "valid") {
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
usable: false,
|
|
118
|
+
integrityValid: true,
|
|
119
|
+
path: snapshotPath,
|
|
120
|
+
manifest,
|
|
121
|
+
error: "backup contains invalid source state and is forensic-only",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { id, usable: true, integrityValid: true, path: snapshotPath, manifest };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
usable: false,
|
|
129
|
+
integrityValid: false,
|
|
130
|
+
path: snapshotPath,
|
|
131
|
+
...(manifest ? { manifest } : {}),
|
|
132
|
+
error: error.message,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function listBackups(workspace) {
|
|
138
|
+
const root = path.join(workspace, "backups");
|
|
139
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
|
|
140
|
+
if (error.code === "ENOENT") return [];
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
const backups = [];
|
|
144
|
+
for (const entry of entries.filter((item) => item.isDirectory() && !item.name.startsWith(".tmp-"))) {
|
|
145
|
+
backups.push(await verifyBackup(workspace, entry.name));
|
|
146
|
+
}
|
|
147
|
+
backups.sort((left, right) => right.id.localeCompare(left.id));
|
|
148
|
+
const backupBytes = (item) => item.manifest?.files
|
|
149
|
+
? Object.values(item.manifest.files)
|
|
150
|
+
.reduce((sum, file) => sum + (file.present ? file.size : 0), 0)
|
|
151
|
+
: 0;
|
|
152
|
+
const usableBytes = backups.filter((item) => item.usable)
|
|
153
|
+
.reduce((sum, item) => sum + backupBytes(item), 0);
|
|
154
|
+
const forensicBytes = backups.filter((item) => item.integrityValid && !item.usable)
|
|
155
|
+
.reduce((sum, item) => sum + backupBytes(item), 0);
|
|
156
|
+
const corruptBytes = backups.filter((item) => !item.integrityValid)
|
|
157
|
+
.reduce((sum, item) => sum + backupBytes(item), 0);
|
|
158
|
+
return {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
backupCount: backups.length,
|
|
161
|
+
usableCount: backups.filter((item) => item.usable).length,
|
|
162
|
+
forensicCount: backups.filter((item) => item.integrityValid && !item.usable).length,
|
|
163
|
+
corruptCount: backups.filter((item) => !item.integrityValid).length,
|
|
164
|
+
usableBytes,
|
|
165
|
+
forensicBytes,
|
|
166
|
+
corruptBytes,
|
|
167
|
+
totalBytes: usableBytes + forensicBytes + corruptBytes,
|
|
168
|
+
backups,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function createBackupUnlocked(workspace, {
|
|
173
|
+
reason = "manual",
|
|
174
|
+
now = () => new Date().toISOString(),
|
|
175
|
+
validateFile = null,
|
|
176
|
+
} = {}) {
|
|
177
|
+
const root = path.join(workspace, "backups");
|
|
178
|
+
await privateDirectory(root);
|
|
179
|
+
const payloads = Object.fromEntries(await Promise.all(BACKUP_FILES.map(async (name) => [
|
|
180
|
+
name, await filePayload(workspace, name, validateFile),
|
|
181
|
+
])));
|
|
182
|
+
const aggregateHash = semanticHash(Object.fromEntries(
|
|
183
|
+
BACKUP_FILES.map((name) => [name, {
|
|
184
|
+
present: payloads[name].present,
|
|
185
|
+
size: payloads[name].size,
|
|
186
|
+
sha256: payloads[name].sha256,
|
|
187
|
+
}]),
|
|
188
|
+
), "taskchef-backup-v1");
|
|
189
|
+
const existing = (await listBackups(workspace)).backups.find(
|
|
190
|
+
(item) => item.usable && item.manifest.aggregateHash === aggregateHash,
|
|
191
|
+
);
|
|
192
|
+
if (existing) return { ...existing, deduplicated: true, reason };
|
|
193
|
+
|
|
194
|
+
const timestamp = now().replaceAll(":", "-").replaceAll(".", "-");
|
|
195
|
+
const id = `${timestamp}-${randomUUID()}`;
|
|
196
|
+
const staging = path.join(root, `.tmp-${id}`);
|
|
197
|
+
const destination = path.join(root, id);
|
|
198
|
+
await mkdir(staging, { mode: 0o700 });
|
|
199
|
+
try {
|
|
200
|
+
const files = {};
|
|
201
|
+
for (const name of BACKUP_FILES) {
|
|
202
|
+
const payload = payloads[name];
|
|
203
|
+
files[name] = {
|
|
204
|
+
present: payload.present,
|
|
205
|
+
size: payload.size,
|
|
206
|
+
sha256: payload.sha256,
|
|
207
|
+
valid: payload.present ? payload.valid : null,
|
|
208
|
+
};
|
|
209
|
+
if (!payload.present) continue;
|
|
210
|
+
const handle = await open(path.join(staging, name), "wx", 0o600);
|
|
211
|
+
try {
|
|
212
|
+
await handle.writeFile(payload.bytes);
|
|
213
|
+
await handle.sync();
|
|
214
|
+
} finally {
|
|
215
|
+
await handle.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
let projectCount = null;
|
|
219
|
+
let configSchemaVersion = null;
|
|
220
|
+
let taskCount = null;
|
|
221
|
+
try {
|
|
222
|
+
const config = JSON.parse(payloads["taskchef.json"].bytes.toString("utf8"));
|
|
223
|
+
projectCount = Array.isArray(config.projects) ? config.projects.length : null;
|
|
224
|
+
configSchemaVersion = config.schemaVersion ?? null;
|
|
225
|
+
} catch {}
|
|
226
|
+
if (payloads["tasks.jsonl"].present && payloads["tasks.jsonl"].valid) {
|
|
227
|
+
taskCount = payloads["tasks.jsonl"].bytes.toString("utf8").trimEnd().split("\n")
|
|
228
|
+
.filter(Boolean).length;
|
|
229
|
+
}
|
|
230
|
+
const manifest = {
|
|
231
|
+
schemaVersion: 1,
|
|
232
|
+
id,
|
|
233
|
+
createdAt: now(),
|
|
234
|
+
reason,
|
|
235
|
+
taskChefVersion: TASKCHEF_VERSION,
|
|
236
|
+
workspace,
|
|
237
|
+
aggregateHash,
|
|
238
|
+
validationStatus: Object.values(payloads)
|
|
239
|
+
.every((payload) => !payload.present || payload.valid) ? "valid" : "invalid",
|
|
240
|
+
configSchemaVersion,
|
|
241
|
+
projectCount,
|
|
242
|
+
taskCount,
|
|
243
|
+
auditBoundary: payloads["config-audit.jsonl"].present
|
|
244
|
+
? payloads["config-audit.jsonl"].sha256
|
|
245
|
+
: null,
|
|
246
|
+
files,
|
|
247
|
+
};
|
|
248
|
+
const manifestHandle = await open(path.join(staging, "manifest.json"), "wx", 0o600);
|
|
249
|
+
try {
|
|
250
|
+
await manifestHandle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
251
|
+
await manifestHandle.sync();
|
|
252
|
+
} finally {
|
|
253
|
+
await manifestHandle.close();
|
|
254
|
+
}
|
|
255
|
+
const stagingHandle = await open(staging, "r");
|
|
256
|
+
try { await stagingHandle.sync(); } finally { await stagingHandle.close(); }
|
|
257
|
+
await rename(staging, destination);
|
|
258
|
+
const rootHandle = await open(root, "r");
|
|
259
|
+
try { await rootHandle.sync(); } finally { await rootHandle.close(); }
|
|
260
|
+
const verified = await verifyBackup(workspace, id);
|
|
261
|
+
if (!verified.usable && !verified.integrityValid) throw new Error(verified.error);
|
|
262
|
+
if (!verified.usable) {
|
|
263
|
+
return { ...verified, deduplicated: false, forensic: true, reason };
|
|
264
|
+
}
|
|
265
|
+
const retention = await pruneBackupsUnlocked(workspace, { apply: true });
|
|
266
|
+
return {
|
|
267
|
+
...verified,
|
|
268
|
+
deduplicated: false,
|
|
269
|
+
reason,
|
|
270
|
+
storagePressure: retention.remainingBytes > MAX_BACKUP_BYTES,
|
|
271
|
+
};
|
|
272
|
+
} catch (error) {
|
|
273
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {});
|
|
274
|
+
throw taskChefError("BACKUP_FAILED", `backup creation failed: ${error.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function pruneBackupsUnlocked(workspace, { apply = false } = {}) {
|
|
279
|
+
const inventory = await listBackups(workspace);
|
|
280
|
+
const usable = inventory.backups.filter((item) => item.usable);
|
|
281
|
+
const preferred = new Set(usable.slice(0, 30).map((item) => item.id));
|
|
282
|
+
const daily = new Set();
|
|
283
|
+
const newestTime = usable.length > 0 ? Date.parse(usable[0].manifest.createdAt) : 0;
|
|
284
|
+
for (const item of usable) {
|
|
285
|
+
const day = item.manifest.createdAt.slice(0, 10);
|
|
286
|
+
const dayTime = Date.parse(`${day}T00:00:00.000Z`);
|
|
287
|
+
const ageDays = Math.floor((newestTime - dayTime) / 86_400_000);
|
|
288
|
+
if (ageDays >= 0 && ageDays < 30 && !daily.has(day)) {
|
|
289
|
+
daily.add(day);
|
|
290
|
+
preferred.add(item.id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let totalBytes = 0;
|
|
294
|
+
for (const item of usable) {
|
|
295
|
+
totalBytes += Object.values(item.manifest.files).reduce((sum, file) => sum + file.size, 0);
|
|
296
|
+
}
|
|
297
|
+
const protectedIds = new Set(usable.slice(0, 1).map((item) => item.id));
|
|
298
|
+
const preRestore = usable.find((item) => item.manifest.reason === "pre-restore");
|
|
299
|
+
if (preRestore) protectedIds.add(preRestore.id);
|
|
300
|
+
const audit = await readFile(path.join(workspace, "config-audit.jsonl"), "utf8").catch(() => "");
|
|
301
|
+
const transactions = new Map();
|
|
302
|
+
for (const line of audit.trimEnd().split("\n")) {
|
|
303
|
+
if (!line) continue;
|
|
304
|
+
try {
|
|
305
|
+
const record = JSON.parse(line);
|
|
306
|
+
if (record.transactionId) transactions.set(record.transactionId, record);
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
for (const record of transactions.values()) {
|
|
310
|
+
if (new Set(["prepared", "recovery-required"]).has(record.phase) && record.backupId) {
|
|
311
|
+
protectedIds.add(record.backupId);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const candidates = [
|
|
315
|
+
...usable.filter((item) => !preferred.has(item.id)).reverse(),
|
|
316
|
+
...usable.filter((item) => preferred.has(item.id)).reverse(),
|
|
317
|
+
].filter((item, index, items) =>
|
|
318
|
+
!protectedIds.has(item.id) && items.findIndex((candidate) => candidate.id === item.id) === index);
|
|
319
|
+
const removed = [];
|
|
320
|
+
for (const item of candidates) {
|
|
321
|
+
if (preferred.has(item.id) && totalBytes <= MAX_BACKUP_BYTES) continue;
|
|
322
|
+
const bytes = Object.values(item.manifest.files).reduce((sum, file) => sum + file.size, 0);
|
|
323
|
+
if (apply) await rm(item.path, { recursive: true });
|
|
324
|
+
totalBytes -= bytes;
|
|
325
|
+
removed.push(item.id);
|
|
326
|
+
}
|
|
327
|
+
return { schemaVersion: 1, apply, removed, remainingBytes: totalBytes };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function readBackupConfig(workspace, id) {
|
|
331
|
+
const verified = await verifyBackup(workspace, id);
|
|
332
|
+
if (!verified.usable) throw taskChefError("BACKUP_FAILED", verified.error);
|
|
333
|
+
const entry = verified.manifest.files["taskchef.json"];
|
|
334
|
+
if (!entry.present) throw taskChefError("BACKUP_FAILED", "backup has no taskchef.json");
|
|
335
|
+
return JSON.parse(await readFile(path.join(verified.path, "taskchef.json"), "utf8"));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function readBackupPayloads(workspace, id, names, {
|
|
339
|
+
allowForensic = false,
|
|
340
|
+
allowMissing = false,
|
|
341
|
+
} = {}) {
|
|
342
|
+
const verified = await verifyBackup(workspace, id);
|
|
343
|
+
if (!verified.usable && !(allowForensic && verified.integrityValid)) {
|
|
344
|
+
throw taskChefError("BACKUP_FAILED", verified.error);
|
|
345
|
+
}
|
|
346
|
+
const payloads = {};
|
|
347
|
+
for (const name of names) {
|
|
348
|
+
if (!BACKUP_FILES.includes(name)) throw taskChefError("INVALID_INPUT", "unsupported backup payload");
|
|
349
|
+
if (!verified.manifest.files[name].present) {
|
|
350
|
+
if (allowMissing) {
|
|
351
|
+
payloads[name] = null;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
throw taskChefError("BACKUP_FAILED", `backup has no ${name}`);
|
|
355
|
+
}
|
|
356
|
+
payloads[name] = await readFile(path.join(verified.path, name));
|
|
357
|
+
}
|
|
358
|
+
return { verified, payloads };
|
|
359
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveWorkspacePath } from "./workspace-path.js";
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
addProject,
|
|
10
|
+
createWorkspaceBackup,
|
|
10
11
|
buildCopilotBrief,
|
|
11
12
|
buildTaskSummary,
|
|
12
13
|
doctorWorkspace,
|
|
@@ -14,12 +15,19 @@ import {
|
|
|
14
15
|
importProjects,
|
|
15
16
|
initializeWorkspace,
|
|
16
17
|
listProjects,
|
|
18
|
+
listWorkspaceBackups,
|
|
19
|
+
pruneWorkspaceBackups,
|
|
20
|
+
readProjectIndex,
|
|
21
|
+
readWorkspaceMaintenance,
|
|
17
22
|
listTasks,
|
|
18
23
|
migrateTaskLog,
|
|
19
24
|
prepareDispatch,
|
|
20
25
|
recordTask,
|
|
21
26
|
removeProject,
|
|
27
|
+
restoreWorkspaceBackup,
|
|
22
28
|
requireSafeId,
|
|
29
|
+
updateProject,
|
|
30
|
+
verifyWorkspaceBackup,
|
|
23
31
|
} from "./workspace.js";
|
|
24
32
|
|
|
25
33
|
const BLANK_TABLE_CELL = Symbol("blank table cell");
|
|
@@ -111,6 +119,30 @@ function print(value, args, human) {
|
|
|
111
119
|
process.stdout.write(`${human ? human(value) : JSON.stringify(value, null, 2)}\n`);
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
async function mutationOptions(args) {
|
|
123
|
+
const confirmedPath = option(args, "--confirm-removed", null);
|
|
124
|
+
let confirmRemoved;
|
|
125
|
+
if (confirmedPath !== null) {
|
|
126
|
+
confirmRemoved = JSON.parse(await readFile(path.resolve(confirmedPath), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
const numberOption = (name) => {
|
|
129
|
+
const value = option(args, name, null);
|
|
130
|
+
if (value === null) return undefined;
|
|
131
|
+
const parsed = Number(value);
|
|
132
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`);
|
|
133
|
+
return parsed;
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
dryRun: args.includes("--dry-run"),
|
|
137
|
+
expectedConfigHash: option(args, "--expect-config-hash", undefined),
|
|
138
|
+
expectedProjectCount: numberOption("--expect-project-count"),
|
|
139
|
+
expectedAfterCount: numberOption("--expect-after-count"),
|
|
140
|
+
confirmPlan: option(args, "--confirm-plan", undefined),
|
|
141
|
+
confirmRemoved,
|
|
142
|
+
confirmCountCollapse: option(args, "--confirm-count-collapse", undefined),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
function table(headers, rows) {
|
|
115
147
|
const display = (value) => {
|
|
116
148
|
if (value === BLANK_TABLE_CELL) return "";
|
|
@@ -300,7 +332,7 @@ async function projectAdd(args) {
|
|
|
300
332
|
if (!args[2] || args[2].startsWith("--")) throw new Error("project add requires a path");
|
|
301
333
|
validateCommandArgs(args, 3, {
|
|
302
334
|
values: ["--workspace", "--name", "--description", "--github-repo"],
|
|
303
|
-
switches: ["--json", "--no-github"],
|
|
335
|
+
switches: ["--json", "--no-github", "--dry-run"],
|
|
304
336
|
repeatable: ["--github-repo"],
|
|
305
337
|
});
|
|
306
338
|
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
@@ -313,8 +345,13 @@ async function projectAdd(args) {
|
|
|
313
345
|
if (description !== null) input.description = description;
|
|
314
346
|
if (args.includes("--no-github")) input.githubRepos = [];
|
|
315
347
|
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
316
|
-
const
|
|
317
|
-
|
|
348
|
+
const result = await addProject(workspaceRoot(args), input, {
|
|
349
|
+
dryRun: args.includes("--dry-run"),
|
|
350
|
+
});
|
|
351
|
+
print(result, args, (value) => {
|
|
352
|
+
const project = value.project ?? value;
|
|
353
|
+
return `${args.includes("--dry-run") ? "Would add" : "Added"} ${project.name}: ${project.path}`;
|
|
354
|
+
});
|
|
318
355
|
return 0;
|
|
319
356
|
}
|
|
320
357
|
|
|
@@ -322,8 +359,11 @@ async function projectImport(args) {
|
|
|
322
359
|
const hasSource = Boolean(args[2] && !args[2].startsWith("--"));
|
|
323
360
|
const source = hasSource ? args[2] : "-";
|
|
324
361
|
validateCommandArgs(args, hasSource ? 3 : 2, {
|
|
325
|
-
values: [
|
|
326
|
-
|
|
362
|
+
values: [
|
|
363
|
+
"--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
|
|
364
|
+
"--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
365
|
+
],
|
|
366
|
+
switches: ["--json", "--replace", "--dry-run"],
|
|
327
367
|
});
|
|
328
368
|
const content = source === "-"
|
|
329
369
|
? await readStdin()
|
|
@@ -331,6 +371,7 @@ async function projectImport(args) {
|
|
|
331
371
|
if (content.trim().length === 0) throw new Error("project import input is empty");
|
|
332
372
|
const result = await importProjects(workspaceRoot(args), JSON.parse(content), {
|
|
333
373
|
replace: args.includes("--replace"),
|
|
374
|
+
...(await mutationOptions(args)),
|
|
334
375
|
});
|
|
335
376
|
print(result, args, (value) =>
|
|
336
377
|
`Imported ${value.importedCount} project(s); ${value.projectCount} configured (${value.mode}).`);
|
|
@@ -338,9 +379,14 @@ async function projectImport(args) {
|
|
|
338
379
|
}
|
|
339
380
|
|
|
340
381
|
async function projectList(args) {
|
|
341
|
-
validateCommandArgs(args, 2, {
|
|
342
|
-
|
|
343
|
-
|
|
382
|
+
validateCommandArgs(args, 2, {
|
|
383
|
+
values: ["--workspace"],
|
|
384
|
+
switches: ["--json", "--allow-missing-paths"],
|
|
385
|
+
});
|
|
386
|
+
const result = await readProjectIndex(workspaceRoot(args), {
|
|
387
|
+
checkPaths: !args.includes("--allow-missing-paths"),
|
|
388
|
+
});
|
|
389
|
+
print(result, args, (value) => table(
|
|
344
390
|
["NAME", "KIND", "GITHUB REPOSITORY", "PATH"],
|
|
345
391
|
projectRows(value.projects),
|
|
346
392
|
));
|
|
@@ -364,14 +410,128 @@ async function dispatchPrepare(args) {
|
|
|
364
410
|
async function projectRemove(args) {
|
|
365
411
|
if (!args[2] || args[2].startsWith("--")) throw new Error("project remove requires a name");
|
|
366
412
|
validateCommandArgs(args, 3, {
|
|
367
|
-
values: [
|
|
368
|
-
|
|
413
|
+
values: [
|
|
414
|
+
"--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
|
|
415
|
+
"--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
416
|
+
],
|
|
417
|
+
switches: ["--json", "--dry-run"],
|
|
369
418
|
});
|
|
370
|
-
const result = await removeProject(workspaceRoot(args), args[2]);
|
|
371
|
-
print(result, args, (value) =>
|
|
419
|
+
const result = await removeProject(workspaceRoot(args), args[2], await mutationOptions(args));
|
|
420
|
+
print(result, args, (value) =>
|
|
421
|
+
`${args.includes("--dry-run") ? "Would remove" : "Removed"} ${value.project.name}: ${value.project.path}`);
|
|
372
422
|
return 0;
|
|
373
423
|
}
|
|
374
424
|
|
|
425
|
+
async function projectUpdate(args) {
|
|
426
|
+
const hasName = Boolean(args[2] && !args[2].startsWith("--"));
|
|
427
|
+
validateCommandArgs(args, hasName ? 3 : 2, {
|
|
428
|
+
values: [
|
|
429
|
+
"--workspace", "--path", "--name", "--description", "--github-repo",
|
|
430
|
+
"--expect-config-hash",
|
|
431
|
+
],
|
|
432
|
+
switches: ["--json", "--clear-description", "--no-github", "--refresh-git", "--dry-run"],
|
|
433
|
+
repeatable: ["--github-repo"],
|
|
434
|
+
});
|
|
435
|
+
const selectedPath = option(args, "--path", null);
|
|
436
|
+
if (hasName === Boolean(selectedPath)) throw new Error("project update requires exactly one name or --path selector");
|
|
437
|
+
if (args.includes("--description") && args.includes("--clear-description")) {
|
|
438
|
+
throw new Error("--description and --clear-description cannot be combined");
|
|
439
|
+
}
|
|
440
|
+
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
441
|
+
throw new Error("--no-github and --github-repo cannot be combined");
|
|
442
|
+
}
|
|
443
|
+
const input = {};
|
|
444
|
+
if (args.includes("--name")) input.name = option(args, "--name", null);
|
|
445
|
+
if (args.includes("--description")) input.description = option(args, "--description", null);
|
|
446
|
+
if (args.includes("--clear-description")) input.clearDescription = true;
|
|
447
|
+
if (args.includes("--no-github")) input.githubRepos = [];
|
|
448
|
+
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
449
|
+
if (args.includes("--refresh-git")) input.refreshGit = true;
|
|
450
|
+
const result = await updateProject(
|
|
451
|
+
workspaceRoot(args),
|
|
452
|
+
selectedPath ? { path: selectedPath } : { name: args[2] },
|
|
453
|
+
input,
|
|
454
|
+
await mutationOptions(args),
|
|
455
|
+
);
|
|
456
|
+
print(result, args, (value) => value.changed
|
|
457
|
+
? `Updated ${value.project.name}: ${value.project.path}`
|
|
458
|
+
: `Unchanged ${value.project.name}: ${value.project.path}`);
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function backupCommand(args) {
|
|
463
|
+
const action = args[1];
|
|
464
|
+
if (action === "list") {
|
|
465
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
466
|
+
const result = await listWorkspaceBackups(workspaceRoot(args));
|
|
467
|
+
print(result, args, (value) => value.backups.map((item) =>
|
|
468
|
+
`${item.id} ${item.usable ? "usable" : item.integrityValid ? "forensic" : "corrupt"}`)
|
|
469
|
+
.join("\n") || "No backups.");
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
if (action === "create") {
|
|
473
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
474
|
+
const result = await createWorkspaceBackup(workspaceRoot(args), { reason: "manual" });
|
|
475
|
+
print(result, args, (value) => `Created backup ${value.id}.`);
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
if (action === "verify" && args[2]) {
|
|
479
|
+
validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
|
|
480
|
+
const result = await verifyWorkspaceBackup(workspaceRoot(args), args[2]);
|
|
481
|
+
print(result, args, (value) => `${value.id}: ${value.usable ? "usable" : value.error}`);
|
|
482
|
+
return result.usable ? 0 : 1;
|
|
483
|
+
}
|
|
484
|
+
if (action === "prune") {
|
|
485
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json", "--apply", "--dry-run"] });
|
|
486
|
+
if (args.includes("--apply") && args.includes("--dry-run")) throw new Error("--apply and --dry-run conflict");
|
|
487
|
+
const result = await pruneWorkspaceBackups(workspaceRoot(args), { apply: args.includes("--apply") });
|
|
488
|
+
print(result, args, (value) => `${value.apply ? "Pruned" : "Would prune"} ${value.removed.length} backup(s).`);
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
if (action === "restore") {
|
|
492
|
+
const hasBackupId = Boolean(args[2] && !args[2].startsWith("--"));
|
|
493
|
+
validateCommandArgs(args, hasBackupId ? 3 : 2, {
|
|
494
|
+
values: [
|
|
495
|
+
"--workspace", "--scope", "--expect-config-hash", "--expect-project-count",
|
|
496
|
+
"--expect-after-count", "--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
497
|
+
"--resume", "--rollback",
|
|
498
|
+
],
|
|
499
|
+
switches: [
|
|
500
|
+
"--json", "--dry-run", "--approve-restore", "--confirm-offline",
|
|
501
|
+
"--confirm-unreadable-current",
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
const resume = option(args, "--resume", null);
|
|
505
|
+
const rollback = option(args, "--rollback", null);
|
|
506
|
+
if ([hasBackupId, resume !== null, rollback !== null].filter(Boolean).length !== 1) {
|
|
507
|
+
throw new Error("backup restore requires exactly one backup ID, --resume ID, or --rollback ID");
|
|
508
|
+
}
|
|
509
|
+
let backupId = hasBackupId ? args[2] : null;
|
|
510
|
+
let maintenanceTransaction = null;
|
|
511
|
+
if (resume !== null || rollback !== null) {
|
|
512
|
+
const maintenance = await readWorkspaceMaintenance(workspaceRoot(args));
|
|
513
|
+
maintenanceTransaction = resume ?? rollback;
|
|
514
|
+
if (!maintenance.active || maintenance.transactionId !== maintenanceTransaction) {
|
|
515
|
+
throw new Error("maintenance transaction not found");
|
|
516
|
+
}
|
|
517
|
+
backupId = resume !== null ? maintenance.backupId : maintenance.safetyBackupId;
|
|
518
|
+
}
|
|
519
|
+
const result = await restoreWorkspaceBackup(workspaceRoot(args), backupId, {
|
|
520
|
+
...(await mutationOptions(args)),
|
|
521
|
+
scope: resume !== null || rollback !== null ? "state" : option(args, "--scope", "projects"),
|
|
522
|
+
approveRestore: args.includes("--approve-restore"),
|
|
523
|
+
confirmOffline: args.includes("--confirm-offline"),
|
|
524
|
+
confirmUnreadableCurrent: args.includes("--confirm-unreadable-current"),
|
|
525
|
+
maintenanceTransaction,
|
|
526
|
+
});
|
|
527
|
+
print(result, args, (value) => value.dryRun
|
|
528
|
+
? `Restore preview ${value.beforeCount} -> ${value.afterCount}.`
|
|
529
|
+
: `Restored backup ${value.backupId} (${value.scope}).`);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
throw new Error("backup command requires list, create, verify, prune, or restore");
|
|
533
|
+
}
|
|
534
|
+
|
|
375
535
|
async function taskRecord(args) {
|
|
376
536
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
377
537
|
const dispatch = await recordTask(workspaceRoot(args), await readJsonStdin());
|
|
@@ -520,8 +680,10 @@ Usage:
|
|
|
520
680
|
taskchef workspace migrate [--json] [--workspace <path>]
|
|
521
681
|
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
522
682
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
523
|
-
taskchef project list [--json] [--workspace <path>]
|
|
524
|
-
taskchef project
|
|
683
|
+
taskchef project list [--allow-missing-paths] [--json] [--workspace <path>]
|
|
684
|
+
taskchef project update <name> [fields] [--dry-run] [--expect-config-hash <sha256>] [--json]
|
|
685
|
+
taskchef project remove <name> --dry-run [--json] [--workspace <path>]
|
|
686
|
+
taskchef backup list|create|verify|restore|prune [options]
|
|
525
687
|
taskchef dispatch prepare [--json] [--workspace <path>]
|
|
526
688
|
taskchef task record [--json] [--workspace <path>]
|
|
527
689
|
taskchef task brief [<task-id-or-8-character-prefix> | --project <name-or-path>] [--all] [--json] [--workspace <path>]
|
|
@@ -536,8 +698,8 @@ Project import reads a JSON
|
|
|
536
698
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
537
699
|
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
538
700
|
~/.agents/taskchef.
|
|
539
|
-
The foreground dashboard binds to 127.0.0.1 and
|
|
540
|
-
|
|
701
|
+
The foreground dashboard binds to 127.0.0.1 and creates one verified startup
|
|
702
|
+
snapshot before reading the canonical task log. The dispatcher MCP may reuse a
|
|
541
703
|
compatible foreground server on port 3210; neither mode terminates a listener
|
|
542
704
|
that already occupies its requested port.
|
|
543
705
|
`);
|
|
@@ -556,7 +718,9 @@ export async function runCli(args) {
|
|
|
556
718
|
if (args[0] === "project" && args[1] === "add") return projectAdd(args);
|
|
557
719
|
if (args[0] === "project" && args[1] === "import") return projectImport(args);
|
|
558
720
|
if (args[0] === "project" && args[1] === "list") return projectList(args);
|
|
721
|
+
if (args[0] === "project" && args[1] === "update") return projectUpdate(args);
|
|
559
722
|
if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
|
|
723
|
+
if (args[0] === "backup") return backupCommand(args);
|
|
560
724
|
if (args[0] === "dispatch" && args[1] === "prepare") return dispatchPrepare(args);
|
|
561
725
|
if (args[0] === "task" && args[1] === "record") return taskRecord(args);
|
|
562
726
|
if (args[0] === "task" && args[1] === "brief") return taskBrief(args);
|