@narumitw/pi-sync 0.51.1 → 0.52.1
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 +30 -5
- package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
- package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
- package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
- package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
- package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
- package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
- package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
- package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
- package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
- package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
- package/dist/chunks/chunk-76NB4R7U.ts +107 -0
- package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
- package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
- package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
- package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
- package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
- package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
- package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
- package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
- package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
- package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
- package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
- package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
- package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
- package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
- package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
- package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
- package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
- package/dist/chunks/chunk-NLS2NV3K.ts +1746 -0
- package/dist/chunks/chunk-NLS2NV3K.ts.map +7 -0
- package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
- package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
- package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
- package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
- package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
- package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
- package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
- package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
- package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
- package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
- package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
- package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
- package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
- package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
- package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
- package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
- package/dist/chunks/conflict-review-4WPGCXFB.ts +175 -0
- package/dist/chunks/conflict-review-4WPGCXFB.ts.map +7 -0
- package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
- package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
- package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
- package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
- package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
- package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
- package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
- package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
- package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
- package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
- package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
- package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
- package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
- package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
- package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
- package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
- package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
- package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
- package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
- package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-RHNECOOG.ts} +309 -508
- package/dist/chunks/sync-operations-RHNECOOG.ts.map +7 -0
- package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
- package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
- package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
- package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
- package/dist/index.ts +142 -43
- package/dist/index.ts.map +2 -2
- package/docs/content-merge-audit.md +156 -0
- package/docs/merge-implementation-audit.md +312 -0
- package/docs/settings-merge-audit.md +153 -0
- package/docs/settings.md +36 -0
- package/package.json +5 -5
- package/src/backends/git/git-backend.ts +2 -0
- package/src/backends/git/git-storage.ts +8 -3
- package/src/backends/s3/s3-backend.ts +2 -1
- package/src/backends/webdav/webdav-backend.ts +2 -1
- package/src/commands/command-execution.ts +10 -0
- package/src/commands/command.ts +4 -0
- package/src/settings/config.ts +12 -0
- package/src/settings/settings-management.ts +2 -2
- package/src/settings/settings-types.ts +14 -1
- package/src/settings/settings-validation.ts +24 -3
- package/src/snapshot/session-paths.ts +34 -7
- package/src/snapshot/snapshot-apply.ts +81 -32
- package/src/snapshot/snapshot-case-replacement.ts +80 -0
- package/src/snapshot/snapshot-codec.ts +6 -1
- package/src/snapshot/snapshot-root-transition.ts +154 -0
- package/src/snapshot/snapshot-transaction-plan.ts +72 -0
- package/src/snapshot/snapshot-transaction.ts +844 -102
- package/src/snapshot/snapshot-types.ts +5 -0
- package/src/state/json-file.ts +32 -5
- package/src/state/merge-baseline-store.ts +182 -0
- package/src/state/mutation-directory-sync.ts +34 -0
- package/src/state/state-types.ts +4 -0
- package/src/state/sync-state-store.ts +14 -1
- package/src/sync/automatic-sync.ts +11 -3
- package/src/sync/conflict-artifact-ownership.ts +63 -0
- package/src/sync/conflict-artifacts.ts +195 -0
- package/src/sync/conflict-groups.ts +68 -0
- package/src/sync/conflict-preview.ts +56 -0
- package/src/sync/conflict-resolution.ts +93 -0
- package/src/sync/conflict-retention.ts +72 -0
- package/src/sync/conflict-review.ts +95 -0
- package/src/sync/content-conflicts.ts +73 -0
- package/src/sync/field-migration.ts +39 -0
- package/src/sync/file-merge-planner.ts +143 -0
- package/src/sync/json-document.ts +184 -0
- package/src/sync/local-fields.ts +134 -0
- package/src/sync/merge-apply.ts +428 -0
- package/src/sync/merge-journal.ts +219 -0
- package/src/sync/merged-sync.ts +723 -0
- package/src/sync/partial-progress.ts +54 -0
- package/src/sync/remote-snapshot.ts +6 -0
- package/src/sync/session-merge.ts +194 -0
- package/src/sync/settings-conflicts.ts +52 -0
- package/src/sync/settings-merge.ts +57 -0
- package/src/sync/startup-check.ts +90 -5
- package/src/sync/sync-inspection.ts +1 -0
- package/src/sync/sync-local.ts +61 -0
- package/src/sync/sync-mutations.ts +256 -114
- package/src/sync/sync-queries.ts +6 -2
- package/src/sync/sync-state.ts +14 -5
- package/src/sync/text-merge.ts +133 -0
- package/src/sync-extension.ts +8 -0
- package/src/ui/manager-state.ts +12 -1
- package/src/ui/manager-ui.ts +10 -0
- package/src/ui/merge-review.ts +28 -0
- package/src/ui/remote-selection-ui.ts +1 -0
- package/src/ui/settings-ui.ts +183 -2
- package/src/ui/setup/setup-actions.ts +2 -2
- package/src/ui/setup/setup-switcher.ts +2 -1
- package/src/ui/storage-connections-ui.ts +11 -7
- package/src/ui/sync-attention.ts +3 -0
- package/src/ui/sync-format.ts +16 -12
- package/src/ui/sync-setups-ui.ts +1 -0
- package/src/ui/terminal-text.ts +9 -2
- package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
- package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
- package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
- package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
- package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
- package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
- package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
- package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
- package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
- package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
- package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
- package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
- package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
- package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
- package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
- package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
- package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
- package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
- /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
- /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
- /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
- /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RemoteHead } from "../backends/sync-backend.js";
|
|
2
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
3
|
+
import type { Snapshot, SnapshotFile } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import type { SyncState } from "../state/state-types.js";
|
|
5
|
+
import type { FileMergeDecision } from "./file-merge-planner.js";
|
|
6
|
+
import { fileHashMap } from "./sync-state.js";
|
|
7
|
+
/** Withholding preserves the complete local version, including explicit absence. */
|
|
8
|
+
export function acceptedMergeFiles(
|
|
9
|
+
decisions: readonly FileMergeDecision[],
|
|
10
|
+
localFiles: readonly SnapshotFile[],
|
|
11
|
+
withheld: ReadonlySet<string>,
|
|
12
|
+
) {
|
|
13
|
+
const localByPath = new Map(localFiles.map((file) => [file.path, file]));
|
|
14
|
+
return decisions.flatMap((decision) => {
|
|
15
|
+
const file = withheld.has(decision.path)
|
|
16
|
+
? localByPath.get(decision.path)
|
|
17
|
+
: decision.kind === "accepted"
|
|
18
|
+
? decision.file
|
|
19
|
+
: undefined;
|
|
20
|
+
return file ? [file] : [];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PartialProgress {
|
|
25
|
+
previous: SyncState;
|
|
26
|
+
groups: { paths: string[]; artifact: string }[];
|
|
27
|
+
}
|
|
28
|
+
export function progressState(
|
|
29
|
+
config: AnySyncConfig,
|
|
30
|
+
head: RemoteHead,
|
|
31
|
+
snapshot: Snapshot,
|
|
32
|
+
progress: PartialProgress,
|
|
33
|
+
): SyncState {
|
|
34
|
+
const withheld = new Set(progress.groups.flatMap((group) => group.paths));
|
|
35
|
+
const current = fileHashMap(snapshot);
|
|
36
|
+
const hashes = Object.assign(Object.create(null) as Record<string, string>, progress.previous.lastFileHashes);
|
|
37
|
+
for (const key of new Set([...Object.keys(hashes), ...Object.keys(current)])) {
|
|
38
|
+
if (withheld.has(key)) continue;
|
|
39
|
+
const value = current[key];
|
|
40
|
+
if (value === undefined) delete hashes[key];
|
|
41
|
+
else hashes[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...progress.previous,
|
|
45
|
+
version: 3,
|
|
46
|
+
profile: config.snapshotIdentity,
|
|
47
|
+
lastFileHashes: hashes,
|
|
48
|
+
include: [...config.include],
|
|
49
|
+
localFields: config.localFields,
|
|
50
|
+
lastObservedSnapshot: head.snapshotId,
|
|
51
|
+
lastObservedRevision: head.revision,
|
|
52
|
+
unresolved: progress.groups,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
|
4
4
|
import { filterSnapshotForConfigPolicy } from "../snapshot/snapshot.js";
|
|
5
5
|
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
6
6
|
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
7
|
+
import { sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
|
|
7
8
|
import {
|
|
8
9
|
inspectRemoteSelection,
|
|
9
10
|
type RemoteSelectionState,
|
|
@@ -17,6 +18,7 @@ export async function readSnapshotForHead(backend: SyncBackend, head: RemoteHead
|
|
|
17
18
|
if (signal?.aborted) {
|
|
18
19
|
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
19
20
|
}
|
|
21
|
+
validatePortableSnapshot(snapshot);
|
|
20
22
|
if (snapshot.id !== head.snapshotId) {
|
|
21
23
|
throw new Error(`Remote head ${head.snapshotId} resolved to unexpected snapshot ${snapshot.id}.`);
|
|
22
24
|
}
|
|
@@ -30,6 +32,10 @@ export async function readSnapshotForHead(backend: SyncBackend, head: RemoteHead
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
|
|
35
|
+
if (!sameLocalFields(config.localFields, snapshot.localFields))
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Remote local-field policy differs; confirm a reviewed directional migration. Old history is not erased.",
|
|
38
|
+
);
|
|
33
39
|
const state = inspectRemoteSelection(config.include, snapshot);
|
|
34
40
|
if (state.kind === "different") {
|
|
35
41
|
throw remoteSelectionMismatch(config, state.include, syncConfigReviewFingerprint(config));
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parseJsonObjectDocument } from "./json-document.js";
|
|
3
|
+
import { text } from "./text-merge.js";
|
|
4
|
+
|
|
5
|
+
// Public header IDs use Pi assertValidSessionId; generated entry IDs have a separate grammar.
|
|
6
|
+
const sessionId = (value: unknown): value is string =>
|
|
7
|
+
typeof value === "string" && /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value);
|
|
8
|
+
const id = (value: unknown): value is string => typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/u.test(value);
|
|
9
|
+
const content = (value: unknown, types = ["text", "image", "thinking", "toolCall"]): boolean =>
|
|
10
|
+
typeof value === "string" ||
|
|
11
|
+
(Array.isArray(value) &&
|
|
12
|
+
value.every(
|
|
13
|
+
(block) =>
|
|
14
|
+
object(block) &&
|
|
15
|
+
types.includes(String(block.type)) &&
|
|
16
|
+
((block.type === "text" && typeof block.text === "string") ||
|
|
17
|
+
(block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") ||
|
|
18
|
+
(block.type === "thinking" && typeof block.thinking === "string") ||
|
|
19
|
+
(block.type === "toolCall" &&
|
|
20
|
+
typeof block.id === "string" &&
|
|
21
|
+
typeof block.name === "string" &&
|
|
22
|
+
object(block.arguments))),
|
|
23
|
+
));
|
|
24
|
+
const message = (value: unknown) => {
|
|
25
|
+
if (!object(value) || typeof value.timestamp !== "number" || !Number.isFinite(value.timestamp)) return false;
|
|
26
|
+
switch (value.role) {
|
|
27
|
+
case "system":
|
|
28
|
+
return (
|
|
29
|
+
content(value.content, ["text"]) &&
|
|
30
|
+
(value.sections === undefined ||
|
|
31
|
+
(object(value.sections) &&
|
|
32
|
+
Object.values(value.sections).every((section) => section === null || typeof section === "string"))) &&
|
|
33
|
+
(value.toolsAdded === undefined ||
|
|
34
|
+
(Array.isArray(value.toolsAdded) &&
|
|
35
|
+
value.toolsAdded.every(
|
|
36
|
+
(tool) =>
|
|
37
|
+
object(tool) &&
|
|
38
|
+
typeof tool.name === "string" &&
|
|
39
|
+
typeof tool.description === "string" &&
|
|
40
|
+
object(tool.parameters),
|
|
41
|
+
))) &&
|
|
42
|
+
(value.toolsRemoved === undefined ||
|
|
43
|
+
(Array.isArray(value.toolsRemoved) &&
|
|
44
|
+
value.toolsRemoved.every((tool) => object(tool) && typeof tool.name === "string")))
|
|
45
|
+
);
|
|
46
|
+
case "user":
|
|
47
|
+
return content(value.content, ["text", "image"]);
|
|
48
|
+
case "assistant":
|
|
49
|
+
return (
|
|
50
|
+
Array.isArray(value.content) &&
|
|
51
|
+
content(value.content, ["text", "thinking", "toolCall"]) &&
|
|
52
|
+
typeof value.api === "string" &&
|
|
53
|
+
typeof value.provider === "string" &&
|
|
54
|
+
typeof value.model === "string" &&
|
|
55
|
+
object(value.usage) &&
|
|
56
|
+
["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"].includes(String(value.stopReason))
|
|
57
|
+
);
|
|
58
|
+
case "toolResult":
|
|
59
|
+
return (
|
|
60
|
+
typeof value.toolCallId === "string" &&
|
|
61
|
+
typeof value.toolName === "string" &&
|
|
62
|
+
Array.isArray(value.content) &&
|
|
63
|
+
content(value.content, ["text", "image"]) &&
|
|
64
|
+
typeof value.isError === "boolean"
|
|
65
|
+
);
|
|
66
|
+
case "custom":
|
|
67
|
+
return (
|
|
68
|
+
typeof value.customType === "string" &&
|
|
69
|
+
content(value.content, ["text", "image"]) &&
|
|
70
|
+
typeof value.display === "boolean"
|
|
71
|
+
);
|
|
72
|
+
case "bashExecution":
|
|
73
|
+
return (
|
|
74
|
+
typeof value.command === "string" &&
|
|
75
|
+
typeof value.output === "string" &&
|
|
76
|
+
typeof value.cancelled === "boolean" &&
|
|
77
|
+
typeof value.truncated === "boolean"
|
|
78
|
+
);
|
|
79
|
+
case "branchSummary":
|
|
80
|
+
return typeof value.summary === "string" && (value.fromId === null || typeof value.fromId === "string");
|
|
81
|
+
case "compactionSummary":
|
|
82
|
+
return (
|
|
83
|
+
typeof value.summary === "string" &&
|
|
84
|
+
typeof value.tokensBefore === "number" &&
|
|
85
|
+
Number.isFinite(value.tokensBefore) &&
|
|
86
|
+
value.tokensBefore >= 0
|
|
87
|
+
);
|
|
88
|
+
default:
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
93
|
+
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
94
|
+
/** Stricter than Pi's repair-tolerant reader: never skip malformed lines or migrate a log. */
|
|
95
|
+
export function validateSession(bytes: Buffer) {
|
|
96
|
+
if (CURRENT_SESSION_VERSION !== 3) throw new Error("Unaudited Pi session format; require review.");
|
|
97
|
+
const raw = text(bytes);
|
|
98
|
+
if (!raw.endsWith("\n")) throw new Error("Incomplete session tail.");
|
|
99
|
+
const rows: unknown[] = raw
|
|
100
|
+
.slice(0, -1)
|
|
101
|
+
.split("\n")
|
|
102
|
+
.map((line) => {
|
|
103
|
+
if (line.startsWith("\uFEFF")) throw new Error("Unexpected session-record BOM; require review.");
|
|
104
|
+
return parseJsonObjectDocument(Buffer.from(line)).root.value;
|
|
105
|
+
});
|
|
106
|
+
if (rows.length > 16_384) throw new Error("Session entry bound exceeded.");
|
|
107
|
+
const header = rows[0];
|
|
108
|
+
if (
|
|
109
|
+
!object(header) ||
|
|
110
|
+
header.type !== "session" ||
|
|
111
|
+
header.version !== CURRENT_SESSION_VERSION ||
|
|
112
|
+
!sessionId(header.id) ||
|
|
113
|
+
typeof header.cwd !== "string" ||
|
|
114
|
+
typeof header.timestamp !== "string" ||
|
|
115
|
+
!Number.isFinite(Date.parse(header.timestamp)) ||
|
|
116
|
+
(header.parentSession !== undefined && typeof header.parentSession !== "string")
|
|
117
|
+
)
|
|
118
|
+
throw new Error("Unsupported session header.");
|
|
119
|
+
const ids = new Set<string>();
|
|
120
|
+
const known = (value: unknown) => id(value) && ids.has(value);
|
|
121
|
+
for (const row of rows.slice(1)) {
|
|
122
|
+
if (
|
|
123
|
+
!object(row) ||
|
|
124
|
+
!id(row.id) ||
|
|
125
|
+
ids.has(row.id) ||
|
|
126
|
+
typeof row.timestamp !== "string" ||
|
|
127
|
+
!Number.isFinite(Date.parse(row.timestamp)) ||
|
|
128
|
+
(row.parentId !== null && !known(row.parentId))
|
|
129
|
+
)
|
|
130
|
+
throw new Error("Invalid session graph.");
|
|
131
|
+
const strings = (...keys: string[]) => keys.every((key) => typeof row[key] === "string");
|
|
132
|
+
let valid = false;
|
|
133
|
+
switch (row.type) {
|
|
134
|
+
case "message":
|
|
135
|
+
valid = message(row.message);
|
|
136
|
+
break;
|
|
137
|
+
case "thinking_level_change":
|
|
138
|
+
valid = strings("thinkingLevel");
|
|
139
|
+
break;
|
|
140
|
+
case "model_change":
|
|
141
|
+
valid = strings("provider", "modelId");
|
|
142
|
+
break;
|
|
143
|
+
case "usage":
|
|
144
|
+
valid = strings("kind", "provider", "model") && object(row.usage);
|
|
145
|
+
break;
|
|
146
|
+
case "compaction":
|
|
147
|
+
valid =
|
|
148
|
+
strings("summary") &&
|
|
149
|
+
(known(row.firstKeptEntryId) || row.firstKeptEntryId === row.id) &&
|
|
150
|
+
(row.systemMessage === undefined ||
|
|
151
|
+
(object(row.systemMessage) && row.systemMessage.role === "system" && message(row.systemMessage))) &&
|
|
152
|
+
typeof row.tokensBefore === "number" &&
|
|
153
|
+
Number.isFinite(row.tokensBefore) &&
|
|
154
|
+
row.tokensBefore >= 0;
|
|
155
|
+
break;
|
|
156
|
+
case "branch_summary":
|
|
157
|
+
valid = strings("summary") && (known(row.fromId) || row.fromId === "root");
|
|
158
|
+
break;
|
|
159
|
+
case "custom":
|
|
160
|
+
valid = strings("customType");
|
|
161
|
+
break;
|
|
162
|
+
case "custom_message":
|
|
163
|
+
valid = strings("customType") && typeof row.display === "boolean" && content(row.content, ["text", "image"]);
|
|
164
|
+
break;
|
|
165
|
+
case "context_edit":
|
|
166
|
+
valid =
|
|
167
|
+
known(row.targetId) &&
|
|
168
|
+
(row.replacement === null || (object(row.replacement) && content(row.replacement.content)));
|
|
169
|
+
break;
|
|
170
|
+
case "label":
|
|
171
|
+
valid = known(row.targetId) && (row.label === undefined || typeof row.label === "string");
|
|
172
|
+
break;
|
|
173
|
+
case "session_info":
|
|
174
|
+
valid = row.name === undefined || typeof row.name === "string";
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (!valid) throw new Error("Unsupported session entry.");
|
|
178
|
+
ids.add(row.id);
|
|
179
|
+
}
|
|
180
|
+
return header.id;
|
|
181
|
+
}
|
|
182
|
+
export function mergeSession(base: Buffer, local: Buffer, remote: Buffer): Buffer | undefined {
|
|
183
|
+
try {
|
|
184
|
+
const identity = validateSession(base);
|
|
185
|
+
if (validateSession(local) !== identity || validateSession(remote) !== identity) return;
|
|
186
|
+
const prefix = (left: Buffer, right: Buffer) =>
|
|
187
|
+
right.length >= left.length && right.subarray(0, left.length).equals(left);
|
|
188
|
+
if (!prefix(base, local) || !prefix(base, remote)) return;
|
|
189
|
+
if (prefix(local, remote)) return remote;
|
|
190
|
+
if (prefix(remote, local)) return local;
|
|
191
|
+
} catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
3
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import { readMergeAncestor } from "../state/merge-baseline-store.js";
|
|
5
|
+
import type { SyncState } from "../state/state-types.js";
|
|
6
|
+
import type { FileMergePlan } from "./file-merge-planner.js";
|
|
7
|
+
import { mergeSettingsJson } from "./settings-merge.js";
|
|
8
|
+
|
|
9
|
+
export async function resolveSettingsConflicts(
|
|
10
|
+
config: AnySyncConfig,
|
|
11
|
+
state: SyncState,
|
|
12
|
+
local: Snapshot,
|
|
13
|
+
remote: Snapshot,
|
|
14
|
+
plan: FileMergePlan,
|
|
15
|
+
): Promise<{ plan: FileMergePlan; fields: string[] }> {
|
|
16
|
+
if (plan.kind !== "planned") return { plan, fields: [] };
|
|
17
|
+
const fields: string[] = [];
|
|
18
|
+
const decisions = [...plan.decisions];
|
|
19
|
+
for (let index = 0; index < decisions.length; index++) {
|
|
20
|
+
const decision = decisions[index];
|
|
21
|
+
if (decision?.kind !== "conflict" || decision.reason !== "both-changed" || decision.path !== "settings.json")
|
|
22
|
+
continue;
|
|
23
|
+
const left = local.files.find((file) => file.path === decision.path);
|
|
24
|
+
const right = remote.files.find((file) => file.path === decision.path);
|
|
25
|
+
if (!left || !right) continue; // File deletion/addition is not a JSON field merge.
|
|
26
|
+
const ancestor = await readMergeAncestor(config, state, decision.path);
|
|
27
|
+
if (!ancestor) continue;
|
|
28
|
+
const merged = mergeSettingsJson(
|
|
29
|
+
ancestor,
|
|
30
|
+
Buffer.from(left.contentBase64, "base64"),
|
|
31
|
+
Buffer.from(right.contentBase64, "base64"),
|
|
32
|
+
);
|
|
33
|
+
if (merged.kind === "review") {
|
|
34
|
+
fields.push(...merged.fields);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
decisions[index] = {
|
|
38
|
+
kind: "accepted",
|
|
39
|
+
path: decision.path,
|
|
40
|
+
source: "merged",
|
|
41
|
+
file: {
|
|
42
|
+
path: decision.path,
|
|
43
|
+
contentBase64: merged.content.toString("base64"),
|
|
44
|
+
sha256: createHash("sha256").update(merged.content).digest("hex"),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
plan: { kind: "planned", decisions, conflicts: decisions.filter((decision) => decision.kind === "conflict") },
|
|
50
|
+
fields,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JsonDocument,
|
|
3
|
+
type JsonNode,
|
|
4
|
+
jsonEqual,
|
|
5
|
+
parseSettingsDocument,
|
|
6
|
+
renderSettingsMembers,
|
|
7
|
+
} from "./json-document.js";
|
|
8
|
+
|
|
9
|
+
export type SettingsMergeResult =
|
|
10
|
+
| { kind: "merged"; content: Buffer }
|
|
11
|
+
| { kind: "review"; reason: "unsupported-format" | "field-conflict"; fields: string[] };
|
|
12
|
+
|
|
13
|
+
const COUPLED_FIELDS = [
|
|
14
|
+
["defaultProvider", "defaultModel"],
|
|
15
|
+
["queueMode", "steeringMode"],
|
|
16
|
+
["websockets", "transport"],
|
|
17
|
+
["skills", "enableSkillCommands"],
|
|
18
|
+
["enableAnalytics", "trackingId"],
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** Only global settings.json; objects/arrays are atomic units until their invariants are proven. */
|
|
22
|
+
export function mergeSettingsJson(ancestor: Buffer, local: Buffer, remote: Buffer): SettingsMergeResult {
|
|
23
|
+
let documents: JsonDocument[];
|
|
24
|
+
try {
|
|
25
|
+
documents = [ancestor, local, remote].map(parseSettingsDocument);
|
|
26
|
+
} catch {
|
|
27
|
+
return { kind: "review", reason: "unsupported-format", fields: [] };
|
|
28
|
+
}
|
|
29
|
+
const [base, left, right] = documents as [JsonDocument, JsonDocument, JsonDocument];
|
|
30
|
+
const selected = new Map<string, { document: JsonDocument; node: JsonNode }>();
|
|
31
|
+
const fields = new Set(documents.flatMap((document) => [...document.root.members.keys()]));
|
|
32
|
+
const units: string[][] = COUPLED_FIELDS.map((group) => [...group]);
|
|
33
|
+
const coupled = new Set(units.flat());
|
|
34
|
+
units.push(...[...fields].filter((field) => !coupled.has(field)).map((field) => [field]));
|
|
35
|
+
const conflicts: string[] = [];
|
|
36
|
+
for (const unit of units) {
|
|
37
|
+
const equal = (first: JsonDocument, second: JsonDocument) =>
|
|
38
|
+
unit.every((field) =>
|
|
39
|
+
jsonEqual(first.root.members.get(field)?.node.value, second.root.members.get(field)?.node.value),
|
|
40
|
+
);
|
|
41
|
+
const source = equal(left, right) ? left : equal(left, base) ? right : equal(right, base) ? left : undefined;
|
|
42
|
+
if (!source) {
|
|
43
|
+
conflicts.push(...unit.filter((field) => fields.has(field)));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const field of unit) {
|
|
47
|
+
const member = source.root.members.get(field);
|
|
48
|
+
if (member) selected.set(field, { document: source, node: member.node });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (conflicts.length) return { kind: "review", reason: "field-conflict", fields: conflicts };
|
|
52
|
+
try {
|
|
53
|
+
return { kind: "merged", content: renderSettingsMembers(left, selected) };
|
|
54
|
+
} catch {
|
|
55
|
+
return { kind: "review", reason: "unsupported-format", fields: [] };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -20,16 +20,40 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
20
20
|
controller: AbortController;
|
|
21
21
|
promise: Promise<void>;
|
|
22
22
|
owner: ExtensionContext["sessionManager"];
|
|
23
|
+
transfer: boolean;
|
|
23
24
|
}
|
|
24
25
|
| undefined;
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
let pending: { ctx: ExtensionContext; signal: AbortSignal; config: AnySyncConfig } | undefined;
|
|
28
|
+
|
|
29
|
+
const controllerApi = {
|
|
30
|
+
async interrupt(ctx: ExtensionContext) {
|
|
31
|
+
const task = active;
|
|
32
|
+
if (!task?.transfer || task.owner !== ctx.sessionManager) return;
|
|
33
|
+
task.controller.abort(new DOMException("Agent became busy", "AbortError"));
|
|
34
|
+
await task.promise;
|
|
35
|
+
},
|
|
36
|
+
settled(ctx: ExtensionContext) {
|
|
37
|
+
const queued = pending;
|
|
38
|
+
if (!queued || queued.ctx.sessionManager !== ctx.sessionManager || queued.signal.aborted || !ctx.isIdle()) return;
|
|
39
|
+
pending = undefined;
|
|
40
|
+
controllerApi.start(ctx, queued.signal, queued.config);
|
|
41
|
+
},
|
|
27
42
|
start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
|
|
28
43
|
if (!ctx.hasUI || sessionSignal.aborted) return;
|
|
44
|
+
if (initialConfig?.automaticTransfer && !ctx.isIdle()) {
|
|
45
|
+
pending = { ctx, signal: sessionSignal, config: initialConfig };
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
29
48
|
if (active) throw new Error("Previous startup check has not been drained.");
|
|
30
49
|
const controller = new AbortController();
|
|
31
50
|
const signal = combineSignals(sessionSignal, controller.signal);
|
|
32
|
-
const task = {
|
|
51
|
+
const task = {
|
|
52
|
+
controller,
|
|
53
|
+
owner: ctx.sessionManager,
|
|
54
|
+
promise: Promise.resolve(),
|
|
55
|
+
transfer: Boolean(initialConfig?.automaticTransfer),
|
|
56
|
+
};
|
|
33
57
|
active = task;
|
|
34
58
|
const isCurrent = () => !sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
|
|
35
59
|
const timer = setTimeout(
|
|
@@ -44,8 +68,9 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
44
68
|
try {
|
|
45
69
|
throwIfAborted(signal);
|
|
46
70
|
config = initialConfig ?? (await loadConfigForCheck());
|
|
71
|
+
task.transfer = Boolean(config.automaticTransfer);
|
|
47
72
|
throwIfAborted(signal);
|
|
48
|
-
if (!config.automatic) return;
|
|
73
|
+
if (!config.automatic && !config.automaticTransfer) return;
|
|
49
74
|
const captured = config;
|
|
50
75
|
const identity = syncCheckConfigFingerprint(captured);
|
|
51
76
|
checking = true;
|
|
@@ -76,6 +101,47 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
76
101
|
configIdentity: identity,
|
|
77
102
|
checkedAt: new Date().toISOString(),
|
|
78
103
|
inspection,
|
|
104
|
+
automaticTransfer: captured.automaticTransfer,
|
|
105
|
+
});
|
|
106
|
+
if (
|
|
107
|
+
captured.automaticTransfer &&
|
|
108
|
+
!inspection.firstSync &&
|
|
109
|
+
inspection.head &&
|
|
110
|
+
inspection.selectionState?.kind === "same"
|
|
111
|
+
) {
|
|
112
|
+
const operations = await loaders.operations();
|
|
113
|
+
throwIfAborted(signal);
|
|
114
|
+
if (!isCurrent()) return;
|
|
115
|
+
if (!ctx.isIdle()) {
|
|
116
|
+
pending = { ctx, signal: sessionSignal, config: captured };
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await withStateDirectoryAccess(() =>
|
|
120
|
+
withLock("startup-transfer", () =>
|
|
121
|
+
operations.syncBoth(ctx, {
|
|
122
|
+
args: [],
|
|
123
|
+
yes: true,
|
|
124
|
+
force: false,
|
|
125
|
+
stale: false,
|
|
126
|
+
silent: true,
|
|
127
|
+
reload: false,
|
|
128
|
+
auto: true,
|
|
129
|
+
signal,
|
|
130
|
+
onCommit: () => attention.clearObservation(),
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
throwIfAborted(signal);
|
|
135
|
+
if (!isCurrent()) return;
|
|
136
|
+
attention.clearObservation();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
attention.observe({
|
|
140
|
+
setupName: captured.setupName,
|
|
141
|
+
configIdentity: identity,
|
|
142
|
+
checkedAt: new Date().toISOString(),
|
|
143
|
+
inspection,
|
|
144
|
+
automaticTransfer: captured.automaticTransfer,
|
|
79
145
|
});
|
|
80
146
|
if (ctx.mode === "rpc") attention.notifyObservation(ctx);
|
|
81
147
|
} catch (error) {
|
|
@@ -88,12 +154,29 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
88
154
|
if (!latest || syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)) return;
|
|
89
155
|
}
|
|
90
156
|
ctx.ui.notify(
|
|
91
|
-
|
|
157
|
+
config?.automaticTransfer
|
|
158
|
+
? `pi-sync automatic transfer stopped: ${safeTerminalText(errorMessage(error))}. Run /sync to review or reconcile retained recovery evidence. No resources were reloaded.`
|
|
159
|
+
: `pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
|
|
92
160
|
"warning",
|
|
93
161
|
);
|
|
94
162
|
} finally {
|
|
95
163
|
clearTimeout(timer);
|
|
96
|
-
if (isCurrent() && checking)
|
|
164
|
+
if (isCurrent() && checking) {
|
|
165
|
+
if (!signal.aborted || controller.signal.reason?.name === "TimeoutError") {
|
|
166
|
+
const latest = await loadConfigForCheck().catch(() => undefined);
|
|
167
|
+
if (isCurrent()) {
|
|
168
|
+
if (signal.aborted && controller.signal.reason?.name !== "TimeoutError")
|
|
169
|
+
setSyncStatus(ctx, undefined);
|
|
170
|
+
else if (
|
|
171
|
+
config &&
|
|
172
|
+
latest &&
|
|
173
|
+
syncCheckConfigFingerprint(latest) === syncCheckConfigFingerprint(config)
|
|
174
|
+
)
|
|
175
|
+
await attention.publish(ctx, sessionSignal);
|
|
176
|
+
else attention.reset(ctx);
|
|
177
|
+
}
|
|
178
|
+
} else setSyncStatus(ctx, undefined);
|
|
179
|
+
}
|
|
97
180
|
if (active === task) active = undefined;
|
|
98
181
|
}
|
|
99
182
|
})
|
|
@@ -104,6 +187,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
104
187
|
});
|
|
105
188
|
},
|
|
106
189
|
async stop() {
|
|
190
|
+
pending = undefined;
|
|
107
191
|
const task = active;
|
|
108
192
|
if (!task) return;
|
|
109
193
|
task.controller.abort(new DOMException("Startup check cancelled", "AbortError"));
|
|
@@ -111,4 +195,5 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
|
|
|
111
195
|
await task.promise;
|
|
112
196
|
},
|
|
113
197
|
};
|
|
198
|
+
return controllerApi;
|
|
114
199
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { agentDir, sessionDirFromContext } from "../snapshot/session-paths.js";
|
|
5
|
+
import { createSnapshot, sessionSnapshotPathFromAbsolute } from "../snapshot/snapshot.js";
|
|
6
|
+
import { encodeSnapshot } from "../snapshot/snapshot-codec.js";
|
|
7
|
+
import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
|
|
8
|
+
import { syncDirectory } from "../state/json-file.js";
|
|
9
|
+
import { stateDir } from "../state/state-directory.js";
|
|
10
|
+
import { throwIfAborted } from "./signals.js";
|
|
11
|
+
|
|
12
|
+
export function protectedSessionPaths(
|
|
13
|
+
ctx: ExtensionCommandContext | ExtensionContext,
|
|
14
|
+
sessionRoot = sessionDirFromContext(ctx),
|
|
15
|
+
) {
|
|
16
|
+
const getSessionFile = ctx.sessionManager.getSessionFile;
|
|
17
|
+
if (typeof getSessionFile !== "function") return new Set<string>();
|
|
18
|
+
const sessionFile = getSessionFile.call(ctx.sessionManager) as string | undefined;
|
|
19
|
+
const snapshotPath = sessionFile ? sessionSnapshotPathFromAbsolute(sessionFile, sessionRoot) : undefined;
|
|
20
|
+
return snapshotPath ? new Set([snapshotPath]) : new Set<string>();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function captureMutationOwner(ctx: ExtensionContext | ExtensionCommandContext, signal?: AbortSignal) {
|
|
24
|
+
const manager = ctx.sessionManager;
|
|
25
|
+
const file = manager.getSessionFile?.();
|
|
26
|
+
const id = manager.getSessionId?.();
|
|
27
|
+
const cwd = manager.getCwd?.();
|
|
28
|
+
const root = agentDir();
|
|
29
|
+
return () => {
|
|
30
|
+
throwIfAborted(signal);
|
|
31
|
+
if (
|
|
32
|
+
ctx.sessionManager !== manager ||
|
|
33
|
+
manager.getSessionFile?.() !== file ||
|
|
34
|
+
manager.getSessionId?.() !== id ||
|
|
35
|
+
manager.getCwd?.() !== cwd ||
|
|
36
|
+
agentDir() !== root
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Session changed during file mutation; evidence retained for review.");
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function backupLocal(profile: string, options: SnapshotOptions = {}, signal?: AbortSignal) {
|
|
43
|
+
throwIfAborted(signal);
|
|
44
|
+
const snapshot = await createSnapshot(profile, { ...options, signal });
|
|
45
|
+
throwIfAborted(signal);
|
|
46
|
+
const backupDirectory = path.join(stateDir(), "backups");
|
|
47
|
+
await fs.mkdir(backupDirectory, { recursive: true, mode: 0o700 });
|
|
48
|
+
throwIfAborted(signal);
|
|
49
|
+
const backupPath = path.join(backupDirectory, `${snapshot.id}.json.gz`);
|
|
50
|
+
const encoded = await encodeSnapshot(snapshot);
|
|
51
|
+
throwIfAborted(signal);
|
|
52
|
+
const handle = await fs.open(backupPath, "wx", 0o600);
|
|
53
|
+
try {
|
|
54
|
+
await handle.writeFile(encoded);
|
|
55
|
+
await handle.sync();
|
|
56
|
+
} finally {
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
await syncDirectory(backupDirectory);
|
|
60
|
+
return backupPath;
|
|
61
|
+
}
|