@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.49.8",
3
+ "version": "0.49.10",
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",
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
- ctx.ui.notify("Removed unreadable pi-sync lock.", "info");
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
- ctx.ui.notify("Removed stale pi-sync lock.", "info");
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
+ }
@@ -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 lock = await inspectLock();
75
- const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
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
- liveLock || recoverableLock
101
- ? undefined
102
- : await readStateForConfig(config).catch(() => undefined);
103
- const lastAppliedSnapshot =
104
- liveLock || recoverableLock
105
- ? "Unavailable while operations are locked"
106
- : syncState?.lastAppliedSnapshot
107
- ? safeTerminalText(syncState.lastAppliedSnapshot)
108
- : syncState
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
- `Current sync setup: ${safeTerminalText(config.setupName)}`,
120
- `Storage: ${backendStorageDescription(config)}`,
121
- `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"}`,
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: liveLock || recoverableLock,
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
- kind: "actions",
83
- title: "Manage sync",
84
- lines: state.manager.title.split("\n").slice(1),
85
- items: [
86
- ...attentionMainMenuItems(state.manager),
87
- ...state.manager.actions.map(
88
- (label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label),
89
- ),
90
- ],
91
- hint: "close",
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.canRecover
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 runRoute("unlock --stale");
247
- return { kind: "stay" };
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 [manager, lock] = await Promise.all([
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
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "help" | "init"
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
- "sync" | "switch" | "diff" | "settings" | "setups" | "connections" | "help" | "init"
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
+ }
@@ -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
- return runWithStateDirectoryGuard(await acquireStateDirectoryGuard(), fn);
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(), {