taskchef 8.7.1 → 9.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.
Files changed (149) hide show
  1. package/.codex-plugin/plugin.json +7 -10
  2. package/.mcp.json +3 -2
  3. package/README.md +31 -158
  4. package/docs/mcp-app.md +3 -3
  5. package/docs/spec.md +38 -746
  6. package/docs/taskchef-next-storage.md +2 -2
  7. package/index.js +2 -99
  8. package/package.json +12 -49
  9. package/skills/delegate/SKILL.md +44 -0
  10. package/skills/delegate/agents/openai.yaml +4 -0
  11. package/src/codex-app.js +3 -152
  12. package/src/codex-session-scanner.js +2 -2
  13. package/src/file-lock.js +25 -0
  14. package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
  15. package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
  16. package/src/mcp-app/react/main.test.tsx +3 -3
  17. package/src/mcp-app/react/main.tsx +7 -7
  18. package/src/mcp-app.js +2 -2
  19. package/src/mcp.js +4 -542
  20. package/src/next-cache.js +2 -2
  21. package/src/next-github.js +3 -3
  22. package/src/next-notifications.js +2 -2
  23. package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
  24. package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
  25. package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
  26. package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
  27. package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
  28. package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
  29. package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
  30. package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
  31. package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
  32. package/src/{dashboard/react → ui}/presentation.ts +1 -1
  33. package/src/version.js +0 -1
  34. package/BACKLOG.md +0 -48
  35. package/assets/e2e-benchmark-example.json +0 -50
  36. package/assets/taskchef-dispatcher-instructions.md +0 -44
  37. package/bin/taskchef.js +0 -20
  38. package/docs/advanced-guide.md +0 -497
  39. package/docs/dashboard-lifecycle.md +0 -149
  40. package/docs/dashboard-ui.md +0 -40
  41. package/docs/firstmate-taskchef-comparison.md +0 -283
  42. package/docs/images/ccusage-token-consumption.png +0 -0
  43. package/docs/images/dashboard-identity.jpg +0 -0
  44. package/docs/images/dashboard-token-pending.jpg +0 -0
  45. package/docs/images/interrupted-turn-recovery.jpg +0 -0
  46. package/docs/images/notification-event-snapshots.jpg +0 -0
  47. package/docs/images/readme-board.png +0 -0
  48. package/docs/images/readme-completed.png +0 -0
  49. package/docs/images/readme-list.png +0 -0
  50. package/docs/images/readme-needs-input.png +0 -0
  51. package/docs/images/readme-roles.png +0 -0
  52. package/docs/images/reported-work-duration-desktop.jpg +0 -0
  53. package/docs/images/reported-work-duration-mobile.jpg +0 -0
  54. package/docs/images/result-history-dashboard.jpg +0 -0
  55. package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
  56. package/docs/images/taskchef-unified-status-popup.jpg +0 -0
  57. package/docs/workflows.md +0 -476
  58. package/mcp/dashboard-session.js +0 -51
  59. package/scripts/benchmark-dispatch-prepare.js +0 -90
  60. package/scripts/e2e-benchmark.js +0 -206
  61. package/scripts/roles/resolve_roles.py +0 -293
  62. package/skills/taskchef-bootstrap/SKILL.md +0 -96
  63. package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
  64. package/skills/taskchef-bootstrap/references/project-index.md +0 -154
  65. package/skills/taskchef-copilot/SKILL.md +0 -75
  66. package/skills/taskchef-copilot/agents/openai.yaml +0 -4
  67. package/skills/taskchef-dashboard/SKILL.md +0 -24
  68. package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
  69. package/skills/taskchef-delegate/SKILL.md +0 -102
  70. package/skills/taskchef-delegate/agents/openai.yaml +0 -4
  71. package/skills/taskchef-delegate/references/model-roles.md +0 -29
  72. package/skills/taskchef-executor/SKILL.md +0 -154
  73. package/skills/taskchef-executor/agents/openai.yaml +0 -4
  74. package/skills/taskchef-executor/references/compatibility.md +0 -22
  75. package/skills/taskchef-executor/references/ending-actions.md +0 -26
  76. package/src/backups.js +0 -359
  77. package/src/cli.js +0 -733
  78. package/src/config-audit.js +0 -99
  79. package/src/config-mutations.js +0 -106
  80. package/src/dashboard/actions.js +0 -136
  81. package/src/dashboard/app.js +0 -1172
  82. package/src/dashboard/dist/app.js +0 -269
  83. package/src/dashboard/dist/app.js.map +0 -1
  84. package/src/dashboard/dist/index.html +0 -16
  85. package/src/dashboard/dist/styles.css +0 -1
  86. package/src/dashboard/index.html +0 -148
  87. package/src/dashboard/react/App.test.tsx +0 -346
  88. package/src/dashboard/react/App.tsx +0 -515
  89. package/src/dashboard/react/api.test.ts +0 -40
  90. package/src/dashboard/react/api.ts +0 -103
  91. package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
  92. package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
  93. package/src/dashboard/react/components/ModelSettings.tsx +0 -106
  94. package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
  95. package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
  96. package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
  97. package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
  98. package/src/dashboard/react/components/TaskDetail.tsx +0 -271
  99. package/src/dashboard/react/components/UsagePanel.tsx +0 -96
  100. package/src/dashboard/react/index.html +0 -15
  101. package/src/dashboard/react/main.tsx +0 -12
  102. package/src/dashboard/react/overlay-state.test.ts +0 -110
  103. package/src/dashboard/react/overlay-state.ts +0 -60
  104. package/src/dashboard/react/presentation.test.tsx +0 -571
  105. package/src/dashboard/styles.css +0 -261
  106. package/src/dashboard-control.js +0 -34
  107. package/src/dashboard-manager.js +0 -339
  108. package/src/dashboard-session-process.js +0 -88
  109. package/src/dashboard-session.js +0 -86
  110. package/src/dashboard.js +0 -1303
  111. package/src/delegation.js +0 -277
  112. package/src/github.js +0 -107
  113. package/src/model-roles.js +0 -70
  114. package/src/recorded-usage-tracker.js +0 -92
  115. package/src/usage-tracker.js +0 -650
  116. package/src/usage.js +0 -367
  117. package/src/workspace-path.js +0 -48
  118. package/src/workspace.js +0 -3629
  119. /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
  120. /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
  121. /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
  122. /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
  123. /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
  124. /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
  125. /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
  126. /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
  127. /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
  128. /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
  129. /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
  130. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
  131. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
  132. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
  133. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
  134. /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
  135. /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
  136. /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
  137. /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
  138. /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
  139. /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
  140. /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
  141. /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
  142. /package/src/{dashboard → ui}/github-links.js +0 -0
  143. /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
  144. /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
  145. /package/src/{dashboard → ui}/state.js +0 -0
  146. /package/src/{dashboard/react → ui}/styles.css +0 -0
  147. /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
  148. /package/src/{dashboard → ui}/time.js +0 -0
  149. /package/src/{dashboard/react → ui}/types.ts +0 -0
package/src/backups.js DELETED
@@ -1,359 +0,0 @@
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
- }