@narumitw/pi-sync 0.49.8 → 0.49.10
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 +24 -0
- package/package.json +1 -1
- package/src/lock.ts +26 -2
- package/src/manager-recovery.ts +65 -0
- package/src/manager-state.ts +108 -62
- package/src/manager-ui.ts +67 -29
- package/src/operation-availability.ts +64 -0
- package/src/state-directory.ts +55 -1
package/README.md
CHANGED
|
@@ -62,6 +62,27 @@ and recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C cl
|
|
|
62
62
|
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
63
63
|
and confirmations.
|
|
64
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
|
+
|
|
65
86
|
### Resolve conflicts in the manager
|
|
66
87
|
|
|
67
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.
|
|
@@ -338,6 +359,9 @@ packages/pi-sync/
|
|
|
338
359
|
│ ├── state-directory.ts
|
|
339
360
|
│ ├── settings-management.ts
|
|
340
361
|
│ ├── manager-ui.ts
|
|
362
|
+
│ ├── manager-state.ts
|
|
363
|
+
│ ├── manager-recovery.ts
|
|
364
|
+
│ ├── operation-availability.ts
|
|
341
365
|
│ ├── manager-attention.ts
|
|
342
366
|
│ ├── sync-attention.ts
|
|
343
367
|
│ ├── storage-connections-ui.ts
|
package/package.json
CHANGED
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,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
|
+
}
|
package/src/manager-state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
2
|
import {
|
|
2
3
|
activeLocalConfigPath,
|
|
3
4
|
isCloudflareR2Endpoint,
|
|
@@ -5,8 +6,13 @@ import {
|
|
|
5
6
|
readLocalConfigObject,
|
|
6
7
|
readStateForConfig,
|
|
7
8
|
} from "./config.js";
|
|
8
|
-
import { inspectLock, isStaleLock } from "./lock.js";
|
|
9
9
|
import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
|
|
10
|
+
import {
|
|
11
|
+
inspectOperationAvailability,
|
|
12
|
+
type OperationAvailability,
|
|
13
|
+
operationBlocksChanges,
|
|
14
|
+
operationCanRecover,
|
|
15
|
+
} from "./operation-availability.js";
|
|
10
16
|
import { type SyncAttentionState, syncAttentionMatchesConfig } from "./sync-attention.js";
|
|
11
17
|
import { compareSyncInclude, syncIncludeSelection } from "./sync-policy.js";
|
|
12
18
|
import { countValidSyncSetups } from "./sync-setups-ui.js";
|
|
@@ -23,6 +29,7 @@ export const MAIN_MENU_ACTIONS = [
|
|
|
23
29
|
export interface ManagerDescription {
|
|
24
30
|
title: string;
|
|
25
31
|
actions: string[];
|
|
32
|
+
operation?: OperationAvailability;
|
|
26
33
|
attention?: SyncAttentionState;
|
|
27
34
|
attentionBlocksSync?: boolean;
|
|
28
35
|
attentionReviewDisabled?: boolean;
|
|
@@ -31,6 +38,7 @@ export interface ManagerDescription {
|
|
|
31
38
|
export async function describeManagerState(
|
|
32
39
|
signal?: AbortSignal,
|
|
33
40
|
attention?: SyncAttentionState,
|
|
41
|
+
inspectOperation: () => Promise<OperationAvailability> = inspectOperationAvailability,
|
|
34
42
|
): Promise<ManagerDescription> {
|
|
35
43
|
let raw: Record<string, unknown> | undefined;
|
|
36
44
|
try {
|
|
@@ -71,10 +79,8 @@ export async function describeManagerState(
|
|
|
71
79
|
}
|
|
72
80
|
try {
|
|
73
81
|
const config = await loadConfig();
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const recoverableLock =
|
|
77
|
-
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
82
|
+
const operation = await inspectOperation();
|
|
83
|
+
const changesBlocked = operationBlocksChanges(operation);
|
|
78
84
|
const selection = syncIncludeSelection(config.include);
|
|
79
85
|
let currentAttention: SyncAttentionState | undefined;
|
|
80
86
|
if (attention) {
|
|
@@ -96,72 +102,61 @@ export async function describeManagerState(
|
|
|
96
102
|
)
|
|
97
103
|
: undefined;
|
|
98
104
|
const noSyncedContent = config.include.length === 0;
|
|
99
|
-
const syncState =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
?
|
|
106
|
-
: syncState
|
|
107
|
-
?
|
|
108
|
-
:
|
|
109
|
-
? "Never synced"
|
|
110
|
-
: "Unavailable";
|
|
105
|
+
const syncState = changesBlocked
|
|
106
|
+
? undefined
|
|
107
|
+
: await readStateForConfig(config).catch(() => undefined);
|
|
108
|
+
const lastAppliedSnapshot = changesBlocked
|
|
109
|
+
? "Unavailable while operations are locked"
|
|
110
|
+
: syncState?.lastAppliedSnapshot
|
|
111
|
+
? safeTerminalText(syncState.lastAppliedSnapshot)
|
|
112
|
+
: syncState
|
|
113
|
+
? "Never synced"
|
|
114
|
+
: "Unavailable";
|
|
111
115
|
const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
|
|
112
116
|
const mainActions = MAIN_MENU_ACTIONS.filter(
|
|
113
117
|
(action) => action !== "Switch sync setup" || canSwitch,
|
|
114
118
|
);
|
|
119
|
+
const ordinaryTitle = [
|
|
120
|
+
"Manage sync",
|
|
121
|
+
"",
|
|
122
|
+
`Current sync setup: ${safeTerminalText(config.setupName)}`,
|
|
123
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
124
|
+
`Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
|
|
125
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
126
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
127
|
+
...(currentAttention
|
|
128
|
+
? [
|
|
129
|
+
currentAttention.decision.setupName === config.setupName
|
|
130
|
+
? "Sync status: Review needed"
|
|
131
|
+
: `Sync status: Review needed for setup ${safeTerminalText(currentAttention.decision.setupName)}`,
|
|
132
|
+
attentionComparison?.remoteOnly.length === 0 &&
|
|
133
|
+
attentionComparison.localOnly.length === 0
|
|
134
|
+
? "Only the synced-content order differs."
|
|
135
|
+
: `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} · Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`,
|
|
136
|
+
"Nothing has been changed.",
|
|
137
|
+
]
|
|
138
|
+
: ["Remote status: Not checked"]),
|
|
139
|
+
...(noSyncedContent
|
|
140
|
+
? [
|
|
141
|
+
"",
|
|
142
|
+
"No included content is selected. Choose included content in Settings before syncing.",
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
"",
|
|
146
|
+
"What do you want to do?",
|
|
147
|
+
];
|
|
115
148
|
return {
|
|
116
|
-
title:
|
|
117
|
-
"Manage sync",
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
123
|
-
`Last applied: ${lastAppliedSnapshot}`,
|
|
124
|
-
...(currentAttention
|
|
125
|
-
? [
|
|
126
|
-
currentAttention.decision.setupName === config.setupName
|
|
127
|
-
? "Sync status: Review needed"
|
|
128
|
-
: `Sync status: Review needed for setup ${safeTerminalText(currentAttention.decision.setupName)}`,
|
|
129
|
-
attentionComparison?.remoteOnly.length === 0 &&
|
|
130
|
-
attentionComparison.localOnly.length === 0
|
|
131
|
-
? "Only the synced-content order differs."
|
|
132
|
-
: `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} · Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`,
|
|
133
|
-
"Nothing has been changed.",
|
|
134
|
-
]
|
|
135
|
-
: ["Remote status: Not checked"]),
|
|
136
|
-
...(noSyncedContent
|
|
137
|
-
? [
|
|
138
|
-
"",
|
|
139
|
-
"No included content is selected. Choose included content in Settings before syncing.",
|
|
140
|
-
]
|
|
141
|
-
: []),
|
|
142
|
-
...(liveLock
|
|
143
|
-
? [
|
|
144
|
-
"",
|
|
145
|
-
`Operation in progress: ${safeTerminalText(lock.lock.command)} (pid ${lock.lock.pid}). Sync and settings changes are disabled.`,
|
|
146
|
-
]
|
|
147
|
-
: []),
|
|
148
|
-
...(recoverableLock
|
|
149
|
-
? ["", "Recovery required: lock metadata is stale or unreadable."]
|
|
150
|
-
: []),
|
|
151
|
-
"",
|
|
152
|
-
"What do you want to do?",
|
|
153
|
-
].join("\n"),
|
|
154
|
-
actions:
|
|
155
|
-
liveLock || recoverableLock
|
|
156
|
-
? ["Status & changes", "History & recovery…", "Help"]
|
|
157
|
-
: noSyncedContent
|
|
158
|
-
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
159
|
-
: mainActions,
|
|
149
|
+
title: (changesBlocked
|
|
150
|
+
? ["Manage sync", ...operationStatusLines(operation)]
|
|
151
|
+
: ordinaryTitle
|
|
152
|
+
).join("\n"),
|
|
153
|
+
actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
|
|
154
|
+
operation,
|
|
160
155
|
...(currentAttention
|
|
161
156
|
? {
|
|
162
157
|
attention: currentAttention,
|
|
163
158
|
attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
|
|
164
|
-
attentionReviewDisabled:
|
|
159
|
+
attentionReviewDisabled: changesBlocked,
|
|
165
160
|
}
|
|
166
161
|
: {}),
|
|
167
162
|
};
|
|
@@ -183,6 +178,57 @@ export async function describeManagerState(
|
|
|
183
178
|
}
|
|
184
179
|
}
|
|
185
180
|
|
|
181
|
+
function operationActions(
|
|
182
|
+
operation: OperationAvailability,
|
|
183
|
+
noSyncedContent: boolean,
|
|
184
|
+
canSwitch: boolean,
|
|
185
|
+
mainActions: string[],
|
|
186
|
+
) {
|
|
187
|
+
if (operationCanRecover(operation)) {
|
|
188
|
+
return [
|
|
189
|
+
"Restore sync access… (recommended)",
|
|
190
|
+
"Status & changes",
|
|
191
|
+
"History & recovery…",
|
|
192
|
+
"Help",
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
if (operation.kind !== "free") {
|
|
196
|
+
return ["Refresh operation status", "Status & changes", "History & recovery…", "Help"];
|
|
197
|
+
}
|
|
198
|
+
return noSyncedContent
|
|
199
|
+
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
200
|
+
: mainActions;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function operationStatusLines(operation: OperationAvailability): string[] {
|
|
204
|
+
switch (operation.kind) {
|
|
205
|
+
case "free":
|
|
206
|
+
return [];
|
|
207
|
+
case "live": {
|
|
208
|
+
const command = truncateToWidth(safeTerminalText(operation.lock.command), 16, "…");
|
|
209
|
+
return [
|
|
210
|
+
`Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.`,
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
case "busy":
|
|
214
|
+
return [
|
|
215
|
+
"Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable.",
|
|
216
|
+
];
|
|
217
|
+
case "recoverable-stale":
|
|
218
|
+
return [
|
|
219
|
+
"Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return.",
|
|
220
|
+
];
|
|
221
|
+
case "recoverable-unreadable":
|
|
222
|
+
return [
|
|
223
|
+
"Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return.",
|
|
224
|
+
];
|
|
225
|
+
case "inspection-error":
|
|
226
|
+
return [
|
|
227
|
+
"Lock check failed. Fix path access, then refresh; Settings and More remain unavailable.",
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
186
232
|
export function backendStorageDescription(config: AnySyncConfig) {
|
|
187
233
|
const connection = safeTerminalText(config.connectionName);
|
|
188
234
|
switch (config.backend.type) {
|
package/src/manager-ui.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
syncConfigReviewIdentity,
|
|
14
14
|
} from "./config.js";
|
|
15
15
|
import { showAddGitTarget, showEditGitTarget, showGitSetup } from "./git-ui.js";
|
|
16
|
-
import { inspectLock, isStaleLock } from "./lock.js";
|
|
17
16
|
import {
|
|
18
17
|
attentionMainMenuItems,
|
|
19
18
|
blockedSyncMenuItem,
|
|
@@ -27,8 +26,10 @@ import {
|
|
|
27
26
|
requiredInput,
|
|
28
27
|
safeTerminalText,
|
|
29
28
|
} from "./manager-helpers.js";
|
|
29
|
+
import { recoverSyncAccess } from "./manager-recovery.js";
|
|
30
30
|
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
31
31
|
import { backendStorageDescription, describeManagerState } from "./manager-state.js";
|
|
32
|
+
import { operationCanRecover } from "./operation-availability.js";
|
|
32
33
|
import { chooseS3Credentials } from "./s3-credentials-ui.js";
|
|
33
34
|
import {
|
|
34
35
|
addSyncSetup,
|
|
@@ -68,28 +69,34 @@ export async function showSyncManager(
|
|
|
68
69
|
| "history"
|
|
69
70
|
| "doctor"
|
|
70
71
|
| "unlock"
|
|
72
|
+
| "recover"
|
|
73
|
+
| "refresh"
|
|
71
74
|
| "help"
|
|
72
75
|
| "init"
|
|
73
76
|
| "back";
|
|
74
77
|
interface State {
|
|
75
78
|
manager: Awaited<ReturnType<typeof describeManagerState>>;
|
|
76
|
-
canRecover: boolean;
|
|
77
79
|
}
|
|
78
80
|
const menu = defineMenu<State, Screen, Action, ExtensionCommandContext>({
|
|
79
81
|
start: "main",
|
|
80
82
|
screens: {
|
|
81
|
-
main: ({ state }) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
main: ({ state }) => {
|
|
84
|
+
const attentionItems = attentionMainMenuItems(state.manager);
|
|
85
|
+
const managerItems = state.manager.actions.map(
|
|
86
|
+
(label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label),
|
|
87
|
+
);
|
|
88
|
+
const operationFirst =
|
|
89
|
+
state.manager.operation !== undefined && state.manager.operation.kind !== "free";
|
|
90
|
+
return {
|
|
91
|
+
kind: "actions",
|
|
92
|
+
title: "Manage sync",
|
|
93
|
+
lines: state.manager.title.split("\n").slice(1),
|
|
94
|
+
items: operationFirst
|
|
95
|
+
? [...managerItems, ...attentionItems]
|
|
96
|
+
: [...attentionItems, ...managerItems],
|
|
97
|
+
hint: "close",
|
|
98
|
+
};
|
|
99
|
+
},
|
|
93
100
|
more: () => ({
|
|
94
101
|
kind: "actions",
|
|
95
102
|
title: "More options",
|
|
@@ -114,7 +121,7 @@ export async function showSyncManager(
|
|
|
114
121
|
items: [
|
|
115
122
|
{ id: "history", label: "Browse history", action: "history" },
|
|
116
123
|
{ id: "doctor", label: "Check setup", action: "doctor" },
|
|
117
|
-
...(state.
|
|
124
|
+
...(state.manager.operation && operationCanRecover(state.manager.operation)
|
|
118
125
|
? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
|
|
119
126
|
: []),
|
|
120
127
|
{ id: "back", label: "Back", action: "back" },
|
|
@@ -242,10 +249,28 @@ export async function showSyncManager(
|
|
|
242
249
|
await runRoute("doctor");
|
|
243
250
|
return { kind: "stay" };
|
|
244
251
|
},
|
|
245
|
-
unlock: async () => {
|
|
246
|
-
await
|
|
247
|
-
|
|
252
|
+
unlock: async ({ state, signal: actionSignal }) => {
|
|
253
|
+
const result = await recoverSyncAccess(
|
|
254
|
+
ctx,
|
|
255
|
+
state.manager,
|
|
256
|
+
runRoute,
|
|
257
|
+
sessionSignal,
|
|
258
|
+
actionSignal,
|
|
259
|
+
);
|
|
260
|
+
if (result === "close") return { kind: "close" };
|
|
261
|
+
return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
|
|
248
262
|
},
|
|
263
|
+
recover: async ({ state, signal: actionSignal }) => {
|
|
264
|
+
const result = await recoverSyncAccess(
|
|
265
|
+
ctx,
|
|
266
|
+
state.manager,
|
|
267
|
+
runRoute,
|
|
268
|
+
sessionSignal,
|
|
269
|
+
actionSignal,
|
|
270
|
+
);
|
|
271
|
+
return { kind: result === "close" ? "close" : "stay" };
|
|
272
|
+
},
|
|
273
|
+
refresh: async () => ({ kind: "stay" }),
|
|
249
274
|
help: async () => {
|
|
250
275
|
await runRoute("help");
|
|
251
276
|
return { kind: "close" };
|
|
@@ -260,18 +285,11 @@ export async function showSyncManager(
|
|
|
260
285
|
await runMenu(ctx, menu, {
|
|
261
286
|
getState: async () => {
|
|
262
287
|
const pendingAttention = options.getAttention?.();
|
|
263
|
-
const
|
|
264
|
-
describeManagerState(sessionSignal, pendingAttention),
|
|
265
|
-
inspectLock(),
|
|
266
|
-
]);
|
|
288
|
+
const manager = await describeManagerState(sessionSignal, pendingAttention);
|
|
267
289
|
if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) {
|
|
268
290
|
options.onSelectionResolved?.(pendingAttention);
|
|
269
291
|
}
|
|
270
|
-
return {
|
|
271
|
-
manager,
|
|
272
|
-
canRecover:
|
|
273
|
-
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock)),
|
|
274
|
-
};
|
|
292
|
+
return { manager };
|
|
275
293
|
},
|
|
276
294
|
signal: sessionSignal,
|
|
277
295
|
isCurrent: () => !sessionSignal?.aborted,
|
|
@@ -282,18 +300,38 @@ function syncMainMenuItem(
|
|
|
282
300
|
label: string,
|
|
283
301
|
): ActionMenuItem<
|
|
284
302
|
"main" | "more" | "recovery",
|
|
285
|
-
|
|
303
|
+
| "sync"
|
|
304
|
+
| "switch"
|
|
305
|
+
| "diff"
|
|
306
|
+
| "settings"
|
|
307
|
+
| "setups"
|
|
308
|
+
| "connections"
|
|
309
|
+
| "recover"
|
|
310
|
+
| "refresh"
|
|
311
|
+
| "help"
|
|
312
|
+
| "init"
|
|
286
313
|
> {
|
|
287
314
|
if (label === "More…") return { id: "more", label, to: "more" };
|
|
288
315
|
if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
|
|
289
316
|
const actions = new Map<
|
|
290
317
|
string,
|
|
291
|
-
|
|
318
|
+
| "sync"
|
|
319
|
+
| "switch"
|
|
320
|
+
| "diff"
|
|
321
|
+
| "settings"
|
|
322
|
+
| "setups"
|
|
323
|
+
| "connections"
|
|
324
|
+
| "recover"
|
|
325
|
+
| "refresh"
|
|
326
|
+
| "help"
|
|
327
|
+
| "init"
|
|
292
328
|
>([
|
|
293
329
|
["Sync now (recommended)", "sync"],
|
|
294
330
|
["Switch sync setup", "switch"],
|
|
295
331
|
["Status & changes", "diff"],
|
|
296
332
|
["Settings", "settings"],
|
|
333
|
+
["Restore sync access… (recommended)", "recover"],
|
|
334
|
+
["Refresh operation status", "refresh"],
|
|
297
335
|
["Sync setups…", "setups"],
|
|
298
336
|
["Storage connections…", "connections"],
|
|
299
337
|
["Help", "help"],
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { inspectLock, isLockGuardHeld, isStaleLock, type LockInspection } from "./lock.js";
|
|
2
|
+
import { errorMessage } from "./manager-helpers.js";
|
|
3
|
+
import type { LockFile } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type OperationAvailability =
|
|
6
|
+
| { kind: "free" }
|
|
7
|
+
| { kind: "live"; lock: LockFile }
|
|
8
|
+
| { kind: "busy"; lock?: LockFile; metadata: LockInspection["status"] }
|
|
9
|
+
| { kind: "recoverable-stale"; lock: LockFile }
|
|
10
|
+
| { kind: "recoverable-unreadable" }
|
|
11
|
+
| { kind: "inspection-error"; message: string };
|
|
12
|
+
|
|
13
|
+
interface OperationInspectionDependencies {
|
|
14
|
+
inspectMetadata(): Promise<LockInspection>;
|
|
15
|
+
inspectGuard(): Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_INSPECTION_DEPENDENCIES: OperationInspectionDependencies = {
|
|
19
|
+
inspectMetadata: inspectLock,
|
|
20
|
+
inspectGuard: isLockGuardHeld,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function inspectOperationAvailability(
|
|
24
|
+
dependencies: OperationInspectionDependencies = DEFAULT_INSPECTION_DEPENDENCIES,
|
|
25
|
+
): Promise<OperationAvailability> {
|
|
26
|
+
try {
|
|
27
|
+
const metadata = await dependencies.inspectMetadata();
|
|
28
|
+
const guardHeld = await dependencies.inspectGuard();
|
|
29
|
+
return classifyOperationAvailability(metadata, guardHeld);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { kind: "inspection-error", message: errorMessage(error) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function classifyOperationAvailability(
|
|
36
|
+
metadata: LockInspection,
|
|
37
|
+
guardHeld: boolean,
|
|
38
|
+
): OperationAvailability {
|
|
39
|
+
if (metadata.status === "valid" && !isStaleLock(metadata.lock)) {
|
|
40
|
+
return { kind: "live", lock: metadata.lock };
|
|
41
|
+
}
|
|
42
|
+
if (guardHeld) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "busy",
|
|
45
|
+
metadata: metadata.status,
|
|
46
|
+
...(metadata.status === "valid" ? { lock: metadata.lock } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (metadata.status === "valid") {
|
|
50
|
+
return { kind: "recoverable-stale", lock: metadata.lock };
|
|
51
|
+
}
|
|
52
|
+
if (metadata.status === "unreadable") return { kind: "recoverable-unreadable" };
|
|
53
|
+
return { kind: "free" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function operationBlocksChanges(availability: OperationAvailability) {
|
|
57
|
+
return availability.kind !== "free";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function operationCanRecover(availability: OperationAvailability) {
|
|
61
|
+
return (
|
|
62
|
+
availability.kind === "recoverable-stale" || availability.kind === "recoverable-unreadable"
|
|
63
|
+
);
|
|
64
|
+
}
|
package/src/state-directory.ts
CHANGED
|
@@ -22,6 +22,14 @@ interface StateDirectoryGuard {
|
|
|
22
22
|
throwIfCompromised: () => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface SharedStateDirectoryGuard {
|
|
26
|
+
guard: Promise<StateDirectoryGuard>;
|
|
27
|
+
users: number;
|
|
28
|
+
closing?: Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sharedStateDirectoryGuards = new Map<string, SharedStateDirectoryGuard>();
|
|
32
|
+
|
|
25
33
|
export function stateDir() {
|
|
26
34
|
const roots = inspectStateRoots();
|
|
27
35
|
if (roots.canonical) return canonicalStateDir();
|
|
@@ -40,7 +48,9 @@ export function stateDirectoryMigrationNotice() {
|
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export async function withStateDirectoryAccess<T>(fn: () => Promise<T>): Promise<T> {
|
|
43
|
-
|
|
51
|
+
const roots = inspectStateRoots();
|
|
52
|
+
if (!roots.legacy) return fn();
|
|
53
|
+
return runWithStateDirectoryGuard(await acquireSharedStateDirectoryGuard(), fn);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPreparation> {
|
|
@@ -87,6 +97,50 @@ export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPrepa
|
|
|
87
97
|
});
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
async function acquireSharedStateDirectoryGuard(): Promise<StateDirectoryGuard> {
|
|
101
|
+
const key = migrationLockPath();
|
|
102
|
+
for (;;) {
|
|
103
|
+
let shared = sharedStateDirectoryGuards.get(key);
|
|
104
|
+
if (shared?.closing) {
|
|
105
|
+
await shared.closing;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!shared) {
|
|
109
|
+
shared = { guard: acquireStateDirectoryGuard(), users: 0 };
|
|
110
|
+
sharedStateDirectoryGuards.set(key, shared);
|
|
111
|
+
}
|
|
112
|
+
shared.users += 1;
|
|
113
|
+
let guard: StateDirectoryGuard;
|
|
114
|
+
try {
|
|
115
|
+
guard = await shared.guard;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (sharedStateDirectoryGuards.get(key) === shared) {
|
|
118
|
+
sharedStateDirectoryGuards.delete(key);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
let released = false;
|
|
123
|
+
return {
|
|
124
|
+
throwIfCompromised: guard.throwIfCompromised,
|
|
125
|
+
release: async () => {
|
|
126
|
+
if (released) return;
|
|
127
|
+
released = true;
|
|
128
|
+
shared.users -= 1;
|
|
129
|
+
if (shared.users > 0) return;
|
|
130
|
+
const closing = guard.release();
|
|
131
|
+
shared.closing = closing;
|
|
132
|
+
try {
|
|
133
|
+
await closing;
|
|
134
|
+
} finally {
|
|
135
|
+
if (sharedStateDirectoryGuards.get(key) === shared) {
|
|
136
|
+
sharedStateDirectoryGuards.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
90
144
|
async function acquireStateDirectoryGuard(): Promise<StateDirectoryGuard> {
|
|
91
145
|
let compromisedError: Error | undefined;
|
|
92
146
|
const release = await lockfile.lock(getAgentDir(), {
|