@narumitw/pi-sync 0.57.10 → 0.57.11
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/CHANGELOG.md +6 -0
- package/README.md +5 -1
- package/dist/chunks/{agent-candidate-EYB35AGL.ts → agent-candidate-OCDPRI5G.ts} +11 -11
- package/dist/chunks/{agent-plan-KO43GTOR.ts → agent-plan-P5B5RGZ7.ts} +14 -14
- package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts → agent-refusal-retry-476YGNU5.ts} +10 -10
- package/dist/chunks/{agent-store-UT52DS7B.ts → agent-store-RJSMNOYP.ts} +7 -7
- package/dist/chunks/{agent-sync-VM3TXWA3.ts → agent-sync-EUK2CPSS.ts} +6 -6
- package/dist/chunks/{chunk-UCD7TQDT.ts → chunk-3IYAPMST.ts} +4 -4
- package/dist/chunks/{chunk-JXWTEJS5.ts → chunk-45COPAK7.ts} +10 -3
- package/dist/chunks/chunk-45COPAK7.ts.map +7 -0
- package/dist/chunks/{chunk-DOJRPBQU.ts → chunk-5TSCBBPP.ts} +4 -4
- package/dist/chunks/{chunk-IMMMZUFC.ts → chunk-67INJPKR.ts} +4 -4
- package/dist/chunks/{chunk-CDQGSZL3.ts → chunk-6LD5KX55.ts} +6 -6
- package/dist/chunks/{chunk-MDUORMCK.ts → chunk-6O3EDBRL.ts} +2 -2
- package/dist/chunks/{chunk-AAPAHANE.ts → chunk-7A35ZAVO.ts} +320 -219
- package/dist/chunks/chunk-7A35ZAVO.ts.map +7 -0
- package/dist/chunks/{chunk-2VSN3MFX.ts → chunk-7OLLPPLU.ts} +29 -23
- package/dist/chunks/chunk-7OLLPPLU.ts.map +7 -0
- package/dist/chunks/{chunk-STVQQ5KG.ts → chunk-7ZIYU5QS.ts} +6 -6
- package/dist/chunks/{chunk-AO6EDUA6.ts → chunk-AKMV47YC.ts} +3 -3
- package/dist/chunks/{chunk-66GAUVDL.ts → chunk-BEST45H2.ts} +6 -6
- package/dist/chunks/{chunk-DO55XUNU.ts → chunk-CBLTWXAU.ts} +15 -5
- package/dist/chunks/chunk-CBLTWXAU.ts.map +7 -0
- package/dist/chunks/{chunk-PWA3YN5Z.ts → chunk-CPT2BN6N.ts} +2 -2
- package/dist/chunks/{chunk-73MDIVYJ.ts → chunk-DN4IA7MR.ts} +6 -6
- package/dist/chunks/{chunk-4RLATUP4.ts → chunk-DRTC4PKM.ts} +6 -6
- package/dist/chunks/{chunk-PHANBTVM.ts → chunk-ESKOPQVB.ts} +4 -4
- package/dist/chunks/{chunk-ODF2M4RF.ts → chunk-F7E3VFXU.ts} +5 -5
- package/dist/chunks/{chunk-YZ5BQNRZ.ts → chunk-FHE2DCA2.ts} +2 -2
- package/dist/chunks/{chunk-YZ5BQNRZ.ts.map → chunk-FHE2DCA2.ts.map} +2 -2
- package/dist/chunks/{chunk-MRIPZGPR.ts → chunk-FLHCOJCD.ts} +3 -3
- package/dist/chunks/{chunk-5WJQ6XOW.ts → chunk-GNYGNHFF.ts} +2 -2
- package/dist/chunks/{chunk-DFJGTVPX.ts → chunk-KYTT7KV4.ts} +2 -2
- package/dist/chunks/{chunk-UGSSZWQ6.ts → chunk-MKSMJPVA.ts} +2 -2
- package/dist/chunks/{chunk-RAMHGOX2.ts → chunk-OJARL2TG.ts} +4 -4
- package/dist/chunks/{chunk-BJXSCGQK.ts → chunk-OKPPZA4D.ts} +148 -89
- package/dist/chunks/chunk-OKPPZA4D.ts.map +7 -0
- package/dist/chunks/{chunk-RX3WGWVL.ts → chunk-OVBAW7KV.ts} +177 -130
- package/dist/chunks/chunk-OVBAW7KV.ts.map +7 -0
- package/dist/chunks/{chunk-U77DWNRH.ts → chunk-Q23JJW7E.ts} +3 -3
- package/dist/chunks/{chunk-QWV3XKNJ.ts → chunk-TIMWN2RH.ts} +5 -5
- package/dist/chunks/{chunk-Z5O2TLNF.ts → chunk-TZC7GDJE.ts} +7 -7
- package/dist/chunks/{chunk-TO2GMX3K.ts → chunk-XJHQPB3E.ts} +2 -2
- package/dist/chunks/{chunk-JY2RVOUL.ts → chunk-Y4UVXOLQ.ts} +5 -5
- package/dist/chunks/{chunk-M5IV3KL2.ts → chunk-ZDDBZH72.ts} +3 -3
- package/dist/chunks/{command-handler-FO6F2APH.ts → command-handler-F2E7A5T5.ts} +22 -17
- package/dist/chunks/command-handler-F2E7A5T5.ts.map +7 -0
- package/dist/chunks/{config-B37W7FXW.ts → config-HYG7G5EM.ts} +6 -4
- package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts → conflict-artifacts-J6WSDCWP.ts} +12 -12
- package/dist/chunks/{conflict-review-BQSHSJB3.ts → conflict-review-2QMO2K6Q.ts} +23 -23
- package/dist/chunks/{continuous-record-4DQ2EAZS.ts → continuous-record-AH7JGYKM.ts} +6 -6
- package/dist/chunks/{continuous-sync-ZT3UEQLK.ts → continuous-sync-VRMXCA5X.ts} +18 -18
- package/dist/chunks/{file-selection-FBLKM25V.ts → file-selection-IELTXSGZ.ts} +6 -7
- package/dist/chunks/{file-selection-FBLKM25V.ts.map → file-selection-IELTXSGZ.ts.map} +1 -1
- package/dist/chunks/{git-backend-7PCWECRN.ts → git-backend-KHD36W27.ts} +6 -7
- package/dist/chunks/{git-backend-7PCWECRN.ts.map → git-backend-KHD36W27.ts.map} +1 -1
- package/dist/chunks/{local-fields-B6JWLPAE.ts → local-fields-JEKAPZKD.ts} +6 -7
- package/dist/chunks/manager-result-dispatcher-6W52NM7F.ts +32 -0
- package/dist/chunks/{manager-ui-DGRTG76I.ts → manager-ui-LM7DSJ2Y.ts} +103 -52
- package/dist/chunks/manager-ui-LM7DSJ2Y.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts → merge-baseline-store-65GKUMXI.ts} +6 -6
- package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts → remote-selection-ui-TD4C4W3J.ts} +16 -12
- package/dist/chunks/{s3-backend-CJXBEWWU.ts → s3-backend-L24UZ6IK.ts} +12 -10
- package/dist/chunks/s3-backend-L24UZ6IK.ts.map +7 -0
- package/dist/chunks/{setup-switch-66GWMBNH.ts → setup-switch-PIPXYPCW.ts} +5 -5
- package/dist/chunks/{setup-wizard-OHZXJVQZ.ts → setup-wizard-RR5U3SUB.ts} +6 -6
- package/dist/chunks/{snapshot-VRHNAUUH.ts → snapshot-RGNDYT6A.ts} +5 -6
- package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts → snapshot-transaction-26BQO36S.ts} +8 -8
- package/dist/chunks/{sync-inspection-4WFQEJOE.ts → sync-inspection-MOMLGUTD.ts} +11 -11
- package/dist/chunks/{sync-operations-X4AOXMBT.ts → sync-operations-CV3TULLD.ts} +25 -25
- package/dist/chunks/{sync-state-SWLSMK4G.ts → sync-state-7L4NT7W2.ts} +7 -8
- package/dist/chunks/{webdav-backend-2Q5ZEBST.ts → webdav-backend-3ZCACILP.ts} +7 -8
- package/dist/chunks/{webdav-backend-2Q5ZEBST.ts.map → webdav-backend-3ZCACILP.ts.map} +1 -1
- package/dist/index.ts +19 -19
- package/package.json +1 -1
- package/skills/configure-pi-sync/references/configuration.md +8 -1
- package/src/backends/s3/s3-client.ts +8 -1
- package/src/commands/command-execution.ts +8 -3
- package/src/paths.ts +6 -1
- package/src/settings/config-file.ts +205 -118
- package/src/settings/config.ts +1 -1
- package/src/settings/settings-management.ts +15 -1
- package/src/settings/settings-store.ts +0 -14
- package/src/snapshot/session-paths.ts +15 -2
- package/src/snapshot/snapshot-mutation-queues.ts +142 -0
- package/src/snapshot/snapshot-transaction.ts +122 -125
- package/src/sync/merge-apply.ts +6 -0
- package/src/ui/cancellable-operation.ts +36 -13
- package/src/ui/manager-state.ts +3 -1
- package/src/ui/manager-ui.ts +76 -24
- package/src/ui/remote-selection-ui.ts +131 -82
- package/src/ui/settings-ui.ts +2 -1
- package/dist/chunks/chunk-2VSN3MFX.ts.map +0 -7
- package/dist/chunks/chunk-AAPAHANE.ts.map +0 -7
- package/dist/chunks/chunk-BJXSCGQK.ts.map +0 -7
- package/dist/chunks/chunk-DO55XUNU.ts.map +0 -7
- package/dist/chunks/chunk-JXWTEJS5.ts.map +0 -7
- package/dist/chunks/chunk-RX3WGWVL.ts.map +0 -7
- package/dist/chunks/command-handler-FO6F2APH.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-NODX3KKH.ts +0 -28
- package/dist/chunks/manager-ui-DGRTG76I.ts.map +0 -7
- package/dist/chunks/s3-backend-CJXBEWWU.ts.map +0 -7
- /package/dist/chunks/{agent-candidate-EYB35AGL.ts.map → agent-candidate-OCDPRI5G.ts.map} +0 -0
- /package/dist/chunks/{agent-plan-KO43GTOR.ts.map → agent-plan-P5B5RGZ7.ts.map} +0 -0
- /package/dist/chunks/{agent-refusal-retry-C2HQROTP.ts.map → agent-refusal-retry-476YGNU5.ts.map} +0 -0
- /package/dist/chunks/{agent-store-UT52DS7B.ts.map → agent-store-RJSMNOYP.ts.map} +0 -0
- /package/dist/chunks/{agent-sync-VM3TXWA3.ts.map → agent-sync-EUK2CPSS.ts.map} +0 -0
- /package/dist/chunks/{chunk-UCD7TQDT.ts.map → chunk-3IYAPMST.ts.map} +0 -0
- /package/dist/chunks/{chunk-DOJRPBQU.ts.map → chunk-5TSCBBPP.ts.map} +0 -0
- /package/dist/chunks/{chunk-IMMMZUFC.ts.map → chunk-67INJPKR.ts.map} +0 -0
- /package/dist/chunks/{chunk-CDQGSZL3.ts.map → chunk-6LD5KX55.ts.map} +0 -0
- /package/dist/chunks/{chunk-MDUORMCK.ts.map → chunk-6O3EDBRL.ts.map} +0 -0
- /package/dist/chunks/{chunk-STVQQ5KG.ts.map → chunk-7ZIYU5QS.ts.map} +0 -0
- /package/dist/chunks/{chunk-AO6EDUA6.ts.map → chunk-AKMV47YC.ts.map} +0 -0
- /package/dist/chunks/{chunk-66GAUVDL.ts.map → chunk-BEST45H2.ts.map} +0 -0
- /package/dist/chunks/{chunk-PWA3YN5Z.ts.map → chunk-CPT2BN6N.ts.map} +0 -0
- /package/dist/chunks/{chunk-73MDIVYJ.ts.map → chunk-DN4IA7MR.ts.map} +0 -0
- /package/dist/chunks/{chunk-4RLATUP4.ts.map → chunk-DRTC4PKM.ts.map} +0 -0
- /package/dist/chunks/{chunk-PHANBTVM.ts.map → chunk-ESKOPQVB.ts.map} +0 -0
- /package/dist/chunks/{chunk-ODF2M4RF.ts.map → chunk-F7E3VFXU.ts.map} +0 -0
- /package/dist/chunks/{chunk-MRIPZGPR.ts.map → chunk-FLHCOJCD.ts.map} +0 -0
- /package/dist/chunks/{chunk-5WJQ6XOW.ts.map → chunk-GNYGNHFF.ts.map} +0 -0
- /package/dist/chunks/{chunk-DFJGTVPX.ts.map → chunk-KYTT7KV4.ts.map} +0 -0
- /package/dist/chunks/{chunk-UGSSZWQ6.ts.map → chunk-MKSMJPVA.ts.map} +0 -0
- /package/dist/chunks/{chunk-RAMHGOX2.ts.map → chunk-OJARL2TG.ts.map} +0 -0
- /package/dist/chunks/{chunk-U77DWNRH.ts.map → chunk-Q23JJW7E.ts.map} +0 -0
- /package/dist/chunks/{chunk-QWV3XKNJ.ts.map → chunk-TIMWN2RH.ts.map} +0 -0
- /package/dist/chunks/{chunk-Z5O2TLNF.ts.map → chunk-TZC7GDJE.ts.map} +0 -0
- /package/dist/chunks/{chunk-TO2GMX3K.ts.map → chunk-XJHQPB3E.ts.map} +0 -0
- /package/dist/chunks/{chunk-JY2RVOUL.ts.map → chunk-Y4UVXOLQ.ts.map} +0 -0
- /package/dist/chunks/{chunk-M5IV3KL2.ts.map → chunk-ZDDBZH72.ts.map} +0 -0
- /package/dist/chunks/{config-B37W7FXW.ts.map → config-HYG7G5EM.ts.map} +0 -0
- /package/dist/chunks/{conflict-artifacts-6XGSSU5S.ts.map → conflict-artifacts-J6WSDCWP.ts.map} +0 -0
- /package/dist/chunks/{conflict-review-BQSHSJB3.ts.map → conflict-review-2QMO2K6Q.ts.map} +0 -0
- /package/dist/chunks/{continuous-record-4DQ2EAZS.ts.map → continuous-record-AH7JGYKM.ts.map} +0 -0
- /package/dist/chunks/{continuous-sync-ZT3UEQLK.ts.map → continuous-sync-VRMXCA5X.ts.map} +0 -0
- /package/dist/chunks/{local-fields-B6JWLPAE.ts.map → local-fields-JEKAPZKD.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-NODX3KKH.ts.map → manager-result-dispatcher-6W52NM7F.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-HL3VLMAM.ts.map → merge-baseline-store-65GKUMXI.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-OHMIQ32X.ts.map → remote-selection-ui-TD4C4W3J.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-66GWMBNH.ts.map → setup-switch-PIPXYPCW.ts.map} +0 -0
- /package/dist/chunks/{setup-wizard-OHZXJVQZ.ts.map → setup-wizard-RR5U3SUB.ts.map} +0 -0
- /package/dist/chunks/{snapshot-VRHNAUUH.ts.map → snapshot-RGNDYT6A.ts.map} +0 -0
- /package/dist/chunks/{snapshot-transaction-EUGA4VX6.ts.map → snapshot-transaction-26BQO36S.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-4WFQEJOE.ts.map → sync-inspection-MOMLGUTD.ts.map} +0 -0
- /package/dist/chunks/{sync-operations-X4AOXMBT.ts.map → sync-operations-CV3TULLD.ts.map} +0 -0
- /package/dist/chunks/{sync-state-SWLSMK4G.ts.map → sync-state-7L4NT7W2.ts.map} +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
type MutationGuards = { signal?: AbortSignal; validateMutation?: () => void };
|
|
6
|
+
|
|
7
|
+
function assertCurrent(options: MutationGuards) {
|
|
8
|
+
options.validateMutation?.();
|
|
9
|
+
options.signal?.throwIfAborted();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isMissing(error: unknown) {
|
|
13
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
14
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function targetMissing(target: string, options: MutationGuards) {
|
|
18
|
+
assertCurrent(options);
|
|
19
|
+
try {
|
|
20
|
+
await fs.lstat(target);
|
|
21
|
+
assertCurrent(options);
|
|
22
|
+
return false;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
assertCurrent(options);
|
|
25
|
+
if (!isMissing(error)) throw error;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function queueKey(target: string, options: MutationGuards) {
|
|
31
|
+
assertCurrent(options);
|
|
32
|
+
try {
|
|
33
|
+
const key = await fs.realpath(target);
|
|
34
|
+
assertCurrent(options);
|
|
35
|
+
return key;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
assertCurrent(options);
|
|
38
|
+
if (!isMissing(error)) throw error;
|
|
39
|
+
return path.resolve(target);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve missing descendants too, including descendants of a file scheduled for removal. */
|
|
44
|
+
async function assertCanonicalParent(target: string, options: MutationGuards) {
|
|
45
|
+
const parent = path.dirname(path.resolve(target));
|
|
46
|
+
const suffix: string[] = [];
|
|
47
|
+
for (let current = parent; ; current = path.dirname(current)) {
|
|
48
|
+
assertCurrent(options);
|
|
49
|
+
let canonical: string;
|
|
50
|
+
try {
|
|
51
|
+
canonical = await fs.realpath(current);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
assertCurrent(options);
|
|
54
|
+
if (!isMissing(error) || path.dirname(current) === current) throw error;
|
|
55
|
+
suffix.unshift(path.basename(current));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
assertCurrent(options);
|
|
59
|
+
// Pi queues existing paths by realpath and missing paths by lexical resolve.
|
|
60
|
+
// Its public API cannot reserve a missing alias key while that alias exists,
|
|
61
|
+
// or report a key that changes during registration. Refuse aliased parents
|
|
62
|
+
// before mutation rather than risk a non-reentrant queue or overwrite a writer.
|
|
63
|
+
if (path.join(canonical, ...suffix) !== parent)
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Snapshot mutation through an aliased parent is unsafe; use the canonical agent/session path. Transaction evidence retained for review.",
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function assertCanonicalMutationTargets(targets: readonly string[], options: MutationGuards) {
|
|
72
|
+
for (const target of targets) await assertCanonicalParent(target, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function withTargetQueues<T>(
|
|
76
|
+
targets: readonly string[],
|
|
77
|
+
options: MutationGuards,
|
|
78
|
+
action: (heldKeys: ReadonlySet<string>) => Promise<T>,
|
|
79
|
+
): Promise<T> {
|
|
80
|
+
// Check the whole group before reserving any key or changing live files.
|
|
81
|
+
await assertCanonicalMutationTargets(targets, options);
|
|
82
|
+
const keys = new Set<string>();
|
|
83
|
+
const missing = new Set<string>();
|
|
84
|
+
for (const target of targets) {
|
|
85
|
+
if (await targetMissing(target, options)) missing.add(target);
|
|
86
|
+
const key = await queueKey(target, options);
|
|
87
|
+
if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
|
|
88
|
+
keys.add(key);
|
|
89
|
+
}
|
|
90
|
+
const ordered = [...keys].sort();
|
|
91
|
+
async function acquire(index: number): Promise<T> {
|
|
92
|
+
assertCurrent(options);
|
|
93
|
+
const key = ordered[index];
|
|
94
|
+
// Pass the resolved key, not an alias whose lookup can select a different key.
|
|
95
|
+
if (key) return withFileMutationQueue(key, () => acquire(index + 1));
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
await assertCanonicalParent(target, options);
|
|
98
|
+
if (missing.has(target) && !(await targetMissing(target, options)))
|
|
99
|
+
throw new Error("Missing transaction target appeared during queue acquisition; review is required.");
|
|
100
|
+
if (!keys.has(await queueKey(target, options)))
|
|
101
|
+
throw new Error("Transaction queue identity changed; review is required.");
|
|
102
|
+
}
|
|
103
|
+
assertCurrent(options);
|
|
104
|
+
return action(keys);
|
|
105
|
+
}
|
|
106
|
+
return acquire(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function withMissingTargetQueues<T>(
|
|
110
|
+
targets: readonly string[],
|
|
111
|
+
heldKeys: ReadonlySet<string>,
|
|
112
|
+
options: MutationGuards,
|
|
113
|
+
action: (heldKeys: ReadonlySet<string>) => Promise<T>,
|
|
114
|
+
): Promise<T> {
|
|
115
|
+
const unique = [...new Set(targets)];
|
|
116
|
+
const requireAbsent = async () => {
|
|
117
|
+
for (const target of unique) {
|
|
118
|
+
await assertCanonicalParent(target, options);
|
|
119
|
+
if (!(await targetMissing(target, options)))
|
|
120
|
+
throw new Error("Snapshot target changed before destination queue reservation; evidence retained.");
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
// Canonical parents make each missing key equal to the intended installed key.
|
|
124
|
+
// Retain the old physical key and exclude it from these non-reentrant queues.
|
|
125
|
+
const keys = unique
|
|
126
|
+
.map((target) => path.resolve(target))
|
|
127
|
+
.filter((key) => !heldKeys.has(key))
|
|
128
|
+
.sort();
|
|
129
|
+
await requireAbsent();
|
|
130
|
+
const combined = new Set([...heldKeys, ...keys]);
|
|
131
|
+
async function acquire(index: number): Promise<T> {
|
|
132
|
+
assertCurrent(options);
|
|
133
|
+
const key = keys[index];
|
|
134
|
+
if (key) return withFileMutationQueue(key, () => acquire(index + 1));
|
|
135
|
+
// A writer registered in the removal gap must finish before this check.
|
|
136
|
+
// Even recognizable pre/postimages belong to that writer, not this transaction.
|
|
137
|
+
await requireAbsent();
|
|
138
|
+
assertCurrent(options);
|
|
139
|
+
return action(combined);
|
|
140
|
+
}
|
|
141
|
+
return acquire(0);
|
|
142
|
+
}
|
|
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { Stats } from "node:fs";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { assertWithinRoot, isDeniedPath, isPathInside } from "../paths.js";
|
|
7
6
|
import { syncDirectory, writeJson } from "../state/json-file.js";
|
|
8
7
|
import { withLock } from "../state/lock.js";
|
|
@@ -11,6 +10,7 @@ import { stateDir } from "../state/state-directory.js";
|
|
|
11
10
|
import { mergePathIdentity } from "../sync/file-merge-planner.js";
|
|
12
11
|
import { agentDir, configuredSessionDir } from "./session-paths.js";
|
|
13
12
|
import { caseReplacementSpelling, coalesceCaseReplacements, isCaseReplacement } from "./snapshot-case-replacement.js";
|
|
13
|
+
import { withMissingTargetQueues, withTargetQueues } from "./snapshot-mutation-queues.js";
|
|
14
14
|
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
15
15
|
import {
|
|
16
16
|
prepareSessionRootTransition,
|
|
@@ -69,7 +69,7 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
|
|
|
69
69
|
const targets = [...new Set([...coalesced.plan.deletes, ...coalesced.plan.writes.map((item) => item.target)])].sort();
|
|
70
70
|
if (targets.length > 16_384)
|
|
71
71
|
throw new Error("Snapshot transaction exceeds its target bound; review a smaller transfer.");
|
|
72
|
-
return withTargetQueues(targets, async () => {
|
|
72
|
+
return withTargetQueues(targets, options, async (heldKeys) => {
|
|
73
73
|
options.signal?.throwIfAborted();
|
|
74
74
|
if (
|
|
75
75
|
targets.some((target) =>
|
|
@@ -134,6 +134,10 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
|
|
|
134
134
|
...(entry.afterTarget ? [[entry.afterTarget, entry] as const] : []),
|
|
135
135
|
]),
|
|
136
136
|
);
|
|
137
|
+
// A released additional key cannot authorize rollback, even for recognized raced bytes.
|
|
138
|
+
const changesQueueKey = transaction.journal.entries.some(
|
|
139
|
+
(entry) => !heldKeys.has(entry.target) || (entry.afterTarget && !heldKeys.has(entry.afterTarget)),
|
|
140
|
+
);
|
|
137
141
|
try {
|
|
138
142
|
for (const target of transaction.deletes.sort((a, b) => a.length - b.length)) {
|
|
139
143
|
options.signal?.throwIfAborted();
|
|
@@ -142,75 +146,81 @@ export async function applySnapshotTransaction(plan: SnapshotApplyPlan, options:
|
|
|
142
146
|
await removeOwnedTarget(transaction.directory, entry, transaction.journal, options);
|
|
143
147
|
await syncDirectory(path.dirname(target));
|
|
144
148
|
}
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
168
|
-
try {
|
|
169
|
-
let mode = 0o600;
|
|
170
|
-
try {
|
|
171
|
-
const stat = await fs.lstat(item.target);
|
|
172
|
-
if (stat.isFile()) mode = stat.mode & 0o777;
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
175
|
-
}
|
|
176
|
-
const handle = await fs.open(temp, "wx", mode);
|
|
177
|
-
try {
|
|
178
|
-
await handle.writeFile(item.content);
|
|
179
|
-
await handle.sync();
|
|
180
|
-
} finally {
|
|
181
|
-
await handle.close();
|
|
182
|
-
}
|
|
149
|
+
await withMissingTargetQueues(
|
|
150
|
+
transaction.journal.entries
|
|
151
|
+
.filter((entry) => entry.removalPending)
|
|
152
|
+
.flatMap((entry) => [entry.target, ...(entry.afterTarget ? [entry.afterTarget] : [])]),
|
|
153
|
+
heldKeys,
|
|
154
|
+
options,
|
|
155
|
+
async () => {
|
|
156
|
+
await retireRemovalIntent(
|
|
157
|
+
transaction.directory,
|
|
158
|
+
transaction.journal,
|
|
159
|
+
transaction.journal.entries.filter(
|
|
160
|
+
(entry) => entry.removalPending && (entry.afterImage !== "missing" || (entry.postFiles?.length ?? 0) > 0),
|
|
161
|
+
),
|
|
162
|
+
options,
|
|
163
|
+
);
|
|
164
|
+
// Only this uninterrupted call knows which deleted writes have not attempted installation yet.
|
|
165
|
+
const uninstalledWrites = new Set(transaction.deletedWrites);
|
|
166
|
+
for (const item of plan.writes) {
|
|
167
|
+
options.signal?.throwIfAborted();
|
|
168
|
+
const entry = entriesByTarget.get(item.target);
|
|
169
|
+
if (!entry) throw new Error("Unowned transaction target.");
|
|
170
|
+
const deletedByThisCall = uninstalledWrites.delete(entry.target);
|
|
183
171
|
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
172
|
+
options.validateMutation?.();
|
|
184
173
|
options.signal?.throwIfAborted();
|
|
174
|
+
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
185
175
|
options.validateMutation?.();
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
176
|
+
options.signal?.throwIfAborted();
|
|
177
|
+
const temp = path.join(path.dirname(item.target), `.pi-sync.json.${randomUUID()}.apply`);
|
|
178
|
+
try {
|
|
179
|
+
let mode = 0o600;
|
|
180
|
+
try {
|
|
181
|
+
const stat = await fs.lstat(item.target);
|
|
182
|
+
if (stat.isFile()) mode = stat.mode & 0o777;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
185
|
+
}
|
|
186
|
+
const handle = await fs.open(temp, "wx", mode);
|
|
187
|
+
try {
|
|
188
|
+
await handle.writeFile(item.content);
|
|
189
|
+
await handle.sync();
|
|
190
|
+
} finally {
|
|
191
|
+
await handle.close();
|
|
192
|
+
}
|
|
193
|
+
await verifyTarget(transaction.directory, entry, transaction.journal, deletedByThisCall);
|
|
194
|
+
options.signal?.throwIfAborted();
|
|
195
|
+
options.validateMutation?.();
|
|
196
|
+
await fs.rename(temp, item.target);
|
|
197
|
+
await syncDirectory(path.dirname(item.target));
|
|
198
|
+
} finally {
|
|
199
|
+
await fs.rm(temp, { force: true });
|
|
200
|
+
}
|
|
190
201
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
options.validateMutation?.();
|
|
203
|
+
options.signal?.throwIfAborted();
|
|
204
|
+
await syncMutationParents(
|
|
205
|
+
targets,
|
|
206
|
+
[transaction.journal.root, ...(transaction.journal.sessionRoot ? [transaction.journal.sessionRoot] : [])],
|
|
207
|
+
options,
|
|
208
|
+
);
|
|
209
|
+
options.validateMutation?.();
|
|
210
|
+
options.signal?.throwIfAborted();
|
|
211
|
+
},
|
|
212
|
+
);
|
|
202
213
|
} catch (error) {
|
|
203
214
|
// Aborted owners cannot roll back files after another session has replaced them.
|
|
204
215
|
if (options.signal?.aborted)
|
|
205
216
|
throw new Error("Snapshot apply cancelled; guarded transaction evidence retained for review.");
|
|
206
|
-
// Additional
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
throw new Error("Case replacement apply failed; guarded transaction evidence retained for review.", {
|
|
217
|
+
// Additional queues have been released. Recovery must reserve missing keys afresh.
|
|
218
|
+
if (changesQueueKey)
|
|
219
|
+
throw new Error("Snapshot apply failed; guarded transaction evidence retained for review.", {
|
|
210
220
|
cause: error,
|
|
211
221
|
});
|
|
212
222
|
try {
|
|
213
|
-
await restoreTransaction(transaction.directory, transaction.journal, options);
|
|
223
|
+
await restoreTransaction(transaction.directory, transaction.journal, options, heldKeys);
|
|
214
224
|
} catch (recoveryError) {
|
|
215
225
|
throw new AggregateError(
|
|
216
226
|
[error, recoveryError],
|
|
@@ -285,8 +295,8 @@ export async function recoverPendingSnapshotTransactions(options: TransactionOpt
|
|
|
285
295
|
await removeTransaction(directory);
|
|
286
296
|
continue;
|
|
287
297
|
}
|
|
288
|
-
await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), () =>
|
|
289
|
-
restoreTransaction(directory, journal, { ...options, sessionDir }),
|
|
298
|
+
await withTargetQueues(journal.entries.map((entry) => entry.target).sort(), options, (heldKeys) =>
|
|
299
|
+
restoreTransaction(directory, journal, { ...options, sessionDir }, heldKeys),
|
|
290
300
|
);
|
|
291
301
|
}
|
|
292
302
|
}
|
|
@@ -388,7 +398,36 @@ async function prepareTransaction(
|
|
|
388
398
|
return { directory, journal, deletes: indexed.deletes, deletedWrites: indexed.deletedWrites };
|
|
389
399
|
}
|
|
390
400
|
|
|
391
|
-
async function restoreTransaction(
|
|
401
|
+
async function restoreTransaction(
|
|
402
|
+
directory: string,
|
|
403
|
+
journal: TransactionJournal,
|
|
404
|
+
options: TransactionOptions,
|
|
405
|
+
heldKeys: ReadonlySet<string>,
|
|
406
|
+
) {
|
|
407
|
+
validateJournal(directory, journal, options.sessionDir);
|
|
408
|
+
const missingSpellings: string[] = [];
|
|
409
|
+
for (const entry of journal.entries) {
|
|
410
|
+
if (!entry.afterTarget) continue;
|
|
411
|
+
options.validateMutation?.();
|
|
412
|
+
options.signal?.throwIfAborted();
|
|
413
|
+
const spelling = await caseReplacementSpelling(entry.target, entry.afterTarget);
|
|
414
|
+
options.validateMutation?.();
|
|
415
|
+
options.signal?.throwIfAborted();
|
|
416
|
+
if (spelling === undefined) missingSpellings.push(entry.target, entry.afterTarget);
|
|
417
|
+
}
|
|
418
|
+
// Reserve an interrupted removal's other spelling before staging or image checks:
|
|
419
|
+
// recognizable bytes installed by a writer in that gap are not our postimage.
|
|
420
|
+
return withMissingTargetQueues(missingSpellings, heldKeys, options, (reservedKeys) =>
|
|
421
|
+
restoreQueuedTransaction(directory, journal, options, reservedKeys),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function restoreQueuedTransaction(
|
|
426
|
+
directory: string,
|
|
427
|
+
journal: TransactionJournal,
|
|
428
|
+
options: TransactionOptions,
|
|
429
|
+
heldKeys: ReadonlySet<string>,
|
|
430
|
+
) {
|
|
392
431
|
validateJournal(directory, journal, options.sessionDir);
|
|
393
432
|
options.validateMutation?.();
|
|
394
433
|
options.signal?.throwIfAborted();
|
|
@@ -453,8 +492,13 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
|
|
|
453
492
|
!(await removeOwnedTarget(directory, entry, journal, options, before))
|
|
454
493
|
)
|
|
455
494
|
continue;
|
|
456
|
-
await
|
|
457
|
-
|
|
495
|
+
await withMissingTargetQueues(
|
|
496
|
+
entry.afterTarget && (needsCaseQueue || verified === "missing")
|
|
497
|
+
? [entry.target, entry.afterTarget]
|
|
498
|
+
: verified.startsWith("directory:")
|
|
499
|
+
? [entry.target]
|
|
500
|
+
: [],
|
|
501
|
+
heldKeys,
|
|
458
502
|
options,
|
|
459
503
|
async () => {
|
|
460
504
|
const atRename = await verifyTarget(directory, entry, journal);
|
|
@@ -501,17 +545,19 @@ async function restoreTransaction(directory: string, journal: TransactionJournal
|
|
|
501
545
|
options.validateMutation?.();
|
|
502
546
|
options.signal?.throwIfAborted();
|
|
503
547
|
if (!(await removeOwnedTarget(directory, entry, journal, options, before))) continue;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
548
|
+
await withMissingTargetQueues([entry.target], heldKeys, options, async () => {
|
|
549
|
+
const atRename = await verifyTarget(directory, entry, journal);
|
|
550
|
+
options.validateMutation?.();
|
|
551
|
+
options.signal?.throwIfAborted();
|
|
552
|
+
if (atRename === before) return;
|
|
553
|
+
await retireRestorationIntent(directory, journal, entry, options);
|
|
554
|
+
const atInstall = await verifyTarget(directory, entry, journal, atRename === "missing");
|
|
555
|
+
options.validateMutation?.();
|
|
556
|
+
options.signal?.throwIfAborted();
|
|
557
|
+
if (atInstall === before) return;
|
|
558
|
+
await fs.rename(temporary, entry.target);
|
|
559
|
+
await syncDirectory(path.dirname(entry.target));
|
|
560
|
+
});
|
|
515
561
|
} finally {
|
|
516
562
|
await fs.rm(temporary, { recursive: true, force: true });
|
|
517
563
|
}
|
|
@@ -867,55 +913,6 @@ async function assertSafeParents(root: string, sessionRoot: string | undefined,
|
|
|
867
913
|
}
|
|
868
914
|
}
|
|
869
915
|
}
|
|
870
|
-
async function withTargetQueues<T>(targets: string[], action: () => Promise<T>) {
|
|
871
|
-
const keys = new Set<string>();
|
|
872
|
-
for (const target of targets) {
|
|
873
|
-
let key = path.resolve(target);
|
|
874
|
-
try {
|
|
875
|
-
key = await fs.realpath(target);
|
|
876
|
-
} catch (error) {
|
|
877
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
878
|
-
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
879
|
-
}
|
|
880
|
-
if (keys.has(key)) throw new Error("Transaction targets alias the same file.");
|
|
881
|
-
keys.add(key);
|
|
882
|
-
}
|
|
883
|
-
async function acquire(index: number): Promise<T> {
|
|
884
|
-
const target = targets[index];
|
|
885
|
-
return target ? withFileMutationQueue(target, () => acquire(index + 1)) : action();
|
|
886
|
-
}
|
|
887
|
-
return acquire(0);
|
|
888
|
-
}
|
|
889
|
-
async function withCaseDestinationQueues<T>(
|
|
890
|
-
spellings: readonly (readonly [string, string])[],
|
|
891
|
-
options: TransactionOptions,
|
|
892
|
-
action: () => Promise<T>,
|
|
893
|
-
): Promise<T> {
|
|
894
|
-
if (!spellings.length) return action();
|
|
895
|
-
const requireAbsent = async () => {
|
|
896
|
-
for (const [before, after] of spellings) {
|
|
897
|
-
options.validateMutation?.();
|
|
898
|
-
options.signal?.throwIfAborted();
|
|
899
|
-
const spelling = await caseReplacementSpelling(before, after);
|
|
900
|
-
options.validateMutation?.();
|
|
901
|
-
options.signal?.throwIfAborted();
|
|
902
|
-
if (spelling !== undefined)
|
|
903
|
-
throw new Error("Case replacement changed before destination queue reservation; evidence retained.");
|
|
904
|
-
}
|
|
905
|
-
};
|
|
906
|
-
// Existing aliases share one non-reentrant realpath queue. Only after removal can
|
|
907
|
-
// we reserve the distinct missing spelling while retaining the original queue.
|
|
908
|
-
// Pi writers that registered in the gap finish before this callback; refuse their
|
|
909
|
-
// bytes, including recognizable pre/postimages, rather than treating them as ours.
|
|
910
|
-
await requireAbsent();
|
|
911
|
-
return withTargetQueues(
|
|
912
|
-
spellings.map(([, after]) => after),
|
|
913
|
-
async () => {
|
|
914
|
-
await requireAbsent();
|
|
915
|
-
return action();
|
|
916
|
-
},
|
|
917
|
-
);
|
|
918
|
-
}
|
|
919
916
|
async function completeTransaction(directory: string, journal: TransactionJournal) {
|
|
920
917
|
// Publish completion before deleting any backup. A surviving journal after power loss
|
|
921
918
|
// may only retry cleanup, never roll back already accepted files.
|
package/src/sync/merge-apply.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
import { agentDir } from "../snapshot/session-paths.js";
|
|
6
6
|
import { createSnapshot, snapshotTarget } from "../snapshot/snapshot.js";
|
|
7
7
|
import { preflightSnapshotApply, preflightSnapshotMutations } from "../snapshot/snapshot-apply.js";
|
|
8
|
+
import { assertCanonicalMutationTargets } from "../snapshot/snapshot-mutation-queues.js";
|
|
8
9
|
import { sessionStorageRoot } from "../snapshot/snapshot-paths.js";
|
|
9
10
|
import type { Snapshot, SnapshotOptions } from "../snapshot/snapshot-types.js";
|
|
10
11
|
import { syncDirectory } from "../state/json-file.js";
|
|
@@ -34,6 +35,10 @@ export async function preflightMergedTargets(
|
|
|
34
35
|
for (const relative of paths) {
|
|
35
36
|
if (changed.has(relative)) await assertFilesystemTarget(root, relative, options, removedTargets, beforeHashes);
|
|
36
37
|
}
|
|
38
|
+
await assertCanonicalMutationTargets(
|
|
39
|
+
[...changed].map((relative) => snapshotTarget(root, relative, options.sessionDir)),
|
|
40
|
+
options,
|
|
41
|
+
);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
function replacementPaths(removed: Set<string>, afterHashes: Readonly<Record<string, string>>) {
|
|
@@ -244,6 +249,7 @@ export async function applyMergedSnapshot(
|
|
|
244
249
|
protectedTarget,
|
|
245
250
|
new Set(Object.keys(afterHashes)),
|
|
246
251
|
);
|
|
252
|
+
await assertCanonicalMutationTargets(targets, options);
|
|
247
253
|
// Existing case aliases share Pi's non-reentrant realpath queue. Reserve the
|
|
248
254
|
// missing destination separately only after deleting its reviewed preimage.
|
|
249
255
|
const targetsByQueue = new Map<string, string[]>();
|
|
@@ -28,6 +28,7 @@ interface CancellableOperationOptions {
|
|
|
28
28
|
cancelledMessage?: string | null;
|
|
29
29
|
target?: string;
|
|
30
30
|
signal?: AbortSignal;
|
|
31
|
+
isCurrent?: () => boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export async function runCancellableOperation(
|
|
@@ -37,21 +38,45 @@ export async function runCancellableOperation(
|
|
|
37
38
|
runRoute: RunRoute,
|
|
38
39
|
options: CancellableOperationOptions = {},
|
|
39
40
|
): Promise<CancellableOperationResult> {
|
|
41
|
+
const result = await runCancellableTask(
|
|
42
|
+
ctx,
|
|
43
|
+
message,
|
|
44
|
+
(signal, onCommit, onDialog) => runRoute(route, signal, onCommit, options.target, onDialog),
|
|
45
|
+
options,
|
|
46
|
+
);
|
|
47
|
+
return result.kind === "completed" ? (result.value ?? { kind: "failed" }) : result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CancellableTaskResult<Value> =
|
|
51
|
+
| { kind: "completed"; value: Value }
|
|
52
|
+
| { kind: "closed" }
|
|
53
|
+
| { kind: "cancelled" };
|
|
54
|
+
|
|
55
|
+
/** Keep non-route work under the same cancellation and drain owner as transfers. */
|
|
56
|
+
export async function runCancellableTask<Value>(
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
message: string,
|
|
59
|
+
task: (
|
|
60
|
+
signal: AbortSignal | undefined,
|
|
61
|
+
onCommit?: () => void,
|
|
62
|
+
onDialog?: (active: boolean) => void,
|
|
63
|
+
) => Promise<Value>,
|
|
64
|
+
options: Omit<CancellableOperationOptions, "target"> = {},
|
|
65
|
+
): Promise<CancellableTaskResult<Value>> {
|
|
40
66
|
const {
|
|
41
67
|
commitAware = false,
|
|
42
68
|
cancelAcrossDialogs = false,
|
|
43
69
|
cancelledMessage = "Check cancelled; no settings or files were changed.",
|
|
44
|
-
target,
|
|
45
70
|
signal,
|
|
71
|
+
isCurrent = () => true,
|
|
46
72
|
} = options;
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
}
|
|
73
|
+
if (signal?.aborted || !isCurrent()) return { kind: "closed" };
|
|
74
|
+
if (ctx.mode !== "tui") return { kind: "completed", value: await task(signal) };
|
|
50
75
|
let commitStarted = false;
|
|
51
|
-
let
|
|
76
|
+
let taskResult!: Value;
|
|
52
77
|
const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }, ExtensionContext>(ctx, {
|
|
53
78
|
signal,
|
|
54
|
-
isCurrent: () => !signal?.aborted,
|
|
79
|
+
isCurrent: () => !signal?.aborted && isCurrent(),
|
|
55
80
|
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
|
56
81
|
const loader = new BorderedLoader(tui, theme, message, { cancellable: false });
|
|
57
82
|
const cancelHint = formatInteractionHints(keybindings, [
|
|
@@ -80,13 +105,11 @@ export async function runCancellableOperation(
|
|
|
80
105
|
return cancel(data) ? { consume: true } : undefined;
|
|
81
106
|
})
|
|
82
107
|
: undefined;
|
|
83
|
-
let
|
|
108
|
+
let pendingTask: Promise<Value>;
|
|
84
109
|
try {
|
|
85
|
-
|
|
86
|
-
route,
|
|
110
|
+
pendingTask = task(
|
|
87
111
|
interactionSignal,
|
|
88
112
|
commitAware ? () => (commitStarted = true) : undefined,
|
|
89
|
-
target,
|
|
90
113
|
cancelAcrossDialogs
|
|
91
114
|
? (active) => {
|
|
92
115
|
activeDialogs += active ? 1 : -1;
|
|
@@ -98,9 +121,9 @@ export async function runCancellableOperation(
|
|
|
98
121
|
loader.dispose();
|
|
99
122
|
throw error;
|
|
100
123
|
}
|
|
101
|
-
const operation =
|
|
124
|
+
const operation = pendingTask.then(
|
|
102
125
|
(result) => {
|
|
103
|
-
|
|
126
|
+
taskResult = result;
|
|
104
127
|
complete({});
|
|
105
128
|
},
|
|
106
129
|
(error: unknown) => complete({ error }),
|
|
@@ -134,7 +157,7 @@ export async function runCancellableOperation(
|
|
|
134
157
|
return { kind: "cancelled" };
|
|
135
158
|
}
|
|
136
159
|
if (interaction.value.error) throw interaction.value.error;
|
|
137
|
-
return
|
|
160
|
+
return { kind: "completed", value: taskResult };
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
function hasControlCharacter(value: string) {
|
package/src/ui/manager-state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { loadConfig } from "../settings/config.js";
|
|
2
|
+
import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
|
|
3
3
|
import { activeLocalConfigPath } from "../settings/config-file.js";
|
|
4
4
|
import { readLocalConfigObject } from "../settings/settings-store.js";
|
|
5
5
|
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
@@ -39,6 +39,7 @@ export const MAIN_MENU_ACTIONS = [
|
|
|
39
39
|
export interface ManagerDescription {
|
|
40
40
|
title: string;
|
|
41
41
|
actions: string[];
|
|
42
|
+
configReview?: { setupName: string; fingerprint: string };
|
|
42
43
|
operation?: OperationAvailability;
|
|
43
44
|
attention?: SyncAttentionState;
|
|
44
45
|
observation?: StartupObservation;
|
|
@@ -176,6 +177,7 @@ export async function describeManagerState(
|
|
|
176
177
|
return {
|
|
177
178
|
title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"),
|
|
178
179
|
actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
|
|
180
|
+
configReview: { setupName: config.setupName, fingerprint: syncCheckConfigFingerprint(config) },
|
|
179
181
|
operation,
|
|
180
182
|
...(currentObservation ? { observation: currentObservation } : {}),
|
|
181
183
|
...(currentAttention
|