@vellumai/cli 0.6.6 → 0.7.1
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/AGENTS.md +8 -2
- package/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +146 -0
- package/src/__tests__/env-drift.test.ts +10 -32
- package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
- package/src/__tests__/multi-local.test.ts +0 -5
- package/src/__tests__/sleep.test.ts +1 -2
- package/src/__tests__/teleport.test.ts +988 -1266
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +58 -13
- package/src/commands/login.ts +77 -12
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +144 -25
- package/src/commands/restore.ts +26 -47
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +17 -7
- package/src/commands/teleport.ts +462 -584
- package/src/commands/terminal.ts +9 -221
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +328 -154
- package/src/index.ts +5 -7
- package/src/lib/__tests__/docker.test.ts +50 -74
- package/src/lib/__tests__/job-polling.test.ts +278 -0
- package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/__tests__/runtime-url.test.ts +87 -0
- package/src/lib/__tests__/terminal-session.test.ts +202 -0
- package/src/lib/assistant-client.ts +5 -21
- package/src/lib/assistant-config.ts +46 -24
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/docker.ts +75 -77
- package/src/lib/environments/__tests__/paths.test.ts +2 -0
- package/src/lib/environments/resolve.ts +89 -7
- package/src/lib/environments/seeds.ts +8 -5
- package/src/lib/environments/types.ts +10 -0
- package/src/lib/hatch-local.ts +15 -120
- package/src/lib/health-check.ts +98 -0
- package/src/lib/job-polling.ts +195 -0
- package/src/lib/local-runtime-client.ts +231 -0
- package/src/lib/local.ts +165 -72
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +190 -194
- package/src/lib/platform-releases.ts +23 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/runtime-url.ts +30 -0
- package/src/lib/sync-cloud-assistants.ts +126 -0
- package/src/lib/terminal-client.ts +6 -1
- package/src/lib/terminal-session.ts +536 -0
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { AssistantEntry } from "./assistant-config.js";
|
|
2
|
+
import {
|
|
3
|
+
authHeaders,
|
|
4
|
+
parseUnifiedJobStatus,
|
|
5
|
+
type UnifiedJobStatus,
|
|
6
|
+
} from "./platform-client.js";
|
|
7
|
+
import { resolveRuntimeMigrationUrl } from "./runtime-url.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when the local runtime returns 409 for an export/import request
|
|
11
|
+
* because another migration of the same type is already in-flight. The
|
|
12
|
+
* caller can inspect {@link existingJobId} and decide whether to poll the
|
|
13
|
+
* existing job instead of retrying.
|
|
14
|
+
*/
|
|
15
|
+
export class MigrationInProgressError extends Error {
|
|
16
|
+
readonly existingJobId: string;
|
|
17
|
+
readonly kind: "export_in_progress" | "import_in_progress";
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
kind: "export_in_progress" | "import_in_progress",
|
|
21
|
+
jobId: string,
|
|
22
|
+
) {
|
|
23
|
+
super(
|
|
24
|
+
`A migration is already in progress (${kind}); existing job_id=${jobId}`,
|
|
25
|
+
);
|
|
26
|
+
this.name = "MigrationInProgressError";
|
|
27
|
+
this.kind = kind;
|
|
28
|
+
this.existingJobId = jobId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bearerHeaders(token: string): Record<string, string> {
|
|
33
|
+
return {
|
|
34
|
+
Authorization: `Bearer ${token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
Accept: "application/json",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the auth + content headers for a runtime migration request.
|
|
42
|
+
*
|
|
43
|
+
* - For `cloud === "vellum"` we go through the platform's wildcard runtime
|
|
44
|
+
* proxy, which authenticates user-session / vak_ tokens via DRF's default
|
|
45
|
+
* authentication classes — `authHeaders()` produces the right combination
|
|
46
|
+
* (`X-Session-Token` + `Vellum-Organization-Id`, or `Authorization: Bearer
|
|
47
|
+
* vak_...`).
|
|
48
|
+
* - For local/docker the runtime endpoint expects a guardian-token bearer.
|
|
49
|
+
*/
|
|
50
|
+
async function migrationRequestHeaders(
|
|
51
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl">,
|
|
52
|
+
token: string,
|
|
53
|
+
): Promise<Record<string, string>> {
|
|
54
|
+
if (entry.cloud === "vellum") {
|
|
55
|
+
return {
|
|
56
|
+
...(await authHeaders(token, entry.runtimeUrl)),
|
|
57
|
+
Accept: "application/json",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return bearerHeaders(token);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Raw409Body {
|
|
64
|
+
detail?: string;
|
|
65
|
+
// The runtime's current 409 contract nests the payload under `error`:
|
|
66
|
+
// { error: { code: "export_in_progress" | "import_in_progress", job_id } }
|
|
67
|
+
// We also tolerate a legacy flat shape ({ code, job_id }) for resilience.
|
|
68
|
+
error?: string | { code?: string; job_id?: string };
|
|
69
|
+
code?: string;
|
|
70
|
+
job_id?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Common 409 → MigrationInProgressError parsing used by the two POST helpers. */
|
|
74
|
+
async function throwIfInProgress(
|
|
75
|
+
response: Response,
|
|
76
|
+
defaultKind: "export_in_progress" | "import_in_progress",
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
if (response.status !== 409) return;
|
|
79
|
+
const body = (await response.json().catch(() => ({}))) as Raw409Body;
|
|
80
|
+
const nested =
|
|
81
|
+
typeof body.error === "object" && body.error !== null
|
|
82
|
+
? body.error
|
|
83
|
+
: undefined;
|
|
84
|
+
const jobId = nested?.job_id ?? body.job_id ?? "";
|
|
85
|
+
const rawKind =
|
|
86
|
+
nested?.code ??
|
|
87
|
+
body.code ??
|
|
88
|
+
(typeof body.error === "string" ? body.error : undefined) ??
|
|
89
|
+
defaultKind;
|
|
90
|
+
const kind: "export_in_progress" | "import_in_progress" =
|
|
91
|
+
rawKind === "export_in_progress" || rawKind === "import_in_progress"
|
|
92
|
+
? rawKind
|
|
93
|
+
: defaultKind;
|
|
94
|
+
throw new MigrationInProgressError(kind, jobId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Kick off an async export-to-GCS job on the assistant's runtime.
|
|
99
|
+
*
|
|
100
|
+
* For local/docker assistants this POSTs to
|
|
101
|
+
* `{runtimeUrl}/v1/migrations/export-to-gcs` with guardian-token bearer
|
|
102
|
+
* auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
|
|
103
|
+
* to the wildcard-runtime-proxy shape
|
|
104
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/export-to-gcs` and
|
|
105
|
+
* authenticated via the platform-token header set the platform's DRF auth
|
|
106
|
+
* accepts (session / vak_).
|
|
107
|
+
*
|
|
108
|
+
* Returns the 202-accepted `job_id`. On 409 (another export in flight)
|
|
109
|
+
* throws {@link MigrationInProgressError} with the existing job_id.
|
|
110
|
+
*/
|
|
111
|
+
export async function localRuntimeExportToGcs(
|
|
112
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
113
|
+
token: string,
|
|
114
|
+
params: { uploadUrl: string; description?: string },
|
|
115
|
+
): Promise<{ jobId: string }> {
|
|
116
|
+
const body: Record<string, unknown> = { upload_url: params.uploadUrl };
|
|
117
|
+
if (params.description !== undefined) {
|
|
118
|
+
body.description = params.description;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = await fetch(
|
|
122
|
+
resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
|
|
123
|
+
{
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await throwIfInProgress(response, "export_in_progress");
|
|
131
|
+
|
|
132
|
+
if (response.status !== 202) {
|
|
133
|
+
const errText = await response.text().catch(() => "");
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Local runtime export-to-gcs failed (${response.status}): ${
|
|
136
|
+
errText || response.statusText
|
|
137
|
+
}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const json = (await response.json()) as {
|
|
142
|
+
job_id: string;
|
|
143
|
+
status?: string;
|
|
144
|
+
type?: string;
|
|
145
|
+
};
|
|
146
|
+
return { jobId: json.job_id };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Kick off an async import-from-GCS job on the assistant's runtime.
|
|
151
|
+
*
|
|
152
|
+
* For local/docker assistants this POSTs to
|
|
153
|
+
* `{runtimeUrl}/v1/migrations/import-from-gcs` with guardian-token bearer
|
|
154
|
+
* auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
|
|
155
|
+
* to the wildcard-runtime-proxy shape
|
|
156
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/import-from-gcs` and
|
|
157
|
+
* authenticated via the platform token. On 409 throws
|
|
158
|
+
* {@link MigrationInProgressError}.
|
|
159
|
+
*/
|
|
160
|
+
export async function localRuntimeImportFromGcs(
|
|
161
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
162
|
+
token: string,
|
|
163
|
+
params: { bundleUrl: string },
|
|
164
|
+
): Promise<{ jobId: string }> {
|
|
165
|
+
const response = await fetch(
|
|
166
|
+
resolveRuntimeMigrationUrl(entry, "import-from-gcs"),
|
|
167
|
+
{
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
170
|
+
body: JSON.stringify({ bundle_url: params.bundleUrl }),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await throwIfInProgress(response, "import_in_progress");
|
|
175
|
+
|
|
176
|
+
if (response.status !== 202) {
|
|
177
|
+
const errText = await response.text().catch(() => "");
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Local runtime import-from-gcs failed (${response.status}): ${
|
|
180
|
+
errText || response.statusText
|
|
181
|
+
}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const json = (await response.json()) as {
|
|
186
|
+
job_id: string;
|
|
187
|
+
status?: string;
|
|
188
|
+
type?: string;
|
|
189
|
+
};
|
|
190
|
+
return { jobId: json.job_id };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Poll the runtime's unified job-status endpoint.
|
|
195
|
+
*
|
|
196
|
+
* For local/docker assistants this GETs
|
|
197
|
+
* `{runtimeUrl}/v1/migrations/jobs/{jobId}` directly (guardian-token
|
|
198
|
+
* bearer). For platform-managed assistants it routes through the wildcard
|
|
199
|
+
* runtime proxy at
|
|
200
|
+
* `{platformUrl}/v1/assistants/<assistantId>/migrations/jobs/{jobId}` with
|
|
201
|
+
* platform-token auth — important: the platform's dedicated
|
|
202
|
+
* `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
|
|
203
|
+
* records and would 404 on runtime-created job IDs.
|
|
204
|
+
*/
|
|
205
|
+
export async function localRuntimePollJobStatus(
|
|
206
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
207
|
+
token: string,
|
|
208
|
+
jobId: string,
|
|
209
|
+
): Promise<UnifiedJobStatus> {
|
|
210
|
+
const response = await fetch(
|
|
211
|
+
resolveRuntimeMigrationUrl(entry, `jobs/${jobId}`),
|
|
212
|
+
{
|
|
213
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (response.status === 404) {
|
|
218
|
+
throw new Error("Migration job not found");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Local job status check failed: ${response.status} ${response.statusText}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const raw = (await response.json()) as Parameters<
|
|
228
|
+
typeof parseUnifiedJobStatus
|
|
229
|
+
>[0];
|
|
230
|
+
return parseUnifiedJobStatus(raw);
|
|
231
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, execSync, spawn } from "child_process";
|
|
2
|
-
import { randomBytes } from "crypto";
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
3
|
import {
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
10
|
import { createRequire } from "module";
|
|
11
|
-
import { homedir,
|
|
11
|
+
import { homedir, networkInterfaces, platform, tmpdir } from "os";
|
|
12
12
|
import { dirname, join } from "path";
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getDaemonPidPath,
|
|
16
|
+
type LocalInstanceResources,
|
|
17
|
+
} from "./assistant-config.js";
|
|
15
18
|
import { GATEWAY_PORT } from "./constants.js";
|
|
16
19
|
import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
|
|
17
20
|
import { stopProcessByPidFile } from "./process.js";
|
|
@@ -19,6 +22,107 @@ import { openLogFile, pipeToLogFile } from "./xdg-log.js";
|
|
|
19
22
|
|
|
20
23
|
const _require = createRequire(import.meta.url);
|
|
21
24
|
|
|
25
|
+
// macOS AF_UNIX path limit (sun_path is 104 bytes, null-terminated → 103 usable).
|
|
26
|
+
const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
|
|
27
|
+
|
|
28
|
+
// The longest socket filename we place in the workspace directory.
|
|
29
|
+
// assistant-skill.sock = 20 chars, plus 1 for the "/" separator = 21 overhead.
|
|
30
|
+
const LONGEST_SOCKET_FILENAME = "assistant-skill.sock";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Warn when an assistant appears to have legacy data in the global workspace.
|
|
34
|
+
*
|
|
35
|
+
* Old local startup paths could launch the daemon without
|
|
36
|
+
* `VELLUM_WORKSPACE_DIR`, causing writes to fall back to `~/.vellum/workspace`.
|
|
37
|
+
* New local instance launches pin the workspace under
|
|
38
|
+
* `<instanceDir>/.vellum/workspace`. If we detect data only in the legacy
|
|
39
|
+
* global path, warn with migration instructions so users are not surprised by
|
|
40
|
+
* missing history/settings after the fix.
|
|
41
|
+
*/
|
|
42
|
+
function warnIfLegacyWorkspaceFallbackDetected(
|
|
43
|
+
resources: LocalInstanceResources,
|
|
44
|
+
): void {
|
|
45
|
+
const instanceWorkspace = join(resources.instanceDir, ".vellum", "workspace");
|
|
46
|
+
const instanceDbPath = join(instanceWorkspace, "data", "db", "assistant.db");
|
|
47
|
+
|
|
48
|
+
const legacyWorkspace = join(homedir(), ".vellum", "workspace");
|
|
49
|
+
const legacyDbPath = join(legacyWorkspace, "data", "db", "assistant.db");
|
|
50
|
+
|
|
51
|
+
// Legacy "first local" entries use ~/.vellum directly; no drift possible.
|
|
52
|
+
if (instanceWorkspace === legacyWorkspace) return;
|
|
53
|
+
|
|
54
|
+
if (existsSync(legacyDbPath) && !existsSync(instanceDbPath)) {
|
|
55
|
+
console.warn("");
|
|
56
|
+
console.warn(
|
|
57
|
+
"WARNING: Detected legacy workspace data in ~/.vellum/workspace for this local assistant.",
|
|
58
|
+
);
|
|
59
|
+
console.warn(" What this means:");
|
|
60
|
+
console.warn(
|
|
61
|
+
" - An older startup path likely wrote assistant data to the global workspace.",
|
|
62
|
+
);
|
|
63
|
+
console.warn(
|
|
64
|
+
" - This assistant now uses its instance workspace instead:",
|
|
65
|
+
);
|
|
66
|
+
console.warn(` ${instanceWorkspace}`);
|
|
67
|
+
console.warn(" What to do:");
|
|
68
|
+
console.warn(
|
|
69
|
+
" 1. Stop the assistant before migrating files (retire/sleep or quit app).",
|
|
70
|
+
);
|
|
71
|
+
console.warn(
|
|
72
|
+
" 2. Copy needed data from ~/.vellum/workspace into the instance workspace.",
|
|
73
|
+
);
|
|
74
|
+
console.warn(
|
|
75
|
+
` Example: cp -a ~/.vellum/workspace/data/db/assistant.db* ${join(instanceWorkspace, "data", "db")}/`,
|
|
76
|
+
);
|
|
77
|
+
console.warn(
|
|
78
|
+
" 3. Re-launch and confirm history/settings appear as expected.",
|
|
79
|
+
);
|
|
80
|
+
console.warn("");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* On macOS, if `{workspaceDir}/assistant-skill.sock` would exceed the
|
|
86
|
+
* 103-byte AF_UNIX path limit, compute a short tmpdir-based IPC socket
|
|
87
|
+
* directory and return it. Returns `undefined` when no override is needed
|
|
88
|
+
* (the workspace path is short enough, or we're not on macOS).
|
|
89
|
+
*/
|
|
90
|
+
function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
|
|
91
|
+
if (platform() !== "darwin") return undefined;
|
|
92
|
+
|
|
93
|
+
const longestPath = join(workspaceDir, LONGEST_SOCKET_FILENAME);
|
|
94
|
+
if (
|
|
95
|
+
Buffer.byteLength(longestPath, "utf8") <= DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
|
|
96
|
+
) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Use a short hash of the workspace dir so multiple instances get
|
|
101
|
+
// distinct socket directories under /tmp.
|
|
102
|
+
const hash = createHash("sha256")
|
|
103
|
+
.update(workspaceDir)
|
|
104
|
+
.digest("hex")
|
|
105
|
+
.slice(0, 12);
|
|
106
|
+
return join(tmpdir(), `vellum-ipc-${hash}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* If the workspace path is too long for AF_UNIX sockets on macOS, compute
|
|
111
|
+
* a short override directory and set all IPC socket env vars on the target
|
|
112
|
+
* env object. No-op on non-macOS or when paths are within limits.
|
|
113
|
+
*/
|
|
114
|
+
function applyIpcSocketDirOverride(env: Record<string, string>): void {
|
|
115
|
+
const workspaceDir =
|
|
116
|
+
env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
|
|
117
|
+
const override = computeIpcSocketDirOverride(workspaceDir);
|
|
118
|
+
if (!override) return;
|
|
119
|
+
|
|
120
|
+
mkdirSync(override, { recursive: true });
|
|
121
|
+
env.GATEWAY_IPC_SOCKET_DIR = override;
|
|
122
|
+
env.ASSISTANT_IPC_SOCKET_DIR = override;
|
|
123
|
+
env.ASSISTANT_SKILL_IPC_SOCKET_DIR = override;
|
|
124
|
+
}
|
|
125
|
+
|
|
22
126
|
function isAssistantSourceDir(dir: string): boolean {
|
|
23
127
|
const pkgPath = join(dir, "package.json");
|
|
24
128
|
if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts")))
|
|
@@ -222,10 +326,9 @@ async function startDaemonFromSource(
|
|
|
222
326
|
const daemonMainPath = resolveDaemonMainPath(assistantIndex);
|
|
223
327
|
|
|
224
328
|
// Ensure the directory containing PID/socket files exists. For named
|
|
225
|
-
// instances this is instanceDir/.vellum/ (matching daemon's
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const pidFile = resources.pidFile;
|
|
329
|
+
// instances this is instanceDir/.vellum/workspace/ (matching daemon's getWorkspaceDir()).
|
|
330
|
+
const pidFile = getDaemonPidPath(resources);
|
|
331
|
+
mkdirSync(dirname(pidFile), { recursive: true });
|
|
229
332
|
|
|
230
333
|
// --- Lifecycle guard: prevent split-brain daemon state ---
|
|
231
334
|
if (existsSync(pidFile)) {
|
|
@@ -289,12 +392,21 @@ async function startDaemonFromSource(
|
|
|
289
392
|
: {}),
|
|
290
393
|
};
|
|
291
394
|
if (resources) {
|
|
292
|
-
env.
|
|
395
|
+
env.VELLUM_WORKSPACE_DIR = join(
|
|
396
|
+
resources.instanceDir,
|
|
397
|
+
".vellum",
|
|
398
|
+
"workspace",
|
|
399
|
+
);
|
|
293
400
|
env.GATEWAY_SECURITY_DIR = join(
|
|
294
401
|
resources.instanceDir,
|
|
295
402
|
".vellum",
|
|
296
403
|
"protected",
|
|
297
404
|
);
|
|
405
|
+
env.CREDENTIAL_SECURITY_DIR = join(
|
|
406
|
+
resources.instanceDir,
|
|
407
|
+
".vellum",
|
|
408
|
+
"protected",
|
|
409
|
+
);
|
|
298
410
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
299
411
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
300
412
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -349,9 +461,8 @@ async function startDaemonWatchFromSource(
|
|
|
349
461
|
throw new Error(`Daemon main.ts not found at ${mainPath}`);
|
|
350
462
|
}
|
|
351
463
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const pidFile = resources.pidFile;
|
|
464
|
+
const pidFile = getDaemonPidPath(resources);
|
|
465
|
+
mkdirSync(dirname(pidFile), { recursive: true });
|
|
355
466
|
|
|
356
467
|
// --- Lifecycle guard: prevent split-brain daemon state ---
|
|
357
468
|
// If a daemon is already running, skip spawning a new one.
|
|
@@ -416,12 +527,21 @@ async function startDaemonWatchFromSource(
|
|
|
416
527
|
: {}),
|
|
417
528
|
};
|
|
418
529
|
if (resources) {
|
|
419
|
-
env.
|
|
530
|
+
env.VELLUM_WORKSPACE_DIR = join(
|
|
531
|
+
resources.instanceDir,
|
|
532
|
+
".vellum",
|
|
533
|
+
"workspace",
|
|
534
|
+
);
|
|
420
535
|
env.GATEWAY_SECURITY_DIR = join(
|
|
421
536
|
resources.instanceDir,
|
|
422
537
|
".vellum",
|
|
423
538
|
"protected",
|
|
424
539
|
);
|
|
540
|
+
env.CREDENTIAL_SECURITY_DIR = join(
|
|
541
|
+
resources.instanceDir,
|
|
542
|
+
".vellum",
|
|
543
|
+
"protected",
|
|
544
|
+
);
|
|
425
545
|
env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
426
546
|
env.GATEWAY_PORT = String(resources.gatewayPort);
|
|
427
547
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -563,51 +683,18 @@ export async function discoverPublicUrl(
|
|
|
563
683
|
return `http://${cloudIp}:${effectivePort}`;
|
|
564
684
|
}
|
|
565
685
|
|
|
566
|
-
// Log the local address source only when we actually use it.
|
|
567
|
-
if (localResult.source === "hostname") {
|
|
568
|
-
console.log(` Discovered macOS local hostname: ${localResult.label}`);
|
|
569
|
-
} else if (localResult.source === "lan") {
|
|
570
|
-
console.log(` Discovered LAN IP: ${localResult.label}`);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
686
|
return localResult.url;
|
|
574
687
|
}
|
|
575
688
|
|
|
576
689
|
/**
|
|
577
|
-
*
|
|
578
|
-
* address or falls back to localhost. Does not emit any logs — the caller
|
|
579
|
-
* decides whether to log based on which result is actually used.
|
|
690
|
+
* Returns the localhost URL for the gateway on the given port.
|
|
580
691
|
*/
|
|
581
692
|
function discoverLocalUrl(effectivePort: number): {
|
|
582
693
|
url: string;
|
|
583
|
-
source: "
|
|
584
|
-
label?: string;
|
|
694
|
+
source: "localhost";
|
|
585
695
|
} {
|
|
586
|
-
// On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
|
|
587
|
-
// the same network can reach the gateway by name.
|
|
588
|
-
if (platform() === "darwin") {
|
|
589
|
-
const localHostname = getMacLocalHostname();
|
|
590
|
-
if (localHostname) {
|
|
591
|
-
return {
|
|
592
|
-
url: `http://${localHostname}:${effectivePort}`,
|
|
593
|
-
source: "hostname",
|
|
594
|
-
label: localHostname,
|
|
595
|
-
};
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
const lanIp = getLocalLanIPv4();
|
|
600
|
-
if (lanIp) {
|
|
601
|
-
return {
|
|
602
|
-
url: `http://${lanIp}:${effectivePort}`,
|
|
603
|
-
source: "lan",
|
|
604
|
-
label: lanIp,
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Final fallback to localhost when no LAN address could be discovered.
|
|
609
696
|
return {
|
|
610
|
-
url: `http://
|
|
697
|
+
url: `http://127.0.0.1:${effectivePort}`,
|
|
611
698
|
source: "localhost",
|
|
612
699
|
};
|
|
613
700
|
}
|
|
@@ -664,19 +751,6 @@ async function discoverCloudExternalIp(): Promise<string | undefined> {
|
|
|
664
751
|
return gcpIp ?? awsIp;
|
|
665
752
|
}
|
|
666
753
|
|
|
667
|
-
/**
|
|
668
|
-
* Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
|
|
669
|
-
* or undefined if not running on macOS or the hostname cannot be determined.
|
|
670
|
-
*/
|
|
671
|
-
export function getMacLocalHostname(): string | undefined {
|
|
672
|
-
const host = hostname();
|
|
673
|
-
if (!host) return undefined;
|
|
674
|
-
// macOS hostnames already end with .local when Bonjour is active
|
|
675
|
-
if (host.endsWith(".local")) return host;
|
|
676
|
-
// Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
|
|
677
|
-
return `${host}.local`;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
754
|
/**
|
|
681
755
|
* Returns the local IPv4 address most likely to be reachable from other
|
|
682
756
|
* devices on the same LAN.
|
|
@@ -769,6 +843,8 @@ export async function startLocalDaemon(
|
|
|
769
843
|
resources: LocalInstanceResources,
|
|
770
844
|
options?: DaemonStartOptions,
|
|
771
845
|
): Promise<void> {
|
|
846
|
+
warnIfLegacyWorkspaceFallbackDetected(resources);
|
|
847
|
+
|
|
772
848
|
const foreground = options?.foreground ?? false;
|
|
773
849
|
// Check for a compiled daemon binary adjacent to the CLI executable.
|
|
774
850
|
// This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
|
|
@@ -779,7 +855,7 @@ export async function startLocalDaemon(
|
|
|
779
855
|
// In watch mode, skip the bundled binary and use source (bun --watch
|
|
780
856
|
// only works with source files, not compiled binaries).
|
|
781
857
|
|
|
782
|
-
const pidFile = resources
|
|
858
|
+
const pidFile = getDaemonPidPath(resources);
|
|
783
859
|
|
|
784
860
|
// If a daemon is already running, skip spawning a new one.
|
|
785
861
|
// This prevents cascading kill→restart cycles when multiple callers
|
|
@@ -874,8 +950,8 @@ export async function startLocalDaemon(
|
|
|
874
950
|
for (const key of [
|
|
875
951
|
"ANTHROPIC_API_KEY",
|
|
876
952
|
"APP_VERSION",
|
|
877
|
-
"BASE_DATA_DIR",
|
|
878
953
|
"GATEWAY_SECURITY_DIR",
|
|
954
|
+
"CREDENTIAL_SECURITY_DIR",
|
|
879
955
|
"VELLUM_ENVIRONMENT",
|
|
880
956
|
"VELLUM_PLATFORM_URL",
|
|
881
957
|
"QDRANT_HTTP_PORT",
|
|
@@ -901,12 +977,21 @@ export async function startLocalDaemon(
|
|
|
901
977
|
// When running a named instance, override env so the daemon resolves
|
|
902
978
|
// all paths under the instance directory and listens on its own port.
|
|
903
979
|
if (resources) {
|
|
904
|
-
daemonEnv.
|
|
980
|
+
daemonEnv.VELLUM_WORKSPACE_DIR = join(
|
|
981
|
+
resources.instanceDir,
|
|
982
|
+
".vellum",
|
|
983
|
+
"workspace",
|
|
984
|
+
);
|
|
905
985
|
daemonEnv.GATEWAY_SECURITY_DIR = join(
|
|
906
986
|
resources.instanceDir,
|
|
907
987
|
".vellum",
|
|
908
988
|
"protected",
|
|
909
989
|
);
|
|
990
|
+
daemonEnv.CREDENTIAL_SECURITY_DIR = join(
|
|
991
|
+
resources.instanceDir,
|
|
992
|
+
".vellum",
|
|
993
|
+
"protected",
|
|
994
|
+
);
|
|
910
995
|
daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
|
|
911
996
|
daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
|
|
912
997
|
daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
@@ -917,6 +1002,8 @@ export async function startLocalDaemon(
|
|
|
917
1002
|
daemonEnv.ACTOR_TOKEN_SIGNING_KEY = options.signingKey;
|
|
918
1003
|
}
|
|
919
1004
|
|
|
1005
|
+
applyIpcSocketDirOverride(daemonEnv);
|
|
1006
|
+
|
|
920
1007
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
921
1008
|
// see the file and fall through to the isDaemonResponsive() port check
|
|
922
1009
|
// instead of racing to spawn a duplicate daemon.
|
|
@@ -1044,7 +1131,7 @@ export async function startGateway(
|
|
|
1044
1131
|
|
|
1045
1132
|
const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
|
|
1046
1133
|
if (publicUrl) {
|
|
1047
|
-
console.log(`
|
|
1134
|
+
console.log(` HTTP URL: ${publicUrl}`);
|
|
1048
1135
|
}
|
|
1049
1136
|
|
|
1050
1137
|
console.log("🌐 Starting gateway...");
|
|
@@ -1058,7 +1145,6 @@ export async function startGateway(
|
|
|
1058
1145
|
GATEWAY_PORT: String(effectiveGatewayPort),
|
|
1059
1146
|
// Pass gateway operational settings via env vars so the CLI does not
|
|
1060
1147
|
// need direct access to the workspace config file.
|
|
1061
|
-
RUNTIME_PROXY_ENABLED: "true",
|
|
1062
1148
|
RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
1063
1149
|
UNMAPPED_POLICY: "default",
|
|
1064
1150
|
DEFAULT_ASSISTANT_ID: "self",
|
|
@@ -1071,12 +1157,11 @@ export async function startGateway(
|
|
|
1071
1157
|
VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
|
|
1072
1158
|
}
|
|
1073
1159
|
: {}),
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1160
|
+
// Pin gateway workspace/security paths to the named instance so parent
|
|
1161
|
+
// env vars cannot leak a different workspace. The gateway opens the
|
|
1162
|
+
// assistant DB directly for guardian bootstrap.
|
|
1077
1163
|
...(resources
|
|
1078
1164
|
? {
|
|
1079
|
-
BASE_DATA_DIR: resources.instanceDir,
|
|
1080
1165
|
VELLUM_WORKSPACE_DIR: join(
|
|
1081
1166
|
resources.instanceDir,
|
|
1082
1167
|
".vellum",
|
|
@@ -1087,11 +1172,19 @@ export async function startGateway(
|
|
|
1087
1172
|
".vellum",
|
|
1088
1173
|
"protected",
|
|
1089
1174
|
),
|
|
1175
|
+
CREDENTIAL_SECURITY_DIR: join(
|
|
1176
|
+
resources.instanceDir,
|
|
1177
|
+
".vellum",
|
|
1178
|
+
"protected",
|
|
1179
|
+
),
|
|
1090
1180
|
}
|
|
1091
1181
|
: {}),
|
|
1092
1182
|
};
|
|
1183
|
+
|
|
1184
|
+
applyIpcSocketDirOverride(gatewayEnv);
|
|
1185
|
+
|
|
1093
1186
|
if (publicUrl) {
|
|
1094
|
-
console.log(`
|
|
1187
|
+
console.log(` HTTP URL: ${publicUrl}`);
|
|
1095
1188
|
}
|
|
1096
1189
|
|
|
1097
1190
|
let gateway;
|
|
@@ -1186,7 +1279,7 @@ export async function stopLocalProcesses(
|
|
|
1186
1279
|
const vellumDir = resources
|
|
1187
1280
|
? join(resources.instanceDir, ".vellum")
|
|
1188
1281
|
: join(homedir(), ".vellum");
|
|
1189
|
-
const daemonPidFile = resources
|
|
1282
|
+
const daemonPidFile = getDaemonPidPath(resources);
|
|
1190
1283
|
await stopProcessByPidFile(daemonPidFile, "daemon");
|
|
1191
1284
|
|
|
1192
1285
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
import { join } from "path";
|
|
4
2
|
|
|
5
|
-
import { loadAllAssistants } from "./assistant-config.js";
|
|
6
3
|
import { execOutput } from "./step-runner";
|
|
7
4
|
|
|
8
5
|
export interface RemoteProcess {
|
|
@@ -74,38 +71,8 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
|
74
71
|
const results: OrphanedProcess[] = [];
|
|
75
72
|
const seenPids = new Set<string>();
|
|
76
73
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// multi-instance data layout, not just the legacy `~/.vellum/` root.
|
|
80
|
-
const dirs = new Set<string>();
|
|
81
|
-
for (const entry of loadAllAssistants()) {
|
|
82
|
-
if (entry.cloud !== "local" || !entry.resources) continue;
|
|
83
|
-
dirs.add(join(entry.resources.instanceDir, ".vellum"));
|
|
84
|
-
}
|
|
85
|
-
// Preserve the legacy root scan for installs that predate multi-instance
|
|
86
|
-
// tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
|
|
87
|
-
// may not have a lockfile entry at all.
|
|
88
|
-
dirs.add(join(homedir(), ".vellum"));
|
|
89
|
-
|
|
90
|
-
// Strategy 1: PID file scan — check every known data directory.
|
|
91
|
-
for (const dir of dirs) {
|
|
92
|
-
const pidFiles: Array<{ file: string; name: string }> = [
|
|
93
|
-
{ file: join(dir, "vellum.pid"), name: "assistant" },
|
|
94
|
-
{ file: join(dir, "gateway.pid"), name: "gateway" },
|
|
95
|
-
{ file: join(dir, "qdrant.pid"), name: "qdrant" },
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
for (const { file, name } of pidFiles) {
|
|
99
|
-
const pid = readPidFile(file);
|
|
100
|
-
if (!pid || seenPids.has(pid)) continue;
|
|
101
|
-
if (isProcessAlive(pid)) {
|
|
102
|
-
results.push({ name, pid, source: "pid file" });
|
|
103
|
-
seenPids.add(pid);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Strategy 2: Process table scan
|
|
74
|
+
// Process table scan — discover orphaned processes by scanning the OS
|
|
75
|
+
// process table rather than reading PID files from the workspace.
|
|
109
76
|
try {
|
|
110
77
|
const output = await execOutput("sh", [
|
|
111
78
|
"-c",
|