@davideasden/pi-undo 0.2.14 → 0.2.16
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/extensions/pi-undo.ts +24 -4
- package/package.json +1 -1
- package/src/controller.ts +111 -13
- package/src/pi-runtime.ts +11 -2
- package/src/snapshot-store.ts +322 -4
package/extensions/pi-undo.ts
CHANGED
|
@@ -218,9 +218,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
218
218
|
pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
|
|
219
219
|
const active = runtime;
|
|
220
220
|
if (active === undefined) return { action: "continue" as const };
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
const inputContext = { streaming: event.streamingBehavior !== undefined };
|
|
222
|
+
const result = active.controller.beginInput !== undefined
|
|
223
|
+
? active.controller.beginInput(event.text, inputContext)
|
|
224
|
+
: await active.controller.prepareInput(event.text, inputContext);
|
|
224
225
|
const replay = replaying;
|
|
225
226
|
if (result.action === "defer") {
|
|
226
227
|
if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
|
|
@@ -254,7 +255,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
254
255
|
}
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
|
-
if (
|
|
258
|
+
if (
|
|
259
|
+
active.controller.beginInput === undefined &&
|
|
260
|
+
result.action === "continue" && active.controller.captureFailed() && !captureFailureNotified
|
|
261
|
+
) {
|
|
258
262
|
captureFailureNotified = true;
|
|
259
263
|
const reason = active.controller.captureFailureReason();
|
|
260
264
|
context.ui.notify(
|
|
@@ -264,6 +268,22 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
264
268
|
}
|
|
265
269
|
return result;
|
|
266
270
|
});
|
|
271
|
+
pi.on("message_end", async (event, context: ExtensionContext) => {
|
|
272
|
+
if (event.message.role !== "user") return;
|
|
273
|
+
const active = runtime;
|
|
274
|
+
const messageGeneration = generation;
|
|
275
|
+
if (active === undefined || active.controller.commitInput === undefined) return;
|
|
276
|
+
await active.controller.commitInput();
|
|
277
|
+
if (runtime !== active || generation !== messageGeneration) return;
|
|
278
|
+
if (active.controller.captureFailed() && !captureFailureNotified) {
|
|
279
|
+
captureFailureNotified = true;
|
|
280
|
+
const reason = active.controller.captureFailureReason();
|
|
281
|
+
context.ui.notify(
|
|
282
|
+
`pi-undo: pre-input snapshot failed${reason === undefined || reason.length === 0 ? "" : ` (${reason})`}; this run will not be undoable`,
|
|
283
|
+
"warning",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
267
287
|
pi.on("before_agent_start", async () => {
|
|
268
288
|
const active = runtime;
|
|
269
289
|
const startGeneration = generation;
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ControllerDependencies {
|
|
|
37
37
|
readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
|
|
38
38
|
readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
|
|
39
39
|
readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
|
|
40
|
+
readonly captureBaseline?: (baseline: SnapshotManifest) => Promise<SnapshotManifest>;
|
|
40
41
|
readonly captureSafety?: (
|
|
41
42
|
referenceManifestId: ManifestId,
|
|
42
43
|
targetManifestId: ManifestId,
|
|
@@ -136,6 +137,10 @@ export interface UndoController {
|
|
|
136
137
|
/** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
|
|
137
138
|
listCheckpoints(): readonly CheckpointRecord[];
|
|
138
139
|
prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
|
|
140
|
+
/** 在 input hook 中启动快照,但不等待,供 message_end 前的快速路径使用。 */
|
|
141
|
+
beginInput?(text: string, context: InputContext): InputEventResult;
|
|
142
|
+
/** 在用户 message_end 后等待快照并写入 start entry。 */
|
|
143
|
+
commitInput?(): Promise<void>;
|
|
139
144
|
beforeAgentStart(): Promise<void>;
|
|
140
145
|
agentSettled(): Promise<void>;
|
|
141
146
|
undo(): Promise<OperationResult>;
|
|
@@ -170,6 +175,7 @@ export interface ControllerInitialState {
|
|
|
170
175
|
readonly redoStack?: readonly ControllerRedoEntry[];
|
|
171
176
|
readonly historyPaused?: boolean;
|
|
172
177
|
readonly locked?: boolean;
|
|
178
|
+
readonly recoveryCompleted?: boolean;
|
|
173
179
|
}
|
|
174
180
|
|
|
175
181
|
interface PendingTree {
|
|
@@ -201,6 +207,11 @@ export class UndoControllerImpl implements UndoController {
|
|
|
201
207
|
private lastCaptureFailed = false;
|
|
202
208
|
private lastCaptureFailureMessage: string | undefined;
|
|
203
209
|
private warmUpInFlight: Promise<void> | undefined;
|
|
210
|
+
private warmUpManifest: SnapshotManifest | undefined;
|
|
211
|
+
private pendingInputCapture: { readonly token: symbol; readonly promise: Promise<void> } | undefined;
|
|
212
|
+
private deferAgentStartUntilMessageEnd = false;
|
|
213
|
+
private recoveryInFlight: Promise<void> | undefined;
|
|
214
|
+
private recoveryCompleted = false;
|
|
204
215
|
|
|
205
216
|
constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
|
|
206
217
|
this.dependencies = dependencies;
|
|
@@ -208,6 +219,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
208
219
|
this.redoStack.push(...(initialState.redoStack ?? []));
|
|
209
220
|
this.historyPaused = initialState.historyPaused ?? false;
|
|
210
221
|
this.locked = initialState.locked ?? false;
|
|
222
|
+
this.recoveryCompleted = initialState.recoveryCompleted ?? false;
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
history(): HistoryState {
|
|
@@ -222,7 +234,7 @@ export class UndoControllerImpl implements UndoController {
|
|
|
222
234
|
if (this.locked || this.warmUpInFlight !== undefined) return;
|
|
223
235
|
this.warmUpInFlight = (async () => {
|
|
224
236
|
try {
|
|
225
|
-
await this.captureWithWorkspaceLock();
|
|
237
|
+
this.warmUpManifest = await this.captureWithWorkspaceLock();
|
|
226
238
|
} catch {
|
|
227
239
|
// 预热是 best-effort:失败静默,正式 capture 会再次尝试并上报。
|
|
228
240
|
}
|
|
@@ -243,21 +255,49 @@ export class UndoControllerImpl implements UndoController {
|
|
|
243
255
|
if (this.operationInFlight) return { action: "defer" };
|
|
244
256
|
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
245
257
|
try {
|
|
246
|
-
const before = await this.
|
|
247
|
-
this.
|
|
248
|
-
this.lastCaptureFailureMessage = undefined;
|
|
249
|
-
this.historyPaused = false;
|
|
250
|
-
this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
|
|
258
|
+
const before = await this.captureInputBaseline();
|
|
259
|
+
this.stageInput(text, before);
|
|
251
260
|
return { action: "continue" };
|
|
252
261
|
} catch (error) {
|
|
253
262
|
// 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
|
|
254
|
-
this.
|
|
255
|
-
this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
|
|
263
|
+
this.recordCaptureFailure(error);
|
|
256
264
|
return { action: "continue" };
|
|
257
265
|
}
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
beginInput(text: string, context: InputContext): InputEventResult {
|
|
269
|
+
if (this.promptDeferralInFlight) return { action: "defer" };
|
|
270
|
+
if (this.locked) return { action: "continue" };
|
|
271
|
+
if (this.operationInFlight) return { action: "defer" };
|
|
272
|
+
if (context.streaming || text.length === 0) return { action: "continue" };
|
|
273
|
+
this.staged = undefined;
|
|
274
|
+
this.lastCaptureFailed = false;
|
|
275
|
+
this.lastCaptureFailureMessage = undefined;
|
|
276
|
+
this.deferAgentStartUntilMessageEnd = true;
|
|
277
|
+
const token = Symbol("input-capture");
|
|
278
|
+
const promise = this.captureInputForToken(text, token);
|
|
279
|
+
this.pendingInputCapture = { token, promise };
|
|
280
|
+
void promise.then(() => {
|
|
281
|
+
if (this.pendingInputCapture?.token === token) this.pendingInputCapture = undefined;
|
|
282
|
+
});
|
|
283
|
+
return { action: "continue" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async commitInput(): Promise<void> {
|
|
287
|
+
if (!this.deferAgentStartUntilMessageEnd) return;
|
|
288
|
+
const pending = this.pendingInputCapture;
|
|
289
|
+
if (pending !== undefined) await pending.promise;
|
|
290
|
+
this.pendingInputCapture = undefined;
|
|
291
|
+
this.deferAgentStartUntilMessageEnd = false;
|
|
292
|
+
await this.startAgentRun();
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
async beforeAgentStart(): Promise<void> {
|
|
296
|
+
if (this.deferAgentStartUntilMessageEnd) return;
|
|
297
|
+
await this.startAgentRun();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async startAgentRun(): Promise<void> {
|
|
261
301
|
if (this.locked || this.staged === undefined) return;
|
|
262
302
|
try {
|
|
263
303
|
this.staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
|
|
@@ -280,6 +320,41 @@ export class UndoControllerImpl implements UndoController {
|
|
|
280
320
|
this.redoStack.length = 0;
|
|
281
321
|
}
|
|
282
322
|
|
|
323
|
+
private async captureInputBaseline(): Promise<SnapshotManifest> {
|
|
324
|
+
// warm-up 仍在进行时先等待,确保随后可以消费已完成的 baseline,而不是再次完整 capture。
|
|
325
|
+
const warmUp = this.warmUpInFlight;
|
|
326
|
+
if (warmUp !== undefined) await warmUp;
|
|
327
|
+
const warmUpManifest = this.warmUpManifest;
|
|
328
|
+
this.warmUpManifest = undefined;
|
|
329
|
+
return warmUpManifest !== undefined && this.dependencies.captureBaseline !== undefined
|
|
330
|
+
? this.captureBaselineWithWorkspaceLock(warmUpManifest)
|
|
331
|
+
: this.captureWithWorkspaceLock();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async captureInputForToken(text: string, token: symbol): Promise<void> {
|
|
335
|
+
try {
|
|
336
|
+
const before = await this.captureInputBaseline();
|
|
337
|
+
if (this.pendingInputCapture?.token !== token) return;
|
|
338
|
+
this.stageInput(text, before);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (this.pendingInputCapture?.token !== token) return;
|
|
341
|
+
// 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
|
|
342
|
+
this.recordCaptureFailure(error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private stageInput(text: string, before: SnapshotManifest): void {
|
|
347
|
+
this.lastCaptureFailed = false;
|
|
348
|
+
this.lastCaptureFailureMessage = undefined;
|
|
349
|
+
this.historyPaused = false;
|
|
350
|
+
this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private recordCaptureFailure(error: unknown): void {
|
|
354
|
+
this.lastCaptureFailed = true;
|
|
355
|
+
this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
|
|
356
|
+
}
|
|
357
|
+
|
|
283
358
|
async agentSettled(): Promise<void> {
|
|
284
359
|
const staged = this.staged;
|
|
285
360
|
this.staged = undefined;
|
|
@@ -434,12 +509,24 @@ export class UndoControllerImpl implements UndoController {
|
|
|
434
509
|
}
|
|
435
510
|
|
|
436
511
|
async recover(): Promise<void> {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
512
|
+
if (this.recoveryCompleted) return;
|
|
513
|
+
const recoveryInFlight = this.recoveryInFlight;
|
|
514
|
+
if (recoveryInFlight !== undefined) {
|
|
515
|
+
await recoveryInFlight;
|
|
516
|
+
return;
|
|
442
517
|
}
|
|
518
|
+
const recovery = (async (): Promise<void> => {
|
|
519
|
+
try {
|
|
520
|
+
const result = await this.dependencies.recoverPending();
|
|
521
|
+
if (result.kind === "locked") this.locked = true;
|
|
522
|
+
} catch {
|
|
523
|
+
this.locked = true;
|
|
524
|
+
} finally {
|
|
525
|
+
this.recoveryCompleted = true;
|
|
526
|
+
}
|
|
527
|
+
})();
|
|
528
|
+
this.recoveryInFlight = recovery;
|
|
529
|
+
await recovery;
|
|
443
530
|
}
|
|
444
531
|
|
|
445
532
|
private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
|
|
@@ -703,6 +790,17 @@ export class UndoControllerImpl implements UndoController {
|
|
|
703
790
|
}
|
|
704
791
|
}
|
|
705
792
|
|
|
793
|
+
private async captureBaselineWithWorkspaceLock(baseline: SnapshotManifest): Promise<SnapshotManifest> {
|
|
794
|
+
const captureBaseline = this.dependencies.captureBaseline;
|
|
795
|
+
if (captureBaseline === undefined) return this.captureWithWorkspaceLock();
|
|
796
|
+
const lease = await this.dependencies.acquireWorkspaceLock();
|
|
797
|
+
try {
|
|
798
|
+
return await captureBaseline(baseline);
|
|
799
|
+
} finally {
|
|
800
|
+
await lease.release();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
706
804
|
private async compensate(
|
|
707
805
|
descriptor: OperationDescriptor,
|
|
708
806
|
rollback: SnapshotManifest,
|
package/src/pi-runtime.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { finalizeDurablePack, hasDurablePack, loadDurablePack } from "./durable-pack.ts";
|
|
15
15
|
import { assertCursor, canonicalJson, checksum, sameWorkspaceSnapshot } from "./encoding.ts";
|
|
16
16
|
import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
|
|
17
|
-
import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
|
|
17
|
+
import type { CheckpointRecord, ManifestId, SessionFileIdentity, SnapshotManifest } from "./model.ts";
|
|
18
18
|
import {
|
|
19
19
|
cleanupPackedMutations,
|
|
20
20
|
materializePackedMutationJournal,
|
|
@@ -50,7 +50,14 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
50
50
|
if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
|
|
51
51
|
throw new Error("workspace identity 已变化");
|
|
52
52
|
}
|
|
53
|
-
return store.capture(topology, scopePaths);
|
|
53
|
+
return store.capture(topology, scopePaths, { topologyAlreadyValidated: true });
|
|
54
|
+
};
|
|
55
|
+
const captureBaseline = async (baseline: SnapshotManifest) => {
|
|
56
|
+
const topology = await discovery.discover(context.cwd);
|
|
57
|
+
if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
|
|
58
|
+
throw new Error("workspace identity 已变化");
|
|
59
|
+
}
|
|
60
|
+
return store.captureBaseline(topology, baseline, undefined, { topologyAlreadyValidated: true });
|
|
54
61
|
};
|
|
55
62
|
const recovery = new JournalRecovery({
|
|
56
63
|
sessionIdentity,
|
|
@@ -214,6 +221,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
214
221
|
appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
|
|
215
222
|
appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
|
|
216
223
|
capture,
|
|
224
|
+
captureBaseline,
|
|
217
225
|
captureSafety: async (referenceManifestId, targetManifestId, scopePaths) => {
|
|
218
226
|
const [reference, target] = await Promise.all([
|
|
219
227
|
store.loadManifest(referenceManifestId),
|
|
@@ -251,6 +259,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
251
259
|
const controller = new UndoControllerImpl(dependencies, {
|
|
252
260
|
...rebuildControllerState(manager, sessionIdentity),
|
|
253
261
|
locked: startupRecovery.kind === "locked",
|
|
262
|
+
recoveryCompleted: true,
|
|
254
263
|
});
|
|
255
264
|
return {
|
|
256
265
|
controller,
|
package/src/snapshot-store.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { fsyncDirectory, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
|
|
6
|
+
import { fsyncDirectory, writeBytesAtomic, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
|
|
7
7
|
import {
|
|
8
8
|
assertManifest,
|
|
9
9
|
canonicalJson,
|
|
@@ -47,6 +47,7 @@ const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
|
47
47
|
const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
|
|
48
48
|
const BLOB_BATCH_MAX_ENTRIES = process.platform === "win32" ? 256 : 2_048;
|
|
49
49
|
const RACY_CLEAN_WINDOW_NS = 2_000_000_000n;
|
|
50
|
+
const LEAF_CACHE_FILE = "leaf-cache.json";
|
|
50
51
|
|
|
51
52
|
interface PinRecord {
|
|
52
53
|
readonly schemaVersion: 1;
|
|
@@ -119,6 +120,22 @@ interface CachedBlob {
|
|
|
119
120
|
size: number;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
/** 持久化叶子缓存文件(storeDirectory/leaf-cache.json),schema 不匹配时整体忽略。 */
|
|
124
|
+
interface PersistedLeafCacheFile {
|
|
125
|
+
readonly schemaVersion: 1;
|
|
126
|
+
readonly entries: Readonly<Record<string, Readonly<Record<string, PersistedLeafCacheEntry>>>>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface PersistedLeafCacheEntry {
|
|
130
|
+
readonly kind: "file" | "symlink";
|
|
131
|
+
readonly mode: number;
|
|
132
|
+
readonly fingerprint: string;
|
|
133
|
+
readonly cacheable: boolean;
|
|
134
|
+
readonly objectId: string;
|
|
135
|
+
readonly changedAtNs: string;
|
|
136
|
+
readonly verifiedAtNs: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
export interface SnapshotStoreOptions {
|
|
123
140
|
readonly storeRoot?: string;
|
|
124
141
|
readonly git?: GitRunner;
|
|
@@ -130,6 +147,8 @@ export interface SnapshotStoreOptions {
|
|
|
130
147
|
|
|
131
148
|
export interface CaptureOptions {
|
|
132
149
|
readonly excludePaths?: readonly string[];
|
|
150
|
+
/** 调用方刚完成 topology discovery 时跳过重复的捕获前校验。捕获后校验仍然执行。 */
|
|
151
|
+
readonly topologyAlreadyValidated?: boolean;
|
|
133
152
|
}
|
|
134
153
|
|
|
135
154
|
export interface SnapshotBlobRequest {
|
|
@@ -159,6 +178,12 @@ export class SnapshotStoreError extends Error {
|
|
|
159
178
|
|
|
160
179
|
export interface SnapshotStore {
|
|
161
180
|
capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
|
|
181
|
+
captureBaseline(
|
|
182
|
+
topology: RootTopology,
|
|
183
|
+
baseline: SnapshotManifest,
|
|
184
|
+
scope?: readonly string[],
|
|
185
|
+
options?: CaptureOptions,
|
|
186
|
+
): Promise<SnapshotManifest>;
|
|
162
187
|
listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
|
|
163
188
|
loadManifest(id: ManifestId): Promise<SnapshotManifest>;
|
|
164
189
|
assertComplete(id: ManifestId, scopePaths?: readonly string[]): Promise<void>;
|
|
@@ -184,6 +209,7 @@ export class SnapshotStore {
|
|
|
184
209
|
private readonly treeBlobMembership = new Map<string, string>();
|
|
185
210
|
private readonly blobCache = new Map<string, CachedBlob>();
|
|
186
211
|
private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
|
|
212
|
+
private readonly leafCacheDirectoriesLoaded = new Set<string>();
|
|
187
213
|
private blobCacheBytes = 0;
|
|
188
214
|
|
|
189
215
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
@@ -251,13 +277,17 @@ export class SnapshotStore {
|
|
|
251
277
|
}
|
|
252
278
|
const coverage = captureCoverage(topology.workspaceIdentity, scope);
|
|
253
279
|
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
254
|
-
|
|
280
|
+
if (options.topologyAlreadyValidated !== true) {
|
|
281
|
+
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
282
|
+
}
|
|
255
283
|
const brokenRoots = brokenRootPaths(topology);
|
|
256
284
|
if (brokenRoots.length > 0) {
|
|
257
285
|
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入快照: ${brokenRoots.join(", ")}`);
|
|
258
286
|
}
|
|
259
287
|
|
|
260
288
|
const storeDirectory = this.storeDirectory(topology);
|
|
289
|
+
// 新进程首次 capture 时从磁盘加载叶子指纹缓存,避免全量重新 hash。
|
|
290
|
+
await this.loadPersistedLeafCache(storeDirectory);
|
|
261
291
|
const transactionsRoot = join(storeDirectory, "transactions");
|
|
262
292
|
await mkdir(transactionsRoot, { recursive: true });
|
|
263
293
|
transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
|
|
@@ -315,6 +345,8 @@ export class SnapshotStore {
|
|
|
315
345
|
await writeContentAddressed(manifestPath, Buffer.from(canonicalJson(manifest), "utf8"));
|
|
316
346
|
this.manifestLocations.set(manifestId, manifestPath);
|
|
317
347
|
for (const update of cacheUpdates) this.rememberVisibleLeaves(update);
|
|
348
|
+
// 指纹缓存落盘:让下一个进程(新会话)的首次 capture 跳过全量内容 hash。
|
|
349
|
+
await this.persistLeafCache(storeDirectory);
|
|
318
350
|
return manifest;
|
|
319
351
|
} catch (error) {
|
|
320
352
|
if (error instanceof SnapshotStoreError) {
|
|
@@ -328,6 +360,189 @@ export class SnapshotStore {
|
|
|
328
360
|
}
|
|
329
361
|
}
|
|
330
362
|
|
|
363
|
+
/**
|
|
364
|
+
* 复核 warm-up 生成的 baseline;只有 topology、可见路径、文件 metadata 和 ignored proof
|
|
365
|
+
* 都能由现有证据证明未变化时,才跳过完整 capture。
|
|
366
|
+
*/
|
|
367
|
+
async captureBaseline(
|
|
368
|
+
topology: RootTopology,
|
|
369
|
+
baseline: SnapshotManifest,
|
|
370
|
+
scope?: readonly string[],
|
|
371
|
+
options: CaptureOptions = {},
|
|
372
|
+
): Promise<SnapshotManifest> {
|
|
373
|
+
await this.assertPrivateStore(topology.workspaceIdentity);
|
|
374
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
375
|
+
return this.lock.withLock(lockIdentity, () => this.captureBaselineLocked(topology, baseline, scope, options));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async captureBaselineLocked(
|
|
379
|
+
topology: RootTopology,
|
|
380
|
+
baseline: SnapshotManifest,
|
|
381
|
+
scope: readonly string[] | undefined,
|
|
382
|
+
options: CaptureOptions,
|
|
383
|
+
): Promise<SnapshotManifest> {
|
|
384
|
+
try {
|
|
385
|
+
if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
|
|
386
|
+
throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
|
|
387
|
+
}
|
|
388
|
+
if (options.topologyAlreadyValidated !== true) {
|
|
389
|
+
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
390
|
+
}
|
|
391
|
+
const brokenRoots = brokenRootPaths(topology);
|
|
392
|
+
if (brokenRoots.length > 0) {
|
|
393
|
+
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入 baseline 校验: ${brokenRoots.join(", ")}`);
|
|
394
|
+
}
|
|
395
|
+
if (await this.isBaselineFresh(topology, baseline, scope, options)) {
|
|
396
|
+
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
397
|
+
await this.touchStore(this.storeDirectory(topology));
|
|
398
|
+
return baseline;
|
|
399
|
+
}
|
|
400
|
+
// 已完成一次捕获前 topology 校验;完整回退仍保留捕获后的校验。
|
|
401
|
+
return this.captureLocked(topology, scope, { ...options, topologyAlreadyValidated: true });
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof SnapshotStoreError) {
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async isBaselineFresh(
|
|
411
|
+
topology: RootTopology,
|
|
412
|
+
baseline: SnapshotManifest,
|
|
413
|
+
scope: readonly string[] | undefined,
|
|
414
|
+
options: CaptureOptions,
|
|
415
|
+
): Promise<boolean> {
|
|
416
|
+
try {
|
|
417
|
+
assertManifest(baseline);
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
422
|
+
if (
|
|
423
|
+
baseline.workspaceIdentity !== topology.workspaceIdentity ||
|
|
424
|
+
baseline.topologyFingerprint !== topology.fingerprint ||
|
|
425
|
+
baseline.coverage !== captureCoverage(topology.workspaceIdentity, scope) ||
|
|
426
|
+
baseline.roots.length !== topology.roots.length
|
|
427
|
+
) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const storeDirectory = this.storeDirectory(topology);
|
|
432
|
+
await this.loadPersistedLeafCache(storeDirectory);
|
|
433
|
+
const transactionsRoot = join(storeDirectory, "transactions");
|
|
434
|
+
await mkdir(transactionsRoot, { recursive: true });
|
|
435
|
+
const transactionDirectory = await mkdtemp(join(transactionsRoot, "baseline-"));
|
|
436
|
+
try {
|
|
437
|
+
const baselineRoots = new Map(baseline.roots.map((root) => [root.relativeRoot, root]));
|
|
438
|
+
for (const root of topology.roots) {
|
|
439
|
+
const baselineRoot = baselineRoots.get(root.relativeRoot);
|
|
440
|
+
if (
|
|
441
|
+
baselineRoot === undefined ||
|
|
442
|
+
baselineRoot.parentRoot !== root.parentRoot ||
|
|
443
|
+
baselineRoot.state !== root.state ||
|
|
444
|
+
baselineRoot.sourceIdentity !== root.sourceIdentity ||
|
|
445
|
+
baselineRoot.privateRepositoryId !== root.privateRepositoryId ||
|
|
446
|
+
(baselineRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null) ||
|
|
447
|
+
baselineRoot.coverage !== rootCaptureCoverage(root.relativeRoot, scope, topology.roots) ||
|
|
448
|
+
baselineRoot.ignorePolicy !== IGNORE_POLICY
|
|
449
|
+
) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
if (root.state !== "active") {
|
|
453
|
+
if (
|
|
454
|
+
baselineRoot.treeId !== null ||
|
|
455
|
+
baselineRoot.ignoredPresentPaths.length > 0 ||
|
|
456
|
+
baselineRoot.objectClosure !== inactiveRootClosure(root)
|
|
457
|
+
) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const treeId = baselineRoot.treeId;
|
|
464
|
+
if (treeId === null) return false;
|
|
465
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
466
|
+
await this.ensurePrivateRepository(gitDirectory);
|
|
467
|
+
await this.assertNoAlternates(gitDirectory);
|
|
468
|
+
const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
|
|
469
|
+
const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
|
|
470
|
+
const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
|
|
471
|
+
await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
|
|
472
|
+
await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
|
|
473
|
+
|
|
474
|
+
const requestedInclusions = rootScopePathspecs(root.relativeRoot, scope);
|
|
475
|
+
const exclusions = topology.roots
|
|
476
|
+
.filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
|
|
477
|
+
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
478
|
+
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
479
|
+
const inclusions = ownedRootInclusions(requestedInclusions, exclusions);
|
|
480
|
+
const entries = await this.readTreeEntries(gitDirectory, treeId);
|
|
481
|
+
await this.assertObjectsComplete(gitDirectory, treeId, entries);
|
|
482
|
+
if (baselineRoot.objectClosure !== treeObjectClosure(treeId, entries)) return false;
|
|
483
|
+
|
|
484
|
+
const leaves = await this.collectVisibleLeaves(
|
|
485
|
+
absoluteRoot,
|
|
486
|
+
environment,
|
|
487
|
+
root.gitBacked,
|
|
488
|
+
inclusions,
|
|
489
|
+
exclusions,
|
|
490
|
+
exactExclusions,
|
|
491
|
+
transactionDirectory,
|
|
492
|
+
);
|
|
493
|
+
if (!samePathList(
|
|
494
|
+
leaves.map((leaf) => leaf.relativePath).sort(comparePaths),
|
|
495
|
+
entries.map((entry) => entry.relativePath).sort(comparePaths),
|
|
496
|
+
)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
const cache = this.visibleLeafCache.get(gitDirectory);
|
|
500
|
+
if (cache === undefined) return false;
|
|
501
|
+
const entriesByPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
|
|
502
|
+
for (const leaf of leaves) {
|
|
503
|
+
const entry = entriesByPath.get(leaf.relativePath);
|
|
504
|
+
const cached = cache.get(leaf.relativePath);
|
|
505
|
+
if (
|
|
506
|
+
entry === undefined ||
|
|
507
|
+
!leaf.cacheable ||
|
|
508
|
+
cached?.cacheable !== true ||
|
|
509
|
+
cached.kind !== leaf.kind ||
|
|
510
|
+
cached.mode !== leaf.mode ||
|
|
511
|
+
cached.fingerprint !== leaf.fingerprint ||
|
|
512
|
+
cached.objectId !== entry.objectId ||
|
|
513
|
+
cached.verifiedAtNs <= cached.changedAtNs + RACY_CLEAN_WINDOW_NS
|
|
514
|
+
) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const ignoredPresentPaths = await this.captureIgnoredPresentPaths(
|
|
520
|
+
absoluteRoot,
|
|
521
|
+
environment,
|
|
522
|
+
root.gitBacked,
|
|
523
|
+
inclusions,
|
|
524
|
+
exclusions,
|
|
525
|
+
exactExclusions,
|
|
526
|
+
transactionDirectory,
|
|
527
|
+
);
|
|
528
|
+
if (!samePathList(ignoredPresentPaths, baselineRoot.ignoredPresentPaths)) return false;
|
|
529
|
+
if (baselineRoot.ignoreClosure !== ignoredPresentClosure({
|
|
530
|
+
coverage: baselineRoot.coverage,
|
|
531
|
+
ignorePolicy: IGNORE_POLICY,
|
|
532
|
+
ignoredPresentPaths,
|
|
533
|
+
})) return false;
|
|
534
|
+
// metadata 初检与最终复核之间若有变化,放弃 baseline,回退完整 capture。
|
|
535
|
+
await this.assertVisibleLeavesUnchanged(absoluteRoot, leaves, transactionDirectory);
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
} catch {
|
|
539
|
+
// baseline 证据读取失败时不复用旧快照;完整 capture 会重新建立对象和缓存。
|
|
540
|
+
return false;
|
|
541
|
+
} finally {
|
|
542
|
+
await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
331
546
|
async listVisibleLeafPaths(
|
|
332
547
|
topology: RootTopology,
|
|
333
548
|
options: CaptureOptions = {},
|
|
@@ -960,6 +1175,69 @@ export class SnapshotStore {
|
|
|
960
1175
|
this.visibleLeafCache.set(gitDirectory, cache);
|
|
961
1176
|
}
|
|
962
1177
|
|
|
1178
|
+
/** 从 storeDirectory 读取持久化叶子缓存并合并进内存;进程内已有条目优先。 */
|
|
1179
|
+
private async loadPersistedLeafCache(storeDirectory: string): Promise<void> {
|
|
1180
|
+
if (this.leafCacheDirectoriesLoaded.has(storeDirectory)) return;
|
|
1181
|
+
this.leafCacheDirectoriesLoaded.add(storeDirectory);
|
|
1182
|
+
let file: unknown;
|
|
1183
|
+
try {
|
|
1184
|
+
file = JSON.parse(await readFile(join(storeDirectory, LEAF_CACHE_FILE), "utf8"));
|
|
1185
|
+
} catch {
|
|
1186
|
+
return; // 缺失或损坏:忽略,本次 capture 走冷路径并重建缓存。
|
|
1187
|
+
}
|
|
1188
|
+
if (!isPersistedLeafCacheFile(file)) return;
|
|
1189
|
+
const prefix = `${storeDirectory}${sep}`;
|
|
1190
|
+
for (const [gitDirectory, entries] of Object.entries(file.entries)) {
|
|
1191
|
+
if (!gitDirectory.startsWith(prefix) || this.visibleLeafCache.has(gitDirectory)) continue;
|
|
1192
|
+
const cache = new Map<string, CachedVisibleLeaf>();
|
|
1193
|
+
for (const [relativePath, entry] of Object.entries(entries)) {
|
|
1194
|
+
cache.set(relativePath, {
|
|
1195
|
+
relativePath,
|
|
1196
|
+
kind: entry.kind,
|
|
1197
|
+
mode: entry.mode,
|
|
1198
|
+
fingerprint: entry.fingerprint,
|
|
1199
|
+
cacheable: entry.cacheable,
|
|
1200
|
+
changedAtNs: BigInt(entry.changedAtNs),
|
|
1201
|
+
objectId: entry.objectId,
|
|
1202
|
+
verifiedAtNs: BigInt(entry.verifiedAtNs),
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
this.visibleLeafCache.set(gitDirectory, cache);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
|
|
1210
|
+
private async persistLeafCache(storeDirectory: string): Promise<void> {
|
|
1211
|
+
const prefix = `${storeDirectory}${sep}`;
|
|
1212
|
+
const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
|
|
1213
|
+
for (const [gitDirectory, cache] of this.visibleLeafCache) {
|
|
1214
|
+
if (!gitDirectory.startsWith(prefix)) continue;
|
|
1215
|
+
const rootEntries: Record<string, PersistedLeafCacheEntry> = {};
|
|
1216
|
+
for (const [relativePath, leaf] of cache) {
|
|
1217
|
+
rootEntries[relativePath] = {
|
|
1218
|
+
kind: leaf.kind,
|
|
1219
|
+
mode: leaf.mode,
|
|
1220
|
+
fingerprint: leaf.fingerprint,
|
|
1221
|
+
cacheable: leaf.cacheable,
|
|
1222
|
+
objectId: leaf.objectId,
|
|
1223
|
+
changedAtNs: leaf.changedAtNs.toString(),
|
|
1224
|
+
verifiedAtNs: leaf.verifiedAtNs.toString(),
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
entries[gitDirectory] = rootEntries;
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
// 用普通 JSON 序列化(非 canonicalJson):缓存只在本机消费,避免大规模排序开销。
|
|
1231
|
+
await writeBytesAtomic(
|
|
1232
|
+
join(storeDirectory, LEAF_CACHE_FILE),
|
|
1233
|
+
Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
|
|
1234
|
+
0o600,
|
|
1235
|
+
);
|
|
1236
|
+
} catch {
|
|
1237
|
+
// 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
963
1241
|
private async assertVisibleLeavesUnchanged(
|
|
964
1242
|
cwd: string,
|
|
965
1243
|
leaves: readonly VisibleLeaf[],
|
|
@@ -1508,8 +1786,17 @@ function ownedRootInclusions(
|
|
|
1508
1786
|
return owned.length === 0 ? null : owned;
|
|
1509
1787
|
}
|
|
1510
1788
|
|
|
1511
|
-
function rootCaptureCoverage(
|
|
1512
|
-
|
|
1789
|
+
function rootCaptureCoverage(
|
|
1790
|
+
rootPath: string,
|
|
1791
|
+
scope: readonly string[] | undefined,
|
|
1792
|
+
roots?: readonly RootTopologyIdentity[],
|
|
1793
|
+
): string {
|
|
1794
|
+
const requestedInclusions = rootScopePathspecs(rootPath, scope);
|
|
1795
|
+
if (roots === undefined) return rootCoverageFromInclusions(requestedInclusions);
|
|
1796
|
+
const exclusions = roots
|
|
1797
|
+
.filter((root) => isStrictRootAncestor(rootPath, root.relativeRoot))
|
|
1798
|
+
.map((root) => rootRelativePath(rootPath, root.relativeRoot));
|
|
1799
|
+
return rootCoverageFromInclusions(ownedRootInclusions(requestedInclusions, exclusions));
|
|
1513
1800
|
}
|
|
1514
1801
|
|
|
1515
1802
|
function rootCoverageFromInclusions(inclusions: readonly string[] | null): string {
|
|
@@ -1792,6 +2079,33 @@ function nativeVisibleLeafMetadata(entry: NativeMetadataEntry): VisibleLeafMetad
|
|
|
1792
2079
|
};
|
|
1793
2080
|
}
|
|
1794
2081
|
|
|
2082
|
+
function isPersistedLeafCacheFile(value: unknown): value is PersistedLeafCacheFile {
|
|
2083
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2084
|
+
const file = value as { schemaVersion?: unknown; entries?: unknown };
|
|
2085
|
+
if (file.schemaVersion !== 1 || typeof file.entries !== "object" || file.entries === null) return false;
|
|
2086
|
+
for (const entries of Object.values(file.entries as Record<string, unknown>)) {
|
|
2087
|
+
if (typeof entries !== "object" || entries === null) return false;
|
|
2088
|
+
for (const entry of Object.values(entries as Record<string, unknown>)) {
|
|
2089
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
2090
|
+
const candidate = entry as Partial<PersistedLeafCacheEntry>;
|
|
2091
|
+
if (
|
|
2092
|
+
(candidate.kind !== "file" && candidate.kind !== "symlink") ||
|
|
2093
|
+
typeof candidate.mode !== "number" ||
|
|
2094
|
+
typeof candidate.fingerprint !== "string" ||
|
|
2095
|
+
typeof candidate.cacheable !== "boolean" ||
|
|
2096
|
+
typeof candidate.objectId !== "string" ||
|
|
2097
|
+
typeof candidate.changedAtNs !== "string" ||
|
|
2098
|
+
typeof candidate.verifiedAtNs !== "string" ||
|
|
2099
|
+
!/^[0-9]+$/.test(candidate.changedAtNs) ||
|
|
2100
|
+
!/^[0-9]+$/.test(candidate.verifiedAtNs)
|
|
2101
|
+
) {
|
|
2102
|
+
return false;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return true;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1795
2109
|
function visibleLeafFingerprint(metadata: VisibleLeafMetadata): string {
|
|
1796
2110
|
return checksum(canonicalJson({
|
|
1797
2111
|
kind: metadata.kind,
|
|
@@ -2074,6 +2388,10 @@ function gitExitCode(error: unknown): number | null | undefined {
|
|
|
2074
2388
|
: undefined;
|
|
2075
2389
|
}
|
|
2076
2390
|
|
|
2391
|
+
function samePathList(left: readonly string[], right: readonly string[]): boolean {
|
|
2392
|
+
return left.length === right.length && left.every((path, index) => path === right[index]);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2077
2395
|
function comparePaths(left: string, right: string): number {
|
|
2078
2396
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
2079
2397
|
}
|