@davideasden/pi-undo 0.2.24 → 0.2.26
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 +38 -1
- package/docs/native-performance.md +52 -0
- package/extensions/pi-undo.ts +75 -15
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +10 -8
- 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-capabilities.ts +34 -0
- package/src/native-directory-scan.ts +114 -0
- package/src/native-metadata.ts +47 -87
- package/src/native-restore.ts +61 -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 +115 -88
- package/src/root-discovery.ts +99 -18
- package/src/snapshot-store.ts +39 -16
- package/src/status-reporter.ts +41 -1
- package/src/workspace-lock.ts +4 -1
package/src/restore-engine.ts
CHANGED
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
} from "./durable-pack.ts";
|
|
18
18
|
import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
|
|
19
19
|
import { MutationJournal } from "./mutation-journal.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { allCompleted, checkOperation, rethrowOperationFailure, isUnconfirmedExit, operationFailure, withRecoveryBudget } from "./operation-context.ts";
|
|
21
|
+
import { createNativeFileBatch, nativeRestoreCapability } from "./native-restore.ts";
|
|
21
22
|
import { recoverPackedMutations } from "./packed-recovery.ts";
|
|
22
23
|
import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
|
|
23
24
|
import {
|
|
@@ -61,6 +62,7 @@ export interface RestoreResult {
|
|
|
61
62
|
verifiedPaths: number;
|
|
62
63
|
totalPaths: number;
|
|
63
64
|
postFingerprint?: string;
|
|
65
|
+
failureCode?: "operation_cancelled" | "operation_timeout";
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export interface RestoreEngine {
|
|
@@ -97,6 +99,16 @@ interface OwnedPath {
|
|
|
97
99
|
readonly root: SnapshotRoot;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
interface VisibleSubsetCheck {
|
|
103
|
+
/**
|
|
104
|
+
* 调用方刚刚完成 topology discovery 且随后没有文件 mutation 时为 true,可跳过枚举入口的重复校验。
|
|
105
|
+
* 枚举结束后的校验仍然执行,所以该窗口内的漂移依然被拒绝。
|
|
106
|
+
*/
|
|
107
|
+
readonly topologyValidated: boolean;
|
|
108
|
+
readonly extraExclusions?: readonly string[];
|
|
109
|
+
readonly ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[];
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
interface PreparedRestorePlan {
|
|
101
113
|
readonly plan: RestorePlan;
|
|
102
114
|
readonly currentPaths: ReadonlyMap<string, OwnedPath>;
|
|
@@ -207,12 +219,12 @@ export class RestoreEngine {
|
|
|
207
219
|
const canonicalScopePaths = scope === undefined ? undefined : [...scope];
|
|
208
220
|
assertCompatibleManifests(current, target, scope);
|
|
209
221
|
const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
|
|
210
|
-
await
|
|
222
|
+
await allCompleted([
|
|
211
223
|
this.store.assertComplete(current.manifestId, canonicalScopePaths),
|
|
212
224
|
this.store.assertComplete(target.manifestId, canonicalScopePaths),
|
|
213
225
|
]);
|
|
214
226
|
|
|
215
|
-
const [currentPaths, targetPaths] = await
|
|
227
|
+
const [currentPaths, targetPaths] = await allCompleted([
|
|
216
228
|
this.readOwnedPaths(current, canonicalScopePaths),
|
|
217
229
|
this.readOwnedPaths(target, canonicalScopePaths),
|
|
218
230
|
]);
|
|
@@ -293,12 +305,13 @@ export class RestoreEngine {
|
|
|
293
305
|
const expected = await this.plan(current, target, scopePaths);
|
|
294
306
|
if (expected.planDigest !== cached.planDigest) return false;
|
|
295
307
|
}
|
|
296
|
-
const topology = await this.discovery.discover(this.workspaceRoot);
|
|
308
|
+
const topology = await this.discovery.discover(this.workspaceRoot, "safety-snapshot");
|
|
297
309
|
this.assertCurrentTopology(current, target, topology);
|
|
298
310
|
const native = await createNativeFileBatch({
|
|
299
311
|
workspaceRoot: this.workspaceRoot,
|
|
300
312
|
planDigest: cached.planDigest,
|
|
301
313
|
journal: cacheJournal,
|
|
314
|
+
requiredCapability: nativeRestoreCapability(cached.pack),
|
|
302
315
|
});
|
|
303
316
|
if (native === undefined || !await native.verifySource(cached.pack)) return false;
|
|
304
317
|
if (fromPersistentIndex) {
|
|
@@ -313,7 +326,8 @@ export class RestoreEngine {
|
|
|
313
326
|
);
|
|
314
327
|
}
|
|
315
328
|
return true;
|
|
316
|
-
} catch {
|
|
329
|
+
} catch (error) {
|
|
330
|
+
rethrowOperationFailure(error);
|
|
317
331
|
return false;
|
|
318
332
|
}
|
|
319
333
|
}
|
|
@@ -517,7 +531,8 @@ export class RestoreEngine {
|
|
|
517
531
|
private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
|
|
518
532
|
try {
|
|
519
533
|
return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
|
|
520
|
-
} catch {
|
|
534
|
+
} catch (error) {
|
|
535
|
+
rethrowOperationFailure(error);
|
|
521
536
|
return undefined;
|
|
522
537
|
}
|
|
523
538
|
}
|
|
@@ -737,7 +752,7 @@ export class RestoreEngine {
|
|
|
737
752
|
compatibilityMode: boolean,
|
|
738
753
|
pinsDeferred: boolean,
|
|
739
754
|
): Promise<RestoreResult> {
|
|
740
|
-
const [current, storedTarget] = await
|
|
755
|
+
const [current, storedTarget] = await allCompleted([
|
|
741
756
|
this.store.loadManifest(plan.currentManifestId),
|
|
742
757
|
this.store.loadManifest(target.manifestId),
|
|
743
758
|
]);
|
|
@@ -773,13 +788,14 @@ export class RestoreEngine {
|
|
|
773
788
|
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) {
|
|
774
789
|
throw new Error("restore workspace root 必须使用 canonical identity");
|
|
775
790
|
}
|
|
776
|
-
topologyBefore = await this.discovery.discover(this.workspaceRoot);
|
|
791
|
+
topologyBefore = await this.discovery.discover(this.workspaceRoot, "restore-pre");
|
|
777
792
|
this.assertCurrentTopology(current, target, topologyBefore);
|
|
778
|
-
} catch {
|
|
793
|
+
} catch (error) {
|
|
794
|
+
rethrowOperationFailure(error);
|
|
779
795
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
780
796
|
}
|
|
781
797
|
const [currentPaths, targetPaths] = prepared === undefined
|
|
782
|
-
? await
|
|
798
|
+
? await allCompleted([
|
|
783
799
|
this.readOwnedPaths(current, plan.scopePaths),
|
|
784
800
|
this.readOwnedPaths(target, plan.scopePaths),
|
|
785
801
|
])
|
|
@@ -796,14 +812,18 @@ export class RestoreEngine {
|
|
|
796
812
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
797
813
|
}
|
|
798
814
|
try {
|
|
815
|
+
// 兼容恢复路径会先在此窗口内补偿 pending mutations,因此只有非补偿路径可以跳过枚举入口的重复发现。
|
|
799
816
|
await this.assertCompleteVisibleSubset(
|
|
800
817
|
topologyBefore,
|
|
801
818
|
[current, target],
|
|
802
819
|
options.mutationJournal,
|
|
803
|
-
|
|
804
|
-
|
|
820
|
+
{
|
|
821
|
+
topologyValidated: !compatibilityMode,
|
|
822
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
|
|
823
|
+
},
|
|
805
824
|
);
|
|
806
|
-
} catch {
|
|
825
|
+
} catch (error) {
|
|
826
|
+
rethrowOperationFailure(error);
|
|
807
827
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
808
828
|
}
|
|
809
829
|
let durablePack: DurablePack | undefined;
|
|
@@ -833,7 +853,8 @@ export class RestoreEngine {
|
|
|
833
853
|
entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
|
|
834
854
|
});
|
|
835
855
|
}
|
|
836
|
-
} catch {
|
|
856
|
+
} catch (error) {
|
|
857
|
+
rethrowOperationFailure(error);
|
|
837
858
|
await removeDurablePack(options.mutationJournal).catch(() => {});
|
|
838
859
|
}
|
|
839
860
|
}
|
|
@@ -842,6 +863,7 @@ export class RestoreEngine {
|
|
|
842
863
|
workspaceRoot: this.workspaceRoot,
|
|
843
864
|
planDigest: plan.planDigest,
|
|
844
865
|
journal: options.mutationJournal,
|
|
866
|
+
requiredCapability: nativeRestoreCapability(durablePack),
|
|
845
867
|
})
|
|
846
868
|
: undefined;
|
|
847
869
|
if (durablePack !== undefined && nativeFileBatch === undefined) {
|
|
@@ -873,7 +895,8 @@ export class RestoreEngine {
|
|
|
873
895
|
}
|
|
874
896
|
try {
|
|
875
897
|
await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
|
|
876
|
-
} catch {
|
|
898
|
+
} catch (error) {
|
|
899
|
+
rethrowOperationFailure(error);
|
|
877
900
|
// 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
|
|
878
901
|
}
|
|
879
902
|
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
@@ -911,14 +934,16 @@ export class RestoreEngine {
|
|
|
911
934
|
);
|
|
912
935
|
await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
|
|
913
936
|
|
|
914
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
937
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
915
938
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
916
939
|
await this.assertCompleteVisibleSubset(
|
|
917
940
|
topologyAfter,
|
|
918
941
|
[target],
|
|
919
942
|
options.mutationJournal,
|
|
920
|
-
|
|
921
|
-
|
|
943
|
+
{
|
|
944
|
+
topologyValidated: true,
|
|
945
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
946
|
+
},
|
|
922
947
|
);
|
|
923
948
|
const verification = await this.verifyTarget(
|
|
924
949
|
target,
|
|
@@ -936,20 +961,17 @@ export class RestoreEngine {
|
|
|
936
961
|
return await this.mutationsAreClean(options.mutationJournal)
|
|
937
962
|
? result
|
|
938
963
|
: { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
|
|
939
|
-
} catch {
|
|
940
|
-
if (
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
plan.scopePaths,
|
|
951
|
-
!durablePackEnabled,
|
|
952
|
-
);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (isUnconfirmedExit(error)) throw error;
|
|
966
|
+
return withRecoveryBudget(async () => {
|
|
967
|
+
if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
|
|
968
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
969
|
+
}
|
|
970
|
+
const result = await this.rollback(
|
|
971
|
+
current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, !durablePackEnabled,
|
|
972
|
+
);
|
|
973
|
+
return { ...result, failureCode: operationFailure(error) };
|
|
974
|
+
});
|
|
953
975
|
}
|
|
954
976
|
}
|
|
955
977
|
|
|
@@ -958,17 +980,16 @@ export class RestoreEngine {
|
|
|
958
980
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
959
981
|
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
960
982
|
): boolean {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
return writeOnly || deleteOnly;
|
|
983
|
+
if (plan.deletePaths.length + plan.writePaths.length === 0) return false;
|
|
984
|
+
if (process.platform === "win32" && plan.deletePaths.length > 0) return false;
|
|
985
|
+
if (!plan.deletePaths.every((path) =>
|
|
986
|
+
currentPaths.get(path)?.entry.kind === "file" && targetPaths.get(path) === undefined)) return false;
|
|
987
|
+
if (!plan.writePaths.every((path) =>
|
|
988
|
+
targetPaths.get(path)?.entry.kind === "file" &&
|
|
989
|
+
(currentPaths.get(path) === undefined || currentPaths.get(path)?.entry.kind === "file"))) return false;
|
|
990
|
+
// 与 helper 的句柄上限一致;目录创建、类型替换及过多父目录继续使用 TypeScript。
|
|
991
|
+
const parents = new Set([...plan.deletePaths, ...plan.writePaths].flatMap(strictPathAncestors));
|
|
992
|
+
return process.platform === "win32" || parents.size <= 128;
|
|
972
993
|
}
|
|
973
994
|
|
|
974
995
|
private async applyNativeFilePlan(
|
|
@@ -984,49 +1005,45 @@ export class RestoreEngine {
|
|
|
984
1005
|
): Promise<RestoreResult> {
|
|
985
1006
|
try {
|
|
986
1007
|
await nativeRun(pack);
|
|
987
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1008
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
988
1009
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
989
1010
|
await this.assertCompleteVisibleSubset(
|
|
990
1011
|
topologyAfter,
|
|
991
1012
|
[target],
|
|
992
1013
|
options.mutationJournal,
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1014
|
+
{
|
|
1015
|
+
topologyValidated: true,
|
|
1016
|
+
extraExclusions: pack.paths().flatMap((path) => {
|
|
1017
|
+
const artifacts = pack.artifacts(path);
|
|
1018
|
+
return artifacts === undefined
|
|
1019
|
+
? []
|
|
1020
|
+
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
1021
|
+
}),
|
|
1022
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
1023
|
+
},
|
|
1000
1024
|
);
|
|
1001
1025
|
const totalPaths = plan.deletePaths.length + plan.writePaths.length;
|
|
1002
1026
|
if ((await options.mutationJournal.load()).length !== 0) {
|
|
1003
1027
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths };
|
|
1004
1028
|
}
|
|
1005
1029
|
return { code: "ok", verifiedPaths: totalPaths, totalPaths };
|
|
1006
|
-
} catch {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (isUnconfirmedExit(error)) throw error;
|
|
1032
|
+
return withRecoveryBudget(async () => {
|
|
1033
|
+
const packedRecovery = await recoverPackedMutations({
|
|
1034
|
+
workspaceRoot: this.workspaceRoot,
|
|
1035
|
+
journal: options.mutationJournal,
|
|
1036
|
+
planDigest: plan.planDigest,
|
|
1037
|
+
decision: "rollback",
|
|
1038
|
+
});
|
|
1039
|
+
if (packedRecovery.kind !== "clean") {
|
|
1040
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.deletePaths.length + plan.writePaths.length };
|
|
1041
|
+
}
|
|
1042
|
+
const result = await this.rollback(
|
|
1043
|
+
current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, false,
|
|
1044
|
+
);
|
|
1045
|
+
return { ...result, failureCode: operationFailure(error) };
|
|
1012
1046
|
});
|
|
1013
|
-
if (packedRecovery.kind !== "clean") {
|
|
1014
|
-
return {
|
|
1015
|
-
code: "recovery_required",
|
|
1016
|
-
verifiedPaths: 0,
|
|
1017
|
-
totalPaths: plan.deletePaths.length + plan.writePaths.length,
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
return this.rollback(
|
|
1021
|
-
current,
|
|
1022
|
-
target,
|
|
1023
|
-
topologyBefore,
|
|
1024
|
-
currentPaths,
|
|
1025
|
-
targetPaths,
|
|
1026
|
-
options,
|
|
1027
|
-
plan.scopePaths,
|
|
1028
|
-
false,
|
|
1029
|
-
);
|
|
1030
1047
|
}
|
|
1031
1048
|
}
|
|
1032
1049
|
|
|
@@ -1041,7 +1058,7 @@ export class RestoreEngine {
|
|
|
1041
1058
|
const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
|
|
1042
1059
|
const useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
|
|
1043
1060
|
const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
|
|
1044
|
-
? await
|
|
1061
|
+
? await allCompleted([
|
|
1045
1062
|
this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
|
|
1046
1063
|
this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
|
|
1047
1064
|
])
|
|
@@ -1211,7 +1228,7 @@ export class RestoreEngine {
|
|
|
1211
1228
|
}
|
|
1212
1229
|
return requests;
|
|
1213
1230
|
};
|
|
1214
|
-
await
|
|
1231
|
+
await allCompleted([
|
|
1215
1232
|
this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
|
|
1216
1233
|
this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
|
|
1217
1234
|
]);
|
|
@@ -1332,6 +1349,7 @@ export class RestoreEngine {
|
|
|
1332
1349
|
fileBatchRoot = undefined;
|
|
1333
1350
|
};
|
|
1334
1351
|
for (const path of deletePaths) {
|
|
1352
|
+
checkOperation();
|
|
1335
1353
|
if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
|
|
1336
1354
|
const source = context.sourcePaths.get(path);
|
|
1337
1355
|
const live = await lstat(this.absolutePath(path)).catch((error) => {
|
|
@@ -1516,14 +1534,16 @@ export class RestoreEngine {
|
|
|
1516
1534
|
context,
|
|
1517
1535
|
);
|
|
1518
1536
|
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
1519
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1537
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
1520
1538
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1521
1539
|
await this.assertCompleteVisibleSubset(
|
|
1522
1540
|
topologyAfter,
|
|
1523
1541
|
[current],
|
|
1524
1542
|
options.mutationJournal,
|
|
1525
|
-
|
|
1526
|
-
|
|
1543
|
+
{
|
|
1544
|
+
topologyValidated: true,
|
|
1545
|
+
ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1546
|
+
},
|
|
1527
1547
|
);
|
|
1528
1548
|
const verification = await this.verifyTarget(
|
|
1529
1549
|
current,
|
|
@@ -1559,14 +1579,16 @@ export class RestoreEngine {
|
|
|
1559
1579
|
}
|
|
1560
1580
|
if (rollbackPlan !== undefined) {
|
|
1561
1581
|
try {
|
|
1562
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1582
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "compensation");
|
|
1563
1583
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1564
1584
|
await this.assertCompleteVisibleSubset(
|
|
1565
1585
|
topologyAfter,
|
|
1566
1586
|
[current],
|
|
1567
1587
|
options.mutationJournal,
|
|
1568
|
-
|
|
1569
|
-
|
|
1588
|
+
{
|
|
1589
|
+
topologyValidated: true,
|
|
1590
|
+
ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1591
|
+
},
|
|
1570
1592
|
);
|
|
1571
1593
|
const verification = await this.verifyTarget(
|
|
1572
1594
|
current,
|
|
@@ -1621,7 +1643,9 @@ export class RestoreEngine {
|
|
|
1621
1643
|
context.ordinal += 1;
|
|
1622
1644
|
const ordinal = context.ordinal;
|
|
1623
1645
|
const beforeInstall = async (): Promise<void> => {
|
|
1646
|
+
checkOperation();
|
|
1624
1647
|
await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
|
|
1648
|
+
checkOperation();
|
|
1625
1649
|
};
|
|
1626
1650
|
if (!deferHook) await beforeInstall();
|
|
1627
1651
|
await this.assertMutationPath(path);
|
|
@@ -1656,6 +1680,7 @@ export class RestoreEngine {
|
|
|
1656
1680
|
};
|
|
1657
1681
|
for (const kind of ["directory", "leaf"] as const) {
|
|
1658
1682
|
for (const path of writePaths) {
|
|
1683
|
+
checkOperation();
|
|
1659
1684
|
const target = targetPaths.get(path);
|
|
1660
1685
|
if (target === undefined) {
|
|
1661
1686
|
throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
|
|
@@ -1715,6 +1740,7 @@ export class RestoreEngine {
|
|
|
1715
1740
|
targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
|
|
1716
1741
|
...(this.beforeMutation === undefined ? {} : {
|
|
1717
1742
|
beforeInstall: async () => {
|
|
1743
|
+
checkOperation();
|
|
1718
1744
|
await this.beforeMutation?.({
|
|
1719
1745
|
phase: context.phase,
|
|
1720
1746
|
ordinal,
|
|
@@ -1757,9 +1783,8 @@ export class RestoreEngine {
|
|
|
1757
1783
|
private async assertCompleteVisibleSubset(
|
|
1758
1784
|
topology: RootTopology,
|
|
1759
1785
|
allowedManifests: readonly SnapshotManifest[],
|
|
1760
|
-
mutationJournal
|
|
1761
|
-
|
|
1762
|
-
ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
|
|
1786
|
+
mutationJournal: MutationJournal | undefined,
|
|
1787
|
+
check: VisibleSubsetCheck,
|
|
1763
1788
|
): Promise<void> {
|
|
1764
1789
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
1765
1790
|
return;
|
|
@@ -1769,7 +1794,7 @@ export class RestoreEngine {
|
|
|
1769
1794
|
for (const path of ignoredWorkspacePaths(manifest)) {
|
|
1770
1795
|
allowedPaths.add(path);
|
|
1771
1796
|
}
|
|
1772
|
-
const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1797
|
+
const paths = check.ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1773
1798
|
for (const [path, owned] of paths) {
|
|
1774
1799
|
if (owned.entry.kind !== "directory") {
|
|
1775
1800
|
allowedPaths.add(path);
|
|
@@ -1777,12 +1802,13 @@ export class RestoreEngine {
|
|
|
1777
1802
|
}
|
|
1778
1803
|
}
|
|
1779
1804
|
|
|
1780
|
-
const exclusions = new Set(extraExclusions);
|
|
1805
|
+
const exclusions = new Set(check.extraExclusions ?? []);
|
|
1781
1806
|
if (mutationJournal !== undefined) {
|
|
1782
1807
|
for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
|
|
1783
1808
|
}
|
|
1784
1809
|
const livePaths = await this.store.listVisibleLeafPaths(topology, {
|
|
1785
1810
|
excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
|
|
1811
|
+
topologyAlreadyValidated: check.topologyValidated,
|
|
1786
1812
|
});
|
|
1787
1813
|
for (const path of livePaths) {
|
|
1788
1814
|
if (!allowedPaths.has(path)) {
|
|
@@ -1933,7 +1959,7 @@ export class RestoreEngine {
|
|
|
1933
1959
|
if (owned.entry.blobId === null) {
|
|
1934
1960
|
throw new Error(`普通文件缺少 blob:${path}`);
|
|
1935
1961
|
}
|
|
1936
|
-
const [actual, expected] = await
|
|
1962
|
+
const [actual, expected] = await allCompleted([
|
|
1937
1963
|
readFile(this.absolutePath(path)),
|
|
1938
1964
|
this.store.readBlob(
|
|
1939
1965
|
manifestId,
|
|
@@ -2262,6 +2288,7 @@ async function mapConcurrentOrdered<T, R>(
|
|
|
2262
2288
|
const index = nextIndex;
|
|
2263
2289
|
nextIndex += 1;
|
|
2264
2290
|
try {
|
|
2291
|
+
checkOperation();
|
|
2265
2292
|
results[index] = await operation(values[index]!);
|
|
2266
2293
|
} catch (error) {
|
|
2267
2294
|
if (!failed) failure = error;
|
package/src/root-discovery.ts
CHANGED
|
@@ -2,10 +2,13 @@ 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";
|
|
5
|
-
import { GitRunner } from "./git-runner.ts";
|
|
5
|
+
import { GitRunError, GitRunner } from "./git-runner.ts";
|
|
6
6
|
import type { DiscoveryRoot } from "./model.ts";
|
|
7
|
+
import { NativeDirectoryScanner, type NativeDirectoryScanPort, type NativeRepositoryCandidate } from "./native-directory-scan.ts";
|
|
8
|
+
import { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts";
|
|
7
9
|
|
|
8
10
|
const DIRECTORY_SCAN_CONCURRENCY = 16;
|
|
11
|
+
const NATIVE_SCAN_MIN_DIRECTORIES = 64;
|
|
9
12
|
const GIT_POINTER_MAX_BYTES = 4096;
|
|
10
13
|
|
|
11
14
|
interface RepositoryInfo {
|
|
@@ -38,31 +41,48 @@ export interface RootTopology {
|
|
|
38
41
|
readonly fingerprint: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* discover 调用原因:区分安全快照、恢复前、可见路径枚举、恢复后与补偿路径。
|
|
46
|
+
* 扫描行为不依赖该标签,它只作为调用方诊断与调用次数回归的归属标记。
|
|
47
|
+
*/
|
|
48
|
+
export type RootDiscoveryReason =
|
|
49
|
+
| "safety-snapshot"
|
|
50
|
+
| "restore-pre"
|
|
51
|
+
| "visible-paths-pre"
|
|
52
|
+
| "visible-paths-post"
|
|
53
|
+
| "restore-post"
|
|
54
|
+
| "compensation"
|
|
55
|
+
| "unspecified";
|
|
56
|
+
|
|
41
57
|
export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
|
|
42
58
|
|
|
43
59
|
export class RootDiscoveryError extends Error {
|
|
44
60
|
readonly code: RootDiscoveryErrorCode;
|
|
45
61
|
|
|
46
|
-
constructor(code: RootDiscoveryErrorCode, message: string) {
|
|
47
|
-
super(message);
|
|
62
|
+
constructor(code: RootDiscoveryErrorCode, message: string, options?: ErrorOptions) {
|
|
63
|
+
super(message, options);
|
|
48
64
|
this.name = "RootDiscoveryError";
|
|
49
65
|
this.code = code;
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
export interface RootDiscovery {
|
|
54
|
-
discover(workspaceRoot: string): Promise<RootTopology>;
|
|
70
|
+
discover(workspaceRoot: string, reason?: RootDiscoveryReason): Promise<RootTopology>;
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
export class RootDiscovery {
|
|
58
74
|
private readonly git: GitRunner;
|
|
75
|
+
private lastDirectoryCount: number | undefined;
|
|
59
76
|
|
|
60
|
-
constructor(git = new GitRunner()) {
|
|
77
|
+
constructor(git = new GitRunner(), private readonly nativeScan: NativeDirectoryScanPort = new NativeDirectoryScanner()) {
|
|
61
78
|
this.git = git;
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
async discover(workspaceRoot: string): Promise<RootTopology> {
|
|
81
|
+
async discover(workspaceRoot: string, reason: RootDiscoveryReason = "unspecified"): Promise<RootTopology> {
|
|
82
|
+
checkOperation();
|
|
83
|
+
reportOperationProgress(`discover_roots:${reason}`);
|
|
65
84
|
const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot);
|
|
85
|
+
checkOperation();
|
|
66
86
|
const activeRoots = new Map<string, DiscoveredRoot>();
|
|
67
87
|
const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity);
|
|
68
88
|
if (outerRepository.kind === "active") {
|
|
@@ -77,7 +97,10 @@ export class RootDiscovery {
|
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots);
|
|
100
|
+
checkOperation();
|
|
101
|
+
reportOperationProgress("discover_gitlinks");
|
|
80
102
|
const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots);
|
|
103
|
+
checkOperation();
|
|
81
104
|
const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]);
|
|
82
105
|
return {
|
|
83
106
|
workspaceIdentity,
|
|
@@ -92,16 +115,41 @@ export class RootDiscovery {
|
|
|
92
115
|
activeRoots: Map<string, DiscoveredRoot>,
|
|
93
116
|
): Promise<void> {
|
|
94
117
|
let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
|
|
118
|
+
let scanned = 0;
|
|
119
|
+
reportOperationProgress("scan_directories");
|
|
120
|
+
// 上次规模只选择执行器;小工作区仍完整重扫,目录增长后会重新使用 native。
|
|
121
|
+
const native = this.lastDirectoryCount !== undefined && this.lastDirectoryCount < NATIVE_SCAN_MIN_DIRECTORIES
|
|
122
|
+
? undefined : await this.nativeScan.scan(workspaceIdentity);
|
|
123
|
+
if (native !== undefined) {
|
|
124
|
+
this.lastDirectoryCount = native.directories;
|
|
125
|
+
for (let index = 0; index < native.repositories.length; index += DIRECTORY_SCAN_CONCURRENCY) {
|
|
126
|
+
await allCompleted(native.repositories.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(async (candidate) => {
|
|
127
|
+
checkOperation();
|
|
128
|
+
const path = join(workspaceIdentity, candidate.path);
|
|
129
|
+
await assertNativeCandidate(path, candidate);
|
|
130
|
+
const inspection = await this.inspectRepository(path, workspaceIdentity);
|
|
131
|
+
await assertNativeCandidate(path, candidate);
|
|
132
|
+
this.recordInspection(workspaceIdentity, inspection, activeRoots);
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
reportOperationProgress(`scan_directories:${native.directories}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
95
138
|
while (level.length > 0) {
|
|
139
|
+
checkOperation();
|
|
96
140
|
const next: Array<{ readonly path: string; readonly inspect: true }> = [];
|
|
97
141
|
for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
|
|
98
|
-
|
|
142
|
+
checkOperation();
|
|
143
|
+
const children = await allCompleted(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
|
|
99
144
|
(candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
|
|
100
145
|
));
|
|
101
146
|
for (const group of children) next.push(...group);
|
|
147
|
+
scanned += children.length;
|
|
148
|
+
reportOperationProgress(`scan_directories:${scanned}`);
|
|
102
149
|
}
|
|
103
150
|
level = next;
|
|
104
151
|
}
|
|
152
|
+
this.lastDirectoryCount = scanned;
|
|
105
153
|
}
|
|
106
154
|
|
|
107
155
|
private async scanDirectoryNode(
|
|
@@ -109,21 +157,14 @@ export class RootDiscovery {
|
|
|
109
157
|
candidate: { readonly path: string; readonly inspect: boolean },
|
|
110
158
|
activeRoots: Map<string, DiscoveredRoot>,
|
|
111
159
|
): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
|
|
160
|
+
checkOperation();
|
|
112
161
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
113
162
|
if (candidate.inspect) {
|
|
114
163
|
const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
|
|
115
|
-
|
|
116
|
-
activeRoots.set(
|
|
117
|
-
inspection.repository.absoluteRoot,
|
|
118
|
-
this.activeRoot(workspaceIdentity, inspection.repository),
|
|
119
|
-
);
|
|
120
|
-
} else if (inspection.kind === "broken") {
|
|
121
|
-
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
122
|
-
} else if (inspection.kind === "stale") {
|
|
123
|
-
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
124
|
-
}
|
|
164
|
+
this.recordInspection(workspaceIdentity, inspection, activeRoots);
|
|
125
165
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
126
166
|
}
|
|
167
|
+
checkOperation();
|
|
127
168
|
const entries = await readdir(candidate.path, { withFileTypes: true });
|
|
128
169
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
129
170
|
return entries
|
|
@@ -131,6 +172,20 @@ export class RootDiscovery {
|
|
|
131
172
|
.map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const }));
|
|
132
173
|
}
|
|
133
174
|
|
|
175
|
+
private recordInspection(
|
|
176
|
+
workspaceIdentity: string,
|
|
177
|
+
inspection: RepositoryInspection,
|
|
178
|
+
activeRoots: Map<string, DiscoveredRoot>,
|
|
179
|
+
): void {
|
|
180
|
+
if (inspection.kind === "active") {
|
|
181
|
+
activeRoots.set(inspection.repository.absoluteRoot, this.activeRoot(workspaceIdentity, inspection.repository));
|
|
182
|
+
} else if (inspection.kind === "broken") {
|
|
183
|
+
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
184
|
+
} else if (inspection.kind === "stale") {
|
|
185
|
+
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
134
189
|
private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {
|
|
135
190
|
const marker = await gitMarkerState(candidate);
|
|
136
191
|
if (marker === "absent") {
|
|
@@ -208,6 +263,7 @@ export class RootDiscovery {
|
|
|
208
263
|
): Promise<Map<string, DiscoveredRoot>> {
|
|
209
264
|
const result = new Map<string, DiscoveredRoot>();
|
|
210
265
|
for (const root of activeRoots.values()) {
|
|
266
|
+
checkOperation();
|
|
211
267
|
if (!root.gitBacked || root.state !== "active") {
|
|
212
268
|
continue;
|
|
213
269
|
}
|
|
@@ -248,13 +304,37 @@ export class RootDiscovery {
|
|
|
248
304
|
const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], {
|
|
249
305
|
env: cleanGitEnvironment(),
|
|
250
306
|
});
|
|
307
|
+
if (result.aborted || result.timedOut) {
|
|
308
|
+
// 取消/超时不应被当成"仓库损坏";有 context 时在这里重新抛出具体原因。
|
|
309
|
+
checkOperation();
|
|
310
|
+
}
|
|
251
311
|
return result.killed ? null : result.stdout;
|
|
252
|
-
} catch {
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error instanceof GitRunError && error.code === "git_termination_failed") {
|
|
314
|
+
// 无法证明子进程已停止:上层必须保留 lease 并走恢复,不能继续扫描。
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (error instanceof OperationError) {
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
253
320
|
return null;
|
|
254
321
|
}
|
|
255
322
|
}
|
|
256
323
|
}
|
|
257
324
|
|
|
325
|
+
async function assertNativeCandidate(path: string, candidate: NativeRepositoryCandidate): Promise<void> {
|
|
326
|
+
try {
|
|
327
|
+
const metadata = await lstat(path, { bigint: true });
|
|
328
|
+
if (!metadata.isDirectory() || metadata.dev !== candidate.dev || metadata.ino !== candidate.ino ||
|
|
329
|
+
await realpath(path) !== path) {
|
|
330
|
+
throw new RootDiscoveryError("discovery_failed", `native 扫描后仓库目录身份发生变化:${candidate.path}`);
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof RootDiscoveryError) throw error;
|
|
334
|
+
throw new RootDiscoveryError("discovery_failed", `无法复核 native 仓库候选:${candidate.path}`, { cause: error });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
258
338
|
function syntheticRoot(workspaceIdentity: string): DiscoveredRoot {
|
|
259
339
|
return {
|
|
260
340
|
absoluteRoot: workspaceIdentity,
|
|
@@ -391,6 +471,7 @@ async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"
|
|
|
391
471
|
const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]);
|
|
392
472
|
|
|
393
473
|
async function directoryTreeContainsFile(directory: string): Promise<boolean> {
|
|
474
|
+
checkOperation();
|
|
394
475
|
let entries;
|
|
395
476
|
try {
|
|
396
477
|
entries = await readdir(directory, { withFileTypes: true });
|