@ricsam/r5d-worker 0.0.132 → 0.0.134
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/atomic-rename.cjs +303 -0
- package/dist/cjs/git-blob-hash.cjs +41 -0
- package/dist/cjs/main.cjs +187 -38
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/three-way-merge.cjs +346 -0
- package/dist/cjs/working-tree-mirror.cjs +1049 -64
- package/dist/cjs/workspace-command-sync-policy.cjs +8 -4
- package/dist/cjs/workspace-command-targets.cjs +63 -0
- package/dist/cjs/workspace-filesystem-job-types.cjs +11 -1
- package/dist/cjs/workspace-filesystem-jobs.cjs +2 -0
- package/dist/cjs/workspace-git-sync.cjs +846 -61
- package/dist/cjs/workspace-hydration-ledger.cjs +66 -0
- package/dist/cjs/workspace-hydration-merge.cjs +433 -0
- package/dist/cjs/workspace-hydration-recovery-state.cjs +53 -0
- package/dist/cjs/workspace-merge-projection.cjs +81 -10
- package/dist/cjs/workspace-project-config-policy.cjs +19 -12
- package/dist/mjs/atomic-rename.mjs +261 -0
- package/dist/mjs/git-blob-hash.mjs +16 -0
- package/dist/mjs/main.mjs +196 -39
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/three-way-merge.mjs +318 -0
- package/dist/mjs/working-tree-mirror.mjs +1035 -64
- package/dist/mjs/workspace-command-sync-policy.mjs +8 -4
- package/dist/mjs/workspace-command-targets.mjs +37 -0
- package/dist/mjs/workspace-filesystem-job-types.mjs +11 -1
- package/dist/mjs/workspace-filesystem-jobs.mjs +4 -0
- package/dist/mjs/workspace-git-sync.mjs +854 -62
- package/dist/mjs/workspace-hydration-ledger.mjs +42 -0
- package/dist/mjs/workspace-hydration-merge.mjs +399 -0
- package/dist/mjs/workspace-hydration-recovery-state.mjs +29 -0
- package/dist/mjs/workspace-merge-projection.mjs +85 -11
- package/dist/mjs/workspace-project-config-policy.mjs +16 -10
- package/dist/types/atomic-rename.d.ts +78 -0
- package/dist/types/git-blob-hash.d.ts +10 -0
- package/dist/types/main.d.ts +21 -2
- package/dist/types/three-way-merge.d.ts +77 -0
- package/dist/types/working-tree-mirror.d.ts +270 -7
- package/dist/types/workspace-command-sync-policy.d.ts +12 -6
- package/dist/types/workspace-command-targets.d.ts +37 -0
- package/dist/types/workspace-filesystem-job-types.d.ts +46 -4
- package/dist/types/workspace-git-sync.d.ts +125 -3
- package/dist/types/workspace-hydration-ledger.d.ts +43 -0
- package/dist/types/workspace-hydration-merge.d.ts +95 -0
- package/dist/types/workspace-hydration-recovery-state.d.ts +10 -0
- package/dist/types/workspace-merge-projection.d.ts +19 -1
- package/dist/types/workspace-project-config-policy.d.ts +17 -3
- 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
|
|
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].
|
|
9
|
+
return [...busyProjectIds].flatMap((projectId) => {
|
|
10
10
|
const current = currentById.get(projectId);
|
|
11
11
|
const incoming = incomingById.get(projectId);
|
|
12
|
-
if (!current && !incoming) return
|
|
13
|
-
if (!current
|
|
14
|
-
if (!
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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;
|
package/dist/types/main.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
17
|
-
* otherwise a promise that settles once
|
|
18
|
-
* (rejecting with WorkspaceMountHoldAbortedError when
|
|
19
|
-
* first). Workspace targets have no mount and
|
|
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(
|
|
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
|
/**
|