@narumitw/pi-sync 0.49.5 → 0.49.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -28
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/index.ts +1 -1
- package/src/manager-attention.ts +81 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +50 -3
- package/src/manager-ui.ts +70 -53
- package/src/remote-selection-ui.ts +541 -142
- package/src/remote-snapshot.ts +3 -2
- package/src/settings-management.ts +9 -2
- package/src/settings-ui.ts +21 -5
- package/src/setup-switch.ts +3 -2
- package/src/snapshot-paths.ts +13 -0
- package/src/snapshot-transaction.ts +1 -1
- package/src/snapshot.ts +2 -12
- package/src/sync-attention.ts +100 -0
- package/src/sync-decision.ts +5 -13
- package/src/sync-errors.ts +23 -0
- package/src/sync-extension.ts +765 -0
- package/src/sync-format.ts +3 -4
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
- package/src/sync.ts +1 -512
package/src/remote-snapshot.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { syncConfigReviewFingerprint } from "./config.js";
|
|
1
2
|
import type { RemoteHead, SyncBackend } from "./sync-backend.js";
|
|
2
3
|
import { safeTerminalText } from "./sync-format.js";
|
|
3
4
|
import {
|
|
4
5
|
inspectRemoteSelection,
|
|
5
|
-
RemoteSelectionMismatchError,
|
|
6
6
|
type RemoteSelectionState,
|
|
7
|
+
remoteSelectionMismatch,
|
|
7
8
|
sameSyncInclude,
|
|
8
9
|
snapshotSelectionInclude,
|
|
9
10
|
} from "./sync-policy.js";
|
|
@@ -37,7 +38,7 @@ export async function readSnapshotForHead(
|
|
|
37
38
|
export function requireCompatibleRemoteSelection(config: AnySyncConfig, snapshot: Snapshot) {
|
|
38
39
|
const state = inspectRemoteSelection(config.include, snapshot);
|
|
39
40
|
if (state.kind === "different") {
|
|
40
|
-
throw
|
|
41
|
+
throw remoteSelectionMismatch(config, state.include, syncConfigReviewFingerprint(config));
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -9,6 +9,13 @@ import {
|
|
|
9
9
|
} from "./config.js";
|
|
10
10
|
import type { PiSyncSettingsV3, StorageConnectionSettings, SyncSetupSettings } from "./types.js";
|
|
11
11
|
|
|
12
|
+
export class SyncSetupReviewChangedError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SyncSetupReviewChangedError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export async function saveNewV3Settings(
|
|
13
20
|
input: {
|
|
14
21
|
setupName: string;
|
|
@@ -119,7 +126,7 @@ export async function updateSyncSetup(
|
|
|
119
126
|
options.expectedInclude &&
|
|
120
127
|
!sameNames(normalizeSyncInclude(setup.sync.include), options.expectedInclude)
|
|
121
128
|
) {
|
|
122
|
-
throw new
|
|
129
|
+
throw new SyncSetupReviewChangedError(
|
|
123
130
|
`Sync setup “${name}” included content changed while it was open; reopen it and review the current selection.`,
|
|
124
131
|
);
|
|
125
132
|
}
|
|
@@ -133,7 +140,7 @@ export async function updateSyncSetup(
|
|
|
133
140
|
options.expectedStorage,
|
|
134
141
|
)
|
|
135
142
|
) {
|
|
136
|
-
throw new
|
|
143
|
+
throw new SyncSetupReviewChangedError(
|
|
137
144
|
`Sync setup “${name}” storage changed while it was open; reopen it and review the current storage location.`,
|
|
138
145
|
);
|
|
139
146
|
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
3
|
import type { RunRoute } from "./cancellable-operation.js";
|
|
4
4
|
import { loadConfig, localConfigPath } from "./config.js";
|
|
5
|
+
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
5
6
|
import { updateSyncSetup } from "./settings-management.js";
|
|
6
7
|
import {
|
|
7
8
|
SETUP_SWITCH_ACTION_OPTIONS,
|
|
@@ -67,9 +68,8 @@ export async function showSyncSettings(
|
|
|
67
68
|
},
|
|
68
69
|
{
|
|
69
70
|
id: "remoteInclude",
|
|
70
|
-
label: "
|
|
71
|
-
description:
|
|
72
|
-
"Compare portable remote selection, then adopt it or keep local settings without pulling files.",
|
|
71
|
+
label: "Compare synced content",
|
|
72
|
+
description: "Review this device and remote content lists before choosing either one.",
|
|
73
73
|
currentValue: "Review",
|
|
74
74
|
action: "remote-include",
|
|
75
75
|
},
|
|
@@ -126,8 +126,24 @@ export async function showSyncSettings(
|
|
|
126
126
|
const reviewSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
127
127
|
const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
|
|
128
128
|
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
129
|
-
await showRemoteSelectionReview(ctx, setupName, reviewSignal
|
|
130
|
-
|
|
129
|
+
const review = await showRemoteSelectionReview(ctx, setupName, reviewSignal, undefined, {
|
|
130
|
+
origin: "settings",
|
|
131
|
+
runRoute,
|
|
132
|
+
});
|
|
133
|
+
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
134
|
+
if (review.kind === "route-result") {
|
|
135
|
+
const disposition = await dispatchManagerResult(
|
|
136
|
+
ctx,
|
|
137
|
+
review.result,
|
|
138
|
+
review.route,
|
|
139
|
+
runRoute,
|
|
140
|
+
reviewSignal,
|
|
141
|
+
);
|
|
142
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
143
|
+
}
|
|
144
|
+
return review.kind === "closed" || review.kind === "stale"
|
|
145
|
+
? { kind: "close" }
|
|
146
|
+
: { kind: "stay" };
|
|
131
147
|
},
|
|
132
148
|
},
|
|
133
149
|
});
|
package/src/setup-switch.ts
CHANGED
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
syncSetupReviewIdentity,
|
|
9
9
|
updateLocalConfig,
|
|
10
10
|
} from "./config.js";
|
|
11
|
+
import { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
11
12
|
import { safeTerminalText } from "./sync-format.js";
|
|
12
13
|
import type { OnSwitchAction } from "./types.js";
|
|
13
14
|
|
|
15
|
+
export { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
16
|
+
|
|
14
17
|
export const SETUP_SWITCH_ACTION_OPTIONS: ReadonlyArray<{
|
|
15
18
|
label: string;
|
|
16
19
|
value: OnSwitchAction;
|
|
@@ -34,8 +37,6 @@ export async function saveOnSwitch(action: OnSwitchAction, signal?: AbortSignal)
|
|
|
34
37
|
|
|
35
38
|
export type SetupPullOutcome = "applied" | "cancelled";
|
|
36
39
|
|
|
37
|
-
export class SetupPullRequiresUiError extends Error {}
|
|
38
|
-
|
|
39
40
|
export interface SetupSwitchResult {
|
|
40
41
|
pullApplied: boolean;
|
|
41
42
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
function expandHome(value: string) {
|
|
4
|
+
if (value === "~") return process.env.HOME ?? value;
|
|
5
|
+
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
|
|
10
|
+
return configuredSessionDir
|
|
11
|
+
? path.resolve(expandHome(configuredSessionDir))
|
|
12
|
+
: path.resolve(root, "sessions");
|
|
13
|
+
}
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { agentDir, stateDir } from "./config.js";
|
|
5
5
|
import { withLock } from "./lock.js";
|
|
6
6
|
import { assertWithinRoot, isPathInside } from "./paths.js";
|
|
7
|
-
import { sessionStorageRoot } from "./snapshot.js";
|
|
7
|
+
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
8
8
|
import type { SnapshotApplyPlan } from "./types.js";
|
|
9
9
|
|
|
10
10
|
const JOURNAL_VERSION = 1;
|
package/src/snapshot.ts
CHANGED
|
@@ -5,8 +5,10 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { agentDir, configuredSessionDir } from "./config.js";
|
|
7
7
|
import { isDeniedPath, posixJoin, safeJoin, toPosix } from "./paths.js";
|
|
8
|
+
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
8
9
|
|
|
9
10
|
export { isDeniedPath } from "./paths.js";
|
|
11
|
+
export { sessionStorageRoot } from "./snapshot-paths.js";
|
|
10
12
|
|
|
11
13
|
import {
|
|
12
14
|
canonicalSnapshotPathForConfig,
|
|
@@ -39,12 +41,6 @@ const SECRET_PATTERNS = [
|
|
|
39
41
|
/gh[pousr]_[A-Za-z0-9_]{20,}/,
|
|
40
42
|
];
|
|
41
43
|
|
|
42
|
-
function expandHome(value: string) {
|
|
43
|
-
if (value === "~") return process.env.HOME ?? value;
|
|
44
|
-
if (value.startsWith("~/")) return path.join(process.env.HOME ?? "~", value.slice(2));
|
|
45
|
-
return value;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
44
|
function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
|
|
49
45
|
const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
|
|
50
46
|
if (exact) return exact;
|
|
@@ -223,12 +219,6 @@ export function isSessionFilePath(relativePath: string) {
|
|
|
223
219
|
return isSessionPath(normalized) && normalized.endsWith(".jsonl");
|
|
224
220
|
}
|
|
225
221
|
|
|
226
|
-
export function sessionStorageRoot(root: string, configuredSessionDir?: string) {
|
|
227
|
-
return configuredSessionDir
|
|
228
|
-
? path.resolve(expandHome(configuredSessionDir))
|
|
229
|
-
: path.resolve(root, "sessions");
|
|
230
|
-
}
|
|
231
|
-
|
|
232
222
|
export function sessionSnapshotPathFromAbsolute(
|
|
233
223
|
sessionFile: string,
|
|
234
224
|
configuredSessionDir?: string,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { syncConfigReviewFingerprint } from "./config.js";
|
|
4
|
+
import { safeTerminalText } from "./sync-format.js";
|
|
5
|
+
import {
|
|
6
|
+
compareSyncInclude,
|
|
7
|
+
type RemoteSelectionDecision,
|
|
8
|
+
sameSyncInclude,
|
|
9
|
+
} from "./sync-policy.js";
|
|
10
|
+
import type { AnySyncConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const STATUS_KEY = "sync";
|
|
13
|
+
const WIDGET_KEY = "sync:attention";
|
|
14
|
+
|
|
15
|
+
export type SyncAttentionOrigin = "sync" | "pull" | "push";
|
|
16
|
+
|
|
17
|
+
export interface SyncAttentionState {
|
|
18
|
+
decision: RemoteSelectionDecision;
|
|
19
|
+
origin: SyncAttentionOrigin;
|
|
20
|
+
offered: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function syncAttentionMatchesConfig(attention: SyncAttentionState, config: AnySyncConfig) {
|
|
24
|
+
return (
|
|
25
|
+
attention.decision.setupName === config.setupName &&
|
|
26
|
+
attention.decision.configIdentity === syncConfigReviewFingerprint(config) &&
|
|
27
|
+
sameSyncInclude(attention.decision.localInclude, config.include)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncAttentionController {
|
|
32
|
+
set(decision: RemoteSelectionDecision, origin: SyncAttentionOrigin): void;
|
|
33
|
+
current(): SyncAttentionState | undefined;
|
|
34
|
+
markOffered(): boolean;
|
|
35
|
+
clear(ctx: ExtensionContext): void;
|
|
36
|
+
reset(ctx: ExtensionContext): void;
|
|
37
|
+
publish(ctx: ExtensionContext): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSyncAttentionController(): SyncAttentionController {
|
|
41
|
+
let state: SyncAttentionState | undefined;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
set(decision, origin) {
|
|
45
|
+
state = { decision, origin, offered: false };
|
|
46
|
+
},
|
|
47
|
+
current() {
|
|
48
|
+
return state;
|
|
49
|
+
},
|
|
50
|
+
markOffered() {
|
|
51
|
+
if (!state || state.offered) return false;
|
|
52
|
+
state = { ...state, offered: true };
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
clear(ctx) {
|
|
56
|
+
state = undefined;
|
|
57
|
+
clearAttentionPresentation(ctx);
|
|
58
|
+
},
|
|
59
|
+
reset(ctx) {
|
|
60
|
+
state = undefined;
|
|
61
|
+
clearAttentionPresentation(ctx);
|
|
62
|
+
},
|
|
63
|
+
publish(ctx) {
|
|
64
|
+
if (!state) {
|
|
65
|
+
clearAttentionPresentation(ctx);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const presentation = attentionPresentation(state.decision);
|
|
69
|
+
ctx.ui.setStatus(STATUS_KEY, presentation.status);
|
|
70
|
+
if (ctx.mode !== "tui") return;
|
|
71
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
|
72
|
+
render(width: number) {
|
|
73
|
+
const safeWidth = Math.max(1, width);
|
|
74
|
+
return presentation.lines.map((line, index) =>
|
|
75
|
+
truncateToWidth(theme.fg(index === 0 ? "warning" : "muted", line), safeWidth, "…"),
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
invalidate() {},
|
|
79
|
+
}));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function attentionPresentation(decision: RemoteSelectionDecision) {
|
|
85
|
+
const setupName = safeTerminalText(decision.setupName);
|
|
86
|
+
const comparison = compareSyncInclude(decision.localInclude, decision.remoteInclude);
|
|
87
|
+
const difference =
|
|
88
|
+
comparison.remoteOnly.length === 0 && comparison.localOnly.length === 0
|
|
89
|
+
? "Only list order differs"
|
|
90
|
+
: `Remote ${comparison.remoteOnly.length} · Device ${comparison.localOnly.length}`;
|
|
91
|
+
return {
|
|
92
|
+
status: "review needed",
|
|
93
|
+
lines: [`Pi Sync needs review · ${setupName}`, difference, "No changes · Run /sync to review"],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearAttentionPresentation(ctx: ExtensionContext) {
|
|
98
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
99
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
100
|
+
}
|
package/src/sync-decision.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { syncConfigReviewIdentity } from "./config.js";
|
|
2
|
+
import { SyncDecisionRequiredError } from "./sync-errors.js";
|
|
2
3
|
import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
|
|
3
4
|
import { includeFromSelectionConfig } from "./sync-policy.js";
|
|
4
5
|
import { syncPolicyChanged } from "./sync-state.js";
|
|
@@ -29,15 +30,10 @@ export interface SyncDecision {
|
|
|
29
30
|
directMessage: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
super(decision.directMessage);
|
|
37
|
-
this.name = "SyncDecisionRequiredError";
|
|
38
|
-
this.decision = decision;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
33
|
+
export {
|
|
34
|
+
isSyncDecisionRequiredError,
|
|
35
|
+
SyncDecisionRequiredError,
|
|
36
|
+
} from "./sync-errors.js";
|
|
41
37
|
|
|
42
38
|
interface CreateSyncDecisionOptions {
|
|
43
39
|
kind: SyncDecisionKind;
|
|
@@ -104,10 +100,6 @@ export function createSyncDecision(options: CreateSyncDecisionOptions) {
|
|
|
104
100
|
});
|
|
105
101
|
}
|
|
106
102
|
|
|
107
|
-
export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
|
|
108
|
-
return error instanceof SyncDecisionRequiredError;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
103
|
function safeList(values: readonly string[]) {
|
|
112
104
|
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
113
105
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SyncDecision } from "./sync-decision.js";
|
|
2
|
+
|
|
3
|
+
export class SetupPullRequiresUiError extends Error {}
|
|
4
|
+
|
|
5
|
+
export class SyncDecisionRequiredError extends Error {
|
|
6
|
+
readonly decision: SyncDecision;
|
|
7
|
+
|
|
8
|
+
constructor(decision: SyncDecision) {
|
|
9
|
+
super(decision.directMessage);
|
|
10
|
+
this.name = "SyncDecisionRequiredError";
|
|
11
|
+
this.decision = decision;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSyncDecisionRequiredError(error: unknown): error is SyncDecisionRequiredError {
|
|
16
|
+
return error instanceof SyncDecisionRequiredError;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function errorMessage(error: unknown) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
|
|
22
|
+
return message.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
|
|
23
|
+
}
|