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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +15 -0
- package/bin/taskchef.js +11 -1
- package/docs/dashboard-lifecycle.md +10 -0
- package/docs/spec.md +28 -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 +58 -38
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +9 -1
- package/src/dashboard/react/App.tsx +16 -0
- package/src/dashboard/react/components/RelativeTime.tsx +23 -5
- package/src/dashboard/react/components/TaskCard.tsx +4 -21
- package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
- package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
- package/src/dashboard/react/presentation.test.tsx +2 -2
- package/src/dashboard/react/presentation.ts +50 -20
- package/src/dashboard/react/styles.css +16 -11
- package/src/dashboard/react/types.ts +10 -0
- package/src/dashboard-manager.js +16 -0
- package/src/dashboard.js +50 -23
- package/src/state-store.js +81 -0
- package/src/workspace.js +639 -37
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
- package/src/dashboard/react/components/TaskUsageSummary.tsx +0 -24
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.
|
|
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
|
@@ -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
|
|
19
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
34
|
-
|
|
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
|
+
}
|