@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.
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +96 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +220 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +170 -65
- package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +35 -0
- package/src/commands/client.ts +8 -0
- package/src/lib/assistant-config.ts +64 -27
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
@@ -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")],
|
package/src/commands/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
/**
|