@vellumai/cli 0.11.3-dev.202608180132.6a0b3da → 0.11.3-dev.202608181511.30d84a2

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.
@@ -229,4 +229,37 @@ describe("unpairAssistant", () => {
229
229
  }
230
230
  expect(result.status).toBe(400);
231
231
  });
232
+
233
+ test("refuses with 423 under a held write lock, leaving entry and token alone", () => {
234
+ writeLockfile({
235
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
236
+ activeAssistant: "paired-1",
237
+ });
238
+ const tokenPath = seedGuardianToken("paired-1");
239
+ fs.mkdirSync(`${lockfilePath}.lock`);
240
+ try {
241
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
242
+
243
+ expect(result).toMatchObject({ ok: false, status: 423 });
244
+ expect(fs.existsSync(tokenPath)).toBe(true);
245
+ expect(readLockfileFromDisk().assistants).toEqual([
246
+ { assistantId: "paired-1", cloud: "paired" },
247
+ ]);
248
+ } finally {
249
+ fs.rmdirSync(`${lockfilePath}.lock`);
250
+ }
251
+ });
252
+
253
+ test("a successful unpair leaves no lock dir behind", () => {
254
+ writeLockfile({
255
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
256
+ activeAssistant: "paired-1",
257
+ });
258
+ seedGuardianToken("paired-1");
259
+
260
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
261
+
262
+ expect(result.ok).toBe(true);
263
+ expect(fs.existsSync(`${lockfilePath}.lock`)).toBe(false);
264
+ });
232
265
  });
@@ -37,6 +37,7 @@ export {
37
37
  } from "./environment";
