@davideasden/pi-undo 0.2.18 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/extensions/pi-undo.ts +35 -2
- package/package.json +1 -1
- package/src/controller.ts +35 -1
- package/src/journal.ts +45 -0
- package/src/pi-runtime.ts +1 -0
- package/src/recovery.ts +48 -1
- package/src/restore-engine.ts +365 -44
- package/src/root-discovery.ts +49 -2
package/README.md
CHANGED
|
@@ -68,6 +68,16 @@ pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
|
|
|
68
68
|
|
|
69
69
|
Work with Pi normally. `pi-undo` automatically records a boundary after each completed agent run.
|
|
70
70
|
|
|
71
|
+
### Recovery
|
|
72
|
+
|
|
73
|
+
If an operation fails mid-flight (for example, the process is killed while files are being restored), pi-undo locks undo history and reports `recovery_required`. Run:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
/undo-recover
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
to re-run recovery in place: it re-verifies pending journals, settles provably-safe transactions, rebuilds the undo/redo stacks, and refreshes the status line — the in-process equivalent of restarting the session window. If the workspace is still locked afterwards, the blocking transaction belongs to another session in the same workspace; open (or restart) that session and run `/undo-recover` there.
|
|
80
|
+
|
|
71
81
|
### Diff
|
|
72
82
|
|
|
73
83
|
```text
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -38,6 +38,13 @@ interface DeferredPrompt {
|
|
|
38
38
|
|
|
39
39
|
export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
|
|
40
40
|
return (pi) => {
|
|
41
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") {
|
|
42
|
+
// pi-subagents 的 runner 子进程会加载 ambient extensions,并用该环境变量
|
|
43
|
+
// 标记自身(其父扩展据此保持惰性)。pi-undo 只在主会话生效:避免子进程
|
|
44
|
+
// 重复 capture 争用 workspace lock。子代理的工作区变更由父会话的 run 级
|
|
45
|
+
// before/after 快照覆盖,撤回语义不受影响。
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
41
48
|
let runtime: PiUndoRuntime | undefined;
|
|
42
49
|
let runtimeContext: ExtensionContext | undefined;
|
|
43
50
|
let generation = 0;
|
|
@@ -47,6 +54,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
47
54
|
let activeCommands = new Set<symbol>();
|
|
48
55
|
let activeAction: "undo" | "redo" | undefined;
|
|
49
56
|
let captureFailureNotified = false;
|
|
57
|
+
let recoveryHintNotified = false;
|
|
50
58
|
|
|
51
59
|
const initialize = async (context: ExtensionContext): Promise<void> => {
|
|
52
60
|
const currentGeneration = ++generation;
|
|
@@ -57,6 +65,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
57
65
|
activeCommands = new Set<symbol>();
|
|
58
66
|
activeAction = undefined;
|
|
59
67
|
captureFailureNotified = false;
|
|
68
|
+
recoveryHintNotified = false;
|
|
60
69
|
try {
|
|
61
70
|
const next = await runtimeFactory(context, pi);
|
|
62
71
|
if (currentGeneration !== generation) return;
|
|
@@ -69,8 +78,6 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
69
78
|
next.recovery,
|
|
70
79
|
);
|
|
71
80
|
} else next.reporter.setReady(history.undoCount, history.redoCount);
|
|
72
|
-
// 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
|
|
73
|
-
next.controller.warmUp();
|
|
74
81
|
} catch (error) {
|
|
75
82
|
if (currentGeneration !== generation) return;
|
|
76
83
|
runtime = undefined;
|
|
@@ -170,6 +177,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
170
177
|
};
|
|
171
178
|
}
|
|
172
179
|
active.reporter.result(result, performance.now() - commandStarted);
|
|
180
|
+
if (result.code === "recovery_required" && !recoveryHintNotified) {
|
|
181
|
+
recoveryHintNotified = true;
|
|
182
|
+
context.ui.notify("pi-undo: run /undo-recover to retry recovery", "info");
|
|
183
|
+
}
|
|
173
184
|
const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
|
|
174
185
|
if (
|
|
175
186
|
action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
|
|
@@ -229,6 +240,28 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
229
240
|
description: "Review files changed by an Agent run (latest, or /diff N)",
|
|
230
241
|
handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
|
|
231
242
|
});
|
|
243
|
+
pi.registerCommand("undo-recover", {
|
|
244
|
+
description: "Re-run pi-undo recovery and refresh undo history",
|
|
245
|
+
handler: async (_args: string, context: ExtensionCommandContext) => {
|
|
246
|
+
if (activeCommands.size > 0) {
|
|
247
|
+
context.ui.notify("pi-undo: wait for the current operation to finish before recovering", "warning");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// 原地重建 runtime:等价于重启窗口,复用启动 recovery 语义。
|
|
251
|
+
await initialize(context);
|
|
252
|
+
const active = runtime;
|
|
253
|
+
if (active === undefined) return;
|
|
254
|
+
const history = active.controller.history();
|
|
255
|
+
if (history.locked) {
|
|
256
|
+
context.ui.notify(
|
|
257
|
+
`pi-undo: still locked (${active.controller.recoveryReason?.() ?? "pending journal"}); resolve the blocking session, then run /undo-recover again`,
|
|
258
|
+
"warning",
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
context.ui.notify(`pi-undo: recovery complete (undo:${history.undoCount} redo:${history.redoCount})`, "info");
|
|
263
|
+
},
|
|
264
|
+
});
|
|
232
265
|
|
|
233
266
|
pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
|
|
234
267
|
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -401,7 +401,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
401
401
|
profiler === undefined ? operation() : profiler.measure(phase, operation);
|
|
402
402
|
try {
|
|
403
403
|
// settled 复用 run 开始时的 before;helper 在缺少 captureBaseline 时回退完整 capture。
|
|
404
|
-
|
|
404
|
+
// 撕裂捕获(并发写撞上断言窗口)是暂态的:短暂退避后重试一次,成功则不清空历史。
|
|
405
|
+
const after = await measure("settled.capture", () =>
|
|
406
|
+
this.captureSettledBaselineWithRetry(staged.before));
|
|
405
407
|
const changedPaths = await measure("settled.changedPaths", () =>
|
|
406
408
|
this.dependencies.changedPaths(staged.before, after));
|
|
407
409
|
if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
|
|
@@ -830,6 +832,21 @@ export class UndoControllerImpl implements UndoController {
|
|
|
830
832
|
}
|
|
831
833
|
}
|
|
832
834
|
|
|
835
|
+
/**
|
|
836
|
+
* settled capture 对暂态失败重试一次:async subagent 等并发写入者可能撞上撕裂断言
|
|
837
|
+
* (捕获期间叶子变化)或短暂持有 workspace lock。settled 失败会清空整个 undo 历史,
|
|
838
|
+
* 不能因一次暂态冲突就放弃;持续失败仍由调用方走原有 historyPaused 路径。
|
|
839
|
+
*/
|
|
840
|
+
private async captureSettledBaselineWithRetry(baseline: SnapshotManifest): Promise<SnapshotManifest> {
|
|
841
|
+
try {
|
|
842
|
+
return await this.captureBaselineWithWorkspaceLock(baseline);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (!isTransientCaptureFailure(error)) throw error;
|
|
845
|
+
await sleep(250);
|
|
846
|
+
return await this.captureBaselineWithWorkspaceLock(baseline);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
833
850
|
private async compensate(
|
|
834
851
|
descriptor: OperationDescriptor,
|
|
835
852
|
rollback: SnapshotManifest,
|
|
@@ -1012,3 +1029,20 @@ function noop(): OperationResult {
|
|
|
1012
1029
|
function truncateReason(reason: string): string {
|
|
1013
1030
|
return reason.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 120);
|
|
1014
1031
|
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 判断 capture 失败是否为并发写入者导致的暂态失败:撕裂断言
|
|
1035
|
+
* (SnapshotStoreError capture_failed,如“捕获期间工作区叶子已变化”)与
|
|
1036
|
+
* workspace lock 超时(WorkspaceLockError lock_timeout)。用 name/code 鸭子类型
|
|
1037
|
+
* 判断以保持 controller 对存储实现的解耦。
|
|
1038
|
+
*/
|
|
1039
|
+
function isTransientCaptureFailure(error: unknown): boolean {
|
|
1040
|
+
if (typeof error !== "object" || error === null) return false;
|
|
1041
|
+
const { name, code } = error as { name?: unknown; code?: unknown };
|
|
1042
|
+
return (name === "SnapshotStoreError" && code === "capture_failed") ||
|
|
1043
|
+
(name === "WorkspaceLockError" && code === "lock_timeout");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function sleep(ms: number): Promise<void> {
|
|
1047
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1048
|
+
}
|
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"
|
|
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";
|
package/src/restore-engine.ts
CHANGED
|
@@ -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,8 +36,11 @@ 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;
|
|
@@ -74,6 +80,8 @@ export interface RestoreEngineOptions {
|
|
|
74
80
|
readonly store: SnapshotStore;
|
|
75
81
|
readonly discovery?: RootDiscovery;
|
|
76
82
|
readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
|
|
83
|
+
/** 仅用于测试:覆盖进程内已加载 durable pack 的内存预算。 */
|
|
84
|
+
readonly durablePackCacheMaxBytes?: number;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export interface RestoreMutation {
|
|
@@ -95,6 +103,40 @@ interface PreparedRestorePlan {
|
|
|
95
103
|
readonly targetPaths: ReadonlyMap<string, OwnedPath>;
|
|
96
104
|
}
|
|
97
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
|
+
|
|
98
140
|
interface MutationContext {
|
|
99
141
|
readonly phase: RestoreMutation["phase"];
|
|
100
142
|
readonly sourceManifestId: ManifestId;
|
|
@@ -115,17 +157,17 @@ export class RestoreEngine {
|
|
|
115
157
|
private readonly discovery: RootDiscovery;
|
|
116
158
|
private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
|
|
117
159
|
private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
|
|
118
|
-
private readonly durablePackCache = new Map<string,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
private readonly
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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;
|
|
129
171
|
|
|
130
172
|
constructor(options: RestoreEngineOptions) {
|
|
131
173
|
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
@@ -133,6 +175,10 @@ export class RestoreEngine {
|
|
|
133
175
|
this.store = options.store;
|
|
134
176
|
this.discovery = options.discovery ?? new RootDiscovery();
|
|
135
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
|
+
}
|
|
136
182
|
}
|
|
137
183
|
|
|
138
184
|
private async compatibleApplyOptions(): Promise<{
|
|
@@ -226,14 +272,27 @@ export class RestoreEngine {
|
|
|
226
272
|
target: SnapshotManifest,
|
|
227
273
|
scopePaths: readonly string[],
|
|
228
274
|
): Promise<boolean> {
|
|
229
|
-
const
|
|
230
|
-
|
|
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;
|
|
231
286
|
const cacheJournal = new MutationJournal(
|
|
232
287
|
join(dirname(cached.pack.storagePath), "mutations.jsonl"),
|
|
233
288
|
`cache-${cached.planDigest}`,
|
|
234
289
|
);
|
|
235
290
|
try {
|
|
236
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
|
+
}
|
|
237
296
|
const topology = await this.discovery.discover(this.workspaceRoot);
|
|
238
297
|
this.assertCurrentTopology(current, target, topology);
|
|
239
298
|
const native = await createNativeFileBatch({
|
|
@@ -241,7 +300,19 @@ export class RestoreEngine {
|
|
|
241
300
|
planDigest: cached.planDigest,
|
|
242
301
|
journal: cacheJournal,
|
|
243
302
|
});
|
|
244
|
-
|
|
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;
|
|
245
316
|
} catch {
|
|
246
317
|
return false;
|
|
247
318
|
}
|
|
@@ -287,7 +358,7 @@ export class RestoreEngine {
|
|
|
287
358
|
throw error;
|
|
288
359
|
}
|
|
289
360
|
await this.rememberDurablePack(
|
|
290
|
-
plan,
|
|
361
|
+
plan.planDigest,
|
|
291
362
|
current,
|
|
292
363
|
target,
|
|
293
364
|
scopePaths,
|
|
@@ -298,41 +369,245 @@ export class RestoreEngine {
|
|
|
298
369
|
}
|
|
299
370
|
|
|
300
371
|
private async rememberDurablePack(
|
|
301
|
-
|
|
372
|
+
planDigest: string,
|
|
302
373
|
current: SnapshotManifest,
|
|
303
374
|
target: SnapshotManifest,
|
|
304
375
|
scopePaths: readonly string[],
|
|
305
376
|
pack: DurablePack,
|
|
306
377
|
pinReason: string,
|
|
307
378
|
): Promise<void> {
|
|
308
|
-
this.
|
|
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, {
|
|
309
415
|
currentManifestId: current.manifestId,
|
|
310
416
|
targetManifestId: target.manifestId,
|
|
311
417
|
path: pack.storagePath,
|
|
312
418
|
packChecksum: pack.packChecksum,
|
|
313
419
|
pinReason,
|
|
420
|
+
pinsDeferred,
|
|
314
421
|
});
|
|
315
|
-
this.
|
|
316
|
-
|
|
422
|
+
this.trimDurablePackMetadata();
|
|
423
|
+
await this.rememberResidentDurablePack(
|
|
424
|
+
durablePairKey(current.manifestId, target.manifestId, scopePaths),
|
|
425
|
+
planDigest,
|
|
317
426
|
pack,
|
|
318
|
-
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private trimDurablePackMetadata(): void {
|
|
319
431
|
while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
|
|
320
|
-
const oldest = this.durablePackCache.
|
|
321
|
-
|
|
322
|
-
|
|
432
|
+
const oldest = this.durablePackCache.keys().next().value as string | undefined;
|
|
433
|
+
if (oldest === undefined) break;
|
|
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;
|
|
323
454
|
if (oldest === undefined) break;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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" };
|
|
328
542
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (hasErrorCode(error, "ENOENT")) return { kind: "missing" };
|
|
545
|
+
return { kind: "unavailable" };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
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);
|
|
333
577
|
}
|
|
334
578
|
}
|
|
335
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
|
+
|
|
336
611
|
private rememberPreparedPlan(
|
|
337
612
|
plan: RestorePlan,
|
|
338
613
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
@@ -391,8 +666,9 @@ export class RestoreEngine {
|
|
|
391
666
|
if (!hasValidPlanDigest(plan)) {
|
|
392
667
|
throw new Error("restore plan digest 与语义字段不匹配");
|
|
393
668
|
}
|
|
669
|
+
const cachedDurablePack = this.durablePackCache.get(plan.planDigest);
|
|
394
670
|
const pinsDeferred = effectiveOptions.deferDurability === true &&
|
|
395
|
-
|
|
671
|
+
cachedDurablePack?.pinsDeferred === true;
|
|
396
672
|
const recoveryReason = `restore:${plan.planDigest}`;
|
|
397
673
|
const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
|
|
398
674
|
const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
|
|
@@ -946,10 +1222,7 @@ export class RestoreEngine {
|
|
|
946
1222
|
target: SnapshotManifest,
|
|
947
1223
|
actual: RootTopology,
|
|
948
1224
|
): void {
|
|
949
|
-
if (
|
|
950
|
-
actual.workspaceIdentity !== current.workspaceIdentity ||
|
|
951
|
-
actual.fingerprint !== current.topologyFingerprint
|
|
952
|
-
) {
|
|
1225
|
+
if (!sameTopologyModuloIdentity(actual, current)) {
|
|
953
1226
|
throw new Error("apply 前 workspace topology 与 current manifest 不一致");
|
|
954
1227
|
}
|
|
955
1228
|
const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
|
|
@@ -958,14 +1231,6 @@ export class RestoreEngine {
|
|
|
958
1231
|
if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
|
|
959
1232
|
throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
|
|
960
1233
|
}
|
|
961
|
-
if (
|
|
962
|
-
currentRoot !== undefined &&
|
|
963
|
-
targetRoot.state === "active" &&
|
|
964
|
-
(currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
|
|
965
|
-
currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
|
|
966
|
-
) {
|
|
967
|
-
throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
|
|
968
|
-
}
|
|
969
1234
|
}
|
|
970
1235
|
}
|
|
971
1236
|
|
|
@@ -1744,6 +2009,28 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
|
|
|
1744
2009
|
left.linkText === right.linkText;
|
|
1745
2010
|
}
|
|
1746
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
|
+
|
|
1747
2034
|
function assertCompatibleManifests(
|
|
1748
2035
|
current: SnapshotManifest,
|
|
1749
2036
|
target: SnapshotManifest,
|
|
@@ -1995,6 +2282,40 @@ function durablePairKey(
|
|
|
1995
2282
|
return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
|
|
1996
2283
|
}
|
|
1997
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
|
+
|
|
1998
2319
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1999
2320
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
2000
2321
|
}
|
package/src/root-discovery.ts
CHANGED
|
@@ -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) {
|