@ricsam/r5d-worker 0.0.93 → 0.0.95

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.
@@ -4,6 +4,12 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
6
6
  import { validateManagedBranchName } from "./managed-paths.mjs";
7
+ import {
8
+ canonicalizeLocalProjectMirrorRefs,
9
+ hashProjectMirrorRefs,
10
+ PROJECT_MIRROR_REF_PREFIX,
11
+ PROJECT_SERVER_SYNCED_REF_PREFIX
12
+ } from "./project-mirror-refs-token.mjs";
7
13
  import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
8
14
  const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
9
15
  const PROJECT_WORKTREE_SNAPSHOT_MANIFEST = "transaction.json";
@@ -11,6 +17,7 @@ const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION = 1;
11
17
  const MAX_PROJECT_WORKTREE_SNAPSHOT_MANIFEST_BYTES = 64 * 1024;
12
18
  const PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID = randomUUID();
13
19
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
20
+ const PROJECT_GIT_TRANSPORT_TIMEOUT_MS = 3e4;
14
21
  const PROJECT_WORKTREE_SNAPSHOT_NAME = new RegExp(
15
22
  `^${PROJECT_WORKTREE_SNAPSHOT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([1-9]\\d*)-([A-Za-z0-9]{6})$`
16
23
  );
@@ -80,7 +87,7 @@ function git(cwd, args, action) {
80
87
  function tryGit(cwd, args) {
81
88
  return gitResult(cwd, args).exitCode === 0;
82
89
  }
83
- async function gitResultAsync(cwd, args) {
90
+ async function gitResultAsync(cwd, args, timeoutMs) {
84
91
  const subprocess = Bun.spawn(gitCommandArgs(args), {
85
92
  cwd,
86
93
  stdin: "ignore",
@@ -88,12 +95,22 @@ async function gitResultAsync(cwd, args) {
88
95
  stderr: "pipe",
89
96
  env: workerGitProcessEnvironment()
90
97
  });
91
- const [stdout, stderr, exitCode] = await Promise.all([
92
- new Response(subprocess.stdout).text(),
93
- new Response(subprocess.stderr).text(),
94
- subprocess.exited
95
- ]);
96
- return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
98
+ let timedOut = false;
99
+ const timeout = timeoutMs ? setTimeout(() => {
100
+ timedOut = true;
101
+ subprocess.kill("SIGKILL");
102
+ }, timeoutMs) : void 0;
103
+ try {
104
+ const [stdout, stderr, exitCode] = await Promise.all([
105
+ new Response(subprocess.stdout).text(),
106
+ new Response(subprocess.stderr).text(),
107
+ subprocess.exited
108
+ ]);
109
+ if (timedOut) throw new Error(`Git transport timed out after ${timeoutMs}ms`);
110
+ return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
111
+ } finally {
112
+ if (timeout) clearTimeout(timeout);
113
+ }
97
114
  }
98
115
  async function gitAsync(cwd, args, action) {
99
116
  const result = await gitResultAsync(cwd, args);
@@ -808,25 +825,33 @@ async function configureRepository(input) {
808
825
  }
809
826
  }
810
827
  async function fetchProjectHeads(input) {
811
- const originFetch = await gitResultAsync(input.primaryPath, [
812
- ...gitTransportSecurityArgs(input.originUrl, input.credentialHelper, input.originCredentialUsername),
813
- "fetch",
814
- "--no-recurse-submodules",
815
- "--prune",
816
- "origin",
817
- "+refs/heads/*:refs/remotes/origin/*"
818
- ]);
828
+ const originFetch = await gitResultAsync(
829
+ input.primaryPath,
830
+ [
831
+ ...gitTransportSecurityArgs(input.originUrl, input.credentialHelper, input.originCredentialUsername),
832
+ "fetch",
833
+ "--no-recurse-submodules",
834
+ "--prune",
835
+ "origin",
836
+ "+refs/heads/*:refs/remotes/origin/*"
837
+ ],
838
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
839
+ );
819
840
  if (originFetch.exitCode !== 0) {
820
841
  throw new Error(`Fetch project origin: ${originFetch.stderr || originFetch.stdout || `git exited ${originFetch.exitCode}`}`);
821
842
  }
822
- const mirrorFetch = await gitResultAsync(input.primaryPath, [
823
- ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.mirrorCredentialUsername),
824
- "fetch",
825
- "--no-recurse-submodules",
826
- "--prune",
827
- input.mirrorUrl,
828
- "+refs/heads/*:refs/r5d/mirror/*"
829
- ]);
843
+ const mirrorFetch = await gitResultAsync(
844
+ input.primaryPath,
845
+ [
846
+ ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.mirrorCredentialUsername),
847
+ "fetch",
848
+ "--no-recurse-submodules",
849
+ "--prune",
850
+ input.mirrorUrl,
851
+ "+refs/heads/*:refs/r5d/mirror/*"
852
+ ],
853
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
854
+ );
830
855
  if (mirrorFetch.exitCode !== 0 && !/(couldn't find remote ref|does not have any commits|remote repository is empty|no such ref)/i.test(
831
856
  `${mirrorFetch.stderr}
832
857
  ${mirrorFetch.stdout}`
@@ -1205,19 +1230,24 @@ function removeProjectWorktrees(input) {
1205
1230
  }
1206
1231
  async function deleteProjectMirrorBranch(input) {
1207
1232
  validateManagedBranchName(input.branchName);
1208
- const deleted = await gitResultAsync(input.gitDirectory, [
1209
- ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1210
- "push",
1211
- "--no-recurse-submodules",
1212
- input.mirrorUrl,
1213
- `:refs/heads/${input.branchName}`
1214
- ]);
1233
+ const deleted = await gitResultAsync(
1234
+ input.gitDirectory,
1235
+ [
1236
+ ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1237
+ "push",
1238
+ "--no-recurse-submodules",
1239
+ input.mirrorUrl,
1240
+ `:refs/heads/${input.branchName}`
1241
+ ],
1242
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1243
+ );
1215
1244
  if (deleted.exitCode !== 0 && !/(remote ref does not exist|unable to delete|no such ref)/i.test(`${deleted.stderr}
1216
1245
  ${deleted.stdout}`)) {
1217
1246
  throw new Error(
1218
1247
  `Delete hidden project mirror for ${input.branchName}: ${deleted.stderr || deleted.stdout || `git exited ${deleted.exitCode}`}`
1219
1248
  );
1220
1249
  }
1250
+ await gitResultAsync(input.gitDirectory, ["update-ref", "-d", `refs/r5d/mirror/${input.branchName}`]);
1221
1251
  }
1222
1252
  async function pushProjectMirrorHeads(input) {
1223
1253
  const results = [];
@@ -1231,21 +1261,51 @@ async function pushProjectMirrorHeads(input) {
1231
1261
  if (!await tryGitAsync(checkoutPath, ["cat-file", "-e", `${head}^{commit}`])) {
1232
1262
  throw new Error(`Exact publication head ${head} is unavailable for project branch ${branchName}`);
1233
1263
  }
1264
+ if (!input.expectedMirrorHeads.has(branchName)) {
1265
+ throw new Error(`Missing hidden-mirror lease for project branch ${branchName}`);
1266
+ }
1267
+ const expectedMirrorHead = input.expectedMirrorHeads.get(branchName) ?? null;
1268
+ if (expectedMirrorHead !== null && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(expectedMirrorHead)) {
1269
+ throw new Error(`Invalid hidden-mirror lease for project branch ${branchName}`);
1270
+ }
1271
+ const branchId = input.branchIds.get(branchName);
1272
+ if (!branchId || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(branchId)) {
1273
+ throw new Error(`Missing valid branch incarnation for project branch ${branchName}`);
1274
+ }
1275
+ if (expectedMirrorHead) {
1276
+ const handoffPrefix = `refs/r5d/server-synced/${branchId}/`;
1277
+ const markedCommits = (await gitAsync(
1278
+ checkoutPath,
1279
+ ["for-each-ref", "--format=%(objectname)", handoffPrefix],
1280
+ `inspect synchronized server commits before publishing ${branchName}`
1281
+ )).split(/\r?\n/).filter((value) => /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(value));
1282
+ for (const markedCommit of markedCommits) {
1283
+ const markerIsInLease = await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", markedCommit, expectedMirrorHead]);
1284
+ if (markerIsInLease && !await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", markedCommit, head])) {
1285
+ throw new Error(`Refusing to overwrite synchronized server commit history for project branch ${branchName}`);
1286
+ }
1287
+ }
1288
+ }
1234
1289
  if (projectWorktreeOperationInProgress(checkoutPath)) {
1235
1290
  results.push({ branchName, head, pushed: false, reason: "git_operation_in_progress" });
1236
1291
  continue;
1237
1292
  }
1238
- await gitAsync(
1293
+ const pushed = await gitResultAsync(
1239
1294
  checkoutPath,
1240
1295
  [
1241
1296
  ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1242
1297
  "push",
1243
1298
  "--no-recurse-submodules",
1299
+ `--force-with-lease=refs/heads/${branchName}:${expectedMirrorHead ?? ""}`,
1244
1300
  input.mirrorUrl,
1245
- `+${head}:refs/heads/${branchName}`
1301
+ `${head}:refs/heads/${branchName}`
1246
1302
  ],
1247
- `push hidden project mirror for ${branchName}`
1303
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1248
1304
  );
1305
+ if (pushed.exitCode !== 0) {
1306
+ throw new Error(`Push hidden project mirror for ${branchName}: ${pushed.stderr || pushed.stdout || `git exited ${pushed.exitCode}`}`);
1307
+ }
1308
+ await gitAsync(checkoutPath, ["update-ref", `refs/r5d/mirror/${branchName}`, head], `record pushed mirror tip ${branchName}`);
1249
1309
  results.push({ branchName, head, pushed: true });
1250
1310
  }
1251
1311
  return results;
@@ -1258,42 +1318,94 @@ function assertProjectMirrorHeadsPushed(results) {
1258
1318
  }
1259
1319
  async function observeProjectMirrorHeads(input) {
1260
1320
  const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1261
- const fetch = await gitResultAsync(primaryPath, [
1262
- ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1263
- "fetch",
1264
- "--no-recurse-submodules",
1265
- "--prune",
1266
- input.mirrorUrl,
1267
- "+refs/heads/*:refs/r5d/mirror/*"
1268
- ]);
1269
- if (fetch.exitCode !== 0)
1270
- throw new Error(`Refresh project head mirror: ${fetch.stderr || fetch.stdout || `git exited ${fetch.exitCode}`}`);
1321
+ if (input.refreshRemote !== false) {
1322
+ const fetch = await gitResultAsync(
1323
+ primaryPath,
1324
+ [
1325
+ ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1326
+ "fetch",
1327
+ "--no-recurse-submodules",
1328
+ "--prune",
1329
+ input.mirrorUrl,
1330
+ "+refs/heads/*:refs/r5d/mirror/*",
1331
+ "+refs/r5d/server-synced/*:refs/r5d/server-synced/*"
1332
+ ],
1333
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1334
+ );
1335
+ if (fetch.exitCode !== 0)
1336
+ throw new Error(`Refresh project head mirror: ${fetch.stderr || fetch.stdout || `git exited ${fetch.exitCode}`}`);
1337
+ }
1271
1338
  const observations = [];
1272
1339
  for (const branchName of [...input.branchNames].sort()) {
1273
1340
  const checkoutPath = branchPath(input.projectRoot, branchName);
1274
1341
  const previousHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
1275
1342
  const mirrorRef = `refs/r5d/mirror/${branchName}`;
1276
1343
  const mirrorHead = await tryGitAsync(primaryPath, ["show-ref", "--verify", "--quiet", mirrorRef]) ? await gitAsync(primaryPath, ["rev-parse", mirrorRef], `resolve refreshed mirror head ${branchName}`) : null;
1277
- const shouldMove = Boolean(
1278
- mirrorHead && mirrorHead !== previousHead && (input.allowNonFastForward || await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", previousHead, mirrorHead]))
1344
+ const branchId = input.branchIds?.get(branchName);
1345
+ if (branchId && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(branchId)) throw new Error(`Invalid branch id for ${branchName}`);
1346
+ const handoffPrefix = branchId ? `refs/r5d/server-synced/${branchId}/` : null;
1347
+ const handoffRows = handoffPrefix ? await gitAsync(
1348
+ primaryPath,
1349
+ ["for-each-ref", "--format=%(objectname) %(refname)", handoffPrefix],
1350
+ `inspect synchronized server commits for ${branchName}`
1351
+ ) : "";
1352
+ const serverSyncedWorkspaceHeads = (await Promise.all(
1353
+ handoffRows.split(/\r?\n/).map(async (row) => {
1354
+ const separator = row.indexOf(" ");
1355
+ if (separator < 0 || !mirrorHead) return null;
1356
+ const markedCommit = row.slice(0, separator);
1357
+ const ref = row.slice(separator + 1);
1358
+ if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(markedCommit) || !handoffPrefix || !ref.startsWith(handoffPrefix)) {
1359
+ return null;
1360
+ }
1361
+ const markerIsIntegrated = markedCommit === mirrorHead || await tryGitAsync(primaryPath, ["merge-base", "--is-ancestor", markedCommit, mirrorHead]);
1362
+ if (!markerIsIntegrated) return null;
1363
+ const workspaceHead = ref.slice(handoffPrefix.length);
1364
+ return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(workspaceHead) ? workspaceHead : null;
1365
+ })
1366
+ )).filter((workspaceHead) => workspaceHead !== null).sort();
1367
+ const fastForward = Boolean(
1368
+ mirrorHead && mirrorHead !== previousHead && await tryGitAsync(checkoutPath, ["merge-base", "--is-ancestor", previousHead, mirrorHead])
1279
1369
  );
1370
+ const serverSynced = Boolean(mirrorHead && mirrorHead !== previousHead && serverSyncedWorkspaceHeads.length > 0);
1371
+ const shouldMove = Boolean(mirrorHead && mirrorHead !== previousHead && (input.allowNonFastForward || fastForward || serverSynced));
1280
1372
  observations.push({
1281
1373
  branchName,
1282
1374
  previousHead,
1283
1375
  mirrorHead,
1284
- mode: input.allowNonFastForward ? "reseed" : "fast_forward",
1376
+ mode: input.allowNonFastForward ? "reseed" : serverSynced && !fastForward ? "server_synced" : "fast_forward",
1377
+ serverSyncedWorkspaceHeads,
1285
1378
  shouldMove
1286
1379
  });
1287
1380
  }
1288
1381
  return observations;
1289
1382
  }
1383
+ async function computeLocalProjectMirrorRefsToken(input) {
1384
+ const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1385
+ const rows = await gitAsync(
1386
+ primaryPath,
1387
+ ["for-each-ref", "--format=%(objectname) %(refname)", PROJECT_MIRROR_REF_PREFIX, PROJECT_SERVER_SYNCED_REF_PREFIX],
1388
+ "list cached project mirror refs"
1389
+ );
1390
+ const refs = rows.split(/\r?\n/).filter(Boolean).map((row) => {
1391
+ const separator = row.indexOf(" ");
1392
+ const objectName = separator < 0 ? "" : row.slice(0, separator);
1393
+ const refname = separator < 0 ? "" : row.slice(separator + 1);
1394
+ if (!/^[0-9a-f]{40,64}$/.test(objectName) || refname.length === 0) {
1395
+ throw new Error(`Invalid cached mirror ref listing row: ${row}`);
1396
+ }
1397
+ return { refname, objectName };
1398
+ });
1399
+ return hashProjectMirrorRefs(canonicalizeLocalProjectMirrorRefs(refs));
1400
+ }
1290
1401
  async function applyObservedProjectMirrorHeads(input) {
1291
1402
  const results = [];
1292
1403
  for (const observation of [...input.observations].sort((left, right) => left.branchName.localeCompare(right.branchName))) {
1293
1404
  const { branchName, mirrorHead } = observation;
1294
1405
  const checkoutPath = branchPath(input.projectRoot, branchName);
1295
1406
  const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
1296
- if (!observation.shouldMove || !mirrorHead || currentHead === mirrorHead || currentHead !== observation.previousHead) {
1407
+ const serverSyncedCovered = observation.mode !== "server_synced" || (input.serverSyncedWorkspaceCovered ? await input.serverSyncedWorkspaceCovered(observation) : false);
1408
+ if (!observation.shouldMove || !serverSyncedCovered || !mirrorHead || currentHead === mirrorHead || currentHead !== observation.previousHead) {
1297
1409
  results.push({ branchName, previousHead: currentHead, mirrorHead, moved: false });
1298
1410
  continue;
1299
1411
  }
@@ -1305,14 +1417,14 @@ async function applyObservedProjectMirrorHeads(input) {
1305
1417
  results.push({ branchName, previousHead: currentHead, mirrorHead, moved: false });
1306
1418
  continue;
1307
1419
  }
1308
- if (observation.mode === "reseed") {
1420
+ if (observation.mode === "reseed" || observation.mode === "server_synced") {
1309
1421
  await preserveHead(checkoutPath);
1310
1422
  if (!mutationAllowed()) {
1311
1423
  results.push({ branchName, previousHead: currentHead, mirrorHead, moved: false });
1312
1424
  continue;
1313
1425
  }
1314
1426
  }
1315
- const action = observation.mode === "reseed" ? "reseed" : "fast-forward";
1427
+ const action = observation.mode === "reseed" ? "reseed" : observation.mode === "server_synced" ? "adopt server commit" : "fast-forward";
1316
1428
  await gitAsync(checkoutPath, ["read-tree", "--reset", mirrorHead], `${action} project index ${branchName}`);
1317
1429
  await gitAsync(
1318
1430
  checkoutPath,
@@ -1367,6 +1479,7 @@ export {
1367
1479
  ProjectBranchCreationRollbackIncompleteError,
1368
1480
  applyObservedProjectMirrorHeads,
1369
1481
  assertProjectMirrorHeadsPushed,
1482
+ computeLocalProjectMirrorRefsToken,
1370
1483
  createLinkedProjectBranch,
1371
1484
  createOrRetryLinkedProjectBranch,
1372
1485
  deleteLinkedProjectBranch,
@@ -125,6 +125,15 @@ type WorkerServerMessage = {
125
125
  confirmationReason?: string;
126
126
  resetToCanonical?: boolean;
127
127
  requiredAncestorHeads?: string[];
128
+ } | {
129
+ /**
130
+ * Content-derived tokens naming each canonical project repository's
131
+ * observable ref state, keyed by project id. Replace-all: projects
132
+ * absent from a message have no advertised token and observation falls
133
+ * back to fetching. See `project-mirror-fetch-policy.ts`.
134
+ */
135
+ type: "project_mirror_refs_tokens";
136
+ tokens: Record<string, string>;
128
137
  } | {
129
138
  type: "create_project_branch";
130
139
  requestId: string;
@@ -0,0 +1,29 @@
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.
7
+ *
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.
13
+ */
14
+ export declare const PROJECT_MIRROR_FETCH_BACKSTOP_MS: number;
15
+ export type ProjectMirrorFetchDecision = {
16
+ fetch: true;
17
+ reason: "skipping_disabled" | "no_advertised_token" | "never_fetched" | "token_changed" | "backstop_elapsed";
18
+ } | {
19
+ fetch: false;
20
+ reason: "token_match";
21
+ };
22
+ export declare function projectMirrorFetchDecision(input: {
23
+ advertisedToken: string | undefined;
24
+ fetchedToken: string | undefined;
25
+ lastFetchAtMs: number | undefined;
26
+ nowMs: number;
27
+ skippingEnabled: boolean;
28
+ backstopMs?: number;
29
+ }): ProjectMirrorFetchDecision;
@@ -0,0 +1,19 @@
1
+ export declare const PROJECT_MIRROR_REF_PREFIX = "refs/r5d/mirror/";
2
+ export declare const PROJECT_SERVER_SYNCED_REF_PREFIX = "refs/r5d/server-synced/";
3
+ export type ProjectMirrorRef = {
4
+ refname: string;
5
+ objectName: string;
6
+ };
7
+ /**
8
+ * Hash a ref listing into a mirror-refs token. Both sides sort here rather
9
+ * than trusting git's listing order: renaming `refs/r5d/mirror/*` back to
10
+ * `refs/heads/*` changes sort neighborhoods.
11
+ */
12
+ export declare function hashProjectMirrorRefs(refs: readonly ProjectMirrorRef[]): string;
13
+ /**
14
+ * Rename locally fetched mirror refs into the namespaces the server hashes.
15
+ * Rows outside the two observed namespaces are dropped; the caller lists only
16
+ * those namespaces, so a dropped row means an unexpected listing, and dropping
17
+ * it can only make tokens differ — which fails open into a fetch.
18
+ */
19
+ export declare function canonicalizeLocalProjectMirrorRefs(refs: readonly ProjectMirrorRef[]): ProjectMirrorRef[];
@@ -175,6 +175,10 @@ export declare function pushProjectMirrorHeads(input: {
175
175
  onlyBranches?: ReadonlySet<string>;
176
176
  /** Exact pre-projection commits to publish after the outer tree is reachable. */
177
177
  publicationHeads?: ReadonlyMap<string, string>;
178
+ /** Hidden-mirror tips observed before the corresponding outer projection. */
179
+ expectedMirrorHeads: ReadonlyMap<string, string | null>;
180
+ /** Durable branch incarnations used to scope server-authored handoff refs. */
181
+ branchIds: ReadonlyMap<string, string>;
178
182
  }): Promise<Array<{
179
183
  branchName: string;
180
184
  head: string;
@@ -200,17 +204,40 @@ export type ProjectMirrorHeadObservation = {
200
204
  branchName: string;
201
205
  previousHead: string;
202
206
  mirrorHead: string | null;
203
- mode: "fast_forward" | "reseed";
207
+ mode: "fast_forward" | "reseed" | "server_synced";
208
+ serverSyncedWorkspaceHeads: string[];
204
209
  shouldMove: boolean;
205
210
  };
206
211
  /**
207
212
  * Fetch and pin the exact hidden-mirror tips that may be applied after a
208
213
  * causally later outer-workspace fetch and hydration. Observation deliberately
209
214
  * does not mutate a live branch or its index.
215
+ *
216
+ * With `refreshRemote: false` the network fetch is skipped and the tips come
217
+ * from the locally cached `refs/r5d/mirror/*` / `refs/r5d/server-synced/*`
218
+ * refs of an earlier fetch. That is safe in the stale direction (a newer
219
+ * mirror tip waits for a later cycle, exactly like a tip that lands mid-cycle
220
+ * today) and in the fresh direction (hidden-mirror publication is tree-first,
221
+ * so any locally cached tip's covering outer tree was published before the
222
+ * fetch that cached it, hence before this cycle's outer fetch).
210
223
  */
211
224
  export declare function observeProjectMirrorHeads(input: Omit<ProjectMirrorHeadUpdateInput, "branchMutationAllowed"> & {
212
225
  allowNonFastForward: boolean;
226
+ branchIds?: ReadonlyMap<string, string>;
227
+ refreshRemote?: boolean;
213
228
  }): Promise<ProjectMirrorHeadObservation[]>;
229
+ /**
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`.
236
+ */
237
+ export declare function computeLocalProjectMirrorRefsToken(input: {
238
+ projectRoot: string;
239
+ primaryBranchName: string;
240
+ }): Promise<string>;
214
241
  /** Apply only previously observed object ids; this function never refetches. */
215
242
  export declare function applyObservedProjectMirrorHeads(input: {
216
243
  projectRoot: string;
@@ -218,6 +245,8 @@ export declare function applyObservedProjectMirrorHeads(input: {
218
245
  observations: readonly ProjectMirrorHeadObservation[];
219
246
  /** Rechecked immediately before refs or the index move. */
220
247
  branchMutationAllowed?: (branchName: string, checkoutPath: string) => boolean;
248
+ /** Proves the visible outer workspace has hydrated the server commit's exact source generation. */
249
+ serverSyncedWorkspaceCovered?: (observation: ProjectMirrorHeadObservation) => Promise<boolean>;
221
250
  }): Promise<Array<{
222
251
  branchName: string;
223
252
  previousHead: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.93",
3
+ "version": "0.0.95",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",