taskchef 7.29.0 → 8.1.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 (36) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +15 -0
  3. package/bin/taskchef.js +11 -1
  4. package/docs/dashboard-lifecycle.md +10 -0
  5. package/docs/spec.md +28 -0
  6. package/docs/workflows.md +5 -1
  7. package/index.js +14 -0
  8. package/package.json +1 -1
  9. package/skills/taskchef-bootstrap/SKILL.md +7 -3
  10. package/skills/taskchef-bootstrap/references/project-index.md +21 -3
  11. package/skills/taskchef-copilot/SKILL.md +2 -0
  12. package/skills/taskchef-dashboard/SKILL.md +4 -0
  13. package/skills/taskchef-delegate/SKILL.md +3 -2
  14. package/src/backups.js +359 -0
  15. package/src/cli.js +180 -16
  16. package/src/config-audit.js +99 -0
  17. package/src/config-mutations.js +98 -0
  18. package/src/dashboard/dist/app.js +58 -38
  19. package/src/dashboard/dist/app.js.map +1 -1
  20. package/src/dashboard/dist/styles.css +1 -1
  21. package/src/dashboard/github-links.js +9 -1
  22. package/src/dashboard/react/App.tsx +16 -0
  23. package/src/dashboard/react/components/RelativeTime.tsx +23 -5
  24. package/src/dashboard/react/components/TaskCard.tsx +4 -21
  25. package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
  26. package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
  27. package/src/dashboard/react/presentation.test.tsx +2 -2
  28. package/src/dashboard/react/presentation.ts +50 -20
  29. package/src/dashboard/react/styles.css +16 -11
  30. package/src/dashboard/react/types.ts +10 -0
  31. package/src/dashboard-manager.js +16 -0
  32. package/src/dashboard.js +50 -23
  33. package/src/state-store.js +81 -0
  34. package/src/workspace.js +639 -37
  35. package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
  36. package/src/dashboard/react/components/TaskUsageSummary.tsx +0 -24
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.29.0",
3
+ "version": "8.1.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -85,6 +85,21 @@ requested, opens the canonical path with the validated Codex Desktop CLI's
85
85
  exact match before indexing it. An open request without a verified native
86
86
  project is reported as partial setup, not delegation-ready.
87
87
 
88
+ Update one indexed project with taskchef project update; repeated
89
+ --github-repo values replace that entry's complete repository list while
90
+ leaving every other project untouched. project list --json returns stable
91
+ configuration and project-set hashes for postcondition checks.
92
+
93
+ Whole-index replacement, project removal, and restore are preview-bound. Run
94
+ them with --dry-run, inspect the exact diff, then apply with the returned
95
+ hashes, counts, plan hash, and exact removed-name confirmation after the user
96
+ approves every removed project. TaskChef creates private snapshots under
97
+ backups/ before mutations and actual dashboard startup. Use taskchef backup
98
+ list, create, verify, restore, and prune; restore never runs automatically and
99
+ creates a pre-restore safety snapshot. If the live configuration is missing or
100
+ malformed, preview an explicit config or state restore, then bind the apply to
101
+ the preview's raw-state hash and plan with --confirm-unreadable-current.
102
+
88
103
  Reindexing catches TaskChef up with newly saved Codex projects by comparing
89
104
  exact canonical paths and indexing missing requested projects. It preserves
90
105
  existing curated entries and does not silently remove projects or overwrite
package/bin/taskchef.js CHANGED
@@ -5,6 +5,16 @@ import { runCli } from "../src/cli.js";
5
5
  try {
6
6
  process.exitCode = await runCli(process.argv.slice(2));
7
7
  } catch (error) {
8
- process.stderr.write(`taskchef: ${error.message}\n`);
8
+ if (process.argv.includes("--json")) {
9
+ process.stdout.write(`${JSON.stringify({
10
+ error: {
11
+ code: error.code ?? "ERROR",
12
+ message: error.message,
13
+ ...(error.details ? { details: error.details } : {}),
14
+ },
15
+ })}\n`);
16
+ } else {
17
+ process.stderr.write(`taskchef: ${error.message}\n`);
18
+ }
9
19
  process.exitCode = 1;
10
20
  }
