@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.
@@ -1,3 +1,4 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
1
2
  import {
2
3
  activeLocalConfigPath,
3
4
  isCloudflareR2Endpoint,
@@ -5,9 +6,15 @@ 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 { syncIncludeSelection } from "./sync-policy.js";
10
+ import {
11
+ inspectOperationAvailability,
12
+ type OperationAvailability,
13
+ operationBlocksChanges,
14
+ operationCanRecover,
15
+ } from "./operation-availability.js";
16
+ import { type SyncAttentionState, syncAttentionMatchesConfig } from "./sync-attention.js";
17
+ import { compareSyncInclude, syncIncludeSelection } from "./sync-policy.js";
11
18
  import { countValidSyncSetups } from "./sync-setups-ui.js";
12
19
  import type { AnySyncConfig } from "./types.js";
13
20
 
@@ -19,9 +26,20 @@ export const MAIN_MENU_ACTIONS = [
19
26
  "More…",
20
27
  ] as const;
21
28
 
29
+ export interface ManagerDescription {
30
+ title: string;
31
+ actions: string[];
32
+ operation?: OperationAvailability;
33
+ attention?: SyncAttentionState;
34
+ attentionBlocksSync?: boolean;
35
+ attentionReviewDisabled?: boolean;
36
+ }
37
+
22
38
  export async function describeManagerState(
23
39
  signal?: AbortSignal,
24
- ): Promise<{ title: string; actions: string[] }> {
40
+ attention?: SyncAttentionState,
41
+ inspectOperation: () => Promise<OperationAvailability> = inspectOperationAvailability,
42
+ ): Promise<ManagerDescription> {
25
43
  let raw: Record<string, unknown> | undefined;
26
44
  try {
27
45
  raw = await readLocalConfigObject();
@@ -61,62 +79,86 @@ export async function describeManagerState(
61
79
  }
62
80
  try {
63
81
  const config = await loadConfig();
64
- const lock = await inspectLock();
65
- const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
66
- const recoverableLock =
67
- lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
82
+ const operation = await inspectOperation();
83
+ const changesBlocked = operationBlocksChanges(operation);
68
84
  const selection = syncIncludeSelection(config.include);
85
+ let currentAttention: SyncAttentionState | undefined;
86
+ if (attention) {
87
+ try {
88
+ const attentionConfig =
89
+ attention.decision.setupName === config.setupName
90
+ ? config
91
+ : await loadConfig(attention.decision.setupName);
92
+ if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention;
93
+ } catch {
94
+ currentAttention = undefined;
95
+ }
96
+ if (signal?.aborted) throw signal.reason;
97
+ }
98
+ const attentionComparison = currentAttention
99
+ ? compareSyncInclude(
100
+ currentAttention.decision.localInclude,
101
+ currentAttention.decision.remoteInclude,
102
+ )
103
+ : undefined;
69
104
  const noSyncedContent = config.include.length === 0;
70
- const syncState =
71
- liveLock || recoverableLock
72
- ? undefined
73
- : await readStateForConfig(config).catch(() => undefined);
74
- const lastAppliedSnapshot =
75
- liveLock || recoverableLock
76
- ? "Unavailable while operations are locked"
77
- : syncState?.lastAppliedSnapshot
78
- ? safeTerminalText(syncState.lastAppliedSnapshot)
79
- : syncState
80
- ? "Never synced"
81
- : "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";
82
115
  const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
83
116
  const mainActions = MAIN_MENU_ACTIONS.filter(
84
117
  (action) => action !== "Switch sync setup" || canSwitch,
85
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
+ ];
86
148
  return {
87
- title: [
88
- "Manage sync",
89
- "",
90
- `Current sync setup: ${safeTerminalText(config.setupName)}`,
91
- `Storage: ${backendStorageDescription(config)}`,
92
- `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"}`,
93
- `Automatic sync: ${config.automatic ? "On" : "Off"}`,
94
- `Last applied: ${lastAppliedSnapshot}`,
95
- "Remote status: Not checked",
96
- ...(noSyncedContent
97
- ? [
98
- "",
99
- "No included content is selected. Choose included content in Settings before syncing.",
100
- ]
101
- : []),
102
- ...(liveLock
103
- ? [
104
- "",
105
- `Operation in progress: ${safeTerminalText(lock.lock.command)} (pid ${lock.lock.pid}). Sync and settings changes are disabled.`,
106
- ]
107
- : []),
108
- ...(recoverableLock
109
- ? ["", "Recovery required: lock metadata is stale or unreadable."]
110
- : []),
111
- "",
112
- "What do you want to do?",
113
- ].join("\n"),
114
- actions:
115
- liveLock || recoverableLock
116
- ? ["Status & changes", "History & recovery…", "Help"]
117
- : noSyncedContent
118
- ? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
119
- : mainActions,
149
+ title: (changesBlocked
150
+ ? ["Manage sync", ...operationStatusLines(operation)]
151
+ : ordinaryTitle
152
+ ).join("\n"),
153
+ actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
154
+ operation,
155
+ ...(currentAttention
156
+ ? {
157
+ attention: currentAttention,
158
+ attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
159
+ attentionReviewDisabled: changesBlocked,
160
+ }
161
+ : {}),
120
162
  };
121
163
  } catch (error) {
122
164
  if (signal?.aborted) throw error;
@@ -136,6 +178,57 @@ export async function describeManagerState(
136
178
  }
137
179
  }
138
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
+
139
232
  export function backendStorageDescription(config: AnySyncConfig) {
140
233
  const connection = safeTerminalText(config.connectionName);
141
234
  switch (config.backend.type) {
package/src/manager-ui.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
- import {
4
- type CancellableOperationResult,
5
- type RunRoute,
6
- runCancellableOperation,
7
- } from "./cancellable-operation.js";
3
+ import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
8
4
  import { setSyncSetupCompletions } from "./command.js";
9
5
  import {
10
6
  configuredSyncSetupNames,
@@ -17,7 +13,12 @@ import {
17
13
  syncConfigReviewIdentity,
18
14
  } from "./config.js";
19
15
  import { showAddGitTarget, showEditGitTarget, showGitSetup } from "./git-ui.js";
20
- import { inspectLock, isStaleLock } from "./lock.js";
16
+ import {
17
+ attentionMainMenuItems,
18
+ blockedSyncMenuItem,
19
+ type SyncManagerAttentionOptions,
20
+ showManagerAttention,
21
+ } from "./manager-attention.js";
21
22
  import {
22
23
  errorMessage,
23
24
  ownRecord,
@@ -25,7 +26,10 @@ import {
25
26
  requiredInput,
26
27
  safeTerminalText,
27
28
  } from "./manager-helpers.js";
29
+ import { recoverSyncAccess } from "./manager-recovery.js";
30
+ import { dispatchManagerResult } from "./manager-result-dispatcher.js";
28
31
  import { backendStorageDescription, describeManagerState } from "./manager-state.js";
32
+ import { operationCanRecover } from "./operation-availability.js";
29
33
  import { chooseS3Credentials } from "./s3-credentials-ui.js";
30
34
  import {
31
35
  addSyncSetup,
@@ -37,7 +41,6 @@ import { showSyncSettings } from "./settings-ui.js";
37
41
  import { useSyncSetup } from "./setup-switch.js";
38
42
  import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
39
43
  import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
40
- import { showSyncResolution } from "./sync-resolution-ui.js";
41
44
  import { showSyncSetups } from "./sync-setups-ui.js";
42
45
  import type { AnySyncConfig } from "./types.js";
43
46
  import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
@@ -46,6 +49,7 @@ export async function showSyncManager(
46
49
  ctx: ExtensionCommandContext,
47
50
  runRoute: RunRoute,
48
51
  sessionSignal?: AbortSignal,
52
+ options: SyncManagerAttentionOptions = {},
49
53
  ): Promise<void> {
50
54
  if (!ctx.hasUI) {
51
55
  await runRoute("help");
@@ -53,6 +57,7 @@ export async function showSyncManager(
53
57
  }
54
58
  type Screen = "main" | "more" | "recovery";
55
59
  type Action =
60
+ | "review-attention"
56
61
  | "sync"
57
62
  | "switch"
58
63
  | "diff"
@@ -64,23 +69,34 @@ export async function showSyncManager(
64
69
  | "history"
65
70
  | "doctor"
66
71
  | "unlock"
72
+ | "recover"
73
+ | "refresh"
67
74
  | "help"
68
75
  | "init"
69
76
  | "back";
70
77
  interface State {
71
78
  manager: Awaited<ReturnType<typeof describeManagerState>>;
72
- canRecover: boolean;
73
79
  }
74
80
  const menu = defineMenu<State, Screen, Action, ExtensionCommandContext>({
75
81
  start: "main",
76
82
  screens: {
77
- main: ({ state }) => ({
78
- kind: "actions",
79
- title: "Manage sync",
80
- lines: state.manager.title.split("\n").slice(1),
81
- items: state.manager.actions.map(syncMainMenuItem),
82
- hint: "close",
83
- }),
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
+ },
84
100
  more: () => ({
85
101
  kind: "actions",
86
102
  title: "More options",
@@ -105,7 +121,7 @@ export async function showSyncManager(
105
121
  items: [
106
122
  { id: "history", label: "Browse history", action: "history" },
107
123
  { id: "doctor", label: "Check setup", action: "doctor" },
108
- ...(state.canRecover
124
+ ...(state.manager.operation && operationCanRecover(state.manager.operation)
109
125
  ? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
110
126
  : []),
111
127
  { id: "back", label: "Back", action: "back" },
@@ -114,7 +130,28 @@ export async function showSyncManager(
114
130
  }),
115
131
  },
116
132
  actions: {
133
+ "review-attention": async () => {
134
+ const attention = options.getAttention?.();
135
+ if (!attention) return { kind: "stay" };
136
+ const disposition = await showManagerAttention(
137
+ ctx,
138
+ attention,
139
+ runRoute,
140
+ sessionSignal,
141
+ () => options.onSelectionResolved?.(attention),
142
+ );
143
+ return { kind: disposition };
144
+ },
117
145
  sync: async () => {
146
+ const pendingAttention = options.getAttention?.();
147
+ if (pendingAttention) {
148
+ const activeConfig = await loadConfig();
149
+ if (sessionSignal?.aborted) return { kind: "close" };
150
+ if (pendingAttention.decision.setupName === activeConfig.setupName) {
151
+ ctx.ui.notify("Review synced content before starting Sync now.", "warning");
152
+ return { kind: "stay" };
153
+ }
154
+ }
118
155
  const result = await runCancellableOperation(
119
156
  ctx,
120
157
  "Checking current sync setup…",
@@ -125,10 +162,14 @@ export async function showSyncManager(
125
162
  signal: sessionSignal,
126
163
  },
127
164
  );
128
- const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
129
- return result.kind === "closed" || resolution === "close"
130
- ? { kind: "close" }
131
- : { kind: "stay" };
165
+ const disposition = await dispatchManagerResult(
166
+ ctx,
167
+ result,
168
+ "sync",
169
+ runRoute,
170
+ sessionSignal,
171
+ );
172
+ return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
132
173
  },
133
174
  switch: async () => {
134
175
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
@@ -162,12 +203,14 @@ export async function showSyncManager(
162
203
  signal: sessionSignal,
163
204
  },
164
205
  );
165
- const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
166
- return result.kind === "closed" ||
167
- (result.kind === "completed" && result.outcome === "applied") ||
168
- resolution === "close"
169
- ? { kind: "close" }
170
- : { kind: "stay" };
206
+ const disposition = await dispatchManagerResult(
207
+ ctx,
208
+ result,
209
+ "pull",
210
+ runRoute,
211
+ sessionSignal,
212
+ );
213
+ return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
171
214
  },
172
215
  push: async () => {
173
216
  const result = await runCancellableOperation(
@@ -181,10 +224,14 @@ export async function showSyncManager(
181
224
  signal: sessionSignal,
182
225
  },
183
226
  );
184
- const resolution = await resolveManagerDecision(ctx, result, runRoute, sessionSignal);
185
- return result.kind === "closed" || resolution === "close"
186
- ? { kind: "close" }
187
- : { kind: "stay" };
227
+ const disposition = await dispatchManagerResult(
228
+ ctx,
229
+ result,
230
+ "push",
231
+ runRoute,
232
+ sessionSignal,
233
+ );
234
+ return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
188
235
  },
189
236
  setups: async () => {
190
237
  const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
@@ -202,10 +249,28 @@ export async function showSyncManager(
202
249
  await runRoute("doctor");
203
250
  return { kind: "stay" };
204
251
  },
205
- unlock: async () => {
206
- await runRoute("unlock --stale");
207
- 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" };
208
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" }),
209
274
  help: async () => {
210
275
  await runRoute("help");
211
276
  return { kind: "close" };
@@ -219,52 +284,54 @@ export async function showSyncManager(
219
284
  });
220
285
  await runMenu(ctx, menu, {
221
286
  getState: async () => {
222
- const [manager, lock] = await Promise.all([
223
- describeManagerState(sessionSignal),
224
- inspectLock(),
225
- ]);
226
- return {
227
- manager,
228
- canRecover:
229
- lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock)),
230
- };
287
+ const pendingAttention = options.getAttention?.();
288
+ const manager = await describeManagerState(sessionSignal, pendingAttention);
289
+ if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) {
290
+ options.onSelectionResolved?.(pendingAttention);
291
+ }
292
+ return { manager };
231
293
  },
232
294
  signal: sessionSignal,
233
295
  isCurrent: () => !sessionSignal?.aborted,
234
296
  });
235
297
  }
236
298
 
237
- async function resolveManagerDecision(
238
- ctx: ExtensionCommandContext,
239
- result: CancellableOperationResult,
240
- runRoute: RunRoute,
241
- signal?: AbortSignal,
242
- ) {
243
- if (result.kind !== "decision-required") return undefined;
244
- const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
245
- return resolution.kind === "resolved" ||
246
- resolution.kind === "closed" ||
247
- resolution.kind === "stale"
248
- ? "close"
249
- : "stay";
250
- }
251
-
252
299
  function syncMainMenuItem(
253
300
  label: string,
254
301
  ): ActionMenuItem<
255
302
  "main" | "more" | "recovery",
256
- "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"
257
313
  > {
258
314
  if (label === "More…") return { id: "more", label, to: "more" };
259
315
  if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
260
316
  const actions = new Map<
261
317
  string,
262
- "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"
263
328
  >([
264
329
  ["Sync now (recommended)", "sync"],
265
330
  ["Switch sync setup", "switch"],
266
331
  ["Status & changes", "diff"],
267
332
  ["Settings", "settings"],
333
+ ["Restore sync access… (recommended)", "recover"],
334
+ ["Refresh operation status", "refresh"],
268
335
  ["Sync setups…", "setups"],
269
336
  ["Storage connections…", "connections"],
270
337
  ["Help", "help"],
@@ -553,23 +620,11 @@ async function showSetupSwitcher(
553
620
  signal,
554
621
  },
555
622
  );
556
- if (pullResult.kind === "closed") {
557
- pullClosed = true;
558
- return undefined;
559
- }
560
- if (pullResult.kind === "decision-required") {
561
- const resolution = await showSyncResolution(ctx, pullResult.decision, runRoute, signal);
562
- if (
563
- resolution.kind === "resolved" ||
564
- resolution.kind === "closed" ||
565
- resolution.kind === "stale"
566
- ) {
567
- pullClosed = true;
568
- }
569
- return resolution.kind === "resolved" && resolution.direction === "pull"
570
- ? "applied"
571
- : undefined;
623
+ const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal);
624
+ if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) {
625
+ pullClosed = disposition.kind === "close";
572
626
  }
627
+ if (disposition.appliedRoute === "pull") return "applied";
573
628
  if (pullResult.kind === "completed") return pullResult.outcome;
574
629
  return pullResult.kind === "cancelled" ? "cancelled" : undefined;
575
630
  },
@@ -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
+ }