taskchef 7.29.0 → 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/.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 +32 -32
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/react/App.tsx +16 -0
- 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/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
|
-
|
|
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
|
-
|
|
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
|
|
997
|
+
const updated = await validateConfig({
|
|
781
998
|
...config,
|
|
782
999
|
projects,
|
|
783
|
-
});
|
|
784
|
-
|
|
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
|
|
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
|
}
|