@narumitw/pi-sync 0.49.6 → 0.49.8
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 +53 -26
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/manager-attention.ts +81 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +50 -3
- package/src/manager-ui.ts +70 -53
- package/src/remote-selection-ui.ts +541 -142
- package/src/remote-snapshot.ts +3 -2
- package/src/settings-management.ts +9 -2
- package/src/settings-ui.ts +21 -5
- package/src/sync-attention.ts +100 -0
- package/src/sync-extension.ts +203 -5
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
package/src/remote-snapshot.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { syncConfigReviewFingerprint } from "./config.js";
|
|
1
2
|
import type { RemoteHead, SyncBackend } from "./sync-backend.js";
|
|
2
3
|
import { safeTerminalText } from "./sync-format.js";
|
|
3
4
|
import {
|
|
4
5
|
inspectRemoteSelection,
|
|
5
|
-
RemoteSelectionMismatchError,
|
|
6
6
|
type RemoteSelectionState,
|
|
7
|
+
remoteSelectionMismatch,
|
|
7
8
|
sameSyncInclude,
|
|
8
9
|
snapshotSelectionInclude,
|
|
9
10
|
} from "./sync-policy.js";
|
|
@@ -37,7 +38,7 @@ export async function readSnapshotForHead(
|
|
|
37
38
|
export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
|
|
38
39
|
const state = inspectRemoteSelection(config.include, snapshot);
|
|
39
40
|
if (state.kind === "different") {
|
|
40
|
-
throw
|
|
41
|
+
throw remoteSelectionMismatch(config, state.include, syncConfigReviewFingerprint(config));
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -9,6 +9,13 @@ import {
|
|
|
9
9
|
} from "./config.js";
|
|
10
10
|
import type { PiSyncSettingsV3, StorageConnectionSettings, SyncSetupSettings } from "./types.js";
|
|
11
11
|
|
|
12
|
+
export class SyncSetupReviewChangedError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SyncSetupReviewChangedError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export async function saveNewV3Settings(
|
|
13
20
|
input: {
|
|
14
21
|
setupName: string;
|
|
@@ -119,7 +126,7 @@ export async function updateSyncSetup(
|
|
|
119
126
|
options.expectedInclude &&
|
|
120
127
|
!sameNames(normalizeSyncInclude(setup.sync.include), options.expectedInclude)
|
|
121
128
|
) {
|
|
122
|
-
throw new
|
|
129
|
+
throw new SyncSetupReviewChangedError(
|
|
123
130
|
`Sync setup “${name}” included content changed while it was open; reopen it and review the current selection.`,
|
|
124
131
|
);
|
|
125
132
|
}
|
|
@@ -133,7 +140,7 @@ export async function updateSyncSetup(
|
|
|
133
140
|
options.expectedStorage,
|
|
134
141
|
)
|
|
135
142
|
) {
|
|
136
|
-
throw new
|
|
143
|
+
throw new SyncSetupReviewChangedError(
|
|
137
144
|
`Sync setup “${name}” storage changed while it was open; reopen it and review the current storage location.`,
|
|
138
145
|
);
|
|
139
146
|
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
3
|
import type { RunRoute } from "./cancellable-operation.js";
|
|
4
4
|
import { loadConfig, localConfigPath } from "./config.js";
|
|
5
|
+
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
5
6
|
import { updateSyncSetup } from "./settings-management.js";
|
|
6
7
|
import {
|
|
7
8
|
SETUP_SWITCH_ACTION_OPTIONS,
|
|
@@ -67,9 +68,8 @@ export async function showSyncSettings(
|
|
|
67
68
|
},
|
|
68
69
|
{
|
|
69
70
|
id: "remoteInclude",
|
|
70
|
-
label: "
|
|
71
|
-
description:
|
|
72
|
-
"Compare portable remote selection, then adopt it or keep local settings without pulling files.",
|
|
71
|
+
label: "Compare synced content",
|
|
72
|
+
description: "Review this device and remote content lists before choosing either one.",
|
|
73
73
|
currentValue: "Review",
|
|
74
74
|
action: "remote-include",
|
|
75
75
|
},
|
|
@@ -126,8 +126,24 @@ export async function showSyncSettings(
|
|
|
126
126
|
const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
127
127
|
const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
|
|
128
128
|
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
129
|
-
await showRemoteSelectionReview(ctx, setupName, reviewSignal
|
|
130
|
-
|
|
129
|
+
const review = await showRemoteSelectionReview(ctx, setupName, reviewSignal, undefined, {
|
|
130
|
+
origin: "settings",
|
|
131
|
+
runRoute,
|
|
132
|
+
});
|
|
133
|
+
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
134
|
+
if (review.kind === "route-result") {
|
|
135
|
+
const disposition = await dispatchManagerResult(
|
|
136
|
+
ctx,
|
|
137
|
+
review.result,
|
|
138
|
+
review.route,
|
|
139
|
+
runRoute,
|
|
140
|
+
reviewSignal,
|
|
141
|
+
);
|
|
142
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
143
|
+
}
|
|
144
|
+
return review.kind === "closed" || review.kind === "stale"
|
|
145
|
+
? { kind: "close" }
|
|
146
|
+
: { kind: "stay" };
|
|
131
147
|
},
|
|
132
148
|
},
|
|
133
149
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { syncConfigReviewFingerprint } from "./config.js";
|
|
4
|
+
import { safeTerminalText } from "./sync-format.js";
|
|
5
|
+
import {
|
|
6
|
+
compareSyncInclude,
|
|
7
|
+
type RemoteSelectionDecision,
|
|
8
|
+
sameSyncInclude,
|
|
9
|
+
} from "./sync-policy.js";
|
|
10
|
+
import type { AnySyncConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const STATUS_KEY = "sync";
|
|
13
|
+
const WIDGET_KEY = "sync:attention";
|
|
14
|
+
|
|
15
|
+
export type SyncAttentionOrigin = "sync" | "pull" | "push";
|
|
16
|
+
|
|
17
|
+
export interface SyncAttentionState {
|
|
18
|
+
decision: RemoteSelectionDecision;
|
|
19
|
+
origin: SyncAttentionOrigin;
|
|
20
|
+
offered: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function syncAttentionMatchesConfig(attention: SyncAttentionState, config: AnySyncConfig) {
|
|
24
|
+
return (
|
|
25
|
+
attention.decision.setupName === config.setupName &&
|
|
26
|
+
attention.decision.configIdentity === syncConfigReviewFingerprint(config) &&
|
|
27
|
+
sameSyncInclude(attention.decision.localInclude, config.include)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncAttentionController {
|
|
32
|
+
set(decision: RemoteSelectionDecision, origin: SyncAttentionOrigin): void;
|
|
33
|
+
current(): SyncAttentionState | undefined;
|
|
34
|
+
markOffered(): boolean;
|
|
35
|
+
clear(ctx: ExtensionContext): void;
|
|
36
|
+
reset(ctx: ExtensionContext): void;
|
|
37
|
+
publish(ctx: ExtensionContext): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSyncAttentionController(): SyncAttentionController {
|
|
41
|
+
let state: SyncAttentionState | undefined;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
set(decision, origin) {
|
|
45
|
+
state = { decision, origin, offered: false };
|
|
46
|
+
},
|
|
47
|
+
current() {
|
|
48
|
+
return state;
|
|
49
|
+
},
|
|
50
|
+
markOffered() {
|
|
51
|
+
if (!state || state.offered) return false;
|
|
52
|
+
state = { ...state, offered: true };
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
clear(ctx) {
|
|
56
|
+
state = undefined;
|
|
57
|
+
clearAttentionPresentation(ctx);
|
|
58
|
+
},
|
|
59
|
+
reset(ctx) {
|
|
60
|
+
state = undefined;
|
|
61
|
+
clearAttentionPresentation(ctx);
|
|
62
|
+
},
|
|
63
|
+
publish(ctx) {
|
|
64
|
+
if (!state) {
|
|
65
|
+
clearAttentionPresentation(ctx);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const presentation = attentionPresentation(state.decision);
|
|
69
|
+
ctx.ui.setStatus(STATUS_KEY, presentation.status);
|
|
70
|
+
if (ctx.mode !== "tui") return;
|
|
71
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
|
72
|
+
render(width: number) {
|
|
73
|
+
const safeWidth = Math.max(1, width);
|
|
74
|
+
return presentation.lines.map((line, index) =>
|
|
75
|
+
truncateToWidth(theme.fg(index === 0 ? "warning" : "muted", line), safeWidth, "…"),
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
invalidate() {},
|
|
79
|
+
}));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function attentionPresentation(decision: RemoteSelectionDecision) {
|
|
85
|
+
const setupName = safeTerminalText(decision.setupName);
|
|
86
|
+
const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
|
|
87
|
+
const difference =
|
|
88
|
+
comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
|
|
89
|
+
? "Only list order differs"
|
|
90
|
+
: `Remote ${comparison.remoteOnly.length} · Device ${comparison.localOnly.length}`;
|
|
91
|
+
return {
|
|
92
|
+
status: "review needed",
|
|
93
|
+
lines: [`Pi Sync needs review · ${setupName}`, difference, "No changes · Run /sync to review"],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearAttentionPresentation(ctx: ExtensionContext) {
|
|
98
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
99
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
100
|
+
}
|
package/src/sync-extension.ts
CHANGED
|
@@ -36,11 +36,22 @@ import {
|
|
|
36
36
|
stateDirectoryMigrationNotice,
|
|
37
37
|
withStateDirectoryAccess,
|
|
38
38
|
} from "./state-directory.js";
|
|
39
|
+
import {
|
|
40
|
+
createSyncAttentionController,
|
|
41
|
+
type SyncAttentionController,
|
|
42
|
+
type SyncAttentionOrigin,
|
|
43
|
+
syncAttentionMatchesConfig,
|
|
44
|
+
} from "./sync-attention.js";
|
|
39
45
|
import {
|
|
40
46
|
errorMessage,
|
|
41
47
|
isSyncDecisionRequiredError,
|
|
42
48
|
SetupPullRequiresUiError,
|
|
43
49
|
} from "./sync-errors.js";
|
|
50
|
+
import {
|
|
51
|
+
formatRemoteSelectionMismatch,
|
|
52
|
+
type RemoteSelectionDecision,
|
|
53
|
+
RemoteSelectionMismatchError,
|
|
54
|
+
} from "./sync-policy.js";
|
|
44
55
|
import type { AnySyncConfig, CommandOptions, SnapshotOptions } from "./types.js";
|
|
45
56
|
|
|
46
57
|
const STATUS_KEY = "sync";
|
|
@@ -84,6 +95,7 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
|
|
|
84
95
|
dependencies.loadSyncOperations ?? (() => import("./sync-operations.js")),
|
|
85
96
|
),
|
|
86
97
|
};
|
|
98
|
+
const attention = createSyncAttentionController();
|
|
87
99
|
let sessionAbort = new AbortController();
|
|
88
100
|
let shutdownAbort: AbortController | undefined;
|
|
89
101
|
|
|
@@ -96,7 +108,7 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
|
|
|
96
108
|
"/sync requires TUI or RPC mode so results and safety prompts are observable.",
|
|
97
109
|
);
|
|
98
110
|
}
|
|
99
|
-
const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders);
|
|
111
|
+
const run = () => handleCommand(args, ctx, sessionAbort.signal, loaders, attention);
|
|
100
112
|
if (splitArgs(args)[0] === "migrate-state") await run();
|
|
101
113
|
else await withStateDirectoryAccess(run);
|
|
102
114
|
},
|
|
@@ -108,17 +120,38 @@ export default function sync(pi: ExtensionAPI, dependencies: Partial<SyncDepende
|
|
|
108
120
|
sessionAbort.abort(new DOMException("Session replaced", "AbortError"));
|
|
109
121
|
sessionAbort = new AbortController();
|
|
110
122
|
const signal = sessionAbort.signal;
|
|
111
|
-
|
|
123
|
+
attention.reset(ctx);
|
|
124
|
+
let decision: RemoteSelectionDecision | undefined;
|
|
112
125
|
try {
|
|
113
|
-
await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
|
|
126
|
+
decision = await withStateDirectoryAccess(() => startSession(ctx, signal, loaders));
|
|
114
127
|
} catch (error) {
|
|
115
128
|
if (signal.aborted) return;
|
|
116
129
|
ctx.ui.notify(`pi-sync state access failed: ${errorMessage(error)}`, "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!decision || signal.aborted) return;
|
|
133
|
+
attention.set(decision, "sync");
|
|
134
|
+
if (ctx.mode !== "tui") {
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`pi-sync auto sync skipped: ${formatRemoteSelectionMismatch(
|
|
137
|
+
decision.setupName,
|
|
138
|
+
decision.localInclude,
|
|
139
|
+
decision.remoteInclude,
|
|
140
|
+
)}\nRPC review is read-only.`,
|
|
141
|
+
"warning",
|
|
142
|
+
);
|
|
143
|
+
} else if (attention.markOffered()) {
|
|
144
|
+
await resolveSelectionAttention(ctx, attention, signal, loaders, {
|
|
145
|
+
cancelLabel: "Later",
|
|
146
|
+
withStateAccess: withStateDirectoryAccess,
|
|
147
|
+
});
|
|
117
148
|
}
|
|
149
|
+
if (!signal.aborted) attention.publish(ctx);
|
|
118
150
|
});
|
|
119
151
|
|
|
120
152
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
121
153
|
sessionAbort.abort(new DOMException("Session shut down", "AbortError"));
|
|
154
|
+
attention.reset(ctx);
|
|
122
155
|
shutdownAbort?.abort(new DOMException("Session shut down again", "AbortError"));
|
|
123
156
|
const controller = new AbortController();
|
|
124
157
|
shutdownAbort = controller;
|
|
@@ -171,7 +204,7 @@ async function startSession(ctx: ExtensionContext, signal: AbortSignal, loaders:
|
|
|
171
204
|
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
172
205
|
if (migrationNotice) ctx.ui.notify(migrationNotice, "warning");
|
|
173
206
|
if (signal.aborted) return;
|
|
174
|
-
|
|
207
|
+
return autoSync(ctx, signal, loaders);
|
|
175
208
|
}
|
|
176
209
|
|
|
177
210
|
async function handleCommand(
|
|
@@ -179,6 +212,7 @@ async function handleCommand(
|
|
|
179
212
|
ctx: ExtensionCommandContext,
|
|
180
213
|
sessionSignal: AbortSignal,
|
|
181
214
|
loaders: SyncLoaders,
|
|
215
|
+
attention: SyncAttentionController,
|
|
182
216
|
) {
|
|
183
217
|
if (!rawArgs.trim()) {
|
|
184
218
|
try {
|
|
@@ -196,17 +230,173 @@ async function handleCommand(
|
|
|
196
230
|
target,
|
|
197
231
|
),
|
|
198
232
|
sessionSignal,
|
|
233
|
+
{
|
|
234
|
+
getAttention: () => attention.current(),
|
|
235
|
+
onSelectionResolved: (expected) => {
|
|
236
|
+
if (attention.current() === expected) attention.clear(ctx);
|
|
237
|
+
},
|
|
238
|
+
},
|
|
199
239
|
);
|
|
200
240
|
} catch (error) {
|
|
201
241
|
if (sessionSignal.aborted) return;
|
|
202
242
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
203
243
|
ctx.ui.notify(errorMessage(error), "error");
|
|
204
244
|
}
|
|
245
|
+
if (!sessionSignal.aborted) attention.publish(ctx);
|
|
205
246
|
return;
|
|
206
247
|
}
|
|
207
248
|
const result = await executeCommand(rawArgs, ctx, sessionSignal, loaders);
|
|
208
249
|
if (result.kind === "decision-required") {
|
|
209
250
|
ctx.ui.notify(result.decision.directMessage, "error");
|
|
251
|
+
} else if (result.kind === "remote-selection-required") {
|
|
252
|
+
const origin = directSelectionOrigin(rawArgs);
|
|
253
|
+
if (origin) attention.set(result.decision, origin);
|
|
254
|
+
const deterministic = splitArgs(rawArgs).some((arg) => arg === "--yes" || arg === "-y");
|
|
255
|
+
if (origin && ctx.mode === "tui" && !deterministic) {
|
|
256
|
+
await resolveSelectionAttention(ctx, attention, sessionSignal, loaders);
|
|
257
|
+
} else {
|
|
258
|
+
ctx.ui.notify(
|
|
259
|
+
formatRemoteSelectionMismatch(
|
|
260
|
+
result.decision.setupName,
|
|
261
|
+
result.decision.localInclude,
|
|
262
|
+
result.decision.remoteInclude,
|
|
263
|
+
),
|
|
264
|
+
"error",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await clearAttentionAfterCompletedOperation(rawArgs, result, ctx, attention, sessionSignal);
|
|
269
|
+
await reconcileSelectionAttention(ctx, attention, sessionSignal);
|
|
270
|
+
if (!sessionSignal.aborted) attention.publish(ctx);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function clearAttentionAfterCompletedOperation(
|
|
274
|
+
rawArgs: string,
|
|
275
|
+
result: RunRouteResult,
|
|
276
|
+
ctx: ExtensionContext,
|
|
277
|
+
attention: SyncAttentionController,
|
|
278
|
+
signal: AbortSignal,
|
|
279
|
+
) {
|
|
280
|
+
if (result.kind !== "completed" || result.outcome === "cancelled" || signal.aborted) return;
|
|
281
|
+
const [command, ...rest] = splitArgs(rawArgs);
|
|
282
|
+
if (command !== "sync" && command !== "pull" && command !== "push") return;
|
|
283
|
+
const current = attention.current();
|
|
284
|
+
if (!current) return;
|
|
285
|
+
try {
|
|
286
|
+
const options = parseOptions(rest);
|
|
287
|
+
const setupName = options.setup ?? (await loadConfig()).setupName;
|
|
288
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
289
|
+
if (current.decision.setupName === setupName) attention.clear(ctx);
|
|
290
|
+
} catch {
|
|
291
|
+
// Attention reconciliation below owns malformed or concurrently changed settings.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function reconcileSelectionAttention(
|
|
296
|
+
ctx: ExtensionContext,
|
|
297
|
+
attention: SyncAttentionController,
|
|
298
|
+
signal: AbortSignal,
|
|
299
|
+
) {
|
|
300
|
+
const current = attention.current();
|
|
301
|
+
if (!current || signal.aborted) return;
|
|
302
|
+
try {
|
|
303
|
+
const config = await loadConfig(current.decision.setupName);
|
|
304
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
305
|
+
if (!syncAttentionMatchesConfig(current, config)) attention.clear(ctx);
|
|
306
|
+
} catch {
|
|
307
|
+
if (!signal.aborted && attention.current() === current) attention.clear(ctx);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function directSelectionOrigin(rawArgs: string): SyncAttentionOrigin | undefined {
|
|
312
|
+
const command = splitArgs(rawArgs)[0];
|
|
313
|
+
return command === "sync" || command === "pull" || command === "push" ? command : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function resolveSelectionAttention(
|
|
317
|
+
ctx: ExtensionContext,
|
|
318
|
+
attention: SyncAttentionController,
|
|
319
|
+
signal: AbortSignal,
|
|
320
|
+
loaders: SyncLoaders,
|
|
321
|
+
options: {
|
|
322
|
+
cancelLabel?: string;
|
|
323
|
+
withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
324
|
+
} = {},
|
|
325
|
+
) {
|
|
326
|
+
const current = attention.current();
|
|
327
|
+
if (!current || signal.aborted) return;
|
|
328
|
+
const { dispatchManagerResult } = await import("./manager-result-dispatcher.js");
|
|
329
|
+
if (signal.aborted || attention.current() !== current) return;
|
|
330
|
+
await dispatchManagerResult(
|
|
331
|
+
ctx,
|
|
332
|
+
{ kind: "remote-selection-required", decision: current.decision },
|
|
333
|
+
current.origin,
|
|
334
|
+
(route, actionSignal, onCommit, target) => {
|
|
335
|
+
const execute = () =>
|
|
336
|
+
executeRecoveryCommand(
|
|
337
|
+
route,
|
|
338
|
+
ctx,
|
|
339
|
+
combineSignals(signal, actionSignal),
|
|
340
|
+
loaders,
|
|
341
|
+
onCommit,
|
|
342
|
+
target,
|
|
343
|
+
);
|
|
344
|
+
return options.withStateAccess ? options.withStateAccess(execute) : execute();
|
|
345
|
+
},
|
|
346
|
+
signal,
|
|
347
|
+
{
|
|
348
|
+
cancelLabel: options.cancelLabel,
|
|
349
|
+
withStateAccess: options.withStateAccess,
|
|
350
|
+
onSelectionResolved: () => {
|
|
351
|
+
if (attention.current() === current) attention.clear(ctx);
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function executeRecoveryCommand(
|
|
358
|
+
rawArgs: string,
|
|
359
|
+
ctx: ExtensionContext,
|
|
360
|
+
signal: AbortSignal | undefined,
|
|
361
|
+
loaders: SyncLoaders,
|
|
362
|
+
onCommit?: () => void,
|
|
363
|
+
setup?: string,
|
|
364
|
+
): Promise<RunRouteResult> {
|
|
365
|
+
try {
|
|
366
|
+
const [subcommand, ...rest] = splitArgs(rawArgs);
|
|
367
|
+
if (subcommand !== "sync" && subcommand !== "pull" && subcommand !== "push") {
|
|
368
|
+
throw new Error(`Unsupported sync recovery route: ${subcommand ?? "missing"}`);
|
|
369
|
+
}
|
|
370
|
+
const options = parseOptions(rest);
|
|
371
|
+
if (setup !== undefined) options.setup = setup;
|
|
372
|
+
if (signal) options.signal = signal;
|
|
373
|
+
if (onCommit) options.onCommit = onCommit;
|
|
374
|
+
options.reload = false;
|
|
375
|
+
options.auto = false;
|
|
376
|
+
validateCommandOptions(subcommand, options);
|
|
377
|
+
const operations = await loaders.operations();
|
|
378
|
+
throwIfAborted(options.signal);
|
|
379
|
+
if (subcommand === "push") {
|
|
380
|
+
const outcome = await withLock("push", () => operations.push(ctx, options));
|
|
381
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
382
|
+
}
|
|
383
|
+
if (subcommand === "pull") {
|
|
384
|
+
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
385
|
+
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
386
|
+
}
|
|
387
|
+
await withLock("sync", () => operations.syncBoth(ctx, options));
|
|
388
|
+
return { kind: "completed" };
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (signal?.aborted) return { kind: "failed" };
|
|
391
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
392
|
+
if (error instanceof RemoteSelectionMismatchError) {
|
|
393
|
+
return { kind: "remote-selection-required", decision: error.decision };
|
|
394
|
+
}
|
|
395
|
+
if (isSyncDecisionRequiredError(error)) {
|
|
396
|
+
return { kind: "decision-required", decision: error.decision };
|
|
397
|
+
}
|
|
398
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
399
|
+
return { kind: "failed" };
|
|
210
400
|
}
|
|
211
401
|
}
|
|
212
402
|
|
|
@@ -324,6 +514,9 @@ async function executeCommand(
|
|
|
324
514
|
if (signal?.aborted) return { kind: "failed" };
|
|
325
515
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
326
516
|
if (error instanceof SetupPullRequiresUiError) throw error;
|
|
517
|
+
if (error instanceof RemoteSelectionMismatchError) {
|
|
518
|
+
return { kind: "remote-selection-required", decision: error.decision };
|
|
519
|
+
}
|
|
327
520
|
if (isSyncDecisionRequiredError(error)) {
|
|
328
521
|
return { kind: "decision-required", decision: error.decision };
|
|
329
522
|
}
|
|
@@ -359,7 +552,11 @@ async function migrateStateDirectory(ctx: ExtensionCommandContext, options: Comm
|
|
|
359
552
|
ctx.ui.notify(result.message, result.status === "migrated" ? "info" : "warning");
|
|
360
553
|
}
|
|
361
554
|
|
|
362
|
-
async function autoSync(
|
|
555
|
+
async function autoSync(
|
|
556
|
+
ctx: ExtensionContext,
|
|
557
|
+
signal: AbortSignal,
|
|
558
|
+
loaders: SyncLoaders,
|
|
559
|
+
): Promise<RemoteSelectionDecision | undefined> {
|
|
363
560
|
try {
|
|
364
561
|
const partial = await loadPartialConfig();
|
|
365
562
|
throwIfAborted(signal);
|
|
@@ -377,6 +574,7 @@ async function autoSync(ctx: ExtensionContext, signal: AbortSignal, loaders: Syn
|
|
|
377
574
|
} catch (error) {
|
|
378
575
|
if (signal.aborted || isMissingConfigError(error)) return;
|
|
379
576
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
577
|
+
if (error instanceof RemoteSelectionMismatchError) return error.decision;
|
|
380
578
|
ctx.ui.notify(`pi-sync auto sync skipped: ${errorMessage(error)}`, "warning");
|
|
381
579
|
}
|
|
382
580
|
}
|
package/src/sync-operations.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readStateForConfig,
|
|
8
8
|
sessionDirForApply,
|
|
9
9
|
stateDir,
|
|
10
|
+
syncConfigReviewFingerprint,
|
|
10
11
|
syncSessionsWarnings,
|
|
11
12
|
writeStateForConfig,
|
|
12
13
|
} from "./config.js";
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
publicationCapabilityDescription,
|
|
47
48
|
safeTerminalText,
|
|
48
49
|
} from "./sync-format.js";
|
|
49
|
-
import { inspectRemoteSelection,
|
|
50
|
+
import { inspectRemoteSelection, remoteSelectionMismatch } from "./sync-policy.js";
|
|
50
51
|
import {
|
|
51
52
|
canPullRemoteSessionsOnFirstSync,
|
|
52
53
|
canPullRemoteSettingsOnFirstSync,
|
|
@@ -923,7 +924,8 @@ async function readRemoteSnapshot(
|
|
|
923
924
|
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
924
925
|
const selectionState = inspectRemoteSelection(config.include, snapshot);
|
|
925
926
|
if (!options.allowSelectionDifference && selectionState.kind === "different") {
|
|
926
|
-
|
|
927
|
+
const configIdentity = syncConfigReviewFingerprint(config);
|
|
928
|
+
throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
|
|
927
929
|
}
|
|
928
930
|
return {
|
|
929
931
|
head,
|
package/src/sync-policy.ts
CHANGED
|
@@ -206,22 +206,81 @@ export function inspectRemoteSelection(
|
|
|
206
206
|
: { kind: "different", include: remoteInclude, ...comparison };
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
export interface RemoteSelectionDecision {
|
|
210
|
+
setupName: string;
|
|
211
|
+
configIdentity: string;
|
|
212
|
+
localInclude: readonly string[];
|
|
213
|
+
remoteInclude: readonly string[];
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
export class RemoteSelectionMismatchError extends Error {
|
|
217
|
+
readonly decision: RemoteSelectionDecision;
|
|
218
|
+
readonly setupName: string;
|
|
210
219
|
readonly localInclude: string[];
|
|
211
220
|
readonly remoteInclude: string[];
|
|
212
221
|
|
|
213
|
-
constructor(
|
|
222
|
+
constructor(
|
|
223
|
+
setupName: string,
|
|
224
|
+
localInclude: unknown,
|
|
225
|
+
remoteInclude: unknown,
|
|
226
|
+
configIdentity = JSON.stringify([setupName, normalizeSyncInclude(localInclude)]),
|
|
227
|
+
) {
|
|
214
228
|
const local = normalizeSyncInclude(localInclude);
|
|
215
229
|
const remote = normalizeSyncInclude(remoteInclude);
|
|
216
|
-
super(
|
|
217
|
-
"Remote included content differs from this sync setup. Review Remote included content in /sync Settings, or use a reviewed force push to keep local selection.",
|
|
218
|
-
);
|
|
230
|
+
super(formatRemoteSelectionMismatch(setupName, local, remote));
|
|
219
231
|
this.name = "RemoteSelectionMismatchError";
|
|
232
|
+
this.setupName = setupName;
|
|
220
233
|
this.localInclude = local;
|
|
221
234
|
this.remoteInclude = remote;
|
|
235
|
+
this.decision = {
|
|
236
|
+
setupName,
|
|
237
|
+
configIdentity,
|
|
238
|
+
localInclude: [...local],
|
|
239
|
+
remoteInclude: [...remote],
|
|
240
|
+
};
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
|
|
244
|
+
export function remoteSelectionMismatch(
|
|
245
|
+
config: { setupName: string; include: unknown },
|
|
246
|
+
remoteInclude: unknown,
|
|
247
|
+
configIdentity?: string,
|
|
248
|
+
) {
|
|
249
|
+
return new RemoteSelectionMismatchError(
|
|
250
|
+
config.setupName,
|
|
251
|
+
config.include,
|
|
252
|
+
remoteInclude,
|
|
253
|
+
configIdentity,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function formatRemoteSelectionMismatch(
|
|
258
|
+
setupName: string,
|
|
259
|
+
localInclude: readonly string[],
|
|
260
|
+
remoteInclude: readonly string[],
|
|
261
|
+
) {
|
|
262
|
+
const comparison = compareSyncInclude(localInclude, remoteInclude);
|
|
263
|
+
const lines = [
|
|
264
|
+
`Synced content differs for sync setup “${stripTerminalControls(setupName)}”.`,
|
|
265
|
+
`Remote-only: ${comparison.remoteOnly.join(", ") || "none"}`,
|
|
266
|
+
`This-device-only: ${comparison.localOnly.join(", ") || "none"}`,
|
|
267
|
+
];
|
|
268
|
+
if (comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0) {
|
|
269
|
+
lines.push(
|
|
270
|
+
"Only ordering differs.",
|
|
271
|
+
`Remote order: ${remoteInclude.join(", ") || "none"}`,
|
|
272
|
+
`This device order: ${localInclude.join(", ") || "none"}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
lines.push("Run /sync in TUI to review both content lists and choose what happens next.");
|
|
276
|
+
return lines.join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stripTerminalControls(value: string) {
|
|
280
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
|
|
281
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
|
|
282
|
+
}
|
|
283
|
+
|
|
225
284
|
export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
|
|
226
285
|
const builtIns = new Set<BuiltInSyncFile>();
|
|
227
286
|
const custom = new Set<string>();
|
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type RunRoute,
|
|
5
|
+
type RunRouteResult,
|
|
6
|
+
runCancellableOperation,
|
|
7
|
+
} from "./cancellable-operation.js";
|
|
4
8
|
import { loadConfig, syncConfigReviewIdentity } from "./config.js";
|
|
5
9
|
import type { SyncDecision, SyncResolutionDirection } from "./sync-decision.js";
|
|
6
10
|
import { errorMessage, safeTerminalText } from "./sync-format.js";
|
|
7
11
|
|
|
8
12
|
export type SyncResolutionResult =
|
|
9
13
|
| { kind: "resolved"; direction: SyncResolutionDirection }
|
|
14
|
+
| {
|
|
15
|
+
kind: "route-result";
|
|
16
|
+
result: RunRouteResult;
|
|
17
|
+
route: SyncResolutionDirection;
|
|
18
|
+
}
|
|
10
19
|
| { kind: "back" }
|
|
11
20
|
| { kind: "closed" }
|
|
12
21
|
| { kind: "stale" };
|
|
13
22
|
|
|
14
23
|
export async function showSyncResolution(
|
|
15
|
-
ctx:
|
|
24
|
+
ctx: ExtensionContext,
|
|
16
25
|
initialDecision: SyncDecision,
|
|
17
26
|
runRoute: RunRoute,
|
|
18
27
|
sessionSignal?: AbortSignal,
|
|
@@ -21,7 +30,8 @@ export async function showSyncResolution(
|
|
|
21
30
|
type Action = "push" | "pull" | "back";
|
|
22
31
|
let currentDecision = initialDecision;
|
|
23
32
|
let resolvedDirection: SyncResolutionDirection | undefined;
|
|
24
|
-
|
|
33
|
+
let chainedResult: { result: RunRouteResult; route: SyncResolutionDirection } | undefined;
|
|
34
|
+
const menu = defineMenu<SyncDecision, Screen, Action, ExtensionContext>({
|
|
25
35
|
start: "resolve",
|
|
26
36
|
screens: {
|
|
27
37
|
resolve: ({ state }) => ({
|
|
@@ -104,6 +114,10 @@ export async function showSyncResolution(
|
|
|
104
114
|
currentDecision = result.decision;
|
|
105
115
|
return { kind: "stay" as const };
|
|
106
116
|
}
|
|
117
|
+
if (result.kind === "remote-selection-required") {
|
|
118
|
+
chainedResult = { result, route: direction };
|
|
119
|
+
return { kind: "close" as const };
|
|
120
|
+
}
|
|
107
121
|
if (result.kind === "completed") {
|
|
108
122
|
if (result.outcome === "cancelled") return { kind: "stay" as const };
|
|
109
123
|
resolvedDirection = direction;
|
|
@@ -119,6 +133,7 @@ export async function showSyncResolution(
|
|
|
119
133
|
onError: (_menuCtx, error) => ctx.ui.notify(errorMessage(error), "error"),
|
|
120
134
|
});
|
|
121
135
|
if (sessionSignal?.aborted || result.kind === "stale") return { kind: "stale" };
|
|
136
|
+
if (chainedResult) return { kind: "route-result", ...chainedResult };
|
|
122
137
|
if (resolvedDirection) return { kind: "resolved", direction: resolvedDirection };
|
|
123
138
|
if (result.kind === "closed") {
|
|
124
139
|
return result.reason === "back" ? { kind: "back" } : { kind: "closed" };
|