@narumitw/pi-sync 0.49.6 → 0.49.9
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 +77 -26
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/lock.ts +26 -2
- package/src/manager-attention.ts +81 -0
- package/src/manager-recovery.ts +65 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +145 -52
- package/src/manager-ui.ts +130 -75
- package/src/operation-availability.ts +64 -0
- 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/sync-attention.ts +100 -0
- package/src/sync-extension.ts +203 -5
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
package/README.md
CHANGED
|
@@ -54,22 +54,57 @@ Remote status: Not checked
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
57
|
-
**More…**.
|
|
58
|
-
|
|
57
|
+
**More…**. When pi-sync has already detected a synced-content-list mismatch, the manager instead
|
|
58
|
+
shows **Sync status: Review needed**, puts **Review synced content (recommended)** first, and keeps
|
|
59
|
+
**Sync now** visibly unavailable until the choice is reviewed. Opening the manager still performs no
|
|
60
|
+
new remote request. The standard manager also owns setup and connection lists/details plus history
|
|
61
|
+
and recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
|
|
59
62
|
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
60
63
|
and confirmations.
|
|
61
64
|
|
|
65
|
+
### Restore sync access
|
|
66
|
+
|
|
67
|
+
When an operation is currently running, the manager shows its command and process ID, keeps sync and
|
|
68
|
+
settings changes unavailable, and puts **Refresh operation status** first.
|
|
69
|
+
When lock metadata is still protected by an active guard, the manager explains that another pi-sync
|
|
70
|
+
process may be starting or finishing and asks the user to wait before refreshing.
|
|
71
|
+
It never offers lock removal while an owner or guard may still be active.
|
|
72
|
+
|
|
73
|
+
When a previous operation appears to have stopped without releasing its local lock, the manager shows
|
|
74
|
+
**Sync paused** and puts **Restore sync access… (recommended)** first instead of hiding recovery
|
|
75
|
+
inside **History & recovery…**.
|
|
76
|
+
Unreadable metadata receives a stronger warning because pi-sync cannot verify who owns it.
|
|
77
|
+
Close every other Pi session that may still be syncing, review the local-lock-only confirmation, then
|
|
78
|
+
choose **Remove local lock and continue**.
|
|
79
|
+
The guarded recovery path rechecks metadata and ownership before removal, changes no settings, local
|
|
80
|
+
files, sync state, or remote data, and returns directly to the normal manager when access is restored.
|
|
81
|
+
Cancellation leaves the lock unchanged.
|
|
82
|
+
If ownership changes or a guard is still expiring, pi-sync refuses removal and keeps actionable status
|
|
83
|
+
available for refresh or retry.
|
|
84
|
+
The deterministic fallback remains `/sync unlock --stale` after the same no-other-sync verification.
|
|
85
|
+
|
|
62
86
|
### Resolve conflicts in the manager
|
|
63
87
|
|
|
64
|
-
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only error.
|
|
89
|
+
The flow names the current sync setup and explains whether local content, remote content, or the included-content policy changed.
|
|
90
|
+
|
|
91
|
+
An explicit remote content-list mismatch now opens **Synced content differs** inside the same manager flow.
|
|
92
|
+
Nothing changes until you choose an action.
|
|
93
|
+
Choose **Review all paths (recommended)** first to compare exact remote-only paths, device-only paths, and both ordered lists.
|
|
94
|
+
If membership matches but order differs, the review says that only ordering differs.
|
|
95
|
+
Then choose one action:
|
|
96
|
+
|
|
97
|
+
- **Use remote content list** revalidates the remote snapshot and reviewed local setup before saving only `sync.include`.
|
|
98
|
+
**Remote content list saved** confirms that no files were pulled and offers a separate **Continue Sync/Pull/Push now…** action plus **Done**.
|
|
99
|
+
Continue starts a fresh operation and exact preview for the captured setup rather than resuming stale work.
|
|
100
|
+
- **Keep this device's content list and update remote…** opens the existing `push --force` preparation and exact confirmation without `--yes`.
|
|
101
|
+
Cancelling preparation or confirmation returns to the content-list choice without changing remote data.
|
|
102
|
+
- **Cancel** returns to the manager without changing settings, files, remote data, or sync state.
|
|
103
|
+
- **Later** appears when automatic startup sync detected the mismatch and returns immediately to the
|
|
104
|
+
Pi editor without changing anything.
|
|
70
105
|
|
|
71
|
-
Choose **Review differences (recommended)** to inspect the exact affected paths without changing
|
|
72
|
-
|
|
106
|
+
Choose **Review differences (recommended)** in an ordinary file-direction conflict to inspect the exact affected paths without changing local files, remote data, or sync state.
|
|
107
|
+
Then choose one reviewed direction:
|
|
73
108
|
|
|
74
109
|
- **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
|
|
75
110
|
local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
|
|
@@ -79,10 +114,22 @@ local files, remote data, or sync state. Then choose one reviewed direction:
|
|
|
79
114
|
- First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
|
|
80
115
|
empty remote offers **Push local content…** only.
|
|
81
116
|
|
|
82
|
-
Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
|
|
83
|
-
returns to the sync manager
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
|
|
118
|
+
Back returns to the sync manager, and Ctrl+C closes the complete flow.
|
|
119
|
+
|
|
120
|
+
When automatic startup sync detects an explicit content-list mismatch in TUI mode, it opens
|
|
121
|
+
**Synced content differs** once for that session instead of ending at a warning. Choosing **Later** or
|
|
122
|
+
closing the flow leaves a compact **Pi Sync needs review** widget above the editor and a
|
|
123
|
+
**review needed** footer status. The attention state is in memory only, contains no credentials or
|
|
124
|
+
file contents, and is revalidated before any settings or remote mutation. It clears after verified
|
|
125
|
+
resolution, invalidation, session replacement, or shutdown.
|
|
126
|
+
|
|
127
|
+
Interactive TUI `/sync sync`, `/sync pull`, and `/sync push` routes without `--yes` open the same
|
|
128
|
+
review flow when they detect the mismatch. Explicit `--yes` routes remain non-interactive and report
|
|
129
|
+
exact remote-only, device-only, or order-only guidance while leaving visible attention for later
|
|
130
|
+
review. Shutdown automatic sync never opens a dialog because Pi is exiting.
|
|
131
|
+
RPC startup and direct routes remain read-only and notification-based, while print and JSON modes
|
|
132
|
+
remain unsupported for `/sync` because their UI output is not observable.
|
|
86
133
|
|
|
87
134
|
## ⚙️ Settings
|
|
88
135
|
|
|
@@ -203,17 +250,16 @@ remote snapshot. Leaving the editor opens an exact Include/Exclude review with *
|
|
|
203
250
|
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
204
251
|
the manual `sync.include` path.
|
|
205
252
|
|
|
206
|
-
Every new snapshot stores the normalized included-content selection separately from the files that
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
the
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
than silently expanding local scope. Status reports matches and exact local-only/remote-only paths.
|
|
253
|
+
Every new snapshot stores the normalized included-content selection separately from the files that happened to exist.
|
|
254
|
+
This preserves selected-but-missing paths without syncing `pi-sync.json`, storage credentials, automatic-sync preferences, or setup names.
|
|
255
|
+
**Settings → Compare synced content** opens the same review-first flow as a manager operation when local and remote lists differ.
|
|
256
|
+
Adoption revalidates the remote head, immutable snapshot, reviewed storage coordinates, and local include list before one atomic settings update.
|
|
257
|
+
The saved state changes only `sync.include`, preserves unknown settings fields, and never pulls files or writes sync state.
|
|
258
|
+
Its explicit Continue action starts a fresh **Sync now** route, while **Done** leaves the reviewed settings change saved without implying a file operation.
|
|
259
|
+
Keeping this device's list opens the reviewed force-push path and explicitly replaces the remote policy while preserving eligible unmanaged remote files.
|
|
260
|
+
|
|
261
|
+
Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather than silently expanding local scope.
|
|
262
|
+
Status reports matches and exact local-only/remote-only paths.
|
|
217
263
|
Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
|
|
218
264
|
clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
|
|
219
265
|
preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
|
|
@@ -297,7 +343,7 @@ The operational state root is `<agent-dir>/pi-sync/`. An existing installation c
|
|
|
297
343
|
|
|
298
344
|
## ♿ Conditional terminal accessibility smoke
|
|
299
345
|
|
|
300
|
-
Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary.
|
|
346
|
+
Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Review needed`, `Later`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary. The attention widget is informational and does not pretend to be a clickable control.
|
|
301
347
|
|
|
302
348
|
## 🗂️ Package layout
|
|
303
349
|
|
|
@@ -313,6 +359,11 @@ packages/pi-sync/
|
|
|
313
359
|
│ ├── state-directory.ts
|
|
314
360
|
│ ├── settings-management.ts
|
|
315
361
|
│ ├── manager-ui.ts
|
|
362
|
+
│ ├── manager-state.ts
|
|
363
|
+
│ ├── manager-recovery.ts
|
|
364
|
+
│ ├── operation-availability.ts
|
|
365
|
+
│ ├── manager-attention.ts
|
|
366
|
+
│ ├── sync-attention.ts
|
|
316
367
|
│ ├── storage-connections-ui.ts
|
|
317
368
|
│ ├── sync-setups-ui.ts
|
|
318
369
|
│ ├── file-selection.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.9",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"typecheck": "tsc --noEmit"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
42
|
+
"@narumitw/pi-tui-kit": "^0.52.0",
|
|
43
43
|
"fast-xml-parser": "^5.10.1",
|
|
44
44
|
"proper-lockfile": "^4.1.2"
|
|
45
45
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BorderedLoader,
|
|
3
|
-
type
|
|
3
|
+
type ExtensionContext,
|
|
4
4
|
type KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -8,10 +8,12 @@ import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
|
8
8
|
import { safeTerminalText } from "./manager-helpers.js";
|
|
9
9
|
import type { SetupPullOutcome } from "./setup-switch.js";
|
|
10
10
|
import type { SyncDecision } from "./sync-decision.js";
|
|
11
|
+
import type { RemoteSelectionDecision } from "./sync-policy.js";
|
|
11
12
|
|
|
12
13
|
export type RunRouteResult =
|
|
13
14
|
| { kind: "completed"; outcome?: SetupPullOutcome }
|
|
14
15
|
| { kind: "decision-required"; decision: SyncDecision }
|
|
16
|
+
| { kind: "remote-selection-required"; decision: RemoteSelectionDecision }
|
|
15
17
|
| { kind: "failed" };
|
|
16
18
|
|
|
17
19
|
export type RunRoute = (
|
|
@@ -34,7 +36,7 @@ interface CancellableOperationOptions {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export async function runCancellableOperation(
|
|
37
|
-
ctx:
|
|
39
|
+
ctx: ExtensionContext,
|
|
38
40
|
message: string,
|
|
39
41
|
route: string,
|
|
40
42
|
runRoute: RunRoute,
|
|
@@ -51,7 +53,10 @@ export async function runCancellableOperation(
|
|
|
51
53
|
}
|
|
52
54
|
let commitStarted = false;
|
|
53
55
|
let routeResult: RunRouteResult | undefined;
|
|
54
|
-
const interaction = await runCustomInteraction<
|
|
56
|
+
const interaction = await runCustomInteraction<
|
|
57
|
+
{ cancelled?: boolean; error?: unknown },
|
|
58
|
+
ExtensionContext
|
|
59
|
+
>(ctx, {
|
|
55
60
|
signal,
|
|
56
61
|
isCurrent: () => !signal?.aborted,
|
|
57
62
|
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
package/src/config.ts
CHANGED
|
@@ -142,6 +142,10 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
|
142
142
|
]);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
export function syncConfigReviewFingerprint(config: AnySyncConfig) {
|
|
146
|
+
return createHash("sha256").update(syncConfigReviewIdentity(config)).digest("hex");
|
|
147
|
+
}
|
|
148
|
+
|
|
145
149
|
function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
|
|
146
150
|
return {
|
|
147
151
|
connectionName: config.connectionName,
|
package/src/lock.ts
CHANGED
|
@@ -152,7 +152,9 @@ export function isStaleLock(lock: LockFile) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
155
|
+
throwIfAborted(options.signal);
|
|
155
156
|
await ensureStateDir();
|
|
157
|
+
throwIfAborted(options.signal);
|
|
156
158
|
let guard: Guard;
|
|
157
159
|
try {
|
|
158
160
|
guard = await acquireGuard();
|
|
@@ -179,7 +181,9 @@ export async function unlock(ctx: ExtensionCommandContext, options: CommandOptio
|
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptions) {
|
|
184
|
+
throwIfAborted(options.signal);
|
|
182
185
|
let inspection = await inspectLock();
|
|
186
|
+
throwIfAborted(options.signal);
|
|
183
187
|
if (inspection.status === "missing") {
|
|
184
188
|
ctx.ui.notify("No pi-sync lock is present.", "info");
|
|
185
189
|
return;
|
|
@@ -193,12 +197,19 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
|
|
|
193
197
|
return;
|
|
194
198
|
}
|
|
195
199
|
inspection = await inspectLock();
|
|
200
|
+
throwIfAborted(options.signal);
|
|
196
201
|
if (inspection.status === "unreadable") {
|
|
197
202
|
// Legacy writers expose an empty file before writing owner metadata, so no
|
|
198
203
|
// automatic test can prove this file is abandoned. The explicit --stale
|
|
199
204
|
// flag is the user's confirmation that no legacy sync is still running.
|
|
205
|
+
throwIfAborted(options.signal);
|
|
200
206
|
await fs.rm(lockPath(), { force: true });
|
|
201
|
-
|
|
207
|
+
if (!options.signal?.aborted) {
|
|
208
|
+
ctx.ui.notify(
|
|
209
|
+
"Removed unreadable pi-sync lock. No settings, files, sync state, or remote data were changed.",
|
|
210
|
+
"info",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
202
213
|
return;
|
|
203
214
|
}
|
|
204
215
|
if (inspection.status === "missing") {
|
|
@@ -210,8 +221,21 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
|
|
|
210
221
|
ctx.ui.notify("Lock owner is still live; refusing to remove it.", "warning");
|
|
211
222
|
return;
|
|
212
223
|
}
|
|
224
|
+
throwIfAborted(options.signal);
|
|
213
225
|
await fs.rm(lockPath(), { force: true });
|
|
214
|
-
|
|
226
|
+
if (!options.signal?.aborted) {
|
|
227
|
+
ctx.ui.notify(
|
|
228
|
+
"Removed stale pi-sync lock. No settings, files, sync state, or remote data were changed.",
|
|
229
|
+
"info",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
235
|
+
if (!signal?.aborted) return;
|
|
236
|
+
throw signal.reason instanceof Error
|
|
237
|
+
? signal.reason
|
|
238
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
215
239
|
}
|
|
216
240
|
|
|
217
241
|
async function releaseGuard(guard: Guard) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ActionMenuItem } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { RunRoute } from "./cancellable-operation.js";
|
|
4
|
+
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
5
|
+
import type { ManagerDescription } from "./manager-state.js";
|
|
6
|
+
import type { SyncAttentionState } from "./sync-attention.js";
|
|
7
|
+
|
|
8
|
+
export interface SyncManagerAttentionOptions {
|
|
9
|
+
getAttention?: () => SyncAttentionState | undefined;
|
|
10
|
+
onSelectionResolved?: (expected: SyncAttentionState) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function attentionMainMenuItems(
|
|
14
|
+
manager: ManagerDescription,
|
|
15
|
+
): ActionMenuItem<"main" | "more" | "recovery", "review-attention">[] {
|
|
16
|
+
if (!manager.attention) return [];
|
|
17
|
+
const disabled = manager.attentionReviewDisabled === true;
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: "review-attention",
|
|
21
|
+
label: "Review synced content (recommended)",
|
|
22
|
+
action: "review-attention",
|
|
23
|
+
...(disabled
|
|
24
|
+
? {
|
|
25
|
+
disabled: true,
|
|
26
|
+
disabledReason: "Finish or recover the active operation first.",
|
|
27
|
+
}
|
|
28
|
+
: {}),
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function blockedSyncMenuItem(
|
|
34
|
+
label: string,
|
|
35
|
+
manager: ManagerDescription,
|
|
36
|
+
): ActionMenuItem<"main" | "more" | "recovery", "sync"> | undefined {
|
|
37
|
+
if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return undefined;
|
|
38
|
+
return {
|
|
39
|
+
id: "sync",
|
|
40
|
+
label,
|
|
41
|
+
description: "Review first.",
|
|
42
|
+
action: "sync",
|
|
43
|
+
disabled: true,
|
|
44
|
+
disabledReason: "Review synced content first.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function showManagerAttention(
|
|
49
|
+
ctx: ExtensionCommandContext,
|
|
50
|
+
attention: SyncAttentionState,
|
|
51
|
+
runRoute: RunRoute,
|
|
52
|
+
signal: AbortSignal | undefined,
|
|
53
|
+
onSelectionResolved: (() => void) | undefined,
|
|
54
|
+
): Promise<"close" | "stay"> {
|
|
55
|
+
const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
|
|
56
|
+
if (signal?.aborted) return "close";
|
|
57
|
+
const review = await showRemoteSelectionReview(
|
|
58
|
+
ctx,
|
|
59
|
+
attention.decision.setupName,
|
|
60
|
+
signal,
|
|
61
|
+
undefined,
|
|
62
|
+
{
|
|
63
|
+
decision: attention.decision,
|
|
64
|
+
origin: attention.origin,
|
|
65
|
+
runRoute,
|
|
66
|
+
onSelectionResolved,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
if (review.kind === "route-result") {
|
|
70
|
+
const disposition = await dispatchManagerResult(
|
|
71
|
+
ctx,
|
|
72
|
+
review.result,
|
|
73
|
+
review.route,
|
|
74
|
+
runRoute,
|
|
75
|
+
signal,
|
|
76
|
+
{ onSelectionResolved },
|
|
77
|
+
);
|
|
78
|
+
return disposition.kind;
|
|
79
|
+
}
|
|
80
|
+
return review.kind === "closed" || review.kind === "stale" ? "close" : "stay";
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { runConfirmation } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { RunRoute } from "./cancellable-operation.js";
|
|
4
|
+
import { errorMessage, safeTerminalText } from "./manager-helpers.js";
|
|
5
|
+
import type { ManagerDescription } from "./manager-state.js";
|
|
6
|
+
import { inspectOperationAvailability, operationCanRecover } from "./operation-availability.js";
|
|
7
|
+
|
|
8
|
+
export type RecoveryDisposition = "restored" | "stay" | "close";
|
|
9
|
+
|
|
10
|
+
export async function recoverSyncAccess(
|
|
11
|
+
ctx: ExtensionCommandContext,
|
|
12
|
+
manager: ManagerDescription,
|
|
13
|
+
runRoute: RunRoute,
|
|
14
|
+
sessionSignal: AbortSignal | undefined,
|
|
15
|
+
actionSignal: AbortSignal,
|
|
16
|
+
): Promise<RecoveryDisposition> {
|
|
17
|
+
const operation = manager.operation;
|
|
18
|
+
if (!operation || !operationCanRecover(operation)) {
|
|
19
|
+
ctx.ui.notify(
|
|
20
|
+
"Operation status changed. Refresh the manager before retrying recovery.",
|
|
21
|
+
"warning",
|
|
22
|
+
);
|
|
23
|
+
return "stay";
|
|
24
|
+
}
|
|
25
|
+
const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
26
|
+
if (signal.aborted) return "close";
|
|
27
|
+
const unreadable = operation.kind === "recoverable-unreadable";
|
|
28
|
+
const details = unreadable
|
|
29
|
+
? "Pi-sync cannot verify who owns the unreadable lock. Close other Pi sessions that may be syncing before continuing."
|
|
30
|
+
: `The recorded ${safeTerminalText(operation.lock.command)} operation (pid ${operation.lock.pid}) appears to have stopped. Close other Pi sessions that may still be syncing before continuing.`;
|
|
31
|
+
const confirmation = await runConfirmation(ctx, {
|
|
32
|
+
title: "Restore sync access?",
|
|
33
|
+
message: [
|
|
34
|
+
details,
|
|
35
|
+
"",
|
|
36
|
+
"This removes only the local operation lock.",
|
|
37
|
+
"It does not change settings, local files, sync state, or remote data.",
|
|
38
|
+
].join("\n"),
|
|
39
|
+
confirmLabel: "Remove local lock and continue",
|
|
40
|
+
cancelLabel: "Cancel",
|
|
41
|
+
signal,
|
|
42
|
+
isCurrent: () => !signal.aborted,
|
|
43
|
+
onError: (_currentCtx, error) => {
|
|
44
|
+
ctx.ui.notify(
|
|
45
|
+
`Recovery confirmation failed: ${safeTerminalText(errorMessage(error))}`,
|
|
46
|
+
"error",
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (confirmation.kind === "stale") return "close";
|
|
51
|
+
if (confirmation.kind === "closed") {
|
|
52
|
+
if (confirmation.reason === "close") return "close";
|
|
53
|
+
if (!signal.aborted) {
|
|
54
|
+
ctx.ui.notify("Recovery cancelled; the local operation lock was not changed.", "info");
|
|
55
|
+
}
|
|
56
|
+
return "stay";
|
|
57
|
+
}
|
|
58
|
+
if (confirmation.kind !== "confirmed") return "stay";
|
|
59
|
+
if (signal.aborted) return "close";
|
|
60
|
+
await runRoute(unreadable ? "unlock --stale" : "unlock", signal);
|
|
61
|
+
if (signal.aborted) return "close";
|
|
62
|
+
const latest = await inspectOperationAvailability();
|
|
63
|
+
if (signal.aborted) return "close";
|
|
64
|
+
return latest.kind === "free" ? "restored" : "stay";
|
|
65
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
CancellableOperationResult,
|
|
4
|
+
RunRoute,
|
|
5
|
+
RunRouteResult,
|
|
6
|
+
} from "./cancellable-operation.js";
|
|
7
|
+
import { type RemoteSelectionOrigin, showRemoteSelectionReview } from "./remote-selection-ui.js";
|
|
8
|
+
import { showSyncResolution } from "./sync-resolution-ui.js";
|
|
9
|
+
|
|
10
|
+
const MAX_DECISION_TRANSITIONS = 32;
|
|
11
|
+
|
|
12
|
+
export interface ManagerResultDisposition {
|
|
13
|
+
kind: "close" | "stay";
|
|
14
|
+
appliedRoute?: "sync" | "pull" | "push";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManagerResultOptions {
|
|
18
|
+
cancelLabel?: string;
|
|
19
|
+
onSelectionResolved?: () => void;
|
|
20
|
+
withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function dispatchManagerResult(
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
initialResult: CancellableOperationResult,
|
|
26
|
+
origin: Exclude<RemoteSelectionOrigin, "settings">,
|
|
27
|
+
runRoute: RunRoute,
|
|
28
|
+
signal?: AbortSignal,
|
|
29
|
+
options: ManagerResultOptions = {},
|
|
30
|
+
): Promise<ManagerResultDisposition> {
|
|
31
|
+
let result: CancellableOperationResult | RunRouteResult = initialResult;
|
|
32
|
+
let currentRoute: "sync" | "pull" | "push" = origin;
|
|
33
|
+
let resolving = false;
|
|
34
|
+
|
|
35
|
+
for (let transition = 0; transition < MAX_DECISION_TRANSITIONS; transition += 1) {
|
|
36
|
+
if (signal?.aborted) return { kind: "close" };
|
|
37
|
+
if (result.kind === "remote-selection-required") {
|
|
38
|
+
resolving = true;
|
|
39
|
+
const resolution = await showRemoteSelectionReview(
|
|
40
|
+
ctx,
|
|
41
|
+
result.decision.setupName,
|
|
42
|
+
signal,
|
|
43
|
+
undefined,
|
|
44
|
+
{
|
|
45
|
+
decision: result.decision,
|
|
46
|
+
origin: currentRoute,
|
|
47
|
+
runRoute,
|
|
48
|
+
cancelLabel: options.cancelLabel,
|
|
49
|
+
onSelectionResolved: options.onSelectionResolved,
|
|
50
|
+
withStateAccess: options.withStateAccess,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
if (resolution.kind === "route-result") {
|
|
54
|
+
result = resolution.result;
|
|
55
|
+
currentRoute = resolution.route;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
return resolution.kind === "closed" || resolution.kind === "stale"
|
|
59
|
+
? { kind: "close" }
|
|
60
|
+
: { kind: "stay" };
|
|
61
|
+
}
|
|
62
|
+
if (result.kind === "decision-required") {
|
|
63
|
+
resolving = true;
|
|
64
|
+
const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
|
|
65
|
+
if (resolution.kind === "route-result") {
|
|
66
|
+
result = resolution.result;
|
|
67
|
+
currentRoute = resolution.route;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (resolution.kind === "resolved") {
|
|
71
|
+
return { kind: "close", appliedRoute: resolution.direction };
|
|
72
|
+
}
|
|
73
|
+
return resolution.kind === "closed" || resolution.kind === "stale"
|
|
74
|
+
? { kind: "close" }
|
|
75
|
+
: { kind: "stay" };
|
|
76
|
+
}
|
|
77
|
+
if (result.kind === "closed") return { kind: "close" };
|
|
78
|
+
if (result.kind === "completed") {
|
|
79
|
+
const appliedRoute = result.outcome === "applied" ? currentRoute : undefined;
|
|
80
|
+
if (resolving || (origin === "pull" && result.outcome === "applied")) {
|
|
81
|
+
return { kind: "close", ...(appliedRoute ? { appliedRoute } : {}) };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "stay", ...(appliedRoute ? { appliedRoute } : {}) };
|
|
84
|
+
}
|
|
85
|
+
return { kind: "stay" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
"Sync resolution stopped after too many state changes. Start /sync again to review fresh state.",
|
|
90
|
+
"warning",
|
|
91
|
+
);
|
|
92
|
+
return { kind: "stay" };
|
|
93
|
+
}
|