@davideasden/pi-undo 0.1.2 → 0.2.1

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/src/controller.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { performance } from "node:perf_hooks";
2
3
 
3
4
  import { canonicalJson, checksum } from "./encoding.ts";
4
5
  import type {
@@ -35,7 +36,7 @@ export interface ControllerDependencies {
35
36
  }>;
36
37
  readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
37
38
  readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
38
- readonly capture: () => Promise<SnapshotManifest>;
39
+ readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
39
40
  readonly changedPaths: (before: SnapshotManifest, after: SnapshotManifest) => Promise<readonly string[]>;
40
41
  readonly loadManifest: (id: ManifestId) => Promise<SnapshotManifest>;
41
42
  readonly planRestore: (
@@ -73,16 +74,23 @@ export type CursorAppendResult =
73
74
  | { readonly kind: "volatile"; readonly reason: string }
74
75
  | { readonly kind: "recovery_required"; readonly reason: string };
75
76
 
77
+ export interface OperationTiming {
78
+ readonly phase: string;
79
+ readonly durationMs: number;
80
+ }
81
+
76
82
  export interface OperationResult {
77
83
  readonly code: ResultCode;
78
84
  readonly changedFiles: number;
79
85
  readonly message?: string;
80
86
  readonly refillPrompt?: string;
87
+ readonly timings?: readonly OperationTiming[];
81
88
  }
82
89
 
83
- export interface InputEventResult {
84
- readonly action: "continue" | "handled";
85
- }
90
+ export type InputEventResult =
91
+ | { readonly action: "continue" }
92
+ | { readonly action: "handled" }
93
+ | { readonly action: "defer" };
86
94
 
87
95
  export interface InputContext {
88
96
  readonly streaming: boolean;
@@ -163,6 +171,7 @@ export class UndoControllerImpl implements UndoController {
163
171
  private locked = false;
164
172
  private historyPaused = false;
165
173
  private operationInFlight = false;
174
+ private promptDeferralInFlight = false;
166
175
  private lastSafetyManifestId: ManifestId | null = null;
167
176
 
168
177
  constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
@@ -182,6 +191,7 @@ export class UndoControllerImpl implements UndoController {
182
191
  }
183
192
 
184
193
  async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
194
+ if (this.promptDeferralInFlight) return { action: "defer" };
185
195
  if (this.locked || this.operationInFlight) return { action: "handled" };
186
196
  if (context.streaming || text.length === 0) return { action: "continue" };
187
197
  try {
@@ -381,79 +391,103 @@ export class UndoControllerImpl implements UndoController {
381
391
  targetManifestId?: ManifestId,
382
392
  ): Promise<OperationResult> {
383
393
  if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
394
+ const profile = new OperationProfiler();
395
+ const done = (result: OperationResult): OperationResult => profile.attach(result);
384
396
  this.operationInFlight = true;
397
+ this.promptDeferralInFlight = true;
385
398
  this.lastSafetyManifestId = null;
386
399
  let lease: { release(): Promise<void> } | undefined;
387
400
  try {
388
- if (!await this.ensureIdle()) return { code: "idle_timeout", changedFiles: 0 };
401
+ if (!await profile.measure("idle", () => this.ensureIdle())) {
402
+ return done({ code: "idle_timeout", changedFiles: 0 });
403
+ }
389
404
  try {
390
- lease = await this.dependencies.acquireWorkspaceLock();
405
+ lease = await profile.measure("lock", () => this.dependencies.acquireWorkspaceLock());
391
406
  } catch {
392
- return { code: "busy", changedFiles: 0 };
407
+ return done({ code: "busy", changedFiles: 0 });
393
408
  }
394
409
  let rollback: SnapshotManifest;
395
410
  try {
396
- rollback = await this.dependencies.capture();
411
+ rollback = await profile.measure("capture", () =>
412
+ this.dependencies.capture(checkpoint.changedPaths));
397
413
  } catch {
398
- return { code: "capture_failed", changedFiles: 0 };
414
+ return done({ code: "capture_failed", changedFiles: 0 });
399
415
  }
400
416
  let target: SnapshotManifest;
401
417
  let plan: RestorePlan;
402
418
  let targetLogicalLeaf: string | null;
403
419
  try {
404
- target = await this.dependencies.loadManifest(
420
+ target = await profile.measure("load", () => this.dependencies.loadManifest(
405
421
  targetManifestId ?? (action === "undo" ? checkpoint.beforeManifestId : checkpoint.afterManifestId),
406
- );
407
- plan = await this.dependencies.planRestore(rollback, target, checkpoint.changedPaths);
422
+ ));
423
+ plan = await profile.measure("plan", () =>
424
+ this.dependencies.planRestore(rollback, target, checkpoint.changedPaths));
408
425
  targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
409
426
  } catch {
410
- return { code: "restore_failed_safe", changedFiles: 0 };
427
+ return done({ code: "restore_failed_safe", changedFiles: 0 });
411
428
  }
412
429
  const descriptor = this.createDescriptor(action, rollback, target, plan, targetLogicalLeaf);
413
- await this.dependencies.journal.prepare(descriptor, plan);
414
- const navigation = await this.dependencies.navigateSession(action, checkpoint);
430
+ await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
431
+ const navigation = await profile.measure("navigate", () =>
432
+ this.dependencies.navigateSession(action, checkpoint));
415
433
  if (navigation.cancelled) {
416
- await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
417
- await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
418
- return { code: "restore_failed_safe", changedFiles: 0 };
434
+ await profile.measure("journal", async () => {
435
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
436
+ await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
437
+ });
438
+ return done({ code: "restore_failed_safe", changedFiles: 0 });
419
439
  }
420
440
  if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
421
441
  this.locked = true;
422
- await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED");
423
- return { code: "recovery_required", changedFiles: 0 };
442
+ await profile.measure("journal", () =>
443
+ this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
444
+ return done({ code: "recovery_required", changedFiles: 0 });
424
445
  }
425
- await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
426
- observedLogicalLeaf: navigation.logicalLeafId,
446
+ await profile.measure("journal", async () => {
447
+ await this.dependencies.journal.setPhase(descriptor.opId, "SESSION_MOVED", {
448
+ observedLogicalLeaf: navigation.logicalLeafId,
449
+ });
450
+ await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
427
451
  });
428
- await this.dependencies.journal.setPhase(descriptor.opId, "APPLYING");
429
- const applied = await this.dependencies.applyRestore(plan, target, { opId: descriptor.opId });
430
- if (applied.code !== "ok") return this.compensate(descriptor, rollback, target, applied);
431
- await this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED");
452
+ const applied = await profile.measure("apply", () =>
453
+ this.dependencies.applyRestore(plan, target, { opId: descriptor.opId }));
454
+ if (applied.code !== "ok") {
455
+ return done(await profile.measure("compensate", () =>
456
+ this.compensate(descriptor, rollback, target, applied)));
457
+ }
458
+ await profile.measure("journal", () =>
459
+ this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED"));
432
460
  const cursor = this.createCursor(descriptor, action, checkpoint);
433
- const cursorResult = await this.dependencies.appendCursor(cursor);
461
+ const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
434
462
  if (cursorResult.kind === "recovery_required") {
435
463
  this.locked = true;
436
- await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
437
- return { code: "recovery_required", changedFiles: applied.verifiedPaths };
464
+ await profile.measure("journal", () =>
465
+ this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
466
+ return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
438
467
  }
439
468
  if (cursorResult.kind === "volatile") {
440
- return this.compensate(descriptor, rollback, target, {
469
+ return done(await profile.measure("compensate", () => this.compensate(descriptor, rollback, target, {
441
470
  code: "recovery_required",
442
471
  verifiedPaths: applied.verifiedPaths,
443
472
  totalPaths: applied.totalPaths,
444
- });
473
+ })));
445
474
  }
446
- await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
447
- await this.dependencies.journal.markCommitted(descriptor.opId);
475
+ await profile.measure("commit", async () => {
476
+ await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
477
+ await this.dependencies.journal.markCommitted(descriptor.opId);
478
+ });
448
479
  this.lastSafetyManifestId = rollback.manifestId;
449
- return { code: "ok", changedFiles: applied.verifiedPaths };
480
+ return done({ code: "ok", changedFiles: applied.verifiedPaths });
450
481
  } catch {
451
482
  this.locked = true;
452
- return { code: "recovery_required", changedFiles: 0 };
483
+ return done({ code: "recovery_required", changedFiles: 0 });
453
484
  } finally {
454
- if (lease !== undefined) {
455
- await lease.release().catch(() => { this.locked = true; });
485
+ const activeLease = lease;
486
+ if (activeLease !== undefined) {
487
+ await profile.measure("unlock", () =>
488
+ activeLease.release().catch(() => { this.locked = true; }));
456
489
  }
490
+ this.promptDeferralInFlight = false;
457
491
  this.operationInFlight = false;
458
492
  }
459
493
  }
@@ -536,7 +570,7 @@ export class UndoControllerImpl implements UndoController {
536
570
  plan: RestorePlan,
537
571
  targetLogicalLeaf: string | null,
538
572
  ): OperationDescriptor {
539
- const scopePaths = [...plan.deletePaths, ...plan.writePaths].sort();
573
+ const scopePaths = [...(plan.scopePaths ?? [...plan.deletePaths, ...plan.writePaths])].sort();
540
574
  const payload = {
541
575
  schemaVersion: 1 as const,
542
576
  opId: `op-${randomUUID()}`,
@@ -599,6 +633,31 @@ export class UndoControllerImpl implements UndoController {
599
633
  }
600
634
  }
601
635
 
636
+ class OperationProfiler {
637
+ private readonly durations = new Map<string, number>();
638
+
639
+ async measure<T>(phase: string, operation: () => Promise<T>): Promise<T> {
640
+ const started = performance.now();
641
+ try {
642
+ return await operation();
643
+ } finally {
644
+ this.durations.set(phase, (this.durations.get(phase) ?? 0) + performance.now() - started);
645
+ }
646
+ }
647
+
648
+ attach(result: OperationResult): OperationResult {
649
+ const total = [...this.durations.values()].reduce((sum, duration) => sum + duration, 0);
650
+ if (total < 1_000) return result;
651
+ return {
652
+ ...result,
653
+ timings: [...this.durations].map(([phase, durationMs]) => ({
654
+ phase,
655
+ durationMs: Math.round(durationMs),
656
+ })),
657
+ };
658
+ }
659
+ }
660
+
602
661
  function noop(): OperationResult {
603
662
  return { code: "noop", changedFiles: 0 };
604
663
  }
@@ -1,4 +1,4 @@
1
- import { open, readFile } from "node:fs/promises";
1
+ import { open, readFile, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  import { fsyncDirectory } from "./atomic-fs.ts";
@@ -14,6 +14,24 @@ export interface MutationIntent {
14
14
  readonly targetFingerprint: string;
15
15
  }
16
16
 
17
+ export interface MutationAdvance {
18
+ readonly ordinal: number;
19
+ readonly states: readonly MutationState[];
20
+ }
21
+
22
+ interface JournalRecords {
23
+ readonly latest: readonly MutationRecord[];
24
+ readonly tail: MutationRecord | undefined;
25
+ readonly durableEnd: number;
26
+ readonly hasNonDurableTail: boolean;
27
+ readonly fileExisted: boolean;
28
+ }
29
+
30
+ interface CachedJournalRecords {
31
+ readonly records: JournalRecords;
32
+ readonly fingerprint: string;
33
+ }
34
+
17
35
  const stateOrder: readonly MutationState[] = [
18
36
  "INTENT",
19
37
  "SOURCE_QUARANTINED",
@@ -27,6 +45,7 @@ export class MutationJournal {
27
45
  private readonly path: string;
28
46
  private readonly opId: string;
29
47
  private mutationQueue: Promise<void> = Promise.resolve();
48
+ private cachedRecords: CachedJournalRecords | undefined;
30
49
 
31
50
  constructor(path: string, opId: string) {
32
51
  this.path = path;
@@ -46,33 +65,53 @@ export class MutationJournal {
46
65
  }
47
66
 
48
67
  begin(intent: MutationIntent): Promise<MutationRecord> {
49
- return this.enqueueMutation(() => this.beginMutation(intent));
68
+ return this.enqueueMutation(async () => (await this.beginManyMutation([intent]))[0]!);
69
+ }
70
+
71
+ beginMany(intents: readonly MutationIntent[]): Promise<readonly MutationRecord[]> {
72
+ return this.enqueueMutation(() => this.beginManyMutation(intents));
50
73
  }
51
74
 
52
- private async beginMutation(intent: MutationIntent): Promise<MutationRecord> {
75
+ private async beginManyMutation(intents: readonly MutationIntent[]): Promise<readonly MutationRecord[]> {
76
+ if (intents.length === 0) throw new Error("mutation 批量 intent 不能为空");
53
77
  const current = await this.readRecords();
54
- const content = {
55
- schemaVersion: 1 as const,
56
- opId: this.opId,
57
- ordinal: current.latest.length + 1,
58
- state: "INTENT" as const,
59
- kind: intent.kind,
60
- path: intent.path,
61
- sourceArtifact: intent.sourceArtifact,
62
- targetArtifact: intent.targetArtifact,
63
- sourceFingerprint: intent.sourceFingerprint,
64
- targetFingerprint: intent.targetFingerprint,
65
- previousChecksum: current.tail?.checksum ?? null,
66
- };
67
- const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
68
- await this.append(record, current.durableEnd, current.hasNonDurableTail);
69
- return record;
78
+ let tail = current.tail;
79
+ const records: MutationRecord[] = [];
80
+ for (let index = 0; index < intents.length; index += 1) {
81
+ const intent = intents[index]!;
82
+ const content = {
83
+ schemaVersion: 1 as const,
84
+ opId: this.opId,
85
+ ordinal: current.latest.length + index + 1,
86
+ state: "INTENT" as const,
87
+ kind: intent.kind,
88
+ path: intent.path,
89
+ sourceArtifact: intent.sourceArtifact,
90
+ targetArtifact: intent.targetArtifact,
91
+ sourceFingerprint: intent.sourceFingerprint,
92
+ targetFingerprint: intent.targetFingerprint,
93
+ previousChecksum: tail?.checksum ?? null,
94
+ };
95
+ const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
96
+ records.push(record);
97
+ tail = record;
98
+ }
99
+ await this.append(records, current);
100
+ return records;
70
101
  }
71
102
 
72
103
  advance(ordinal: number, state: MutationState): Promise<MutationRecord> {
73
104
  return this.enqueueMutation(() => this.advanceMutation(ordinal, state));
74
105
  }
75
106
 
107
+ advanceMany(ordinal: number, states: readonly MutationState[]): Promise<readonly MutationRecord[]> {
108
+ return this.enqueueMutation(() => this.advanceManyMutation(ordinal, states));
109
+ }
110
+
111
+ advanceBatch(advances: readonly MutationAdvance[]): Promise<readonly MutationRecord[]> {
112
+ return this.enqueueMutation(() => this.advanceBatchMutation(advances));
113
+ }
114
+
76
115
  markRollbackCleaned(ordinal: number): Promise<MutationRecord> {
77
116
  return this.enqueueMutation(() => this.markRollbackCleanedMutation(ordinal));
78
117
  }
@@ -87,16 +126,58 @@ export class MutationJournal {
87
126
  }
88
127
 
89
128
  private async advanceMutation(ordinal: number, state: MutationState): Promise<MutationRecord> {
129
+ const [record] = await this.advanceManyMutation(ordinal, [state]);
130
+ return record!;
131
+ }
132
+
133
+ private advanceManyMutation(
134
+ ordinal: number,
135
+ states: readonly MutationState[],
136
+ ): Promise<readonly MutationRecord[]> {
137
+ return this.advanceBatchMutation([{ ordinal, states }]);
138
+ }
139
+
140
+ private async advanceBatchMutation(
141
+ advances: readonly MutationAdvance[],
142
+ ): Promise<readonly MutationRecord[]> {
143
+ if (advances.length === 0 || advances.some((advance) => advance.states.length === 0)) {
144
+ throw new Error("mutation 批量状态不能为空");
145
+ }
90
146
  const current = await this.readRecords();
91
- const previous = current.latest[ordinal - 1];
92
- if (previous === undefined || stateOrder.indexOf(state) !== stateOrder.indexOf(previous.state) + 1) {
93
- throw new Error(`mutation state 必须严格推进:${previous?.state ?? "missing"} -> ${state}`);
147
+ const latest = [...current.latest];
148
+ let tail = current.tail;
149
+ const records: MutationRecord[] = [];
150
+ for (const advance of advances) {
151
+ for (const state of advance.states) {
152
+ const previous = latest[advance.ordinal - 1];
153
+ if (previous === undefined || stateOrder.indexOf(state) !== stateOrder.indexOf(previous.state) + 1) {
154
+ throw new Error(`mutation state 必须严格推进:${previous?.state ?? "missing"} -> ${state}`);
155
+ }
156
+ const content = {
157
+ schemaVersion: previous.schemaVersion,
158
+ opId: previous.opId,
159
+ ordinal: previous.ordinal,
160
+ state,
161
+ kind: previous.kind,
162
+ path: previous.path,
163
+ sourceArtifact: previous.sourceArtifact,
164
+ targetArtifact: previous.targetArtifact,
165
+ sourceFingerprint: previous.sourceFingerprint,
166
+ targetFingerprint: previous.targetFingerprint,
167
+ previousChecksum: tail?.checksum ?? null,
168
+ };
169
+ const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
170
+ records.push(record);
171
+ latest[advance.ordinal - 1] = record;
172
+ tail = record;
173
+ }
94
174
  }
95
- return this.appendState(current, previous, state);
175
+ await this.append(records, current);
176
+ return records;
96
177
  }
97
178
 
98
179
  private async appendState(
99
- current: Awaited<ReturnType<MutationJournal["readRecords"]>>,
180
+ current: JournalRecords,
100
181
  previous: MutationRecord,
101
182
  state: MutationState,
102
183
  ): Promise<MutationRecord> {
@@ -114,7 +195,7 @@ export class MutationJournal {
114
195
  previousChecksum: current.tail?.checksum ?? null,
115
196
  };
116
197
  const record = assertMutationRecord({ ...content, checksum: checksum(canonicalJson(content)) });
117
- await this.append(record, current.durableEnd, current.hasNonDurableTail);
198
+ await this.append([record], current);
118
199
  return record;
119
200
  }
120
201
 
@@ -140,21 +221,26 @@ export class MutationJournal {
140
221
  }
141
222
  }
142
223
 
143
- private async readRecords(): Promise<{
144
- readonly latest: readonly MutationRecord[];
145
- readonly tail: MutationRecord | undefined;
146
- readonly durableEnd: number;
147
- readonly hasNonDurableTail: boolean;
148
- }> {
224
+ private async readRecords(): Promise<JournalRecords> {
225
+ const before = await journalFileFingerprint(this.path);
226
+ if (before !== null && this.cachedRecords?.fingerprint === before) {
227
+ return this.cachedRecords.records;
228
+ }
149
229
  let bytes: Buffer;
150
230
  try {
151
231
  bytes = await readFile(this.path);
152
232
  } catch (error) {
153
233
  if (hasErrorCode(error, "ENOENT")) {
154
- return { latest: [], tail: undefined, durableEnd: 0, hasNonDurableTail: false };
234
+ const records = emptyJournalRecords();
235
+ this.cachedRecords = undefined;
236
+ return records;
155
237
  }
156
238
  throw error;
157
239
  }
240
+ const after = await journalFileFingerprint(this.path);
241
+ if (before === null || after === null || before !== after) {
242
+ throw new Error("mutation journal 读取期间发生变化");
243
+ }
158
244
 
159
245
  const durableEnd = bytes.at(-1) === 0x0a ? bytes.length : bytes.lastIndexOf(0x0a) + 1;
160
246
  const durable = bytes.subarray(0, durableEnd).toString("utf8");
@@ -163,7 +249,7 @@ export class MutationJournal {
163
249
  let tail: MutationRecord | undefined;
164
250
 
165
251
  for (const line of lines) {
166
- const record = assertMutationRecord(JSON.parse(line));
252
+ const record = Object.freeze(assertMutationRecord(JSON.parse(line)));
167
253
  if (record.opId !== this.opId) throw new Error("mutation record opId 与 journal 不匹配");
168
254
  if (record.previousChecksum !== (tail?.checksum ?? null)) {
169
255
  throw new Error("mutation journal hash chain 断裂");
@@ -185,24 +271,78 @@ export class MutationJournal {
185
271
  tail = record;
186
272
  }
187
273
 
188
- return { latest, tail, durableEnd, hasNonDurableTail: durableEnd !== bytes.length };
274
+ const records: JournalRecords = {
275
+ latest: Object.freeze(latest),
276
+ tail,
277
+ durableEnd,
278
+ hasNonDurableTail: durableEnd !== bytes.length,
279
+ fileExisted: true,
280
+ };
281
+ this.cachedRecords = { records, fingerprint: after };
282
+ return records;
189
283
  }
190
284
 
191
- private async append(record: MutationRecord, durableEnd: number, hasNonDurableTail: boolean): Promise<void> {
285
+ private async append(records: readonly MutationRecord[], current: JournalRecords): Promise<void> {
192
286
  const directory = dirname(this.path);
287
+ const lines = records.map((record) => `${canonicalJson(record)}\n`).join("");
193
288
  const handle = await open(this.path, "a+", 0o600);
194
289
  try {
195
- if (hasNonDurableTail) {
196
- await handle.truncate(durableEnd);
290
+ if (current.hasNonDurableTail) {
291
+ await handle.truncate(current.durableEnd);
197
292
  await handle.sync();
198
- await fsyncDirectory(directory);
199
293
  }
200
- await handle.writeFile(`${canonicalJson(record)}\n`);
294
+ await handle.writeFile(lines);
201
295
  await handle.sync();
202
296
  } finally {
203
297
  await handle.close();
204
298
  }
205
- await fsyncDirectory(directory);
299
+ if (!current.fileExisted) await fsyncDirectory(directory);
300
+ const fingerprint = await journalFileFingerprint(this.path);
301
+ if (fingerprint === null) throw new Error("mutation journal append 后丢失");
302
+ const latest = [...current.latest];
303
+ let tail = current.tail;
304
+ for (const record of records) {
305
+ const durableRecord = Object.freeze({ ...record });
306
+ latest[record.ordinal - 1] = durableRecord;
307
+ tail = durableRecord;
308
+ }
309
+ this.cachedRecords = {
310
+ records: {
311
+ latest: Object.freeze(latest),
312
+ tail,
313
+ durableEnd: current.durableEnd + Buffer.byteLength(lines),
314
+ hasNonDurableTail: false,
315
+ fileExisted: true,
316
+ },
317
+ fingerprint,
318
+ };
319
+ }
320
+ }
321
+
322
+ function emptyJournalRecords(): JournalRecords {
323
+ return {
324
+ latest: Object.freeze([]),
325
+ tail: undefined,
326
+ durableEnd: 0,
327
+ hasNonDurableTail: false,
328
+ fileExisted: false,
329
+ };
330
+ }
331
+
332
+ async function journalFileFingerprint(path: string): Promise<string | null> {
333
+ try {
334
+ const metadata = await stat(path, { bigint: true });
335
+ return [
336
+ metadata.dev,
337
+ metadata.ino,
338
+ metadata.mode,
339
+ metadata.size,
340
+ metadata.mtimeNs,
341
+ metadata.ctimeNs,
342
+ ].join(":");
343
+ } catch (error) {
344
+ if (hasErrorCode(error, "ENOENT")) return null;
345
+ throw error;
206
346
  }
207
347
  }
208
348
 
package/src/pi-runtime.ts CHANGED
@@ -38,12 +38,12 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
38
38
  const workspaceLock = new WorkspaceLock();
39
39
  let commandContext: ExtensionCommandContext | undefined;
40
40
  let internalNavigation = false;
41
- const capture = async () => {
41
+ const capture = async (scopePaths?: readonly string[]) => {
42
42
  const topology = await discovery.discover(context.cwd);
43
43
  if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
44
44
  throw new Error("workspace identity 已变化");
45
45
  }
46
- return store.capture(topology);
46
+ return store.capture(topology, scopePaths);
47
47
  };
48
48
  const recovery = new JournalRecovery({
49
49
  sessionIdentity,