taskchef 7.28.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.js CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  mkdir,
8
8
  readFile,
9
9
  realpath,
10
- link,
11
10
  rename,
12
11
  stat,
13
12
  unlink,
@@ -27,6 +26,31 @@ import {
27
26
  canonicalGithubRepository,
28
27
  normalizeGithubRepositories,
29
28
  } from "./github.js";
29
+ import {
30
+ configHash,
31
+ projectDiff,
32
+ projectSetHash,
33
+ } from "./config-mutations.js";
34
+ import {
35
+ createBackupUnlocked,
36
+ listBackups,
37
+ pruneBackupsUnlocked,
38
+ readBackupPayloads,
39
+ verifyBackup,
40
+ } from "./backups.js";
41
+ import {
42
+ appendAuditUnlocked,
43
+ reconcileAuditUnlocked,
44
+ supersedeAuditTransactionsUnlocked,
45
+ validateAuditUnlocked,
46
+ } from "./config-audit.js";
47
+ import {
48
+ removeDurable,
49
+ semanticHash,
50
+ sha256,
51
+ taskChefError,
52
+ writeDurableAtomic,
53
+ } from "./state-store.js";
30
54
 
31
55
  const execFile = promisify(execFileCallback);
32
56
  const DISPATCHER_INSTRUCTIONS_URL = new URL(
@@ -289,9 +313,15 @@ export async function acquireWorkspaceLock(workspaceRoot, {
289
313
  throw new Error("workspace lock retry loop ended unexpectedly");
290
314
  }
291
315
 
292
- async function withWorkspaceLock(workspaceRoot, operation) {
316
+ async function withWorkspaceLock(workspaceRoot, operation, { allowMaintenance = false } = {}) {
293
317
  const release = await acquireWorkspaceLock(workspaceRoot);
294
318
  try {
319
+ if (!allowMaintenance && await pathExists(path.join(workspaceRoot, ".taskchef-maintenance.json"))) {
320
+ throw taskChefError(
321
+ "STATE_RECOVERY_REQUIRED",
322
+ "workspace has an incomplete maintenance transaction; inspect and explicitly resume or roll it back",
323
+ );
324
+ }
295
325
  return await operation();
296
326
  } finally {
297
327
  await release();
@@ -315,30 +345,10 @@ async function writeDispatchLinesAtomic(workspaceRoot, lines) {
315
345
  }
316
346
 
317
347
  async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
318
- await mkdir(path.dirname(filePath), { recursive: true });
319
348
  if (exclusive && (await pathExists(filePath))) {
320
349
  throw new Error(`file already exists: ${filePath}`);
321
350
  }
322
- const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
323
- await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
324
- encoding: "utf8",
325
- mode: 0o600,
326
- flag: "wx",
327
- });
328
- try {
329
- if (exclusive) {
330
- await link(temporaryPath, filePath).catch((error) => {
331
- if (error.code === "EEXIST") throw new Error(`file already exists: ${filePath}`);
332
- throw error;
333
- });
334
- await unlink(temporaryPath);
335
- return;
336
- }
337
- await rename(temporaryPath, filePath);
338
- } catch (error) {
339
- await unlink(temporaryPath).catch(() => {});
340
- throw error;
341
- }
351
+ await writeDurableAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { exclusive });
342
352
  }
343
353
 
344
354
  async function writeTextAtomic(filePath, value) {
@@ -625,6 +635,19 @@ async function ensureDispatchFile(workspaceRoot) {
625
635
  return { path: filePath, action: exists ? "unchanged" : "created" };
626
636
  }
627
637
 
638
+ async function createStateBackupUnlocked(root, options = {}) {
639
+ return createBackupUnlocked(root, {
640
+ ...options,
641
+ validateFile: async (name, bytes) => {
642
+ if (name === "taskchef.json") {
643
+ await validateConfig(JSON.parse(bytes.toString("utf8")), { checkPaths: false });
644
+ } else if (name === DISPATCH_FILE_NAME) {
645
+ await parseTaskLogContent(root, bytes.toString("utf8"), { validateWorkspace: false });
646
+ }
647
+ },
648
+ });
649
+ }
650
+
628
651
  export async function initializeWorkspace(workspaceRoot) {
629
652
  const requestedRoot = path.resolve(workspaceRoot);
630
653
  await mkdir(requestedRoot, { recursive: true, mode: 0o700 });
@@ -640,6 +663,9 @@ export async function initializeWorkspace(workspaceRoot) {
640
663
  return withWorkspaceLock(root, async () => {
641
664
  const configPath = path.join(root, "taskchef.json");
642
665
  const configExists = await managedRegularFileExists(configPath);
666
+ if (configExists) {
667
+ await createStateBackupUnlocked(root, { reason: "pre-workspace-init" });
668
+ }
643
669
  const config = configExists
644
670
  ? await readConfig(root, { checkPaths: false })
645
671
  : {
@@ -689,6 +715,174 @@ export async function listProjects(workspaceRoot) {
689
715
  return [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
690
716
  }
691
717
 
718
+ export async function readProjectIndex(workspaceRoot, { checkPaths = true } = {}) {
719
+ const config = await readConfig(workspaceRoot, { checkPaths });
720
+ const projects = [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
721
+ return {
722
+ schemaVersion: 1,
723
+ projectCount: projects.length,
724
+ configHash: configHash(config),
725
+ projectSetHash: projectSetHash(config.projects),
726
+ projects,
727
+ };
728
+ }
729
+
730
+ function mutationPreview(root, operation, current, updated) {
731
+ const diff = projectDiff(current.projects, updated.projects);
732
+ const preview = {
733
+ schemaVersion: 1,
734
+ operation,
735
+ beforeCount: current.projects.length,
736
+ afterCount: updated.projects.length,
737
+ beforeConfigHash: configHash(current),
738
+ afterConfigHash: configHash(updated),
739
+ beforeProjectSetHash: projectSetHash(current.projects),
740
+ afterProjectSetHash: projectSetHash(updated.projects),
741
+ beforeProjects: current.projects,
742
+ afterProjects: updated.projects,
743
+ diff,
744
+ };
745
+ return {
746
+ ...preview,
747
+ planHash: semanticHash({ workspace: root, ...preview }, "taskchef-mutation-plan-v1"),
748
+ };
749
+ }
750
+
751
+ function exactStringSet(values, name) {
752
+ if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || value.length === 0)) {
753
+ throw taskChefError("INVALID_INPUT", `${name} must be an array of exact project names`);
754
+ }
755
+ if (new Set(values).size !== values.length) {
756
+ throw taskChefError("INVALID_INPUT", `${name} contains duplicate names`);
757
+ }
758
+ return [...values].sort();
759
+ }
760
+
761
+ function requirePreviewAuthorization(preview, options, { replacement = false } = {}) {
762
+ if (options.expectedConfigHash !== preview.beforeConfigHash) {
763
+ throw taskChefError(
764
+ options.expectedConfigHash ? "STALE_PREVIEW" : "CONFIRMATION_REQUIRED",
765
+ "mutation requires the current --expect-config-hash from a dry-run",
766
+ { preview },
767
+ );
768
+ }
769
+ if (!replacement && preview.diff.removed.length === 0) return;
770
+ const required = [
771
+ ["expectedProjectCount", preview.beforeCount],
772
+ ["expectedAfterCount", preview.afterCount],
773
+ ["confirmPlan", preview.planHash],
774
+ ];
775
+ for (const [name, expected] of required) {
776
+ if (options[name] !== expected) {
777
+ throw taskChefError("CONFIRMATION_REQUIRED", "destructive mutation confirmation does not match its preview", { preview });
778
+ }
779
+ }
780
+ const removed = exactStringSet(preview.diff.removed.map((project) => project.name), "removed projects");
781
+ const confirmed = exactStringSet(options.confirmRemoved, "--confirm-removed");
782
+ if (JSON.stringify(removed) !== JSON.stringify(confirmed)) {
783
+ throw taskChefError("CONFIRMATION_REQUIRED", "confirm every exact removed project name", { preview });
784
+ }
785
+ const collapse = preview.beforeCount > 0
786
+ && (preview.afterCount === 0 || preview.afterCount / preview.beforeCount <= 0.5);
787
+ if (collapse && options.confirmCountCollapse !== `${preview.beforeCount}:${preview.afterCount}`) {
788
+ throw taskChefError("CONFIRMATION_REQUIRED", "large project-count collapse requires exact confirmation", { preview });
789
+ }
790
+ }
791
+
792
+ async function commitConfigMutation(root, operation, current, updated, preview, {
793
+ now = () => new Date().toISOString(),
794
+ attribution = null,
795
+ existingBackup = null,
796
+ allowForensicBackup = false,
797
+ skipReconcile = false,
798
+ supersedePending = false,
799
+ writeConfigFile = writeDurableAtomic,
800
+ } = {}) {
801
+ if (!skipReconcile) await reconcileAuditUnlocked(root, preview.beforeConfigHash);
802
+ const transactionId = randomUUID();
803
+ const backup = existingBackup ?? await createStateBackupUnlocked(root, {
804
+ reason: `pre-${operation}`,
805
+ now,
806
+ });
807
+ if (!backup.usable && !(allowForensicBackup && backup.integrityValid)) {
808
+ throw taskChefError(
809
+ "BACKUP_FAILED",
810
+ "current state was captured forensically but is not valid enough to protect a mutation",
811
+ { backupId: backup.id },
812
+ );
813
+ }
814
+ if (backup.storagePressure) {
815
+ throw taskChefError(
816
+ "BACKUP_FAILED",
817
+ "backup storage exceeds the protected retention budget; prune or free space before mutating configuration",
818
+ { backupId: backup.id },
819
+ );
820
+ }
821
+ const audit = {
822
+ transactionId,
823
+ timestamp: now(),
824
+ operation,
825
+ beforeHash: preview.beforeConfigHash,
826
+ afterHash: preview.afterConfigHash,
827
+ beforeCount: preview.beforeCount,
828
+ afterCount: preview.afterCount,
829
+ added: preview.diff.added.map((project) => project.name),
830
+ changed: preview.diff.changed.map((project) => project.after.name),
831
+ removed: preview.diff.removed.map((project) => project.name),
832
+ backupId: backup.id,
833
+ ...(attribution ? { attribution } : {}),
834
+ };
835
+ await appendAuditUnlocked(root, { ...audit, phase: "prepared" });
836
+ let replaced = false;
837
+ try {
838
+ await writeConfigFile(
839
+ path.join(root, "taskchef.json"),
840
+ `${JSON.stringify(updated, null, 2)}\n`,
841
+ { mode: 0o600 },
842
+ );
843
+ replaced = true;
844
+ const persisted = await readConfig(root, { checkPaths: false });
845
+ if (configHash(persisted) !== preview.afterConfigHash) {
846
+ throw new Error("persisted configuration does not match the planned mutation");
847
+ }
848
+ } catch (error) {
849
+ replaced ||= error.replacementCommitted === true;
850
+ if (!replaced) {
851
+ await appendAuditUnlocked(root, { ...audit, phase: "aborted", error: "configuration write failed" })
852
+ .catch(() => {});
853
+ throw error;
854
+ }
855
+ await appendAuditUnlocked(root, {
856
+ ...audit,
857
+ phase: "recovery-required",
858
+ error: "post-write verification failed",
859
+ }).catch(() => {});
860
+ throw taskChefError(
861
+ "STATE_RECOVERY_REQUIRED",
862
+ `configuration changed but transaction ${transactionId} needs reconciliation`,
863
+ { transactionId, backupId: backup.id },
864
+ );
865
+ }
866
+ try {
867
+ if (supersedePending) {
868
+ await supersedeAuditTransactionsUnlocked(root, transactionId);
869
+ }
870
+ await appendAuditUnlocked(root, { ...audit, phase: "committed" });
871
+ } catch (error) {
872
+ await appendAuditUnlocked(root, {
873
+ ...audit,
874
+ phase: "recovery-required",
875
+ error: "audit completion failed",
876
+ }).catch(() => {});
877
+ throw taskChefError(
878
+ "STATE_RECOVERY_REQUIRED",
879
+ `configuration changed but audit completion failed for transaction ${transactionId}: ${error.message}`,
880
+ { transactionId },
881
+ );
882
+ }
883
+ return { transactionId, backupId: backup.id, backupDeduplicated: backup.deduplicated };
884
+ }
885
+
692
886
  export async function prepareDispatch(workspaceRoot, {
693
887
  taskId = randomUUID(),
694
888
  now = () => new Date().toISOString(),
@@ -709,22 +903,27 @@ export async function prepareDispatch(workspaceRoot, {
709
903
  };
710
904
  }
711
905
 
712
- export async function addProject(workspaceRoot, input) {
906
+ export async function addProject(workspaceRoot, input, options = {}) {
713
907
  const root = await realpath(path.resolve(workspaceRoot));
714
908
  return withWorkspaceLock(root, async () => {
715
- const config = await readConfig(root);
909
+ const config = await readConfig(root, { checkPaths: false });
716
910
  const project = await inspectProject(input);
717
911
  assertWorkspaceOutsideProject(root, project.path);
718
912
  const updated = await validateConfig({
719
913
  ...config,
720
914
  projects: [...config.projects, project],
915
+ }, { checkPaths: false });
916
+ const preview = mutationPreview(root, "project-add", config, updated);
917
+ if (options.dryRun) return { changed: true, project, ...preview };
918
+ const committed = await commitConfigMutation(root, "project-add", config, updated, preview, options);
919
+ Object.defineProperties(project, {
920
+ mutation: { value: { ...preview, ...committed }, enumerable: false },
721
921
  });
722
- await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
723
922
  return project;
724
923
  });
725
924
  }
726
925
 
727
- export async function importProjects(workspaceRoot, inputs, { replace = false } = {}) {
926
+ export async function importProjects(workspaceRoot, inputs, { replace = false, dryRun = false, ...options } = {}) {
728
927
  if (!Array.isArray(inputs)) throw new Error("project import must be a JSON array");
729
928
  const root = await realpath(path.resolve(workspaceRoot));
730
929
  return withWorkspaceLock(root, async () => {
@@ -756,18 +955,36 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
756
955
  const config = await validateConfig({
757
956
  ...current,
758
957
  projects,
759
- });
760
- await writeJsonAtomic(path.join(root, "taskchef.json"), config);
958
+ }, { checkPaths: false });
959
+ const preview = mutationPreview(root, replace ? "project-replace" : "project-import", current, config);
960
+ if (dryRun) {
961
+ return {
962
+ mode: replace ? "replace" : "merge",
963
+ importedCount: imported.length,
964
+ projectCount: config.projects.length,
965
+ projects: imported,
966
+ changed: preview.beforeConfigHash !== preview.afterConfigHash,
967
+ ...preview,
968
+ };
969
+ }
970
+ if (replace) requirePreviewAuthorization(preview, options, { replacement: true });
971
+ const changed = preview.beforeConfigHash !== preview.afterConfigHash;
972
+ const committed = changed
973
+ ? await commitConfigMutation(root, replace ? "project-replace" : "project-import", current, config, preview, options)
974
+ : { transactionId: null, backupId: null, backupDeduplicated: false };
761
975
  return {
762
976
  mode: replace ? "replace" : "merge",
763
977
  importedCount: imported.length,
764
978
  projectCount: config.projects.length,
765
979
  projects: imported,
980
+ changed,
981
+ ...preview,
982
+ ...committed,
766
983
  };
767
984
  });
768
985
  }
769
986
 
770
- export async function removeProject(workspaceRoot, name) {
987
+ export async function removeProject(workspaceRoot, name, { dryRun = false, ...options } = {}) {
771
988
  const root = await realpath(path.resolve(workspaceRoot));
772
989
  return withWorkspaceLock(root, async () => {
773
990
  const config = await readConfig(root, { checkPaths: false });
@@ -777,14 +994,391 @@ export async function removeProject(workspaceRoot, name) {
777
994
  if (index === -1) throw new Error(`configured project not found: ${name}`);
778
995
  const [project] = config.projects.slice(index, index + 1);
779
996
  const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
780
- await writeJsonAtomic(path.join(root, "taskchef.json"), {
997
+ const updated = await validateConfig({
781
998
  ...config,
782
999
  projects,
783
- });
784
- return { project };
1000
+ }, { checkPaths: false });
1001
+ const preview = mutationPreview(root, "project-remove", config, updated);
1002
+ if (dryRun) return { project, changed: true, ...preview };
1003
+ requirePreviewAuthorization(preview, options);
1004
+ const committed = await commitConfigMutation(root, "project-remove", config, updated, preview, options);
1005
+ return { project, changed: true, ...preview, ...committed };
785
1006
  });
786
1007
  }
787
1008
 
1009
+ export async function updateProject(workspaceRoot, selector, input, {
1010
+ dryRun = false,
1011
+ ...options
1012
+ } = {}) {
1013
+ const root = await realpath(path.resolve(workspaceRoot));
1014
+ return withWorkspaceLock(root, async () => {
1015
+ const current = await readConfig(root, { checkPaths: false });
1016
+ const index = current.projects.findIndex((project) => (
1017
+ selector.path
1018
+ ? project.path === path.resolve(selector.path)
1019
+ : project.name.toLowerCase() === requireString(selector.name, "project name").toLowerCase()
1020
+ ));
1021
+ if (index === -1) throw taskChefError("INVALID_INPUT", "configured project not found");
1022
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1023
+ throw taskChefError("INVALID_INPUT", "project update fields must be an object");
1024
+ }
1025
+ const allowed = new Set(["name", "description", "clearDescription", "githubRepos", "refreshGit"]);
1026
+ const unexpected = Object.keys(input).find((key) => !allowed.has(key));
1027
+ if (unexpected) throw taskChefError("INVALID_INPUT", `unsupported project update field: ${unexpected}`);
1028
+ if (Object.keys(input).length === 0) throw taskChefError("INVALID_INPUT", "project update requires a field");
1029
+ if ("description" in input && input.clearDescription) {
1030
+ throw taskChefError("INVALID_INPUT", "description and clearDescription cannot be combined");
1031
+ }
1032
+ const before = current.projects[index];
1033
+ let project = { ...before };
1034
+ if ("name" in input) project.name = requireString(input.name, "project name").trim();
1035
+ if ("description" in input) project.description = requireString(input.description, "description").trim();
1036
+ if (input.clearDescription) delete project.description;
1037
+ if ("githubRepos" in input) {
1038
+ project.githubRepos = normalizeGithubRepositories(input.githubRepos, "githubRepos");
1039
+ }
1040
+ if (input.refreshGit) {
1041
+ const inspected = await inspectProject({ path: before.path });
1042
+ project.isGitRepository = inspected.isGitRepository;
1043
+ }
1044
+ const projects = [...current.projects];
1045
+ projects[index] = project;
1046
+ const updated = await validateConfig({ ...current, projects }, { checkPaths: false });
1047
+ const preview = mutationPreview(root, "project-update", current, updated);
1048
+ const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1049
+ if (dryRun || !changed) return { project: updated.projects[index], changed, ...preview };
1050
+ requirePreviewAuthorization(preview, options);
1051
+ const committed = await commitConfigMutation(root, "project-update", current, updated, preview, options);
1052
+ return { project: updated.projects[index], changed, ...preview, ...committed };
1053
+ });
1054
+ }
1055
+
1056
+ export async function createWorkspaceBackup(workspaceRoot, options = {}) {
1057
+ const root = await realpath(path.resolve(workspaceRoot));
1058
+ return withWorkspaceLock(root, () => createStateBackupUnlocked(root, options));
1059
+ }
1060
+
1061
+ export async function listWorkspaceBackups(workspaceRoot) {
1062
+ const root = await realpath(path.resolve(workspaceRoot));
1063
+ return listBackups(root);
1064
+ }
1065
+
1066
+ export async function verifyWorkspaceBackup(workspaceRoot, id) {
1067
+ const root = await realpath(path.resolve(workspaceRoot));
1068
+ return verifyBackup(root, id);
1069
+ }
1070
+
1071
+ export async function pruneWorkspaceBackups(workspaceRoot, options = {}) {
1072
+ const root = await realpath(path.resolve(workspaceRoot));
1073
+ return withWorkspaceLock(root, () => pruneBackupsUnlocked(root, options));
1074
+ }
1075
+
1076
+ export async function restoreWorkspaceBackup(workspaceRoot, id, {
1077
+ dryRun = false,
1078
+ scope = "projects",
1079
+ approveRestore = false,
1080
+ confirmOffline = false,
1081
+ confirmUnreadableCurrent = false,
1082
+ maintenanceTransaction = null,
1083
+ writeStateFile = writeDurableAtomic,
1084
+ ...options
1085
+ } = {}) {
1086
+ if (!new Set(["projects", "config", "state"]).has(scope)) {
1087
+ throw taskChefError("INVALID_INPUT", "restore scope must be projects, config, or state");
1088
+ }
1089
+ const root = await realpath(path.resolve(workspaceRoot));
1090
+ return withWorkspaceLock(root, async () => {
1091
+ let maintenance = null;
1092
+ if (maintenanceTransaction !== null) {
1093
+ maintenance = JSON.parse(await readFile(path.join(root, ".taskchef-maintenance.json"), "utf8"));
1094
+ if (maintenance.transactionId !== maintenanceTransaction) {
1095
+ throw taskChefError("STATE_RECOVERY_REQUIRED", "maintenance transaction ID does not match");
1096
+ }
1097
+ if (!new Set([maintenance.backupId, maintenance.safetyBackupId]).has(id)) {
1098
+ throw taskChefError("STATE_RECOVERY_REQUIRED", "recovery backup does not belong to the maintenance transaction");
1099
+ }
1100
+ }
1101
+ const sourceVerification = await verifyBackup(root, id);
1102
+ const maintenanceRollback = maintenance !== null
1103
+ && id === maintenance.safetyBackupId
1104
+ && sourceVerification.integrityValid;
1105
+ if (!sourceVerification.usable && !maintenanceRollback) {
1106
+ throw taskChefError("BACKUP_FAILED", sourceVerification.error);
1107
+ }
1108
+ let sourcePayloads = null;
1109
+ if (scope === "state" || maintenanceRollback) {
1110
+ sourcePayloads = (await readBackupPayloads(
1111
+ root,
1112
+ id,
1113
+ ["taskchef.json", "tasks.jsonl", "AGENTS.md"],
1114
+ { allowForensic: maintenanceRollback, allowMissing: maintenanceRollback },
1115
+ )).payloads;
1116
+ if (!maintenanceRollback) {
1117
+ await parseTaskLogContent(root, sourcePayloads[DISPATCH_FILE_NAME].toString("utf8"), {
1118
+ validateWorkspace: false,
1119
+ });
1120
+ }
1121
+ }
1122
+ let stored = null;
1123
+ let storedError = null;
1124
+ const storedConfigBytes = sourcePayloads !== null ? sourcePayloads["taskchef.json"] : (
1125
+ sourceVerification.usable && sourceVerification.manifest.files["taskchef.json"].present
1126
+ ? await readFile(path.join(sourceVerification.path, "taskchef.json"))
1127
+ : null
1128
+ );
1129
+ if (storedConfigBytes !== null) {
1130
+ try {
1131
+ stored = await validateConfig(JSON.parse(storedConfigBytes.toString("utf8")), {
1132
+ checkPaths: false,
1133
+ });
1134
+ } catch (error) {
1135
+ storedError = error.message;
1136
+ }
1137
+ } else {
1138
+ storedError = "backup configuration is absent";
1139
+ }
1140
+ if (!stored && !maintenanceRollback) {
1141
+ throw taskChefError(
1142
+ "BACKUP_FAILED",
1143
+ `backup configuration cannot be restored: ${storedError}`,
1144
+ );
1145
+ }
1146
+ let current = null;
1147
+ let currentBytes = null;
1148
+ let currentPresent = true;
1149
+ let currentError = null;
1150
+ try {
1151
+ currentBytes = await readFile(path.join(root, "taskchef.json"));
1152
+ current = await readConfig(root, { checkPaths: false });
1153
+ } catch (error) {
1154
+ currentError = error.message;
1155
+ if (error.code === "ENOENT" || /configuration does not exist/.test(error.message)) {
1156
+ currentPresent = false;
1157
+ currentBytes = null;
1158
+ }
1159
+ }
1160
+ if ((!current || !stored) && scope === "projects") {
1161
+ throw taskChefError(
1162
+ "INVALID_INPUT",
1163
+ "projects-only restore requires readable current configuration; preview --scope config or state instead",
1164
+ );
1165
+ }
1166
+ const updated = scope === "projects" ? { ...current, projects: stored.projects } : stored;
1167
+ const beforeStateHash = current
1168
+ ? configHash(current)
1169
+ : currentPresent ? `unreadable:${sha256(currentBytes)}` : "missing";
1170
+ const afterStateHash = updated
1171
+ ? configHash(updated)
1172
+ : storedConfigBytes === null ? "missing" : `unreadable:${sha256(storedConfigBytes)}`;
1173
+ const preview = current && updated
1174
+ ? mutationPreview(root, `backup-restore-${scope}`, current, updated)
1175
+ : {
1176
+ schemaVersion: 1,
1177
+ operation: `backup-restore-${scope}`,
1178
+ beforeCount: null,
1179
+ afterCount: updated?.projects.length ?? null,
1180
+ beforeConfigHash: beforeStateHash,
1181
+ afterConfigHash: afterStateHash,
1182
+ beforeProjectSetHash: current ? projectSetHash(current.projects) : null,
1183
+ afterProjectSetHash: updated ? projectSetHash(updated.projects) : null,
1184
+ beforeProjects: current?.projects ?? null,
1185
+ afterProjects: updated?.projects ?? null,
1186
+ diff: { added: [], changed: [], removed: [], unavailable: true },
1187
+ currentError,
1188
+ storedError,
1189
+ };
1190
+ let stateFiles = [];
1191
+ if (scope === "state") {
1192
+ const verified = sourceVerification;
1193
+ for (const name of ["taskchef.json", "tasks.jsonl", "AGENTS.md"]) {
1194
+ const liveBytes = await readFile(path.join(root, name)).catch((error) => {
1195
+ if (error.code === "ENOENT") return null;
1196
+ throw error;
1197
+ });
1198
+ stateFiles.push({
1199
+ name,
1200
+ beforePresent: liveBytes !== null,
1201
+ beforeHash: liveBytes === null ? null : sha256(liveBytes),
1202
+ afterPresent: sourcePayloads[name] !== null,
1203
+ afterHash: verified.manifest.files[name].sha256,
1204
+ changed: (liveBytes === null) !== (sourcePayloads[name] === null)
1205
+ || (liveBytes !== null && sha256(liveBytes) !== verified.manifest.files[name].sha256),
1206
+ });
1207
+ }
1208
+ }
1209
+ const restorePlan = {
1210
+ ...preview,
1211
+ planHash: semanticHash({
1212
+ schemaVersion: 1,
1213
+ workspace: root,
1214
+ backupId: id,
1215
+ scope,
1216
+ beforeConfigHash: preview.beforeConfigHash,
1217
+ afterConfigHash: preview.afterConfigHash,
1218
+ beforeProjectSetHash: preview.beforeProjectSetHash,
1219
+ afterProjectSetHash: preview.afterProjectSetHash,
1220
+ diff: preview.diff,
1221
+ stateFiles,
1222
+ }, "taskchef-restore-plan-v1"),
1223
+ };
1224
+ if (dryRun) return {
1225
+ dryRun: true,
1226
+ backupId: id,
1227
+ scope,
1228
+ changed: restorePlan.beforeConfigHash !== restorePlan.afterConfigHash
1229
+ || stateFiles.some((file) => file.changed),
1230
+ stateFiles,
1231
+ ...restorePlan,
1232
+ };
1233
+ if (!approveRestore) {
1234
+ throw taskChefError("CONFIRMATION_REQUIRED", "restore requires explicit --approve-restore", {
1235
+ preview: restorePlan,
1236
+ });
1237
+ }
1238
+ if (!current || !updated) {
1239
+ if (!confirmUnreadableCurrent
1240
+ || options.expectedConfigHash !== restorePlan.beforeConfigHash
1241
+ || options.confirmPlan !== restorePlan.planHash) {
1242
+ throw taskChefError(
1243
+ "CONFIRMATION_REQUIRED",
1244
+ "raw-state recovery requires its exact current hash, restore plan, and --confirm-unreadable-current",
1245
+ { preview: restorePlan },
1246
+ );
1247
+ }
1248
+ } else {
1249
+ requirePreviewAuthorization(restorePlan, options, { replacement: true });
1250
+ }
1251
+ if (scope === "state" && !confirmOffline) {
1252
+ throw taskChefError(
1253
+ "CONFIRMATION_REQUIRED",
1254
+ "full state restore requires --confirm-offline after stopping the dashboard and TaskChef writers",
1255
+ { preview: restorePlan, stateFiles },
1256
+ );
1257
+ }
1258
+ await validateAuditUnlocked(root);
1259
+ const safety = maintenance
1260
+ ? await verifyBackup(root, maintenance.safetyBackupId)
1261
+ : await createStateBackupUnlocked(root, { reason: "pre-restore", now: options.now });
1262
+ if (!safety.usable && !safety.integrityValid) {
1263
+ throw taskChefError("BACKUP_FAILED", "restore safety backup is unavailable", {
1264
+ backupId: maintenance?.safetyBackupId ?? safety.id,
1265
+ });
1266
+ }
1267
+ if (scope === "state") {
1268
+ const transactionId = maintenanceTransaction ?? randomUUID();
1269
+ const payloads = sourcePayloads;
1270
+ const fencePath = path.join(root, ".taskchef-maintenance.json");
1271
+ await writeDurableAtomic(fencePath, `${JSON.stringify({
1272
+ schemaVersion: 1,
1273
+ transactionId,
1274
+ operation: "state-restore",
1275
+ backupId: maintenance?.backupId ?? id,
1276
+ recoveryBackupId: id,
1277
+ safetyBackupId: safety.id,
1278
+ stateFiles,
1279
+ }, null, 2)}\n`);
1280
+ await appendAuditUnlocked(root, {
1281
+ schemaVersion: 1,
1282
+ transactionId,
1283
+ timestamp: new Date().toISOString(),
1284
+ operation: "backup-restore-state",
1285
+ phase: "prepared",
1286
+ beforeHash: restorePlan.beforeConfigHash,
1287
+ afterHash: restorePlan.afterConfigHash,
1288
+ beforeCount: restorePlan.beforeCount,
1289
+ afterCount: restorePlan.afterCount,
1290
+ added: restorePlan.diff.added.map((project) => project.name),
1291
+ changed: restorePlan.diff.changed.map((project) => project.after.name),
1292
+ removed: restorePlan.diff.removed.map((project) => project.name),
1293
+ backupId: id,
1294
+ });
1295
+ try {
1296
+ for (const name of ["tasks.jsonl", "AGENTS.md", "taskchef.json"]) {
1297
+ const filePath = path.join(root, name);
1298
+ if (payloads[name] === null) await removeDurable(filePath);
1299
+ else await writeStateFile(filePath, payloads[name]);
1300
+ }
1301
+ for (const file of stateFiles) {
1302
+ const restoredBytes = await readFile(path.join(root, file.name)).catch((error) => {
1303
+ if (error.code === "ENOENT") return null;
1304
+ throw error;
1305
+ });
1306
+ if ((restoredBytes !== null) !== file.afterPresent
1307
+ || (restoredBytes !== null && sha256(restoredBytes) !== file.afterHash)) {
1308
+ throw new Error(`restored payload failed verification: ${file.name}`);
1309
+ }
1310
+ }
1311
+ await removeDurable(path.join(root, ".taskchef-usage.json"));
1312
+ await supersedeAuditTransactionsUnlocked(root, transactionId);
1313
+ await appendAuditUnlocked(root, {
1314
+ transactionId,
1315
+ timestamp: new Date().toISOString(),
1316
+ operation: "backup-restore-state",
1317
+ phase: "committed",
1318
+ beforeHash: restorePlan.beforeConfigHash,
1319
+ afterHash: restorePlan.afterConfigHash,
1320
+ beforeCount: restorePlan.beforeCount,
1321
+ afterCount: restorePlan.afterCount,
1322
+ added: restorePlan.diff.added.map((project) => project.name),
1323
+ changed: restorePlan.diff.changed.map((project) => project.after.name),
1324
+ removed: restorePlan.diff.removed.map((project) => project.name),
1325
+ backupId: id,
1326
+ });
1327
+ await removeDurable(fencePath);
1328
+ } catch (error) {
1329
+ throw taskChefError(
1330
+ "STATE_RECOVERY_REQUIRED",
1331
+ `state restore ${transactionId} is incomplete: ${error.message}`,
1332
+ { transactionId, safetyBackupId: safety.id },
1333
+ );
1334
+ }
1335
+ return {
1336
+ backupId: id,
1337
+ safetyBackupId: safety.id,
1338
+ scope,
1339
+ changed: true,
1340
+ stateFiles,
1341
+ transactionId,
1342
+ ...restorePlan,
1343
+ };
1344
+ }
1345
+ const committed = await commitConfigMutation(
1346
+ root,
1347
+ `backup-restore-${scope}`,
1348
+ current,
1349
+ updated,
1350
+ restorePlan,
1351
+ {
1352
+ ...options,
1353
+ existingBackup: safety,
1354
+ allowForensicBackup: !current,
1355
+ skipReconcile: true,
1356
+ supersedePending: true,
1357
+ },
1358
+ );
1359
+ return {
1360
+ ...restorePlan,
1361
+ ...committed,
1362
+ backupId: id,
1363
+ safetyBackupId: safety.id,
1364
+ scope,
1365
+ changed: true,
1366
+ };
1367
+ }, { allowMaintenance: maintenanceTransaction !== null });
1368
+ }
1369
+
1370
+ export async function readWorkspaceMaintenance(workspaceRoot) {
1371
+ const root = await realpath(path.resolve(workspaceRoot));
1372
+ const filePath = path.join(root, ".taskchef-maintenance.json");
1373
+ try {
1374
+ const value = JSON.parse(await readFile(filePath, "utf8"));
1375
+ return { active: true, ...value };
1376
+ } catch (error) {
1377
+ if (error.code === "ENOENT") return { active: false };
1378
+ throw taskChefError("STATE_RECOVERY_REQUIRED", `maintenance state is unreadable: ${error.message}`);
1379
+ }
1380
+ }
1381
+
788
1382
  async function validateDispatchShape(dispatch, name = "task") {
789
1383
  const supportedVersions = [
790
1384
  FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
@@ -1330,8 +1924,8 @@ async function validateDispatchShape(dispatch, name = "task") {
1330
1924
  return normalized;
1331
1925
  }
1332
1926
 
1333
- async function parseDispatchRecordsUnlocked(root, content) {
1334
- await readConfig(root, { checkPaths: false });
1927
+ async function parseDispatchRecordsUnlocked(root, content, { validateWorkspace = true } = {}) {
1928
+ if (validateWorkspace) await readConfig(root, { checkPaths: false });
1335
1929
  if (content.length > 0 && !content.endsWith("\n")) {
1336
1930
  throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
1337
1931
  }
@@ -1400,10 +1994,13 @@ export async function listTasks(workspaceRoot) {
1400
1994
  return readDispatchesUnlocked(root);
1401
1995
  }
1402
1996
 
1403
- export async function parseTaskLogContent(workspaceRoot, content) {
1997
+ export async function parseTaskLogContent(workspaceRoot, content, {
1998
+ validateWorkspace = true,
1999
+ } = {}) {
1404
2000
  const root = await realpath(path.resolve(workspaceRoot));
1405
2001
  if (typeof content !== "string") throw new Error("task log content must be a string");
1406
- return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
2002
+ return (await parseDispatchRecordsUnlocked(root, content, { validateWorkspace }))
2003
+ .map((record) => record.normalized);
1407
2004
  }
1408
2005
 
1409
2006
  function currentSchemaTask(dispatch, patch = {}) {
@@ -1463,6 +2060,10 @@ export async function migrateTaskLog(workspaceRoot, {
1463
2060
  backupPath: null,
1464
2061
  };
1465
2062
  }
2063
+ const stateBackup = await createStateBackupUnlocked(root, {
2064
+ reason: `pre-task-log-migration-v${CURRENT_TASK_SCHEMA_VERSION}`,
2065
+ now,
2066
+ });
1466
2067
  const beforeTurnCount = records.reduce((count, record) => count + record.normalized.turns.length, 0);
1467
2068
  const migratedTasks = records.map((record) => currentSchemaTask(record.normalized));
1468
2069
  const lines = migratedTasks.map((task) => JSON.stringify(task));
@@ -1504,6 +2105,7 @@ export async function migrateTaskLog(workspaceRoot, {
1504
2105
  nativeTurnRefCount,
1505
2106
  fallbackTurnRefCount,
1506
2107
  backupPath,
2108
+ stateBackupId: stateBackup.id,
1507
2109
  };
1508
2110
  });
1509
2111
  }