@@ -67,6 +67,16 @@ elapsed time, not model compute time.
67
67
 
68
68
  ## Start and reuse
69
69
 
70
+ An actual new dashboard server creates or deduplicates a verified private state
71
+ snapshot before monitors or derived-state writers start. Reusing an existing
72
+ listener does not create another startup snapshot. Snapshot failure aborts the
73
+ new server start and does not weaken MCP reporting isolation.
74
+
75
+ The dashboard watches both task history and the project index. It warns when
76
+ historical task snapshots reference paths absent from the current index, and
77
+ offers recovery-oriented read-only commands. It never mutates or restores
78
+ configuration.
79
+
70
80
  Unless `dashboard.autostart` is explicitly `false`, MCP activation runs the
71
81
  same serialized ensure operation exposed by `ensure_dashboard` before the MCP
72
82
  transport connects. An explicit ensure remains available when autostart is
package/docs/spec.md CHANGED
@@ -14,6 +14,8 @@ is dated research, not contract.
14
14
  | **Dispatcher workspace** | The per-user local data project selected by `--workspace`, then `TASKCHEF_WORKSPACE`, then `~/.agents/taskchef`. |
15
15
  | **Dispatcher** | The Codex task that accepts a request, routes it, records it, creates executors, and returns without supervising them. |
16
16
  | **Configured project** | One routing target in `taskchef.json`, identified by canonical local path and described by `name`, optional `description`, `isGitRepository`, and `githubRepos`. |
17
+ | **Mutation preview** | A read-only, hash-bound exact before/after project-index diff required before destructive replacement, removal, or restore. |
18
+ | **State backup** | A private, verified snapshot under the dispatcher workspace's `backups/` directory. It never restores automatically. |
17
19
  | **Routing** | Selecting exactly one configured project and exactly one matching native Codex project for an outcome. |
18
20
  | **Delegated task** | One independently useful outcome represented by one TaskChef task UUID and snapshot. |
19
21
  | **Executor** | The native Codex task created to own and perform one delegated task. |
@@ -124,6 +126,32 @@ from the final derived semantic result. Interrupted outcomes MUST be excluded.
124
126
  These projections MUST NOT be persisted in schema 10 and remain compatibility
125
127
  aliases for existing callers.
126
128
 
129
+ ## Project-index mutation and recovery
130
+
131
+ TaskChef MUST provide a targeted update that preserves every unrelated project
132
+ and replaces an explicitly supplied repository list in full. Project list JSON
133
+ MUST expose stable semantic hashes. Whole-index replacement, project removal,
134
+ and restore MUST first return an exact diff and MUST reject stale hashes,
135
+ incomplete removed-name confirmation, mismatched counts, and unconfirmed large
136
+ count collapse. Agents MUST obtain explicit user permission for every removed
137
+ project entry and verify the complete post-state.
138
+
139
+ Every actual configuration mutation and actual dashboard-server start MUST
140
+ create or deduplicate a mode-private, manifest-and-hash-verified state snapshot
141
+ before writing. Mutations MUST append bounded attribution-free prepared and
142
+ terminal audit records. Backup list, create, and verify are read-only except for
143
+ explicit creation. Restore MUST default to projects only, show a diff, require
144
+ explicit approval, and create a pre-restore safety snapshot. TaskChef MUST NOT
145
+ restore automatically. State restore authorization MUST bind the selected
146
+ backup and every selected live/source payload hash. Missing or malformed live
147
+ configuration requires config or state scope plus exact raw-state and explicit
148
+ unreadable-current confirmation; a forensic safety snapshot remains eligible
149
+ for rollback when its own integrity verifies.
150
+
151
+ The dashboard MUST distinguish an unreadable index from missing historical
152
+ project paths, warn without rewriting state, and expose recovery guidance
153
+ without backup payload contents.
154
+
127
155
  ## Optional usage projection
128
156
 
129
157
  TaskChef MUST pin one tested `ccusage` version as an optional dependency. It
