@davideasden/pi-undo 0.2.22 → 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.
- package/README.md +36 -3
- package/extensions/pi-undo.ts +75 -15
- package/package.json +7 -6
- package/src/controller.ts +377 -45
- package/src/git-runner.ts +356 -51
- package/src/journal.ts +5 -2
- package/src/model.ts +2 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +49 -56
- package/src/operation-context.ts +266 -0
- package/src/pi-runtime.ts +119 -15
- package/src/quarantine.ts +3 -0
- package/src/recovery.ts +3 -3
- package/src/restore-engine.ts +102 -76
- package/src/root-discovery.ts +46 -5
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
package/src/snapshot-store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1687
|
-
|
|
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;
|
package/src/status-reporter.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/workspace-lock.ts
CHANGED
|
@@ -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()),
|