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/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveWorkspacePath } from "./workspace-path.js";
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
addProject,
|
|
10
|
+
createWorkspaceBackup,
|
|
10
11
|
buildCopilotBrief,
|
|
11
12
|
buildTaskSummary,
|
|
12
13
|
doctorWorkspace,
|
|
@@ -14,12 +15,19 @@ import {
|
|
|
14
15
|
importProjects,
|
|
15
16
|
initializeWorkspace,
|
|
16
17
|
listProjects,
|
|
18
|
+
listWorkspaceBackups,
|
|
19
|
+
pruneWorkspaceBackups,
|
|
20
|
+
readProjectIndex,
|
|
21
|
+
readWorkspaceMaintenance,
|
|
17
22
|
listTasks,
|
|
18
23
|
migrateTaskLog,
|
|
19
24
|
prepareDispatch,
|
|
20
25
|
recordTask,
|
|
21
26
|
removeProject,
|
|
27
|
+
restoreWorkspaceBackup,
|
|
22
28
|
requireSafeId,
|
|
29
|
+
updateProject,
|
|
30
|
+
verifyWorkspaceBackup,
|
|
23
31
|
} from "./workspace.js";
|
|
24
32
|
|
|
25
33
|
const BLANK_TABLE_CELL = Symbol("blank table cell");
|
|
@@ -111,6 +119,30 @@ function print(value, args, human) {
|
|
|
111
119
|
process.stdout.write(`${human ? human(value) : JSON.stringify(value, null, 2)}\n`);
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
async function mutationOptions(args) {
|
|
123
|
+
const confirmedPath = option(args, "--confirm-removed", null);
|
|
124
|
+
let confirmRemoved;
|
|
125
|
+
if (confirmedPath !== null) {
|
|
126
|
+
confirmRemoved = JSON.parse(await readFile(path.resolve(confirmedPath), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
const numberOption = (name) => {
|
|
129
|
+
const value = option(args, name, null);
|
|
130
|
+
if (value === null) return undefined;
|
|
131
|
+
const parsed = Number(value);
|
|
132
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`);
|
|
133
|
+
return parsed;
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
dryRun: args.includes("--dry-run"),
|
|
137
|
+
expectedConfigHash: option(args, "--expect-config-hash", undefined),
|
|
138
|
+
expectedProjectCount: numberOption("--expect-project-count"),
|
|
139
|
+
expectedAfterCount: numberOption("--expect-after-count"),
|
|
140
|
+
confirmPlan: option(args, "--confirm-plan", undefined),
|
|
141
|
+
confirmRemoved,
|
|
142
|
+
confirmCountCollapse: option(args, "--confirm-count-collapse", undefined),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
function table(headers, rows) {
|
|
115
147
|
const display = (value) => {
|
|
116
148
|
if (value === BLANK_TABLE_CELL) return "";
|
|
@@ -300,7 +332,7 @@ async function projectAdd(args) {
|
|
|
300
332
|
if (!args[2] || args[2].startsWith("--")) throw new Error("project add requires a path");
|
|
301
333
|
validateCommandArgs(args, 3, {
|
|
302
334
|
values: ["--workspace", "--name", "--description", "--github-repo"],
|
|
303
|
-
switches: ["--json", "--no-github"],
|
|
335
|
+
switches: ["--json", "--no-github", "--dry-run"],
|
|
304
336
|
repeatable: ["--github-repo"],
|
|
305
337
|
});
|
|
306
338
|
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
@@ -313,8 +345,13 @@ async function projectAdd(args) {
|
|
|
313
345
|
if (description !== null) input.description = description;
|
|
314
346
|
if (args.includes("--no-github")) input.githubRepos = [];
|
|
315
347
|
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
316
|
-
const
|
|
317
|
-
|
|
348
|
+
const result = await addProject(workspaceRoot(args), input, {
|
|
349
|
+
dryRun: args.includes("--dry-run"),
|
|
350
|
+
});
|
|
351
|
+
print(result, args, (value) => {
|
|
352
|
+
const project = value.project ?? value;
|
|
353
|
+
return `${args.includes("--dry-run") ? "Would add" : "Added"} ${project.name}: ${project.path}`;
|
|
354
|
+
});
|
|
318
355
|
return 0;
|
|
319
356
|
}
|
|
320
357
|
|
|
@@ -322,8 +359,11 @@ async function projectImport(args) {
|
|
|
322
359
|
const hasSource = Boolean(args[2] && !args[2].startsWith("--"));
|
|
323
360
|
const source = hasSource ? args[2] : "-";
|
|
324
361
|
validateCommandArgs(args, hasSource ? 3 : 2, {
|
|
325
|
-
values: [
|
|
326
|
-
|
|
362
|
+
values: [
|
|
363
|
+
"--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
|
|
364
|
+
"--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
365
|
+
],
|
|
366
|
+
switches: ["--json", "--replace", "--dry-run"],
|
|
327
367
|
});
|
|
328
368
|
const content = source === "-"
|
|
329
369
|
? await readStdin()
|
|
@@ -331,6 +371,7 @@ async function projectImport(args) {
|
|
|
331
371
|
if (content.trim().length === 0) throw new Error("project import input is empty");
|
|
332
372
|
const result = await importProjects(workspaceRoot(args), JSON.parse(content), {
|
|
333
373
|
replace: args.includes("--replace"),
|
|
374
|
+
...(await mutationOptions(args)),
|
|
334
375
|
});
|
|
335
376
|
print(result, args, (value) =>
|
|
336
377
|
`Imported ${value.importedCount} project(s); ${value.projectCount} configured (${value.mode}).`);
|
|
@@ -338,9 +379,14 @@ async function projectImport(args) {
|
|
|
338
379
|
}
|
|
339
380
|
|
|
340
381
|
async function projectList(args) {
|
|
341
|
-
validateCommandArgs(args, 2, {
|
|
342
|
-
|
|
343
|
-
|
|
382
|
+
validateCommandArgs(args, 2, {
|
|
383
|
+
values: ["--workspace"],
|
|
384
|
+
switches: ["--json", "--allow-missing-paths"],
|
|
385
|
+
});
|
|
386
|
+
const result = await readProjectIndex(workspaceRoot(args), {
|
|
387
|
+
checkPaths: !args.includes("--allow-missing-paths"),
|
|
388
|
+
});
|
|
389
|
+
print(result, args, (value) => table(
|
|
344
390
|
["NAME", "KIND", "GITHUB REPOSITORY", "PATH"],
|
|
345
391
|
projectRows(value.projects),
|
|
346
392
|
));
|
|
@@ -364,14 +410,128 @@ async function dispatchPrepare(args) {
|
|
|
364
410
|
async function projectRemove(args) {
|
|
365
411
|
if (!args[2] || args[2].startsWith("--")) throw new Error("project remove requires a name");
|
|
366
412
|
validateCommandArgs(args, 3, {
|
|
367
|
-
values: [
|
|
368
|
-
|
|
413
|
+
values: [
|
|
414
|
+
"--workspace", "--expect-config-hash", "--expect-project-count", "--expect-after-count",
|
|
415
|
+
"--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
416
|
+
],
|
|
417
|
+
switches: ["--json", "--dry-run"],
|
|
369
418
|
});
|
|
370
|
-
const result = await removeProject(workspaceRoot(args), args[2]);
|
|
371
|
-
print(result, args, (value) =>
|
|
419
|
+
const result = await removeProject(workspaceRoot(args), args[2], await mutationOptions(args));
|
|
420
|
+
print(result, args, (value) =>
|
|
421
|
+
`${args.includes("--dry-run") ? "Would remove" : "Removed"} ${value.project.name}: ${value.project.path}`);
|
|
372
422
|
return 0;
|
|
373
423
|
}
|
|
374
424
|
|
|
425
|
+
async function projectUpdate(args) {
|
|
426
|
+
const hasName = Boolean(args[2] && !args[2].startsWith("--"));
|
|
427
|
+
validateCommandArgs(args, hasName ? 3 : 2, {
|
|
428
|
+
values: [
|
|
429
|
+
"--workspace", "--path", "--name", "--description", "--github-repo",
|
|
430
|
+
"--expect-config-hash",
|
|
431
|
+
],
|
|
432
|
+
switches: ["--json", "--clear-description", "--no-github", "--refresh-git", "--dry-run"],
|
|
433
|
+
repeatable: ["--github-repo"],
|
|
434
|
+
});
|
|
435
|
+
const selectedPath = option(args, "--path", null);
|
|
436
|
+
if (hasName === Boolean(selectedPath)) throw new Error("project update requires exactly one name or --path selector");
|
|
437
|
+
if (args.includes("--description") && args.includes("--clear-description")) {
|
|
438
|
+
throw new Error("--description and --clear-description cannot be combined");
|
|
439
|
+
}
|
|
440
|
+
if (args.includes("--no-github") && args.includes("--github-repo")) {
|
|
441
|
+
throw new Error("--no-github and --github-repo cannot be combined");
|
|
442
|
+
}
|
|
443
|
+
const input = {};
|
|
444
|
+
if (args.includes("--name")) input.name = option(args, "--name", null);
|
|
445
|
+
if (args.includes("--description")) input.description = option(args, "--description", null);
|
|
446
|
+
if (args.includes("--clear-description")) input.clearDescription = true;
|
|
447
|
+
if (args.includes("--no-github")) input.githubRepos = [];
|
|
448
|
+
else if (args.includes("--github-repo")) input.githubRepos = options(args, "--github-repo");
|
|
449
|
+
if (args.includes("--refresh-git")) input.refreshGit = true;
|
|
450
|
+
const result = await updateProject(
|
|
451
|
+
workspaceRoot(args),
|
|
452
|
+
selectedPath ? { path: selectedPath } : { name: args[2] },
|
|
453
|
+
input,
|
|
454
|
+
await mutationOptions(args),
|
|
455
|
+
);
|
|
456
|
+
print(result, args, (value) => value.changed
|
|
457
|
+
? `Updated ${value.project.name}: ${value.project.path}`
|
|
458
|
+
: `Unchanged ${value.project.name}: ${value.project.path}`);
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function backupCommand(args) {
|
|
463
|
+
const action = args[1];
|
|
464
|
+
if (action === "list") {
|
|
465
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
466
|
+
const result = await listWorkspaceBackups(workspaceRoot(args));
|
|
467
|
+
print(result, args, (value) => value.backups.map((item) =>
|
|
468
|
+
`${item.id} ${item.usable ? "usable" : item.integrityValid ? "forensic" : "corrupt"}`)
|
|
469
|
+
.join("\n") || "No backups.");
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
if (action === "create") {
|
|
473
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
474
|
+
const result = await createWorkspaceBackup(workspaceRoot(args), { reason: "manual" });
|
|
475
|
+
print(result, args, (value) => `Created backup ${value.id}.`);
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
if (action === "verify" && args[2]) {
|
|
479
|
+
validateCommandArgs(args, 3, { values: ["--workspace"], switches: ["--json"] });
|
|
480
|
+
const result = await verifyWorkspaceBackup(workspaceRoot(args), args[2]);
|
|
481
|
+
print(result, args, (value) => `${value.id}: ${value.usable ? "usable" : value.error}`);
|
|
482
|
+
return result.usable ? 0 : 1;
|
|
483
|
+
}
|
|
484
|
+
if (action === "prune") {
|
|
485
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json", "--apply", "--dry-run"] });
|
|
486
|
+
if (args.includes("--apply") && args.includes("--dry-run")) throw new Error("--apply and --dry-run conflict");
|
|
487
|
+
const result = await pruneWorkspaceBackups(workspaceRoot(args), { apply: args.includes("--apply") });
|
|
488
|
+
print(result, args, (value) => `${value.apply ? "Pruned" : "Would prune"} ${value.removed.length} backup(s).`);
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
if (action === "restore") {
|
|
492
|
+
const hasBackupId = Boolean(args[2] && !args[2].startsWith("--"));
|
|
493
|
+
validateCommandArgs(args, hasBackupId ? 3 : 2, {
|
|
494
|
+
values: [
|
|
495
|
+
"--workspace", "--scope", "--expect-config-hash", "--expect-project-count",
|
|
496
|
+
"--expect-after-count", "--confirm-plan", "--confirm-removed", "--confirm-count-collapse",
|
|
497
|
+
"--resume", "--rollback",
|
|
498
|
+
],
|
|
499
|
+
switches: [
|
|
500
|
+
"--json", "--dry-run", "--approve-restore", "--confirm-offline",
|
|
501
|
+
"--confirm-unreadable-current",
|
|
502
|
+
],
|
|
503
|
+
});
|
|
504
|
+
const resume = option(args, "--resume", null);
|
|
505
|
+
const rollback = option(args, "--rollback", null);
|
|
506
|
+
if ([hasBackupId, resume !== null, rollback !== null].filter(Boolean).length !== 1) {
|
|
507
|
+
throw new Error("backup restore requires exactly one backup ID, --resume ID, or --rollback ID");
|
|
508
|
+
}
|
|
509
|
+
let backupId = hasBackupId ? args[2] : null;
|
|
510
|
+
let maintenanceTransaction = null;
|
|
511
|
+
if (resume !== null || rollback !== null) {
|
|
512
|
+
const maintenance = await readWorkspaceMaintenance(workspaceRoot(args));
|
|
513
|
+
maintenanceTransaction = resume ?? rollback;
|
|
514
|
+
if (!maintenance.active || maintenance.transactionId !== maintenanceTransaction) {
|
|
515
|
+
throw new Error("maintenance transaction not found");
|
|
516
|
+
}
|
|
517
|
+
backupId = resume !== null ? maintenance.backupId : maintenance.safetyBackupId;
|
|
518
|
+
}
|
|
519
|
+
const result = await restoreWorkspaceBackup(workspaceRoot(args), backupId, {
|
|
520
|
+
...(await mutationOptions(args)),
|
|
521
|
+
scope: resume !== null || rollback !== null ? "state" : option(args, "--scope", "projects"),
|
|
522
|
+
approveRestore: args.includes("--approve-restore"),
|
|
523
|
+
confirmOffline: args.includes("--confirm-offline"),
|
|
524
|
+
confirmUnreadableCurrent: args.includes("--confirm-unreadable-current"),
|
|
525
|
+
maintenanceTransaction,
|
|
526
|
+
});
|
|
527
|
+
print(result, args, (value) => value.dryRun
|
|
528
|
+
? `Restore preview ${value.beforeCount} -> ${value.afterCount}.`
|
|
529
|
+
: `Restored backup ${value.backupId} (${value.scope}).`);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
throw new Error("backup command requires list, create, verify, prune, or restore");
|
|
533
|
+
}
|
|
534
|
+
|
|
375
535
|
async function taskRecord(args) {
|
|
376
536
|
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
377
537
|
const dispatch = await recordTask(workspaceRoot(args), await readJsonStdin());
|
|
@@ -520,8 +680,10 @@ Usage:
|
|
|
520
680
|
taskchef workspace migrate [--json] [--workspace <path>]
|
|
521
681
|
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
522
682
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
523
|
-
taskchef project list [--json] [--workspace <path>]
|
|
524
|
-
taskchef project
|
|
683
|
+
taskchef project list [--allow-missing-paths] [--json] [--workspace <path>]
|
|
684
|
+
taskchef project update <name> [fields] [--dry-run] [--expect-config-hash <sha256>] [--json]
|
|
685
|
+
taskchef project remove <name> --dry-run [--json] [--workspace <path>]
|
|
686
|
+
taskchef backup list|create|verify|restore|prune [options]
|
|
525
687
|
taskchef dispatch prepare [--json] [--workspace <path>]
|
|
526
688
|
taskchef task record [--json] [--workspace <path>]
|
|
527
689
|
taskchef task brief [<task-id-or-8-character-prefix> | --project <name-or-path>] [--all] [--json] [--workspace <path>]
|
|
@@ -536,8 +698,8 @@ Project import reads a JSON
|
|
|
536
698
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
537
699
|
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
538
700
|
~/.agents/taskchef.
|
|
539
|
-
The foreground dashboard binds to 127.0.0.1 and
|
|
540
|
-
|
|
701
|
+
The foreground dashboard binds to 127.0.0.1 and creates one verified startup
|
|
702
|
+
snapshot before reading the canonical task log. The dispatcher MCP may reuse a
|
|
541
703
|
compatible foreground server on port 3210; neither mode terminates a listener
|
|
542
704
|
that already occupies its requested port.
|
|
543
705
|
`);
|
|
@@ -556,7 +718,9 @@ export async function runCli(args) {
|
|
|
556
718
|
if (args[0] === "project" && args[1] === "add") return projectAdd(args);
|
|
557
719
|
if (args[0] === "project" && args[1] === "import") return projectImport(args);
|
|
558
720
|
if (args[0] === "project" && args[1] === "list") return projectList(args);
|
|
721
|
+
if (args[0] === "project" && args[1] === "update") return projectUpdate(args);
|
|
559
722
|
if (args[0] === "project" && args[1] === "remove") return projectRemove(args);
|
|
723
|
+
if (args[0] === "backup") return backupCommand(args);
|
|
560
724
|
if (args[0] === "dispatch" && args[1] === "prepare") return dispatchPrepare(args);
|
|
561
725
|
if (args[0] === "task" && args[1] === "record") return taskRecord(args);
|
|
562
726
|
if (args[0] === "task" && args[1] === "brief") return taskBrief(args);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { open, readFile, readdir, rename, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { taskChefError } from "./state-store.js";
|
|
4
|
+
|
|
5
|
+
export async function appendAuditUnlocked(workspace, record) {
|
|
6
|
+
const filePath = path.join(workspace, "config-audit.jsonl");
|
|
7
|
+
const size = await stat(filePath).then((details) => details.size).catch((error) => {
|
|
8
|
+
if (error.code === "ENOENT") return 0;
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
if (size >= 10 * 1024 * 1024) {
|
|
12
|
+
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
13
|
+
await rename(filePath, path.join(workspace, `config-audit.${timestamp}.jsonl`));
|
|
14
|
+
const archives = (await readdir(workspace))
|
|
15
|
+
.filter((name) => /^config-audit\..+\.jsonl$/.test(name))
|
|
16
|
+
.sort()
|
|
17
|
+
.reverse();
|
|
18
|
+
for (const name of archives.slice(10)) await unlink(path.join(workspace, name));
|
|
19
|
+
}
|
|
20
|
+
const handle = await open(filePath, "a", 0o600);
|
|
21
|
+
try {
|
|
22
|
+
await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...record })}\n`);
|
|
23
|
+
await handle.sync();
|
|
24
|
+
} finally {
|
|
25
|
+
await handle.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readLatestAuditRecordsUnlocked(workspace) {
|
|
30
|
+
const filePath = path.join(workspace, "config-audit.jsonl");
|
|
31
|
+
const content = await readFile(filePath, "utf8").catch((error) => {
|
|
32
|
+
if (error.code === "ENOENT") return "";
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
const latest = new Map();
|
|
36
|
+
for (const [index, line] of content.trimEnd().split("\n").entries()) {
|
|
37
|
+
if (line.length === 0) continue;
|
|
38
|
+
let record;
|
|
39
|
+
try {
|
|
40
|
+
record = JSON.parse(line);
|
|
41
|
+
} catch {
|
|
42
|
+
throw taskChefError(
|
|
43
|
+
"STATE_RECOVERY_REQUIRED",
|
|
44
|
+
`configuration audit is malformed at line ${index + 1}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (record.transactionId) latest.set(record.transactionId, record);
|
|
48
|
+
}
|
|
49
|
+
return latest;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function validateAuditUnlocked(workspace) {
|
|
53
|
+
await readLatestAuditRecordsUnlocked(workspace);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function reconcileAuditUnlocked(workspace, currentHash) {
|
|
57
|
+
const latest = await readLatestAuditRecordsUnlocked(workspace);
|
|
58
|
+
for (const record of latest.values()) {
|
|
59
|
+
if (record.phase !== "prepared" && record.phase !== "recovery-required") continue;
|
|
60
|
+
if (currentHash === record.beforeHash) {
|
|
61
|
+
await appendAuditUnlocked(workspace, {
|
|
62
|
+
...record,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
phase: "aborted",
|
|
65
|
+
reconciliation: "matched-before",
|
|
66
|
+
});
|
|
67
|
+
} else if (currentHash === record.afterHash) {
|
|
68
|
+
await appendAuditUnlocked(workspace, {
|
|
69
|
+
...record,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
phase: "committed",
|
|
72
|
+
reconciliation: "matched-after",
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
throw taskChefError(
|
|
76
|
+
"STATE_RECOVERY_REQUIRED",
|
|
77
|
+
`configuration does not match either side of transaction ${record.transactionId}`,
|
|
78
|
+
{ transactionId: record.transactionId },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function supersedeAuditTransactionsUnlocked(workspace, recoveryTransactionId) {
|
|
85
|
+
const latest = await readLatestAuditRecordsUnlocked(workspace);
|
|
86
|
+
const superseded = [];
|
|
87
|
+
for (const record of latest.values()) {
|
|
88
|
+
if (record.transactionId === recoveryTransactionId
|
|
89
|
+
|| !new Set(["prepared", "recovery-required"]).has(record.phase)) continue;
|
|
90
|
+
await appendAuditUnlocked(workspace, {
|
|
91
|
+
...record,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
phase: "superseded",
|
|
94
|
+
supersededBy: recoveryTransactionId,
|
|
95
|
+
});
|
|
96
|
+
superseded.push(record.transactionId);
|
|
97
|
+
}
|
|
98
|
+
return superseded;
|
|
99
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { semanticHash, taskChefError } from "./state-store.js";
|
|
2
|
+
|
|
3
|
+
function fieldsChanged(before, after) {
|
|
4
|
+
const fields = {};
|
|
5
|
+
for (const key of ["name", "description", "isGitRepository", "githubRepos"]) {
|
|
6
|
+
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
7
|
+
fields[key] = { before: before[key] ?? null, after: after[key] ?? null };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return fields;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function projectSetHash(projects) {
|
|
14
|
+
return semanticHash({ schemaVersion: 1, projects }, "taskchef-project-set-v1");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function configHash(config) {
|
|
18
|
+
return semanticHash(config, "taskchef-config-v1");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function projectDiff(beforeProjects, afterProjects) {
|
|
22
|
+
const before = new Map(beforeProjects.map((project) => [project.path, project]));
|
|
23
|
+
const after = new Map(afterProjects.map((project) => [project.path, project]));
|
|
24
|
+
const added = [];
|
|
25
|
+
const changed = [];
|
|
26
|
+
const removed = [];
|
|
27
|
+
for (const project of afterProjects) {
|
|
28
|
+
const prior = before.get(project.path);
|
|
29
|
+
if (!prior) added.push(project);
|
|
30
|
+
else {
|
|
31
|
+
const fields = fieldsChanged(prior, project);
|
|
32
|
+
if (Object.keys(fields).length) changed.push({ path: project.path, before: prior, after: project, fields });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const project of beforeProjects) {
|
|
36
|
+
if (!after.has(project.path)) removed.push(project);
|
|
37
|
+
}
|
|
38
|
+
return { added, changed, removed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function mutationPreview(workspace, operation, beforeConfig, afterConfig) {
|
|
42
|
+
const diff = projectDiff(beforeConfig.projects, afterConfig.projects);
|
|
43
|
+
const preview = {
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
operation,
|
|
46
|
+
beforeConfigHash: configHash(beforeConfig),
|
|
47
|
+
afterConfigHash: configHash(afterConfig),
|
|
48
|
+
beforeProjectSetHash: projectSetHash(beforeConfig.projects),
|
|
49
|
+
afterProjectSetHash: projectSetHash(afterConfig.projects),
|
|
50
|
+
beforeCount: beforeConfig.projects.length,
|
|
51
|
+
afterCount: afterConfig.projects.length,
|
|
52
|
+
diff,
|
|
53
|
+
projects: afterConfig.projects,
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
...preview,
|
|
57
|
+
planHash: semanticHash({
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
workspace,
|
|
60
|
+
operation,
|
|
61
|
+
beforeConfigHash: preview.beforeConfigHash,
|
|
62
|
+
afterConfigHash: preview.afterConfigHash,
|
|
63
|
+
diff,
|
|
64
|
+
}, "taskchef-mutation-plan-v1"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function exactStringSet(actual, expected) {
|
|
69
|
+
if (!Array.isArray(actual) || new Set(actual).size !== actual.length) return false;
|
|
70
|
+
return actual.length === expected.length
|
|
71
|
+
&& [...actual].sort().every((value, index) => value === [...expected].sort()[index]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertMutationAuthorization(preview, authorization = {}, {
|
|
75
|
+
destructive = preview.diff.removed.length > 0,
|
|
76
|
+
replacement = false,
|
|
77
|
+
} = {}) {
|
|
78
|
+
if (authorization.expectConfigHash !== preview.beforeConfigHash) {
|
|
79
|
+
throw taskChefError("STALE_PREVIEW", "configuration changed or --expect-config-hash is missing", preview);
|
|
80
|
+
}
|
|
81
|
+
if (!destructive && !replacement) return;
|
|
82
|
+
const removedNames = preview.diff.removed.map((project) => project.name);
|
|
83
|
+
if (authorization.expectProjectCount !== preview.beforeCount
|
|
84
|
+
|| authorization.expectAfterCount !== preview.afterCount
|
|
85
|
+
|| authorization.confirmPlan !== preview.planHash
|
|
86
|
+
|| !exactStringSet(authorization.confirmRemoved, removedNames)) {
|
|
87
|
+
throw taskChefError(
|
|
88
|
+
"CONFIRMATION_REQUIRED",
|
|
89
|
+
"destructive project mutation requires exact preview-bound count, hash, plan, and removed-name confirmation",
|
|
90
|
+
preview,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const collapses = preview.beforeCount > 0
|
|
94
|
+
&& (preview.afterCount === 0 || preview.afterCount / preview.beforeCount <= 0.5);
|
|
95
|
+
if (collapses && authorization.confirmCountCollapse !== `${preview.beforeCount}:${preview.afterCount}`) {
|
|
96
|
+
throw taskChefError("CONFIRMATION_REQUIRED", "project-count collapse requires exact before:after confirmation", preview);
|
|
97
|
+
}
|
|
98
|
+
}
|