@davideasden/pi-undo 0.2.24 → 0.2.25

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.
@@ -22,8 +22,9 @@ import type {
22
22
  } from "./model.ts";
23
23
  import type { NativeMetadataEntry, NativeMetadataPort } from "./native-metadata.ts";
24
24
  import { NativeMetadataInspector } from "./native-metadata.ts";
25
+ import { allCompleted, checkOperation, configuredTimeout, OperationError, operationProcessOptions, rethrowOperationFailure } from "./operation-context.ts";
25
26
  import { assertNoSymlinkEscape, assertNoSymlinkParents, pathSetsOverlap, relativeSafePath } from "./path-safety.ts";
26
- import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
27
+ import { RootDiscovery, type RootDiscoveryReason, type RootTopology } from "./root-discovery.ts";
27
28
  import { WorkspaceLock } from "./workspace-lock.ts";
28
29
 
29
30
  const SCHEMA_VERSION = 1;
@@ -147,7 +148,10 @@ export interface SnapshotStoreOptions {
147
148
 
148
149
  export interface CaptureOptions {
149
150
  readonly excludePaths?: readonly string[];
150
- /** 调用方刚完成 topology discovery 时跳过重复的捕获前校验。捕获后校验仍然执行。 */
151
+ /**
152
+ * 调用方刚完成 topology discovery 时跳过重复的入口校验;枚举或捕获结束后的校验仍然执行,
153
+ * 因此校验窗口内出现的漂移依然会被拒绝。仅限调用方与本次枚举之间没有文件变更时使用。
154
+ */
151
155
  readonly topologyAlreadyValidated?: boolean;
152
156
  }
153
157
 
@@ -280,7 +284,7 @@ export class SnapshotStore {
280
284
  const coverage = captureCoverage(topology.workspaceIdentity, scope);
281
285
  const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
282
286
  if (options.topologyAlreadyValidated !== true) {
283
- await this.assertTopology(topology, "捕获前 topology 已变化");
287
+ await this.assertTopology(topology, "捕获前 topology 已变化", "safety-snapshot");
284
288
  }
285
289
  const brokenRoots = brokenRootPaths(topology);
286
290
  if (brokenRoots.length > 0) {
@@ -329,7 +333,7 @@ export class SnapshotStore {
329
333
  const cacheUpdates = capturedRoots.flatMap((captured) =>
330
334
  captured.cacheUpdate === undefined ? [] : [captured.cacheUpdate]);
331
335
 
332
- await this.assertTopology(topology, "捕获期间 topology 已变化");
336
+ await this.assertTopology(topology, "捕获期间 topology 已变化", "safety-snapshot");
333
337
  const content = {
334
338
  schemaVersion: SCHEMA_VERSION as 1,
335
339
  workspaceIdentity: topology.workspaceIdentity,
@@ -388,14 +392,14 @@ export class SnapshotStore {
388
392
  throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
389
393
  }
390
394
  if (options.topologyAlreadyValidated !== true) {
391
- await this.assertTopology(topology, "捕获前 topology 已变化");
395
+ await this.assertTopology(topology, "捕获前 topology 已变化", "safety-snapshot");
392
396
  }
393
397
  const brokenRoots = brokenRootPaths(topology);
394
398
  if (brokenRoots.length > 0) {
395
399
  throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入 baseline 校验: ${brokenRoots.join(", ")}`);
396
400
  }
397
401
  if (await this.isBaselineFresh(topology, baseline, scope, options)) {
398
- await this.assertTopology(topology, "捕获期间 topology 已变化");
402
+ await this.assertTopology(topology, "捕获期间 topology 已变化", "safety-snapshot");
399
403
  await this.touchStore(this.storeDirectory(topology));
400
404
  return baseline;
401
405
  }
@@ -537,7 +541,8 @@ export class SnapshotStore {
537
541
  await this.assertVisibleLeavesUnchanged(absoluteRoot, leaves, transactionDirectory);
538
542
  }
539
543
  return true;
540
- } catch {
544
+ } catch (error) {
545
+ rethrowOperationFailure(error);
541
546
  // baseline 证据读取失败时不复用旧快照;完整 capture 会重新建立对象和缓存。
542
547
  return false;
543
548
  } finally {
@@ -564,7 +569,9 @@ export class SnapshotStore {
564
569
  throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
565
570
  }
566
571
  const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
567
- await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
572
+ if (options.topologyAlreadyValidated !== true) {
573
+ await this.assertTopology(topology, "可见路径枚举前 topology 已变化", "visible-paths-pre");
574
+ }
568
575
  const brokenRoots = brokenRootPaths(topology);
569
576
  if (brokenRoots.length > 0) {
570
577
  throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入可见路径枚举: ${brokenRoots.join(", ")}`);
@@ -601,7 +608,7 @@ export class SnapshotStore {
601
608
  result.add(workspaceRelativePath(root.relativeRoot, relativePath));
602
609
  }
603
610
  }
604
- await this.assertTopology(topology, "可见路径枚举期间 topology 已变化");
611
+ await this.assertTopology(topology, "可见路径枚举期间 topology 已变化", "visible-paths-post");
605
612
  return [...result].sort(comparePaths);
606
613
  } catch (error) {
607
614
  if (error instanceof SnapshotStoreError) throw error;
@@ -1358,7 +1365,7 @@ export class SnapshotStore {
1358
1365
  const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
1359
1366
  for (const excluded of exclusions) pathspecs.push(excludeLiteralPathspec(excluded));
1360
1367
  const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
1361
- const [output, deletedOutput] = await Promise.all([
1368
+ const [output, deletedOutput] = await allCompleted([
1362
1369
  this.runGitBytes([
1363
1370
  ...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
1364
1371
  "ls-files",
@@ -1668,23 +1675,27 @@ export class SnapshotStore {
1668
1675
  }
1669
1676
 
1670
1677
  private async runGit(args: readonly string[], options: GitRunOptions = {}): Promise<string> {
1671
- const result = await this.git.run(args, options);
1678
+ const result = await this.git.run(args, injectOperationBudget(options));
1672
1679
  if (result.killed) {
1673
- throw new SnapshotStoreError("capture_failed", "Git 命令未正常结束");
1680
+ throw new OperationError(result.timedOut ? "operation_timeout" : "operation_cancelled", "Git 命令已停止");
1674
1681
  }
1675
1682
  return result.stdout;
1676
1683
  }
1677
1684
 
1678
1685
  private async runGitBytes(args: readonly string[], options: GitRunOptions = {}): Promise<Uint8Array> {
1679
- const result = await this.git.run(args, options);
1686
+ const result = await this.git.run(args, injectOperationBudget(options));
1680
1687
  if (result.killed) {
1681
- throw new SnapshotStoreError("capture_failed", "Git 命令未正常结束");
1688
+ throw new OperationError(result.timedOut ? "operation_timeout" : "operation_cancelled", "Git 命令已停止");
1682
1689
  }
1683
1690
  return result.stdoutBytes;
1684
1691
  }
1685
1692
 
1686
- private async assertTopology(expected: RootTopology, message: string): Promise<void> {
1687
- const actual = await this.discovery.discover(expected.workspaceIdentity);
1693
+ private async assertTopology(
1694
+ expected: RootTopology,
1695
+ message: string,
1696
+ reason: RootDiscoveryReason,
1697
+ ): Promise<void> {
1698
+ const actual = await this.discovery.discover(expected.workspaceIdentity, reason);
1688
1699
  const rootKindsMatch = actual.roots.length === expected.roots.length && actual.roots.every((root, index) => {
1689
1700
  const expectedRoot = expected.roots[index];
1690
1701
  return expectedRoot !== undefined &&
@@ -1966,6 +1977,17 @@ function rootStoreId(root: RootTopologyIdentity): string {
1966
1977
  }));
1967
1978
  }
1968
1979
 
1980
+ /** 为 GitRunner 调用注入 operation 预算:调用方 budget 优先,否则取当前 context 的剩余预算。 */
1981
+ function injectOperationBudget(options: GitRunOptions): GitRunOptions {
1982
+ if (options.timeoutMs !== undefined) return options;
1983
+ const budget = operationProcessOptions(configuredTimeout("PI_UNDO_GIT_TIMEOUT_MS", 120_000));
1984
+ return {
1985
+ ...options,
1986
+ timeoutMs: budget.timeoutMs,
1987
+ ...(options.signal === undefined && budget.signal !== undefined ? { signal: budget.signal } : {}),
1988
+ };
1989
+ }
1990
+
1969
1991
  function privateGitEnvironment(
1970
1992
  gitDirectory: string,
1971
1993
  workTree: string,
@@ -2242,6 +2264,7 @@ async function mapConcurrentOrdered<T, R>(
2242
2264
  const index = nextIndex;
2243
2265
  nextIndex += 1;
2244
2266
  try {
2267
+ checkOperation();
2245
2268
  result[index] = await operation(values[index]!);
2246
2269
  } catch (error) {
2247
2270
  if (!failed) failure = error;
@@ -15,11 +15,40 @@ export interface StatusContext {
15
15
  /** 统一管理 footer、通知与 prompt 回填,避免状态文本泄露运行时细节。 */
16
16
  export class StatusReporter {
17
17
  private readonly context: StatusContext;
18
+ private progress: { opId: string; phase: string; started: number } | undefined;
19
+ private progressTimer: ReturnType<typeof setInterval> | undefined;
18
20
 
19
21
  constructor(context: StatusContext) {
20
22
  this.context = context;
21
23
  }
22
24
 
25
+ startOperation(opId: string): void {
26
+ this.endOperation();
27
+ this.progress = { opId, phase: "等待检查点", started: Date.now() };
28
+ this.progressTimer = setInterval(() => this.renderProgress(), 1_000);
29
+ this.progressTimer.unref();
30
+ }
31
+
32
+ setOperationPhase(phase: string): void {
33
+ if (this.progress === undefined) return;
34
+ this.progress.phase = phase;
35
+ }
36
+
37
+ endOperation(): void {
38
+ if (this.progressTimer !== undefined) clearInterval(this.progressTimer);
39
+ this.progressTimer = undefined;
40
+ this.progress = undefined;
41
+ }
42
+
43
+ private renderProgress(): void {
44
+ const progress = this.progress;
45
+ if (progress === undefined) return;
46
+ const scanned = /^scan_directories:(\d+)$/.exec(progress.phase)?.[1];
47
+ const label = scanned !== undefined ? `扫描目录 ${scanned}`
48
+ : progress.phase.startsWith("discover_roots") ? "扫描目录" : phaseLabels[progress.phase] ?? progress.phase;
49
+ this.setStatus(`${label} ${Math.floor((Date.now() - progress.started) / 1_000)}s op:${progress.opId}`);
50
+ }
51
+
23
52
  setReady(undoCount: number, redoCount: number): void {
24
53
  this.setStatus(`ready undo:${undoCount} redo:${redoCount}`);
25
54
  }
@@ -41,15 +70,19 @@ export class StatusReporter {
41
70
  }
42
71
 
43
72
  clear(): void {
73
+ this.endOperation();
44
74
  this.context.ui.setStatus("pi-undo", undefined);
45
75
  }
46
76
 
47
77
  result(result: OperationResult, totalMs?: number): void {
78
+ const opId = this.progress?.opId;
79
+ this.endOperation();
48
80
  const details = result.message === undefined ? "" : ` ${sanitize(result.message)}`;
49
81
  const timing = totalMs !== undefined && totalMs >= 1_000
50
82
  ? formatTiming(totalMs, result.timings)
51
83
  : "";
52
- const message = sanitize(`${result.code} files:${result.changedFiles}${details}${timing}`);
84
+ const diagnostic = opId !== undefined && (result.code !== "ok" || (totalMs ?? 0) >= 1_000) ? ` op:${opId}` : "";
85
+ const message = sanitize(`${result.code} files:${result.changedFiles}${details}${timing}${diagnostic}`);
53
86
  const type = result.code === "ok" || result.code === "noop"
54
87
  ? "info"
55
88
  : result.code === "recovery_required" ? "error" : "warning";
@@ -72,6 +105,13 @@ export class StatusReporter {
72
105
  }
73
106
  }
74
107
 
108
+ const phaseLabels: Record<string, string> = {
109
+ idle: "等待 Agent 停止", checkpoint: "等待检查点", lock: "等待工作区锁",
110
+ capture: "捕获安全快照", load: "读取快照", plan: "准备恢复", journal: "记录事务",
111
+ navigate: "切换会话", apply: "恢复文件", cursor: "提交历史", commit: "完成持久化",
112
+ compensate: "恢复操作前状态", unlock: "释放工作区锁", discover_gitlinks: "检查子模块", scan_directories: "扫描目录",
113
+ };
114
+
75
115
  function formatTiming(totalMs: number, timings: OperationResult["timings"]): string {
76
116
  const phases = [...(timings ?? [])]
77
117
  .filter((timing) => timing.durationMs >= 5)
@@ -3,6 +3,8 @@ import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile }
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { checkOperation, operationHasUnconfirmedExit } from "./operation-context.ts";
7
+
6
8
  const LEGACY_OWNER_FILE = "owner.json";
7
9
  const OWNER_PREFIX = "owner.";
8
10
  const OWNER_SUFFIX = ".json";
@@ -86,7 +88,7 @@ export class WorkspaceLock {
86
88
  try {
87
89
  return await fn();
88
90
  } finally {
89
- await lease.release();
91
+ if (!operationHasUnconfirmedExit()) await lease.release();
90
92
  }
91
93
  });
92
94
  }
@@ -100,6 +102,7 @@ export class WorkspaceLock {
100
102
  const deadline = this.clock() + this.acquireTimeoutMs;
101
103
 
102
104
  while (true) {
105
+ checkOperation();
103
106
  const owner: LockOwner = {
104
107
  pid: process.pid,
105
108
  processStartedAt: currentProcessStartedAt(this.clock()),