@davideasden/pi-undo 0.2.17 → 0.2.20

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.
@@ -69,8 +69,6 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
69
69
  next.recovery,
70
70
  );
71
71
  } else next.reporter.setReady(history.undoCount, history.redoCount);
72
- // 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
73
- next.controller.warmUp();
74
72
  } catch (error) {
75
73
  if (currentGeneration !== generation) return;
76
74
  runtime = undefined;
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.17",
3
+ "version": "0.2.20",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "keywords": [
7
8
  "pi-package",
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
@@ -194,7 +194,12 @@ export async function createDurablePack(
194
194
  await rm(temporary, { force: true }).catch(() => {});
195
195
  throw error;
196
196
  }
197
- const packChecksum = checksum(Buffer.concat([MAGIC, lengthBytes, headerBytes, ...payloads]));
197
+ const digest = createHash("sha256");
198
+ digest.update(MAGIC);
199
+ digest.update(lengthBytes);
200
+ digest.update(headerBytes);
201
+ for (const payload of payloads) digest.update(payload);
202
+ const packChecksum = digest.digest("hex");
198
203
  return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
199
204
  }
200
205
 
package/src/journal.ts CHANGED
@@ -2,6 +2,7 @@ import { appendFile, lstat, readFile, readdir, rm } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
4
  import { fsyncDirectory, fsyncFile, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
5
+ import { hasDurablePack } from "./durable-pack.ts";
5
6
  import {
6
7
  assertCursor,
7
8
  assertJournalState,
@@ -13,6 +14,7 @@ import type {
13
14
  CursorState,
14
15
  JournalPhase,
15
16
  JournalState,
17
+ MutationRecord,
16
18
  OperationDescriptor,
17
19
  } from "./model.ts";
18
20
  import { MutationJournal } from "./mutation-journal.ts";
@@ -154,6 +156,31 @@ export class JournalStore {
154
156
  }
155
157
  }
156
158
 
159
+ /**
160
+ * 判断 transaction 的 mutation 义务是否已全部履行且净效果回到操作前状态:
161
+ * 所有记录都达到 CLEANED,且按路径折叠的 fingerprint 链首尾相接(前向变更
162
+ * 被补偿变更精确抵消);没有任何 mutation 记录且无 durable pack 时视为从未发生。
163
+ * 这是纯只读检查;评估期间 journal 发生变化或任何证据不确定时都返回 false。
164
+ */
165
+ async isFullyCompensated(pending: PendingJournal): Promise<boolean> {
166
+ try {
167
+ const current = await this.load(pending.descriptor.opId);
168
+ if (!samePendingJournal(current, pending)) return false;
169
+ const mutationJournal = this.mutationJournal(pending.descriptor.opId);
170
+ const records = await mutationJournal.load();
171
+ const compensated = records.length === 0
172
+ ? !await hasDurablePack(mutationJournal)
173
+ : fingerprintChainsFoldToIdentity(records);
174
+ if (!compensated) return false;
175
+ const verified = await this.load(pending.descriptor.opId);
176
+ if (!samePendingJournal(verified, current)) return false;
177
+ const reloaded = await mutationJournal.load();
178
+ return reloaded.length === records.length && fingerprintChainsFoldToIdentity(reloaded);
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
157
184
  async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
158
185
  if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
159
186
  const pending = await this.load(opId);
@@ -414,6 +441,24 @@ function samePendingJournal(left: PendingJournal, right: PendingJournal): boolea
414
441
  }
415
442
  }
416
443
 
444
+ function fingerprintChainsFoldToIdentity(records: readonly MutationRecord[]): boolean {
445
+ const chains = new Map<string, { source: string; target: string }>();
446
+ for (const record of records) {
447
+ if (record.state !== "CLEANED") return false;
448
+ const chain = chains.get(record.path);
449
+ if (chain === undefined) {
450
+ chains.set(record.path, { source: record.sourceFingerprint, target: record.targetFingerprint });
451
+ continue;
452
+ }
453
+ if (record.sourceFingerprint !== chain.target) return false;
454
+ chain.target = record.targetFingerprint;
455
+ }
456
+ for (const chain of chains.values()) {
457
+ if (chain.target !== chain.source) return false;
458
+ }
459
+ return true;
460
+ }
461
+
417
462
  function isEmptyArray(value: unknown): value is readonly unknown[] {
418
463
  return Array.isArray(value) && value.length === 0;
419
464
  }
package/src/pi-runtime.ts CHANGED
@@ -65,6 +65,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
65
65
  getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
66
66
  loadPending: () => journal.loadPending(),
67
67
  assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
68
+ assessCompensatedTransaction: (pending) => journal.isFullyCompensated(pending),
68
69
  inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
69
70
  finalizeCursor: (pending, inspection) => finalizeCursorMarker(
70
71
  pending.descriptor.sessionIdentity.path,
package/src/recovery.ts CHANGED
@@ -11,6 +11,8 @@ export interface JournalRecoveryDependencies {
11
11
  readonly loadPending: () => Promise<readonly PendingJournal[]>;
12
12
  /** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
13
13
  readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
14
+ /** 判断 transaction 是否已被完全补偿(mutation 全部 CLEANED 且净效果回到操作前状态),可直接 settle。 */
15
+ readonly assessCompensatedTransaction?: (journal: PendingJournal) => Promise<boolean>;
14
16
  readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
15
17
  readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
16
18
  readonly recoverMutations: (
@@ -67,9 +69,22 @@ export class JournalRecovery {
67
69
  for (const journal of pending) {
68
70
  const identityError = this.identityError(journal);
69
71
  if (identityError !== null) {
70
- if (identityError === "session_identity_mismatch" && await this.canIgnoreForeignTransaction(journal)) continue;
72
+ if (identityError === "session_identity_mismatch") {
73
+ if (await this.settleCompensated(journal)) {
74
+ recovered += 1;
75
+ continue;
76
+ }
77
+ if (await this.canIgnoreForeignTransaction(journal)) continue;
78
+ }
71
79
  return { kind: "locked", reason: identityError, operations: recovered };
72
80
  }
81
+ // 同一会话(重启或第二窗口)内,完全补偿的事务同样可直接 settle:
82
+ // 工作区义务已全部履行且无 cursor 提交证据,session leaf 位置对
83
+ // ABORTED 终结不构成安全输入,无需通过 leaf 校验。
84
+ if (await this.settleCompensated(journal)) {
85
+ recovered += 1;
86
+ continue;
87
+ }
73
88
  let inspection: CursorMarkerInspection;
74
89
  try {
75
90
  inspection = await this.dependencies.inspectCursor(journal);
@@ -145,6 +160,38 @@ export class JournalRecovery {
145
160
  }
146
161
  }
147
162
 
163
+ /**
164
+ * 终结完全补偿的 pending transaction(own session 重启/第二窗口与 foreign 会话均适用)。
165
+ * 调用方持有 workspace lock,pending 非终态事务的 owner 操作已确定性结束;
166
+ * mutation 义务全部履行且净效果回到操作前状态时,无需触碰工作区即可 settle。
167
+ * cursor marker 必须缺失——marker 存在意味着操作已提交,与补偿证据矛盾,fail closed。
168
+ */
169
+ private async settleCompensated(journal: PendingJournal): Promise<boolean> {
170
+ const assess = this.dependencies.assessCompensatedTransaction;
171
+ if (assess === undefined) return false;
172
+ let compensated: boolean;
173
+ try {
174
+ compensated = await assess(journal);
175
+ } catch {
176
+ // 评估失败等同于证据不足,退回原有恢复语义。
177
+ return false;
178
+ }
179
+ if (!compensated) return false;
180
+ let inspection: CursorMarkerInspection;
181
+ try {
182
+ inspection = await this.dependencies.inspectCursor(journal);
183
+ } catch {
184
+ return false;
185
+ }
186
+ if (inspection.kind !== "absent") return false;
187
+ try {
188
+ await this.dependencies.settle(journal.descriptor.opId, "ABORTED");
189
+ } catch {
190
+ return false;
191
+ }
192
+ return true;
193
+ }
194
+
148
195
  private identityError(journal: PendingJournal): string | null {
149
196
  if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
150
197
  return "workspace_identity_mismatch";
@@ -4,8 +4,11 @@ import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
 
7
+ import { writeJsonAtomic } from "./atomic-fs.ts";
7
8
  import {
8
9
  createDurablePack,
10
+ hasDurablePack,
11
+ loadDurablePack,
9
12
  publishCachedDurablePack,
10
13
  removeDurablePack,
11
14
  type DurableLeafInput,
@@ -33,11 +36,15 @@ import {
33
36
  type ReplaceFileRequest,
34
37
  } from "./quarantine.ts";
35
38
  import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
39
+ import { WorkspaceLock } from "./workspace-lock.ts";
36
40
 
37
41
  const PREPARED_PLAN_CACHE_LIMIT = 16;
42
+ const DURABLE_CACHE_INDEX_FILE = "index.json";
43
+ const DURABLE_PACK_MEMORY_MAX_BYTES = 64 * 1024 * 1024;
38
44
  const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
39
45
  const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
40
46
  const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
47
+ const RESTORE_FILE_VERIFY_CONCURRENCY = 32;
41
48
 
42
49
  export interface RestorePlan {
43
50
  currentManifestId: ManifestId;
@@ -73,6 +80,8 @@ export interface RestoreEngineOptions {
73
80
  readonly store: SnapshotStore;
74
81
  readonly discovery?: RootDiscovery;
75
82
  readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
83
+ /** 仅用于测试:覆盖进程内已加载 durable pack 的内存预算。 */
84
+ readonly durablePackCacheMaxBytes?: number;
76
85
  }
77
86
 
78
87
  export interface RestoreMutation {
@@ -94,6 +103,40 @@ interface PreparedRestorePlan {
94
103
  readonly targetPaths: ReadonlyMap<string, OwnedPath>;
95
104
  }
96
105
 
106
+ interface DurableCacheIndexEntry {
107
+ readonly currentManifestId: ManifestId;
108
+ readonly targetManifestId: ManifestId;
109
+ readonly scopePaths: readonly string[];
110
+ readonly planDigest: string;
111
+ readonly packChecksum: string;
112
+ }
113
+
114
+ interface DurableCacheIndex {
115
+ readonly schemaVersion: 1;
116
+ entries: DurableCacheIndexEntry[];
117
+ }
118
+
119
+ interface CachedDurablePack {
120
+ readonly currentManifestId: ManifestId;
121
+ readonly targetManifestId: ManifestId;
122
+ readonly path: string;
123
+ readonly packChecksum: string;
124
+ readonly pinReason: string;
125
+ readonly pinsDeferred: boolean;
126
+ }
127
+
128
+ interface CachedDurablePair {
129
+ readonly planDigest: string;
130
+ readonly pack: DurablePack;
131
+ readonly bytes: number;
132
+ }
133
+
134
+ type DurableIndexDisk =
135
+ | { readonly kind: "ok"; readonly index: DurableCacheIndex }
136
+ | { readonly kind: "missing" }
137
+ | { readonly kind: "corrupt" }
138
+ | { readonly kind: "unavailable" };
139
+
97
140
  interface MutationContext {
98
141
  readonly phase: RestoreMutation["phase"];
99
142
  readonly sourceManifestId: ManifestId;
@@ -114,17 +157,17 @@ export class RestoreEngine {
114
157
  private readonly discovery: RootDiscovery;
115
158
  private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
116
159
  private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
117
- private readonly durablePackCache = new Map<string, {
118
- readonly currentManifestId: ManifestId;
119
- readonly targetManifestId: ManifestId;
120
- readonly path: string;
121
- readonly packChecksum: string;
122
- readonly pinReason: string;
123
- }>();
124
- private readonly durablePackByPair = new Map<string, {
125
- readonly planDigest: string;
126
- readonly pack: DurablePack;
127
- }>();
160
+ private readonly durablePackCache = new Map<string, CachedDurablePack>();
161
+ private readonly durablePackByPair = new Map<string, CachedDurablePair>();
162
+ private readonly durableIndexLock = new WorkspaceLock({
163
+ leaseMs: 10_000,
164
+ retryMs: 25,
165
+ acquireTimeoutMs: 1_000,
166
+ });
167
+ private readonly durablePackCacheMaxBytes: number;
168
+ private durableIndex: DurableCacheIndex | undefined;
169
+ private durableIndexQueue: Promise<void> = Promise.resolve();
170
+ private durablePackMemoryBytes = 0;
128
171
 
129
172
  constructor(options: RestoreEngineOptions) {
130
173
  this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
@@ -132,6 +175,10 @@ export class RestoreEngine {
132
175
  this.store = options.store;
133
176
  this.discovery = options.discovery ?? new RootDiscovery();
134
177
  this.beforeMutation = options.beforeMutation;
178
+ this.durablePackCacheMaxBytes = options.durablePackCacheMaxBytes ?? DURABLE_PACK_MEMORY_MAX_BYTES;
179
+ if (!Number.isSafeInteger(this.durablePackCacheMaxBytes) || this.durablePackCacheMaxBytes <= 0) {
180
+ throw new Error("durable pack 内存预算必须是正整数");
181
+ }
135
182
  }
136
183
 
137
184
  private async compatibleApplyOptions(): Promise<{
@@ -225,14 +272,27 @@ export class RestoreEngine {
225
272
  target: SnapshotManifest,
226
273
  scopePaths: readonly string[],
227
274
  ): Promise<boolean> {
228
- const cached = this.durablePackByPair.get(durablePairKey(current.manifestId, target.manifestId, scopePaths));
229
- if (cached === undefined) return false;
275
+ const pairKey = durablePairKey(current.manifestId, target.manifestId, scopePaths);
276
+ let cached = this.durablePackByPair.get(pairKey);
277
+ if (cached !== undefined && !this.durablePackCache.has(cached.planDigest)) {
278
+ this.dropResidentDurablePack(pairKey);
279
+ cached = undefined;
280
+ }
281
+ const fromPersistentIndex = cached === undefined;
282
+ if (cached === undefined) {
283
+ cached = await this.readIndexedDurablePack(current.manifestId, target.manifestId, scopePaths);
284
+ }
285
+ if (cached === undefined || cached.pack.opId !== `cache-${cached.planDigest}`) return false;
230
286
  const cacheJournal = new MutationJournal(
231
287
  join(dirname(cached.pack.storagePath), "mutations.jsonl"),
232
288
  `cache-${cached.planDigest}`,
233
289
  );
234
290
  try {
235
291
  if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
292
+ if (fromPersistentIndex) {
293
+ const expected = await this.plan(current, target, scopePaths);
294
+ if (expected.planDigest !== cached.planDigest) return false;
295
+ }
236
296
  const topology = await this.discovery.discover(this.workspaceRoot);
237
297
  this.assertCurrentTopology(current, target, topology);
238
298
  const native = await createNativeFileBatch({
@@ -240,7 +300,19 @@ export class RestoreEngine {
240
300
  planDigest: cached.planDigest,
241
301
  journal: cacheJournal,
242
302
  });
243
- return native !== undefined && await native.verifySource(cached.pack);
303
+ if (native === undefined || !await native.verifySource(cached.pack)) return false;
304
+ if (fromPersistentIndex) {
305
+ await this.rememberDurablePackInMemory(
306
+ cached.planDigest,
307
+ current,
308
+ target,
309
+ scopePaths,
310
+ cached.pack,
311
+ `durable-cache:${cached.planDigest}`,
312
+ false,
313
+ );
314
+ }
315
+ return true;
244
316
  } catch {
245
317
  return false;
246
318
  }
@@ -286,7 +358,7 @@ export class RestoreEngine {
286
358
  throw error;
287
359
  }
288
360
  await this.rememberDurablePack(
289
- plan,
361
+ plan.planDigest,
290
362
  current,
291
363
  target,
292
364
  scopePaths,
@@ -297,41 +369,245 @@ export class RestoreEngine {
297
369
  }
298
370
 
299
371
  private async rememberDurablePack(
300
- plan: RestorePlan,
372
+ planDigest: string,
301
373
  current: SnapshotManifest,
302
374
  target: SnapshotManifest,
303
375
  scopePaths: readonly string[],
304
376
  pack: DurablePack,
305
377
  pinReason: string,
306
378
  ): Promise<void> {
307
- this.durablePackCache.set(plan.planDigest, {
379
+ await this.rememberDurablePackInMemory(planDigest, current, target, scopePaths, pack, pinReason, true);
380
+ await this.enqueueDurableIndex(async () => {
381
+ const lease = await this.acquireDurableIndexLease();
382
+ if (lease === undefined) return;
383
+ try {
384
+ const disk = await this.readDurableIndexFromDisk();
385
+ if (disk.kind === "unavailable") return;
386
+ this.durableIndex = disk.kind === "ok"
387
+ ? disk.index
388
+ : { schemaVersion: 1, entries: [] };
389
+ this.upsertDurableIndexEntry({
390
+ currentManifestId: current.manifestId,
391
+ targetManifestId: target.manifestId,
392
+ scopePaths: [...scopePaths],
393
+ planDigest,
394
+ packChecksum: pack.packChecksum,
395
+ });
396
+ await this.evictPersistedDurablePacks(planDigest);
397
+ await this.persistDurableIndex();
398
+ } finally {
399
+ await lease.release().catch(() => {});
400
+ }
401
+ });
402
+ }
403
+
404
+ private async rememberDurablePackInMemory(
405
+ planDigest: string,
406
+ current: SnapshotManifest,
407
+ target: SnapshotManifest,
408
+ scopePaths: readonly string[],
409
+ pack: DurablePack,
410
+ pinReason: string,
411
+ pinsDeferred: boolean,
412
+ ): Promise<void> {
413
+ this.durablePackCache.delete(planDigest);
414
+ this.durablePackCache.set(planDigest, {
308
415
  currentManifestId: current.manifestId,
309
416
  targetManifestId: target.manifestId,
310
417
  path: pack.storagePath,
311
418
  packChecksum: pack.packChecksum,
312
419
  pinReason,
420
+ pinsDeferred,
313
421
  });
314
- this.durablePackByPair.set(durablePairKey(current.manifestId, target.manifestId, scopePaths), {
315
- planDigest: plan.planDigest,
422
+ this.trimDurablePackMetadata();
423
+ await this.rememberResidentDurablePack(
424
+ durablePairKey(current.manifestId, target.manifestId, scopePaths),
425
+ planDigest,
316
426
  pack,
317
- });
427
+ );
428
+ }
429
+
430
+ private trimDurablePackMetadata(): void {
318
431
  while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
319
- const oldest = this.durablePackCache.entries().next().value as
320
- | readonly [string, { readonly currentManifestId: ManifestId; readonly targetManifestId: ManifestId; readonly path: string; readonly packChecksum: string; readonly pinReason: string }]
321
- | undefined;
432
+ const oldest = this.durablePackCache.keys().next().value as string | undefined;
322
433
  if (oldest === undefined) break;
323
- const [digest, entry] = oldest;
324
- this.durablePackCache.delete(digest);
325
- for (const [key, pair] of this.durablePackByPair) {
326
- if (pair.planDigest === digest) this.durablePackByPair.delete(key);
434
+ this.durablePackCache.delete(oldest);
435
+ this.dropResidentDurablePacks(oldest);
436
+ }
437
+ }
438
+
439
+ private async rememberResidentDurablePack(
440
+ pairKey: string,
441
+ planDigest: string,
442
+ pack: DurablePack,
443
+ ): Promise<void> {
444
+ const bytes = await this.durablePackFileBytes(pack.storagePath);
445
+ const previous = this.durablePackByPair.get(pairKey);
446
+ if (previous !== undefined) {
447
+ this.durablePackMemoryBytes -= previous.bytes;
448
+ this.durablePackByPair.delete(pairKey);
449
+ }
450
+ this.durablePackByPair.set(pairKey, { planDigest, pack, bytes });
451
+ this.durablePackMemoryBytes += bytes;
452
+ while (this.durablePackMemoryBytes > this.durablePackCacheMaxBytes) {
453
+ const oldest = this.durablePackByPair.keys().next().value as string | undefined;
454
+ if (oldest === undefined) break;
455
+ this.dropResidentDurablePack(oldest);
456
+ }
457
+ }
458
+
459
+ private dropResidentDurablePack(pairKey: string): void {
460
+ const cached = this.durablePackByPair.get(pairKey);
461
+ if (cached === undefined) return;
462
+ this.durablePackByPair.delete(pairKey);
463
+ this.durablePackMemoryBytes = Math.max(0, this.durablePackMemoryBytes - cached.bytes);
464
+ }
465
+
466
+ private dropResidentDurablePacks(planDigest: string): void {
467
+ for (const [pairKey, cached] of this.durablePackByPair) {
468
+ if (cached.planDigest === planDigest) this.dropResidentDurablePack(pairKey);
469
+ }
470
+ }
471
+
472
+ private async readIndexedDurablePack(
473
+ currentManifestId: ManifestId,
474
+ targetManifestId: ManifestId,
475
+ scopePaths: readonly string[],
476
+ ): Promise<CachedDurablePair | undefined> {
477
+ const pairKey = durablePairKey(currentManifestId, targetManifestId, scopePaths);
478
+ const existing = this.durablePackByPair.get(pairKey);
479
+ if (existing !== undefined && this.durablePackCache.has(existing.planDigest)) return existing;
480
+ if (existing !== undefined) this.dropResidentDurablePack(pairKey);
481
+ const disk = await this.enqueueDurableIndex(async () => {
482
+ const result = await this.readDurableIndexFromDisk();
483
+ if (result.kind === "ok") this.durableIndex = result.index;
484
+ return result;
485
+ });
486
+ const entries = disk.kind === "ok"
487
+ ? disk.index.entries
488
+ : disk.kind === "unavailable" ? this.durableIndex?.entries : undefined;
489
+ const entry = entries?.find((candidate) =>
490
+ durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) === pairKey);
491
+ if (entry === undefined || !isDigest(entry.planDigest) || !isDigest(entry.packChecksum)) return undefined;
492
+ try {
493
+ const cacheRoot = await this.store.durableCacheDirectory();
494
+ const journal = new MutationJournal(
495
+ join(cacheRoot, entry.planDigest, "mutations.jsonl"),
496
+ `cache-${entry.planDigest}`,
497
+ );
498
+ if (!await hasDurablePack(journal)) return undefined;
499
+ const pack = await loadDurablePack(journal, entry.planDigest, true);
500
+ if (pack.packChecksum !== entry.packChecksum || pack.planDigest !== entry.planDigest) return undefined;
501
+ return {
502
+ planDigest: entry.planDigest,
503
+ pack,
504
+ bytes: await this.durablePackFileBytes(pack.storagePath),
505
+ };
506
+ } catch {
507
+ return undefined;
508
+ }
509
+ }
510
+
511
+ private enqueueDurableIndex<T>(operation: () => Promise<T>): Promise<T> {
512
+ const run = this.durableIndexQueue.then(operation, operation);
513
+ this.durableIndexQueue = run.then(() => undefined, () => undefined);
514
+ return run;
515
+ }
516
+
517
+ private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
518
+ try {
519
+ return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
520
+ } catch {
521
+ return undefined;
522
+ }
523
+ }
524
+
525
+ private async durableIndexLockIdentity(): Promise<string> {
526
+ const cacheRoot = await this.store.durableCacheDirectory();
527
+ try {
528
+ return `durable-cache:${await realpath(cacheRoot)}`;
529
+ } catch {
530
+ return `durable-cache:${resolve(cacheRoot)}`;
531
+ }
532
+ }
533
+
534
+ private async readDurableIndexFromDisk(): Promise<DurableIndexDisk> {
535
+ try {
536
+ const cacheRoot = await this.store.durableCacheDirectory();
537
+ const raw = await readFile(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), "utf8");
538
+ try {
539
+ return { kind: "ok", index: parseDurableCacheIndex(JSON.parse(raw)) };
540
+ } catch {
541
+ return { kind: "corrupt" };
327
542
  }
328
- await Promise.all([...new Set([entry.currentManifestId, entry.targetManifestId])].map(
329
- (manifestId) => this.store.unpin(manifestId, entry.pinReason).catch(() => {}),
330
- ));
331
- await rm(dirname(entry.path), { recursive: true, force: true }).catch(() => {});
543
+ } catch (error) {
544
+ if (hasErrorCode(error, "ENOENT")) return { kind: "missing" };
545
+ return { kind: "unavailable" };
332
546
  }
333
547
  }
334
548
 
549
+ private upsertDurableIndexEntry(entry: DurableCacheIndexEntry): void {
550
+ const index = this.durableIndex ?? { schemaVersion: 1, entries: [] };
551
+ const pairKey = durablePairKey(entry.currentManifestId, entry.targetManifestId, entry.scopePaths);
552
+ index.entries = index.entries.filter((candidate) =>
553
+ candidate.planDigest !== entry.planDigest &&
554
+ durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) !== pairKey);
555
+ index.entries.push(entry);
556
+ this.durableIndex = index;
557
+ }
558
+
559
+ private async persistDurableIndex(): Promise<void> {
560
+ if (this.durableIndex === undefined) return;
561
+ try {
562
+ const cacheRoot = await this.store.durableCacheDirectory();
563
+ await writeJsonAtomic(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), {
564
+ schemaVersion: 1,
565
+ entries: this.durableIndex.entries,
566
+ });
567
+ } catch {
568
+ // 索引只是跨会话候选提示;写入失败不得让已经 pin 成功的 pack 准备失败。
569
+ }
570
+ }
571
+
572
+ private async evictPersistedDurablePacks(keepPlanDigest: string): Promise<void> {
573
+ while (this.durableIndex !== undefined && this.durableIndex.entries.length > PREPARED_PLAN_CACHE_LIMIT) {
574
+ const oldest = this.durableIndex.entries.find((entry) => entry.planDigest !== keepPlanDigest);
575
+ if (oldest === undefined) break;
576
+ await this.dropPersistedDurablePack(oldest.planDigest);
577
+ }
578
+ }
579
+
580
+ private async dropPersistedDurablePack(planDigest: string): Promise<void> {
581
+ const cached = this.durablePackCache.get(planDigest);
582
+ const indexed = this.durableIndex?.entries.find((entry) => entry.planDigest === planDigest);
583
+ this.durablePackCache.delete(planDigest);
584
+ this.dropResidentDurablePacks(planDigest);
585
+ if (this.durableIndex !== undefined) {
586
+ this.durableIndex.entries = this.durableIndex.entries.filter((entry) => entry.planDigest !== planDigest);
587
+ }
588
+ const pinReason = cached?.pinReason ?? `durable-cache:${planDigest}`;
589
+ const manifests = [...new Set([
590
+ ...(cached === undefined ? [] : [cached.currentManifestId, cached.targetManifestId]),
591
+ ...(indexed === undefined ? [] : [indexed.currentManifestId, indexed.targetManifestId]),
592
+ ])];
593
+ await Promise.all(manifests.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
594
+ try {
595
+ await rm(join(await this.store.durableCacheDirectory(), planDigest), { recursive: true, force: true });
596
+ } catch {
597
+ // 淘汰失败只影响缓存占用,不影响当前 restore 正确性。
598
+ }
599
+ }
600
+
601
+ private async durablePackFileBytes(path: string): Promise<number> {
602
+ try {
603
+ const stats = await lstat(path);
604
+ if (stats.isFile() && !stats.isSymbolicLink() && stats.size > 0) return Number(stats.size);
605
+ } catch {
606
+ // 文件可能在加载后被其他进程淘汰;不能把已加载的 pack 当作零字节常驻。
607
+ }
608
+ return this.durablePackCacheMaxBytes + 1;
609
+ }
610
+
335
611
  private rememberPreparedPlan(
336
612
  plan: RestorePlan,
337
613
  currentPaths: ReadonlyMap<string, OwnedPath>,
@@ -390,8 +666,9 @@ export class RestoreEngine {
390
666
  if (!hasValidPlanDigest(plan)) {
391
667
  throw new Error("restore plan digest 与语义字段不匹配");
392
668
  }
669
+ const cachedDurablePack = this.durablePackCache.get(plan.planDigest);
393
670
  const pinsDeferred = effectiveOptions.deferDurability === true &&
394
- this.durablePackCache.has(plan.planDigest);
671
+ cachedDurablePack?.pinsDeferred === true;
395
672
  const recoveryReason = `restore:${plan.planDigest}`;
396
673
  const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
397
674
  const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
@@ -519,7 +796,13 @@ export class RestoreEngine {
519
796
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
520
797
  }
521
798
  try {
522
- await this.assertCompleteVisibleSubset(topologyBefore, [current, target], options.mutationJournal);
799
+ await this.assertCompleteVisibleSubset(
800
+ topologyBefore,
801
+ [current, target],
802
+ options.mutationJournal,
803
+ [],
804
+ this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
805
+ );
523
806
  } catch {
524
807
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
525
808
  }
@@ -588,6 +871,11 @@ export class RestoreEngine {
588
871
  );
589
872
  return result;
590
873
  }
874
+ try {
875
+ await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
876
+ } catch {
877
+ // 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
878
+ }
591
879
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
592
880
  if (!preflight.ok) {
593
881
  return {
@@ -625,7 +913,13 @@ export class RestoreEngine {
625
913
 
626
914
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
627
915
  assertUnchangedTopology(topologyBefore, topologyAfter);
628
- await this.assertCompleteVisibleSubset(topologyAfter, [target], options.mutationJournal);
916
+ await this.assertCompleteVisibleSubset(
917
+ topologyAfter,
918
+ [target],
919
+ options.mutationJournal,
920
+ [],
921
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
922
+ );
629
923
  const verification = await this.verifyTarget(
630
924
  target,
631
925
  currentPaths,
@@ -702,6 +996,7 @@ export class RestoreEngine {
702
996
  ? []
703
997
  : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
704
998
  }),
999
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
705
1000
  );
706
1001
  const totalPaths = plan.deletePaths.length + plan.writePaths.length;
707
1002
  if ((await options.mutationJournal.load()).length !== 0) {
@@ -887,15 +1182,47 @@ export class RestoreEngine {
887
1182
  return result;
888
1183
  }
889
1184
 
1185
+ private completeCoverageOwnedPaths(
1186
+ scopePaths: readonly string[] | undefined,
1187
+ ownedPaths: readonly ReadonlyMap<string, OwnedPath>[],
1188
+ ): readonly ReadonlyMap<string, OwnedPath>[] | undefined {
1189
+ return scopePaths === undefined ? ownedPaths : undefined;
1190
+ }
1191
+
1192
+ private async prefetchCompleteRestoreBlobs(
1193
+ plan: RestorePlan,
1194
+ current: SnapshotManifest,
1195
+ target: SnapshotManifest,
1196
+ currentPaths: ReadonlyMap<string, OwnedPath>,
1197
+ targetPaths: ReadonlyMap<string, OwnedPath>,
1198
+ ): Promise<void> {
1199
+ if (plan.scopePaths !== undefined || !SnapshotStore.supportsValidatedBlobBatch(this.store)) return;
1200
+ const requestsFor = (paths: ReadonlyMap<string, OwnedPath>, extraPaths: readonly string[] = []) => {
1201
+ const requests = [];
1202
+ const requested = new Set([...plan.writePaths, ...extraPaths]);
1203
+ for (const path of requested) {
1204
+ const owned = paths.get(path);
1205
+ if (owned === undefined || owned.entry.kind !== "file" || owned.entry.blobId === null) continue;
1206
+ requests.push({
1207
+ rootPath: owned.root.relativeRoot,
1208
+ blobId: owned.entry.blobId,
1209
+ relativePath: owned.entry.relativePath,
1210
+ });
1211
+ }
1212
+ return requests;
1213
+ };
1214
+ await Promise.all([
1215
+ this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
1216
+ this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
1217
+ ]);
1218
+ }
1219
+
890
1220
  private assertCurrentTopology(
891
1221
  current: SnapshotManifest,
892
1222
  target: SnapshotManifest,
893
1223
  actual: RootTopology,
894
1224
  ): void {
895
- if (
896
- actual.workspaceIdentity !== current.workspaceIdentity ||
897
- actual.fingerprint !== current.topologyFingerprint
898
- ) {
1225
+ if (!sameTopologyModuloIdentity(actual, current)) {
899
1226
  throw new Error("apply 前 workspace topology 与 current manifest 不一致");
900
1227
  }
901
1228
  const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
@@ -904,14 +1231,6 @@ export class RestoreEngine {
904
1231
  if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
905
1232
  throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
906
1233
  }
907
- if (
908
- currentRoot !== undefined &&
909
- targetRoot.state === "active" &&
910
- (currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
911
- currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
912
- ) {
913
- throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
914
- }
915
1234
  }
916
1235
  }
917
1236
 
@@ -926,12 +1245,13 @@ export class RestoreEngine {
926
1245
  const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
927
1246
  .filter((path) => scope === undefined || scope.has(path))
928
1247
  .sort(comparePaths);
929
- let verifiedPaths = 0;
930
- for (const path of paths) {
931
- if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
932
- verifiedPaths += 1;
933
- continue;
934
- }
1248
+ const results: Array<boolean | undefined> = new Array(paths.length);
1249
+ let nextIndex = 0;
1250
+ let stop = false;
1251
+ let failure: unknown;
1252
+ let failureIndex: number | undefined;
1253
+ const verifyPath = async (path: string): Promise<boolean> => {
1254
+ if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) return true;
935
1255
  const currentPath = currentPaths.get(path);
936
1256
  const targetPath = targetPaths.get(path);
937
1257
  const matchesCurrent = currentPath !== undefined &&
@@ -941,7 +1261,37 @@ export class RestoreEngine {
941
1261
  const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
942
1262
  (currentPath === undefined || targetPath === undefined) &&
943
1263
  await this.pathIsAbsent(path);
944
- if (!matchesCurrent && !matchesTarget && !matchesAbsentSide) {
1264
+ return matchesCurrent || matchesTarget || matchesAbsentSide;
1265
+ };
1266
+ const worker = async (): Promise<void> => {
1267
+ while (!stop && nextIndex < paths.length) {
1268
+ const index = nextIndex;
1269
+ nextIndex += 1;
1270
+ try {
1271
+ const ok = await verifyPath(paths[index]!);
1272
+ results[index] = ok;
1273
+ if (!ok) stop = true;
1274
+ } catch (error) {
1275
+ if (failureIndex === undefined || index < failureIndex) {
1276
+ failure = error;
1277
+ failureIndex = index;
1278
+ }
1279
+ stop = true;
1280
+ }
1281
+ }
1282
+ };
1283
+ if (paths.length > 0) {
1284
+ await Promise.all(Array.from(
1285
+ { length: Math.min(RESTORE_FILE_VERIFY_CONCURRENCY, paths.length) },
1286
+ () => worker(),
1287
+ ));
1288
+ }
1289
+ let verifiedPaths = 0;
1290
+ for (let index = 0; index < results.length; index += 1) {
1291
+ const ok = results[index];
1292
+ if (ok === false) return { ok: false, verifiedPaths, totalPaths: paths.length };
1293
+ if (ok === undefined) {
1294
+ if (failureIndex === index && failure !== undefined) throw failure;
945
1295
  return { ok: false, verifiedPaths, totalPaths: paths.length };
946
1296
  }
947
1297
  verifiedPaths += 1;
@@ -1168,7 +1518,13 @@ export class RestoreEngine {
1168
1518
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
1169
1519
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1170
1520
  assertUnchangedTopology(topologyBefore, topologyAfter);
1171
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1521
+ await this.assertCompleteVisibleSubset(
1522
+ topologyAfter,
1523
+ [current],
1524
+ options.mutationJournal,
1525
+ [],
1526
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1527
+ );
1172
1528
  const verification = await this.verifyTarget(
1173
1529
  current,
1174
1530
  targetPaths,
@@ -1205,7 +1561,13 @@ export class RestoreEngine {
1205
1561
  try {
1206
1562
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1207
1563
  assertUnchangedTopology(topologyBefore, topologyAfter);
1208
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1564
+ await this.assertCompleteVisibleSubset(
1565
+ topologyAfter,
1566
+ [current],
1567
+ options.mutationJournal,
1568
+ [],
1569
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1570
+ );
1209
1571
  const verification = await this.verifyTarget(
1210
1572
  current,
1211
1573
  targetPaths,
@@ -1397,16 +1759,17 @@ export class RestoreEngine {
1397
1759
  allowedManifests: readonly SnapshotManifest[],
1398
1760
  mutationJournal?: MutationJournal,
1399
1761
  extraExclusions: readonly string[] = [],
1762
+ ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
1400
1763
  ): Promise<void> {
1401
1764
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
1402
1765
  return;
1403
1766
  }
1404
1767
  const allowedPaths = new Set<string>();
1405
- for (const manifest of allowedManifests) {
1768
+ for (const [index, manifest] of allowedManifests.entries()) {
1406
1769
  for (const path of ignoredWorkspacePaths(manifest)) {
1407
1770
  allowedPaths.add(path);
1408
1771
  }
1409
- const paths = await this.readOwnedPaths(manifest);
1772
+ const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1410
1773
  for (const [path, owned] of paths) {
1411
1774
  if (owned.entry.kind !== "directory") {
1412
1775
  allowedPaths.add(path);
@@ -1435,23 +1798,18 @@ export class RestoreEngine {
1435
1798
  deletePaths: readonly string[],
1436
1799
  scopePaths?: readonly string[],
1437
1800
  ): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
1438
- const pathFingerprints: string[] = [];
1439
1801
  const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
1440
- for (const [path, owned] of targetPaths) {
1441
- if (scope !== undefined && !scope.has(path)) continue;
1442
- pathFingerprints.push(await this.verifyEntry(target.manifestId, owned));
1443
- }
1444
- let verifiedPaths = pathFingerprints.length;
1445
- let totalPaths = pathFingerprints.length;
1446
- for (const path of deletePaths) {
1447
- if (
1448
- targetPaths.has(path) ||
1449
- currentPaths.get(path)?.entry.kind === "directory" ||
1450
- hasNonDirectoryAncestor(path, targetPaths)
1451
- ) {
1452
- continue;
1453
- }
1454
- totalPaths += 1;
1802
+ const scopedTargets = [...targetPaths].filter(([path]) => scope === undefined || scope.has(path));
1803
+ const pathFingerprints = await mapConcurrentOrdered(
1804
+ scopedTargets,
1805
+ RESTORE_FILE_VERIFY_CONCURRENCY,
1806
+ ([, owned]) => this.verifyEntry(target.manifestId, owned),
1807
+ );
1808
+ const remainingDeletes = deletePaths.filter((path) =>
1809
+ !targetPaths.has(path) &&
1810
+ currentPaths.get(path)?.entry.kind !== "directory" &&
1811
+ !hasNonDirectoryAncestor(path, targetPaths));
1812
+ await mapConcurrentOrdered(remainingDeletes, RESTORE_FILE_VERIFY_CONCURRENCY, async (path) => {
1455
1813
  try {
1456
1814
  await lstat(this.absolutePath(path));
1457
1815
  throw new Error(`目标应删除的路径仍然存在:${path}`);
@@ -1460,11 +1818,10 @@ export class RestoreEngine {
1460
1818
  throw error;
1461
1819
  }
1462
1820
  }
1463
- verifiedPaths += 1;
1464
- }
1821
+ });
1465
1822
  return {
1466
- verifiedPaths,
1467
- totalPaths,
1823
+ verifiedPaths: pathFingerprints.length + remainingDeletes.length,
1824
+ totalPaths: pathFingerprints.length + remainingDeletes.length,
1468
1825
  pathFingerprints,
1469
1826
  };
1470
1827
  }
@@ -1652,6 +2009,28 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
1652
2009
  left.linkText === right.linkText;
1653
2010
  }
1654
2011
 
2012
+ // sourceIdentity/privateRepositoryId 会随 git remote 配置漂移(例如后来补充 remote origin),
2013
+ // 它们是仓库元数据而非工作区内容;restore 的内容安全由逐文件校验
2014
+ // (verifyKnownState、assertCompleteVisibleSubset、verifyTarget)保证。
2015
+ // 因此这里只比较结构性拓扑字段:root 集合、parentRoot、state 与 gitlinkOid。
2016
+ function sameTopologyModuloIdentity(actual: RootTopology, expected: SnapshotManifest): boolean {
2017
+ if (actual.workspaceIdentity !== expected.workspaceIdentity) return false;
2018
+ const expectedRoots = new Map(expected.roots.map((root) => [root.relativeRoot, root]));
2019
+ if (actual.roots.length !== expectedRoots.size) return false;
2020
+ for (const root of actual.roots) {
2021
+ const expectedRoot = expectedRoots.get(root.relativeRoot);
2022
+ if (
2023
+ expectedRoot === undefined ||
2024
+ expectedRoot.parentRoot !== root.parentRoot ||
2025
+ expectedRoot.state !== root.state ||
2026
+ (expectedRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null)
2027
+ ) {
2028
+ return false;
2029
+ }
2030
+ }
2031
+ return true;
2032
+ }
2033
+
1655
2034
  function assertCompatibleManifests(
1656
2035
  current: SnapshotManifest,
1657
2036
  target: SnapshotManifest,
@@ -1903,6 +2282,40 @@ function durablePairKey(
1903
2282
  return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
1904
2283
  }
1905
2284
 
2285
+ function isDigest(value: unknown): value is string {
2286
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
2287
+ }
2288
+
2289
+ function parseDurableCacheIndex(value: unknown): DurableCacheIndex {
2290
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
2291
+ throw new Error("durable cache index 无效");
2292
+ }
2293
+ const entries: DurableCacheIndexEntry[] = [];
2294
+ for (const entry of value.entries) {
2295
+ if (
2296
+ !isRecord(entry) ||
2297
+ !isDigest(entry.currentManifestId) ||
2298
+ !isDigest(entry.targetManifestId) ||
2299
+ !isDigest(entry.planDigest) ||
2300
+ !isDigest(entry.packChecksum) ||
2301
+ !Array.isArray(entry.scopePaths) ||
2302
+ entry.scopePaths.some((path) => typeof path !== "string")
2303
+ ) continue;
2304
+ entries.push({
2305
+ currentManifestId: entry.currentManifestId as ManifestId,
2306
+ targetManifestId: entry.targetManifestId as ManifestId,
2307
+ scopePaths: entry.scopePaths.filter((path): path is string => typeof path === "string"),
2308
+ planDigest: entry.planDigest,
2309
+ packChecksum: entry.packChecksum,
2310
+ });
2311
+ }
2312
+ return { schemaVersion: 1, entries };
2313
+ }
2314
+
2315
+ function isRecord(value: unknown): value is Record<string, unknown> {
2316
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2317
+ }
2318
+
1906
2319
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
1907
2320
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
1908
2321
  }
@@ -1,4 +1,4 @@
1
- import { lstat, readdir, realpath } from "node:fs/promises";
1
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
 
4
4
  import { checksum, topologyFingerprint } from "./encoding.ts";
@@ -6,6 +6,7 @@ import { GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
7
 
8
8
  const DIRECTORY_SCAN_CONCURRENCY = 16;
9
+ const GIT_POINTER_MAX_BYTES = 4096;
9
10
 
10
11
  interface RepositoryInfo {
11
12
  readonly absoluteRoot: string;
@@ -17,6 +18,7 @@ interface RepositoryInfo {
17
18
  type RepositoryInspection =
18
19
  | { readonly kind: "active"; readonly repository: RepositoryInfo }
19
20
  | { readonly kind: "broken"; readonly absoluteRoot: string }
21
+ | { readonly kind: "stale"; readonly absoluteRoot: string }
20
22
  | { readonly kind: "absent" };
21
23
 
22
24
  interface DiscoveredRoot {
@@ -68,7 +70,7 @@ export class RootDiscovery {
68
70
  outerRepository.repository.absoluteRoot,
69
71
  this.activeRoot(workspaceIdentity, outerRepository.repository),
70
72
  );
71
- } else if (outerRepository.kind === "broken") {
73
+ } else if (outerRepository.kind === "broken" || outerRepository.kind === "stale") {
72
74
  activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot));
73
75
  } else {
74
76
  activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity));
@@ -117,6 +119,8 @@ export class RootDiscovery {
117
119
  );
118
120
  } else if (inspection.kind === "broken") {
119
121
  activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
122
+ } else if (inspection.kind === "stale") {
123
+ activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
120
124
  }
121
125
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
122
126
  }
@@ -145,6 +149,9 @@ export class RootDiscovery {
145
149
  if (marker === "invalid") {
146
150
  return { kind: "broken", absoluteRoot };
147
151
  }
152
+ if (await deadWorktreePointer(absoluteRoot)) {
153
+ return { kind: "stale", absoluteRoot };
154
+ }
148
155
 
149
156
  const details = await this.gitOutput([
150
157
  "-C",
@@ -274,6 +281,46 @@ function brokenRoot(workspaceIdentity: string, absoluteRoot: string): Discovered
274
281
  };
275
282
  }
276
283
 
284
+ // git worktree 的 .git 文件是 "gitdir: <path>" 指针;项目从其他机器/位置搬移后,
285
+ // 指针常指向本机不存在的 gitdir(例如虚拟机共享目录的绝对路径)。这种可证明失效的
286
+ // 指针不再代表可用仓库,按未初始化根处理:内容不进入快照,也不会被 restore 触碰。
287
+ async function deadWorktreePointer(absoluteRoot: string): Promise<boolean> {
288
+ const pointerPath = join(absoluteRoot, ".git");
289
+ let content: string;
290
+ try {
291
+ const marker = await lstat(pointerPath);
292
+ if (!marker.isFile()) return false;
293
+ content = await readFile(pointerPath, "utf8");
294
+ } catch {
295
+ return false;
296
+ }
297
+ if (content.length > GIT_POINTER_MAX_BYTES) return false;
298
+ const firstLine = content.split("\n", 1)[0] ?? "";
299
+ const match = /^gitdir: (.+)$/.exec(firstLine.trimEnd());
300
+ if (match === null) return false;
301
+ const gitDirectory = isAbsolute(match[1]) ? resolve(match[1]) : resolve(absoluteRoot, match[1]);
302
+ try {
303
+ await stat(gitDirectory);
304
+ return false;
305
+ } catch (error) {
306
+ return hasErrorCode(error, "ENOENT");
307
+ }
308
+ }
309
+
310
+ function staleWorktreeRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot {
311
+ const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot);
312
+ const sourceIdentity = `stale-worktree:${absoluteRoot}`;
313
+ return {
314
+ absoluteRoot,
315
+ relativeRoot,
316
+ gitBacked: true,
317
+ state: "uninitialized",
318
+ sourceIdentity,
319
+ privateRepositoryId: checksum(sourceIdentity),
320
+ treeId: null,
321
+ };
322
+ }
323
+
277
324
  function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] {
278
325
  const unique = new Map<string, DiscoveredRoot>();
279
326
  for (const root of discovered) {
@@ -210,6 +210,8 @@ export class SnapshotStore {
210
210
  private readonly blobCache = new Map<string, CachedBlob>();
211
211
  private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
212
212
  private readonly leafCacheDirectoriesLoaded = new Set<string>();
213
+ private readonly configuredPrivateRepositories = new Set<string>();
214
+ private readonly leafCacheDirtyDirectories = new Set<string>();
213
215
  private blobCacheBytes = 0;
214
216
 
215
217
  constructor(options: SnapshotStoreOptions = {}) {
@@ -741,6 +743,61 @@ export class SnapshotStore {
741
743
  return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
742
744
  }
743
745
 
746
+ /** 按 root 批量预取普通文件 blob;membership 与 manifest 校验仍走只读路径。 */
747
+ async prefetchBlobs(id: ManifestId, requests: readonly SnapshotBlobRequest[]): Promise<void> {
748
+ if (requests.length === 0) return;
749
+ for (const request of requests) {
750
+ relativeSafePath("/", request.rootPath);
751
+ if (!isObjectId(request.blobId)) {
752
+ throw new SnapshotStoreError("object_missing", "blob ID 无效");
753
+ }
754
+ if (request.relativePath === undefined) {
755
+ throw new SnapshotStoreError("object_missing", "blob 预取必须提供 root-relative path");
756
+ }
757
+ }
758
+ const manifestPath = await this.findManifestPath(id);
759
+ const manifest = await this.loadManifest(id);
760
+ const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
761
+ const storeDirectory = dirname(dirname(manifestPath));
762
+ const byRoot = new Map<string, SnapshotBlobRequest[]>();
763
+ for (const request of requests) {
764
+ const grouped = byRoot.get(request.rootPath) ?? [];
765
+ grouped.push(request);
766
+ byRoot.set(request.rootPath, grouped);
767
+ }
768
+ try {
769
+ const grouped = new Map<string, Map<string, CapturedTreeEntry>>();
770
+ for (const [rootPath, rootRequests] of byRoot) {
771
+ const root = roots.get(rootPath);
772
+ if (root === undefined) {
773
+ throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
774
+ }
775
+ if (root.state !== "active" || root.treeId === null) {
776
+ throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
777
+ }
778
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
779
+ const entries = await this.readTreeEntries(gitDirectory, root.treeId);
780
+ const byPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
781
+ const unique = grouped.get(gitDirectory) ?? new Map<string, CapturedTreeEntry>();
782
+ for (const request of rootRequests) {
783
+ const safeRelativePath = relativeSafePath("/", request.relativePath!);
784
+ const entry = byPath.get(safeRelativePath);
785
+ if (entry === undefined || entry.objectId !== request.blobId) {
786
+ throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
787
+ }
788
+ unique.set(entry.objectId, entry);
789
+ }
790
+ grouped.set(gitDirectory, unique);
791
+ }
792
+ for (const [gitDirectory, unique] of grouped) {
793
+ await this.preloadBlobBytes(gitDirectory, [...unique.values()]);
794
+ }
795
+ } catch (error) {
796
+ if (error instanceof SnapshotStoreError) throw error;
797
+ throw new SnapshotStoreError("object_missing", "blob 无法预取", { cause: error });
798
+ }
799
+ }
800
+
744
801
  private readBlobsValidated(
745
802
  id: ManifestId,
746
803
  requests: readonly SnapshotBlobRequest[],
@@ -1155,9 +1212,10 @@ export class SnapshotStore {
1155
1212
 
1156
1213
  private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
1157
1214
  const { gitDirectory, staged, inclusions } = update;
1215
+ const previous = this.visibleLeafCache.get(gitDirectory);
1158
1216
  const cache = inclusions !== null && inclusions.length === 0
1159
1217
  ? new Map<string, CachedVisibleLeaf>()
1160
- : new Map(this.visibleLeafCache.get(gitDirectory));
1218
+ : new Map(previous);
1161
1219
  if (inclusions !== null && inclusions.length > 0) {
1162
1220
  for (const relativePath of cache.keys()) {
1163
1221
  if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
@@ -1174,6 +1232,9 @@ export class SnapshotStore {
1174
1232
  }
1175
1233
  cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
1176
1234
  }
1235
+ if (!samePersistedLeafCache(previous, cache)) {
1236
+ this.leafCacheDirtyDirectories.add(storeDirectoryForGitDirectory(gitDirectory));
1237
+ }
1177
1238
  this.visibleLeafCache.set(gitDirectory, cache);
1178
1239
  }
1179
1240
 
@@ -1210,6 +1271,7 @@ export class SnapshotStore {
1210
1271
 
1211
1272
  /** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
1212
1273
  private async persistLeafCache(storeDirectory: string): Promise<void> {
1274
+ if (!this.leafCacheDirtyDirectories.has(storeDirectory)) return;
1213
1275
  const prefix = `${storeDirectory}${sep}`;
1214
1276
  const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
1215
1277
  for (const [gitDirectory, cache] of this.visibleLeafCache) {
@@ -1235,6 +1297,7 @@ export class SnapshotStore {
1235
1297
  Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
1236
1298
  0o600,
1237
1299
  );
1300
+ this.leafCacheDirtyDirectories.delete(storeDirectory);
1238
1301
  } catch {
1239
1302
  // 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
1240
1303
  }
@@ -1428,9 +1491,11 @@ export class SnapshotStore {
1428
1491
  }
1429
1492
 
1430
1493
  private async configurePrivateRepository(gitDirectory: string): Promise<void> {
1494
+ if (this.configuredPrivateRepositories.has(gitDirectory)) return;
1431
1495
  const environment = cleanGitEnvironment();
1432
1496
  await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
1433
1497
  await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
1498
+ this.configuredPrivateRepositories.add(gitDirectory);
1434
1499
  }
1435
1500
 
1436
1501
  private async assertNoAlternates(gitDirectory: string): Promise<void> {
@@ -1982,6 +2047,35 @@ function blobCacheKey(gitDirectory: string, objectId: string): string {
1982
2047
  return `${gitDirectory}\0${objectId}`;
1983
2048
  }
1984
2049
 
2050
+ function storeDirectoryForGitDirectory(gitDirectory: string): string {
2051
+ return dirname(dirname(dirname(gitDirectory)));
2052
+ }
2053
+
2054
+ function samePersistedLeafCache(
2055
+ left: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
2056
+ right: ReadonlyMap<string, CachedVisibleLeaf>,
2057
+ ): boolean {
2058
+ if (left === undefined) return right.size === 0;
2059
+ if (left.size !== right.size) return false;
2060
+ for (const [path, entry] of right) {
2061
+ const existing = left.get(path);
2062
+ const existingTrusted = existing !== undefined &&
2063
+ existing.verifiedAtNs > existing.changedAtNs + RACY_CLEAN_WINDOW_NS;
2064
+ const entryTrusted = entry.verifiedAtNs > entry.changedAtNs + RACY_CLEAN_WINDOW_NS;
2065
+ if (
2066
+ existing === undefined ||
2067
+ existing.kind !== entry.kind ||
2068
+ existing.mode !== entry.mode ||
2069
+ existing.fingerprint !== entry.fingerprint ||
2070
+ existing.cacheable !== entry.cacheable ||
2071
+ existing.objectId !== entry.objectId ||
2072
+ existing.changedAtNs !== entry.changedAtNs ||
2073
+ existingTrusted !== entryTrusted
2074
+ ) return false;
2075
+ }
2076
+ return true;
2077
+ }
2078
+
1985
2079
  function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
1986
2080
  const result: CapturedTreeEntry[][] = [];
1987
2081
  let batch: CapturedTreeEntry[] = [];