@vellumai/cli 0.4.43 ā 0.4.45
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/CONTRIBUTING.md +5 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +249 -1
- package/src/__tests__/multi-local.test.ts +6 -5
- package/src/commands/clean.ts +34 -0
- package/src/commands/hatch.ts +48 -6
- package/src/commands/ps.ts +8 -104
- package/src/commands/wake.ts +17 -1
- package/src/components/DefaultMainScreen.tsx +234 -105
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +144 -6
- package/src/lib/aws.ts +0 -1
- package/src/lib/docker.ts +59 -7
- package/src/lib/gcp.ts +0 -52
- package/src/lib/local.ts +38 -20
- package/src/lib/ngrok.ts +92 -0
- package/src/lib/orphan-detection.ts +103 -0
- package/src/lib/xdg-log.ts +47 -3
package/src/lib/docker.ts
CHANGED
|
@@ -10,7 +10,12 @@ import type { Species } from "./constants";
|
|
|
10
10
|
import { discoverPublicUrl } from "./local";
|
|
11
11
|
import { generateRandomSuffix } from "./random-name";
|
|
12
12
|
import { exec, execOutput } from "./step-runner";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
closeLogFile,
|
|
15
|
+
openLogFile,
|
|
16
|
+
resetLogFile,
|
|
17
|
+
writeToLogFile,
|
|
18
|
+
} from "./xdg-log";
|
|
14
19
|
|
|
15
20
|
const _require = createRequire(import.meta.url);
|
|
16
21
|
|
|
@@ -50,6 +55,12 @@ function findDockerRoot(): DockerRoot {
|
|
|
50
55
|
dir = parent;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// macOS app bundle: Contents/MacOS/vellum-cli -> Contents/Resources/Dockerfile
|
|
59
|
+
const appResourcesDir = join(dirname(process.execPath), "..", "Resources");
|
|
60
|
+
if (existsSync(join(appResourcesDir, "Dockerfile"))) {
|
|
61
|
+
return { root: appResourcesDir, dockerfileDir: "." };
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
// Fall back to Node module resolution for the `vellum` package
|
|
54
65
|
try {
|
|
55
66
|
const vellumPkgPath = _require.resolve("vellum/package.json");
|
|
@@ -152,14 +163,38 @@ export async function hatchDocker(
|
|
|
152
163
|
name: string | null,
|
|
153
164
|
watch: boolean,
|
|
154
165
|
): Promise<void> {
|
|
155
|
-
|
|
166
|
+
resetLogFile("hatch.log");
|
|
167
|
+
|
|
168
|
+
let repoRoot: string;
|
|
169
|
+
let dockerfileDir: string;
|
|
170
|
+
try {
|
|
171
|
+
({ root: repoRoot, dockerfileDir } = findDockerRoot());
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
+
const logFd = openLogFile("hatch.log");
|
|
175
|
+
writeToLogFile(
|
|
176
|
+
logFd,
|
|
177
|
+
`[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
178
|
+
);
|
|
179
|
+
closeLogFile(logFd);
|
|
180
|
+
console.error(message);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
|
|
157
185
|
const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
|
|
158
186
|
const dockerfile = join(dockerfileDir, dockerfileName);
|
|
159
187
|
const dockerfilePath = join(repoRoot, dockerfile);
|
|
160
188
|
|
|
161
189
|
if (!existsSync(dockerfilePath)) {
|
|
162
|
-
|
|
190
|
+
const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
|
|
191
|
+
const logFd = openLogFile("hatch.log");
|
|
192
|
+
writeToLogFile(
|
|
193
|
+
logFd,
|
|
194
|
+
`[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
195
|
+
);
|
|
196
|
+
closeLogFile(logFd);
|
|
197
|
+
console.error(message);
|
|
163
198
|
process.exit(1);
|
|
164
199
|
}
|
|
165
200
|
|
|
@@ -180,7 +215,10 @@ export async function hatchDocker(
|
|
|
180
215
|
});
|
|
181
216
|
} catch (err) {
|
|
182
217
|
const message = err instanceof Error ? err.message : String(err);
|
|
183
|
-
writeToLogFile(
|
|
218
|
+
writeToLogFile(
|
|
219
|
+
logFd,
|
|
220
|
+
`[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
|
|
221
|
+
);
|
|
184
222
|
closeLogFile(logFd);
|
|
185
223
|
throw err;
|
|
186
224
|
}
|
|
@@ -242,13 +280,21 @@ export async function hatchDocker(
|
|
|
242
280
|
// requires an extra argument the Dockerfile doesn't include.
|
|
243
281
|
const containerCmd: string[] =
|
|
244
282
|
species !== "vellum"
|
|
245
|
-
? [
|
|
283
|
+
? [
|
|
284
|
+
"vellum",
|
|
285
|
+
"hatch",
|
|
286
|
+
species,
|
|
287
|
+
...(watch ? ["--watch"] : []),
|
|
288
|
+
"--keep-alive",
|
|
289
|
+
]
|
|
246
290
|
: [];
|
|
247
291
|
|
|
248
292
|
// Always start the container detached so it keeps running after the CLI exits.
|
|
249
293
|
runArgs.push("-d");
|
|
250
294
|
console.log("š Starting Docker container...");
|
|
251
|
-
await exec("docker", [...runArgs, imageTag, ...containerCmd], {
|
|
295
|
+
await exec("docker", [...runArgs, imageTag, ...containerCmd], {
|
|
296
|
+
cwd: repoRoot,
|
|
297
|
+
});
|
|
252
298
|
|
|
253
299
|
if (detached) {
|
|
254
300
|
console.log("\nā
Docker assistant hatched!\n");
|
|
@@ -302,7 +348,13 @@ export async function hatchDocker(
|
|
|
302
348
|
child.on("close", (code) => {
|
|
303
349
|
// The log tail may exit if the container stops before the sentinel
|
|
304
350
|
// is seen, or we killed it after detecting the sentinel.
|
|
305
|
-
if (
|
|
351
|
+
if (
|
|
352
|
+
code === 0 ||
|
|
353
|
+
code === null ||
|
|
354
|
+
code === 130 ||
|
|
355
|
+
code === 137 ||
|
|
356
|
+
code === 143
|
|
357
|
+
) {
|
|
306
358
|
resolve();
|
|
307
359
|
} else {
|
|
308
360
|
reject(new Error(`Docker container exited with code ${code}`));
|
package/src/lib/gcp.ts
CHANGED
|
@@ -441,45 +441,6 @@ async function recoverFromCurlFailure(
|
|
|
441
441
|
} catch {}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
async function fetchRemoteBearerToken(
|
|
445
|
-
instanceName: string,
|
|
446
|
-
project: string,
|
|
447
|
-
zone: string,
|
|
448
|
-
sshUser: string,
|
|
449
|
-
account?: string,
|
|
450
|
-
): Promise<string | null> {
|
|
451
|
-
try {
|
|
452
|
-
const remoteCmd =
|
|
453
|
-
'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
|
|
454
|
-
const args = [
|
|
455
|
-
"compute",
|
|
456
|
-
"ssh",
|
|
457
|
-
`${sshUser}@${instanceName}`,
|
|
458
|
-
`--project=${project}`,
|
|
459
|
-
`--zone=${zone}`,
|
|
460
|
-
"--quiet",
|
|
461
|
-
"--ssh-flag=-o StrictHostKeyChecking=no",
|
|
462
|
-
"--ssh-flag=-o UserKnownHostsFile=/dev/null",
|
|
463
|
-
"--ssh-flag=-o ConnectTimeout=10",
|
|
464
|
-
"--ssh-flag=-o LogLevel=ERROR",
|
|
465
|
-
`--command=${remoteCmd}`,
|
|
466
|
-
];
|
|
467
|
-
if (account) args.push(`--account=${account}`);
|
|
468
|
-
const output = await execOutput("gcloud", args);
|
|
469
|
-
const data = JSON.parse(output.trim());
|
|
470
|
-
const assistants = data.assistants;
|
|
471
|
-
if (Array.isArray(assistants) && assistants.length > 0) {
|
|
472
|
-
const token = assistants[0].bearerToken;
|
|
473
|
-
if (typeof token === "string" && token) {
|
|
474
|
-
return token;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
return null;
|
|
478
|
-
} catch {
|
|
479
|
-
return null;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
444
|
export async function hatchGcp(
|
|
484
445
|
species: Species,
|
|
485
446
|
detached: boolean,
|
|
@@ -629,7 +590,6 @@ export async function hatchGcp(
|
|
|
629
590
|
const gcpEntry: AssistantEntry = {
|
|
630
591
|
assistantId: instanceName,
|
|
631
592
|
runtimeUrl,
|
|
632
|
-
bearerToken,
|
|
633
593
|
cloud: "gcp",
|
|
634
594
|
project,
|
|
635
595
|
zone,
|
|
@@ -694,18 +654,6 @@ export async function hatchGcp(
|
|
|
694
654
|
}
|
|
695
655
|
}
|
|
696
656
|
|
|
697
|
-
const remoteBearerToken = await fetchRemoteBearerToken(
|
|
698
|
-
instanceName,
|
|
699
|
-
project,
|
|
700
|
-
zone,
|
|
701
|
-
sshUser,
|
|
702
|
-
account,
|
|
703
|
-
);
|
|
704
|
-
if (remoteBearerToken) {
|
|
705
|
-
gcpEntry.bearerToken = remoteBearerToken;
|
|
706
|
-
saveAssistantEntry(gcpEntry);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
657
|
console.log("Instance details:");
|
|
710
658
|
console.log(` Name: ${instanceName}`);
|
|
711
659
|
console.log(` Project: ${project}`);
|
package/src/lib/local.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execFileSync, execSync, spawn } from "child_process";
|
|
2
2
|
import {
|
|
3
|
-
closeSync,
|
|
4
3
|
existsSync,
|
|
5
4
|
mkdirSync,
|
|
6
5
|
readFileSync,
|
|
@@ -258,16 +257,13 @@ async function startDaemonFromSource(
|
|
|
258
257
|
delete env.QDRANT_URL;
|
|
259
258
|
}
|
|
260
259
|
|
|
261
|
-
|
|
262
|
-
// after the parent (hatch) exits. Bun does not ignore SIGPIPE, so piped
|
|
263
|
-
// stdio would kill the daemon on its first write after the parent closes.
|
|
264
|
-
const logFd = openLogFile("hatch.log");
|
|
260
|
+
const daemonLogFd = openLogFile("hatch.log");
|
|
265
261
|
const child = spawn("bun", ["run", daemonMainPath], {
|
|
266
262
|
detached: true,
|
|
267
|
-
stdio: ["ignore",
|
|
263
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
268
264
|
env,
|
|
269
265
|
});
|
|
270
|
-
|
|
266
|
+
pipeToLogFile(child, daemonLogFd, "daemon");
|
|
271
267
|
child.unref();
|
|
272
268
|
|
|
273
269
|
if (child.pid) {
|
|
@@ -472,7 +468,9 @@ function recoverPidFile(
|
|
|
472
468
|
return pid;
|
|
473
469
|
}
|
|
474
470
|
|
|
475
|
-
export async function discoverPublicUrl(
|
|
471
|
+
export async function discoverPublicUrl(
|
|
472
|
+
port?: number,
|
|
473
|
+
): Promise<string | undefined> {
|
|
476
474
|
const effectivePort = port ?? GATEWAY_PORT;
|
|
477
475
|
const cloud = process.env.VELLUM_CLOUD;
|
|
478
476
|
|
|
@@ -714,18 +712,14 @@ export async function startLocalDaemon(
|
|
|
714
712
|
delete daemonEnv.QDRANT_URL;
|
|
715
713
|
}
|
|
716
714
|
|
|
717
|
-
// Use fd inheritance instead of pipes so the daemon's stdout/stderr
|
|
718
|
-
// survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
|
|
719
|
-
// so piped stdio would kill the daemon on its first write after the
|
|
720
|
-
// parent closes.
|
|
721
715
|
const daemonLogFd = openLogFile("hatch.log");
|
|
722
716
|
const child = spawn(daemonBinary, [], {
|
|
723
717
|
cwd: dirname(daemonBinary),
|
|
724
718
|
detached: true,
|
|
725
|
-
stdio: ["ignore",
|
|
719
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
726
720
|
env: daemonEnv,
|
|
727
721
|
});
|
|
728
|
-
|
|
722
|
+
pipeToLogFile(child, daemonLogFd, "daemon");
|
|
729
723
|
child.unref();
|
|
730
724
|
const daemonPid = child.pid;
|
|
731
725
|
|
|
@@ -816,6 +810,16 @@ export async function startGateway(
|
|
|
816
810
|
): Promise<string> {
|
|
817
811
|
const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
|
|
818
812
|
|
|
813
|
+
// Kill any existing gateway process before spawning a new one.
|
|
814
|
+
// Without this, crashed/stale gateways accumulate as zombies ā the old
|
|
815
|
+
// process holds the port (or lingers after losing it), and every restart
|
|
816
|
+
// attempt spawns yet another process that fails with EADDRINUSE.
|
|
817
|
+
const gwPidDir = resources
|
|
818
|
+
? join(resources.instanceDir, ".vellum")
|
|
819
|
+
: join(homedir(), ".vellum");
|
|
820
|
+
const gwPidFile = join(gwPidDir, "gateway.pid");
|
|
821
|
+
await stopProcessByPidFile(gwPidFile, "gateway");
|
|
822
|
+
|
|
819
823
|
const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
|
|
820
824
|
if (publicUrl) {
|
|
821
825
|
console.log(` Public URL: ${publicUrl}`);
|
|
@@ -952,15 +956,13 @@ export async function startGateway(
|
|
|
952
956
|
);
|
|
953
957
|
}
|
|
954
958
|
|
|
955
|
-
// Use fd inheritance (not pipes) so the gateway survives after the
|
|
956
|
-
// hatch CLI exits ā Bun does not ignore SIGPIPE.
|
|
957
959
|
const gatewayLogFd = openLogFile("hatch.log");
|
|
958
960
|
gateway = spawn(gatewayBinary, [], {
|
|
959
961
|
detached: true,
|
|
960
|
-
stdio: ["ignore",
|
|
962
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
961
963
|
env: gatewayEnv,
|
|
962
964
|
});
|
|
963
|
-
|
|
965
|
+
pipeToLogFile(gateway, gatewayLogFd, "gateway");
|
|
964
966
|
} else {
|
|
965
967
|
// Source tree / bunx: resolve the gateway source directory and run via bun.
|
|
966
968
|
const gatewayDir = resolveGatewayDir();
|
|
@@ -971,10 +973,10 @@ export async function startGateway(
|
|
|
971
973
|
gateway = spawn("bun", bunArgs, {
|
|
972
974
|
cwd: gatewayDir,
|
|
973
975
|
detached: true,
|
|
974
|
-
stdio: ["ignore",
|
|
976
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
975
977
|
env: gatewayEnv,
|
|
976
978
|
});
|
|
977
|
-
|
|
979
|
+
pipeToLogFile(gateway, gwLogFd, "gateway");
|
|
978
980
|
if (watch) {
|
|
979
981
|
console.log(" Gateway started in watch mode (bun --watch)");
|
|
980
982
|
}
|
|
@@ -1044,4 +1046,20 @@ export async function stopLocalProcesses(
|
|
|
1044
1046
|
|
|
1045
1047
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
1046
1048
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
1049
|
+
|
|
1050
|
+
// Kill ngrok directly by PID rather than using stopProcessByPidFile, because
|
|
1051
|
+
// isVellumProcess() checks for /vellum|@vellumai|--vellum-gateway/ which
|
|
1052
|
+
// won't match the ngrok binary ā resulting in a no-op that leaves ngrok running.
|
|
1053
|
+
const ngrokPidFile = join(vellumDir, "ngrok.pid");
|
|
1054
|
+
if (existsSync(ngrokPidFile)) {
|
|
1055
|
+
try {
|
|
1056
|
+
const pid = parseInt(readFileSync(ngrokPidFile, "utf-8").trim(), 10);
|
|
1057
|
+
if (!isNaN(pid)) {
|
|
1058
|
+
try {
|
|
1059
|
+
process.kill(pid, "SIGTERM");
|
|
1060
|
+
} catch {}
|
|
1061
|
+
}
|
|
1062
|
+
unlinkSync(ngrokPidFile);
|
|
1063
|
+
} catch {}
|
|
1064
|
+
}
|
|
1047
1065
|
}
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -115,6 +115,7 @@ export async function findExistingTunnel(
|
|
|
115
115
|
*/
|
|
116
116
|
export function startNgrokProcess(targetPort: number): ChildProcess {
|
|
117
117
|
const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
|
|
118
|
+
detached: true,
|
|
118
119
|
stdio: ["ignore", "pipe", "pipe"],
|
|
119
120
|
});
|
|
120
121
|
return child;
|
|
@@ -168,6 +169,97 @@ function clearIngressUrl(): void {
|
|
|
168
169
|
saveRawConfig(config);
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Check whether any webhook-based integrations (e.g. Telegram) are configured
|
|
174
|
+
* that require a public ingress URL.
|
|
175
|
+
*/
|
|
176
|
+
function hasWebhookIntegrationsConfigured(): boolean {
|
|
177
|
+
try {
|
|
178
|
+
const config = loadRawConfig();
|
|
179
|
+
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
180
|
+
if (telegram?.botUsername) return true;
|
|
181
|
+
return false;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check whether a non-ngrok ingress URL is already configured (e.g. custom
|
|
189
|
+
* domain or cloud deployment), meaning ngrok is not needed.
|
|
190
|
+
*/
|
|
191
|
+
function hasNonNgrokIngressUrl(): boolean {
|
|
192
|
+
try {
|
|
193
|
+
const config = loadRawConfig();
|
|
194
|
+
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
195
|
+
const publicBaseUrl = ingress?.publicBaseUrl;
|
|
196
|
+
if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
|
|
197
|
+
return !publicBaseUrl.includes("ngrok");
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Auto-start an ngrok tunnel if webhook integrations are configured and no
|
|
205
|
+
* non-ngrok ingress URL is present. Designed to be called during daemon/gateway
|
|
206
|
+
* startup. Non-fatal: if ngrok is unavailable or fails, startup continues.
|
|
207
|
+
*
|
|
208
|
+
* Returns the spawned ngrok child process (for PID tracking) or null.
|
|
209
|
+
*/
|
|
210
|
+
export async function maybeStartNgrokTunnel(
|
|
211
|
+
targetPort: number,
|
|
212
|
+
): Promise<ChildProcess | null> {
|
|
213
|
+
if (!hasWebhookIntegrationsConfigured()) return null;
|
|
214
|
+
if (hasNonNgrokIngressUrl()) return null;
|
|
215
|
+
|
|
216
|
+
const version = getNgrokVersion();
|
|
217
|
+
if (!version) return null;
|
|
218
|
+
|
|
219
|
+
// Reuse an existing tunnel if one is already running
|
|
220
|
+
const existingUrl = await findExistingTunnel(targetPort);
|
|
221
|
+
if (existingUrl) {
|
|
222
|
+
console.log(` Found existing ngrok tunnel: ${existingUrl}`);
|
|
223
|
+
saveIngressUrl(existingUrl);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log(` Starting ngrok tunnel for webhook integrations...`);
|
|
228
|
+
const ngrokProcess = startNgrokProcess(targetPort);
|
|
229
|
+
|
|
230
|
+
// Pipe output for debugging but don't block on it
|
|
231
|
+
ngrokProcess.stdout?.on("data", (data: Buffer) => {
|
|
232
|
+
const line = data.toString().trim();
|
|
233
|
+
if (line) console.log(`[ngrok] ${line}`);
|
|
234
|
+
});
|
|
235
|
+
ngrokProcess.stderr?.on("data", (data: Buffer) => {
|
|
236
|
+
const line = data.toString().trim();
|
|
237
|
+
if (line) console.error(`[ngrok] ${line}`);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const publicUrl = await waitForNgrokUrl();
|
|
242
|
+
saveIngressUrl(publicUrl);
|
|
243
|
+
console.log(` Tunnel established: ${publicUrl}`);
|
|
244
|
+
|
|
245
|
+
// Detach the ngrok process so the CLI (hatch/wake) can exit without
|
|
246
|
+
// keeping it alive. Remove stdout/stderr listeners and unref all handles.
|
|
247
|
+
ngrokProcess.stdout?.removeAllListeners("data");
|
|
248
|
+
ngrokProcess.stderr?.removeAllListeners("data");
|
|
249
|
+
ngrokProcess.stdout?.destroy();
|
|
250
|
+
ngrokProcess.stderr?.destroy();
|
|
251
|
+
ngrokProcess.unref();
|
|
252
|
+
|
|
253
|
+
return ngrokProcess;
|
|
254
|
+
} catch {
|
|
255
|
+
console.warn(
|
|
256
|
+
` ā Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel\`.`,
|
|
257
|
+
);
|
|
258
|
+
if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
171
263
|
/**
|
|
172
264
|
* Run the ngrok tunnel workflow: check installation, find or start a tunnel,
|
|
173
265
|
* save the public URL to config, and block until exit or signal.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
import { execOutput } from "./step-runner";
|
|
6
|
+
|
|
7
|
+
export interface RemoteProcess {
|
|
8
|
+
pid: string;
|
|
9
|
+
ppid: string;
|
|
10
|
+
command: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function classifyProcess(command: string): string {
|
|
14
|
+
if (/qdrant/.test(command)) return "qdrant";
|
|
15
|
+
if (/vellum-gateway/.test(command)) return "gateway";
|
|
16
|
+
if (/openclaw/.test(command)) return "openclaw-adapter";
|
|
17
|
+
if (/vellum-daemon/.test(command)) return "assistant";
|
|
18
|
+
if (/daemon\s+(start|restart)/.test(command)) return "assistant";
|
|
19
|
+
// Exclude macOS desktop app processes ā their path contains .app/Contents/MacOS/
|
|
20
|
+
// but they are not background service processes.
|
|
21
|
+
if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
|
|
22
|
+
if (/vellum/.test(command)) return "vellum";
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseRemotePs(output: string): RemoteProcess[] {
|
|
27
|
+
return output
|
|
28
|
+
.trim()
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter((line) => line.trim().length > 0)
|
|
31
|
+
.map((line) => {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
const parts = trimmed.split(/\s+/);
|
|
34
|
+
const pid = parts[0];
|
|
35
|
+
const ppid = parts[1];
|
|
36
|
+
const command = parts.slice(2).join(" ");
|
|
37
|
+
return { pid, ppid, command };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readPidFile(pidFile: string): string | null {
|
|
42
|
+
if (!existsSync(pidFile)) return null;
|
|
43
|
+
const pid = readFileSync(pidFile, "utf-8").trim();
|
|
44
|
+
return pid || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isProcessAlive(pid: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(parseInt(pid, 10), 0);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OrphanedProcess {
|
|
57
|
+
name: string;
|
|
58
|
+
pid: string;
|
|
59
|
+
source: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
63
|
+
const results: OrphanedProcess[] = [];
|
|
64
|
+
const seenPids = new Set<string>();
|
|
65
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
66
|
+
|
|
67
|
+
// Strategy 1: PID file scan
|
|
68
|
+
const pidFiles: Array<{ file: string; name: string }> = [
|
|
69
|
+
{ file: join(vellumDir, "vellum.pid"), name: "assistant" },
|
|
70
|
+
{ file: join(vellumDir, "gateway.pid"), name: "gateway" },
|
|
71
|
+
{ file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const { file, name } of pidFiles) {
|
|
75
|
+
const pid = readPidFile(file);
|
|
76
|
+
if (pid && isProcessAlive(pid)) {
|
|
77
|
+
results.push({ name, pid, source: "pid file" });
|
|
78
|
+
seenPids.add(pid);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Strategy 2: Process table scan
|
|
83
|
+
try {
|
|
84
|
+
const output = await execOutput("sh", [
|
|
85
|
+
"-c",
|
|
86
|
+
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
|
|
87
|
+
]);
|
|
88
|
+
const procs = parseRemotePs(output);
|
|
89
|
+
const ownPid = String(process.pid);
|
|
90
|
+
|
|
91
|
+
for (const p of procs) {
|
|
92
|
+
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
93
|
+
const type = classifyProcess(p.command);
|
|
94
|
+
if (type === "unknown") continue;
|
|
95
|
+
results.push({ name: type, pid: p.pid, source: "process table" });
|
|
96
|
+
seenPids.add(p.pid);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// grep exits 1 when no matches found ā ignore
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return results;
|
|
103
|
+
}
|
package/src/lib/xdg-log.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { closeSync, mkdirSync, openSync, writeSync } from "fs";
|
|
2
1
|
import type { ChildProcess } from "child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
writeSync,
|
|
11
|
+
} from "fs";
|
|
3
12
|
import { homedir } from "os";
|
|
4
13
|
import { join } from "path";
|
|
5
14
|
|
|
15
|
+
/** Regex matching pino-pretty's short time prefix, e.g. `[12:07:37.467] `. */
|
|
16
|
+
const PINO_TIME_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
|
|
17
|
+
|
|
6
18
|
/** Returns the XDG-compatible log directory for Vellum CLI logs. */
|
|
7
19
|
export function getLogDir(): string {
|
|
8
20
|
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
@@ -23,6 +35,36 @@ export function openLogFile(name: string): number | "ignore" {
|
|
|
23
35
|
}
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
/** Truncate (or create) a log file so each session starts fresh. */
|
|
39
|
+
export function resetLogFile(name: string): void {
|
|
40
|
+
try {
|
|
41
|
+
const dir = getLogDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(join(dir, name), "");
|
|
44
|
+
} catch {
|
|
45
|
+
/* best-effort */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Copy the current log file into `destDir` with a timestamped name so that
|
|
51
|
+
* previous session logs are preserved for debugging. No-op when the source
|
|
52
|
+
* file is missing or empty.
|
|
53
|
+
*/
|
|
54
|
+
export function archiveLogFile(name: string, destDir: string): void {
|
|
55
|
+
try {
|
|
56
|
+
const srcPath = join(getLogDir(), name);
|
|
57
|
+
if (!existsSync(srcPath) || statSync(srcPath).size === 0) return;
|
|
58
|
+
|
|
59
|
+
mkdirSync(destDir, { recursive: true });
|
|
60
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
61
|
+
const base = name.replace(/\.log$/, "");
|
|
62
|
+
copyFileSync(srcPath, join(destDir, `${base}-${ts}.log`));
|
|
63
|
+
} catch {
|
|
64
|
+
/* best-effort */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
26
68
|
/** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
|
|
27
69
|
export function closeLogFile(fd: number | "ignore"): void {
|
|
28
70
|
if (typeof fd === "number") {
|
|
@@ -46,7 +88,8 @@ export function writeToLogFile(fd: number | "ignore", msg: string): void {
|
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
/** Pipe a child process's stdout/stderr to a shared log file descriptor,
|
|
49
|
-
* prefixing each line with
|
|
91
|
+
* prefixing each line with an ISO timestamp and tag (e.g. "[daemon]").
|
|
92
|
+
* Strips pino-pretty's redundant short time prefix when present.
|
|
50
93
|
* Streams are unref'd so they don't prevent the parent from exiting.
|
|
51
94
|
* The fd is closed automatically when both streams end. */
|
|
52
95
|
export function pipeToLogFile(
|
|
@@ -80,9 +123,10 @@ export function pipeToLogFile(
|
|
|
80
123
|
for (let i = 0; i < lines.length; i++) {
|
|
81
124
|
if (i === lines.length - 1 && lines[i] === "") break;
|
|
82
125
|
const nl = i < lines.length - 1 ? "\n" : "";
|
|
126
|
+
const stripped = lines[i].replace(PINO_TIME_RE, "");
|
|
83
127
|
const prefix = `${new Date().toISOString()} ${tagLabel} `;
|
|
84
128
|
try {
|
|
85
|
-
writeSync(numFd, prefix +
|
|
129
|
+
writeSync(numFd, prefix + stripped + nl);
|
|
86
130
|
} catch {
|
|
87
131
|
/* best-effort */
|
|
88
132
|
}
|