@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.
@@ -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
 
@@ -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, {
@@ -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 snapshot.syncSessions === true || snapshot.files.some((file) => isSessionPath(file.path));
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
- if (files.length === snapshot.files.length && snapshot.syncSessions !== true) return snapshot;
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
+ }