38
38
  export {
39
39
  getLockfileData,
40
+ renameLockfileAssistantIfPresent,
40
41
  upsertLockfileAssistant,
41
42
  upsertRendererLockfileAssistant,
42
43
  replacePlatformAssistants,
@@ -44,6 +45,8 @@ export {
44
45
  isPairedLockfileEntry,
45
46
  } from "./lockfile";
46
47
  export type { LockfileResult, WriteResult } from "./lockfile";
48
+ export { withLockfileLock } from "./lockfile-lock";
49
+ export type { LockfileLockResult } from "./lockfile-lock";
47
50
  export { parseLockfile } from "./lockfile-contract";
48
51
  export type {
49
52
  Lockfile,
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { withLockfileLock } from "./lockfile-lock";
7
+
8
+ let dir: string;
9
+ let lockfilePath: string;
10
+ let lockDir: string;
11
+
12
+ beforeEach(() => {
13
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "lockfile-lock-test-"));
14
+ lockfilePath = path.join(dir, "lockfile.json");
15
+ lockDir = `${lockfilePath}.lock`;
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("withLockfileLock", () => {
23
+ test("runs fn, propagates its result, and removes the lock dir", () => {
24
+ const result = withLockfileLock([lockfilePath], () => {
25
+ expect(fs.existsSync(lockDir)).toBe(true);
26
+ return 42;
27
+ });
28
+ expect(result).toEqual({ ok: true, value: 42 });
29
+ expect(fs.existsSync(lockDir)).toBe(false);
30
+ });
31
+
32
+ test("keys the lock to the write path, ignoring fallback paths", () => {
33
+ const fallback = path.join(dir, "legacy.json");
34
+ withLockfileLock([lockfilePath, fallback], () => {
35
+ expect(fs.existsSync(lockDir)).toBe(true);
36
+ expect(fs.existsSync(`${fallback}.lock`)).toBe(false);
37
+ });
38
+ });
39
+
40
+ test("releases the lock when fn throws, rethrowing", () => {
41
+ expect(() =>
42
+ withLockfileLock([lockfilePath], () => {
43
+ throw new Error("boom");
44
+ }),
45
+ ).toThrow("boom");
46
+ expect(fs.existsSync(lockDir)).toBe(false);
47
+ });
48
+
49
+ test("is reentrant within the process", () => {
50
+ const result = withLockfileLock([lockfilePath], () => {
51
+ const inner = withLockfileLock([lockfilePath], () => "inner");
52
+ expect(inner).toEqual({ ok: true, value: "inner" });
53
+ // Inner exit must not release the outer hold.
54
+ expect(fs.existsSync(lockDir)).toBe(true);
55
+ return "outer";
56
+ });
57
+ expect(result).toEqual({ ok: true, value: "outer" });
58
+ expect(fs.existsSync(lockDir)).toBe(false);
59
+ });
60
+
61
+ test("fails cleanly within budget when another holder is fresh", () => {
62
+ fs.mkdirSync(lockDir);
63
+ let ran = false;
64
+ const start = Date.now();
65
+ const result = withLockfileLock([lockfilePath], () => {
66
+ ran = true;
67
+ });
68
+ expect(Date.now() - start).toBeLessThan(2_000);
69
+ expect(ran).toBe(false);
70
+ expect(result.ok).toBe(false);
71
+ if (!result.ok) {
72
+ expect(result.error).toContain("Timed out acquiring lockfile lock");
73
+ }
74
+ // The other holder's lock is untouched.
75
+ expect(fs.existsSync(lockDir)).toBe(true);
76
+ });
77
+
78
+ test("breaks a stale lock left by a crashed holder", () => {
79
+ fs.mkdirSync(lockDir);
80
+ const past = new Date(Date.now() - 60_000);
81
+ fs.utimesSync(lockDir, past, past);
82
+
83
+ const result = withLockfileLock([lockfilePath], () => "ran");
84
+ expect(result).toEqual({ ok: true, value: "ran" });
85
+ expect(fs.existsSync(lockDir)).toBe(false);
86
+ });
87
+
88
+ test("creates a missing parent directory and acquires", () => {
89
+ const nested = path.join(dir, "a", "b", "lockfile.json");
90
+ const result = withLockfileLock([nested], () => "ran");
91
+ expect(result).toEqual({ ok: true, value: "ran" });
92
+ expect(fs.existsSync(`${nested}.lock`)).toBe(false);
93
+ });
94
+
95
+ test("refuses an empty path list", () => {
96
+ expect(withLockfileLock([], () => "never").ok).toBe(false);
97
+ });
98
+ });
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type LockfileLockResult<T> =
5
+ | { ok: true; value: T }
6
+ | { ok: false; error: string };
7
+
8
+ /** A holder only holds for the microseconds a read-modify-write takes, so a
9
+ * lock this old belongs to a crashed process and is safe to break. */
10
+ const STALE_LOCK_MS = 5_000;
11
+ const ACQUIRE_ATTEMPTS = 10;
12
+ const RETRY_DELAY_MS = 25;
13
+
14
+ /** Locks held by this process, so composed writers reenter instead of deadlocking. */
15
+ const heldLocks = new Set<string>();
16
+
17
+ /** Node-compatible synchronous sleep (no Bun.sleepSync in Electron/Vite hosts). */
18
+ function sleepSync(ms: number): void {
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
20
+ }
21
+
22
+ function tryBreakStaleLock(lockDir: string): void {
23
+ try {
24
+ if (Date.now() - fs.statSync(lockDir).mtimeMs > STALE_LOCK_MS) {
25
+ fs.rmdirSync(lockDir);
26
+ }
27
+ } catch {
28
+ // Holder released or another contender broke it; next mkdir attempt decides.
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Run `fn` under a cross-process advisory lock keyed to the lockfile write
34
+ * path (`lockfilePaths[0]`). The lock is a `${writePath}.lock` directory:
35
+ * mkdir has O_EXCL semantics on every platform, so exactly one process wins.
36
+ * Acquisition is bounded (~250ms worst case) and failure is returned
37
+ * structurally rather than thrown, so never-throw seams stay never-throw;
38
+ * `fn`'s own exceptions propagate, with the lock released in `finally`.
39
+ */
40
+ export function withLockfileLock<T>(
41
+ lockfilePaths: string[],
42
+ fn: () => T,
43
+ ): LockfileLockResult<T> {
44
+ const writePath = lockfilePaths[0];
45
+ if (!writePath) {
46
+ return { ok: false, error: "No lockfile path to lock" };
47
+ }
48
+ const lockDir = `${writePath}.lock`;
49
+
50
+ if (heldLocks.has(lockDir)) {
51
+ return { ok: true, value: fn() };
52
+ }
53
+
54
+ let acquired = false;
55
+ for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt++) {
56
+ if (attempt > 0) sleepSync(RETRY_DELAY_MS);
57
+ try {
58
+ fs.mkdirSync(lockDir);
59
+ acquired = true;
60
+ break;
61
+ } catch (err) {
62
+ const code = (err as NodeJS.ErrnoException).code;
63
+ if (code === "EEXIST") {
64
+ tryBreakStaleLock(lockDir);
65
+ continue;
66
+ }
67
+ if (code === "ENOENT") {
68
+ try {
69
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
70
+ } catch {
71
+ // Surfaced by the next mkdir attempt.
72
+ }
73
+ continue;
74
+ }
75
+ return { ok: false, error: `Failed to acquire lockfile lock: ${err}` };
76
+ }
77
+ }
78
+ if (!acquired) {
79
+ return {
80
+ ok: false,
81
+ error: `Timed out acquiring lockfile lock at ${lockDir}`,
82
+ };
83
+ }
84
+
85
+ heldLocks.add(lockDir);
86
+ try {
87
+ return { ok: true, value: fn() };
88
+ } finally {
89
+ heldLocks.delete(lockDir);
90
+ try {
91
+ fs.rmdirSync(lockDir);
92
+ } catch {
93
+ // Best effort; a leftover dir is broken as stale by the next acquirer.
94
+ }
95
+ }
96
+ }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  getLockfileData,
8
8
  isPairedLockfileEntry,
9
+ renameLockfileAssistantIfPresent,
9
10
  replacePlatformAssistants,
10
11
  upsertLockfileAssistant,
11
12
  upsertRendererLockfileAssistant,
@@ -224,6 +225,33 @@ describe("upsertLockfileAssistant", () => {
224
225
  });
225
226
  });
226
227
 
228
+ test("a name-only payload preserves resources and on-disk secrets byte-for-byte", () => {
229
+ const entry = {
230
+ assistantId: "asst_1",
231
+ cloud: "local",
232
+ runtimeUrl: "http://a",
233
+ name: "Old Name",
234
+ resources: { gatewayPort: 7830, daemonPort: 7831, dataDir: "/tmp/x" },
235
+ signingKey: "sk-on-disk-secret",
236
+ bearerToken: "bt-on-disk-secret",
237
+ guardianBootstrapSecret: "gb-on-disk-secret",
238
+ };
239
+ writeOnDisk({ activeAssistant: "asst_1", assistants: [entry] });
240
+
241
+ const result = upsertLockfileAssistant(
242
+ [lockfilePath],
243
+ { assistantId: "asst_1", name: "Renamed" },
244
+ undefined,
245
+ );
246
+
247
+ expect(result.ok).toBe(true);
248
+ const assistants = readOnDisk().assistants as Array<
249
+ Record<string, unknown>
250
+ >;
251
+ expect(assistants).toHaveLength(1);
252
+ expect(assistants[0]).toEqual({ ...entry, name: "Renamed" });
253
+ });
254
+
227
255
  test("preserves activeAssistant when no active id is provided", () => {
228
256
  writeOnDisk({
229
257
  activeAssistant: "asst_active",
@@ -251,6 +279,198 @@ describe("upsertLockfileAssistant", () => {
251
279
  });
252
280
  });
253
281
 
282
+ describe("renameLockfileAssistantIfPresent", () => {
283
+ const entry = {
284
+ assistantId: "asst_1",
285
+ cloud: "local",
286
+ runtimeUrl: "http://a",
287
+ name: "Old Name",
288
+ resources: { gatewayPort: 7830, daemonPort: 7831, dataDir: "/tmp/x" },
289
+ signingKey: "sk-on-disk-secret",
290
+ bearerToken: "bt-on-disk-secret",
291
+ };
292
+
293
+ test("renames the entry preserving resources, secrets, and activeAssistant", () => {
294
+ writeOnDisk({
295
+ activeAssistant: "asst_other",
296
+ assistants: [entry, { assistantId: "asst_other", cloud: "local" }],
297
+ });
298
+
299
+ const result = renameLockfileAssistantIfPresent(
300
+ [lockfilePath],
301
+ "asst_1",
302
+ "Renamed",
303
+ );
304
+
305
+ expect(result.ok).toBe(true);
306
+ const onDisk = readOnDisk();
307
+ expect(onDisk.activeAssistant).toBe("asst_other");
308
+ const assistants = onDisk.assistants as Array<Record<string, unknown>>;
309
+ expect(assistants).toEqual([
310
+ { ...entry, name: "Renamed" },
311
+ { assistantId: "asst_other", cloud: "local" },
312
+ ]);
313
+ });
314
+
315
+ test("refuses a missing entry without writing the file", () => {
316
+ const result = renameLockfileAssistantIfPresent(
317
+ [lockfilePath],
318
+ "asst_gone",
319
+ "Renamed",
320
+ );
321
+
322
+ expect(result).toEqual({
323
+ ok: false,
324
+ status: 404,
325
+ error: "No lockfile entry for this assistant",
326
+ });
327
+ expect(fs.existsSync(lockfilePath)).toBe(false);
328
+ });
329
+
330
+ test("refuses when the id names another process's retired entry", () => {
331
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
332
+ const before = fs.readFileSync(lockfilePath, "utf-8");
333
+
334
+ const result = renameLockfileAssistantIfPresent(
335
+ [lockfilePath],
336
+ "asst_retired",
337
+ "Renamed",
338
+ );
339
+
340
+ expect(result.ok).toBe(false);
341
+ if (!result.ok) {
342
+ expect(result.status).toBe(404);
343
+ }
344
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
345
+ });
346
+
347
+ test("refuses a corrupt on-disk file without clobbering it", () => {
348
+ fs.writeFileSync(lockfilePath, "{ not json");
349
+
350
+ const result = renameLockfileAssistantIfPresent(
351
+ [lockfilePath],
352
+ "asst_1",
353
+ "Renamed",
354
+ );
355
+
356
+ expect(result.ok).toBe(false);
357
+ if (!result.ok) {
358
+ expect(result.status).toBe(409);
359
+ }
360
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe("{ not json");
361
+ });
362
+
363
+ test("an already-equal name succeeds without rewriting the file", () => {
364
+ // Compact formatting: any rewrite would re-indent and change the bytes.
365
+ fs.writeFileSync(
366
+ lockfilePath,
367
+ JSON.stringify({ activeAssistant: null, assistants: [entry] }),
368
+ );
369
+ const before = fs.readFileSync(lockfilePath, "utf-8");
370
+
371
+ const result = renameLockfileAssistantIfPresent(
372
+ [lockfilePath],
373
+ "asst_1",
374
+ "Old Name",
375
+ );
376
+
377
+ expect(result.ok).toBe(true);
378
+ if (result.ok) {
379
+ expect(result.lockfile.assistants[0]?.name).toBe("Old Name");
380
+ expect(result.lockfile.assistants[0]).not.toHaveProperty("signingKey");
381
+ }
382
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
383
+ });
384
+
385
+ test("rejects a missing id or name without touching disk", () => {
386
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
387
+ const before = fs.readFileSync(lockfilePath, "utf-8");
388
+
389
+ expect(renameLockfileAssistantIfPresent([lockfilePath], "", "N").ok).toBe(
390
+ false,
391
+ );
392
+ expect(
393
+ renameLockfileAssistantIfPresent([lockfilePath], "asst_1", "").ok,
394
+ ).toBe(false);
395
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
396
+ });
397
+ });
398
+
399
+ describe("lockfile writers under lock contention", () => {
400
+ const entry = {
401
+ assistantId: "asst_1",
402
+ cloud: "local",
403
+ runtimeUrl: "http://a",
404
+ name: "Old Name",
405
+ };
406
+
407
+ function holdLock(): string {
408
+ const lockDir = `${lockfilePath}.lock`;
409
+ fs.mkdirSync(lockDir);
410
+ return lockDir;
411
+ }
412
+
413
+ test("rename refuses with 423 and leaves the file untouched", () => {
414
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
415
+ const before = fs.readFileSync(lockfilePath, "utf-8");
416
+ holdLock();
417
+
418
+ const result = renameLockfileAssistantIfPresent(
419
+ [lockfilePath],
420
+ "asst_1",
421
+ "Renamed",
422
+ );
423
+
424
+ expect(result.ok).toBe(false);
425
+ if (!result.ok) {
426
+ expect(result.status).toBe(423);
427
+ }
428
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
429
+ });
430
+
431
+ test("upsert refuses with 423 and leaves the file untouched", () => {
432
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
433
+ const before = fs.readFileSync(lockfilePath, "utf-8");
434
+ holdLock();
435
+
436
+ const result = upsertLockfileAssistant(
437
+ [lockfilePath],
438
+ { assistantId: "asst_2", cloud: "local" },
439
+ undefined,
440
+ );
441
+
442
+ expect(result).toMatchObject({ ok: false, status: 423 });
443
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
444
+ });
445
+
446
+ test("platform replace refuses with 423 and leaves the file untouched", () => {
447
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
448
+ const before = fs.readFileSync(lockfilePath, "utf-8");
449
+ holdLock();
450
+
451
+ const result = replacePlatformAssistants(
452
+ [lockfilePath],
453
+ [{ assistantId: "asst_p", cloud: "vellum", runtimeUrl: "http://p" }],
454
+ );
455
+
456
+ expect(result).toMatchObject({ ok: false, status: 423 });
457
+ expect(fs.readFileSync(lockfilePath, "utf-8")).toBe(before);
458
+ });
459
+
460
+ test("a successful rename leaves no lock dir behind", () => {
461
+ writeOnDisk({ activeAssistant: null, assistants: [entry] });
462
+
463
+ const result = renameLockfileAssistantIfPresent(
464
+ [lockfilePath],
465
+ "asst_1",
466
+ "Renamed",
467
+ );
468
+
469
+ expect(result.ok).toBe(true);
470
+ expect(fs.existsSync(`${lockfilePath}.lock`)).toBe(false);
471
+ });
472
+ });
473
+
254
474
  describe("upsertRendererLockfileAssistant", () => {
255
475
  const paired = {
256
476
  assistantId: "paired-1",
@@ -6,6 +6,7 @@ import {
6
6
  resolveCloud,
7
7
  type Lockfile,
8
8
  } from "./lockfile-contract";
9
+ import { withLockfileLock } from "./lockfile-lock";
9
10
  import { stripSensitiveFields } from "./util";
10
11
 
11
12
  export type LockfileResult =
@@ -63,6 +64,11 @@ export type WriteResult =
63
64
  | { ok: true; lockfile: Lockfile }
64
65
  | { ok: false; status: number; error: string };
65
66
 
67
+ /** 423 Locked: the cross-process advisory lock could not be acquired. */
68
+ function lockFailure(error: string): WriteResult {
69
+ return { ok: false, status: 423, error };
70
+ }
71
+
66
72
  /**
67
73
  * Read the first parseable lockfile as raw JSON (unknown fields intact) for a
68
74
  * read-modify-write cycle; an unreadable file yields an empty lockfile.
@@ -83,6 +89,50 @@ export function readRawLockfile(
83
89
  return { assistants: [], activeAssistant: null };
84
90
  }
85
91
 
92
+ export type RawLockfileReadResult =
93
+ | { ok: true; lockfile: Record<string, unknown> }
94
+ | { ok: false; error: string };
95
+
96
+ /**
97
+ * Like {@link readRawLockfile}, but a file that exists and cannot be read or
98
+ * parsed refuses instead of degrading to the empty registry. Missing or empty
99
+ * files still yield the empty registry. Use for writes that must never
100
+ * replace a registry they could not actually read.
101
+ */
102
+ export function readRawLockfileStrict(
103
+ lockfilePaths: string[],
104
+ ): RawLockfileReadResult {
105
+ for (const candidate of lockfilePaths) {
106
+ let raw: string;
107
+ try {
108
+ raw = fs.readFileSync(candidate, "utf-8");
109
+ } catch (err) {
110
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
111
+ return { ok: false, error: `Failed to read lockfile: ${err}` };
112
+ }
113
+ if (raw.trim() === "") continue;
114
+ try {
115
+ return {
116
+ ok: true,
117
+ lockfile: JSON.parse(raw) as Record<string, unknown>,
118
+ };
119
+ } catch (err) {
120
+ return { ok: false, error: `Failed to parse lockfile: ${err}` };
121
+ }
122
+ }
123
+ return { ok: true, lockfile: { assistants: [], activeAssistant: null } };
124
+ }
125
+
126
+ /** The validated, sensitive-field-stripped wire view of a raw lockfile. */
127
+ function toWireLockfile(lockfile: Record<string, unknown>): Lockfile {
128
+ const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<
129
+ string,
130
+ unknown
131
+ >;
132
+ stripSensitiveFields(stripped);
133
+ return parseLockfile(stripped);
134
+ }
135
+
86
136
  /**
87
137
  * Atomically persist a raw lockfile (write-to-temp + rename) and return the
88
138
  * validated, sensitive-field-stripped view of what was written.
@@ -102,12 +152,56 @@ export function writeRawLockfile(
102
152
  return { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
103
153
  }
104
154
 
105
- const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<
106
- string,
107
- unknown
108
- >;
109
- stripSensitiveFields(stripped);
110
- return { ok: true, lockfile: parseLockfile(stripped) };
155
+ return { ok: true, lockfile: toWireLockfile(lockfile) };
156
+ }
157
+
158
+ /**
159
+ * Rename an existing assistant entry, never creating one. Unlike the upserts,
160
+ * a missing entry refuses (404) and an unreadable on-disk file refuses (409)
161
+ * instead of being treated as an empty registry, so a stale renderer cache
162
+ * can neither resurrect a retired assistant nor replace a registry it could
163
+ * not read with a skeleton row. Never touches `activeAssistant`. The whole
164
+ * read-check-write runs under the shared advisory lock so a concurrent writer
165
+ * (e.g. a CLI retire) cannot be clobbered by this snapshot; lock contention
166
+ * refuses (423) without writing.
167
+ */
168
+ export function renameLockfileAssistantIfPresent(
169
+ lockfilePaths: string[],
170
+ assistantId: string,
171
+ name: string,
172
+ ): WriteResult {
173
+ if (typeof assistantId !== "string" || assistantId === "") {
174
+ return { ok: false, status: 400, error: "Missing assistantId" };
175
+ }
176
+ if (typeof name !== "string" || name === "") {
177
+ return { ok: false, status: 400, error: "Missing name" };
178
+ }
179
+
180
+ const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
181
+ const read = readRawLockfileStrict(lockfilePaths);
182
+ if (!read.ok) {
183
+ return { ok: false, status: 409, error: read.error };
184
+ }
185
+ const lockfile = read.lockfile;
186
+ const assistants = Array.isArray(lockfile.assistants)
187
+ ? (lockfile.assistants as Array<Record<string, unknown>>)
188
+ : [];
189
+ const idx = assistants.findIndex((a) => a?.assistantId === assistantId);
190
+ if (idx < 0) {
191
+ return {
192
+ ok: false,
193
+ status: 404,
194
+ error: "No lockfile entry for this assistant",
195
+ };
196
+ }
197
+ if (assistants[idx]!.name === name) {
198
+ return { ok: true, lockfile: toWireLockfile(lockfile) };
199
+ }
200
+ assistants[idx] = { ...assistants[idx], name };
201
+ lockfile.assistants = assistants;
202
+ return writeRawLockfile(lockfilePaths, lockfile);
203
+ });
204
+ return locked.ok ? locked.value : lockFailure(locked.error);
111
205
  }
112
206
 
113
207
  export function upsertLockfileAssistant(
@@ -119,22 +213,25 @@ export function upsertLockfileAssistant(
119
213
  return { ok: false, status: 400, error: "Missing assistant.assistantId" };
120
214
  }
121
215
 
122
- const lockfile = readRawLockfile(lockfilePaths);
123
- const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
124
- const existingIdx = assistants.findIndex(
125
- (a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
126
- );
127
- if (existingIdx >= 0) {
128
- assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
129
- } else {
130
- assistants.push(assistant);
131
- }
132
- lockfile.assistants = assistants;
133
- if (activeAssistant !== undefined) {
134
- lockfile.activeAssistant = activeAssistant;
135
- }
216
+ const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
217
+ const lockfile = readRawLockfile(lockfilePaths);
218
+ const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
219
+ const existingIdx = assistants.findIndex(
220
+ (a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
221
+ );
222
+ if (existingIdx >= 0) {
223
+ assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
224
+ } else {
225
+ assistants.push(assistant);
226
+ }
227
+ lockfile.assistants = assistants;
228
+ if (activeAssistant !== undefined) {
229
+ lockfile.activeAssistant = activeAssistant;
230
+ }
136
231
 
137
- return writeRawLockfile(lockfilePaths, lockfile);
232
+ return writeRawLockfile(lockfilePaths, lockfile);
233
+ });
234
+ return locked.ok ? locked.value : lockFailure(locked.error);
138
235
  }
139
236
 
140
237
  const PAIRED_LOCKFILE_WRITE_ERROR =
@@ -154,33 +251,38 @@ export function upsertRendererLockfileAssistant(
154
251
  return { ok: false, status: 400, error: "Missing assistant.assistantId" };
155
252
  }
156
253
 
157
- const lockfile = readRawLockfile(lockfilePaths);
158
- const assistants = Array.isArray(lockfile.assistants)
159
- ? (lockfile.assistants as Array<Record<string, unknown>>)
160
- : [];
161
- const existing = assistants.find(
162
- (entry) => entry?.assistantId === assistant.assistantId,
163
- );
164
- const merged = { ...existing, ...assistant };
165
- const existingIsPaired =
166
- existing != null &&
167
- (resolveCloud(existing) === "paired" || existing.paired === true);
168
- const mergedIsPaired =
169
- resolveCloud(merged) === "paired" || merged.paired === true;
170
-
171
- if (!existingIsPaired && mergedIsPaired) {
172
- return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
173
- }
174
- if (
175
- existingIsPaired &&
176
- (resolveCloud(merged) !== "paired" ||
177
- merged.runtimeUrl !== existing.runtimeUrl ||
178
- merged.paired !== existing.paired)
179
- ) {
180
- return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
181
- }
254
+ // Lock spans the paired-guard read and the upsert (which reenters) so the
255
+ // guard cannot be judged against a snapshot another writer replaces.
256
+ const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
257
+ const lockfile = readRawLockfile(lockfilePaths);
258
+ const assistants = Array.isArray(lockfile.assistants)
259
+ ? (lockfile.assistants as Array<Record<string, unknown>>)
260
+ : [];
261
+ const existing = assistants.find(
262
+ (entry) => entry?.assistantId === assistant.assistantId,
263
+ );
264
+ const merged = { ...existing, ...assistant };
265
+ const existingIsPaired =
266
+ existing != null &&
267
+ (resolveCloud(existing) === "paired" || existing.paired === true);
268
+ const mergedIsPaired =
269
+ resolveCloud(merged) === "paired" || merged.paired === true;
182
270
 
183
- return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
271
+ if (!existingIsPaired && mergedIsPaired) {
272
+ return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
273
+ }
274
+ if (
275
+ existingIsPaired &&
276
+ (resolveCloud(merged) !== "paired" ||
277
+ merged.runtimeUrl !== existing.runtimeUrl ||
278
+ merged.paired !== existing.paired)
279
+ ) {
280
+ return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
281
+ }
282
+
283
+ return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
284
+ });
285
+ return locked.ok ? locked.value : lockFailure(locked.error);
184
286
  }
185
287
 
186
288
  export function isActiveAssistant(
@@ -226,24 +328,27 @@ export function replacePlatformAssistants(
226
328
  };
227
329
  }
228
330
 
229
- const lockfile = readRawLockfile(lockfilePaths);
230
- const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
231
- const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
232
- // Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
233
- const preserved = existing.filter((a: Record<string, unknown>) => {
234
- if (a?.cloud !== "vellum") return true;
235
- if (syncedIds.has(a.assistantId)) return false;
236
- return organizationId != null && a.organizationId !== organizationId;
237
- });
238
- lockfile.assistants = [...preserved, ...platformAssistants];
331
+ const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
332
+ const lockfile = readRawLockfile(lockfilePaths);
333
+ const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
334
+ const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
335
+ // Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
336
+ const preserved = existing.filter((a: Record<string, unknown>) => {
337
+ if (a?.cloud !== "vellum") return true;
338
+ if (syncedIds.has(a.assistantId)) return false;
339
+ return organizationId != null && a.organizationId !== organizationId;
340
+ });
341
+ lockfile.assistants = [...preserved, ...platformAssistants];
239
342
 
240
- const active = lockfile.activeAssistant as string | null;
241
- if (active) {
242
- const stillExists = (lockfile.assistants as Array<Record<string, unknown>>).some(
243
- (a) => a.assistantId === active,
244
- );
245
- if (!stillExists) lockfile.activeAssistant = null;
246
- }
343
+ const active = lockfile.activeAssistant as string | null;
344
+ if (active) {
345
+ const stillExists = (lockfile.assistants as Array<Record<string, unknown>>).some(
346
+ (a) => a.assistantId === active,
347
+ );
348
+ if (!stillExists) lockfile.activeAssistant = null;
349
+ }
247
350
 
248
- return writeRawLockfile(lockfilePaths, lockfile);
351
+ return writeRawLockfile(lockfilePaths, lockfile);
352
+ });
353
+ return locked.ok ? locked.value : lockFailure(locked.error);
249
354
  }
