@vellumai/cli 0.5.14 → 0.5.16
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/package.json +1 -1
- package/src/__tests__/teleport.test.ts +568 -397
- package/src/commands/hatch.ts +3 -387
- package/src/commands/retire.ts +2 -120
- package/src/commands/teleport.ts +595 -187
- package/src/commands/wake.ts +29 -4
- package/src/lib/hatch-local.ts +403 -0
- package/src/lib/local.ts +9 -120
- package/src/lib/retire-local.ts +124 -0
package/src/commands/wake.ts
CHANGED
|
@@ -110,11 +110,36 @@ export async function wake(): Promise<void> {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
113
|
+
// Resolve the signing key. The gateway persists its own copy to disk at
|
|
114
|
+
// <instanceDir>/.vellum/protected/actor-token-signing-key. That on-disk key
|
|
115
|
+
// is the source of truth because it is what the gateway actually used to sign
|
|
116
|
+
// existing actor tokens. Prefer it over the lockfile value so that tokens
|
|
117
|
+
// survive upgrades and any scenario where the two diverge.
|
|
118
|
+
//
|
|
119
|
+
// NOTE: Removal of this legacy key path read is blocked on removing all use
|
|
120
|
+
// of the signing key from the assistant daemon. Until then, the on-disk key
|
|
121
|
+
// must remain the authoritative source.
|
|
122
|
+
const legacyKeyPath = join(
|
|
123
|
+
resources.instanceDir,
|
|
124
|
+
".vellum",
|
|
125
|
+
"protected",
|
|
126
|
+
"actor-token-signing-key",
|
|
127
|
+
);
|
|
128
|
+
let signingKey: string | undefined;
|
|
129
|
+
if (existsSync(legacyKeyPath)) {
|
|
130
|
+
try {
|
|
131
|
+
const raw = readFileSync(legacyKeyPath);
|
|
132
|
+
if (raw.length === 32) {
|
|
133
|
+
signingKey = raw.toString("hex");
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore — fall through to lockfile or generate.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
116
139
|
if (!signingKey) {
|
|
117
|
-
signingKey = generateLocalSigningKey();
|
|
140
|
+
signingKey = resources.signingKey ?? generateLocalSigningKey();
|
|
141
|
+
}
|
|
142
|
+
if (signingKey !== resources.signingKey) {
|
|
118
143
|
entry.resources = { ...resources, signingKey };
|
|
119
144
|
saveAssistantEntry(entry);
|
|
120
145
|
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
appendFileSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
17
|
+
import cliPkg from "../../package.json";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
allocateLocalResources,
|
|
21
|
+
findAssistantByName,
|
|
22
|
+
loadAllAssistants,
|
|
23
|
+
saveAssistantEntry,
|
|
24
|
+
setActiveAssistant,
|
|
25
|
+
syncConfigToLockfile,
|
|
26
|
+
} from "./assistant-config.js";
|
|
27
|
+
import type {
|
|
28
|
+
AssistantEntry,
|
|
29
|
+
LocalInstanceResources,
|
|
30
|
+
} from "./assistant-config.js";
|
|
31
|
+
import type { Species } from "./constants.js";
|
|
32
|
+
import { writeInitialConfig } from "./config-utils.js";
|
|
33
|
+
import {
|
|
34
|
+
generateLocalSigningKey,
|
|
35
|
+
startLocalDaemon,
|
|
36
|
+
startGateway,
|
|
37
|
+
stopLocalProcesses,
|
|
38
|
+
} from "./local.js";
|
|
39
|
+
import { maybeStartNgrokTunnel } from "./ngrok.js";
|
|
40
|
+
import { httpHealthCheck } from "./http-client.js";
|
|
41
|
+
import { detectOrphanedProcesses } from "./orphan-detection.js";
|
|
42
|
+
import { isProcessAlive, stopProcess } from "./process.js";
|
|
43
|
+
import { generateInstanceName } from "./random-name.js";
|
|
44
|
+
import { leaseGuardianToken } from "./guardian-token.js";
|
|
45
|
+
import { archiveLogFile, resetLogFile } from "./xdg-log.js";
|
|
46
|
+
import { emitProgress } from "./desktop-progress.js";
|
|
47
|
+
|
|
48
|
+
const IS_DESKTOP = !!process.env.VELLUM_DESKTOP_APP;
|
|
49
|
+
|
|
50
|
+
function desktopLog(msg: string): void {
|
|
51
|
+
process.stdout.write(msg + "\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Attempts to place a symlink at the given path pointing to cliBinary.
|
|
56
|
+
* Returns true if the symlink was created (or already correct), false on failure.
|
|
57
|
+
*/
|
|
58
|
+
function trySymlink(cliBinary: string, symlinkPath: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
// Use lstatSync (not existsSync) to detect dangling symlinks —
|
|
61
|
+
// existsSync follows symlinks and returns false for broken links.
|
|
62
|
+
try {
|
|
63
|
+
const stats = lstatSync(symlinkPath);
|
|
64
|
+
if (!stats.isSymbolicLink()) {
|
|
65
|
+
// Real file — don't overwrite (developer's local install)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Already a symlink — skip if it already points to our binary
|
|
69
|
+
const dest = readlinkSync(symlinkPath);
|
|
70
|
+
if (dest === cliBinary) return true;
|
|
71
|
+
// Stale or dangling symlink — remove before creating new one
|
|
72
|
+
unlinkSync(symlinkPath);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") return false;
|
|
75
|
+
// Path doesn't exist — proceed to create symlink
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dir = join(symlinkPath, "..");
|
|
79
|
+
if (!existsSync(dir)) {
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
symlinkSync(cliBinary, symlinkPath);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ensures ~/.local/bin is present in the user's shell profile so that
|
|
91
|
+
* symlinks placed there are on PATH in new terminal sessions.
|
|
92
|
+
*/
|
|
93
|
+
function ensureLocalBinInShellProfile(localBinDir: string): void {
|
|
94
|
+
const shell = process.env.SHELL ?? "";
|
|
95
|
+
const home = homedir();
|
|
96
|
+
// Determine the appropriate shell profile to modify
|
|
97
|
+
const profilePath = shell.endsWith("/zsh")
|
|
98
|
+
? join(home, ".zshrc")
|
|
99
|
+
: shell.endsWith("/bash")
|
|
100
|
+
? join(home, ".bash_profile")
|
|
101
|
+
: null;
|
|
102
|
+
if (!profilePath) return;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const contents = existsSync(profilePath)
|
|
106
|
+
? readFileSync(profilePath, "utf-8")
|
|
107
|
+
: "";
|
|
108
|
+
// Check if ~/.local/bin is already referenced in PATH exports
|
|
109
|
+
if (contents.includes(localBinDir)) return;
|
|
110
|
+
const line = `\nexport PATH="${localBinDir}:\$PATH"\n`;
|
|
111
|
+
appendFileSync(profilePath, line);
|
|
112
|
+
console.log(` Added ${localBinDir} to ${profilePath}`);
|
|
113
|
+
} catch {
|
|
114
|
+
// Not critical — user can add it manually
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function installCLISymlink(): void {
|
|
119
|
+
const cliBinary = process.execPath;
|
|
120
|
+
if (!cliBinary || !existsSync(cliBinary)) return;
|
|
121
|
+
|
|
122
|
+
// Preferred location — works on most Macs where /usr/local/bin exists
|
|
123
|
+
const preferredPath = "/usr/local/bin/vellum";
|
|
124
|
+
if (trySymlink(cliBinary, preferredPath)) {
|
|
125
|
+
console.log(` Symlinked ${preferredPath} → ${cliBinary}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fallback — use ~/.local/bin which is user-writable and doesn't need root.
|
|
130
|
+
// On some Macs /usr/local doesn't exist and creating it requires admin privileges.
|
|
131
|
+
const localBinDir = join(homedir(), ".local", "bin");
|
|
132
|
+
const fallbackPath = join(localBinDir, "vellum");
|
|
133
|
+
if (trySymlink(cliBinary, fallbackPath)) {
|
|
134
|
+
console.log(` Symlinked ${fallbackPath} → ${cliBinary}`);
|
|
135
|
+
ensureLocalBinInShellProfile(localBinDir);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(
|
|
140
|
+
` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function hatchLocal(
|
|
145
|
+
species: Species,
|
|
146
|
+
name: string | null,
|
|
147
|
+
restart: boolean = false,
|
|
148
|
+
watch: boolean = false,
|
|
149
|
+
keepAlive: boolean = false,
|
|
150
|
+
configValues: Record<string, string> = {},
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
|
|
153
|
+
console.error(
|
|
154
|
+
"Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
|
|
155
|
+
);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const instanceName = generateInstanceName(
|
|
160
|
+
species,
|
|
161
|
+
name ?? process.env.VELLUM_ASSISTANT_NAME,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
emitProgress(1, 7, "Preparing workspace...");
|
|
165
|
+
|
|
166
|
+
// Clean up stale local state: if daemon/gateway processes are running but
|
|
167
|
+
// the lock file has no entries AND the daemon is not healthy, stop them
|
|
168
|
+
// before starting fresh. A healthy daemon should be reused, not killed —
|
|
169
|
+
// it may have been started intentionally via `vellum wake`.
|
|
170
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
171
|
+
const existingAssistants = loadAllAssistants();
|
|
172
|
+
const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
|
|
173
|
+
if (localAssistants.length === 0) {
|
|
174
|
+
const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
|
|
175
|
+
const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
|
|
176
|
+
if (daemonPid.alive || gatewayPid.alive) {
|
|
177
|
+
// Check if the daemon is actually healthy before killing it.
|
|
178
|
+
// Default port 7821 is used when there's no lockfile entry.
|
|
179
|
+
const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
|
|
180
|
+
const healthy = await httpHealthCheck(defaultPort);
|
|
181
|
+
if (!healthy) {
|
|
182
|
+
console.log(
|
|
183
|
+
"🧹 Cleaning up stale local processes (no lock file entry)...\n",
|
|
184
|
+
);
|
|
185
|
+
await stopLocalProcesses();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// On desktop, scan the process table for orphaned vellum processes that
|
|
191
|
+
// are not tracked by any PID file or lock file entry and kill them before
|
|
192
|
+
// starting new ones. This prevents resource leaks when the desktop app
|
|
193
|
+
// crashes or is force-quit without a clean shutdown.
|
|
194
|
+
//
|
|
195
|
+
// Skip orphan cleanup if the daemon is already healthy on the expected port
|
|
196
|
+
// — those processes are intentional (e.g. started via `vellum wake`) and
|
|
197
|
+
// startLocalDaemon() will reuse them.
|
|
198
|
+
if (IS_DESKTOP) {
|
|
199
|
+
const existingResources = findAssistantByName(instanceName);
|
|
200
|
+
const expectedPort =
|
|
201
|
+
existingResources?.cloud === "local" && existingResources.resources
|
|
202
|
+
? existingResources.resources.daemonPort
|
|
203
|
+
: undefined;
|
|
204
|
+
const daemonAlreadyHealthy = expectedPort
|
|
205
|
+
? await httpHealthCheck(expectedPort)
|
|
206
|
+
: false;
|
|
207
|
+
|
|
208
|
+
if (!daemonAlreadyHealthy) {
|
|
209
|
+
const orphans = await detectOrphanedProcesses();
|
|
210
|
+
if (orphans.length > 0) {
|
|
211
|
+
desktopLog(
|
|
212
|
+
`🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
|
|
213
|
+
);
|
|
214
|
+
for (const orphan of orphans) {
|
|
215
|
+
await stopProcess(
|
|
216
|
+
parseInt(orphan.pid, 10),
|
|
217
|
+
`${orphan.name} (PID ${orphan.pid})`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
emitProgress(2, 7, "Allocating resources...");
|
|
225
|
+
|
|
226
|
+
// Reuse existing resources if re-hatching with --name that matches a known
|
|
227
|
+
// local assistant, otherwise allocate fresh per-instance ports and directories.
|
|
228
|
+
let resources: LocalInstanceResources;
|
|
229
|
+
const existingEntry = findAssistantByName(instanceName);
|
|
230
|
+
if (existingEntry?.cloud === "local" && existingEntry.resources) {
|
|
231
|
+
resources = existingEntry.resources;
|
|
232
|
+
} else {
|
|
233
|
+
resources = await allocateLocalResources(instanceName);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Clean up stale workspace data: if the workspace directory already exists for
|
|
237
|
+
// this instance but no local lockfile entry owns it, a previous retire failed
|
|
238
|
+
// to archive it (or a managed-only retire left local data behind). Remove the
|
|
239
|
+
// workspace subtree so the new assistant starts fresh — but preserve the rest
|
|
240
|
+
// of .vellum (e.g. protected/, credentials) which may be shared.
|
|
241
|
+
if (
|
|
242
|
+
!existingEntry ||
|
|
243
|
+
(existingEntry.cloud != null && existingEntry.cloud !== "local")
|
|
244
|
+
) {
|
|
245
|
+
const instanceWorkspaceDir = join(
|
|
246
|
+
resources.instanceDir,
|
|
247
|
+
".vellum",
|
|
248
|
+
"workspace",
|
|
249
|
+
);
|
|
250
|
+
if (existsSync(instanceWorkspaceDir)) {
|
|
251
|
+
const ownedByOther = loadAllAssistants().some((a) => {
|
|
252
|
+
if ((a.cloud != null && a.cloud !== "local") || !a.resources)
|
|
253
|
+
return false;
|
|
254
|
+
return (
|
|
255
|
+
join(a.resources.instanceDir, ".vellum", "workspace") ===
|
|
256
|
+
instanceWorkspaceDir
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
if (!ownedByOther) {
|
|
260
|
+
console.log(
|
|
261
|
+
`🧹 Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
|
|
262
|
+
);
|
|
263
|
+
rmSync(instanceWorkspaceDir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const logsDir = join(
|
|
269
|
+
resources.instanceDir,
|
|
270
|
+
".vellum",
|
|
271
|
+
"workspace",
|
|
272
|
+
"data",
|
|
273
|
+
"logs",
|
|
274
|
+
);
|
|
275
|
+
archiveLogFile("hatch.log", logsDir);
|
|
276
|
+
resetLogFile("hatch.log");
|
|
277
|
+
|
|
278
|
+
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
279
|
+
console.log(` Species: ${species}`);
|
|
280
|
+
console.log("");
|
|
281
|
+
|
|
282
|
+
if (!process.env.APP_VERSION) {
|
|
283
|
+
process.env.APP_VERSION = cliPkg.version;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
emitProgress(3, 7, "Writing configuration...");
|
|
287
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
|
|
288
|
+
|
|
289
|
+
emitProgress(4, 7, "Starting assistant...");
|
|
290
|
+
const signingKey = generateLocalSigningKey();
|
|
291
|
+
await startLocalDaemon(watch, resources, {
|
|
292
|
+
defaultWorkspaceConfigPath,
|
|
293
|
+
signingKey,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
emitProgress(5, 7, "Starting gateway...");
|
|
297
|
+
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
298
|
+
try {
|
|
299
|
+
runtimeUrl = await startGateway(watch, resources, { signingKey });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// Gateway failed — stop the daemon we just started so we don't leave
|
|
302
|
+
// orphaned processes with no lock file entry.
|
|
303
|
+
console.error(
|
|
304
|
+
`\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
|
|
305
|
+
);
|
|
306
|
+
await stopLocalProcesses(resources);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Lease a guardian token so the desktop app can import it on first launch
|
|
311
|
+
// instead of hitting /v1/guardian/init itself.
|
|
312
|
+
emitProgress(6, 7, "Securing connection...");
|
|
313
|
+
try {
|
|
314
|
+
await leaseGuardianToken(runtimeUrl, instanceName);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.error(`⚠️ Guardian token lease failed: ${err}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
320
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
321
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
322
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
323
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
324
|
+
if (ngrokChild?.pid) {
|
|
325
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
326
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
327
|
+
}
|
|
328
|
+
if (prevBaseDataDir !== undefined) {
|
|
329
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
330
|
+
} else {
|
|
331
|
+
delete process.env.BASE_DATA_DIR;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const localEntry: AssistantEntry = {
|
|
335
|
+
assistantId: instanceName,
|
|
336
|
+
runtimeUrl,
|
|
337
|
+
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
338
|
+
cloud: "local",
|
|
339
|
+
species,
|
|
340
|
+
hatchedAt: new Date().toISOString(),
|
|
341
|
+
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
342
|
+
resources: { ...resources, signingKey },
|
|
343
|
+
};
|
|
344
|
+
emitProgress(7, 7, "Saving configuration...");
|
|
345
|
+
if (!restart) {
|
|
346
|
+
saveAssistantEntry(localEntry);
|
|
347
|
+
setActiveAssistant(instanceName);
|
|
348
|
+
syncConfigToLockfile();
|
|
349
|
+
|
|
350
|
+
if (process.env.VELLUM_DESKTOP_APP) {
|
|
351
|
+
installCLISymlink();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log("");
|
|
355
|
+
console.log(`✅ Local assistant hatched!`);
|
|
356
|
+
console.log("");
|
|
357
|
+
console.log("Instance details:");
|
|
358
|
+
console.log(` Name: ${instanceName}`);
|
|
359
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
360
|
+
console.log("");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (keepAlive) {
|
|
364
|
+
const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
365
|
+
const healthTarget = "Gateway";
|
|
366
|
+
const POLL_INTERVAL_MS = 5000;
|
|
367
|
+
const MAX_FAILURES = 3;
|
|
368
|
+
let consecutiveFailures = 0;
|
|
369
|
+
|
|
370
|
+
const shutdown = async (): Promise<void> => {
|
|
371
|
+
console.log("\nShutting down local processes...");
|
|
372
|
+
await stopLocalProcesses(resources);
|
|
373
|
+
process.exit(0);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
process.on("SIGTERM", () => void shutdown());
|
|
377
|
+
process.on("SIGINT", () => void shutdown());
|
|
378
|
+
|
|
379
|
+
// Poll the health endpoint until it stops responding.
|
|
380
|
+
while (true) {
|
|
381
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(healthUrl, {
|
|
384
|
+
signal: AbortSignal.timeout(3000),
|
|
385
|
+
});
|
|
386
|
+
if (res.ok) {
|
|
387
|
+
consecutiveFailures = 0;
|
|
388
|
+
} else {
|
|
389
|
+
consecutiveFailures++;
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
consecutiveFailures++;
|
|
393
|
+
}
|
|
394
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
395
|
+
console.log(
|
|
396
|
+
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
|
|
397
|
+
);
|
|
398
|
+
await stopLocalProcesses(resources);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -474,108 +474,6 @@ function resolveGatewayDir(): string {
|
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
-
function normalizeIngressUrl(value: unknown): string | undefined {
|
|
478
|
-
if (typeof value !== "string") return undefined;
|
|
479
|
-
const normalized = value.trim().replace(/\/+$/, "");
|
|
480
|
-
return normalized || undefined;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// ── Workspace config helpers ──
|
|
484
|
-
|
|
485
|
-
function getWorkspaceConfigPath(instanceDir?: string): string {
|
|
486
|
-
const baseDataDir =
|
|
487
|
-
instanceDir ??
|
|
488
|
-
(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
|
|
489
|
-
return join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function loadWorkspaceConfig(instanceDir?: string): Record<string, unknown> {
|
|
493
|
-
const configPath = getWorkspaceConfigPath(instanceDir);
|
|
494
|
-
try {
|
|
495
|
-
if (!existsSync(configPath)) return {};
|
|
496
|
-
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
497
|
-
string,
|
|
498
|
-
unknown
|
|
499
|
-
>;
|
|
500
|
-
} catch {
|
|
501
|
-
return {};
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function saveWorkspaceConfig(
|
|
506
|
-
config: Record<string, unknown>,
|
|
507
|
-
instanceDir?: string,
|
|
508
|
-
): void {
|
|
509
|
-
const configPath = getWorkspaceConfigPath(instanceDir);
|
|
510
|
-
const dir = dirname(configPath);
|
|
511
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
512
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Write gateway operational settings to the workspace config file so the
|
|
517
|
-
* gateway reads them at startup via its config.ts readWorkspaceConfig().
|
|
518
|
-
*/
|
|
519
|
-
function writeGatewayConfig(
|
|
520
|
-
instanceDir?: string,
|
|
521
|
-
opts?: {
|
|
522
|
-
runtimeProxyEnabled?: boolean;
|
|
523
|
-
runtimeProxyRequireAuth?: boolean;
|
|
524
|
-
unmappedPolicy?: "reject" | "default";
|
|
525
|
-
defaultAssistantId?: string;
|
|
526
|
-
routingEntries?: Array<{
|
|
527
|
-
type: "conversation_id" | "actor_id";
|
|
528
|
-
key: string;
|
|
529
|
-
assistantId: string;
|
|
530
|
-
}>;
|
|
531
|
-
},
|
|
532
|
-
): void {
|
|
533
|
-
const config = loadWorkspaceConfig(instanceDir);
|
|
534
|
-
const gateway = (config.gateway ?? {}) as Record<string, unknown>;
|
|
535
|
-
|
|
536
|
-
if (opts?.runtimeProxyEnabled !== undefined) {
|
|
537
|
-
gateway.runtimeProxyEnabled = opts.runtimeProxyEnabled;
|
|
538
|
-
}
|
|
539
|
-
if (opts?.runtimeProxyRequireAuth !== undefined) {
|
|
540
|
-
gateway.runtimeProxyRequireAuth = opts.runtimeProxyRequireAuth;
|
|
541
|
-
}
|
|
542
|
-
if (opts?.unmappedPolicy !== undefined) {
|
|
543
|
-
gateway.unmappedPolicy = opts.unmappedPolicy;
|
|
544
|
-
}
|
|
545
|
-
if (opts?.defaultAssistantId !== undefined) {
|
|
546
|
-
gateway.defaultAssistantId = opts.defaultAssistantId;
|
|
547
|
-
}
|
|
548
|
-
if (opts?.routingEntries !== undefined) {
|
|
549
|
-
gateway.routingEntries = opts.routingEntries;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
config.gateway = gateway;
|
|
553
|
-
saveWorkspaceConfig(config, instanceDir);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function readWorkspaceIngressPublicBaseUrl(
|
|
557
|
-
instanceDir?: string,
|
|
558
|
-
): string | undefined {
|
|
559
|
-
const baseDataDir =
|
|
560
|
-
instanceDir ??
|
|
561
|
-
(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
|
|
562
|
-
const workspaceConfigPath = join(
|
|
563
|
-
baseDataDir,
|
|
564
|
-
".vellum",
|
|
565
|
-
"workspace",
|
|
566
|
-
"config.json",
|
|
567
|
-
);
|
|
568
|
-
try {
|
|
569
|
-
const raw = JSON.parse(
|
|
570
|
-
readFileSync(workspaceConfigPath, "utf-8"),
|
|
571
|
-
) as Record<string, unknown>;
|
|
572
|
-
const ingress = raw.ingress as Record<string, unknown> | undefined;
|
|
573
|
-
return normalizeIngressUrl(ingress?.publicBaseUrl);
|
|
574
|
-
} catch {
|
|
575
|
-
return undefined;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
477
|
/**
|
|
580
478
|
* Check if the daemon is responsive by hitting its HTTP `/healthz` endpoint.
|
|
581
479
|
* This replaces the socket-based `isSocketResponsive()` check.
|
|
@@ -973,6 +871,7 @@ export async function startLocalDaemon(
|
|
|
973
871
|
"VELLUM_DEBUG",
|
|
974
872
|
"VELLUM_DEV",
|
|
975
873
|
"VELLUM_DESKTOP_APP",
|
|
874
|
+
"VELLUM_WORKSPACE_DIR",
|
|
976
875
|
]) {
|
|
977
876
|
if (process.env[key]) {
|
|
978
877
|
daemonEnv[key] = process.env[key]!;
|
|
@@ -1131,19 +1030,16 @@ export async function startGateway(
|
|
|
1131
1030
|
const effectiveDaemonPort =
|
|
1132
1031
|
resources?.daemonPort ?? Number(process.env.RUNTIME_HTTP_PORT || "7821");
|
|
1133
1032
|
|
|
1134
|
-
// Write gateway operational settings to workspace config before starting
|
|
1135
|
-
// the gateway process. The gateway reads these at startup from config.json.
|
|
1136
|
-
writeGatewayConfig(resources?.instanceDir, {
|
|
1137
|
-
runtimeProxyEnabled: true,
|
|
1138
|
-
runtimeProxyRequireAuth: true,
|
|
1139
|
-
unmappedPolicy: "default",
|
|
1140
|
-
defaultAssistantId: "self",
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
1033
|
const gatewayEnv: Record<string, string> = {
|
|
1144
1034
|
...(process.env as Record<string, string>),
|
|
1145
1035
|
RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
|
|
1146
1036
|
GATEWAY_PORT: String(effectiveGatewayPort),
|
|
1037
|
+
// Pass gateway operational settings via env vars so the CLI does not
|
|
1038
|
+
// need direct access to the workspace config file.
|
|
1039
|
+
RUNTIME_PROXY_ENABLED: "true",
|
|
1040
|
+
RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
1041
|
+
UNMAPPED_POLICY: "default",
|
|
1042
|
+
DEFAULT_ASSISTANT_ID: "self",
|
|
1147
1043
|
...(options?.signingKey
|
|
1148
1044
|
? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
|
|
1149
1045
|
: {}),
|
|
@@ -1152,15 +1048,8 @@ export async function startGateway(
|
|
|
1152
1048
|
// workspace config for this instance (mirrors the daemon env setup).
|
|
1153
1049
|
...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
|
|
1154
1050
|
};
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
// for diagnostic visibility during startup.
|
|
1158
|
-
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
|
|
1159
|
-
resources?.instanceDir,
|
|
1160
|
-
);
|
|
1161
|
-
const ingressPublicBaseUrl = workspaceIngressPublicBaseUrl ?? publicUrl;
|
|
1162
|
-
if (ingressPublicBaseUrl) {
|
|
1163
|
-
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
1051
|
+
if (publicUrl) {
|
|
1052
|
+
console.log(` Ingress URL: ${publicUrl}`);
|
|
1164
1053
|
}
|
|
1165
1054
|
|
|
1166
1055
|
let gateway;
|