@vellumai/cli 0.10.12 → 0.11.0-dev.202607290139.f1746d4
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/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +9 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +449 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +468 -0
- package/package.json +1 -1
- package/src/__tests__/drain.test.ts +337 -0
- package/src/__tests__/flags.test.ts +11 -11
- package/src/__tests__/local-hatch-lock.test.ts +163 -0
- package/src/__tests__/sleep.test.ts +171 -2
- package/src/__tests__/wake.test.ts +38 -1
- package/src/commands/flags.ts +3 -3
- package/src/commands/pair.ts +2 -2
- package/src/commands/sleep.ts +151 -2
- package/src/commands/wake.ts +47 -5
- package/src/lib/drain.ts +346 -0
- package/src/lib/flag-args.test.ts +3 -3
- package/src/lib/flag-args.ts +2 -2
- package/src/lib/hatch-local.ts +98 -86
- package/src/lib/local-hatch-lock.ts +322 -0
- package/src/lib/local.ts +20 -2
- package/src/lib/provider-secrets.ts +4 -0
- package/src/shared/provider-env-vars.ts +2 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { drainAssistant, parseWaitDuration } from "../lib/drain.js";
|
|
4
|
+
|
|
5
|
+
const BASE = "http://127.0.0.1:7823";
|
|
6
|
+
|
|
7
|
+
interface RecordedCall {
|
|
8
|
+
url: string;
|
|
9
|
+
method: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fetch stub: routes requests through `handler` and records every call.
|
|
14
|
+
* Throwing from the handler simulates a network failure.
|
|
15
|
+
*/
|
|
16
|
+
function fetchStub(
|
|
17
|
+
handler: (url: string, method: string) => Response | Promise<Response>,
|
|
18
|
+
): {
|
|
19
|
+
impl: (url: string, init?: RequestInit) => Promise<Response>;
|
|
20
|
+
calls: RecordedCall[];
|
|
21
|
+
} {
|
|
22
|
+
const calls: RecordedCall[] = [];
|
|
23
|
+
const impl = async (url: string, init?: RequestInit): Promise<Response> => {
|
|
24
|
+
const method = init?.method ?? "GET";
|
|
25
|
+
calls.push({ url, method });
|
|
26
|
+
return handler(url, method);
|
|
27
|
+
};
|
|
28
|
+
return { impl, calls };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
32
|
+
return new Response(JSON.stringify(body), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function busyStatus(): unknown {
|
|
39
|
+
return {
|
|
40
|
+
quiescedUntil: Date.now() + 60_000,
|
|
41
|
+
idle: false,
|
|
42
|
+
activeConversations: [],
|
|
43
|
+
memoryJobs: [
|
|
44
|
+
{ id: "job-1", type: "memory_retrospective", startedAt: Date.now() },
|
|
45
|
+
],
|
|
46
|
+
scheduleRuns: [],
|
|
47
|
+
workflowRuns: [],
|
|
48
|
+
heartbeatRuns: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function workflowBusyStatus(): unknown {
|
|
53
|
+
return {
|
|
54
|
+
quiescedUntil: Date.now() + 60_000,
|
|
55
|
+
idle: false,
|
|
56
|
+
activeConversations: [],
|
|
57
|
+
memoryJobs: [],
|
|
58
|
+
scheduleRuns: [],
|
|
59
|
+
workflowRuns: [
|
|
60
|
+
{ runId: "wf-1", name: "nightly-report", startedAt: Date.now() },
|
|
61
|
+
],
|
|
62
|
+
heartbeatRuns: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function idleStatus(): unknown {
|
|
67
|
+
return {
|
|
68
|
+
quiescedUntil: Date.now() + 60_000,
|
|
69
|
+
idle: true,
|
|
70
|
+
activeConversations: [],
|
|
71
|
+
memoryJobs: [],
|
|
72
|
+
scheduleRuns: [],
|
|
73
|
+
workflowRuns: [],
|
|
74
|
+
heartbeatRuns: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function idleExpiredLeaseStatus(): unknown {
|
|
79
|
+
return { ...(idleStatus() as Record<string, unknown>), quiescedUntil: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("parseWaitDuration", () => {
|
|
83
|
+
test("parses bare seconds, seconds, and minutes", () => {
|
|
84
|
+
expect(parseWaitDuration("90")).toBe(90_000);
|
|
85
|
+
expect(parseWaitDuration("90s")).toBe(90_000);
|
|
86
|
+
expect(parseWaitDuration("10m")).toBe(600_000);
|
|
87
|
+
expect(parseWaitDuration(" 5s ")).toBe(5_000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("rejects invalid durations", () => {
|
|
91
|
+
expect(parseWaitDuration("0")).toBeNull();
|
|
92
|
+
expect(parseWaitDuration("abc")).toBeNull();
|
|
93
|
+
expect(parseWaitDuration("5x")).toBeNull();
|
|
94
|
+
expect(parseWaitDuration("-5")).toBeNull();
|
|
95
|
+
expect(parseWaitDuration("")).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("drainAssistant", () => {
|
|
100
|
+
test("arms the lease, narrates, and returns drained when work finishes", async () => {
|
|
101
|
+
let statusCalls = 0;
|
|
102
|
+
const { impl, calls } = fetchStub((url, method) => {
|
|
103
|
+
if (url.endsWith("/v1/lifecycle/quiesce") && method === "POST") {
|
|
104
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
105
|
+
}
|
|
106
|
+
statusCalls += 1;
|
|
107
|
+
return jsonResponse(statusCalls < 3 ? busyStatus() : idleStatus());
|
|
108
|
+
});
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
const outcome = await drainAssistant({
|
|
112
|
+
baseUrl: BASE,
|
|
113
|
+
token: "tok",
|
|
114
|
+
deadlineAt: null,
|
|
115
|
+
pollIntervalMs: 1,
|
|
116
|
+
fetchImpl: impl,
|
|
117
|
+
log: (line) => lines.push(line),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(outcome).toBe("drained");
|
|
121
|
+
expect(calls[0]).toEqual({
|
|
122
|
+
url: `${BASE}/v1/lifecycle/quiesce`,
|
|
123
|
+
method: "POST",
|
|
124
|
+
});
|
|
125
|
+
expect(lines.join("\n")).toContain("memory memory_retrospective");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a running workflow keeps the drain busy and is narrated", async () => {
|
|
129
|
+
let statusCalls = 0;
|
|
130
|
+
const { impl } = fetchStub((_url, method) => {
|
|
131
|
+
if (method === "POST") {
|
|
132
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
133
|
+
}
|
|
134
|
+
statusCalls += 1;
|
|
135
|
+
return jsonResponse(
|
|
136
|
+
statusCalls < 2 ? workflowBusyStatus() : idleStatus(),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
|
|
141
|
+
const outcome = await drainAssistant({
|
|
142
|
+
baseUrl: BASE,
|
|
143
|
+
token: "tok",
|
|
144
|
+
deadlineAt: null,
|
|
145
|
+
pollIntervalMs: 1,
|
|
146
|
+
fetchImpl: impl,
|
|
147
|
+
log: (line) => lines.push(line),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(outcome).toBe("drained");
|
|
151
|
+
expect(lines.join("\n")).toContain('workflow "nightly-report"');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a single idle snapshot is not enough — drained needs two in a row", async () => {
|
|
155
|
+
let statusCalls = 0;
|
|
156
|
+
const sequence = [idleStatus(), busyStatus(), idleStatus(), idleStatus()];
|
|
157
|
+
const { impl } = fetchStub((_url, method) => {
|
|
158
|
+
if (method === "POST") {
|
|
159
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
160
|
+
}
|
|
161
|
+
const status = sequence[Math.min(statusCalls, sequence.length - 1)];
|
|
162
|
+
statusCalls += 1;
|
|
163
|
+
return jsonResponse(status);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const outcome = await drainAssistant({
|
|
167
|
+
baseUrl: BASE,
|
|
168
|
+
token: "tok",
|
|
169
|
+
deadlineAt: null,
|
|
170
|
+
pollIntervalMs: 1,
|
|
171
|
+
fetchImpl: impl,
|
|
172
|
+
log: () => {},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(outcome).toBe("drained");
|
|
176
|
+
// idle, busy (streak reset), idle, idle — four reads, not one.
|
|
177
|
+
expect(statusCalls).toBe(4);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("idle without a live lease re-arms instead of counting toward drained", async () => {
|
|
181
|
+
let statusCalls = 0;
|
|
182
|
+
let armCalls = 0;
|
|
183
|
+
const { impl } = fetchStub((_url, method) => {
|
|
184
|
+
if (method === "POST") {
|
|
185
|
+
armCalls += 1;
|
|
186
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
187
|
+
}
|
|
188
|
+
statusCalls += 1;
|
|
189
|
+
// The lease has lapsed on the first idle read; once re-armed, the
|
|
190
|
+
// subsequent idle reads carry a live lease.
|
|
191
|
+
return jsonResponse(
|
|
192
|
+
statusCalls === 1 ? idleExpiredLeaseStatus() : idleStatus(),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const outcome = await drainAssistant({
|
|
197
|
+
baseUrl: BASE,
|
|
198
|
+
token: "tok",
|
|
199
|
+
deadlineAt: null,
|
|
200
|
+
pollIntervalMs: 1,
|
|
201
|
+
fetchImpl: impl,
|
|
202
|
+
log: () => {},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(outcome).toBe("drained");
|
|
206
|
+
// Initial arm + the re-arm triggered by the lapsed lease.
|
|
207
|
+
expect(armCalls).toBeGreaterThanOrEqual(2);
|
|
208
|
+
// The expired-lease idle read did not count: two live-lease reads follow.
|
|
209
|
+
expect(statusCalls).toBeGreaterThanOrEqual(3);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("an unmaintainable lease fails the drain instead of trusting idle", async () => {
|
|
213
|
+
let armCalls = 0;
|
|
214
|
+
const { impl } = fetchStub((_url, method) => {
|
|
215
|
+
if (method === "POST") {
|
|
216
|
+
armCalls += 1;
|
|
217
|
+
if (armCalls === 1) {
|
|
218
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
219
|
+
}
|
|
220
|
+
return jsonResponse({ error: "boom" }, 500);
|
|
221
|
+
}
|
|
222
|
+
return jsonResponse(idleExpiredLeaseStatus());
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const outcome = await drainAssistant({
|
|
226
|
+
baseUrl: BASE,
|
|
227
|
+
token: "tok",
|
|
228
|
+
deadlineAt: null,
|
|
229
|
+
pollIntervalMs: 1,
|
|
230
|
+
fetchImpl: impl,
|
|
231
|
+
log: () => {},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(outcome).toBe("unreachable");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("returns unsupported when the assistant lacks the quiesce route", async () => {
|
|
238
|
+
const { impl } = fetchStub(() => jsonResponse({ error: "nope" }, 404));
|
|
239
|
+
|
|
240
|
+
const outcome = await drainAssistant({
|
|
241
|
+
baseUrl: BASE,
|
|
242
|
+
token: "tok",
|
|
243
|
+
deadlineAt: null,
|
|
244
|
+
fetchImpl: impl,
|
|
245
|
+
log: () => {},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(outcome).toBe("unsupported");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("returns unreachable when the initial lease request fails", async () => {
|
|
252
|
+
const { impl } = fetchStub(() => {
|
|
253
|
+
throw new Error("connection refused");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const outcome = await drainAssistant({
|
|
257
|
+
baseUrl: BASE,
|
|
258
|
+
token: "tok",
|
|
259
|
+
deadlineAt: null,
|
|
260
|
+
fetchImpl: impl,
|
|
261
|
+
log: () => {},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(outcome).toBe("unreachable");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("returns timeout when the deadline passes while still busy", async () => {
|
|
268
|
+
const { impl } = fetchStub((_url, method) =>
|
|
269
|
+
method === "POST"
|
|
270
|
+
? jsonResponse({ quiescedUntil: Date.now() + 60_000 })
|
|
271
|
+
: jsonResponse(busyStatus()),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const outcome = await drainAssistant({
|
|
275
|
+
baseUrl: BASE,
|
|
276
|
+
token: "tok",
|
|
277
|
+
deadlineAt: Date.now() + 40,
|
|
278
|
+
pollIntervalMs: 1,
|
|
279
|
+
fetchImpl: impl,
|
|
280
|
+
log: () => {},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(outcome).toBe("timeout");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("returns unreachable after sustained status failures", async () => {
|
|
287
|
+
const { impl } = fetchStub((_url, method) => {
|
|
288
|
+
if (method === "POST") {
|
|
289
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
290
|
+
}
|
|
291
|
+
throw new Error("boom");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const outcome = await drainAssistant({
|
|
295
|
+
baseUrl: BASE,
|
|
296
|
+
token: "tok",
|
|
297
|
+
deadlineAt: null,
|
|
298
|
+
pollIntervalMs: 1,
|
|
299
|
+
fetchImpl: impl,
|
|
300
|
+
log: () => {},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(outcome).toBe("unreachable");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("cancel releases the lease and returns cancelled", async () => {
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
const { impl, calls } = fetchStub((_url, method) => {
|
|
309
|
+
if (method === "POST") {
|
|
310
|
+
return jsonResponse({ quiescedUntil: Date.now() + 60_000 });
|
|
311
|
+
}
|
|
312
|
+
if (method === "DELETE") {
|
|
313
|
+
return jsonResponse({ released: true });
|
|
314
|
+
}
|
|
315
|
+
// First busy status arrives, then the user hits Ctrl-C.
|
|
316
|
+
controller.abort();
|
|
317
|
+
return jsonResponse(busyStatus());
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const outcome = await drainAssistant({
|
|
321
|
+
baseUrl: BASE,
|
|
322
|
+
token: "tok",
|
|
323
|
+
deadlineAt: null,
|
|
324
|
+
pollIntervalMs: 1,
|
|
325
|
+
fetchImpl: impl,
|
|
326
|
+
signal: controller.signal,
|
|
327
|
+
log: () => {},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(outcome).toBe("cancelled");
|
|
331
|
+
expect(
|
|
332
|
+
calls.some(
|
|
333
|
+
(c) => c.method === "DELETE" && c.url.endsWith("/v1/lifecycle/quiesce"),
|
|
334
|
+
),
|
|
335
|
+
).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -83,7 +83,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
83
83
|
fetchCalls.push({ url, method });
|
|
84
84
|
if (method === "PATCH") {
|
|
85
85
|
return jsonResponse({
|
|
86
|
-
key: "
|
|
86
|
+
key: "browser",
|
|
87
87
|
enabled: true,
|
|
88
88
|
defaultEnabled: false,
|
|
89
89
|
label: "Voice Mode",
|
|
@@ -133,7 +133,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
133
133
|
"vellum",
|
|
134
134
|
"flags",
|
|
135
135
|
"set",
|
|
136
|
-
"
|
|
136
|
+
"browser",
|
|
137
137
|
"true",
|
|
138
138
|
"--assistant",
|
|
139
139
|
"Bob",
|
|
@@ -146,7 +146,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
146
146
|
// bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
|
|
147
147
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
|
|
148
148
|
expect(fetchCalls[0].url).toContain(
|
|
149
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
149
|
+
"/v1/assistants/bob-2/feature-flags/browser",
|
|
150
150
|
);
|
|
151
151
|
});
|
|
152
152
|
|
|
@@ -166,7 +166,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
166
166
|
"--assistant",
|
|
167
167
|
"Bob",
|
|
168
168
|
"set",
|
|
169
|
-
"
|
|
169
|
+
"browser",
|
|
170
170
|
"true",
|
|
171
171
|
];
|
|
172
172
|
|
|
@@ -174,7 +174,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
174
174
|
|
|
175
175
|
expect(fetchCalls.length).toBe(1);
|
|
176
176
|
expect(fetchCalls[0].url).toContain(
|
|
177
|
-
"/v1/assistants/bob-2/feature-flags/
|
|
177
|
+
"/v1/assistants/bob-2/feature-flags/browser",
|
|
178
178
|
);
|
|
179
179
|
});
|
|
180
180
|
|
|
@@ -188,7 +188,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
188
188
|
],
|
|
189
189
|
"alice-1",
|
|
190
190
|
);
|
|
191
|
-
process.argv = ["bun", "vellum", "flags", "set", "
|
|
191
|
+
process.argv = ["bun", "vellum", "flags", "set", "browser", "true"];
|
|
192
192
|
|
|
193
193
|
await flags();
|
|
194
194
|
|
|
@@ -196,7 +196,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
196
196
|
// alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
|
|
197
197
|
expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
|
|
198
198
|
expect(fetchCalls[0].url).toContain(
|
|
199
|
-
"/v1/assistants/alice-1/feature-flags/
|
|
199
|
+
"/v1/assistants/alice-1/feature-flags/browser",
|
|
200
200
|
);
|
|
201
201
|
});
|
|
202
202
|
|
|
@@ -207,7 +207,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
207
207
|
"vellum",
|
|
208
208
|
"flags",
|
|
209
209
|
"set",
|
|
210
|
-
"
|
|
210
|
+
"browser",
|
|
211
211
|
"true",
|
|
212
212
|
"--assistant",
|
|
213
213
|
"Ghost",
|
|
@@ -227,7 +227,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
227
227
|
"vellum",
|
|
228
228
|
"flags",
|
|
229
229
|
"set",
|
|
230
|
-
"
|
|
230
|
+
"browser",
|
|
231
231
|
"true",
|
|
232
232
|
"--assistant",
|
|
233
233
|
];
|
|
@@ -298,14 +298,14 @@ describe("vellum flags cross-environment hint", () => {
|
|
|
298
298
|
test("gateway-unreachable error gains the hint when another env has assistants", async () => {
|
|
299
299
|
writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
|
|
300
300
|
seedDevAssistant();
|
|
301
|
-
process.argv = ["bun", "vellum", "flags", "set", "
|
|
301
|
+
process.argv = ["bun", "vellum", "flags", "set", "browser", "true"];
|
|
302
302
|
await expect(flags()).rejects.toThrow(/Install vellum Command/);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
305
|
test("gateway-unreachable error stays bare when no other env has assistants", async () => {
|
|
306
306
|
writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
|
|
307
307
|
// xdgDataHome is empty — detection finds nothing, so the message is intact.
|
|
308
|
-
process.argv = ["bun", "vellum", "flags", "set", "
|
|
308
|
+
process.argv = ["bun", "vellum", "flags", "set", "browser", "true"];
|
|
309
309
|
let error: Error | undefined;
|
|
310
310
|
try {
|
|
311
311
|
await flags();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resolveLocalHatchLockPath,
|
|
8
|
+
withLocalHatchLock,
|
|
9
|
+
} from "../lib/local-hatch-lock.js";
|
|
10
|
+
|
|
11
|
+
const tempDirs: string[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const tempDir of tempDirs.splice(0)) {
|
|
15
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function makeLockPath(): string {
|
|
20
|
+
const tempDir = mkdtempSync(join(tmpdir(), "vellum-hatch-lock-test-"));
|
|
21
|
+
tempDirs.push(tempDir);
|
|
22
|
+
return join(tempDir, "local-hatch.lock");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("withLocalHatchLock", () => {
|
|
26
|
+
test("uses the same machine lock when callers have different temp directories", () => {
|
|
27
|
+
const originalTmpDir = process.env.TMPDIR;
|
|
28
|
+
const firstTmpDir = mkdtempSync(join(tmpdir(), "vellum-tmp-a-"));
|
|
29
|
+
const secondTmpDir = mkdtempSync(join(tmpdir(), "vellum-tmp-b-"));
|
|
30
|
+
tempDirs.push(firstTmpDir, secondTmpDir);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
process.env.TMPDIR = firstTmpDir;
|
|
34
|
+
const firstPath = resolveLocalHatchLockPath();
|
|
35
|
+
process.env.TMPDIR = secondTmpDir;
|
|
36
|
+
const secondPath = resolveLocalHatchLockPath();
|
|
37
|
+
|
|
38
|
+
expect(secondPath).toBe(firstPath);
|
|
39
|
+
expect(firstPath.startsWith(firstTmpDir)).toBe(false);
|
|
40
|
+
expect(firstPath.startsWith(secondTmpDir)).toBe(false);
|
|
41
|
+
} finally {
|
|
42
|
+
if (originalTmpDir === undefined) {
|
|
43
|
+
delete process.env.TMPDIR;
|
|
44
|
+
} else {
|
|
45
|
+
process.env.TMPDIR = originalTmpDir;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("serializes startup work that shares the machine-wide lock", async () => {
|
|
51
|
+
const lockPath = makeLockPath();
|
|
52
|
+
let releaseFirst!: () => void;
|
|
53
|
+
let firstStarted!: () => void;
|
|
54
|
+
const firstStartedPromise = new Promise<void>((resolve) => {
|
|
55
|
+
firstStarted = resolve;
|
|
56
|
+
});
|
|
57
|
+
const firstReleasePromise = new Promise<void>((resolve) => {
|
|
58
|
+
releaseFirst = resolve;
|
|
59
|
+
});
|
|
60
|
+
let secondEntered = false;
|
|
61
|
+
|
|
62
|
+
const first = withLocalHatchLock(
|
|
63
|
+
async () => {
|
|
64
|
+
firstStarted();
|
|
65
|
+
await firstReleasePromise;
|
|
66
|
+
},
|
|
67
|
+
{ lockPath, timeoutMs: 1_000, retryMs: 5 },
|
|
68
|
+
);
|
|
69
|
+
await firstStartedPromise;
|
|
70
|
+
|
|
71
|
+
const second = withLocalHatchLock(
|
|
72
|
+
async () => {
|
|
73
|
+
secondEntered = true;
|
|
74
|
+
},
|
|
75
|
+
{ lockPath, timeoutMs: 1_000, retryMs: 5 },
|
|
76
|
+
);
|
|
77
|
+
await Bun.sleep(20);
|
|
78
|
+
expect(secondEntered).toBe(false);
|
|
79
|
+
|
|
80
|
+
releaseFirst();
|
|
81
|
+
await Promise.all([first, second]);
|
|
82
|
+
|
|
83
|
+
expect(secondEntered).toBe(true);
|
|
84
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("recovers a lock owned by a dead process", async () => {
|
|
88
|
+
const lockPath = makeLockPath();
|
|
89
|
+
writeFileSync(
|
|
90
|
+
lockPath,
|
|
91
|
+
JSON.stringify({ pid: 2_147_483_647, token: "stale-owner" }) + "\n",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = await withLocalHatchLock(async () => "started", {
|
|
95
|
+
lockPath,
|
|
96
|
+
timeoutMs: 1_000,
|
|
97
|
+
retryMs: 5,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result).toBe("started");
|
|
101
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("recovers an expired lock even when its PID has been reused", async () => {
|
|
105
|
+
const lockPath = makeLockPath();
|
|
106
|
+
writeFileSync(
|
|
107
|
+
lockPath,
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
processStartedAt: "not-the-current-process",
|
|
111
|
+
token: "reused-pid-owner",
|
|
112
|
+
}) + "\n",
|
|
113
|
+
);
|
|
114
|
+
const expired = new Date(Date.now() - 300_000);
|
|
115
|
+
utimesSync(lockPath, expired, expired);
|
|
116
|
+
|
|
117
|
+
const result = await withLocalHatchLock(async () => "started", {
|
|
118
|
+
lockPath,
|
|
119
|
+
liveOwnerRecoveryGraceMs: 0,
|
|
120
|
+
timeoutMs: 1_000,
|
|
121
|
+
retryMs: 5,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result).toBe("started");
|
|
125
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("recovers a fresh lock when its PID has been reused", async () => {
|
|
129
|
+
const lockPath = makeLockPath();
|
|
130
|
+
writeFileSync(
|
|
131
|
+
lockPath,
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
processStartedAt: "not-the-current-process",
|
|
135
|
+
token: "fresh-reused-pid-owner",
|
|
136
|
+
}) + "\n",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const result = await withLocalHatchLock(async () => "started", {
|
|
140
|
+
lockPath,
|
|
141
|
+
timeoutMs: 1_000,
|
|
142
|
+
retryMs: 5,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toBe("started");
|
|
146
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("releases the lock when startup fails", async () => {
|
|
150
|
+
const lockPath = makeLockPath();
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
withLocalHatchLock(
|
|
154
|
+
async () => {
|
|
155
|
+
throw new Error("startup failed");
|
|
156
|
+
},
|
|
157
|
+
{ lockPath },
|
|
158
|
+
),
|
|
159
|
+
).rejects.toThrow("startup failed");
|
|
160
|
+
|
|
161
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|