@@ -8,6 +8,7 @@ import {
8
8
  type WriteResult,
9
9
  } from "./lockfile";
10
10
  import { resolveCloud } from "./lockfile-contract";
11
+ import { withLockfileLock } from "./lockfile-lock";
11
12
 
12
13
  /**
13
14
  * Forget a paired assistant on this machine: remove its lockfile entry and
@@ -21,6 +22,23 @@ export function unpairAssistant(
21
22
  lockfilePaths: string[],
22
23
  configDir: string,
23
24
  assistantId: string,
25
+ ): WriteResult {
26
+ // The whole transaction holds the shared write lock so a concurrent
27
+ // read-modify-write (e.g. a persona-name rename) cannot restore the entry
28
+ // after its credential is deleted.
29
+ const locked = withLockfileLock(lockfilePaths, (): WriteResult =>
30
+ unpairAssistantLocked(lockfilePaths, configDir, assistantId),
31
+ );
32
+ if (!locked.ok) {
33
+ return { ok: false, status: 423, error: locked.error };
34
+ }
35
+ return locked.value;
36
+ }
37
+
38
+ function unpairAssistantLocked(
39
+ lockfilePaths: string[],
40
+ configDir: string,
41
+ assistantId: string,
24
42
  ): WriteResult {
25
43
  const lockfile = readRawLockfile(lockfilePaths);
26
44
  const assistants = Array.isArray(lockfile.assistants)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.11.3-dev.202608180132.6a0b3da",
3
+ "version": "0.11.3-dev.202608181511.30d84a2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,10 +8,12 @@ import {
8
8
  spyOn,
9
9
  } from "bun:test";
10
10
  import {
11
+ existsSync,
11
12
  mkdirSync,
12
13
  mkdtempSync,
13
14
  readFileSync,
14
15
  rmSync,
16
+ utimesSync,
15
17
  writeFileSync,
16
18
  } from "node:fs";
17
19
  import { tmpdir } from "node:os";
@@ -97,6 +99,39 @@ describe("assistant-config", () => {
97
99
  expect(all[0].assistantId).toBe("test-1");
98
100
  });
99
101
 
102
+ test("saveAssistantEntry writes under the advisory lock and cleans it up", () => {
103
+ saveAssistantEntry(makeEntry("locked-1"));
104
+ expect(loadAllAssistants()).toHaveLength(1);
105
+ // The shared `${lockfilePath}.lock` key hosts contend on is released.
106
+ expect(existsSync(join(testDir, ".vellum.lock.json.lock"))).toBe(false);
107
+ });
108
+
109
+ test("saveAssistantEntry breaks a stale lock and still lands the write", () => {
110
+ const lockDir = join(testDir, ".vellum.lock.json.lock");
111
+ mkdirSync(lockDir);
112
+ const past = new Date(Date.now() - 60_000);
113
+ utimesSync(lockDir, past, past);
114
+
115
+ saveAssistantEntry(makeEntry("stale-lock-1"));
116
+ expect(loadAllAssistants()).toHaveLength(1);
117
+ expect(existsSync(lockDir)).toBe(false);
118
+ });
119
+
120
+ test("saveAssistantEntry fails fast without writing when another holder is fresh", () => {
121
+ const lockDir = join(testDir, ".vellum.lock.json.lock");
122
+ mkdirSync(lockDir);
123
+ try {
124
+ const start = Date.now();
125
+ expect(() => saveAssistantEntry(makeEntry("contended-1"))).toThrow(
126
+ /Timed out acquiring lockfile lock/,
127
+ );
128
+ expect(Date.now() - start).toBeLessThan(2_000);
129
+ expect(loadAllAssistants()).toEqual([]);
130
+ } finally {
131
+ rmSync(lockDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
100
135
  test("findAssistantByName returns matching entry", () => {
101
136
  writeLockfile({
102
137
  assistants: [makeEntry("alpha"), makeEntry("beta")],
@@ -29,6 +29,7 @@ import {
29
29
  } from "../lib/client-identity";
30
30
  import {
31
31
  getLockfileData,
32
+ renameLockfileAssistantIfPresent,
32
33
  upsertRendererLockfileAssistant,
33
34
  replacePlatformAssistants,
34
35
  isActiveAssistant,
@@ -556,6 +557,13 @@ async function handleLocalEndpoints(
556
557
  body.platformAssistants as Array<Record<string, unknown>>,
557
558
  body.organizationId as string | undefined,
558
559
  );
560
+ } else if (body.rename && typeof body.rename === "object") {
561
+ const rename = body.rename as Record<string, unknown>;
562
+ result = renameLockfileAssistantIfPresent(
563
+ lockfilePaths,
564
+ rename.assistantId as string,
565
+ rename.name as string,
566
+ );
559
567
  } else {
560
568
  result = upsertRendererLockfileAssistant(
561
569
  lockfilePaths,
@@ -11,6 +11,7 @@ import { homedir } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
13
  import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
14
+ import { withLockfileLock } from "@vellumai/local-mode";
14
15
  import {
15
16
  resolveCloud,
16
17
  type LocalAssistantResources,
@@ -186,7 +187,7 @@ function readLockfile(): LockfileData {
186
187
  return {};
187
188
  }
188
189
 
189
- function writeLockfile(data: LockfileData): void {
190
+ function writeLockfileUnlocked(data: LockfileData): void {
190
191
  const lockfilePath = getLockfilePath(getCurrentEnvironment());
191
192
  mkdirSync(dirname(lockfilePath), { recursive: true });
192
193
  const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
@@ -201,6 +202,23 @@ function writeLockfile(data: LockfileData): void {
201
202
  }
202
203
  }
203
204
 
205
+ /**
206
+ * Run `fn` under the same cross-process advisory lock the local-mode hosts
207
+ * use, keyed to this environment's write-path lockfile, so CLI writers and
208
+ * package-side writers (Electron main, Vite plugin, `vellum client`) never
209
+ * interleave read-modify-write cycles. Reentrant, so wrapped units may call
210
+ * the locked write helpers. Throws on lock timeout, matching the fs-error
211
+ * contract of the write path.
212
+ */
213
+ function withCliLockfileLock<T>(fn: () => T): T {
214
+ const locked = withLockfileLock(
215
+ [getLockfilePath(getCurrentEnvironment())],
216
+ fn,
217
+ );
218
+ if (!locked.ok) throw new Error(locked.error);
219
+ return locked.value;
220
+ }
221
+
204
222
  /**
205
223
  * Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
206
224
  * Returns undefined if the URL is malformed or has no explicit port.
@@ -322,7 +340,18 @@ function readAssistants(): AssistantEntry[] {
322
340
  }
323
341
 
324
342
  if (migrated) {
325
- writeLockfile(data);
343
+ // Persist the backfill against a fresh read under the lock so this
344
+ // snapshot cannot clobber a concurrent writer; skip on contention (the
345
+ // in-memory result below is already migrated).
346
+ withLockfileLock([getLockfilePath(getCurrentEnvironment())], () => {
347
+ const fresh = readLockfile();
348
+ if (!Array.isArray(fresh.assistants)) return;
349
+ let freshMigrated = false;
350
+ for (const entry of fresh.assistants) {
351
+ if (migrateLegacyEntry(entry)) freshMigrated = true;
352
+ }
353
+ if (freshMigrated) writeLockfileUnlocked(fresh);
354
+ });
326
355
  }
327
356
 
328
357
  const result: AssistantEntry[] = [];
@@ -342,9 +371,11 @@ function readAssistants(): AssistantEntry[] {
342
371
  }
343
372
 
344
373
  function writeAssistants(entries: AssistantEntry[]): void {
345
- const data = readLockfile();
346
- data.assistants = entries;
347
- writeLockfile(data);
374
+ withCliLockfileLock(() => {
375
+ const data = readLockfile();
376
+ data.assistants = entries;
377
+ writeLockfileUnlocked(data);
378
+ });
348
379
  }
349
380
 
350
381
  export function loadLatestAssistant(): AssistantEntry | null {
@@ -428,21 +459,23 @@ export function formatAssistantLookupError(
428
459
  }
429
460
 
430
461
  export function removeAssistantEntry(assistantId: string): void {
431
- const data = readLockfile();
432
- const entries = (data.assistants ?? []).filter(
433
- (e) => e.assistantId !== assistantId,
434
- );
435
- data.assistants = entries;
436
- // Reassign active assistant if it matches the removed entry
437
- if (data.activeAssistant === assistantId) {
438
- const remaining = entries[0];
439
- if (remaining) {
440
- data.activeAssistant = String(remaining.assistantId);
441
- } else {
442
- delete data.activeAssistant;
462
+ withCliLockfileLock(() => {
463
+ const data = readLockfile();
464
+ const entries = (data.assistants ?? []).filter(
465
+ (e) => e.assistantId !== assistantId,
466
+ );
467
+ data.assistants = entries;
468
+ // Reassign active assistant if it matches the removed entry
469
+ if (data.activeAssistant === assistantId) {
470
+ const remaining = entries[0];
471
+ if (remaining) {
472
+ data.activeAssistant = String(remaining.assistantId);
473
+ } else {
474
+ delete data.activeAssistant;
475
+ }
443
476
  }
444
- }
445
- writeLockfile(data);
477
+ writeLockfileUnlocked(data);
478
+ });
446
479
  }
447
480
 
448
481
  export function loadAllAssistants(): AssistantEntry[] {
@@ -519,9 +552,11 @@ export function getActiveAssistant(): string | null {
519
552
  }
520
553
 
521
554
  export function setActiveAssistant(assistantId: string): void {
522
- const data = readLockfile();
523
- data.activeAssistant = assistantId;
524
- writeLockfile(data);
555
+ withCliLockfileLock(() => {
556
+ const data = readLockfile();
557
+ data.activeAssistant = assistantId;
558
+ writeLockfileUnlocked(data);
559
+ });
525
560
  }
526
561
 
527
562
  /**
@@ -607,11 +642,13 @@ export function extractHostFromUrl(url: string): string {
607
642
  }
608
643
 
609
644
  export function saveAssistantEntry(entry: AssistantEntry): void {
610
- const entries = readAssistants().filter(
611
- (e) => e.assistantId !== entry.assistantId,
612
- );
613
- entries.unshift(entry);
614
- writeAssistants(entries);
645
+ withCliLockfileLock(() => {
646
+ const entries = readAssistants().filter(
647
+ (e) => e.assistantId !== entry.assistantId,
648
+ );
649
+ entries.unshift(entry);
650
+ writeAssistants(entries);
651
+ });
615
652
  }
616
653
 
617
654
  /**