@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
package/src/lib/drain.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drain phase for `vellum sleep --wait`.
|
|
3
|
+
*
|
|
4
|
+
* Arms the assistant's quiesce lease (pausing the START of new background
|
|
5
|
+
* work — heartbeats, schedule claims, memory-job claims), then polls the
|
|
6
|
+
* drain-status endpoint until nothing is in flight. The lease is TTL-bound
|
|
7
|
+
* and refreshed while polling, so a killed CLI can never leave background
|
|
8
|
+
* work paused. The wait narrates what it is waiting on — background jobs can
|
|
9
|
+
* legitimately run for many minutes, and a silent wait is indistinguishable
|
|
10
|
+
* from a hang.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
14
|
+
|
|
15
|
+
const QUIESCE_TTL_MS = 60_000;
|
|
16
|
+
const QUIESCE_REFRESH_INTERVAL_MS = 20_000;
|
|
17
|
+
const REQUEST_TIMEOUT_MS = 2_500;
|
|
18
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
19
|
+
const NARRATE_HEARTBEAT_MS = 15_000;
|
|
20
|
+
/** Consecutive failed polls before giving up on the drain. Transient
|
|
21
|
+
* failures (a brief daemon event-loop stall) recover; a wall of failures
|
|
22
|
+
* means the assistant cannot report status and waiting is pointless. */
|
|
23
|
+
const MAX_CONSECUTIVE_FAILURES = 10;
|
|
24
|
+
/** Consecutive failed lease arms/refreshes before giving up. Without a live
|
|
25
|
+
* lease the claim gates are open and an idle snapshot proves nothing, so an
|
|
26
|
+
* unmaintainable lease makes the drain unsound. */
|
|
27
|
+
const MAX_LEASE_FAILURES = 3;
|
|
28
|
+
|
|
29
|
+
export type DrainOutcome =
|
|
30
|
+
| "drained"
|
|
31
|
+
| "timeout"
|
|
32
|
+
| "unsupported"
|
|
33
|
+
| "unreachable"
|
|
34
|
+
| "cancelled";
|
|
35
|
+
|
|
36
|
+
interface DrainStatusResponse {
|
|
37
|
+
quiescedUntil: number | null;
|
|
38
|
+
idle: boolean;
|
|
39
|
+
activeConversations: Array<{
|
|
40
|
+
conversationId: string;
|
|
41
|
+
title: string | null;
|
|
42
|
+
originChannel: string | null;
|
|
43
|
+
originInterface: string | null;
|
|
44
|
+
processingStartedAt: number;
|
|
45
|
+
}>;
|
|
46
|
+
memoryJobs: Array<{
|
|
47
|
+
id: string;
|
|
48
|
+
type: string;
|
|
49
|
+
startedAt: number | null;
|
|
50
|
+
}>;
|
|
51
|
+
scheduleRuns: Array<{
|
|
52
|
+
runId: string;
|
|
53
|
+
scheduleName: string | null;
|
|
54
|
+
startedAt: number;
|
|
55
|
+
}>;
|
|
56
|
+
workflowRuns?: Array<{
|
|
57
|
+
runId: string;
|
|
58
|
+
name: string | null;
|
|
59
|
+
startedAt: number | null;
|
|
60
|
+
}>;
|
|
61
|
+
heartbeatRuns?: Array<{
|
|
62
|
+
runId: string;
|
|
63
|
+
startedAt: number;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DrainOptions {
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
token: string;
|
|
70
|
+
/** Epoch ms to stop waiting at, or null to wait as long as it takes. */
|
|
71
|
+
deadlineAt: number | null;
|
|
72
|
+
/** Aborted on Ctrl-C — releases the lease and returns "cancelled". */
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
pollIntervalMs?: number;
|
|
75
|
+
fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
|
|
76
|
+
log?: (line: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a `--wait` duration: bare seconds (`90`), seconds (`90s`), or
|
|
81
|
+
* minutes (`10m`). Returns milliseconds, or null when not a valid duration.
|
|
82
|
+
*/
|
|
83
|
+
export function parseWaitDuration(raw: string): number | null {
|
|
84
|
+
const match = /^(\d+)(s|m)?$/.exec(raw.trim());
|
|
85
|
+
if (!match) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const value = Number.parseInt(match[1]!, 10);
|
|
89
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return match[2] === "m" ? value * 60_000 : value * 1_000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatElapsed(sinceMs: number | null, now: number): string {
|
|
96
|
+
if (sinceMs == null) {
|
|
97
|
+
return "just started";
|
|
98
|
+
}
|
|
99
|
+
const totalSeconds = Math.max(0, Math.floor((now - sinceMs) / 1000));
|
|
100
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
101
|
+
const seconds = totalSeconds % 60;
|
|
102
|
+
return minutes > 0
|
|
103
|
+
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
|
|
104
|
+
: `${seconds}s`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeBusyWork(status: DrainStatusResponse, now: number): string[] {
|
|
108
|
+
const items: string[] = [];
|
|
109
|
+
for (const conv of status.activeConversations) {
|
|
110
|
+
const label =
|
|
111
|
+
conv.title != null && conv.title.length > 0
|
|
112
|
+
? `conversation "${conv.title}"`
|
|
113
|
+
: `background turn (${conv.originInterface ?? conv.originChannel ?? "unknown origin"})`;
|
|
114
|
+
items.push(`${label} (${formatElapsed(conv.processingStartedAt, now)})`);
|
|
115
|
+
}
|
|
116
|
+
for (const job of status.memoryJobs) {
|
|
117
|
+
items.push(`memory ${job.type} (${formatElapsed(job.startedAt, now)})`);
|
|
118
|
+
}
|
|
119
|
+
for (const run of status.scheduleRuns) {
|
|
120
|
+
const label = run.scheduleName ?? run.runId.slice(0, 8);
|
|
121
|
+
items.push(`schedule "${label}" (${formatElapsed(run.startedAt, now)})`);
|
|
122
|
+
}
|
|
123
|
+
for (const run of status.workflowRuns ?? []) {
|
|
124
|
+
const label = run.name ?? run.runId.slice(0, 8);
|
|
125
|
+
items.push(`workflow "${label}" (${formatElapsed(run.startedAt, now)})`);
|
|
126
|
+
}
|
|
127
|
+
for (const run of status.heartbeatRuns ?? []) {
|
|
128
|
+
items.push(`heartbeat (${formatElapsed(run.startedAt, now)})`);
|
|
129
|
+
}
|
|
130
|
+
return items;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function busySignature(status: DrainStatusResponse): string {
|
|
134
|
+
return [
|
|
135
|
+
...status.activeConversations.map((c) => c.conversationId),
|
|
136
|
+
...status.memoryJobs.map((j) => j.id),
|
|
137
|
+
...status.scheduleRuns.map((r) => r.runId),
|
|
138
|
+
...(status.workflowRuns ?? []).map((r) => r.runId),
|
|
139
|
+
...(status.heartbeatRuns ?? []).map((r) => r.runId),
|
|
140
|
+
].join("|");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const timer = setTimeout(done, ms);
|
|
146
|
+
function done(): void {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
signal?.removeEventListener("abort", done);
|
|
149
|
+
resolve();
|
|
150
|
+
}
|
|
151
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Wait for the assistant's in-flight background work to finish.
|
|
157
|
+
*
|
|
158
|
+
* Never throws: every failure mode maps onto a {@link DrainOutcome} so the
|
|
159
|
+
* caller can decide how to proceed with the stop.
|
|
160
|
+
*/
|
|
161
|
+
export async function drainAssistant(
|
|
162
|
+
opts: DrainOptions,
|
|
163
|
+
): Promise<DrainOutcome> {
|
|
164
|
+
const fetchImpl = opts.fetchImpl ?? loopbackSafeFetch;
|
|
165
|
+
const log = opts.log ?? console.log;
|
|
166
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
167
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
168
|
+
|
|
169
|
+
const request = async (
|
|
170
|
+
path: string,
|
|
171
|
+
init: { method: string; body?: string },
|
|
172
|
+
): Promise<Response> => {
|
|
173
|
+
// Per-request controller aborted by either the request timeout or the
|
|
174
|
+
// caller's cancel signal; listeners are removed per request so a long
|
|
175
|
+
// wait never accumulates them on the shared cancel signal.
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
178
|
+
const onOuterAbort = (): void => controller.abort();
|
|
179
|
+
opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
180
|
+
try {
|
|
181
|
+
return await fetchImpl(`${base}${path}`, {
|
|
182
|
+
method: init.method,
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: `Bearer ${opts.token}`,
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
},
|
|
187
|
+
...(init.body !== undefined && { body: init.body }),
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
});
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
opts.signal?.removeEventListener("abort", onOuterAbort);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const armLease = () =>
|
|
197
|
+
request("/v1/lifecycle/quiesce", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: JSON.stringify({ ttlMs: QUIESCE_TTL_MS }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const releaseLease = async (): Promise<void> => {
|
|
203
|
+
try {
|
|
204
|
+
await request("/v1/lifecycle/quiesce", { method: "DELETE" });
|
|
205
|
+
} catch {
|
|
206
|
+
// Best-effort — the lease self-expires.
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Arm the lease before anything else: without it a busy queue keeps
|
|
211
|
+
// claiming new work and the wait never converges.
|
|
212
|
+
let response: Response;
|
|
213
|
+
try {
|
|
214
|
+
response = await armLease();
|
|
215
|
+
} catch {
|
|
216
|
+
if (opts.signal?.aborted) {
|
|
217
|
+
// The abort may have landed after the server persisted the lease, so
|
|
218
|
+
// its outcome is ambiguous — release best-effort rather than leaving
|
|
219
|
+
// background work paused for a TTL on a cancelled sleep.
|
|
220
|
+
await releaseLease();
|
|
221
|
+
return "cancelled";
|
|
222
|
+
}
|
|
223
|
+
return "unreachable";
|
|
224
|
+
}
|
|
225
|
+
if (response.status === 404 || response.status === 405) {
|
|
226
|
+
return "unsupported";
|
|
227
|
+
}
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
return "unreachable";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let lastLeaseAt = Date.now();
|
|
233
|
+
let consecutiveFailures = 0;
|
|
234
|
+
let leaseFailureStreak = 0;
|
|
235
|
+
let lastSignature: string | null = null;
|
|
236
|
+
let lastNarratedAt = 0;
|
|
237
|
+
let idleStreak = 0;
|
|
238
|
+
|
|
239
|
+
for (;;) {
|
|
240
|
+
if (opts.signal?.aborted) {
|
|
241
|
+
await releaseLease();
|
|
242
|
+
return "cancelled";
|
|
243
|
+
}
|
|
244
|
+
if (opts.deadlineAt != null && Date.now() >= opts.deadlineAt) {
|
|
245
|
+
return "timeout";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const statusResponse = await request("/v1/lifecycle/drain-status", {
|
|
250
|
+
method: "GET",
|
|
251
|
+
});
|
|
252
|
+
if (!statusResponse.ok) {
|
|
253
|
+
throw new Error(`drain-status returned ${statusResponse.status}`);
|
|
254
|
+
}
|
|
255
|
+
const status = (await statusResponse.json()) as DrainStatusResponse;
|
|
256
|
+
consecutiveFailures = 0;
|
|
257
|
+
|
|
258
|
+
if (status.idle) {
|
|
259
|
+
// Idle only counts under a LIVE lease: with the lease expired the
|
|
260
|
+
// claim gates are open again, so new work invisible to this snapshot
|
|
261
|
+
// could be starting. Re-arm and retry rather than trusting the read.
|
|
262
|
+
const leaseActive =
|
|
263
|
+
status.quiescedUntil != null && status.quiescedUntil > Date.now();
|
|
264
|
+
if (!leaseActive) {
|
|
265
|
+
idleStreak = 0;
|
|
266
|
+
try {
|
|
267
|
+
const rearm = await armLease();
|
|
268
|
+
if (rearm.ok) {
|
|
269
|
+
lastLeaseAt = Date.now();
|
|
270
|
+
leaseFailureStreak = 0;
|
|
271
|
+
} else {
|
|
272
|
+
leaseFailureStreak += 1;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
if (opts.signal?.aborted) {
|
|
276
|
+
await releaseLease();
|
|
277
|
+
return "cancelled";
|
|
278
|
+
}
|
|
279
|
+
leaseFailureStreak += 1;
|
|
280
|
+
}
|
|
281
|
+
if (leaseFailureStreak >= MAX_LEASE_FAILURES) {
|
|
282
|
+
return "unreachable";
|
|
283
|
+
}
|
|
284
|
+
await sleepMs(pollIntervalMs, opts.signal);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Require two consecutive idle snapshots: work that raced past a
|
|
288
|
+
// pre-lease gate check lands its run row within a tick, so a second
|
|
289
|
+
// read one poll later rules out a stop landing on that window.
|
|
290
|
+
idleStreak += 1;
|
|
291
|
+
if (idleStreak >= 2) {
|
|
292
|
+
return "drained";
|
|
293
|
+
}
|
|
294
|
+
await sleepMs(pollIntervalMs, opts.signal);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
idleStreak = 0;
|
|
298
|
+
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
const signature = busySignature(status);
|
|
301
|
+
if (
|
|
302
|
+
signature !== lastSignature ||
|
|
303
|
+
now - lastNarratedAt >= NARRATE_HEARTBEAT_MS
|
|
304
|
+
) {
|
|
305
|
+
const items = describeBusyWork(status, now);
|
|
306
|
+
const shown = items.slice(0, 4);
|
|
307
|
+
const more = items.length - shown.length;
|
|
308
|
+
log(
|
|
309
|
+
`Waiting for the assistant to finish: ${shown.join(", ")}${
|
|
310
|
+
more > 0 ? ` and ${more} more` : ""
|
|
311
|
+
}…`,
|
|
312
|
+
);
|
|
313
|
+
lastSignature = signature;
|
|
314
|
+
lastNarratedAt = now;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
if (opts.signal?.aborted) {
|
|
318
|
+
await releaseLease();
|
|
319
|
+
return "cancelled";
|
|
320
|
+
}
|
|
321
|
+
consecutiveFailures += 1;
|
|
322
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
323
|
+
return "unreachable";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (Date.now() - lastLeaseAt >= QUIESCE_REFRESH_INTERVAL_MS) {
|
|
328
|
+
try {
|
|
329
|
+
const refresh = await armLease();
|
|
330
|
+
if (refresh.ok) {
|
|
331
|
+
lastLeaseAt = Date.now();
|
|
332
|
+
leaseFailureStreak = 0;
|
|
333
|
+
} else {
|
|
334
|
+
leaseFailureStreak += 1;
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
leaseFailureStreak += 1;
|
|
338
|
+
}
|
|
339
|
+
if (leaseFailureStreak >= MAX_LEASE_FAILURES) {
|
|
340
|
+
return "unreachable";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await sleepMs(pollIntervalMs, opts.signal);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -4,9 +4,9 @@ import { parseFeatureFlagArgs } from "./flag-args";
|
|
|
4
4
|
|
|
5
5
|
describe("parseFeatureFlagArgs", () => {
|
|
6
6
|
test("single flag produces env var and empty remaining", () => {
|
|
7
|
-
const result = parseFeatureFlagArgs(["--flag", "
|
|
7
|
+
const result = parseFeatureFlagArgs(["--flag", "browser=true"]);
|
|
8
8
|
expect(result).toEqual({
|
|
9
|
-
envVars: {
|
|
9
|
+
envVars: { VELLUM_FLAG_BROWSER: "true" },
|
|
10
10
|
remaining: [],
|
|
11
11
|
});
|
|
12
12
|
});
|
|
@@ -75,7 +75,7 @@ describe("parseFeatureFlagArgs", () => {
|
|
|
75
75
|
"process.exit",
|
|
76
76
|
);
|
|
77
77
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
78
|
-
'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "
|
|
78
|
+
'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "browser")',
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
exitSpy.mockRestore();
|
package/src/lib/flag-args.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Only allow simple kebab-case keys (e.g. "
|
|
1
|
+
/** Only allow simple kebab-case keys (e.g. "browser", "contacts"). */
|
|
2
2
|
const ALLOWED_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -38,7 +38,7 @@ export function parseFeatureFlagArgs(args: string[]): {
|
|
|
38
38
|
|
|
39
39
|
if (!ALLOWED_KEY_RE.test(key)) {
|
|
40
40
|
console.error(
|
|
41
|
-
`Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "
|
|
41
|
+
`Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "browser")`,
|
|
42
42
|
);
|
|
43
43
|
process.exit(1);
|
|
44
44
|
}
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
47
47
|
import { checkProviderApiKey } from "./api-key-check.js";
|
|
48
48
|
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
49
|
+
import { withLocalHatchLock } from "./local-hatch-lock.js";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Attempts to place a symlink at the given path pointing to cliBinary.
|
|
@@ -179,89 +180,116 @@ export async function hatchLocal(
|
|
|
179
180
|
name ?? process.env.VELLUM_ASSISTANT_NAME,
|
|
180
181
|
);
|
|
181
182
|
|
|
182
|
-
|
|
183
|
+
const { resources, runtimeUrl, loopbackUrl, bootstrapSecret } =
|
|
184
|
+
await withLocalHatchLock(async () => {
|
|
185
|
+
reporter.progress(1, 6, "Allocating resources...");
|
|
183
186
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
const existing = findAssistantByName(instanceName);
|
|
188
|
+
if (existing && (!existing.cloud || existing.cloud === "local")) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`An assistant named "${instanceName}" is already hatched.\n` +
|
|
191
|
+
`Run \`vellum wake\` to restart it, or \`vellum retire ${instanceName}\` to remove it first.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
191
194
|
|
|
192
|
-
|
|
195
|
+
const resources = await allocateLocalResources(instanceName);
|
|
193
196
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
const logsDir = join(
|
|
198
|
+
resources.instanceDir,
|
|
199
|
+
".vellum",
|
|
200
|
+
"workspace",
|
|
201
|
+
"data",
|
|
202
|
+
"logs",
|
|
203
|
+
);
|
|
204
|
+
archiveLogFile("hatch.log", logsDir);
|
|
205
|
+
resetLogFile("hatch.log");
|
|
203
206
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
208
|
+
reporter.log(` Species: ${species}`);
|
|
209
|
+
reporter.log("");
|
|
207
210
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
const apiKeyCheck = checkProviderApiKey();
|
|
212
|
+
if (!apiKeyCheck.hasKey) {
|
|
213
|
+
reporter.warn(
|
|
214
|
+
"Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
|
|
215
|
+
);
|
|
216
|
+
reporter.warn(" To fix, export your key before running vellum hatch:");
|
|
217
|
+
reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
|
|
218
|
+
reporter.warn("");
|
|
219
|
+
}
|
|
217
220
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
if (!process.env.APP_VERSION) {
|
|
222
|
+
process.env.APP_VERSION = cliPkg.version;
|
|
223
|
+
}
|
|
221
224
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
225
|
+
reporter.progress(2, 6, "Writing configuration...");
|
|
226
|
+
const hatchConfigValues = buildHatchConfigValues(configValues, provider);
|
|
227
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
|
|
228
|
+
|
|
229
|
+
reporter.progress(3, 6, "Starting assistant...");
|
|
230
|
+
const signingKey = generateLocalSigningKey();
|
|
231
|
+
const bootstrapSecret = generateLocalSigningKey();
|
|
232
|
+
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
233
|
+
try {
|
|
234
|
+
// Launch the CES sibling alongside the daemon, in parallel — matching the
|
|
235
|
+
// Docker topology. The assistant does not spawn its own CES, so a freshly
|
|
236
|
+
// hatched instance would otherwise come up with CES unavailable.
|
|
237
|
+
// startCes always launches the CES sibling.
|
|
238
|
+
const startupResults = await Promise.allSettled([
|
|
239
|
+
startCes(watch, resources),
|
|
240
|
+
startLocalDaemon(watch, resources, {
|
|
241
|
+
defaultWorkspaceConfigPath,
|
|
242
|
+
requireReady: true,
|
|
243
|
+
signingKey,
|
|
244
|
+
}),
|
|
245
|
+
]);
|
|
246
|
+
const startupFailure = startupResults.find(
|
|
247
|
+
(result): result is PromiseRejectedResult =>
|
|
248
|
+
result.status === "rejected",
|
|
249
|
+
);
|
|
250
|
+
if (startupFailure) {
|
|
251
|
+
throw startupFailure.reason;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
reporter.progress(4, 6, "Starting gateway...");
|
|
255
|
+
runtimeUrl = await startGateway(watch, resources, {
|
|
256
|
+
signingKey,
|
|
257
|
+
bootstrapSecret,
|
|
258
|
+
envOverrides: flagEnvVars,
|
|
259
|
+
requireReady: true,
|
|
260
|
+
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
reporter.error(
|
|
263
|
+
`\n❌ Local assistant startup failed — stopping partial processes.`,
|
|
264
|
+
);
|
|
265
|
+
await stopLocalProcesses(resources);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
270
|
+
const localEntry: AssistantEntry = {
|
|
271
|
+
assistantId: instanceName,
|
|
272
|
+
runtimeUrl,
|
|
273
|
+
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
274
|
+
cloud: "local",
|
|
275
|
+
species,
|
|
276
|
+
hatchedAt: new Date().toISOString(),
|
|
277
|
+
resources: { ...resources, signingKey },
|
|
278
|
+
guardianBootstrapSecret: bootstrapSecret,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
reporter.progress(5, 6, "Saving configuration...");
|
|
282
|
+
saveAssistantEntry(localEntry);
|
|
283
|
+
setActiveAssistant(instanceName);
|
|
284
|
+
|
|
285
|
+
return { resources, runtimeUrl, loopbackUrl, bootstrapSecret };
|
|
248
286
|
});
|
|
249
|
-
} catch (error) {
|
|
250
|
-
// Gateway failed — stop the daemon we just started so we don't leave
|
|
251
|
-
// orphaned processes with no lock file entry.
|
|
252
|
-
reporter.error(
|
|
253
|
-
`\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
|
|
254
|
-
);
|
|
255
|
-
await stopLocalProcesses(resources);
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
287
|
|
|
259
288
|
// Lease a guardian token so the desktop app can import it on first launch
|
|
260
289
|
// instead of hitting /v1/guardian/init itself. Use loopback to satisfy
|
|
261
290
|
// the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
|
|
262
291
|
// IP which the daemon rejects as non-loopback.
|
|
263
|
-
reporter.progress(
|
|
264
|
-
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
292
|
+
reporter.progress(6, 6, "Securing connection...");
|
|
265
293
|
const maxLeaseAttempts = 3;
|
|
266
294
|
let guardianAccessToken: string | undefined;
|
|
267
295
|
for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
|
|
@@ -290,22 +318,6 @@ export async function hatchLocal(
|
|
|
290
318
|
}
|
|
291
319
|
}
|
|
292
320
|
|
|
293
|
-
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
294
|
-
const localEntry: AssistantEntry = {
|
|
295
|
-
assistantId: instanceName,
|
|
296
|
-
runtimeUrl,
|
|
297
|
-
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
298
|
-
cloud: "local",
|
|
299
|
-
species,
|
|
300
|
-
hatchedAt: new Date().toISOString(),
|
|
301
|
-
resources: { ...resources, signingKey },
|
|
302
|
-
guardianBootstrapSecret: bootstrapSecret,
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
reporter.progress(6, 6, "Saving configuration...");
|
|
306
|
-
saveAssistantEntry(localEntry);
|
|
307
|
-
setActiveAssistant(instanceName);
|
|
308
|
-
|
|
309
321
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
310
322
|
installCLISymlink();
|
|
311
323
|
}
|