@narumitw/pi-sync 0.49.3 β 0.49.4
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 +27 -5
- package/package.json +7 -4
- package/src/file-selection.ts +51 -1
- package/src/git-backend.ts +3 -0
- package/src/git-storage.ts +17 -1
- 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/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/types.ts +9 -0
- package/src/webdav-backend.ts +4 -0
package/README.md
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
- Immutable snapshots, secret scanning, local locks, conflict checks, pull backups, transactional apply, and recovery journals.
|
|
12
12
|
- Exact reviewed storage paths that do not change when a local sync setup is renamed.
|
|
13
13
|
- One ordered `sync.include` list for Pi roots, safe agent-relative paths, and the privacy-sensitive `sessions` root.
|
|
14
|
+
- Portable, credential-free snapshot selection with explicit cross-environment review and adoption.
|
|
14
15
|
- Atomic private settings writes that preserve unknown fields and reject stale concurrent edits.
|
|
15
|
-
- Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe
|
|
16
|
+
- Fail-closed validation for missing references, mixed backend fields, duplicate remote locations, unsafe or oversized selection policies, malformed credentials, and credential-bearing Git URLs.
|
|
16
17
|
|
|
17
18
|
## π¦ Install
|
|
18
19
|
|
|
@@ -29,7 +30,7 @@ pi -e npm:@narumitw/pi-sync
|
|
|
29
30
|
Try a local checkout:
|
|
30
31
|
|
|
31
32
|
```bash
|
|
32
|
-
pi -e ./
|
|
33
|
+
pi -e ./packages/pi-sync
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
## π Quick start
|
|
@@ -63,7 +64,9 @@ and confirmations.
|
|
|
63
64
|
When **Sync now**, **Pull from remoteβ¦**, or **Push to remoteβ¦** finds changes that require a human
|
|
64
65
|
direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only
|
|
65
66
|
error. The flow names the current sync setup and explains whether local content, remote content, or
|
|
66
|
-
the included-content policy changed.
|
|
67
|
+
the included-content policy changed. If the active remote snapshot explicitly selects different
|
|
68
|
+
content, automatic sync and pulls pause until you adopt the remote policy in Settings or publish the
|
|
69
|
+
local policy with a reviewed force push.
|
|
67
70
|
|
|
68
71
|
Choose **Review differences (recommended)** to inspect the exact affected paths without changing
|
|
69
72
|
local files, remote data, or sync state. Then choose one reviewed direction:
|
|
@@ -193,11 +196,28 @@ Safe agent-relative custom files or directories may also be included. Absolute p
|
|
|
193
196
|
An empty array is valid. It means no useful transfer is selected: **Sync now** reports the condition and does not claim that the setup is up to date. Unselected content remains unmanaged locally and is preserved when republishing existing remote snapshots.
|
|
194
197
|
|
|
195
198
|
The Included Content editor is a standard bounded multi-select backed by one in-memory draft. Toggles
|
|
196
|
-
never write settings.
|
|
199
|
+
never write settings. **Add custom pathβ¦** accepts a safe agent-relative file or directory even when
|
|
200
|
+
it does not exist locally yet, allowing a new environment to select content that exists only in the
|
|
201
|
+
remote snapshot. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
|
|
197
202
|
**Discard changes**, and **Continue editing**; only reviewed Save publishes, while Continue preserves
|
|
198
203
|
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
199
204
|
the manual `sync.include` path.
|
|
200
205
|
|
|
206
|
+
Every new snapshot stores the normalized included-content selection separately from the files that
|
|
207
|
+
happened to exist. This preserves selected-but-missing paths without syncing `pi-sync.json`, storage
|
|
208
|
+
credentials, automatic-sync preferences, or setup names. **Settings β Remote included content** reads
|
|
209
|
+
the active snapshot and offers **Adopt remote included content**, **Keep local included content**,
|
|
210
|
+
**Review paths**, and **Cancel** when local and remote selections differ. Adoption revalidates the
|
|
211
|
+
remote head, saves local settings atomically, and does not pull files or write sync state; review
|
|
212
|
+
**Sync now** separately. A reviewed force push keeps local selection and explicitly replaces the
|
|
213
|
+
remote policy while preserving eligible unmanaged remote files.
|
|
214
|
+
|
|
215
|
+
Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather
|
|
216
|
+
than silently expanding local scope. Status reports matches and exact local-only/remote-only paths.
|
|
217
|
+
Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
|
|
218
|
+
clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
|
|
219
|
+
preserved-unmanaged intent cannot be reconstructed. Use **Add custom pathβ¦** for any needed path.
|
|
220
|
+
|
|
201
221
|
Adding `sessions` requires a privacy acknowledgement in interactive flows. Session JSONL can contain prompts, tool output, file paths, images, and secrets. Automatic apply protects the currently open session file; restart Pi or resume a pulled session to use newly synchronized conversations.
|
|
202
222
|
|
|
203
223
|
### Unsupported old settings and recovery
|
|
@@ -279,7 +299,7 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
|
|
|
279
299
|
## ποΈ Package layout
|
|
280
300
|
|
|
281
301
|
```text
|
|
282
|
-
|
|
302
|
+
packages/pi-sync/
|
|
283
303
|
βββ src/
|
|
284
304
|
β βββ index.ts
|
|
285
305
|
β βββ sync.ts
|
|
@@ -290,6 +310,8 @@ extensions/pi-sync/
|
|
|
290
310
|
β βββ storage-connections-ui.ts
|
|
291
311
|
β βββ sync-setups-ui.ts
|
|
292
312
|
β βββ file-selection.ts
|
|
313
|
+
β βββ remote-selection-ui.ts
|
|
314
|
+
β βββ remote-snapshot.ts
|
|
293
315
|
β βββ sync-operations.ts
|
|
294
316
|
β βββ sync-backend.ts
|
|
295
317
|
β βββ backend-factory.ts # lazy selected-backend loader
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.4",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"./src/index.ts"
|
|
31
31
|
]
|
|
32
32
|
},
|
|
33
|
+
"piExtension": {
|
|
34
|
+
"lifecycle": "stable"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"check": "biome check . && npm run typecheck",
|
|
35
38
|
"format": "biome check --write .",
|
|
@@ -42,8 +45,8 @@
|
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@biomejs/biome": "2.5.7",
|
|
45
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
46
|
-
"@earendil-works/pi-tui": "0.
|
|
48
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
49
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
47
50
|
"@types/proper-lockfile": "^4.1.4",
|
|
48
51
|
"typescript": "7.0.2"
|
|
49
52
|
},
|
|
@@ -54,6 +57,6 @@
|
|
|
54
57
|
"repository": {
|
|
55
58
|
"type": "git",
|
|
56
59
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
57
|
-
"directory": "
|
|
60
|
+
"directory": "packages/pi-sync"
|
|
58
61
|
}
|
|
59
62
|
}
|
package/src/file-selection.ts
CHANGED
|
@@ -6,12 +6,14 @@ import { updateSyncSetup } from "./settings-management.js";
|
|
|
6
6
|
import {
|
|
7
7
|
BUILT_IN_SYNC_ROOTS,
|
|
8
8
|
isSafeCustomIncludePath,
|
|
9
|
+
normalizeSyncInclude,
|
|
9
10
|
syncIncludeSelection,
|
|
10
11
|
} from "./sync-policy.js";
|
|
11
12
|
|
|
12
13
|
const BUILT_IN_PREFIX = "builtin:";
|
|
13
14
|
const CUSTOM_PREFIX = "custom:";
|
|
14
15
|
const SESSIONS_ID = "sessions";
|
|
16
|
+
const ADD_CUSTOM_ID = "add-custom";
|
|
15
17
|
|
|
16
18
|
interface SelectionDraft {
|
|
17
19
|
builtIns: Set<string>;
|
|
@@ -107,7 +109,7 @@ async function showDraftEditor(
|
|
|
107
109
|
customCandidates: string[],
|
|
108
110
|
signal?: AbortSignal,
|
|
109
111
|
) {
|
|
110
|
-
const menu = defineMenu<undefined, "editor", "toggle", ExtensionCommandContext>({
|
|
112
|
+
const menu = defineMenu<undefined, "editor", "toggle" | "addCustom", ExtensionCommandContext>({
|
|
111
113
|
start: "editor",
|
|
112
114
|
screens: {
|
|
113
115
|
editor: () => ({
|
|
@@ -139,6 +141,15 @@ async function showDraftEditor(
|
|
|
139
141
|
},
|
|
140
142
|
],
|
|
141
143
|
action: "toggle",
|
|
144
|
+
actions: [
|
|
145
|
+
{
|
|
146
|
+
id: ADD_CUSTOM_ID,
|
|
147
|
+
label: "Add custom pathβ¦",
|
|
148
|
+
description:
|
|
149
|
+
"Include an agent-relative file or directory even when it exists only remotely.",
|
|
150
|
+
action: "addCustom",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
142
153
|
hint: "close",
|
|
143
154
|
doneLabel: "Review changes",
|
|
144
155
|
}),
|
|
@@ -148,6 +159,45 @@ async function showDraftEditor(
|
|
|
148
159
|
updateDraft(draft, itemId, selected === true);
|
|
149
160
|
return { kind: "stay" };
|
|
150
161
|
},
|
|
162
|
+
addCustom: async ({ ctx: actionCtx, signal: actionSignal }) => {
|
|
163
|
+
const entered = await actionCtx.ui.input(
|
|
164
|
+
"Add included content",
|
|
165
|
+
"Agent-relative path, for example custom.toml or snippets",
|
|
166
|
+
{ signal: actionSignal },
|
|
167
|
+
);
|
|
168
|
+
if (actionSignal.aborted) return { kind: "stay" };
|
|
169
|
+
const requested = entered?.trim();
|
|
170
|
+
if (!requested) return { kind: "stay" };
|
|
171
|
+
const existing = customCandidates.find(
|
|
172
|
+
(candidate) => candidate.toLowerCase() === requested.toLowerCase(),
|
|
173
|
+
);
|
|
174
|
+
const relativePath = existing ?? requested;
|
|
175
|
+
if (draft.custom.has(relativePath)) return { kind: "stay" };
|
|
176
|
+
try {
|
|
177
|
+
if (!isSafeCustomIncludePath(relativePath)) {
|
|
178
|
+
throw new Error("Enter a safe agent-relative file or directory path.");
|
|
179
|
+
}
|
|
180
|
+
normalizeSyncInclude([
|
|
181
|
+
...draft.builtIns,
|
|
182
|
+
...draft.custom,
|
|
183
|
+
...(draft.sessions ? ["sessions"] : []),
|
|
184
|
+
relativePath,
|
|
185
|
+
]);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (actionSignal.aborted) return { kind: "stay" };
|
|
188
|
+
actionCtx.ui.notify(
|
|
189
|
+
`Could not add included content: ${safeTerminalText(error instanceof Error ? error.message : String(error))}`,
|
|
190
|
+
"error",
|
|
191
|
+
);
|
|
192
|
+
return { kind: "stay" };
|
|
193
|
+
}
|
|
194
|
+
if (!existing) {
|
|
195
|
+
customCandidates.push(relativePath);
|
|
196
|
+
customCandidates.sort((left, right) => left.localeCompare(right));
|
|
197
|
+
}
|
|
198
|
+
draft.custom.add(relativePath);
|
|
199
|
+
return { kind: "stay" };
|
|
200
|
+
},
|
|
151
201
|
},
|
|
152
202
|
});
|
|
153
203
|
await runMenu(ctx, menu, {
|
package/src/git-backend.ts
CHANGED
|
@@ -140,6 +140,7 @@ export class GitSyncBackend implements SyncBackend {
|
|
|
140
140
|
...(manifest.snapshotSyncSessions === undefined
|
|
141
141
|
? {}
|
|
142
142
|
: { syncSessions: manifest.snapshotSyncSessions }),
|
|
143
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
143
144
|
files,
|
|
144
145
|
};
|
|
145
146
|
validateGitSnapshot(snapshot, manifest, this.config.destination.namespace);
|
|
@@ -174,6 +175,7 @@ export class GitSyncBackend implements SyncBackend {
|
|
|
174
175
|
...(snapshot.syncSessions === undefined
|
|
175
176
|
? {}
|
|
176
177
|
: { snapshotSyncSessions: snapshot.syncSessions }),
|
|
178
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
177
179
|
files: files.map(({ path: filePath, sha256: fileSha, size }) => ({
|
|
178
180
|
path: filePath,
|
|
179
181
|
sha256: fileSha,
|
|
@@ -688,6 +690,7 @@ function remoteHead(sha: string, manifest: GitManifest, identity: string): Remot
|
|
|
688
690
|
createdAt: manifest.createdAt,
|
|
689
691
|
machine: manifest.machine,
|
|
690
692
|
syncSessions: manifest.syncSessions,
|
|
693
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
691
694
|
};
|
|
692
695
|
}
|
|
693
696
|
|
package/src/git-storage.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { portableSnapshotSelection, snapshotSelectionInclude } from "./sync-policy.js";
|
|
3
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
3
4
|
|
|
4
5
|
export const GIT_MANIFEST_VERSION = 2;
|
|
5
6
|
export const MAX_GIT_MANIFEST_BYTES = 1024 * 1024;
|
|
@@ -23,6 +24,7 @@ export interface GitManifest {
|
|
|
23
24
|
profile: string;
|
|
24
25
|
syncSessions: boolean;
|
|
25
26
|
snapshotSyncSessions?: boolean;
|
|
27
|
+
selection?: SnapshotSelection;
|
|
26
28
|
files: GitManifestFile[];
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -81,11 +83,13 @@ export function requireGitManifest(value: unknown): GitManifest {
|
|
|
81
83
|
"profile",
|
|
82
84
|
"syncSessions",
|
|
83
85
|
...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
|
|
86
|
+
...(manifest.selection === undefined ? [] : ["selection"]),
|
|
84
87
|
"files",
|
|
85
88
|
])
|
|
86
89
|
) {
|
|
87
90
|
throw new Error("Git publication manifest is malformed.");
|
|
88
91
|
}
|
|
92
|
+
if (manifest.selection !== undefined) portableSnapshotSelection(manifest.selection);
|
|
89
93
|
let total = 0;
|
|
90
94
|
const paths = new Set<string>();
|
|
91
95
|
for (const rawFile of manifest.files) {
|
|
@@ -126,6 +130,12 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
|
|
|
126
130
|
snapshot.profile !== manifest.profile ||
|
|
127
131
|
snapshot.syncSessions !== manifest.snapshotSyncSessions ||
|
|
128
132
|
syncSessions !== manifest.syncSessions ||
|
|
133
|
+
!sameOptionalInclude(
|
|
134
|
+
snapshotSelectionInclude(snapshot),
|
|
135
|
+
manifest.selection === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: portableSnapshotSelection(manifest.selection).include,
|
|
138
|
+
) ||
|
|
129
139
|
prepared.length !== manifest.files.length ||
|
|
130
140
|
prepared.some((file, index) => {
|
|
131
141
|
const expected = manifest.files[index];
|
|
@@ -142,6 +152,7 @@ export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, n
|
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export function prepareGitSnapshot(snapshot: Snapshot, namespace: string) {
|
|
155
|
+
snapshotSelectionInclude(snapshot);
|
|
145
156
|
if (
|
|
146
157
|
snapshot.version !== SNAPSHOT_VERSION ||
|
|
147
158
|
typeof snapshot.id !== "string" ||
|
|
@@ -242,6 +253,11 @@ export function validateGitPublicationTree(
|
|
|
242
253
|
return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
|
|
243
254
|
}
|
|
244
255
|
|
|
256
|
+
function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
|
|
257
|
+
if (!left || !right) return left === right;
|
|
258
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
259
|
+
}
|
|
260
|
+
|
|
245
261
|
function isSafeSnapshotPath(value: unknown): value is string {
|
|
246
262
|
return (
|
|
247
263
|
typeof value === "string" &&
|
|
@@ -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
|
+
}
|
package/src/sync-backend.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Snapshot } from "./types.js";
|
|
1
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
2
2
|
|
|
3
3
|
export type PublicationCapability =
|
|
4
4
|
| "read-check-write-verify"
|
|
@@ -15,6 +15,8 @@ export interface RemoteHead {
|
|
|
15
15
|
createdAt: string;
|
|
16
16
|
machine: string;
|
|
17
17
|
syncSessions: boolean;
|
|
18
|
+
/** Validated lightweight projection; revalidate it against the immutable snapshot before adoption. */
|
|
19
|
+
selection?: SnapshotSelection;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface RemoteHistoryEntry {
|
package/src/sync-format.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { agentDir } from "./config.js";
|
|
2
2
|
import type { PublicationCapability, RemoteHead } from "./sync-backend.js";
|
|
3
|
+
import { inspectRemoteSelection } from "./sync-policy.js";
|
|
3
4
|
import { fileHashMap } from "./sync-state.js";
|
|
4
5
|
import type { CommonSyncConfig, Snapshot } from "./types.js";
|
|
5
6
|
|
|
@@ -50,6 +51,11 @@ export function formatPushSummary(
|
|
|
50
51
|
`Sessions: ${upload.syncSessions ? "included β may contain private conversations" : "not included"}`,
|
|
51
52
|
head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
|
|
52
53
|
"Publication effect: the backend's active head will reference the new immutable snapshot.",
|
|
54
|
+
...(remote && inspectRemoteSelection(config.include, remote).kind === "different"
|
|
55
|
+
? [
|
|
56
|
+
"Included-content policy effect: replace the differing remote selection with this setup's local selection.",
|
|
57
|
+
]
|
|
58
|
+
: []),
|
|
53
59
|
formatPublicationPreview(remote, upload),
|
|
54
60
|
preservedRemoteFileCount > 0
|
|
55
61
|
? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.`
|
package/src/sync-operations.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
writeStateForConfig,
|
|
12
12
|
} from "./config.js";
|
|
13
13
|
import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "./lock.js";
|
|
14
|
+
import {
|
|
15
|
+
formatRemoteSelectionStatus,
|
|
16
|
+
readSnapshotForHead,
|
|
17
|
+
requireCompatibleRemoteSelection,
|
|
18
|
+
} from "./remote-snapshot.js";
|
|
14
19
|
import {
|
|
15
20
|
createSnapshot,
|
|
16
21
|
filterSnapshotForConfigPolicy,
|
|
@@ -41,6 +46,7 @@ import {
|
|
|
41
46
|
publicationCapabilityDescription,
|
|
42
47
|
safeTerminalText,
|
|
43
48
|
} from "./sync-format.js";
|
|
49
|
+
import { inspectRemoteSelection, RemoteSelectionMismatchError } from "./sync-policy.js";
|
|
44
50
|
import {
|
|
45
51
|
canPullRemoteSessionsOnFirstSync,
|
|
46
52
|
canPullRemoteSettingsOnFirstSync,
|
|
@@ -124,6 +130,9 @@ export async function status(
|
|
|
124
130
|
throwIfAborted(options.signal);
|
|
125
131
|
const head = await backend.readHead(options.signal);
|
|
126
132
|
throwIfAborted(options.signal);
|
|
133
|
+
const selectionState = head
|
|
134
|
+
? inspectRemoteSelection(config.include, { selection: head.selection, files: [] })
|
|
135
|
+
: undefined;
|
|
127
136
|
const localChanged = hasLocalChanges(local, state, config);
|
|
128
137
|
|
|
129
138
|
const remoteText = head
|
|
@@ -143,12 +152,15 @@ export async function status(
|
|
|
143
152
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
144
153
|
`sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
|
|
145
154
|
remoteText,
|
|
155
|
+
formatRemoteSelectionStatus(selectionState),
|
|
146
156
|
`local files: ${local.files.length}`,
|
|
147
157
|
`local changed since last sync: ${localChanged ? "yes" : "no"}`,
|
|
148
158
|
`remote changed since last sync: ${remoteChanged ? "yes" : "no"}`,
|
|
149
159
|
...warnings,
|
|
150
160
|
].join("\n"),
|
|
151
|
-
localChanged || remoteChanged || warnings.length > 0
|
|
161
|
+
localChanged || remoteChanged || selectionState?.kind === "different" || warnings.length > 0
|
|
162
|
+
? "warning"
|
|
163
|
+
: "info",
|
|
152
164
|
);
|
|
153
165
|
}
|
|
154
166
|
|
|
@@ -166,7 +178,12 @@ export async function diff(
|
|
|
166
178
|
snapshotOptionsForContext(ctx, config),
|
|
167
179
|
);
|
|
168
180
|
throwIfAborted(options.signal);
|
|
169
|
-
const { snapshot: remote } = await readRemoteSnapshot(
|
|
181
|
+
const { snapshot: remote, selectionState } = await readRemoteSnapshot(
|
|
182
|
+
backend,
|
|
183
|
+
config,
|
|
184
|
+
options.signal,
|
|
185
|
+
{ allowSelectionDifference: true },
|
|
186
|
+
);
|
|
170
187
|
throwIfAborted(options.signal);
|
|
171
188
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
172
189
|
|
|
@@ -177,9 +194,10 @@ export async function diff(
|
|
|
177
194
|
`storage location: ${safeTerminalText(backend.destination)}`,
|
|
178
195
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
179
196
|
`sessions: ${config.include.includes("sessions") ? "included" : "excluded"}`,
|
|
197
|
+
formatRemoteSelectionStatus(selectionState),
|
|
180
198
|
...warnings,
|
|
181
199
|
].join("\n");
|
|
182
|
-
const level = warnings.length > 0 ? "warning" : "info";
|
|
200
|
+
const level = warnings.length > 0 || selectionState?.kind === "different" ? "warning" : "info";
|
|
183
201
|
if (!remote) {
|
|
184
202
|
ctx.ui.notify(
|
|
185
203
|
`${header}\n\n${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`,
|
|
@@ -299,6 +317,18 @@ export async function push(
|
|
|
299
317
|
state,
|
|
300
318
|
options.signal,
|
|
301
319
|
);
|
|
320
|
+
if (
|
|
321
|
+
!options.force &&
|
|
322
|
+
!remoteForUpload &&
|
|
323
|
+
head?.selection &&
|
|
324
|
+
inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind ===
|
|
325
|
+
"different"
|
|
326
|
+
) {
|
|
327
|
+
remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
|
|
328
|
+
}
|
|
329
|
+
if (remoteForUpload && !options.force) {
|
|
330
|
+
requireCompatibleRemoteSelection(config, remoteForUpload);
|
|
331
|
+
}
|
|
302
332
|
if (
|
|
303
333
|
remoteChangedSinceState(head, state, config, (left, right) =>
|
|
304
334
|
backend.sameRevision(left, right),
|
|
@@ -886,16 +916,20 @@ async function readRemoteSnapshot(
|
|
|
886
916
|
backend: SyncBackend,
|
|
887
917
|
config: AnySyncConfig,
|
|
888
918
|
signal?: AbortSignal,
|
|
919
|
+
options: { allowSelectionDifference?: boolean } = {},
|
|
889
920
|
) {
|
|
890
921
|
const head = await backend.readHead(signal);
|
|
891
|
-
if (!head) return { head: undefined, snapshot: undefined };
|
|
892
|
-
const snapshot = await backend
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
);
|
|
922
|
+
if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
|
|
923
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
924
|
+
const selectionState = inspectRemoteSelection(config.include, snapshot);
|
|
925
|
+
if (!options.allowSelectionDifference && selectionState.kind === "different") {
|
|
926
|
+
throw new RemoteSelectionMismatchError(config.include, selectionState.include);
|
|
897
927
|
}
|
|
898
|
-
return {
|
|
928
|
+
return {
|
|
929
|
+
head,
|
|
930
|
+
snapshot: filterSnapshotForConfigPolicy(snapshot, config),
|
|
931
|
+
selectionState,
|
|
932
|
+
};
|
|
899
933
|
}
|
|
900
934
|
|
|
901
935
|
async function confirmPush(
|
package/src/sync-policy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isDeniedPath, toPosix } from "./paths.js";
|
|
3
|
+
import type { Snapshot, SnapshotSelection } from "./types.js";
|
|
3
4
|
|
|
4
5
|
export const BUILT_IN_SYNC_ROOTS = [
|
|
5
6
|
"settings.json",
|
|
@@ -15,6 +16,15 @@ export const BUILT_IN_SYNC_ROOTS = [
|
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_SYNC_INCLUDE = [...BUILT_IN_SYNC_ROOTS] as const;
|
|
17
18
|
export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_ROOTS)[number];
|
|
19
|
+
const SNAPSHOT_SELECTION_VERSION = 1;
|
|
20
|
+
const MAX_SYNC_INCLUDE_ITEMS = 1_024;
|
|
21
|
+
const MAX_SYNC_INCLUDE_PATH_BYTES = 4_096;
|
|
22
|
+
const MAX_SYNC_INCLUDE_TOTAL_BYTES = 256 * 1_024;
|
|
23
|
+
|
|
24
|
+
interface IncludePathNode {
|
|
25
|
+
selected?: string;
|
|
26
|
+
children: Map<string, IncludePathNode>;
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
|
|
20
30
|
BUILT_IN_SYNC_ROOTS.map((fileName) => [fileName.toLowerCase(), fileName]),
|
|
@@ -34,12 +44,31 @@ export function normalizeSyncInclude(value: unknown): string[] {
|
|
|
34
44
|
if (!Array.isArray(value)) {
|
|
35
45
|
throw new Error("Invalid pi-sync settings: sync.include must be an array.");
|
|
36
46
|
}
|
|
47
|
+
if (value.length > MAX_SYNC_INCLUDE_ITEMS) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Invalid pi-sync settings: sync.include has too many items; limit: ${MAX_SYNC_INCLUDE_ITEMS}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
37
52
|
const result: string[] = [];
|
|
38
53
|
const seen = new Set<string>();
|
|
54
|
+
const pathRoot: IncludePathNode = { children: new Map() };
|
|
55
|
+
let totalBytes = 0;
|
|
39
56
|
for (const item of value) {
|
|
40
57
|
if (typeof item !== "string") {
|
|
41
58
|
throw new Error("Invalid pi-sync settings: sync.include items must be strings.");
|
|
42
59
|
}
|
|
60
|
+
const itemBytes = Buffer.byteLength(item, "utf8");
|
|
61
|
+
if (itemBytes > MAX_SYNC_INCLUDE_PATH_BYTES) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid pi-sync settings: sync.include item is too long; limit: ${MAX_SYNC_INCLUDE_PATH_BYTES} bytes.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
totalBytes += itemBytes;
|
|
67
|
+
if (totalBytes > MAX_SYNC_INCLUDE_TOTAL_BYTES) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Invalid pi-sync settings: sync.include is too large; limit: ${MAX_SYNC_INCLUDE_TOTAL_BYTES} bytes.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
43
72
|
const trimmed = item.trim();
|
|
44
73
|
const builtIn = BUILT_IN_BY_LOWER.get(trimmed.toLowerCase());
|
|
45
74
|
const normalized = builtIn ?? (trimmed.toLowerCase() === "sessions" ? "sessions" : trimmed);
|
|
@@ -48,19 +77,34 @@ export function normalizeSyncInclude(value: unknown): string[] {
|
|
|
48
77
|
if (seen.has(identity)) {
|
|
49
78
|
throw new Error(`Invalid pi-sync settings: duplicate sync.include item: ${item}`);
|
|
50
79
|
}
|
|
51
|
-
|
|
52
|
-
if (identity.startsWith(`${previous}/`) || previous.startsWith(`${identity}/`)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
80
|
+
addIncludePath(pathRoot, identity, item);
|
|
58
81
|
seen.add(identity);
|
|
59
82
|
result.push(normalized);
|
|
60
83
|
}
|
|
61
84
|
return result;
|
|
62
85
|
}
|
|
63
86
|
|
|
87
|
+
function addIncludePath(root: IncludePathNode, identity: string, source: string) {
|
|
88
|
+
let node = root;
|
|
89
|
+
for (const segment of identity.split("/")) {
|
|
90
|
+
if (node.selected !== undefined) throwOverlappingInclude(source);
|
|
91
|
+
let child = node.children.get(segment);
|
|
92
|
+
if (!child) {
|
|
93
|
+
child = { children: new Map() };
|
|
94
|
+
node.children.set(segment, child);
|
|
95
|
+
}
|
|
96
|
+
node = child;
|
|
97
|
+
}
|
|
98
|
+
if (node.children.size > 0) throwOverlappingInclude(source);
|
|
99
|
+
node.selected = identity;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function throwOverlappingInclude(item: string): never {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
function validateAgentRelativeInclude(value: string) {
|
|
65
109
|
const normalized = toPosix(value);
|
|
66
110
|
const topLevel = normalized.split("/")[0]?.toLowerCase();
|
|
@@ -89,6 +133,138 @@ function validateAgentRelativeInclude(value: string) {
|
|
|
89
133
|
}
|
|
90
134
|
}
|
|
91
135
|
|
|
136
|
+
export function portableSnapshotSelection(value: unknown): SnapshotSelection {
|
|
137
|
+
const selection = value as Partial<SnapshotSelection> | null;
|
|
138
|
+
if (
|
|
139
|
+
!selection ||
|
|
140
|
+
typeof selection !== "object" ||
|
|
141
|
+
Array.isArray(selection) ||
|
|
142
|
+
selection.version !== SNAPSHOT_SELECTION_VERSION ||
|
|
143
|
+
!Object.hasOwn(selection, "include") ||
|
|
144
|
+
Object.keys(selection).some((key) => key !== "version" && key !== "include")
|
|
145
|
+
) {
|
|
146
|
+
throw new Error("Invalid snapshot selection policy.");
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
version: SNAPSHOT_SELECTION_VERSION,
|
|
150
|
+
include: normalizeSyncInclude(selection.include),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function snapshotSelectionInclude(snapshot: Pick<Snapshot, "selection">) {
|
|
155
|
+
return snapshot.selection === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: portableSnapshotSelection(snapshot.selection).include;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function selectionForSnapshot(include: unknown): SnapshotSelection {
|
|
161
|
+
return { version: SNAPSHOT_SELECTION_VERSION, include: normalizeSyncInclude(include) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sameSyncInclude(left: unknown, right: unknown) {
|
|
165
|
+
const normalizedLeft = normalizeSyncInclude(left);
|
|
166
|
+
const normalizedRight = normalizeSyncInclude(right);
|
|
167
|
+
return (
|
|
168
|
+
normalizedLeft.length === normalizedRight.length &&
|
|
169
|
+
normalizedLeft.every((item, index) => item === normalizedRight[index])
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function compareSyncInclude(local: unknown, remote: unknown) {
|
|
174
|
+
const localInclude = normalizeSyncInclude(local);
|
|
175
|
+
const remoteInclude = normalizeSyncInclude(remote);
|
|
176
|
+
const localSet = new Set(localInclude);
|
|
177
|
+
const remoteSet = new Set(remoteInclude);
|
|
178
|
+
return {
|
|
179
|
+
same: sameSyncInclude(localInclude, remoteInclude),
|
|
180
|
+
remoteOnly: remoteInclude.filter((item) => !localSet.has(item)),
|
|
181
|
+
localOnly: localInclude.filter((item) => !remoteSet.has(item)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type RemoteSelectionState =
|
|
186
|
+
| { kind: "legacy"; discovered: string[] }
|
|
187
|
+
| { kind: "same"; include: string[] }
|
|
188
|
+
| {
|
|
189
|
+
kind: "different";
|
|
190
|
+
include: string[];
|
|
191
|
+
remoteOnly: string[];
|
|
192
|
+
localOnly: string[];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export function inspectRemoteSelection(
|
|
196
|
+
localInclude: unknown,
|
|
197
|
+
snapshot: Pick<Snapshot, "selection" | "files">,
|
|
198
|
+
): RemoteSelectionState {
|
|
199
|
+
const remoteInclude = snapshotSelectionInclude(snapshot);
|
|
200
|
+
if (!remoteInclude) {
|
|
201
|
+
return { kind: "legacy", discovered: discoverLegacySnapshotInclude(snapshot) };
|
|
202
|
+
}
|
|
203
|
+
const comparison = compareSyncInclude(localInclude, remoteInclude);
|
|
204
|
+
return comparison.same
|
|
205
|
+
? { kind: "same", include: remoteInclude }
|
|
206
|
+
: { kind: "different", include: remoteInclude, ...comparison };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class RemoteSelectionMismatchError extends Error {
|
|
210
|
+
readonly localInclude: string[];
|
|
211
|
+
readonly remoteInclude: string[];
|
|
212
|
+
|
|
213
|
+
constructor(localInclude: unknown, remoteInclude: unknown) {
|
|
214
|
+
const local = normalizeSyncInclude(localInclude);
|
|
215
|
+
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
|
+
);
|
|
219
|
+
this.name = "RemoteSelectionMismatchError";
|
|
220
|
+
this.localInclude = local;
|
|
221
|
+
this.remoteInclude = remote;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function discoverLegacySnapshotInclude(snapshot: Pick<Snapshot, "files">) {
|
|
226
|
+
const builtIns = new Set<BuiltInSyncFile>();
|
|
227
|
+
const custom = new Set<string>();
|
|
228
|
+
let sessions = false;
|
|
229
|
+
for (const file of snapshot.files) {
|
|
230
|
+
const normalized = toPosix(file.path);
|
|
231
|
+
if (
|
|
232
|
+
!normalized ||
|
|
233
|
+
file.path.includes("\\") ||
|
|
234
|
+
normalized.length > 4096 ||
|
|
235
|
+
normalized.startsWith("../") ||
|
|
236
|
+
path.posix.isAbsolute(normalized) ||
|
|
237
|
+
path.posix.normalize(normalized) !== normalized ||
|
|
238
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Ignore unsafe legacy paths.
|
|
239
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(normalized) ||
|
|
240
|
+
isDeniedPath(normalized)
|
|
241
|
+
) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const [topLevel, ...rest] = normalized.split("/");
|
|
245
|
+
if (!topLevel) continue;
|
|
246
|
+
if (topLevel === "sessions" && rest.length > 0) {
|
|
247
|
+
sessions = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const builtIn = BUILT_IN_BY_LOWER.get(topLevel.toLowerCase());
|
|
251
|
+
if (
|
|
252
|
+
builtIn &&
|
|
253
|
+
((TOP_LEVEL_DIRS.has(builtIn) && rest.length > 0) ||
|
|
254
|
+
(!TOP_LEVEL_DIRS.has(builtIn) && rest.length === 0))
|
|
255
|
+
) {
|
|
256
|
+
builtIns.add(builtIn);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (isSafeCustomIncludePath(topLevel)) custom.add(topLevel);
|
|
260
|
+
}
|
|
261
|
+
return [
|
|
262
|
+
...BUILT_IN_SYNC_ROOTS.filter((item) => builtIns.has(item)),
|
|
263
|
+
...[...custom].sort((left, right) => left.localeCompare(right)),
|
|
264
|
+
...(sessions ? ["sessions"] : []),
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
92
268
|
export function syncIncludeSelection(value: unknown) {
|
|
93
269
|
const include = normalizeSyncInclude(value);
|
|
94
270
|
const builtIns = include.filter((item): item is BuiltInSyncFile =>
|
package/src/types.ts
CHANGED
|
@@ -193,6 +193,11 @@ export interface SnapshotFile {
|
|
|
193
193
|
sha256: string;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
export interface SnapshotSelection {
|
|
197
|
+
version: 1;
|
|
198
|
+
include: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
196
201
|
export interface Snapshot {
|
|
197
202
|
version: number;
|
|
198
203
|
id: string;
|
|
@@ -201,6 +206,8 @@ export interface Snapshot {
|
|
|
201
206
|
/** Backend-scoped remote identity retained in the snapshot wire format. */
|
|
202
207
|
profile: string;
|
|
203
208
|
syncSessions?: boolean;
|
|
209
|
+
/** Portable, credential-free included-content intent. Absent on legacy snapshots. */
|
|
210
|
+
selection?: SnapshotSelection;
|
|
204
211
|
files: SnapshotFile[];
|
|
205
212
|
}
|
|
206
213
|
|
|
@@ -212,6 +219,8 @@ export interface LatestPointer {
|
|
|
212
219
|
createdAt: string;
|
|
213
220
|
machine: string;
|
|
214
221
|
syncSessions?: boolean;
|
|
222
|
+
/** Lightweight projection; the immutable snapshot remains authoritative. */
|
|
223
|
+
selection?: SnapshotSelection;
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
export interface RemoteObject<T> {
|
package/src/webdav-backend.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
SyncBackendConflictError,
|
|
12
12
|
SyncBackendPublicationOutcomeUnknownError,
|
|
13
13
|
} from "./sync-backend.js";
|
|
14
|
+
import { portableSnapshotSelection } from "./sync-policy.js";
|
|
14
15
|
import type { LatestPointer, RemoteObject, ResolvedWebDavBackend, Snapshot } from "./types.js";
|
|
15
16
|
import { WebDavClient, WebDavHttpError, WebDavPreconditionError } from "./webdav-client.js";
|
|
16
17
|
|
|
@@ -427,6 +428,7 @@ function pointerFor(
|
|
|
427
428
|
syncSessions:
|
|
428
429
|
snapshot.syncSessions === true ||
|
|
429
430
|
snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
431
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
430
432
|
};
|
|
431
433
|
}
|
|
432
434
|
|
|
@@ -438,6 +440,7 @@ function remoteHead(pointer: LatestPointer, identity: string, etag?: string): Re
|
|
|
438
440
|
createdAt: pointer.createdAt,
|
|
439
441
|
machine: pointer.machine,
|
|
440
442
|
syncSessions: pointer.syncSessions === true,
|
|
443
|
+
...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
|
|
441
444
|
};
|
|
442
445
|
}
|
|
443
446
|
|
|
@@ -503,6 +506,7 @@ function requirePointer(value: LatestPointer | undefined, expectedProfile: strin
|
|
|
503
506
|
) {
|
|
504
507
|
throw new Error("Remote latest pointer is malformed.");
|
|
505
508
|
}
|
|
509
|
+
if (value.selection !== undefined) portableSnapshotSelection(value.selection);
|
|
506
510
|
return value;
|
|
507
511
|
}
|
|
508
512
|
|