@ricsam/r5d-worker 0.0.101 → 0.0.103

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.
@@ -1,20 +1,28 @@
1
1
  /**
2
- * Decides whether a periodic observation must fetch a project's hidden mirror
3
- * or may reuse the locally cached mirror refs. The server advertises a
4
- * content-derived token per project (hash of the exact ref namespaces the
5
- * observation fetch would transfer); when the advertised token equals the
6
- * token the local refs already cover, the fetch would be a no-op.
2
+ * Decides which part of a project's hidden mirror a periodic observation must
3
+ * fetch, from the tokens the server advertises and the tokens the worker's
4
+ * own mirror refs cover (both computed with `project-mirror-refs-token.ts`).
7
5
  *
8
- * Skipping is fail-open: any missing or mismatched information fetches. The
9
- * backstop bounds the damage of a server-side token bug (for example a token
10
- * computed against the wrong repository path) to one interval; every
11
- * git-transport writer is observed by the server's on-disk recompute, so the
12
- * backstop is insurance against bugs, not against known unobserved writers.
6
+ * Skipping is fail-open: any missing or mismatched information fetches.
7
+ *
8
+ * There is deliberately no timed backstop refetch for matching tokens. The
9
+ * server recomputes every advertised token from the canonical repository on
10
+ * disk on each heartbeat sweep, so a matching token is a fresh statement that
11
+ * the fetch would transfer nothing, whoever moved the refs. The worker
12
+ * records the tokens it covers from its own refs after every fetch and lease
13
+ * push, with the same shared formula, so a skip can only be stale if the
14
+ * canonical repository moved after the sweep that produced the advertisement
15
+ * — which the next sweep corrects, exactly like a tip that lands mid-cycle —
16
+ * or if the formula itself were wrong, which the shared module and its locked
17
+ * vectors guard and a periodic blind fetch would only hide. A project or
18
+ * mount the server advertises no token for fetches every cycle, as before
19
+ * tokens existed.
13
20
  */