package/docs/workflows.md CHANGED
@@ -19,6 +19,9 @@ start, upgrade, shutdown, and security rationale.
19
19
  | `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias, MCP cleanup, and annotations. |
20
20
  | `src/delegation.js` | UUID marker, concise executor-skill invocation shape, and creation-failure handling. |
21
21
  | `src/workspace.js` | Current schemas, validation, locking, atomic JSONL writes, linking, and result freshness. |
22
+ | `src/config-mutations.js` | Canonical hashes and exact field-level project diffs. |
23
+ | `src/backups.js` | Private snapshot creation, verification, retention, and restore inputs. |
24
+ | `src/config-audit.js` | Durable prepared/terminal configuration-mutation journal. |
22
25
  | `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
23
26
  | `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
24
27
  | `src/dashboard-manager.js` | Concurrent session ensure, authenticated reuse and upgrade handoff, and safe conflicts. |
@@ -428,7 +431,8 @@ after a later filesystem failure, the reported backup is the recovery source.
428
431
  Configuration and task mutations use the same `proper-lockfile` lock. ID and
429
432
  identity uniqueness checks, link eligibility, and result freshness occur
430
433
  inside the critical section. Writes use temporary files/hard links and atomic
431
- rename so readers see complete snapshots.
434
+ rename so readers see complete snapshots. Configuration replacement syncs the
435
+ new file and workspace directory before its audit transaction is committed.
432
436
 
433
437
  The dispatcher controls routing and immutable intent. The executor controls its
434
438
  cooperatively asserted identity and semantic result. Neither identity nor
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  buildCopilotBrief,
3
3
  addProject,
4
+ createWorkspaceBackup,
4
5
  buildTaskSummary,
5
6
  canonicalDirectory,
6
7
  canonicalGitRoot,
@@ -12,6 +13,10 @@ export {
12
13
  initializeWorkspace,
13
14
  linkTask,
14
15
  listProjects,
16
+ listWorkspaceBackups,
17
+ pruneWorkspaceBackups,
18
+ readProjectIndex,
19
+ readWorkspaceMaintenance,
15
20
  manuallyTransitionTask,
16
21
  migrateTaskLog,
17
22
  prepareDispatch,
@@ -23,9 +28,18 @@ export {
23
28
  reportTaskResult,
24
29
  requireSafeId,
25
30
  removeProject,
31
+ restoreWorkspaceBackup,
26
32
  validateConfig,
33
+ updateProject,
34
+ verifyWorkspaceBackup,
27
35
  } from "./src/workspace.js";
28
36
 
37
+ export {
38
+ configHash,
39
+ projectDiff,
40
+ projectSetHash,
41
+ } from "./src/config-mutations.js";
42
+
29
43
  export {
30
44
  canonicalGithubRepository,
31
45
  matchProjectForGithubUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.29.0",
3
+ "version": "8.1.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -15,8 +15,9 @@ all deterministic workspace operations.
15
15
  ## Boundaries
16
16
 
17
17
  - Keep implementation, tests, and reports in the TaskChef source repository.
18
- - Create and manage only `AGENTS.md`, `taskchef.json`, and `tasks.jsonl` in a
19
- dispatcher workspace. Preserve unrelated user-owned paths.
18
+ - Create and manage only AGENTS.md, taskchef.json, tasks.jsonl,
19
+ config-audit.jsonl, and TaskChef's private backups and maintenance artifacts
20
+ in a dispatcher workspace. Preserve unrelated user-owned paths.
20
21
  - Do not dispatch tasks or report on executor threads during bootstrap unless
21
22
  the user separately requests those actions.
22
23
  - Never create hooks, schedules, polling, daemons, login items, or system
@@ -55,7 +56,10 @@ all deterministic workspace operations.
55
56
  For adding, importing, removing, listing, or reindexing projects, read
56
57
  [project index](references/project-index.md). Preserve exact canonical local
57
58
  Codex project identity and existing curated entries; opening a folder is not
58
- proof of registration. Completion requires verifying the intended index entry.
59
+ proof of registration. Completion requires verifying the intended index entry
60
+ and the complete project set. Any operation that removes a project entry
61
+ requires the user's explicit permission for the exact removed names; removing a
62
+ repository URL from an entry does not authorize removing the entry.
59
63
 
60
64
  ## Optional model roles
61
65
 
@@ -85,6 +85,18 @@ requires an explicit user request and the project-management workflows below.
85
85
 
86
86
  ## Manage the project index
87
87
 
88
+ Before every mutation, run project list --json and retain the complete
89
+ normalized project set, count, and configHash. Use project update for changes
90
+ to one existing entry, including replacing its complete githubRepos list.
91
+ Never use whole-index replacement to edit one entry.
92
+
93
+ Run destructive replacement or removal first with --dry-run. Present the
94
+ complete diff and exact removed project names to the user and obtain explicit
95
+ permission for those removals. Apply only with the preview's config hash,
96
+ before/after counts, plan hash, exact removed-name JSON file, and count-collapse
97
+ confirmation when requested. A request to remove a repository URL is not
98
+ permission to remove a project entry. Do not use a generic force or yes flag.
99
+
88
100
  1. Bulk import with `project import <file|-> --json`. Input is a JSON array of
89
101
  objects containing `path` plus optional `name`, `description`, and
90
102
  `githubRepos`, which is always a JSON array of GitHub repository URLs. Before
@@ -94,9 +106,15 @@ requires an explicit user request and the project-management workflows below.
94
106
  canonical path, preserves an existing name or description when omitted, and
95
107
  unions existing and imported repository lists without duplicates. Use
96
108
  `--replace` only when the user explicitly requests replacement. Run
97
- `project list --json` afterward and verify every imported entry.
98
- 2. Inspect indexed projects with `project list --json`. Remove by name with
99
- `project remove`. Existing task entries keep their project snapshots.
109
+ `project list --json` afterward and compare the complete project set with the
110
+ expected post-state. Verify every imported entry and prove all unrelated
111
+ entries are unchanged; matching counts alone are insufficient.
112
+ 2. Inspect indexed projects with project list --json. Update one entry with
113
+ project update; explicit repository arguments are the complete replacement
114
+ list. Remove by name only through the preview-bound workflow. Existing task
115
+ entries keep their project snapshots.
116
+ 3. On any postcondition mismatch, stop, report the failure, and offer backup
117
+ list plus a restore dry-run. Never restore automatically.
100
118
 
101
119
  Example managed-workspace import entry:
102
120
 
@@ -58,6 +58,8 @@ cached brief.
58
58
  preserve its durable identity.
59
59
  - Never automatically retry a failure, interrupt a working task, or redelegate
60
60
  an existing executor as a new task.
61
+ - Explain project-index warnings and suggest read-only backup list, verify, or
62
+ restore-preview commands. Never restore state without explicit user approval.
61
63
  - A missing executor link calls for passive waiting or identity inspection,
62
64
  not a retry, continuation, or reason to create another executor.
63
65
  - Keep same-assignment follow-up in the existing executor task. Independent new
@@ -16,5 +16,9 @@ Maintain only the canonical TaskChef dashboard.
16
16
  navigate to it. Browser absence, refusal, or blocked localhost navigation is
17
17
  non-fatal: still return the clickable URL and the ensure result.
18
18
 
19
+ “Recover the dashboard” means restore server availability. Data recovery uses
20
+ the explicit taskchef backup restore --dry-run workflow, shows a diff, requires
21
+ user approval, and never runs automatically.
22
+
19
23
  Do not dispatch project work, read task briefs, inspect executor outcomes, poll,
20
24
  start a separate foreground CLI server, or modify TaskChef workspace files.
@@ -30,8 +30,9 @@ stop and report that the TaskChef plugin must be reloaded or installed.
30
30
 
31
31
  ## Boundaries
32
32
 
33
- - Treat only `AGENTS.md`, `taskchef.json`, and `tasks.jsonl` as TaskChef-managed
34
- dispatcher files. Preserve unrelated user-owned paths.
33
+ - Treat only AGENTS.md, taskchef.json, tasks.jsonl, config-audit.jsonl, and
34
+ TaskChef's private backups and maintenance artifacts as managed dispatcher
35
+ state. Preserve unrelated user-owned paths.
35
36
  - Use real Codex tasks, never collaboration or subagent tools.
36
37
  - Never use hooks, schedules, daemons, background monitors, recent-task
37
38
  searches, transcripts, hidden reasoning, or polling for identity.
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
+ }