@narumitw/pi-sync 0.49.3 → 0.49.5
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 -8
- package/package.json +7 -4
- package/src/command.ts +6 -1
- package/src/config.ts +2 -3
- package/src/file-selection.ts +51 -1
- package/src/git-backend.ts +5 -2
- package/src/git-storage.ts +17 -1
- package/src/lock-policy.ts +2 -0
- package/src/lock.ts +5 -43
- package/src/lockfile-fs.ts +25 -0
- package/src/paths.ts +2 -0
- package/src/remote-selection-ui.ts +279 -0
- package/src/remote-snapshot.ts +59 -0
- package/src/s3-backend.ts +4 -0
- package/src/settings-ui.ts +16 -1
- package/src/snapshot-codec.ts +3 -0
- package/src/snapshot.ts +33 -2
- package/src/state-directory.ts +184 -0
- package/src/sync-backend.ts +3 -1
- package/src/sync-format.ts +6 -0
- package/src/sync-operations.ts +44 -10
- package/src/sync-policy.ts +183 -7
- package/src/sync.ts +81 -17
- package/src/types.ts +9 -0
- package/src/webdav-backend.ts +4 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { createSyncBackend, type SyncBackendFactory } from "./backend-factory.js";
|
|
4
|
+
import { loadConfig, loadPartialConfig } from "./config.js";
|
|
5
|
+
import { readSnapshotForHead } from "./remote-snapshot.js";
|
|
6
|
+
import { updateSyncSetup } from "./settings-management.js";
|
|
7
|
+
import type { RemoteHead, SyncBackend } from "./sync-backend.js";
|
|
8
|
+
import { errorMessage, safeTerminalText } from "./sync-format.js";
|
|
9
|
+
import {
|
|
10
|
+
inspectRemoteSelection,
|
|
11
|
+
type RemoteSelectionState,
|
|
12
|
+
sameSyncInclude,
|
|
13
|
+
} from "./sync-policy.js";
|
|
14
|
+
import type { AnySyncConfig } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const STATUS_KEY = "sync";
|
|
17
|
+
|
|
18
|
+
export async function showRemoteSelectionReview(
|
|
19
|
+
ctx: ExtensionCommandContext,
|
|
20
|
+
setupName?: string,
|
|
21
|
+
signal?: AbortSignal,
|
|
22
|
+
factory: SyncBackendFactory = createSyncBackend,
|
|
23
|
+
) {
|
|
24
|
+
try {
|
|
25
|
+
const config = await loadConfig(setupName);
|
|
26
|
+
if (signal?.aborted) return;
|
|
27
|
+
const storageReview = await loadPartialConfig(config.setupName);
|
|
28
|
+
if (signal?.aborted) return;
|
|
29
|
+
ctx.ui.setStatus(STATUS_KEY, `checking remote selection for ${config.setupName}`);
|
|
30
|
+
const backend = await factory(config);
|
|
31
|
+
if (signal?.aborted) return;
|
|
32
|
+
const head = await backend.readHead(signal);
|
|
33
|
+
if (signal?.aborted) return;
|
|
34
|
+
if (!head) {
|
|
35
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
36
|
+
ctx.ui.notify("Remote storage has no snapshot or included-content policy yet.", "info");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
40
|
+
if (signal?.aborted) return;
|
|
41
|
+
const state = inspectRemoteSelection(config.include, snapshot);
|
|
42
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
43
|
+
|
|
44
|
+
if (ctx.mode !== "tui") {
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
formatRemoteSelectionSummary(config, state),
|
|
47
|
+
state.kind === "different" ? "warning" : "info",
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (state.kind === "same") {
|
|
52
|
+
ctx.ui.notify("Remote included content already matches this sync setup.", "info");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (state.kind === "legacy") {
|
|
56
|
+
await showLegacyDiscovery(ctx, config, state.discovered, signal);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await showSelectionDifference(ctx, config, storageReview, backend, head, state, signal);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (signal?.aborted) return;
|
|
62
|
+
ctx.ui.notify(`Could not review remote included content: ${errorMessage(error)}`, "error");
|
|
63
|
+
} finally {
|
|
64
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function showSelectionDifference(
|
|
69
|
+
ctx: ExtensionCommandContext,
|
|
70
|
+
config: AnySyncConfig,
|
|
71
|
+
storageReview: Awaited<ReturnType<typeof loadPartialConfig>>,
|
|
72
|
+
backend: SyncBackend,
|
|
73
|
+
reviewedHead: RemoteHead,
|
|
74
|
+
state: Extract<RemoteSelectionState, { kind: "different" }>,
|
|
75
|
+
sessionSignal?: AbortSignal,
|
|
76
|
+
) {
|
|
77
|
+
type Screen = "choice" | "review";
|
|
78
|
+
type Action = "adopt" | "keep" | "cancel";
|
|
79
|
+
const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
|
|
80
|
+
start: "choice",
|
|
81
|
+
screens: {
|
|
82
|
+
choice: () => ({
|
|
83
|
+
kind: "actions",
|
|
84
|
+
title: `Remote included content · ${safeTerminalText(config.setupName)}`,
|
|
85
|
+
lines: [
|
|
86
|
+
"The remote selection differs from this local sync setup.",
|
|
87
|
+
"No settings or files have been changed.",
|
|
88
|
+
],
|
|
89
|
+
items: [
|
|
90
|
+
{
|
|
91
|
+
id: "adopt",
|
|
92
|
+
label: "Adopt remote included content",
|
|
93
|
+
description: "Save the reviewed remote paths locally without pulling files.",
|
|
94
|
+
action: "adopt",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "keep",
|
|
98
|
+
label: "Keep local included content",
|
|
99
|
+
description: "Make no settings change; a reviewed force push can publish it later.",
|
|
100
|
+
action: "keep",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "review",
|
|
104
|
+
label: "Review paths",
|
|
105
|
+
description: "Compare exact local-only and remote-only selection paths.",
|
|
106
|
+
to: "review",
|
|
107
|
+
},
|
|
108
|
+
{ id: "cancel", label: "Cancel", action: "cancel" },
|
|
109
|
+
],
|
|
110
|
+
hint: "close",
|
|
111
|
+
}),
|
|
112
|
+
review: () => ({
|
|
113
|
+
kind: "review",
|
|
114
|
+
title: `Review remote included content · ${safeTerminalText(config.setupName)}`,
|
|
115
|
+
content: formatSelectionDifference(config.include, state),
|
|
116
|
+
format: { kind: "text" },
|
|
117
|
+
viewportSize: "adaptive",
|
|
118
|
+
hint: "back",
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
actions: {
|
|
122
|
+
adopt: async ({ signal: actionSignal }) => {
|
|
123
|
+
const signal = sessionSignal
|
|
124
|
+
? AbortSignal.any([sessionSignal, actionSignal])
|
|
125
|
+
: actionSignal;
|
|
126
|
+
try {
|
|
127
|
+
if (!config.include.includes("sessions") && state.include.includes("sessions")) {
|
|
128
|
+
const acknowledged = await ctx.ui.confirm(
|
|
129
|
+
"Adopt session conversations?",
|
|
130
|
+
"Session JSONL may contain prompts, tool output, file paths, images, and secrets. This saves the selection only; it does not pull files.",
|
|
131
|
+
{ signal },
|
|
132
|
+
);
|
|
133
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
134
|
+
if (!acknowledged) return { kind: "stay" as const };
|
|
135
|
+
}
|
|
136
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
137
|
+
const currentHead = await backend.readHead(signal);
|
|
138
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
139
|
+
if (!currentHead || !backend.sameRevision(reviewedHead.revision, currentHead.revision)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"Remote changed while the included-content review was open; reopen it.",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const currentSnapshot = await readSnapshotForHead(backend, currentHead, signal);
|
|
145
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
146
|
+
const currentState = inspectRemoteSelection(config.include, currentSnapshot);
|
|
147
|
+
if (
|
|
148
|
+
currentState.kind === "legacy" ||
|
|
149
|
+
!sameSyncInclude(currentState.include, state.include)
|
|
150
|
+
) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"Remote included content changed while the review was open; reopen it.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
await updateSyncSetup(
|
|
156
|
+
config.setupName,
|
|
157
|
+
(setup) => ({
|
|
158
|
+
...setup,
|
|
159
|
+
sync: { ...setup.sync, include: [...state.include] },
|
|
160
|
+
}),
|
|
161
|
+
{
|
|
162
|
+
expectedStorage: storageReview,
|
|
163
|
+
expectedInclude: config.include,
|
|
164
|
+
signal,
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
168
|
+
ctx.ui.notify(
|
|
169
|
+
`Saved remote included content for sync setup “${safeTerminalText(config.setupName)}”. No files were pulled; review Sync now separately.`,
|
|
170
|
+
"info",
|
|
171
|
+
);
|
|
172
|
+
return { kind: "close" as const };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (signal.aborted) return { kind: "close" as const };
|
|
175
|
+
ctx.ui.notify(`Could not adopt remote included content: ${errorMessage(error)}`, "error");
|
|
176
|
+
return { kind: "close" as const };
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
keep: async () => {
|
|
180
|
+
ctx.ui.notify(
|
|
181
|
+
"Kept local included content. No settings or files changed; use a reviewed force push to publish the local selection.",
|
|
182
|
+
"info",
|
|
183
|
+
);
|
|
184
|
+
return { kind: "close" };
|
|
185
|
+
},
|
|
186
|
+
cancel: async () => ({ kind: "close" }),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
await runMenu(ctx, menu, {
|
|
190
|
+
getState: () => undefined,
|
|
191
|
+
signal: sessionSignal,
|
|
192
|
+
isCurrent: () => !sessionSignal?.aborted,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function showLegacyDiscovery(
|
|
197
|
+
ctx: ExtensionCommandContext,
|
|
198
|
+
config: AnySyncConfig,
|
|
199
|
+
discovered: string[],
|
|
200
|
+
signal?: AbortSignal,
|
|
201
|
+
) {
|
|
202
|
+
const menu = defineMenu<undefined, "choice" | "review", "back", ExtensionCommandContext>({
|
|
203
|
+
start: "choice",
|
|
204
|
+
screens: {
|
|
205
|
+
choice: () => ({
|
|
206
|
+
kind: "actions",
|
|
207
|
+
title: `Remote included content · ${safeTerminalText(config.setupName)}`,
|
|
208
|
+
lines: [
|
|
209
|
+
"This legacy snapshot has no portable included-content policy.",
|
|
210
|
+
"Discovered paths are partial and read-only; preserved files may not have been selected.",
|
|
211
|
+
],
|
|
212
|
+
items: [
|
|
213
|
+
{ id: "review", label: "Review discovered paths", to: "review" },
|
|
214
|
+
{ id: "back", label: "Back", action: "back" },
|
|
215
|
+
],
|
|
216
|
+
hint: "close",
|
|
217
|
+
}),
|
|
218
|
+
review: () => ({
|
|
219
|
+
kind: "review",
|
|
220
|
+
title: "Partial discovery from legacy snapshot",
|
|
221
|
+
content: [
|
|
222
|
+
"Partial discovery only — not an authoritative selection.",
|
|
223
|
+
"",
|
|
224
|
+
...(discovered.length > 0
|
|
225
|
+
? discovered.map((item) => `Discovered: ${safeTerminalText(item)}`)
|
|
226
|
+
: ["No safe paths were discovered."]),
|
|
227
|
+
"",
|
|
228
|
+
"Use Add custom path… in the local Included Content editor if needed.",
|
|
229
|
+
].join("\n"),
|
|
230
|
+
format: { kind: "text" },
|
|
231
|
+
viewportSize: "adaptive",
|
|
232
|
+
hint: "back",
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
actions: { back: async () => ({ kind: "close" }) },
|
|
236
|
+
});
|
|
237
|
+
await runMenu(ctx, menu, {
|
|
238
|
+
getState: () => undefined,
|
|
239
|
+
signal,
|
|
240
|
+
isCurrent: () => !signal?.aborted,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatSelectionDifference(
|
|
245
|
+
localInclude: readonly string[],
|
|
246
|
+
state: Extract<RemoteSelectionState, { kind: "different" }>,
|
|
247
|
+
) {
|
|
248
|
+
return [
|
|
249
|
+
"Remote-only selection:",
|
|
250
|
+
...(state.remoteOnly.length > 0
|
|
251
|
+
? state.remoteOnly.map((item) => `+ ${safeTerminalText(item)}`)
|
|
252
|
+
: ["(none)"]),
|
|
253
|
+
"",
|
|
254
|
+
"Local-only selection:",
|
|
255
|
+
...(state.localOnly.length > 0
|
|
256
|
+
? state.localOnly.map((item) => `- ${safeTerminalText(item)}`)
|
|
257
|
+
: ["(none)"]),
|
|
258
|
+
"",
|
|
259
|
+
`Remote order: ${state.include.map(safeTerminalText).join(", ") || "none"}`,
|
|
260
|
+
`Local order: ${localInclude.map(safeTerminalText).join(", ") || "none"}`,
|
|
261
|
+
"",
|
|
262
|
+
"Adopting saves settings only and does not pull files.",
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function formatRemoteSelectionSummary(config: AnySyncConfig, state: RemoteSelectionState) {
|
|
267
|
+
if (state.kind === "same") {
|
|
268
|
+
return `Remote included content for “${safeTerminalText(config.setupName)}” matches local settings.`;
|
|
269
|
+
}
|
|
270
|
+
if (state.kind === "legacy") {
|
|
271
|
+
return `Remote snapshot for “${safeTerminalText(config.setupName)}” has no portable included-content policy; ${state.discovered.length} safe path${state.discovered.length === 1 ? " was" : "s were"} discovered, but the result is partial and read-only.`;
|
|
272
|
+
}
|
|
273
|
+
return [
|
|
274
|
+
`Remote included content for “${safeTerminalText(config.setupName)}” differs from local settings.`,
|
|
275
|
+
`Remote-only: ${state.remoteOnly.map(safeTerminalText).join(", ") || "none"}`,
|
|
276
|
+
`Local-only: ${state.localOnly.map(safeTerminalText).join(", ") || "none"}`,
|
|
277
|
+
"Use TUI Settings to review or adopt it; RPC is read-only.",
|
|
278
|
+
].join("\n");
|
|
279
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RemoteHead, SyncBackend } from "./sync-backend.js";
|
|
2
|
+
import { safeTerminalText } from "./sync-format.js";
|
|
3
|
+
import {
|
|
4
|
+
inspectRemoteSelection,
|
|
5
|
+
RemoteSelectionMismatchError,
|
|
6
|
+
type RemoteSelectionState,
|
|
7
|
+
sameSyncInclude,
|
|
8
|
+
snapshotSelectionInclude,
|
|
9
|
+
} from "./sync-policy.js";
|
|
10
|
+
import type { AnySyncConfig, Snapshot } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export async function readSnapshotForHead(
|
|
13
|
+
backend: SyncBackend,
|
|
14
|
+
head: RemoteHead,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
) {
|
|
17
|
+
const snapshot = await backend.readSnapshot(head.snapshotRef, signal);
|
|
18
|
+
if (signal?.aborted) {
|
|
19
|
+
throw signal.reason instanceof Error
|
|
20
|
+
? signal.reason
|
|
21
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
22
|
+
}
|
|
23
|
+
if (snapshot.id !== head.snapshotId) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (head.selection) {
|
|
29
|
+
const snapshotInclude = snapshotSelectionInclude(snapshot);
|
|
30
|
+
if (!snapshotInclude || !sameSyncInclude(head.selection.include, snapshotInclude)) {
|
|
31
|
+
throw new Error("Remote head selection does not match its immutable snapshot.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return snapshot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
|
|
38
|
+
const state = inspectRemoteSelection(config.include, snapshot);
|
|
39
|
+
if (state.kind === "different") {
|
|
40
|
+
throw new RemoteSelectionMismatchError(config.include, state.include);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatRemoteSelectionStatus(state: RemoteSelectionState | undefined) {
|
|
45
|
+
if (!state) return "remote included content: unavailable (remote is empty)";
|
|
46
|
+
if (state.kind === "same") return "remote included content: matches this setup";
|
|
47
|
+
if (state.kind === "legacy") {
|
|
48
|
+
return `remote included content: unavailable in legacy snapshot (${state.discovered.length} path${state.discovered.length === 1 ? "" : "s"} discovered, partial)`;
|
|
49
|
+
}
|
|
50
|
+
return [
|
|
51
|
+
"remote included content: differs from this setup",
|
|
52
|
+
`remote-only selection: ${safeList(state.remoteOnly)}`,
|
|
53
|
+
`local-only selection: ${safeList(state.localOnly)}`,
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeList(values: readonly string[]) {
|
|
58
|
+
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
59
|
+
}
|
package/src/s3-backend.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
SyncBackendConflictError,
|
|
15
15
|
SyncBackendPublicationOutcomeUnknownError,
|
|
16
16
|
} from "./sync-backend.js";
|
|
17
|
+
import { portableSnapshotSelection } from "./sync-policy.js";
|
|
17
18
|
import type { LatestPointer, RemoteObject, ResolvedS3Backend, Snapshot } from "./types.js";
|
|
18
19
|
|
|
19
20
|
const VERSION = 1;
|
|
@@ -274,6 +275,7 @@ export function pointerFor(
|
|
|
274
275
|
syncSessions:
|
|
275
276
|
snapshot.syncSessions === true ||
|
|
276
277
|
snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
278
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
277
279
|
};
|
|
278
280
|
}
|
|
279
281
|
|
|
@@ -330,6 +332,7 @@ function remoteHead(pointer: LatestPointer, identity: string, etag?: string): Re
|
|
|
330
332
|
createdAt: pointer.createdAt,
|
|
331
333
|
machine: pointer.machine,
|
|
332
334
|
syncSessions: pointer.syncSessions === true,
|
|
335
|
+
...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
|
|
333
336
|
};
|
|
334
337
|
}
|
|
335
338
|
|
|
@@ -377,6 +380,7 @@ function requirePointer(
|
|
|
377
380
|
) {
|
|
378
381
|
throw new Error(message);
|
|
379
382
|
}
|
|
383
|
+
if (value.selection !== undefined) portableSnapshotSelection(value.selection);
|
|
380
384
|
return value;
|
|
381
385
|
}
|
|
382
386
|
|
package/src/settings-ui.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function showSyncSettings(
|
|
|
25
25
|
const initial = await loadConfig();
|
|
26
26
|
if (signal?.aborted) return;
|
|
27
27
|
const setupName = initial.setupName;
|
|
28
|
-
type Action = "automatic" | "on-switch" | "include";
|
|
28
|
+
type Action = "automatic" | "on-switch" | "include" | "remote-include";
|
|
29
29
|
const menu = defineMenu<
|
|
30
30
|
Awaited<ReturnType<typeof loadConfig>>,
|
|
31
31
|
"settings",
|
|
@@ -65,6 +65,14 @@ export async function showSyncSettings(
|
|
|
65
65
|
currentValue: "Open editor",
|
|
66
66
|
action: "include",
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
id: "remoteInclude",
|
|
70
|
+
label: "Remote included content",
|
|
71
|
+
description:
|
|
72
|
+
"Compare portable remote selection, then adopt it or keep local settings without pulling files.",
|
|
73
|
+
currentValue: "Review",
|
|
74
|
+
action: "remote-include",
|
|
75
|
+
},
|
|
68
76
|
],
|
|
69
77
|
}),
|
|
70
78
|
},
|
|
@@ -114,6 +122,13 @@ export async function showSyncSettings(
|
|
|
114
122
|
await runRoute("files", editorSignal, undefined, setupName);
|
|
115
123
|
return editorSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
|
|
116
124
|
},
|
|
125
|
+
"remote-include": async ({ signal: actionSignal }) => {
|
|
126
|
+
const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
127
|
+
const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
|
|
128
|
+
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
129
|
+
await showRemoteSelectionReview(ctx, setupName, reviewSignal);
|
|
130
|
+
return reviewSignal.aborted ? { kind: "rejected" } : { kind: "stay" };
|
|
131
|
+
},
|
|
117
132
|
},
|
|
118
133
|
});
|
|
119
134
|
await runMenu(ctx, menu, {
|
package/src/snapshot-codec.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Readable, Writable } from "node:stream";
|
|
|
2
2
|
import { pipeline } from "node:stream/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { createGunzip, gzip } from "node:zlib";
|
|
5
|
+
import { snapshotSelectionInclude } from "./sync-policy.js";
|
|
5
6
|
import type { Snapshot } from "./types.js";
|
|
6
7
|
|
|
7
8
|
const VERSION = 1;
|
|
@@ -9,6 +10,7 @@ const MAX_DECOMPRESSED_SNAPSHOT_BYTES = 512 * 1024 * 1024;
|
|
|
9
10
|
const gzipAsync = promisify(gzip);
|
|
10
11
|
|
|
11
12
|
export async function encodeSnapshot(snapshot: Snapshot) {
|
|
13
|
+
snapshotSelectionInclude(snapshot);
|
|
12
14
|
return gzipAsync(Buffer.from(JSON.stringify(snapshot), "utf8"));
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -37,6 +39,7 @@ export async function decodeSnapshot(
|
|
|
37
39
|
if (parsed.version !== VERSION || !Array.isArray(parsed.files)) {
|
|
38
40
|
throw new Error("Unsupported snapshot format.");
|
|
39
41
|
}
|
|
42
|
+
snapshotSelectionInclude(parsed);
|
|
40
43
|
return parsed;
|
|
41
44
|
}
|
|
42
45
|
|
package/src/snapshot.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
normalizeExtraFiles,
|
|
19
19
|
normalizeSyncFiles,
|
|
20
20
|
type SyncSelectionConfig,
|
|
21
|
+
selectionForSnapshot,
|
|
22
|
+
snapshotSelectionInclude,
|
|
21
23
|
syncIncludeSelection,
|
|
22
24
|
} from "./sync-policy.js";
|
|
23
25
|
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
|
|
@@ -74,6 +76,8 @@ function snapshotsMatch(left: Snapshot, right: Snapshot) {
|
|
|
74
76
|
const leftHashes = new Map(left.files.map((file) => [file.path, file.sha256]));
|
|
75
77
|
const rightHashes = new Map(right.files.map((file) => [file.path, file.sha256]));
|
|
76
78
|
return (
|
|
79
|
+
left.syncSessions === right.syncSessions &&
|
|
80
|
+
sameOptionalInclude(snapshotSelectionInclude(left), snapshotSelectionInclude(right)) &&
|
|
77
81
|
leftHashes.size === rightHashes.size &&
|
|
78
82
|
[...leftHashes].every(([filePath, hash]) => rightHashes.get(filePath) === hash)
|
|
79
83
|
);
|
|
@@ -109,6 +113,7 @@ export async function createSnapshot(
|
|
|
109
113
|
machine: os.hostname(),
|
|
110
114
|
profile,
|
|
111
115
|
syncSessions,
|
|
116
|
+
selection: selectionForSnapshot(include),
|
|
112
117
|
files,
|
|
113
118
|
};
|
|
114
119
|
}
|
|
@@ -249,7 +254,11 @@ export function snapshotTarget(root: string, relativePath: string, configuredSes
|
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
export function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
252
|
-
return
|
|
257
|
+
return (
|
|
258
|
+
snapshot.syncSessions === true ||
|
|
259
|
+
snapshotSelectionInclude(snapshot)?.includes("sessions") === true ||
|
|
260
|
+
snapshot.files.some((file) => isSessionPath(file.path))
|
|
261
|
+
);
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
export function filterSnapshotForConfigPolicy(
|
|
@@ -262,6 +271,7 @@ export function filterSnapshotForConfigPolicy(
|
|
|
262
271
|
const filtered = {
|
|
263
272
|
...snapshot,
|
|
264
273
|
syncSessions: include.includes("sessions") ? snapshot.syncSessions : false,
|
|
274
|
+
selection: selectionForSnapshot(include),
|
|
265
275
|
files: canonicalizeSnapshotFilesForConfig(snapshot.files, config, includePaths),
|
|
266
276
|
};
|
|
267
277
|
if (!options.regenerateId || snapshotsMatch(snapshot, filtered)) return filtered;
|
|
@@ -319,13 +329,21 @@ function isPreferredExtraCandidate(
|
|
|
319
329
|
|
|
320
330
|
export function snapshotWithoutSessions(snapshot: Snapshot) {
|
|
321
331
|
const files = snapshot.files.filter((file) => !isSessionPath(file.path));
|
|
322
|
-
|
|
332
|
+
const include = snapshotSelectionInclude(snapshot)?.filter((item) => item !== "sessions");
|
|
333
|
+
if (
|
|
334
|
+
files.length === snapshot.files.length &&
|
|
335
|
+
snapshot.syncSessions !== true &&
|
|
336
|
+
include?.length === snapshot.selection?.include.length
|
|
337
|
+
) {
|
|
338
|
+
return snapshot;
|
|
339
|
+
}
|
|
323
340
|
return {
|
|
324
341
|
...snapshot,
|
|
325
342
|
id: snapshotId(),
|
|
326
343
|
createdAt: new Date().toISOString(),
|
|
327
344
|
machine: os.hostname(),
|
|
328
345
|
syncSessions: false,
|
|
346
|
+
...(include ? { selection: selectionForSnapshot(include) } : {}),
|
|
329
347
|
files,
|
|
330
348
|
};
|
|
331
349
|
}
|
|
@@ -387,14 +405,27 @@ export function mergeRemoteSessionFiles(local: Snapshot, remote: Snapshot) {
|
|
|
387
405
|
return isSessionFilePath(normalized) && isSafeSnapshotPath(file.path);
|
|
388
406
|
});
|
|
389
407
|
if (remoteSessions.length === 0 && !snapshotIncludesSessions(remote)) return local;
|
|
408
|
+
const localInclude = snapshotSelectionInclude(local);
|
|
390
409
|
return {
|
|
391
410
|
...local,
|
|
392
411
|
id: snapshotId(),
|
|
393
412
|
createdAt: new Date().toISOString(),
|
|
394
413
|
machine: os.hostname(),
|
|
395
414
|
syncSessions: true,
|
|
415
|
+
...(localInclude
|
|
416
|
+
? {
|
|
417
|
+
selection: selectionForSnapshot(
|
|
418
|
+
localInclude.includes("sessions") ? localInclude : [...localInclude, "sessions"],
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
: {}),
|
|
396
422
|
files: [...local.files.filter((file) => !isSessionPath(file.path)), ...remoteSessions].sort(
|
|
397
423
|
(left, right) => left.path.localeCompare(right.path),
|
|
398
424
|
),
|
|
399
425
|
};
|
|
400
426
|
}
|
|
427
|
+
|
|
428
|
+
function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
|
|
429
|
+
if (!left || !right) return left === right;
|
|
430
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
431
|
+
}
|