@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.
- package/README.md +34 -1
- 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/restore-engine.ts
CHANGED
|
@@ -17,6 +17,7 @@ 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 { allCompleted, checkOperation, rethrowOperationFailure, isUnconfirmedExit, operationFailure, withRecoveryBudget } from "./operation-context.ts";
|
|
20
21
|
import { createNativeFileBatch } from "./native-restore.ts";
|
|
21
22
|
import { recoverPackedMutations } from "./packed-recovery.ts";
|
|
22
23
|
import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
|
|
@@ -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,7 +305,7 @@ 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,
|
|
@@ -313,7 +325,8 @@ export class RestoreEngine {
|
|
|
313
325
|
);
|
|
314
326
|
}
|
|
315
327
|
return true;
|
|
316
|
-
} catch {
|
|
328
|
+
} catch (error) {
|
|
329
|
+
rethrowOperationFailure(error);
|
|
317
330
|
return false;
|
|
318
331
|
}
|
|
319
332
|
}
|
|
@@ -517,7 +530,8 @@ export class RestoreEngine {
|
|
|
517
530
|
private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
|
|
518
531
|
try {
|
|
519
532
|
return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
|
|
520
|
-
} catch {
|
|
533
|
+
} catch (error) {
|
|
534
|
+
rethrowOperationFailure(error);
|
|
521
535
|
return undefined;
|
|
522
536
|
}
|
|
523
537
|
}
|
|
@@ -737,7 +751,7 @@ export class RestoreEngine {
|
|
|
737
751
|
compatibilityMode: boolean,
|
|
738
752
|
pinsDeferred: boolean,
|
|
739
753
|
): Promise<RestoreResult> {
|
|
740
|
-
const [current, storedTarget] = await
|
|
754
|
+
const [current, storedTarget] = await allCompleted([
|
|
741
755
|
this.store.loadManifest(plan.currentManifestId),
|
|
742
756
|
this.store.loadManifest(target.manifestId),
|
|
743
757
|
]);
|
|
@@ -773,13 +787,14 @@ export class RestoreEngine {
|
|
|
773
787
|
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) {
|
|
774
788
|
throw new Error("restore workspace root 必须使用 canonical identity");
|
|
775
789
|
}
|
|
776
|
-
topologyBefore = await this.discovery.discover(this.workspaceRoot);
|
|
790
|
+
topologyBefore = await this.discovery.discover(this.workspaceRoot, "restore-pre");
|
|
777
791
|
this.assertCurrentTopology(current, target, topologyBefore);
|
|
778
|
-
} catch {
|
|
792
|
+
} catch (error) {
|
|
793
|
+
rethrowOperationFailure(error);
|
|
779
794
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
780
795
|
}
|
|
781
796
|
const [currentPaths, targetPaths] = prepared === undefined
|
|
782
|
-
? await
|
|
797
|
+
? await allCompleted([
|
|
783
798
|
this.readOwnedPaths(current, plan.scopePaths),
|
|
784
799
|
this.readOwnedPaths(target, plan.scopePaths),
|
|
785
800
|
])
|
|
@@ -796,14 +811,18 @@ export class RestoreEngine {
|
|
|
796
811
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
797
812
|
}
|
|
798
813
|
try {
|
|
814
|
+
// 兼容恢复路径会先在此窗口内补偿 pending mutations,因此只有非补偿路径可以跳过枚举入口的重复发现。
|
|
799
815
|
await this.assertCompleteVisibleSubset(
|
|
800
816
|
topologyBefore,
|
|
801
817
|
[current, target],
|
|
802
818
|
options.mutationJournal,
|
|
803
|
-
|
|
804
|
-
|
|
819
|
+
{
|
|
820
|
+
topologyValidated: !compatibilityMode,
|
|
821
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
|
|
822
|
+
},
|
|
805
823
|
);
|
|
806
|
-
} catch {
|
|
824
|
+
} catch (error) {
|
|
825
|
+
rethrowOperationFailure(error);
|
|
807
826
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
808
827
|
}
|
|
809
828
|
let durablePack: DurablePack | undefined;
|
|
@@ -833,7 +852,8 @@ export class RestoreEngine {
|
|
|
833
852
|
entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
|
|
834
853
|
});
|
|
835
854
|
}
|
|
836
|
-
} catch {
|
|
855
|
+
} catch (error) {
|
|
856
|
+
rethrowOperationFailure(error);
|
|
837
857
|
await removeDurablePack(options.mutationJournal).catch(() => {});
|
|
838
858
|
}
|
|
839
859
|
}
|
|
@@ -873,7 +893,8 @@ export class RestoreEngine {
|
|
|
873
893
|
}
|
|
874
894
|
try {
|
|
875
895
|
await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
|
|
876
|
-
} catch {
|
|
896
|
+
} catch (error) {
|
|
897
|
+
rethrowOperationFailure(error);
|
|
877
898
|
// 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
|
|
878
899
|
}
|
|
879
900
|
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
@@ -911,14 +932,16 @@ export class RestoreEngine {
|
|
|
911
932
|
);
|
|
912
933
|
await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
|
|
913
934
|
|
|
914
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
935
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
915
936
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
916
937
|
await this.assertCompleteVisibleSubset(
|
|
917
938
|
topologyAfter,
|
|
918
939
|
[target],
|
|
919
940
|
options.mutationJournal,
|
|
920
|
-
|
|
921
|
-
|
|
941
|
+
{
|
|
942
|
+
topologyValidated: true,
|
|
943
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
944
|
+
},
|
|
922
945
|
);
|
|
923
946
|
const verification = await this.verifyTarget(
|
|
924
947
|
target,
|
|
@@ -936,20 +959,17 @@ export class RestoreEngine {
|
|
|
936
959
|
return await this.mutationsAreClean(options.mutationJournal)
|
|
937
960
|
? result
|
|
938
961
|
: { 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
|
-
);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (isUnconfirmedExit(error)) throw error;
|
|
964
|
+
return withRecoveryBudget(async () => {
|
|
965
|
+
if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
|
|
966
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
967
|
+
}
|
|
968
|
+
const result = await this.rollback(
|
|
969
|
+
current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, !durablePackEnabled,
|
|
970
|
+
);
|
|
971
|
+
return { ...result, failureCode: operationFailure(error) };
|
|
972
|
+
});
|
|
953
973
|
}
|
|
954
974
|
}
|
|
955
975
|
|
|
@@ -984,49 +1004,45 @@ export class RestoreEngine {
|
|
|
984
1004
|
): Promise<RestoreResult> {
|
|
985
1005
|
try {
|
|
986
1006
|
await nativeRun(pack);
|
|
987
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1007
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
988
1008
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
989
1009
|
await this.assertCompleteVisibleSubset(
|
|
990
1010
|
topologyAfter,
|
|
991
1011
|
[target],
|
|
992
1012
|
options.mutationJournal,
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1013
|
+
{
|
|
1014
|
+
topologyValidated: true,
|
|
1015
|
+
extraExclusions: pack.paths().flatMap((path) => {
|
|
1016
|
+
const artifacts = pack.artifacts(path);
|
|
1017
|
+
return artifacts === undefined
|
|
1018
|
+
? []
|
|
1019
|
+
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
1020
|
+
}),
|
|
1021
|
+
ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
|
|
1022
|
+
},
|
|
1000
1023
|
);
|
|
1001
1024
|
const totalPaths = plan.deletePaths.length + plan.writePaths.length;
|
|
1002
1025
|
if ((await options.mutationJournal.load()).length !== 0) {
|
|
1003
1026
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths };
|
|
1004
1027
|
}
|
|
1005
1028
|
return { code: "ok", verifiedPaths: totalPaths, totalPaths };
|
|
1006
|
-
} catch {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
if (isUnconfirmedExit(error)) throw error;
|
|
1031
|
+
return withRecoveryBudget(async () => {
|
|
1032
|
+
const packedRecovery = await recoverPackedMutations({
|
|
1033
|
+
workspaceRoot: this.workspaceRoot,
|
|
1034
|
+
journal: options.mutationJournal,
|
|
1035
|
+
planDigest: plan.planDigest,
|
|
1036
|
+
decision: "rollback",
|
|
1037
|
+
});
|
|
1038
|
+
if (packedRecovery.kind !== "clean") {
|
|
1039
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.deletePaths.length + plan.writePaths.length };
|
|
1040
|
+
}
|
|
1041
|
+
const result = await this.rollback(
|
|
1042
|
+
current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, false,
|
|
1043
|
+
);
|
|
1044
|
+
return { ...result, failureCode: operationFailure(error) };
|
|
1012
1045
|
});
|
|
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
1046
|
}
|
|
1031
1047
|
}
|
|
1032
1048
|
|
|
@@ -1041,7 +1057,7 @@ export class RestoreEngine {
|
|
|
1041
1057
|
const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
|
|
1042
1058
|
const useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
|
|
1043
1059
|
const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
|
|
1044
|
-
? await
|
|
1060
|
+
? await allCompleted([
|
|
1045
1061
|
this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
|
|
1046
1062
|
this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
|
|
1047
1063
|
])
|
|
@@ -1211,7 +1227,7 @@ export class RestoreEngine {
|
|
|
1211
1227
|
}
|
|
1212
1228
|
return requests;
|
|
1213
1229
|
};
|
|
1214
|
-
await
|
|
1230
|
+
await allCompleted([
|
|
1215
1231
|
this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
|
|
1216
1232
|
this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
|
|
1217
1233
|
]);
|
|
@@ -1332,6 +1348,7 @@ export class RestoreEngine {
|
|
|
1332
1348
|
fileBatchRoot = undefined;
|
|
1333
1349
|
};
|
|
1334
1350
|
for (const path of deletePaths) {
|
|
1351
|
+
checkOperation();
|
|
1335
1352
|
if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
|
|
1336
1353
|
const source = context.sourcePaths.get(path);
|
|
1337
1354
|
const live = await lstat(this.absolutePath(path)).catch((error) => {
|
|
@@ -1516,14 +1533,16 @@ export class RestoreEngine {
|
|
|
1516
1533
|
context,
|
|
1517
1534
|
);
|
|
1518
1535
|
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
1519
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1536
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
|
|
1520
1537
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1521
1538
|
await this.assertCompleteVisibleSubset(
|
|
1522
1539
|
topologyAfter,
|
|
1523
1540
|
[current],
|
|
1524
1541
|
options.mutationJournal,
|
|
1525
|
-
|
|
1526
|
-
|
|
1542
|
+
{
|
|
1543
|
+
topologyValidated: true,
|
|
1544
|
+
ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1545
|
+
},
|
|
1527
1546
|
);
|
|
1528
1547
|
const verification = await this.verifyTarget(
|
|
1529
1548
|
current,
|
|
@@ -1559,14 +1578,16 @@ export class RestoreEngine {
|
|
|
1559
1578
|
}
|
|
1560
1579
|
if (rollbackPlan !== undefined) {
|
|
1561
1580
|
try {
|
|
1562
|
-
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
1581
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot, "compensation");
|
|
1563
1582
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
1564
1583
|
await this.assertCompleteVisibleSubset(
|
|
1565
1584
|
topologyAfter,
|
|
1566
1585
|
[current],
|
|
1567
1586
|
options.mutationJournal,
|
|
1568
|
-
|
|
1569
|
-
|
|
1587
|
+
{
|
|
1588
|
+
topologyValidated: true,
|
|
1589
|
+
ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
|
|
1590
|
+
},
|
|
1570
1591
|
);
|
|
1571
1592
|
const verification = await this.verifyTarget(
|
|
1572
1593
|
current,
|
|
@@ -1621,7 +1642,9 @@ export class RestoreEngine {
|
|
|
1621
1642
|
context.ordinal += 1;
|
|
1622
1643
|
const ordinal = context.ordinal;
|
|
1623
1644
|
const beforeInstall = async (): Promise<void> => {
|
|
1645
|
+
checkOperation();
|
|
1624
1646
|
await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
|
|
1647
|
+
checkOperation();
|
|
1625
1648
|
};
|
|
1626
1649
|
if (!deferHook) await beforeInstall();
|
|
1627
1650
|
await this.assertMutationPath(path);
|
|
@@ -1656,6 +1679,7 @@ export class RestoreEngine {
|
|
|
1656
1679
|
};
|
|
1657
1680
|
for (const kind of ["directory", "leaf"] as const) {
|
|
1658
1681
|
for (const path of writePaths) {
|
|
1682
|
+
checkOperation();
|
|
1659
1683
|
const target = targetPaths.get(path);
|
|
1660
1684
|
if (target === undefined) {
|
|
1661
1685
|
throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
|
|
@@ -1715,6 +1739,7 @@ export class RestoreEngine {
|
|
|
1715
1739
|
targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
|
|
1716
1740
|
...(this.beforeMutation === undefined ? {} : {
|
|
1717
1741
|
beforeInstall: async () => {
|
|
1742
|
+
checkOperation();
|
|
1718
1743
|
await this.beforeMutation?.({
|
|
1719
1744
|
phase: context.phase,
|
|
1720
1745
|
ordinal,
|
|
@@ -1757,9 +1782,8 @@ export class RestoreEngine {
|
|
|
1757
1782
|
private async assertCompleteVisibleSubset(
|
|
1758
1783
|
topology: RootTopology,
|
|
1759
1784
|
allowedManifests: readonly SnapshotManifest[],
|
|
1760
|
-
mutationJournal
|
|
1761
|
-
|
|
1762
|
-
ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
|
|
1785
|
+
mutationJournal: MutationJournal | undefined,
|
|
1786
|
+
check: VisibleSubsetCheck,
|
|
1763
1787
|
): Promise<void> {
|
|
1764
1788
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
1765
1789
|
return;
|
|
@@ -1769,7 +1793,7 @@ export class RestoreEngine {
|
|
|
1769
1793
|
for (const path of ignoredWorkspacePaths(manifest)) {
|
|
1770
1794
|
allowedPaths.add(path);
|
|
1771
1795
|
}
|
|
1772
|
-
const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1796
|
+
const paths = check.ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
|
|
1773
1797
|
for (const [path, owned] of paths) {
|
|
1774
1798
|
if (owned.entry.kind !== "directory") {
|
|
1775
1799
|
allowedPaths.add(path);
|
|
@@ -1777,12 +1801,13 @@ export class RestoreEngine {
|
|
|
1777
1801
|
}
|
|
1778
1802
|
}
|
|
1779
1803
|
|
|
1780
|
-
const exclusions = new Set(extraExclusions);
|
|
1804
|
+
const exclusions = new Set(check.extraExclusions ?? []);
|
|
1781
1805
|
if (mutationJournal !== undefined) {
|
|
1782
1806
|
for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
|
|
1783
1807
|
}
|
|
1784
1808
|
const livePaths = await this.store.listVisibleLeafPaths(topology, {
|
|
1785
1809
|
excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
|
|
1810
|
+
topologyAlreadyValidated: check.topologyValidated,
|
|
1786
1811
|
});
|
|
1787
1812
|
for (const path of livePaths) {
|
|
1788
1813
|
if (!allowedPaths.has(path)) {
|
|
@@ -1933,7 +1958,7 @@ export class RestoreEngine {
|
|
|
1933
1958
|
if (owned.entry.blobId === null) {
|
|
1934
1959
|
throw new Error(`普通文件缺少 blob:${path}`);
|
|
1935
1960
|
}
|
|
1936
|
-
const [actual, expected] = await
|
|
1961
|
+
const [actual, expected] = await allCompleted([
|
|
1937
1962
|
readFile(this.absolutePath(path)),
|
|
1938
1963
|
this.store.readBlob(
|
|
1939
1964
|
manifestId,
|
|
@@ -2262,6 +2287,7 @@ async function mapConcurrentOrdered<T, R>(
|
|
|
2262
2287
|
const index = nextIndex;
|
|
2263
2288
|
nextIndex += 1;
|
|
2264
2289
|
try {
|
|
2290
|
+
checkOperation();
|
|
2265
2291
|
results[index] = await operation(values[index]!);
|
|
2266
2292
|
} catch (error) {
|
|
2267
2293
|
if (!failed) failure = error;
|
package/src/root-discovery.ts
CHANGED
|
@@ -2,8 +2,9 @@ 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 { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts";
|
|
7
8
|
|
|
8
9
|
const DIRECTORY_SCAN_CONCURRENCY = 16;
|
|
9
10
|
const GIT_POINTER_MAX_BYTES = 4096;
|
|
@@ -38,6 +39,19 @@ export interface RootTopology {
|
|
|
38
39
|
readonly fingerprint: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* discover 调用原因:区分安全快照、恢复前、可见路径枚举、恢复后与补偿路径。
|
|
44
|
+
* 扫描行为不依赖该标签,它只作为调用方诊断与调用次数回归的归属标记。
|
|
45
|
+
*/
|
|
46
|
+
export type RootDiscoveryReason =
|
|
47
|
+
| "safety-snapshot"
|
|
48
|
+
| "restore-pre"
|
|
49
|
+
| "visible-paths-pre"
|
|
50
|
+
| "visible-paths-post"
|
|
51
|
+
| "restore-post"
|
|
52
|
+
| "compensation"
|
|
53
|
+
| "unspecified";
|
|
54
|
+
|
|
41
55
|
export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
|
|
42
56
|
|
|
43
57
|
export class RootDiscoveryError extends Error {
|
|
@@ -51,7 +65,7 @@ export class RootDiscoveryError extends Error {
|
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
export interface RootDiscovery {
|
|
54
|
-
discover(workspaceRoot: string): Promise<RootTopology>;
|
|
68
|
+
discover(workspaceRoot: string, reason?: RootDiscoveryReason): Promise<RootTopology>;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export class RootDiscovery {
|
|
@@ -61,8 +75,11 @@ export class RootDiscovery {
|
|
|
61
75
|
this.git = git;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
|
-
async discover(workspaceRoot: string): Promise<RootTopology> {
|
|
78
|
+
async discover(workspaceRoot: string, reason: RootDiscoveryReason = "unspecified"): Promise<RootTopology> {
|
|
79
|
+
checkOperation();
|
|
80
|
+
reportOperationProgress(`discover_roots:${reason}`);
|
|
65
81
|
const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot);
|
|
82
|
+
checkOperation();
|
|
66
83
|
const activeRoots = new Map<string, DiscoveredRoot>();
|
|
67
84
|
const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity);
|
|
68
85
|
if (outerRepository.kind === "active") {
|
|
@@ -77,7 +94,10 @@ export class RootDiscovery {
|
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots);
|
|
97
|
+
checkOperation();
|
|
98
|
+
reportOperationProgress("discover_gitlinks");
|
|
80
99
|
const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots);
|
|
100
|
+
checkOperation();
|
|
81
101
|
const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]);
|
|
82
102
|
return {
|
|
83
103
|
workspaceIdentity,
|
|
@@ -92,13 +112,19 @@ export class RootDiscovery {
|
|
|
92
112
|
activeRoots: Map<string, DiscoveredRoot>,
|
|
93
113
|
): Promise<void> {
|
|
94
114
|
let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
|
|
115
|
+
let scanned = 0;
|
|
116
|
+
reportOperationProgress("scan_directories");
|
|
95
117
|
while (level.length > 0) {
|
|
118
|
+
checkOperation();
|
|
96
119
|
const next: Array<{ readonly path: string; readonly inspect: true }> = [];
|
|
97
120
|
for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
|
|
98
|
-
|
|
121
|
+
checkOperation();
|
|
122
|
+
const children = await allCompleted(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
|
|
99
123
|
(candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
|
|
100
124
|
));
|
|
101
125
|
for (const group of children) next.push(...group);
|
|
126
|
+
scanned += children.length;
|
|
127
|
+
reportOperationProgress(`scan_directories:${scanned}`);
|
|
102
128
|
}
|
|
103
129
|
level = next;
|
|
104
130
|
}
|
|
@@ -109,6 +135,7 @@ export class RootDiscovery {
|
|
|
109
135
|
candidate: { readonly path: string; readonly inspect: boolean },
|
|
110
136
|
activeRoots: Map<string, DiscoveredRoot>,
|
|
111
137
|
): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
|
|
138
|
+
checkOperation();
|
|
112
139
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
113
140
|
if (candidate.inspect) {
|
|
114
141
|
const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
|
|
@@ -124,6 +151,7 @@ export class RootDiscovery {
|
|
|
124
151
|
}
|
|
125
152
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
126
153
|
}
|
|
154
|
+
checkOperation();
|
|
127
155
|
const entries = await readdir(candidate.path, { withFileTypes: true });
|
|
128
156
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
129
157
|
return entries
|
|
@@ -208,6 +236,7 @@ export class RootDiscovery {
|
|
|
208
236
|
): Promise<Map<string, DiscoveredRoot>> {
|
|
209
237
|
const result = new Map<string, DiscoveredRoot>();
|
|
210
238
|
for (const root of activeRoots.values()) {
|
|
239
|
+
checkOperation();
|
|
211
240
|
if (!root.gitBacked || root.state !== "active") {
|
|
212
241
|
continue;
|
|
213
242
|
}
|
|
@@ -248,8 +277,19 @@ export class RootDiscovery {
|
|
|
248
277
|
const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], {
|
|
249
278
|
env: cleanGitEnvironment(),
|
|
250
279
|
});
|
|
280
|
+
if (result.aborted || result.timedOut) {
|
|
281
|
+
// 取消/超时不应被当成"仓库损坏";有 context 时在这里重新抛出具体原因。
|
|
282
|
+
checkOperation();
|
|
283
|
+
}
|
|
251
284
|
return result.killed ? null : result.stdout;
|
|
252
|
-
} catch {
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof GitRunError && error.code === "git_termination_failed") {
|
|
287
|
+
// 无法证明子进程已停止:上层必须保留 lease 并走恢复,不能继续扫描。
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
if (error instanceof OperationError) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
253
293
|
return null;
|
|
254
294
|
}
|
|
255
295
|
}
|
|
@@ -391,6 +431,7 @@ async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"
|
|
|
391
431
|
const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]);
|
|
392
432
|
|
|
393
433
|
async function directoryTreeContainsFile(directory: string): Promise<boolean> {
|
|
434
|
+
checkOperation();
|
|
394
435
|
let entries;
|
|
395
436
|
try {
|
|
396
437
|
entries = await readdir(directory, { withFileTypes: true });
|