@ricsam/r5d-worker 0.0.131 → 0.0.133

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.
Files changed (47) hide show
  1. package/dist/cjs/atomic-rename.cjs +303 -0
  2. package/dist/cjs/git-blob-hash.cjs +41 -0
  3. package/dist/cjs/main.cjs +187 -38
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/three-way-merge.cjs +346 -0
  6. package/dist/cjs/working-tree-mirror.cjs +1049 -64
  7. package/dist/cjs/workspace-command-sync-policy.cjs +8 -4
  8. package/dist/cjs/workspace-command-targets.cjs +63 -0
  9. package/dist/cjs/workspace-filesystem-job-types.cjs +11 -1
  10. package/dist/cjs/workspace-filesystem-jobs.cjs +2 -0
  11. package/dist/cjs/workspace-git-sync.cjs +846 -61
  12. package/dist/cjs/workspace-hydration-ledger.cjs +66 -0
  13. package/dist/cjs/workspace-hydration-merge.cjs +433 -0
  14. package/dist/cjs/workspace-hydration-recovery-state.cjs +53 -0
  15. package/dist/cjs/workspace-merge-projection.cjs +81 -10
  16. package/dist/cjs/workspace-project-config-policy.cjs +19 -12
  17. package/dist/mjs/atomic-rename.mjs +261 -0
  18. package/dist/mjs/git-blob-hash.mjs +16 -0
  19. package/dist/mjs/main.mjs +196 -39
  20. package/dist/mjs/package.json +1 -1
  21. package/dist/mjs/three-way-merge.mjs +318 -0
  22. package/dist/mjs/working-tree-mirror.mjs +1035 -64
  23. package/dist/mjs/workspace-command-sync-policy.mjs +8 -4
  24. package/dist/mjs/workspace-command-targets.mjs +37 -0
  25. package/dist/mjs/workspace-filesystem-job-types.mjs +11 -1
  26. package/dist/mjs/workspace-filesystem-jobs.mjs +4 -0
  27. package/dist/mjs/workspace-git-sync.mjs +854 -62
  28. package/dist/mjs/workspace-hydration-ledger.mjs +42 -0
  29. package/dist/mjs/workspace-hydration-merge.mjs +399 -0
  30. package/dist/mjs/workspace-hydration-recovery-state.mjs +29 -0
  31. package/dist/mjs/workspace-merge-projection.mjs +85 -11
  32. package/dist/mjs/workspace-project-config-policy.mjs +16 -10
  33. package/dist/types/atomic-rename.d.ts +78 -0
  34. package/dist/types/git-blob-hash.d.ts +10 -0
  35. package/dist/types/main.d.ts +21 -2
  36. package/dist/types/three-way-merge.d.ts +77 -0
  37. package/dist/types/working-tree-mirror.d.ts +270 -7
  38. package/dist/types/workspace-command-sync-policy.d.ts +12 -6
  39. package/dist/types/workspace-command-targets.d.ts +37 -0
  40. package/dist/types/workspace-filesystem-job-types.d.ts +46 -4
  41. package/dist/types/workspace-git-sync.d.ts +125 -3
  42. package/dist/types/workspace-hydration-ledger.d.ts +43 -0
  43. package/dist/types/workspace-hydration-merge.d.ts +95 -0
  44. package/dist/types/workspace-hydration-recovery-state.d.ts +10 -0
  45. package/dist/types/workspace-merge-projection.d.ts +19 -1
  46. package/dist/types/workspace-project-config-policy.d.ts +17 -3
  47. package/package.json +2 -2
@@ -1,21 +1,27 @@
1
1
  function deferredProjectConfigurationPendingBranches(projects) {
2
- return projects.flatMap((project) => project.branches.map(({ branchName }) => ({ projectId: project.projectId, branchName }))).sort((left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName));
2
+ return projects.filter((project) => !project.executionDisabled).flatMap((project) => project.branches.map(({ branchName }) => ({ projectId: project.projectId, branchName }))).sort((left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName));
3
3
  }
