@ricsam/r5d-worker 0.0.123 → 0.0.124
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/dist/cjs/main.cjs +488 -238
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-checkout-garbage.cjs +32 -3
- package/dist/cjs/project-workspace-state.cjs +51 -35
- package/dist/cjs/project-worktrees.cjs +79 -34
- package/dist/cjs/recovery-journal-protocol.cjs +56 -0
- package/dist/cjs/recovery-journal-runtime.cjs +133 -0
- package/dist/cjs/recovery-journal-thread.cjs +9 -0
- package/dist/cjs/recovery-journal.cjs +735 -0
- package/dist/cjs/recovery-store.cjs +8 -0
- package/dist/cjs/session-file-mutations.cjs +61 -0
- package/dist/cjs/working-tree-mirror.cjs +1 -0
- package/dist/cjs/workspace-command-sync-policy.cjs +37 -8
- package/dist/cjs/workspace-filesystem-executor-thread.cjs +36 -0
- package/dist/cjs/workspace-filesystem-executor.cjs +327 -0
- package/dist/cjs/workspace-filesystem-job-types.cjs +134 -0
- package/dist/cjs/workspace-filesystem-jobs.cjs +57 -0
- package/dist/cjs/workspace-git-sync.cjs +275 -201
- package/dist/cjs/workspace-mount-hold-fence.cjs +120 -0
- package/dist/mjs/main.mjs +491 -244
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-checkout-garbage.mjs +30 -2
- package/dist/mjs/project-workspace-state.mjs +51 -35
- package/dist/mjs/project-worktrees.mjs +74 -34
- package/dist/mjs/recovery-journal-protocol.mjs +30 -0
- package/dist/mjs/recovery-journal-runtime.mjs +112 -0
- package/dist/mjs/recovery-journal-thread.mjs +8 -0
- package/dist/mjs/recovery-journal.mjs +687 -0
- package/dist/mjs/recovery-store.mjs +8 -0
- package/dist/mjs/session-file-mutations.mjs +37 -0
- package/dist/mjs/working-tree-mirror.mjs +1 -0
- package/dist/mjs/workspace-command-sync-policy.mjs +37 -8
- package/dist/mjs/workspace-filesystem-executor-thread.mjs +38 -0
- package/dist/mjs/workspace-filesystem-executor.mjs +287 -0
- package/dist/mjs/workspace-filesystem-job-types.mjs +106 -0
- package/dist/mjs/workspace-filesystem-jobs.mjs +51 -0
- package/dist/mjs/workspace-git-sync.mjs +264 -202
- package/dist/mjs/workspace-mount-hold-fence.mjs +95 -0
- package/dist/types/main.d.ts +24 -19
- package/dist/types/project-checkout-garbage.d.ts +19 -2
- package/dist/types/project-workspace-state.d.ts +9 -9
- package/dist/types/project-worktrees.d.ts +49 -5
- package/dist/types/recovery-journal-protocol.d.ts +35 -0
- package/dist/types/recovery-journal-runtime.d.ts +12 -0
- package/dist/types/recovery-journal-stall-fixture.d.ts +1 -0
- package/dist/types/recovery-journal-thread.d.ts +1 -0
- package/dist/types/recovery-journal.d.ts +246 -0
- package/dist/types/recovery-store.d.ts +6 -0
- package/dist/types/session-file-mutations.d.ts +22 -0
- package/dist/types/workspace-command-sync-policy.d.ts +22 -7
- package/dist/types/workspace-filesystem-executor-thread.d.ts +1 -0
- package/dist/types/workspace-filesystem-executor.d.ts +123 -0
- package/dist/types/workspace-filesystem-job-types.d.ts +246 -0
- package/dist/types/workspace-filesystem-jobs.d.ts +7 -0
- package/dist/types/workspace-git-sync.d.ts +113 -7
- package/dist/types/workspace-mount-hold-fence.d.ts +42 -0
- package/package.json +1 -1
- package/dist/cjs/project-snapshot-recovery-runner.cjs +0 -171
- package/dist/mjs/project-snapshot-recovery-runner.mjs +0 -135
- package/dist/types/project-snapshot-recovery-runner.d.ts +0 -10
package/dist/mjs/package.json
CHANGED
|
@@ -19,11 +19,33 @@ function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath)
|
|
|
19
19
|
fs.renameSync(checkoutPath, target);
|
|
20
20
|
return target;
|
|
21
21
|
}
|
|
22
|
+
function stageProjectForDeletion(projectsRoot, projectRoot) {
|
|
23
|
+
const root = path.resolve(projectsRoot);
|
|
24
|
+
const resolved = path.resolve(projectRoot);
|
|
25
|
+
const relative = path.relative(root, resolved);
|
|
26
|
+
const segments = relative.split(path.sep);
|
|
27
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || segments.length !== 2 || segments.some((segment) => !segment || segment.startsWith("."))) {
|
|
28
|
+
throw new Error(`Cannot stage ${projectRoot} for deletion: not a project root under ${projectsRoot}`);
|
|
29
|
+
}
|
|
30
|
+
if (!isRealDirectory(resolved)) throw new Error(`Cannot stage ${projectRoot} for deletion: not a directory`);
|
|
31
|
+
const garbageRoot = path.join(root, segments[0], PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
32
|
+
fs.mkdirSync(garbageRoot, { recursive: true });
|
|
33
|
+
if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${projectRoot} for deletion: ${garbageRoot} is not a directory`);
|
|
34
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
35
|
+
const target = path.join(garbageRoot, `${segments[1]}-${timestamp}-${randomUUID().slice(0, 8)}`);
|
|
36
|
+
fs.renameSync(resolved, target);
|
|
37
|
+
return target;
|
|
38
|
+
}
|
|
22
39
|
function isStagedProjectCheckoutPath(projectsRoot, candidate) {
|
|
23
40
|
const relative = path.relative(path.resolve(projectsRoot), path.resolve(candidate));
|
|
24
41
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
25
42
|
const segments = relative.split(path.sep);
|
|
26
|
-
|
|
43
|
+
const entry = segments[segments.length - 1];
|
|
44
|
+
if (!entry || entry.startsWith(".")) return false;
|
|
45
|
+
if (segments.length === 4)
|
|
46
|
+
return segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && !segments[0].startsWith(".") && !segments[1].startsWith(".");
|
|
47
|
+
if (segments.length === 3) return segments[1] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && !segments[0].startsWith(".");
|
|
48
|
+
return false;
|
|
27
49
|
}
|
|
28
50
|
function ownedStagedPath(projectsRoot, candidate) {
|
|
29
51
|
const root = path.resolve(projectsRoot);
|
|
@@ -56,6 +78,11 @@ function discoverStagedProjectCheckouts(projectsRoot) {
|
|
|
56
78
|
const staged = [];
|
|
57
79
|
for (const namespace of realDirectoryEntries(root)) {
|
|
58
80
|
if (namespace.startsWith(".")) continue;
|
|
81
|
+
const namespaceGarbageRoot = path.join(root, namespace, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
82
|
+
for (const entry of realDirectoryEntries(namespaceGarbageRoot)) {
|
|
83
|
+
const candidate = path.join(namespaceGarbageRoot, entry);
|
|
84
|
+
if (ownedStagedPath(root, candidate)) staged.push(candidate);
|
|
85
|
+
}
|
|
59
86
|
for (const project of realDirectoryEntries(path.join(root, namespace))) {
|
|
60
87
|
if (project.startsWith(".")) continue;
|
|
61
88
|
const garbageRoot = path.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
@@ -146,5 +173,6 @@ export {
|
|
|
146
173
|
isStagedProjectCheckoutPath,
|
|
147
174
|
ownedStagedPath,
|
|
148
175
|
removeStagedProjectCheckout,
|
|
149
|
-
stageProjectCheckoutForDeletion
|
|
176
|
+
stageProjectCheckoutForDeletion,
|
|
177
|
+
stageProjectForDeletion
|
|
150
178
|
};
|
|
@@ -280,47 +280,63 @@ function lstatIfExists(targetPath) {
|
|
|
280
280
|
throw error;
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
-
function
|
|
283
|
+
async function fsyncDirectoryAsync(directory) {
|
|
284
|
+
const handle = await fs.promises.open(directory, "r");
|
|
285
|
+
try {
|
|
286
|
+
await handle.sync();
|
|
287
|
+
} finally {
|
|
288
|
+
await handle.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function lstatIfExistsAsync(targetPath) {
|
|
292
|
+
try {
|
|
293
|
+
return await fs.promises.lstat(targetPath);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code === "ENOENT") return null;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function ensureDurableStateRoot(stateRoot) {
|
|
284
300
|
const resolvedStateRoot = path.resolve(stateRoot);
|
|
285
301
|
let existingAncestor = resolvedStateRoot;
|
|
286
|
-
while (!
|
|
302
|
+
while (!await lstatIfExistsAsync(existingAncestor)) {
|
|
287
303
|
const parent = path.dirname(existingAncestor);
|
|
288
304
|
if (parent === existingAncestor) throw new Error(`Project workspace state has no existing ancestor: ${resolvedStateRoot}`);
|
|
289
305
|
existingAncestor = parent;
|
|
290
306
|
}
|
|
291
|
-
const ancestorStat =
|
|
307
|
+
const ancestorStat = await lstatIfExistsAsync(existingAncestor);
|
|
292
308
|
if (!ancestorStat || ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
|
|
293
309
|
throw new Error(`Project workspace state must descend from a real directory: ${resolvedStateRoot}`);
|
|
294
310
|
}
|
|
295
|
-
fs.
|
|
311
|
+
await fs.promises.mkdir(resolvedStateRoot, { recursive: true, mode: 448 });
|
|
296
312
|
let current = resolvedStateRoot;
|
|
297
313
|
while (true) {
|
|
298
|
-
const stat =
|
|
314
|
+
const stat = await lstatIfExistsAsync(current);
|
|
299
315
|
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
300
316
|
throw new Error(`Project workspace state path must be a real directory: ${current}`);
|
|
301
317
|
}
|
|
302
|
-
|
|
318
|
+
await fsyncDirectoryAsync(current);
|
|
303
319
|
if (current === existingAncestor) break;
|
|
304
320
|
current = path.dirname(current);
|
|
305
321
|
}
|
|
306
322
|
}
|
|
307
|
-
function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
|
|
308
|
-
ensureDurableStateRoot(stateRoot);
|
|
323
|
+
async function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
|
|
324
|
+
await ensureDurableStateRoot(stateRoot);
|
|
309
325
|
afterStateRootFsync?.();
|
|
310
326
|
const temporaryPath = path.join(stateRoot, `.${PROJECT_WORKSPACE_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`);
|
|
311
|
-
let
|
|
327
|
+
let handle;
|
|
312
328
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
329
|
+
handle = await fs.promises.open(temporaryPath, "wx", 384);
|
|
330
|
+
await handle.writeFile(`${JSON.stringify(normalizeState(state), null, 2)}
|
|
315
331
|
`, "utf8");
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
fs.
|
|
320
|
-
|
|
332
|
+
await handle.sync();
|
|
333
|
+
await handle.close();
|
|
334
|
+
handle = void 0;
|
|
335
|
+
await fs.promises.rename(temporaryPath, statePath);
|
|
336
|
+
await fsyncDirectoryAsync(stateRoot);
|
|
321
337
|
} finally {
|
|
322
|
-
if (
|
|
323
|
-
fs.
|
|
338
|
+
if (handle !== void 0) await handle.close();
|
|
339
|
+
await fs.promises.rm(temporaryPath, { force: true });
|
|
324
340
|
}
|
|
325
341
|
}
|
|
326
342
|
function createBranchTombstone(project, branchName) {
|
|
@@ -696,7 +712,7 @@ class ProjectWorkspaceStateStore {
|
|
|
696
712
|
locallyPendingCreatedBranches: state.locallyPendingCreatedBranches
|
|
697
713
|
});
|
|
698
714
|
}
|
|
699
|
-
reconcile(input) {
|
|
715
|
+
async reconcile(input) {
|
|
700
716
|
const previous = readState(this.statePath);
|
|
701
717
|
const authoritativeDesiredProjects = normalizeDesiredProjects(input.desiredProjects);
|
|
702
718
|
for (const project of authoritativeDesiredProjects) {
|
|
@@ -772,11 +788,11 @@ class ProjectWorkspaceStateStore {
|
|
|
772
788
|
enabledRepositoryTransitions: previous.enabledRepositoryTransitions.filter(({ projectId }) => authoritativeProjectIds.has(projectId)),
|
|
773
789
|
tombstones
|
|
774
790
|
});
|
|
775
|
-
writeStateAtomically(this.stateRoot, this.statePath, nextState);
|
|
791
|
+
await writeStateAtomically(this.stateRoot, this.statePath, nextState);
|
|
776
792
|
return stateView(this.projectsRoot, nextState);
|
|
777
793
|
}
|
|
778
794
|
/** Record a successful enabled reseed without letting disabled config consume it. */
|
|
779
|
-
recordEnabledRepositoryTransition(input) {
|
|
795
|
+
async recordEnabledRepositoryTransition(input) {
|
|
780
796
|
const [transition] = normalizeEnabledRepositoryTransitions([input]);
|
|
781
797
|
const state = readState(this.statePath);
|
|
782
798
|
if (!state.desiredProjects.some(({ projectId }) => projectId === transition.projectId)) {
|
|
@@ -785,11 +801,11 @@ class ProjectWorkspaceStateStore {
|
|
|
785
801
|
state.enabledRepositoryTransitions = state.enabledRepositoryTransitions.filter(({ projectId }) => projectId !== transition.projectId);
|
|
786
802
|
state.enabledRepositoryTransitions.push(transition);
|
|
787
803
|
state.enabledRepositoryTransitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
788
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
804
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
789
805
|
return stateView(this.projectsRoot, state);
|
|
790
806
|
}
|
|
791
807
|
/** Persist the create intent before invoking Git so a crash cannot orphan an untracked worktree. */
|
|
792
|
-
recordPendingCreatedBranch(input) {
|
|
808
|
+
async recordPendingCreatedBranch(input) {
|
|
793
809
|
const [pending] = normalizePendingCreatedBranches([input]);
|
|
794
810
|
const state = readState(this.statePath);
|
|
795
811
|
const existingPending = state.locallyPendingCreatedBranches.find(
|
|
@@ -812,11 +828,11 @@ class ProjectWorkspaceStateStore {
|
|
|
812
828
|
state.locallyPendingCreatedBranches.sort(
|
|
813
829
|
(left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
|
|
814
830
|
);
|
|
815
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
831
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
816
832
|
return stateView(this.projectsRoot, state);
|
|
817
833
|
}
|
|
818
834
|
/** Roll back an intent only when Git failed before creating its branch. */
|
|
819
|
-
rollbackPendingCreatedBranch(input) {
|
|
835
|
+
async rollbackPendingCreatedBranch(input) {
|
|
820
836
|
const [pending] = normalizePendingCreatedBranches([input]);
|
|
821
837
|
const state = readState(this.statePath);
|
|
822
838
|
const key = pendingCreatedBranchKey(pending);
|
|
@@ -829,11 +845,11 @@ class ProjectWorkspaceStateStore {
|
|
|
829
845
|
);
|
|
830
846
|
const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
|
|
831
847
|
if (project) project.branches = project.branches.filter(({ branchName }) => branchName !== pending.branchName);
|
|
832
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
848
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
833
849
|
return stateView(this.projectsRoot, state);
|
|
834
850
|
}
|
|
835
851
|
/** Clear a create intent after an authoritative desired config contains it. */
|
|
836
|
-
clearPendingCreatedBranch(input) {
|
|
852
|
+
async clearPendingCreatedBranch(input) {
|
|
837
853
|
const [pending] = normalizePendingCreatedBranches([input]);
|
|
838
854
|
const state = readState(this.statePath);
|
|
839
855
|
const key = pendingCreatedBranchKey(pending);
|
|
@@ -843,11 +859,11 @@ class ProjectWorkspaceStateStore {
|
|
|
843
859
|
state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
|
|
844
860
|
(candidate) => pendingCreatedBranchKey(candidate) !== key
|
|
845
861
|
);
|
|
846
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
862
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
847
863
|
return stateView(this.projectsRoot, state);
|
|
848
864
|
}
|
|
849
865
|
/** Persist a branch tombstone before mutating its linked worktree or local ref. */
|
|
850
|
-
beginBranchDeletion(input) {
|
|
866
|
+
async beginBranchDeletion(input) {
|
|
851
867
|
const [target] = normalizePendingCreatedBranches([input]);
|
|
852
868
|
const state = readState(this.statePath);
|
|
853
869
|
const project = state.desiredProjects.find(({ projectId }) => projectId === target.projectId);
|
|
@@ -863,10 +879,10 @@ class ProjectWorkspaceStateStore {
|
|
|
863
879
|
state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
|
|
864
880
|
(candidate) => pendingCreatedBranchKey(candidate) !== key
|
|
865
881
|
);
|
|
866
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
882
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
867
883
|
return stateView(this.projectsRoot, state);
|
|
868
884
|
}
|
|
869
|
-
recordTreePublication(input) {
|
|
885
|
+
async recordTreePublication(input) {
|
|
870
886
|
if (!GIT_OBJECT_ID_PATTERN.test(input.publishedHead)) throw new Error("Invalid published workspace head");
|
|
871
887
|
const state = readState(this.statePath);
|
|
872
888
|
const ids = new Set(input.tombstoneIds);
|
|
@@ -879,10 +895,10 @@ class ProjectWorkspaceStateStore {
|
|
|
879
895
|
state.tombstones = state.tombstones.filter(
|
|
880
896
|
(tombstone) => !tombstone.treePublishedHead || tombstone.kind === "branch" && !branchIsCurrentlyDesired(state.desiredProjects, tombstone.projectId, tombstone.branchName)
|
|
881
897
|
);
|
|
882
|
-
if (changed) writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
898
|
+
if (changed) await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
883
899
|
return stateView(this.projectsRoot, state);
|
|
884
900
|
}
|
|
885
|
-
recordMirrorRefDeletion(input) {
|
|
901
|
+
async recordMirrorRefDeletion(input) {
|
|
886
902
|
validateManagedBranchName(input.branchName);
|
|
887
903
|
const state = readState(this.statePath);
|
|
888
904
|
const tombstone = state.tombstones.find(({ id }) => id === input.tombstoneId);
|
|
@@ -901,13 +917,13 @@ class ProjectWorkspaceStateStore {
|
|
|
901
917
|
if (branchNames.every((branchName) => tombstone.mirrorRefsDeleted.includes(branchName))) {
|
|
902
918
|
state.tombstones = state.tombstones.filter(({ id }) => id !== tombstone.id);
|
|
903
919
|
}
|
|
904
|
-
writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
920
|
+
await writeStateAtomically(this.stateRoot, this.statePath, state);
|
|
905
921
|
return stateView(this.projectsRoot, state);
|
|
906
922
|
}
|
|
907
923
|
}
|
|
908
924
|
const projectWorkspaceStateTestHarness = {
|
|
909
925
|
writeEmptyStateAtDurableRootBoundary(stateRoot, afterStateRootFsync) {
|
|
910
|
-
writeStateAtomically(stateRoot, path.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
|
|
926
|
+
return writeStateAtomically(stateRoot, path.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
|
|
911
927
|
}
|
|
912
928
|
};
|
|
913
929
|
export {
|
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
|
|
6
6
|
import { validateManagedBranchName } from "./managed-paths.mjs";
|
|
7
|
-
import { stageProjectCheckoutForDeletion } from "./project-checkout-garbage.mjs";
|
|
7
|
+
import { stageProjectCheckoutForDeletion, stageProjectForDeletion } from "./project-checkout-garbage.mjs";
|
|
8
8
|
import {
|
|
9
9
|
canonicalizeLocalProjectMirrorRefs,
|
|
10
10
|
parseProjectMirrorRefListing,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
projectMirrorRefsTokens
|
|
15
15
|
} from "./project-mirror-refs-token.mjs";
|
|
16
16
|
import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
|
|
17
|
+
import { workspaceFilesystemExecutor } from "./workspace-filesystem-executor.mjs";
|
|
17
18
|
const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
|
|
18
19
|
const PROJECT_WORKTREE_SNAPSHOT_MANIFEST = "transaction.json";
|
|
19
20
|
const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION = 1;
|
|
@@ -429,7 +430,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
|
429
430
|
}
|
|
430
431
|
}
|
|
431
432
|
}
|
|
432
|
-
function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope
|
|
433
|
+
function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope, ownerSessionId) {
|
|
433
434
|
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
434
435
|
const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
|
|
435
436
|
assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
|
|
@@ -452,7 +453,7 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, s
|
|
|
452
453
|
state: "building",
|
|
453
454
|
scope,
|
|
454
455
|
ownerProcessId: process.pid,
|
|
455
|
-
ownerSessionId
|
|
456
|
+
ownerSessionId,
|
|
456
457
|
projectRoot: resolvedProjectRoot,
|
|
457
458
|
branches: branchSnapshots,
|
|
458
459
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -975,7 +976,7 @@ async function reconcileLinkedLayout(input) {
|
|
|
975
976
|
plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: keep ? "keep" : "reset" });
|
|
976
977
|
}
|
|
977
978
|
const mutatedBranches = new Set(plan.filter(({ action }) => action !== "keep").map(({ branchName }) => branchName));
|
|
978
|
-
if (mutatedBranches.size > 0) input.beforeDestructiveMutation(mutatedBranches);
|
|
979
|
+
if (mutatedBranches.size > 0) await input.beforeDestructiveMutation(mutatedBranches);
|
|
979
980
|
for (const entry of plan) {
|
|
980
981
|
if (entry.action === "keep") continue;
|
|
981
982
|
if (entry.action === "reset") {
|
|
@@ -1016,13 +1017,19 @@ async function ensureProjectWorktrees(input) {
|
|
|
1016
1017
|
const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
|
|
1017
1018
|
assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
|
|
1018
1019
|
let snapshot;
|
|
1019
|
-
const ensureSnapshot = (branchNames) => {
|
|
1020
|
-
snapshot ??=
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1020
|
+
const ensureSnapshot = async (branchNames) => {
|
|
1021
|
+
snapshot ??= await workspaceFilesystemExecutor().run(
|
|
1022
|
+
"project_snapshot_create",
|
|
1023
|
+
{
|
|
1024
|
+
projectRoot,
|
|
1025
|
+
branches: (branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches).map((branch) => ({
|
|
1026
|
+
...branch
|
|
1027
|
+
})),
|
|
1028
|
+
temporaryRoot: snapshotStorageRoot,
|
|
1029
|
+
scope: branchNames ? "branches" : "project",
|
|
1030
|
+
ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID
|
|
1031
|
+
},
|
|
1032
|
+
{ onProgress: input.onSnapshotProgress }
|
|
1026
1033
|
);
|
|
1027
1034
|
return snapshot;
|
|
1028
1035
|
};
|
|
@@ -1040,10 +1047,12 @@ async function ensureProjectWorktrees(input) {
|
|
|
1040
1047
|
...input,
|
|
1041
1048
|
projectRoot,
|
|
1042
1049
|
defaultBranch,
|
|
1043
|
-
beforeDestructiveMutation:
|
|
1050
|
+
beforeDestructiveMutation: async (branchNames) => {
|
|
1051
|
+
await ensureSnapshot(branchNames);
|
|
1052
|
+
}
|
|
1044
1053
|
}));
|
|
1045
1054
|
} else {
|
|
1046
|
-
ensureSnapshot();
|
|
1055
|
+
await ensureSnapshot();
|
|
1047
1056
|
mirrorHeads = await initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
|
|
1048
1057
|
mutatedBranches = new Set(input.branches.map(({ branchName }) => branchName));
|
|
1049
1058
|
}
|
|
@@ -1051,17 +1060,12 @@ async function ensureProjectWorktrees(input) {
|
|
|
1051
1060
|
if (mutatedBranches.size > 0 && !preparedSnapshot) {
|
|
1052
1061
|
throw new Error("Project worktree mutation started without a prepared recovery snapshot");
|
|
1053
1062
|
}
|
|
1054
|
-
|
|
1055
|
-
if (!mutatedBranches.has(branch.branchName))
|
|
1063
|
+
const snapshotMirrors = input.branches.flatMap((branch) => {
|
|
1064
|
+
if (!mutatedBranches.has(branch.branchName)) return [];
|
|
1056
1065
|
const snapshotPath = preparedSnapshot?.paths.get(branch.branchName);
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
targetRoot: branchPath(projectRoot, branch.branchName),
|
|
1061
|
-
sourceMode: "all",
|
|
1062
|
-
deletionMode: "git"
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1066
|
+
return snapshotPath ? [{ snapshotPath, targetPath: branchPath(projectRoot, branch.branchName) }] : [];
|
|
1067
|
+
});
|
|
1068
|
+
if (snapshotMirrors.length > 0) await workspaceFilesystemExecutor().run("project_snapshot_apply", { mirrors: snapshotMirrors });
|
|
1065
1069
|
const states = [];
|
|
1066
1070
|
for (const branch of input.branches) {
|
|
1067
1071
|
const checkoutPath = branchPath(projectRoot, branch.branchName);
|
|
@@ -1077,24 +1081,23 @@ async function ensureProjectWorktrees(input) {
|
|
|
1077
1081
|
states.sort((left, right) => left.branchName.localeCompare(right.branchName));
|
|
1078
1082
|
if (snapshot) {
|
|
1079
1083
|
const primaryGitDirectory = path.join(branchPath(projectRoot, input.primaryBranchName), ".git");
|
|
1080
|
-
|
|
1084
|
+
await workspaceFilesystemExecutor().run("project_snapshot_persist", {
|
|
1081
1085
|
snapshot,
|
|
1082
|
-
|
|
1083
|
-
snapshot.manifest.scope === "branches" ? [
|
|
1086
|
+
durabilityPaths: snapshot.manifest.scope === "branches" ? [
|
|
1084
1087
|
...snapshot.manifest.branches.map(({ branchName }) => branchPath(projectRoot, branchName)),
|
|
1085
1088
|
// Branch refs and worktree registrations moved in the shared
|
|
1086
1089
|
// administrative directory; objects are fsynced by git itself.
|
|
1087
1090
|
path.join(primaryGitDirectory, "refs"),
|
|
1088
1091
|
path.join(primaryGitDirectory, "packed-refs"),
|
|
1089
1092
|
path.join(primaryGitDirectory, "worktrees")
|
|
1090
|
-
] :
|
|
1091
|
-
);
|
|
1093
|
+
] : null
|
|
1094
|
+
});
|
|
1092
1095
|
}
|
|
1093
1096
|
return states;
|
|
1094
1097
|
} catch (error) {
|
|
1095
1098
|
if (!snapshot) throw error;
|
|
1096
1099
|
try {
|
|
1097
|
-
|
|
1100
|
+
await workspaceFilesystemExecutor().run("project_snapshot_rollback", { snapshot });
|
|
1098
1101
|
} catch (recoveryError) {
|
|
1099
1102
|
throw new Error(
|
|
1100
1103
|
`Project worktree reconciliation failed and its snapshot could not be fully restored; recovery state is retained at ${snapshot.root}`,
|
|
@@ -1225,7 +1228,7 @@ function createLinkedProjectBranch(input) {
|
|
|
1225
1228
|
throw error;
|
|
1226
1229
|
}
|
|
1227
1230
|
}
|
|
1228
|
-
function createOrRetryLinkedProjectBranch(input) {
|
|
1231
|
+
async function createOrRetryLinkedProjectBranch(input) {
|
|
1229
1232
|
if (input.pendingRetry) {
|
|
1230
1233
|
deleteLinkedProjectBranch({
|
|
1231
1234
|
projectRoot: input.projectRoot,
|
|
@@ -1233,7 +1236,12 @@ function createOrRetryLinkedProjectBranch(input) {
|
|
|
1233
1236
|
branchName: input.branchName
|
|
1234
1237
|
});
|
|
1235
1238
|
}
|
|
1236
|
-
return
|
|
1239
|
+
return await workspaceFilesystemExecutor().run("project_branch_create", {
|
|
1240
|
+
projectRoot: input.projectRoot,
|
|
1241
|
+
sourceBranchName: input.sourceBranchName,
|
|
1242
|
+
branchName: input.branchName,
|
|
1243
|
+
workingTree: input.workingTree
|
|
1244
|
+
});
|
|
1237
1245
|
}
|
|
1238
1246
|
function classifyProjectCheckout(input) {
|
|
1239
1247
|
const checkoutPath = branchPath(input.projectRoot, input.branchName);
|
|
@@ -1327,7 +1335,8 @@ function removeProjectWorktrees(input) {
|
|
|
1327
1335
|
throw new Error(`Project branch ${branchName} has an in-progress Git operation`);
|
|
1328
1336
|
}
|
|
1329
1337
|
}
|
|
1330
|
-
fs.
|
|
1338
|
+
if (!fs.existsSync(projectRoot)) return {};
|
|
1339
|
+
return { stagedForCollectionPath: stageProjectForDeletion(input.projectsRoot, projectRoot) };
|
|
1331
1340
|
}
|
|
1332
1341
|
async function deleteProjectMirrorBranch(input) {
|
|
1333
1342
|
validateManagedBranchName(input.branchName);
|
|
@@ -1570,11 +1579,37 @@ async function reseedProjectHeadsFromMirror(input) {
|
|
|
1570
1579
|
reseeded: moved
|
|
1571
1580
|
}));
|
|
1572
1581
|
}
|
|
1582
|
+
function runProjectSnapshotCreateJob(input, context) {
|
|
1583
|
+
return snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, context.progress, input.scope, input.ownerSessionId);
|
|
1584
|
+
}
|
|
1585
|
+
function runProjectSnapshotApplyJob(input) {
|
|
1586
|
+
for (const { snapshotPath, targetPath } of input.mirrors) {
|
|
1587
|
+
mirrorWorkingTree({ sourceRoot: snapshotPath, targetRoot: targetPath, sourceMode: "all", deletionMode: "git" });
|
|
1588
|
+
}
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1591
|
+
function runProjectSnapshotPersistJob(input) {
|
|
1592
|
+
persistReconciledProjectAndConsumeSnapshot(input.snapshot, void 0, input.durabilityPaths ?? void 0);
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
function runProjectSnapshotRollbackJob(input) {
|
|
1596
|
+
rollbackProjectWorktreeSnapshot(input.snapshot);
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1573
1599
|
const projectWorktreesTestHarness = {
|
|
1574
1600
|
commandArgs: gitCommandArgs,
|
|
1575
1601
|
configureRepository,
|
|
1576
1602
|
createPreparedSnapshot(input) {
|
|
1577
|
-
return {
|
|
1603
|
+
return {
|
|
1604
|
+
root: snapshotBranchTrees(
|
|
1605
|
+
input.projectRoot,
|
|
1606
|
+
input.branches,
|
|
1607
|
+
input.temporaryRoot,
|
|
1608
|
+
void 0,
|
|
1609
|
+
input.scope ?? "project",
|
|
1610
|
+
PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID
|
|
1611
|
+
).root
|
|
1612
|
+
};
|
|
1578
1613
|
},
|
|
1579
1614
|
consumeSnapshot(snapshotRoot) {
|
|
1580
1615
|
const manifest = readSnapshotManifest(snapshotRoot);
|
|
@@ -1590,6 +1625,7 @@ const projectWorktreesTestHarness = {
|
|
|
1590
1625
|
};
|
|
1591
1626
|
export {
|
|
1592
1627
|
PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
|
|
1628
|
+
PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
|
|
1593
1629
|
PROJECT_WORKTREE_SNAPSHOT_PREFIX,
|
|
1594
1630
|
ProjectBranchCreationRollbackIncompleteError,
|
|
1595
1631
|
applyObservedProjectMirrorHeads,
|
|
@@ -1613,5 +1649,9 @@ export {
|
|
|
1613
1649
|
pushProjectMirrorHeads,
|
|
1614
1650
|
recoverStaleProjectWorktreeSnapshots,
|
|
1615
1651
|
removeProjectWorktrees,
|
|
1616
|
-
reseedProjectHeadsFromMirror
|
|
1652
|
+
reseedProjectHeadsFromMirror,
|
|
1653
|
+
runProjectSnapshotApplyJob,
|
|
1654
|
+
runProjectSnapshotCreateJob,
|
|
1655
|
+
runProjectSnapshotPersistJob,
|
|
1656
|
+
runProjectSnapshotRollbackJob
|
|
1617
1657
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const WORKER_RECOVERY_JOURNAL_OPS = [
|
|
2
|
+
"admit",
|
|
3
|
+
"readmitUnknownBranchDeletion",
|
|
4
|
+
"record",
|
|
5
|
+
"unknown",
|
|
6
|
+
"unknownRun",
|
|
7
|
+
"acknowledgeResult",
|
|
8
|
+
"acknowledgeOutput",
|
|
9
|
+
"acknowledgeTerminal",
|
|
10
|
+
"cancelSession",
|
|
11
|
+
"isRequestCancelled",
|
|
12
|
+
"operations",
|
|
13
|
+
"recoveryState",
|
|
14
|
+
"terminals",
|
|
15
|
+
"outputReplayBegin",
|
|
16
|
+
"outputReplayNext",
|
|
17
|
+
"outputReplayEnd"
|
|
18
|
+
];
|
|
19
|
+
function isWorkerRecoveryJournalOp(value) {
|
|
20
|
+
return typeof value === "string" && WORKER_RECOVERY_JOURNAL_OPS.includes(value);
|
|
21
|
+
}
|
|
22
|
+
function serializeWorkerRecoveryJournalError(error) {
|
|
23
|
+
if (!(error instanceof Error)) return { name: "Error", message: String(error) };
|
|
24
|
+
return { name: error.name, message: error.message, ...typeof error.stack === "string" ? { stack: error.stack } : {} };
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
WORKER_RECOVERY_JOURNAL_OPS,
|
|
28
|
+
isWorkerRecoveryJournalOp,
|
|
29
|
+
serializeWorkerRecoveryJournalError
|
|
30
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isWorkerRecoveryJournalOp,
|
|
3
|
+
serializeWorkerRecoveryJournalError
|
|
4
|
+
} from "./recovery-journal-protocol.mjs";
|
|
5
|
+
import { WorkerRecoveryStore } from "./recovery-store.mjs";
|
|
6
|
+
function serveRecoveryJournal(port, filename, hooks = {}) {
|
|
7
|
+
const store = new WorkerRecoveryStore(filename);
|
|
8
|
+
const outputReplays = /* @__PURE__ */ new Map();
|
|
9
|
+
let nextReplayId = 1;
|
|
10
|
+
function runOp(op, args) {
|
|
11
|
+
switch (op) {
|
|
12
|
+
case "admit": {
|
|
13
|
+
const message = args[0];
|
|
14
|
+
const admission = store.admit(message);
|
|
15
|
+
return {
|
|
16
|
+
admission,
|
|
17
|
+
response: admission === "new" ? void 0 : store.response(message.requestId),
|
|
18
|
+
cancelled: store.isRequestCancelled(message.requestId)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
case "readmitUnknownBranchDeletion":
|
|
22
|
+
return store.readmitUnknownBranchDeletion(args[0]);
|
|
23
|
+
case "record": {
|
|
24
|
+
const message = args[0];
|
|
25
|
+
if (typeof message.requestId === "string" && store.isUnknown(message.requestId)) return { suppressed: true, recorded: message };
|
|
26
|
+
return { suppressed: false, recorded: store.record(message) };
|
|
27
|
+
}
|
|
28
|
+
case "unknown":
|
|
29
|
+
store.unknown(args[0]);
|
|
30
|
+
return null;
|
|
31
|
+
case "unknownRun":
|
|
32
|
+
store.unknownRun(args[0]);
|
|
33
|
+
return null;
|
|
34
|
+
case "acknowledgeResult":
|
|
35
|
+
store.acknowledgeResult(args[0]);
|
|
36
|
+
return null;
|
|
37
|
+
case "acknowledgeOutput":
|
|
38
|
+
store.acknowledgeOutput(args[0], args[1], args[2]);
|
|
39
|
+
return null;
|
|
40
|
+
case "acknowledgeTerminal":
|
|
41
|
+
store.acknowledgeTerminal(args[0]);
|
|
42
|
+
return null;
|
|
43
|
+
case "cancelSession":
|
|
44
|
+
store.cancelSession(args[0]);
|
|
45
|
+
return null;
|
|
46
|
+
case "isRequestCancelled":
|
|
47
|
+
return store.isRequestCancelled(args[0]);
|
|
48
|
+
case "operations":
|
|
49
|
+
return store.operations();
|
|
50
|
+
case "recoveryState": {
|
|
51
|
+
const requests = args[0];
|
|
52
|
+
return {
|
|
53
|
+
operations: store.operations(),
|
|
54
|
+
cursors: store.outputCursors(),
|
|
55
|
+
responses: store.responses(requests),
|
|
56
|
+
terminals: store.terminals()
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case "terminals":
|
|
60
|
+
return store.terminals();
|
|
61
|
+
case "outputReplayBegin": {
|
|
62
|
+
const replayId = nextReplayId++;
|
|
63
|
+
outputReplays.set(replayId, store.output(args[0]));
|
|
64
|
+
return replayId;
|
|
65
|
+
}
|
|
66
|
+
case "outputReplayNext": {
|
|
67
|
+
const replay = outputReplays.get(args[0]);
|
|
68
|
+
if (!replay) return { frames: [], done: true };
|
|
69
|
+
const maxFrames = args[1];
|
|
70
|
+
const frames = [];
|
|
71
|
+
while (frames.length < maxFrames) {
|
|
72
|
+
const next = replay.next();
|
|
73
|
+
if (next.done) {
|
|
74
|
+
outputReplays.delete(args[0]);
|
|
75
|
+
return { frames, done: true };
|
|
76
|
+
}
|
|
77
|
+
frames.push(next.value);
|
|
78
|
+
}
|
|
79
|
+
return { frames, done: false };
|
|
80
|
+
}
|
|
81
|
+
case "outputReplayEnd":
|
|
82
|
+
outputReplays.delete(args[0]);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
port.on("message", (message) => {
|
|
87
|
+
if (!message || typeof message !== "object") return;
|
|
88
|
+
const request = message;
|
|
89
|
+
if (request.type !== "batch" || typeof request.id !== "number" || !Array.isArray(request.entries)) return;
|
|
90
|
+
const entries = request.entries;
|
|
91
|
+
try {
|
|
92
|
+
hooks.beforeBatch?.();
|
|
93
|
+
const results = store.batch(
|
|
94
|
+
() => entries.map((entry) => {
|
|
95
|
+
if (!isWorkerRecoveryJournalOp(entry.op)) throw new Error(`Unknown recovery journal operation: ${String(entry.op)}`);
|
|
96
|
+
return { seq: entry.seq, value: runOp(entry.op, entry.args) };
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
port.postMessage({ type: "batch_result", id: request.id, results });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
port.postMessage({
|
|
102
|
+
type: "batch_failure",
|
|
103
|
+
id: request.id,
|
|
104
|
+
error: serializeWorkerRecoveryJournalError(error)
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
port.postMessage({ type: "ready", ledgerId: store.ledgerId });
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
serveRecoveryJournal
|
|
112
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { serveRecoveryJournal } from "./recovery-journal-runtime.mjs";
|
|
3
|
+
if (isMainThread || !parentPort) {
|
|
4
|
+
throw new Error("The recovery journal thread must be started by the worker runtime");
|
|
5
|
+
}
|
|
6
|
+
const data = workerData;
|
|
7
|
+
if (!data || typeof data.filename !== "string" || !data.filename) throw new Error("The recovery journal thread needs a store filename");
|
|
8
|
+
serveRecoveryJournal(parentPort, data.filename);
|