14
- export declare const PROJECT_MIRROR_FETCH_BACKSTOP_MS: number;
21
+ /** Why a fetch runs. `fail_closed` is never decided here: reset and remediation observation bypasses the gate. */
22
+ export type ProjectMirrorFetchReason = "skipping_disabled" | "no_advertised_token" | "never_fetched" | "token_changed" | "fail_closed";
15
23
  export type ProjectMirrorFetchDecision = {
16
24
  fetch: true;
17
- reason: "skipping_disabled" | "no_advertised_token" | "never_fetched" | "token_changed" | "backstop_elapsed";
25
+ reason: ProjectMirrorFetchReason;
18
26
  } | {
19
27
  fetch: false;
20
28
  reason: "token_match";
@@ -22,8 +30,55 @@ export type ProjectMirrorFetchDecision = {
22
30
  export declare function projectMirrorFetchDecision(input: {
23
31
  advertisedToken: string | undefined;
24
32
  fetchedToken: string | undefined;
25
- lastFetchAtMs: number | undefined;
26
- nowMs: number;
27
33
  skippingEnabled: boolean;
28
- backstopMs?: number;
29
34
  }): ProjectMirrorFetchDecision;
35
+ export type ProjectMirrorObservationFetchPlan =
36
+ /** Fetch every canonical ref of the project, as observation always did before per-mount tokens. */
37
+ {
38
+ scope: "project";
39
+ reason: ProjectMirrorFetchReason;
40
+ }
41
+ /** Fetch only the named mounts' branch heads and handoff refs. */
42
+ | {
43
+ scope: "mounts";
44
+ mounts: Array<{
45
+ branchName: string;
46
+ reason: ProjectMirrorFetchReason;
47
+ }>;
48
+ }
49
+ /** Every token the observation depends on matches; reuse the local refs. */
50
+ | {
51
+ scope: "none";
52
+ };
53
+ /**
54
+ * Plan one project's observation fetch. With per-mount tokens advertised for
55
+ * the project, each mount decides for itself and only changed mounts fetch;
56
+ * without them (an older server) the project token decides for the whole
57
+ * mirror.
58
+ */
59
+ export declare function planProjectMirrorObservationFetch(input: {
60
+ branchNames: readonly string[];
61
+ advertisedProjectToken: string | undefined;
62
+ /** Per-mount tokens for this project, keyed by branch name; undefined when the server advertised none. */
63
+ advertisedMountTokens: ReadonlyMap<string, string> | undefined;
64
+ fetchedProjectToken: string | undefined;
65
+ fetchedMountTokens: ReadonlyMap<string, string>;
66
+ skippingEnabled: boolean;
67
+ }): ProjectMirrorObservationFetchPlan;
68
+ export type ProjectMirrorRefsTokensAdvertisement = {
69
+ /** One token per project id. */
70
+ projectTokens: Map<string, string>;
71
+ /** Per-mount tokens keyed by project id then branch name; a project is present only when the server advertised mount tokens for it. */
72
+ mountTokens: Map<string, Map<string, string>>;
73
+ /** Current head of the user's hidden workspace repository, when advertised. */
74
+ workspaceHead: string | undefined;
75
+ };
76
+ /**
77
+ * Read a `project_mirror_refs_tokens` message. Older servers send only
78
+ * `tokens`; malformed entries are dropped, which fails open into fetching.
79
+ */
80
+ export declare function parseProjectMirrorRefsTokensMessage(message: {
81
+ tokens?: unknown;
82
+ mountTokens?: unknown;
83
+ workspaceHead?: unknown;
84
+ }): ProjectMirrorRefsTokensAdvertisement;
@@ -1,15 +1,40 @@
1
+ /** Canonical namespaces the token hashes; also the observation fetch's sources. */
2
+ export declare const PROJECT_MIRROR_REFS_TOKEN_NAMESPACES: readonly ["refs/heads/", "refs/r5d/server-synced/"];
3
+ /** Worker-local copy of a canonical `refs/heads/*` ref. */
1
4
  export declare const PROJECT_MIRROR_REF_PREFIX = "refs/r5d/mirror/";
2
5
  export declare const PROJECT_SERVER_SYNCED_REF_PREFIX = "refs/r5d/server-synced/";
6
+ /** `git for-each-ref` format both sides list refs with; see parseProjectMirrorRefListing. */
7
+ export declare const PROJECT_MIRROR_REF_LISTING_FORMAT = "--format=%(objectname) %(refname)";
3
8
  export type ProjectMirrorRef = {
4
9
  refname: string;
5
10
  objectName: string;
6
11
  };
12
+ /** One checked-out project branch, identified by its durable incarnation id. */
13
+ export type ProjectMirrorMount = {
14
+ branchName: string;
15
+ branchId: string;
16
+ };
17
+ export type ProjectMirrorRefsTokens = {
18
+ /** Hash over every ref in the observed namespaces. */
19
+ project: string;
20
+ /** Hash over one mount's branch head plus its handoff refs, keyed by branch name. */
21
+ mounts: Map<string, string>;
22
+ };
7
23
  /**
8
24
  * Hash a ref listing into a mirror-refs token. Both sides sort here rather
9
25
  * than trusting git's listing order: renaming `refs/r5d/mirror/*` back to
10
26
  * `refs/heads/*` changes sort neighborhoods.
11
27
  */
12
28
  export declare function hashProjectMirrorRefs(refs: readonly ProjectMirrorRef[]): string;
29
+ /**
30
+ * Parse `git for-each-ref` output produced with
31
+ * PROJECT_MIRROR_REF_LISTING_FORMAT. Malformed rows throw rather than being
32
+ * dropped: a caller that cannot trust its listing must not produce a token.
33
+ * With `namespaces`, rows outside them throw too.
34
+ */
35
+ export declare function parseProjectMirrorRefListing(output: string, options?: {
36
+ namespaces?: readonly string[];
37
+ }): ProjectMirrorRef[];
13
38
  /**
14
39
  * Rename locally fetched mirror refs into the namespaces the server hashes.
15
40
  * Rows outside the two observed namespaces are dropped; the caller lists only
@@ -17,3 +42,12 @@ export declare function hashProjectMirrorRefs(refs: readonly ProjectMirrorRef[])
17
42
  * it can only make tokens differ — which fails open into a fetch.
18
43
  */
19
44
  export declare function canonicalizeLocalProjectMirrorRefs(refs: readonly ProjectMirrorRef[]): ProjectMirrorRef[];
45
+ /** The canonical refs one mount's token covers: its branch head and its server-synced handoff refs. */
46
+ export declare function projectMirrorMountRefs(refs: readonly ProjectMirrorRef[], mount: ProjectMirrorMount): ProjectMirrorRef[];
47
+ /**
48
+ * Compute the project token and one token per mount from one canonical ref
49
+ * listing. A mount's token is the project formula applied to the mount's own
50
+ * refs, so a mount whose branch is the only ref of its project has the same
51
+ * token as the project.
52
+ */
53
+ export declare function projectMirrorRefsTokens(refs: readonly ProjectMirrorRef[], mounts: readonly ProjectMirrorMount[]): ProjectMirrorRefsTokens;
@@ -1,3 +1,4 @@
1
+ import { type ProjectMirrorMount, type ProjectMirrorRefsTokens } from "./project-mirror-refs-token";
1
2
  export declare const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
2
3
  declare const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION: 1;
3
4
  type ProjectWorktreeSnapshotManifest = {
@@ -124,10 +125,17 @@ export declare function ensureProjectWorktrees(input: {
124
125
  onSnapshotProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
125
126
  }): Promise<ProjectWorktreeState[]>;
126
127
  export declare function projectWorktreeOperationInProgress(checkoutPath: string): boolean;
128
+ /**
129
+ * How a new linked worktree's working tree starts. `carry` materializes the
130
+ * source checkout's Git-visible working state, uncommitted changes included;
131
+ * `clean` checks out the source branch's current commit and nothing else.
132
+ */
133
+ export type ProjectWorktreeWorkingTreeMode = "carry" | "clean";
127
134
  export declare function createLinkedProjectBranch(input: {
128
135
  projectRoot: string;
129
136
  sourceBranchName: string;
130
137
  branchName: string;
138
+ workingTree: ProjectWorktreeWorkingTreeMode;
131
139
  }): {
132
140
  branchPath: string;
133
141
  baseCommitHash: string;
@@ -143,6 +151,7 @@ export declare function createOrRetryLinkedProjectBranch(input: {
143
151
  primaryBranchName: string;
144
152
  sourceBranchName: string;
145
153
  branchName: string;
154
+ workingTree: ProjectWorktreeWorkingTreeMode;
146
155
  pendingRetry: boolean;
147
156
  }): {
148
157
  branchPath: string;
@@ -225,19 +234,28 @@ export declare function observeProjectMirrorHeads(input: Omit<ProjectMirrorHeadU
225
234
  allowNonFastForward: boolean;
226
235
  branchIds?: ReadonlyMap<string, string>;
227
236
  refreshRemote?: boolean;
237
+ /**
238
+ * Fetch only these mounts' branch heads and handoff refs, because
239
+ * per-mount token gating found every other mount current. Undefined
240
+ * fetches the whole mirror; an empty set fetches nothing, exactly like
241
+ * `refreshRemote: false`.
242
+ */
243
+ fetchOnlyBranches?: ReadonlySet<string>;
228
244
  }): Promise<ProjectMirrorHeadObservation[]>;
229
245
  /**
230
- * Compute the mirror-refs token for the locally cached mirror state — the
231
- * token the server will advertise once it hashes the same refs. After a
232
- * successful lease push (which updates `refs/r5d/mirror/*` above), this
233
- * matches the server's next advertisement unless something else also moved
234
- * the canonical repository, in which case the mismatch fails open into a
235
- * fetch. Formula twin: `server/git/project-mirror-refs-token.ts`.
246
+ * Compute the tokens the locally cached mirror state covers — the project
247
+ * token and one token per mount — from one listing of `refs/r5d/mirror/*` and
248
+ * `refs/r5d/server-synced/*`, with the formula shared with the server. After a
249
+ * fetch these name exactly the canonical state the fetch transferred; after a
250
+ * successful lease push (which updates `refs/r5d/mirror/*` above) they predict
251
+ * the server's next advertisement unless something else also moved the
252
+ * canonical repository, in which case the mismatch fails open into a fetch.
236
253
  */
237
- export declare function computeLocalProjectMirrorRefsToken(input: {
254
+ export declare function computeLocalProjectMirrorRefsTokens(input: {
238
255
  projectRoot: string;
239
256
  primaryBranchName: string;
240
- }): Promise<string>;
257
+ mounts: readonly ProjectMirrorMount[];
258
+ }): Promise<ProjectMirrorRefsTokens>;
241
259
  /** Apply only previously observed object ids; this function never refetches. */
242
260
  export declare function applyObservedProjectMirrorHeads(input: {
243
261
  projectRoot: string;
@@ -38,6 +38,14 @@ export type WorkspaceGitMount = {
38
38
  busy?: () => boolean;
39
39
  /** Monotonic/opaque visible-mutation token used to catch short writes that begin and end between busy checks. */
40
40
  mutationToken?: () => string;
41
+ /**
42
+ * The token a completed cycle observed when it last projected this mount.
43
+ * While the current token still equals it and the mount's hydration basis
44
+ * is the outer HEAD being projected, the outer subtree already carries the
45
+ * visible bytes and the mirror copy is skipped. This is a cross-cycle skip
46
+ * only; the per-cycle busy and token re-checks are unchanged.
47
+ */
48
+ lastProjectedMutationToken?: string;
41
49
  /** Activity-only fence used before checkout readiness is established on reconnect. */
42
50
  busyForRecovery?: () => boolean;
43
51
  };
@@ -86,8 +94,12 @@ export type WorkspaceGitSyncResult = {
86
94
  affectedPaths: string[];
87
95
  activeMountIds: string[];
88
96
  skippedMountIds: string[];
97
+ /** Token each active mount carried when this cycle projected it; recorded by the caller to skip unchanged mounts later. */
98
+ projectedMutationTokens?: Record<string, string>;
89
99
  /** Creator-local mounts whose first outer publication was measured against their source subtree. */
90
100
  initialPublicationMountIds?: string[];
101
+ /** Network fetches of the outer workspace head this cycle ran; zero when the advertised head proved them unnecessary. */
102
+ remoteHeadFetches?: number;
91
103
  conflictPaths?: string[];
92
104
  conflictSnapshotRefs?: {
93
105
  local: string;
@@ -150,9 +162,12 @@ export declare function ensureWorkspaceGitClone(input: {
150
162
  email: string;
151
163
  };
152
164
  preserveResolutionInProgress?: boolean;
165
+ /** Server-advertised head of the workspace remote; see resolveWorkspaceRemoteHead. */
166
+ advertisedRemoteHead?: string | null;
153
167
  }): Promise<{
154
168
  localHead: string | null;
155
169
  remoteHead: string | null;
170
+ remoteHeadFetched: boolean;
156
171
  }>;
157
172
  type ProjectedMountGitlinks = {
158
173
  mount: WorkspaceGitMount;
@@ -204,7 +219,7 @@ export declare function resetWorkspaceGit(input: {
204
219
  * such as a subtree a gitlink replaces (see `pathsReplacedByGitlinks`).
205
220
  */
206
221
  declare function diffSizeBytes(workspacePath: string, baseRevision: string | null, headRevision: string, limit: number, pathspecs?: readonly string[]): Promise<number>;
207
- export declare function synchronizeWorkspaceGit(input: {
222
+ export type SynchronizeWorkspaceGitInput = {
208
223
  attemptId?: string;
209
224
  workerLabel: string;
210
225
  workspacePath: string;
@@ -224,6 +239,12 @@ export declare function synchronizeWorkspaceGit(input: {
224
239
  skipMountMirror?: boolean;
225
240
  /** Positive ancestry proof required before a remediation may integrate, publish, or hydrate. */
226
241
  requiredAncestorHeads?: readonly string[];
242
+ /**
243
+ * Head of the workspace remote as the server last read it from disk. Equal
244
+ * to the local remote-tracking ref, it lets the cycle skip its workspace
245
+ * head fetches; see resolveWorkspaceRemoteHead. Omit to always fetch.
246
+ */
247
+ advertisedRemoteHead?: string | null;
227
248
  /** Revalidate the worker/config/incident generation after every async boundary. */
228
249
  assertStillAdmitted?: () => void;
229
250
  /** Test seam for exercising an incident transition during asynchronous diff measurement. */
@@ -234,7 +255,8 @@ export declare function synchronizeWorkspaceGit(input: {
234
255
  }) => void | Promise<void>;
235
256
  /** Announces each synchronous hydration transaction of this cycle; see WorkspaceHydrationTransactionHooks. */
236
257
  hydrationHooks?: WorkspaceHydrationTransactionHooks;
237
- }): Promise<WorkspaceGitSyncResult>;
258
+ };
259
+ export declare function synchronizeWorkspaceGit(input: SynchronizeWorkspaceGitInput): Promise<WorkspaceGitSyncResult>;
238
260
  export declare const workspaceGitSyncTestHarness: {
239
261
  commandArgs: typeof gitCommandArgs;
240
262
  workspaceCloneCommandArgs: typeof workspaceCloneCommandArgs;
@@ -3,9 +3,20 @@ export type WorkspaceMutationLease = () => void;
3
3
  * Coordinates project worktrees with the outer workspace Git clone.
4
4
  *
5
5
  * Ordinary worktree activity may overlap other ordinary activity, but a sync
6
- * gets exclusive access. Once a sync is queued, later mutations wait behind it
7
- * so a queued sync cannot be starved by later short mutations. Long-running
8
- * long-running project commands do not enter this gate; outer-workspace
6
+ * gets exclusive access. Once a sync is queued, later ordinary mutations wait
7
+ * behind it so a queued sync cannot be starved by later short mutations.
8
+ *
9
+ * Branch creation and deletion use their own lane: a branch mutation never
10
+ * waits behind a queued sync, only behind a sync that is already running.
11
+ * Every sync still gets exclusive access, so hydration cannot overlap a
12
+ * worktree being created or removed; the lane only decides ordering between
13
+ * cycles. Branch operations are short and rare, so the queued sync they
14
+ * step in front of waits at most their own duration. Without the lane a
15
+ * branch operation queued behind a backlog of syncs waited for every one of
16
+ * them, outlived the server's operation timeout, and then ran anyway against
17
+ * a request the server had already rolled back.
18
+ *
19
+ * Long-running project commands do not enter this gate; outer-workspace
9
20
  * remediation commands do because they operate on the sync clone itself.
10
21
  */
11
22
  export declare class WorkspaceMutationGate {
@@ -15,6 +26,16 @@ export declare class WorkspaceMutationGate {
15
26
  acquireMutation(): Promise<WorkspaceMutationLease>;
16
27
  runMutation<T>(operation: () => Promise<T> | T): Promise<T>;
17
28
  runSync<T>(operation: () => Promise<T> | T): Promise<T>;
29
+ /** A worktree create/delete lease; see the branch lane in the class comment. */
30
+ acquireBranchMutation(): Promise<WorkspaceMutationLease>;
31
+ runBranchMutation<T>(operation: () => Promise<T> | T): Promise<T>;
32
+ /**
33
+ * Operations a branch mutation requested now would wait for: the running
34
+ * sync, if any, plus branch mutations queued behind it. Zero means it runs
35
+ * at once. Ordinary waiters are not counted because the branch lane admits
36
+ * ahead of them.
37
+ */
38
+ branchMutationQueuePosition(): number;
18
39
  private drain;
19
40
  private releaseMutation;
20
41
  private releaseSync;
@@ -0,0 +1,59 @@
1
+ export type WorkspaceProjectionLedgerMount = {
2
+ id: string;
3
+ /** Durable logical incarnation; a re-created branch never matches its predecessor's record. */
4
+ hydrationIncarnationKey: string;
5
+ mutationToken?: () => string;
6
+ };
7
+ /**
8
+ * Remembers, per mount incarnation, the visible-mutation token observed when
9
+ * the mount was last projected into the outer clone by a completed cycle,
10
+ * together with that cycle's result.
11
+ *
12
+ * Two decisions read it. A cycle skips mirroring a mount whose token is
13
+ * unchanged, because its outer subtree already carries those bytes. A
14
+ * request that targets a project checkout is answered with the previous
15
+ * cycle's result, without running a cycle, when no mount's token moved since
16
+ * that cycle. Both are cross-cycle skips only; the within-cycle busy and
17
+ * token re-checks in the sync engine stay as they are.
18
+ *
19
+ * The ledger is reset whenever something other than a recorded visible
20
+ * mutation can change checkout bytes: workspace configuration, canonical
21
+ * reset, outer remediation, branch creation or deletion, and any cycle that
22
+ * failed or ended in a conflict.
23
+ */
24
+ export declare class WorkspaceProjectionLedger<Result extends {
25
+ outcome: string;
26
+ }> {
27
+ private readonly projectedTokens;
28
+ private lastCycle;
29
+ private static recordKey;
30
+ get lastCycleResult(): Result | null;
31
+ lastProjectedToken(mount: {
32
+ id: string;
33
+ hydrationIncarnationKey: string;
34
+ }): string | undefined;
35
+ /**
36
+ * Record a completed cycle. `projectedMutationTokens` names the token each
37
+ * active mount carried when the cycle projected it; a mount absent from it
38
+ * keeps its earlier record. Any outcome that leaves the outer clone without
39
+ * a trustworthy projection clears the ledger.
40
+ */
41
+ recordCycle(input: {
42
+ result: Result;
43
+ mounts: readonly WorkspaceProjectionLedgerMount[];
44
+ projectedMutationTokens: Readonly<Record<string, string>> | undefined;
45
+ }): void;
46
+ reset(): void;
47
+ /**
48
+ * The previous cycle's result when every request targets a configured
49
+ * mount and no mount's token moved since its recorded projection. A request
50
+ * without a target (a workspace-rooted command may touch any checkout), a
51
+ * mount without a token (a pending deletion), or pending deletion work all
52
+ * require a real cycle.
53
+ */
54
+ answerFromLastCycle(input: {
55
+ targetMountIds: ReadonlyArray<string | null>;
56
+ mounts: readonly WorkspaceProjectionLedgerMount[];
57
+ pendingDeletions: boolean;
58
+ }): Result | null;
59
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Batches ordinary workspace synchronization requests into one exclusive
3
+ * cycle each. A request that arrives while a batch is queued but not yet
4
+ * running joins that batch; a request that arrives while a batch is running
5
+ * opens the next one, because the running cycle may have selected its mounts
6
+ * before the request's command finished. Every request in a batch receives
7
+ * the same settlement, and the caller fans that out per attempt.
8
+ *
9
+ * Before this, every finished command queued its own full cycle behind every
10
+ * earlier one, so queue time grew without bound under a steady arrival rate
11
+ * even though each cycle finished in seconds.
12
+ */
13
+ export declare class WorkspaceSyncCoalescer<Request, Settlement> {
14
+ private readonly options;
15
+ private pendingBatch;
16
+ private batchesStarted;
17
+ constructor(options: {
18
+ /** The exclusive sync lease; the batch body runs inside it. */
19
+ runExclusive: <T>(operation: () => Promise<T>) => Promise<T>;
20
+ /** Runs (or answers without running) one cycle for the whole batch. */
21
+ runBatch: (requests: readonly Request[]) => Promise<Settlement>;
22
+ });
23
+ /** Requests attached to the batch that has not started yet. */
24
+ get queuedRequestCount(): number;
25
+ /** Batches whose body has been entered, whether or not they ran a cycle. */
26
+ get startedBatchCount(): number;
27
+ request(request: Request): Promise<Settlement>;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.101",
3
+ "version": "0.0.103",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",