4
- function busyProjectConfigurationChangeIds(input) {
4
+ function busyProjectConfigurationChanges(input) {
5
5
  const currentById = new Map(input.currentProjects.map((project) => [project.projectId, project]));
6
6
  const incomingById = new Map(input.incomingProjects.map((project) => [project.projectId, project]));
7
7
  const targets = [...input.activeTargets];
8
8
  const busyProjectIds = targets.some((target) => target.type === "workspace" && target.rootProfile === "visible_projects") ? /* @__PURE__ */ new Set([...currentById.keys(), ...incomingById.keys()]) : new Set(targets.flatMap((target) => target.type === "project" ? [target.projectId] : []));
9
- return [...busyProjectIds].filter((projectId) => {
9
+ return [...busyProjectIds].flatMap((projectId) => {
10
10
  const current = currentById.get(projectId);
11
11
  const incoming = incomingById.get(projectId);
12
- if (!current && !incoming) return false;
13
- if (!current || !incoming) return true;
14
- if (!input.currentProjectReady(current)) return true;
15
- return input.currentFingerprint(current) !== input.incomingFingerprint(incoming);
16
- }).sort();
12
+ if (!current && !incoming) return [];
13
+ if (!current) return [{ projectId, reason: "added" }];
14
+ if (!incoming) return [{ projectId, reason: "removed" }];
15
+ if (!input.currentProjectReady(current)) return [{ projectId, reason: "initializing" }];
16
+ if (input.currentFingerprint(current) !== input.incomingFingerprint(incoming)) return [{ projectId, reason: "changed" }];
17
+ return [];
18
+ }).sort((left, right) => left.projectId.localeCompare(right.projectId));
19
+ }
20
+ function describeBusyProjectConfigurationChanges(changes) {
21
+ return changes.map(({ projectId, reason }) => `${projectId} (${reason})`).join(", ");
17
22
  }
18
23
  export {
19
- busyProjectConfigurationChangeIds,
20
- deferredProjectConfigurationPendingBranches
24
+ busyProjectConfigurationChanges,
25
+ deferredProjectConfigurationPendingBranches,
26
+ describeBusyProjectConfigurationChanges
21
27
  };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Atomic directory-entry primitives the working-tree mirror needs beyond
3
+ * `rename(2)`:
4
+ *
5
+ * - exchange: swap two existing entries in one step
6
+ * (`renameat2(RENAME_EXCHANGE)` on Linux, `renamex_np(RENAME_SWAP)` on
7
+ * macOS). After the swap the first path names what the second held and
8
+ * vice versa, so a replacement can inspect exactly the entry it displaced
9
+ * instead of guessing from a stat taken before the rename.
10
+ * - no-replace rename: move an entry only while nothing is at the
11
+ * destination (`RENAME_NOREPLACE` / `RENAME_EXCL`, or `link(2)` plus
12
+ * `unlink(2)` for a regular file where the flag is unavailable).
13
+ *
14
+ * Both are reached through `bun:ffi` against the C library already loaded in
15
+ * the process; nothing is spawned and nothing is compiled. Availability is
16
+ * never assumed: a filesystem can reject a flag (`EINVAL` on NFS), a runtime
17
+ * can lack FFI, and some filesystems accept the exchange flag and then
18
+ * perform a plain replacing rename (observed on a Docker Desktop virtiofs
19
+ * bind mount), which would lose the displaced entry. `atomicRenamePrimitivesFor`
20
+ * therefore probes each filesystem once with two scratch files and hands out
21
+ * primitives whose flags were seen to work there; the caller falls back to a
22
+ * protocol built from plain POSIX calls for anything that was not.
23
+ */
24
+ export type AtomicExchangeBackend = "renameat2" | "renamex_np" | "syscall" | "unavailable";
25
+ export type AtomicNoReplaceBackend = "renameat2" | "renamex_np" | "syscall" | "link";
26
+ export type AtomicRenameSupport = {
27
+ platform: NodeJS.Platform;
28
+ exchange: AtomicExchangeBackend;
29
+ noReplace: AtomicNoReplaceBackend;
30
+ /** Why a native primitive is not used, when it is not. */
31
+ detail?: string;
32
+ };
33
+ /** Failure of one primitive, carrying the POSIX error name the way `fs` errors do. */
34
+ export declare class AtomicRenameError extends Error {
35
+ readonly syscall: string;
36
+ readonly code: string;
37
+ readonly errno: number;
38
+ readonly path: string;
39
+ readonly dest: string;
40
+ constructor(syscall: string, code: string, errno: number, path: string, dest: string);
41
+ }
42
+ export declare function isAtomicRenameUnsupported(error: unknown): boolean;
43
+ /** What this process can do natively, before any filesystem probe. */
44
+ export declare function atomicRenameSupport(): AtomicRenameSupport;
45
+ /**
46
+ * Test seam: pretend a primitive is unavailable so the portable fallback
47
+ * protocols run on a platform that has the native ones. Pass null to clear.
48
+ */
49
+ export declare function overrideAtomicRenameSupportForTests(value: Partial<Pick<AtomicRenameSupport, "exchange" | "noReplace">> | null): void;
50
+ /** Test seam: forget probe results so the next `atomicRenamePrimitivesFor` probes again. */
51
+ export declare function resetAtomicRenameProbesForTests(): void;
52
+ /**
53
+ * Atomically exchange the directory entries `a` and `b` (any kinds, same
54
+ * filesystem) with the process-level primitive, unprobed. Throws
55
+ * AtomicRenameError `ENOENT` when either is missing and an unsupported code
56
+ * (see `isAtomicRenameUnsupported`) when the kernel or filesystem cannot
57
+ * exchange. Prefer `atomicRenamePrimitivesFor` for anything that matters.
58
+ */
59
+ export declare function exchangePaths(a: string, b: string): void;
60
+ /** Move `from` to `to` only if nothing is at `to` (`EEXIST` otherwise), with the process-level primitive, unprobed. */
61
+ export declare function renameNoReplace(from: string, to: string): void;
62
+ /** Primitives bound to what a probe of one filesystem showed to work there. */
63
+ export type AtomicRenamePrimitives = {
64
+ support: AtomicRenameSupport;
65
+ exchange: (a: string, b: string) => void;
66
+ renameNoReplace: (from: string, to: string) => void;
67
+ };
68
+ /**
69
+ * Probe the filesystem holding `directory` (which must be writable) once per
70
+ * device: the exchange flag must actually swap two scratch files (inode
71
+ * numbers exchanged, both still present), and the native no-replace flag
72
+ * must actually refuse an existing destination. A flag that fails, or that
73
+ * succeeds without doing what it promises, is reported as unavailable and
74
+ * the returned primitives use the fallbacks instead.
75
+ */
76
+ export declare function probeAtomicRenameSupport(directory: string): AtomicRenameSupport;
77
+ /** Primitives for the filesystem holding `directory`, probed on first use per device (see `probeAtomicRenameSupport`). */
78
+ export declare function atomicRenamePrimitivesFor(directory: string): AtomicRenamePrimitives;
@@ -0,0 +1,10 @@
1
+ export type GitObjectHashAlgorithm = "sha1" | "sha256";
2
+ /** The object format an object id of this length belongs to, or null for anything that is not an object id. */
3
+ export declare function gitObjectHashAlgorithmFor(objectId: string): GitObjectHashAlgorithm | null;
4
+ /**
5
+ * The id `git hash-object` gives `content` as a blob: the hash of the
6
+ * `blob <size>\0` header followed by the bytes, with no filter applied. The
7
+ * sync engine hashes checkout bytes with it to ask whether a file still holds
8
+ * what a commit records without spawning git.
9
+ */
10
+ export declare function gitBlobHash(content: Buffer, algorithm: GitObjectHashAlgorithm): string;
@@ -6,7 +6,9 @@ import { Database } from "bun:sqlite";
6
6
  import type { WorkerGitIdentity } from "./git-identity";
7
7
  import { configureGitHubRegistryAuthFiles, type PreparedPrivateAuthFileGeneration } from "./registry-auth";
8
8
  import { WorkspaceMountHoldFence } from "./workspace-mount-hold-fence";
9
- import { type WorkspaceHydrationTransactionHooks } from "./workspace-git-sync";
9
+ import { type WorkspaceCommandProjectTarget } from "./workspace-command-targets";
10
+ import { type WorkerHydrationRecovery } from "./workspace-hydration-recovery-state";
11
+ import { type WorkspaceHydrationTransactionHooks, type WorkspaceGitSyncResult } from "./workspace-git-sync";
10
12
  type WorkerProjectConfig = {
11
13
  projectId: string;
12
14
  checkoutPathSegments: [namespace: string, project: string];
@@ -86,6 +88,8 @@ type WorkspaceSyncTrigger = {
86
88
  detail?: string;
87
89
  };
88
90
  type WorkspaceSyncResult = {
91
+ hydrationRecovery?: WorkerHydrationRecovery;
92
+ mountSkipReasons?: WorkspaceGitSyncResult["mountSkipReasons"];
89
93
  type: "workspace_sync";
90
94
  attemptId: string;
91
95
  workerLabel: string;
@@ -129,7 +133,7 @@ type WorkspaceSyncResult = {
129
133
  };
130
134
  error?: string;
131
135
  /** Typed reason for a failed outcome the server should surface loudly. */
132
- failureReason?: "unsafe_hydration";
136
+ failureReason?: "unsafe_hydration" | "hydration_recovery_required";
133
137
  /** Consecutive in-process recovery bounces taken for unsafe hydration failures. */
134
138
  recoveryAttempt?: number;
135
139
  };
@@ -284,6 +288,12 @@ type WorkerClientMessage = WorkerRecoveryClientMessage | {
284
288
  }>;
285
289
  aheadOfOriginBranches: WorkerAheadOfOriginBranch[];
286
290
  deferredWorkspaceSyncForIncidentId?: string;
291
+ /**
292
+ * The configuration was not applied because these projects have visible
293
+ * checkout activity; every executable checkout above is pending and the
294
+ * previous generation stays on disk until a retry lands on idle checkouts.
295
+ */
296
+ deferredForBusyProjectIds?: string[];
287
297
  } | {
288
298
  type: "workspace_sync_started";
289
299
  attemptId: string;
@@ -309,6 +319,7 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
309
319
  workerId: string;
310
320
  } | {
311
321
  type: "workspace_config";
322
+ recoverHydrationTransactionId?: string;
312
323
  requestId: string;
313
324
  projects: WorkerProjectConfig[];
314
325
  workspaceRemoteUrl: string;
@@ -403,6 +414,8 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
403
414
  requestId: string;
404
415
  runId: string;
405
416
  target: WorkerSessionTarget;
417
+ /** Sibling checkouts fenced for the command's lifetime alongside `target`; project branches only. */
418
+ additionalTargets?: WorkspaceCommandProjectTarget[];
406
419
  sessionId?: string;
407
420
  argv: string[];
408
421
  cwd?: string;
@@ -414,6 +427,8 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
414
427
  requestId: string;
415
428
  runId: string;
416
429
  target: WorkerSessionTarget;
430
+ /** Fixed at admission; kept until the process exits, whatever happens to the claim that produced it. */
431
+ additionalTargets?: WorkspaceCommandProjectTarget[];
417
432
  sessionId: string;
418
433
  argv: string[];
419
434
  command: string;
@@ -442,6 +457,8 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
442
457
  requestId: string;
443
458
  ptyId: string;
444
459
  target: WorkerSessionTarget;
460
+ /** Fixed when the shell opens; kept until the PTY exits. */
461
+ additionalTargets?: WorkspaceCommandProjectTarget[];
445
462
  cols: number;
446
463
  rows: number;
447
464
  command?: string;
@@ -680,6 +697,8 @@ type WorkerPty = {
680
697
  };
681
698
  type ActiveWorkerPty = WorkerPty & {
682
699
  target: WorkerSessionTarget;
700
+ /** Declared sibling checkouts, fenced like `target` while the PTY is busy. */
701
+ additionalTargets: readonly WorkerSessionTarget[];
683
702
  foregroundBusy: boolean;
684
703
  lastInputAt: number;
685
704
  releaseWorkspaceMutation?: () => void;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pure three-way line merge (classic diff3) for merge hydration.
3
+ *
4
+ * The verdict is deliberately conservative: a false conflict only pins a
5
+ * mount for a cycle, while a false clean silently loses one side's edit. Git
6
+ * picks one of possibly many minimal diffs per side and its verdict depends on
7
+ * which, so instead of trusting one alignment this merge only treats a base
8
+ * line as stable when every minimal diff agrees on it, and only accepts an
9
+ * identical change from both sides when its alignment is unique. That keeps
10
+ * the clean verdict a subset of `git merge-file`'s for every minimal diff Git
11
+ * could choose, and clean output byte-identical to `git merge-file -p ours
12
+ * base theirs`; the differential test enforces both.
13
+ */
14
+ export type ThreeWayMergeOptions = {
15
+ /** Sides with more lines than this are not merged (conflict). */
16
+ maxLines?: number;
17
+ /** Two-way diffs with more line edits than this are not merged (conflict); bounds the O((N+M)D) diff. */
18
+ maxEditDistance?: number;
19
+ /** Bound on the banded (lines × edits) alignment analysis per side; beyond it the merge conflicts. */
20
+ maxAlignmentCells?: number;
21
+ /** Bound on the diagonal visits one two-way diff may spend; beyond it the merge conflicts. */
22
+ maxDiffWork?: number;
23
+ };
24
+ export type ThreeWayMergeResult = {
25
+ kind: "clean";
26
+ content: Buffer;
27
+ } | {
28
+ kind: "conflict";
29
+ conflicts: number;
30
+ };
31
+ /** A changed region of a two-way diff: `a[aStart, aEnd)` was replaced by `b[bStart, bEnd)`. */
32
+ export type DiffHunk = {
33
+ aStart: number;
34
+ aEnd: number;
35
+ bStart: number;
36
+ bEnd: number;
37
+ };
38
+ /** What every minimal diff of a side against the base agrees on, per base line. */
39
+ export type SideAlignment = {
40
+ /** Base line -> the side line it is matched to in every minimal diff, or -1. */
41
+ certain: Int32Array;
42
+ /** 1 where minimal diffs disagree: matched to different side lines, or matched in some and deleted in others. */
43
+ ambiguous: Uint8Array;
44
+ };
45
+ type LineSequence = ArrayLike<number>;
46
+ /** Git's `buffer_is_binary`: a NUL byte within the first 8000 bytes. */
47
+ export declare function isBinaryContent(content: Buffer): boolean;
48
+ /**
49
+ * Minimal line diff `a` -> `b` as ordered, non-touching hunks, or null when
50
+ * the edit distance exceeds `maxEditDistance`. Divide and conquer on the
51
+ * middle snake keeps memory linear; the explicit stack keeps deep, lopsided
52
+ * splits off the call stack. The merge only needs the edit distance from it,
53
+ * the hunks are one of the minimal diffs.
54
+ */
55
+ export declare function diffLines(a: LineSequence, b: LineSequence, options?: {
56
+ maxEditDistance?: number;
57
+ maxWork?: number;
58
+ }): DiffHunk[] | null;
59
+ /**
60
+ * Classifies every base line by what all minimal diffs `a` -> `b` agree on,
61
+ * given the length of their common subsequence.
62
+ *
63
+ * A minimal diff crosses from base line i to i + 1 either by deleting line i
64
+ * or by matching it to some b[j]; the crossing is on a minimal diff exactly
65
+ * when prefix LCS + suffix LCS (+ 1 for a match) reaches `lcsLength`. Both
66
+ * tables are only needed on the diagonal band any minimal diff stays within,
67
+ * so the cost is (lines × edits): suffix rows are checkpointed every √N rows
68
+ * and recomputed per block, prefix rows roll forward. Returns null once the
69
+ * band exceeds `maxCells`, which the caller treats as a conflict.
70
+ */
71
+ export declare function analyzeAlignment(a: LineSequence, b: LineSequence, lcsLength: number, maxCells: number): SideAlignment | null;
72
+ export declare function mergeThreeWay(base: Buffer, ours: Buffer, theirs: Buffer, options?: ThreeWayMergeOptions): ThreeWayMergeResult;
73
+ export declare const threeWayMergeTestHarness: {
74
+ diffLines: typeof diffLines;
75
+ analyzeAlignment: typeof analyzeAlignment;
76
+ };
77
+ export {};
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import { type AtomicRenamePrimitives } from "./atomic-rename";
1
3
  export type WorkingTreeSourceMode = "all" | "git";
2
4
  export type WorkingTreeDeletionMode = "all" | "git";
3
5
  export type WorkingTreeMirrorDurability = "per_entry" | "deferred_private_staging";
@@ -50,7 +52,164 @@ export type WorkingTreeInspectionOptions = {
50
52
  * disk at that path. Ignored in Git mode, where the index is read directly.
51
53
  */
52
54
  gitlinks?: ReadonlyMap<string, string>;
55
+ /**
56
+ * Receives the `lstat` of every inspected entry, keyed by relative path.
57
+ * Merge hydration compares these against the stats a later mutation finds
58
+ * on disk, so a write that lands between inspection and mutation is seen.
59
+ */
60
+ stats?: Map<string, fs.Stats>;
61
+ };
62
+ /**
63
+ * What a projection read for one source entry: enough of the `lstat` to tell
64
+ * later whether the same path changed since (a rewrite moves ctime, and a
65
+ * rename-over changes the inode), or the symlink target, or the gitlink
66
+ * pointer. Recorded by the projection mirror and by merge projection and
67
+ * consumed by merge hydration.
68
+ */
69
+ export type ProjectedWorkingTreeEntry = {
70
+ kind: "file";
71
+ size: number;
72
+ mode: number;
73
+ mtimeMs: number;
74
+ ctimeMs: number;
75
+ ino: number;
76
+ } | {
77
+ kind: "symlink";
78
+ target: string;
79
+ } | {
80
+ kind: "gitlink";
81
+ objectId: string;
82
+ };
83
+ /** The stat fields a projection records, from an `lstat` or `fstat` of a regular file. */
84
+ export declare function projectedFileEntry(stat: fs.Stats): Extract<ProjectedWorkingTreeEntry, {
85
+ kind: "file";
86
+ }>;
87
+ /** True when a current `lstat` still describes the projected regular file: same inode, size, mode and timestamps. */
88
+ export declare function projectedFileEntryMatches(entry: Extract<ProjectedWorkingTreeEntry, {
89
+ kind: "file";
90
+ }>, stat: fs.Stats): boolean;
91
+ /** True when two `lstat` results describe the same unchanged entry (kind, inode, size, mode, timestamps). */
92
+ export declare function workingTreeStatsMatch(left: fs.Stats, right: fs.Stats): boolean;
93
+ /** True when two `lstat` results name the same inode. */
94
+ export declare function sameWorkingTreeInode(left: fs.Stats, right: fs.Stats): boolean;
95
+ /**
96
+ * True when the entry a switch displaced is the very inode its expectation
97
+ * described, still holding what that expectation saw: same identity, kind,
98
+ * mode, size and mtime. Its ctime is not compared, because the exchange or
99
+ * rename that moved it aside set the ctime itself; the expectation's ctime
100
+ * was already checked immediately before the switch.
101
+ */
102
+ export declare function displacedEntryMatches(expected: fs.Stats, displaced: fs.Stats): boolean;
103
+ /**
104
+ * An entry a guarded pass displaced and kept, by absolute path, with the
105
+ * `lstat` it had the moment it was displaced. It is the exact inode that was
106
+ * at the path (not a copy), so a rollback can put it back without touching
107
+ * its bytes and so a write through a descriptor opened before the switch is
108
+ * still visible on it when the pass settles.
109
+ */
110
+ export type WorkingTreeRetainedEntry = {
111
+ path: string;
112
+ stat: fs.Stats;
113
+ content?: string;
114
+ };
115
+ /**
116
+ * Another writer's entry a guarded pass displaced and could not put back at
117
+ * its path without displacing yet another write: it is kept where it is and
118
+ * reported instead of being removed. `later_writer`: a newer entry took the
119
+ * path while this one was being restored. `reappeared`: while the path was
120
+ * momentarily empty during a fallback replacement, something was created
121
+ * there; the displaced entry is kept because that creator may have meant to
122
+ * modify it. `unrestorable`: no primitive could restore it (a directory on a
123
+ * filesystem without a native no-replace rename).
124
+ */
125
+ export type WorkingTreeRetainedForeignEntry = {
126
+ relativePath: string;
127
+ path: string;
128
+ reason: "later_writer" | "reappeared" | "unrestorable";
129
+ };
130
+ /**
131
+ * Thrown by a guarded mirror when the target entry it is about to replace,
132
+ * remove or create no longer matches the state observed when the mirror was
133
+ * prepared: something else wrote there in between. The caller rolls the
134
+ * mount back through the journal instead of finishing over the writer. When
135
+ * the pass had to keep a displaced writer's entry aside (see
136
+ * `WorkingTreeRetainedForeignEntry`) the error lists it.
137
+ */
138
+ export declare class WorkingTreeTargetChangedError extends Error {
139
+ readonly relativePath: string;
140
+ readonly retained: WorkingTreeRetainedForeignEntry[];
141
+ constructor(relativePath: string, options?: {
142
+ detail?: string;
143
+ retained?: WorkingTreeRetainedForeignEntry[];
144
+ });
145
+ }
146
+ /**
147
+ * One mutation a guarded mirror made, in order. `replaced` is the entry that
148
+ * was there before (null when the path was created) and `after` the `lstat`
149
+ * of the entry the mirror left behind (null when it removed the path); it is
150
+ * the mirror's own entry, identified by inode, never a later writer's.
151
+ * `displaced` is the replaced or removed entry itself, retained until the
152
+ * pass settles or its rollback finishes. A rollback undoes an entry only
153
+ * while the path still shows `after`; anything else means another writer
154
+ * came later and keeps what it wrote.
155
+ */
156
+ export type WorkingTreeMirrorJournalEntry = {
157
+ relativePath: string;
158
+ replaced: TreeEntry | null;
159
+ after: fs.Stats | null;
160
+ /** Immutable bytes owned by the pass, never inferred from the visible path after publication. */
161
+ afterContent?: string;
162
+ displaced?: WorkingTreeRetainedEntry;
163
+ };
164
+ export type WorkingTreeMirrorJournal = {
165
+ entries: WorkingTreeMirrorJournalEntry[];
166
+ /** The Git index changes the pass applied (its last step); a rollback puts them back. */
167
+ indexApplied?: WorkingTreeIndexChanges;
168
+ /** Writers' entries the pass or its rollback displaced and kept aside; never removed by the mirror. */
169
+ retained?: WorkingTreeRetainedForeignEntry[];
170
+ };
171
+ /** Content a guarded mirror writes at a path instead of copying the source entry (a merged file). */
172
+ export type WorkingTreeMirrorOverride = {
173
+ content: Buffer;
174
+ mode: number;
175
+ };
176
+ export type WorkingTreeMirrorGuards = {
177
+ /**
178
+ * The target `lstat` each path must still show immediately before it is
179
+ * mutated (null: the path must still be absent). Paths not listed are
180
+ * switched against whatever they hold at that moment. A mismatch throws
181
+ * WorkingTreeTargetChangedError before anything is written at that path.
182
+ * The pass updates an entry once it has satisfied it (a removal it made
183
+ * itself leaves the path expected absent), so a path mutated in two steps
184
+ * is checked against the pass's own intermediate state.
185
+ */
186
+ expectations?: Map<string, fs.Stats | null>;
187
+ /** Known pre-hydration Git blob IDs, when the merge decision already read the bytes. */
188
+ preBlobs?: ReadonlyMap<string, string>;
189
+ /** Merged bytes written in place of the source entry's bytes. */
190
+ overrides?: ReadonlyMap<string, WorkingTreeMirrorOverride>;
191
+ /** Every mutation is appended here for `rollbackWorkingTreeMirrorJournal`. */
192
+ journal?: WorkingTreeMirrorJournal;
193
+ };
194
+ /**
195
+ * Points in a guarded pass at which another writer could interleave, exposed
196
+ * so tests can place a deterministic write there. `before_switch` and
197
+ * `before_remove` fire after the path's expectation was verified and
198
+ * immediately before the exchange, rename or unlink; `after_switch` and
199
+ * `after_remove` immediately after it, before the displaced entry is
200
+ * inspected; `before_yield` before a mismatched displaced entry is put back;
201
+ * `before_settle` before the pass re-inspects its retained entries; the
202
+ * rollback variants bracket the rollback's own switches. Never set in
203
+ * production.
204
+ */
205
+ export type WorkingTreeMirrorRacePhase = "before_switch" | "after_switch" | "before_yield" | "before_remove" | "after_remove" | "before_settle" | "before_rollback_switch" | "before_rollback_remove";
206
+ export type WorkingTreeMirrorRaceEvent = {
207
+ phase: WorkingTreeMirrorRacePhase;
208
+ relativePath: string;
209
+ targetPath: string;
53
210
  };
211
+ /** Test seam for deterministic race regressions; pass null to clear. */
212
+ export declare function setWorkingTreeMirrorRaceHook(hook: ((event: WorkingTreeMirrorRaceEvent) => void) | null): void;
54
213
  /**
55
214
  * Destructive native-tree moves cannot silently apply the Git-target filter:
56
215
  * doing so would delete legitimate case-sensitive POSIX filenames after the
@@ -58,20 +217,97 @@ export type WorkingTreeInspectionOptions = {
58
217
  * every portable alias must be remediated before the old tree is retired.
59
218
  */
60
219
  export declare function assertWorkingTreeHasNoPortableGitMetadataAliases(root: string): void;
220
+ type RelativePathInspection = {
221
+ kind: "entry";
222
+ absolutePath: string;
223
+ stat: fs.Stats;
224
+ } | {
225
+ kind: "missing";
226
+ } | {
227
+ kind: "blocked";
228
+ blockingPath: string;
229
+ };
230
+ export type WorkingTreePathInspection = RelativePathInspection;
231
+ /** `lstat` a target-relative path without following any ancestor symlink; see the mirror's own traversal rules. */
232
+ export declare function inspectWorkingTreePath(root: string, relativePath: string): WorkingTreePathInspection;
61
233
  export declare function inspectWorkingTree(root: string, mode: WorkingTreeSourceMode, options?: WorkingTreeInspectionOptions): Map<string, TreeEntry>;
62
- export declare function mirrorWorkingTree(input: {
234
+ /** The primitives a guarded pass or rollback on `targetRoot` would use, after probing its filesystem. */
235
+ export declare function workingTreeAtomicRenameSupport(targetRoot: string): AtomicRenamePrimitives["support"];
236
+ /**
237
+ * Where a guarded pass on `targetRoot` keeps staged and displaced entries.
238
+ * Entries left there after a crash are evidence for recovery: each name ends
239
+ * with the percent-encoded target-relative path of the entry it held.
240
+ */
241
+ export declare function workingTreeDisplacedRetentionDirectory(targetRoot: string): string | null;
242
+ type MirrorTarget = {
243
+ existing: Map<string, TreeEntry>;
244
+ /** Present for Git targets: their index and the submodule directories a mirror must leave alone. */
245
+ git: {
246
+ indexRecords: WorkingTreeIndexRecord[];
247
+ submodules: Map<string, string | null>;
248
+ /** Submodule directories that hold anything at all; never removed, and their index entries stay. */
249
+ occupied: Set<string>;
250
+ } | null;
251
+ };
252
+ export type WorkingTreeMirrorInput = {
63
253
  sourceRoot: string;
64
254
  targetRoot: string;
65
255
  sourceMode: WorkingTreeSourceMode;
66
256
  deletionMode: WorkingTreeDeletionMode;
67
257
  /** Gitlinks the enclosing repository records for an all-mode source (see `inspectWorkingTree`). */
68
258
  sourceGitlinks?: ReadonlyMap<string, string>;
259
+ };
260
+ /**
261
+ * Both trees of a mirror, inspected once. `desired` is every source entry
262
+ * (shielded ones included), `existing` the target's deletion-mode entry set,
263
+ * and `existingStats` the `lstat` of each existing entry as it was seen.
264
+ * Merge hydration decides per path from this and applies through
265
+ * `applyWorkingTreeMirror` with guards that compare against those stats.
266
+ */
267
+ export type PreparedWorkingTreeMirror = {
268
+ sourceRoot: string;
269
+ targetRoot: string;
270
+ deletionMode: WorkingTreeDeletionMode;
271
+ desired: Map<string, TreeEntry>;
272
+ existing: Map<string, TreeEntry>;
273
+ existingStats: Map<string, fs.Stats>;
274
+ git: MirrorTarget["git"];
275
+ shielded: Set<string>;
276
+ };
277
+ export declare function prepareWorkingTreeMirror(input: WorkingTreeMirrorInput): PreparedWorkingTreeMirror;
278
+ /**
279
+ * Restrict what an apply pass touches. Without a selection the mirror makes
280
+ * the target equal to the source (every desired path written, every existing
281
+ * path outside the desired set removed). With one, only `write` paths are
282
+ * copied (or replaced by an override), only `remove` paths are removed, and
283
+ * `desiredForIndex` is the tree the Git index is reconciled to; every other
284
+ * path is left exactly as it is. Desired directories are still ensured.
285
+ */
286
+ export type WorkingTreeMirrorSelection = {
287
+ write: ReadonlySet<string>;
288
+ remove: ReadonlySet<string>;
289
+ desiredForIndex: ReadonlyMap<string, TreeEntry>;
290
+ };
291
+ export type WorkingTreeMirrorApplyOptions = {
292
+ durability?: WorkingTreeMirrorDurability;
293
+ guards?: WorkingTreeMirrorGuards;
294
+ selection?: WorkingTreeMirrorSelection;
295
+ /** Collect the projection record of every copied or verified source entry. */
296
+ projected?: Map<string, ProjectedWorkingTreeEntry>;
297
+ };
298
+ export declare function applyWorkingTreeMirror(prepared: PreparedWorkingTreeMirror, options?: WorkingTreeMirrorApplyOptions): {
299
+ paths: string[];
300
+ gitlinks: WorkingTreeGitlink[];
301
+ };
302
+ export declare function mirrorWorkingTree(input: WorkingTreeMirrorInput & {
69
303
  /**
70
304
  * Defer destination fsync only while building an unpublished private tree.
71
305
  * The caller must durably fsync that complete tree before publishing it or
72
306
  * mutating the source authority.
73
307
  */
74
308
  durability?: WorkingTreeMirrorDurability;
309
+ /** Collect the projection record of every copied or verified source entry. */
310
+ projected?: Map<string, ProjectedWorkingTreeEntry>;
75
311
  }): {
76
312
  paths: string[];
77
313
  gitlinks: WorkingTreeGitlink[];
@@ -107,13 +343,39 @@ export type WorkingTreeMirrorPlan = {
107
343
  * target's ignored trees appear only where a desired path lands on them, and
108
344
  * its submodule directories only as index changes.
109
345
  */
110
- export declare function planWorkingTreeMirror(input: {
111
- sourceRoot: string;
112
- targetRoot: string;
113
- sourceMode: WorkingTreeSourceMode;
114
- deletionMode: WorkingTreeDeletionMode;
115
- sourceGitlinks?: ReadonlyMap<string, string>;
346
+ export declare function planWorkingTreeMirror(input: WorkingTreeMirrorInput): WorkingTreeMirrorPlan;
347
+ /**
348
+ * The plan for an apply pass over `prepared`, optionally restricted to a
349
+ * selection (see `applyWorkingTreeMirror`): with one, only the selected
350
+ * removals and writes count, an override always replaces its target entry,
351
+ * and `desired` lists the selected writes plus the desired directories.
352
+ */
353
+ export declare function planPreparedWorkingTreeMirror(prepared: PreparedWorkingTreeMirror, options?: {
354
+ selection?: WorkingTreeMirrorSelection;
355
+ overrides?: ReadonlyMap<string, WorkingTreeMirrorOverride>;
116
356
  }): WorkingTreeMirrorPlan;
357
+ /**
358
+ * Undo the mutations a guarded apply pass journaled, in reverse order. A
359
+ * replaced or removed entry the pass still retains (its very inode) is
360
+ * switched back into place; one already released is rebuilt from the
361
+ * snapshot `captureWorkingTreeMirrorPlan` took of the plan's replaced
362
+ * entries. Every restore is itself a guarded switch: an entry is undone only
363
+ * while the path still shows exactly what the mirror left there (or is still
364
+ * absent where the mirror removed it), verified by the switch as it happens;
365
+ * a path that another writer has touched since is left alone and reported in
366
+ * `abandoned`. Directories the mirror created are removed only when empty.
367
+ * Returns the target-relative paths it touched for the durability barrier,
368
+ * and, when there are any, the writer's entries it had to keep aside.
369
+ */
370
+ export declare function rollbackWorkingTreeMirrorJournal(input: {
371
+ journal: WorkingTreeMirrorJournal;
372
+ targetRoot: string;
373
+ snapshotRoot: string;
374
+ }): {
375
+ paths: string[];
376
+ abandoned: string[];
377
+ retained?: WorkingTreeRetainedForeignEntry[];
378
+ };
117
379
  export type WorkingTreeMirrorScope = {
118
380
  /** Desired paths that did not exist before the mirror ran; a restore removes them. */
119
381
  absent: string[];
@@ -159,3 +421,4 @@ export declare function restoreWorkingTreeMirrorScope(input: {
159
421
  * symlinks. Nothing outside the listed paths is read.
160
422
  */
161
423
  export declare function fsyncWorkingTreePaths(root: string, relativePaths: Iterable<string>): void;
424
+ export {};
@@ -13,12 +13,12 @@ type WorkspaceCommandMutationCoordinator = {
13
13
  export type WorkspaceSyncCompletionBarrier = {
14
14
  afterCurrent(): Promise<void>;
15
15
  /**
16
- * Null when no workspace filesystem job holds the target's mount right now;
17
- * otherwise a promise that settles once the current holders released it
18
- * (rejecting with WorkspaceMountHoldAbortedError when `signal` aborts
19
- * first). Workspace targets have no mount and always get null.
16
+ * Null when no workspace filesystem job holds any of the targets' mounts
17
+ * right now; otherwise a promise that settles once every current holder
18
+ * released them (rejecting with WorkspaceMountHoldAbortedError when
19
+ * `signal` aborts first). Workspace targets have no mount and never hold.
20
20
  */
21
- mountHold(target: WorkspaceCommandTarget, signal?: AbortSignal): Promise<void> | null;
21
+ mountHold(targets: readonly WorkspaceCommandTarget[], signal?: AbortSignal): Promise<void> | null;
22
22
  };
23
23
  /**
24
24
  * A project command may reserve as soon as no workspace filesystem job holds
@@ -29,8 +29,14 @@ export type WorkspaceSyncCompletionBarrier = {
29
29
  * whole-cycle barrier. Canonical remediation is serialized separately by the
30
30
  * mutation gate. Commands without a workspace effect (incident-9 control
31
31
  * commands among them) never come here and never wait.
32
+ *
33
+ * A command may declare several targets (its own checkout plus the sibling
34
+ * checkouts its session claimed). Every listed mount is reserved together:
35
+ * the reservation happens only in a synchronous stretch in which none of
36
+ * them is held, so a claimed mount that is mid-hydration delays the command
37
+ * exactly as its own mount would.
32
38
  */
33
- export declare function reserveWorkspaceCommandAfterCurrentSync(target: WorkspaceCommandTarget, coordinator: WorkspaceSyncCompletionBarrier, reserve: () => void, options?: {
39
+ export declare function reserveWorkspaceCommandAfterCurrentSync(target: WorkspaceCommandTarget | readonly WorkspaceCommandTarget[], coordinator: WorkspaceSyncCompletionBarrier, reserve: () => void, options?: {
34
40
  signal?: AbortSignal;
35
41
  }): Promise<void>;
36
42
  /**