@narumitw/pi-sync 0.24.0 → 0.28.0

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/src/lock.ts ADDED
@@ -0,0 +1,301 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ mkdirSync,
5
+ realpath,
6
+ realpathSync,
7
+ rmdir,
8
+ rmdirSync,
9
+ stat,
10
+ statSync,
11
+ utimes,
12
+ utimesSync,
13
+ } from "node:fs";
14
+ import fs from "node:fs/promises";
15
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import lockfile from "proper-lockfile";
17
+ import { ensureStateDir, lockPath } from "./config.js";
18
+ import type { CommandOptions, LockFile } from "./types.js";
19
+
20
+ const LOCK_STALE_MS = 30 * 60 * 1000;
21
+ const GUARD_STALE_MS = 30_000;
22
+ const GUARD_UPDATE_MS = 10_000;
23
+ const MAX_PROCESS_ID = 2_147_483_647;
24
+
25
+ type LockfileFsAdapter = {
26
+ mkdir: typeof mkdir;
27
+ mkdirSync: typeof mkdirSync;
28
+ realpath: typeof realpath;
29
+ realpathSync: typeof realpathSync;
30
+ rmdir: typeof rmdir;
31
+ rmdirSync: typeof rmdirSync;
32
+ stat: typeof stat;
33
+ statSync: typeof statSync;
34
+ utimes: typeof utimes;
35
+ utimesSync: typeof utimesSync;
36
+ };
37
+
38
+ const LOCKFILE_FS_ADAPTER: LockfileFsAdapter = {
39
+ mkdir,
40
+ mkdirSync,
41
+ realpath,
42
+ realpathSync,
43
+ rmdir,
44
+ rmdirSync,
45
+ stat,
46
+ statSync,
47
+ utimes,
48
+ utimesSync,
49
+ };
50
+
51
+ interface Guard {
52
+ release: () => Promise<void>;
53
+ throwIfCompromised: () => void;
54
+ isCompromised: () => boolean;
55
+ }
56
+
57
+ export type LockInspection =
58
+ | { status: "missing" }
59
+ | { status: "unreadable" }
60
+ | { status: "valid"; lock: LockFile };
61
+
62
+ export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
63
+ await ensureStateDir();
64
+ const lock: LockFile = {
65
+ id: randomUUID(),
66
+ pid: process.pid,
67
+ command,
68
+ startedAt: new Date().toISOString(),
69
+ };
70
+ let guard: Guard | undefined;
71
+ let result: T | undefined;
72
+ let failed = false;
73
+ let failure: unknown;
74
+ try {
75
+ try {
76
+ guard = await acquireGuard();
77
+ } catch (error) {
78
+ if (!isLockHeldError(error)) throw error;
79
+ throw await describeHeldLock();
80
+ }
81
+
82
+ const inspection = await inspectLock();
83
+ if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
+ throw new Error(
85
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
86
+ );
87
+ }
88
+ if (inspection.status === "valid") {
89
+ throw new Error(
90
+ `pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
91
+ );
92
+ }
93
+ if (inspection.status === "unreadable") {
94
+ throw new Error(
95
+ "pi-sync lock metadata is unreadable. Run /sync unlock --stale after verifying no sync is running.",
96
+ );
97
+ }
98
+
99
+ await fs.writeFile(lockPath(), JSON.stringify(lock, null, "\t"), { flag: "wx" });
100
+ guard.throwIfCompromised();
101
+ result = await fn();
102
+ guard.throwIfCompromised();
103
+ } catch (error) {
104
+ failed = true;
105
+ failure = error;
106
+ }
107
+
108
+ try {
109
+ const current = await readLock();
110
+ if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
111
+ } catch (error) {
112
+ if (!failed) {
113
+ failed = true;
114
+ failure = error;
115
+ }
116
+ }
117
+ if (guard) {
118
+ const releaseError = await releaseGuard(guard);
119
+ if (releaseError && !failed) {
120
+ failed = true;
121
+ failure = releaseError;
122
+ }
123
+ }
124
+ if (failed) throw failure;
125
+ return result as T;
126
+ }
127
+
128
+ export async function inspectLock(): Promise<LockInspection> {
129
+ try {
130
+ const text = await fs.readFile(lockPath(), "utf8");
131
+ if (text.trim().length === 0) return { status: "unreadable" };
132
+ const parsed = JSON.parse(text) as unknown;
133
+ return isLockFile(parsed) ? { status: "valid", lock: parsed } : { status: "unreadable" };
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" };
136
+ if (error instanceof SyntaxError) return { status: "unreadable" };
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ export async function readLock(): Promise<LockFile | undefined> {
142
+ const inspection = await inspectLock();
143
+ return inspection.status === "valid" ? inspection.lock : undefined;
144
+ }
145
+
146
+ export async function lockFileExists(): Promise<boolean> {
147
+ try {
148
+ await fs.stat(lockPath());
149
+ return true;
150
+ } catch (error) {
151
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ export function isLockGuardHeld() {
157
+ return lockfile.check(lockPath(), {
158
+ fs: LOCKFILE_FS_ADAPTER,
159
+ lockfilePath: `${lockPath()}.guard`,
160
+ realpath: false,
161
+ stale: GUARD_STALE_MS,
162
+ });
163
+ }
164
+
165
+ export function isStaleLock(lock: LockFile) {
166
+ try {
167
+ process.kill(lock.pid, 0);
168
+ return false;
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
171
+ return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
172
+ }
173
+ }
174
+
175
+ export async function unlock(ctx: ExtensionCommandContext, options: CommandOptions) {
176
+ await ensureStateDir();
177
+ let guard: Guard;
178
+ try {
179
+ guard = await acquireGuard();
180
+ } catch (error) {
181
+ if (!isLockHeldError(error)) throw error;
182
+ ctx.ui.notify((await describeHeldLock()).message, "warning");
183
+ return;
184
+ }
185
+
186
+ let failed = false;
187
+ let failure: unknown;
188
+ try {
189
+ await unlockGuarded(ctx, options);
190
+ } catch (error) {
191
+ failed = true;
192
+ failure = error;
193
+ }
194
+ const releaseError = await releaseGuard(guard);
195
+ if (releaseError && !failed) {
196
+ failed = true;
197
+ failure = releaseError;
198
+ }
199
+ if (failed) throw failure;
200
+ }
201
+
202
+ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptions) {
203
+ let inspection = await inspectLock();
204
+ if (inspection.status === "missing") {
205
+ ctx.ui.notify("No pi-sync lock is present.", "info");
206
+ return;
207
+ }
208
+ if (inspection.status === "unreadable") {
209
+ if (!options.stale) {
210
+ ctx.ui.notify(
211
+ "Pi-sync lock metadata is unreadable. Use /sync unlock --stale only after verifying no sync is running.",
212
+ "warning",
213
+ );
214
+ return;
215
+ }
216
+ inspection = await inspectLock();
217
+ if (inspection.status === "unreadable") {
218
+ // Legacy writers expose an empty file before writing owner metadata, so no
219
+ // automatic test can prove this file is abandoned. The explicit --stale
220
+ // flag is the user's confirmation that no legacy sync is still running.
221
+ await fs.rm(lockPath(), { force: true });
222
+ ctx.ui.notify("Removed unreadable pi-sync lock.", "info");
223
+ return;
224
+ }
225
+ if (inspection.status === "missing") {
226
+ ctx.ui.notify("No pi-sync lock is present.", "info");
227
+ return;
228
+ }
229
+ }
230
+ if (!isStaleLock(inspection.lock)) {
231
+ ctx.ui.notify("Lock owner is still live; refusing to remove it.", "warning");
232
+ return;
233
+ }
234
+ await fs.rm(lockPath(), { force: true });
235
+ ctx.ui.notify("Removed stale pi-sync lock.", "info");
236
+ }
237
+
238
+ async function releaseGuard(guard: Guard) {
239
+ try {
240
+ await guard.release();
241
+ return undefined;
242
+ } catch (error) {
243
+ return guard.isCompromised() ? undefined : error;
244
+ }
245
+ }
246
+
247
+ async function acquireGuard(): Promise<Guard> {
248
+ let compromisedError: Error | undefined;
249
+ const release = await lockfile.lock(lockPath(), {
250
+ fs: LOCKFILE_FS_ADAPTER,
251
+ lockfilePath: `${lockPath()}.guard`,
252
+ realpath: false,
253
+ stale: GUARD_STALE_MS,
254
+ update: GUARD_UPDATE_MS,
255
+ onCompromised: (error) => {
256
+ compromisedError = error;
257
+ },
258
+ });
259
+ return {
260
+ release,
261
+ throwIfCompromised: () => {
262
+ if (compromisedError) throw compromisedError;
263
+ },
264
+ isCompromised: () => compromisedError !== undefined,
265
+ };
266
+ }
267
+
268
+ async function describeHeldLock() {
269
+ const current = await readLock();
270
+ if (current && isStaleLock(current)) {
271
+ return new Error("pi-sync lock owner exited; retry shortly while the lock guard expires.");
272
+ }
273
+ if (current) {
274
+ return new Error(
275
+ `Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`,
276
+ );
277
+ }
278
+ return new Error(
279
+ "Pi-sync is currently running (lock metadata is unreadable or still being written).",
280
+ );
281
+ }
282
+
283
+ function isLockHeldError(error: unknown) {
284
+ return (error as NodeJS.ErrnoException).code === "ELOCKED";
285
+ }
286
+
287
+ function isLockFile(value: unknown): value is LockFile {
288
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
289
+ const lock = value as Partial<LockFile>;
290
+ return (
291
+ typeof lock.id === "string" &&
292
+ lock.id.length > 0 &&
293
+ Number.isInteger(lock.pid) &&
294
+ (lock.pid ?? 0) > 0 &&
295
+ (lock.pid ?? 0) <= MAX_PROCESS_ID &&
296
+ typeof lock.command === "string" &&
297
+ lock.command.length > 0 &&
298
+ typeof lock.startedAt === "string" &&
299
+ Number.isFinite(Date.parse(lock.startedAt))
300
+ );
301
+ }
@@ -3,7 +3,14 @@ import type { Dirent } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { agentDir } from "./config.js";
6
- import { assertWithinRoot, isDeniedPath, isPathInside, parentPaths, safeJoin, toPosix } from "./paths.js";
6
+ import {
7
+ assertWithinRoot,
8
+ isDeniedPath,
9
+ isPathInside,
10
+ parentPaths,
11
+ safeJoin,
12
+ toPosix,
13
+ } from "./paths.js";
7
14
  import {
8
15
  createSnapshot,
9
16
  isSessionFilePath,
@@ -25,11 +32,12 @@ function fileHashMap(snapshot: Snapshot) {
25
32
  export async function applySnapshot(
26
33
  snapshot: Snapshot,
27
34
  protectedRelativePaths = new Set<string>(),
28
- options: Pick<SnapshotOptions, "sessionDir" | "extraFiles"> = {},
35
+ options: Pick<SnapshotOptions, "syncFiles" | "sessionDir" | "extraFiles"> = {},
29
36
  ) {
30
37
  const root = agentDir();
31
38
  const { sessionDir } = options;
32
39
  const current = await createSnapshot(snapshot.profile, {
40
+ syncFiles: options.syncFiles,
33
41
  syncSessions: snapshotIncludesSessions(snapshot),
34
42
  sessionDir,
35
43
  extraFiles: options.extraFiles,
@@ -79,7 +87,8 @@ export function preflightSnapshotApply(
79
87
 
80
88
  const target = snapshotTarget(root, normalized, options.sessionDir);
81
89
  const content = decodeBase64Strict(file.contentBase64, normalized);
82
- if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
90
+ if (sha256(content) !== file.sha256)
91
+ throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
83
92
  writes.push({ target, content });
84
93
  }
85
94
 
@@ -108,7 +117,9 @@ export function protectSnapshotApplyPlan(
108
117
  ): SnapshotApplyPlan {
109
118
  if (protectedRelativePaths.size === 0) return plan;
110
119
  const protectedTargets = new Set(
111
- [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir)),
120
+ [...protectedRelativePaths].map((relativePath) =>
121
+ snapshotTarget(root, relativePath, sessionDir),
122
+ ),
112
123
  );
113
124
  return {
114
125
  writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
@@ -141,7 +152,11 @@ export async function addTopLevelCaseVariantDeletes(
141
152
  const deletes = new Set(plan.deletes);
142
153
  for (const entry of entries) {
143
154
  const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
144
- if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
155
+ if (
156
+ canonicalPath &&
157
+ entry.name !== canonicalPath &&
158
+ (entry.isFile() || entry.isSymbolicLink())
159
+ ) {
145
160
  deletes.add(safeJoin(root, entry.name));
146
161
  }
147
162
  }
@@ -198,7 +213,11 @@ async function preflightSnapshotMutations(
198
213
  await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
199
214
  }
200
215
  for (const item of plan.writes) {
201
- await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
216
+ await prepareSnapshotWrite(
217
+ rootForTarget(root, item.target, sessionDir),
218
+ item.target,
219
+ deletePaths,
220
+ );
202
221
  }
203
222
  }
204
223
 
@@ -212,7 +231,8 @@ async function prepareSnapshotWrite(root: string, target: string, deletePaths: S
212
231
  await ensureSafeDirectory(root, path.dirname(target));
213
232
  try {
214
233
  const stat = await fs.lstat(target);
215
- if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
234
+ if (stat.isSymbolicLink())
235
+ throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
216
236
  if (stat.isDirectory() && !deletePaths.has(target)) {
217
237
  throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
218
238
  }
@@ -230,8 +250,10 @@ async function ensureSafeDirectory(root: string, directory: string) {
230
250
  current = path.join(current, part);
231
251
  try {
232
252
  const stat = await fs.lstat(current);
233
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
234
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
253
+ if (stat.isSymbolicLink())
254
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
255
+ if (!stat.isDirectory())
256
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
235
257
  } catch (error) {
236
258
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
237
259
  await fs.mkdir(current);
@@ -249,8 +271,10 @@ async function assertNoSymlinkParents(root: string, target: string) {
249
271
  current = path.join(current, part);
250
272
  try {
251
273
  const stat = await fs.lstat(current);
252
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
253
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
274
+ if (stat.isSymbolicLink())
275
+ throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
276
+ if (!stat.isDirectory())
277
+ throw new Error(`Snapshot path parent is not a directory: ${current}`);
254
278
  } catch (error) {
255
279
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
256
280
  throw error;