@vellumai/cli 0.6.5 → 0.7.0
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/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +1 -7
- package/src/__tests__/config-utils.test.ts +159 -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 +919 -1255
- package/src/commands/env.ts +93 -0
- package/src/commands/events.ts +2 -0
- package/src/commands/exec.ts +40 -8
- package/src/commands/hatch.ts +6 -2
- package/src/commands/login.ts +89 -6
- package/src/commands/ps.ts +104 -20
- package/src/commands/retire.ts +23 -0
- package/src/commands/sleep.ts +5 -2
- package/src/commands/ssh.ts +15 -2
- package/src/commands/teleport.ts +447 -583
- package/src/commands/terminal.ts +225 -0
- package/src/commands/wake.ts +2 -1
- package/src/components/DefaultMainScreen.tsx +304 -152
- package/src/index.ts +6 -0
- 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 +383 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
- package/src/lib/assistant-config.ts +12 -8
- package/src/lib/client-identity.ts +67 -0
- package/src/lib/config-utils.ts +97 -1
- package/src/lib/docker.ts +73 -75
- 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 +178 -0
- package/src/lib/local.ts +139 -15
- package/src/lib/orphan-detection.ts +2 -35
- package/src/lib/platform-client.ts +215 -0
- package/src/lib/retire-local.ts +6 -2
- package/src/lib/terminal-client.ts +177 -0
- package/src/lib/terminal-session.ts +457 -0
- package/src/shared/provider-env-vars.ts +2 -3
- package/src/__tests__/orphan-detection.test.ts +0 -214
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, hostname, networkInterfaces, platform } from "os";
|
|
11
|
+
import { homedir, hostname, 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)) {
|
|
@@ -290,6 +393,11 @@ async function startDaemonFromSource(
|
|
|
290
393
|
};
|
|
291
394
|
if (resources) {
|
|
292
395
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
396
|
+
env.VELLUM_WORKSPACE_DIR = join(
|
|
397
|
+
resources.instanceDir,
|
|
398
|
+
".vellum",
|
|
399
|
+
"workspace",
|
|
400
|
+
);
|
|
293
401
|
env.GATEWAY_SECURITY_DIR = join(
|
|
294
402
|
resources.instanceDir,
|
|
295
403
|
".vellum",
|
|
@@ -349,9 +457,8 @@ async function startDaemonWatchFromSource(
|
|
|
349
457
|
throw new Error(`Daemon main.ts not found at ${mainPath}`);
|
|
350
458
|
}
|
|
351
459
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const pidFile = resources.pidFile;
|
|
460
|
+
const pidFile = getDaemonPidPath(resources);
|
|
461
|
+
mkdirSync(dirname(pidFile), { recursive: true });
|
|
355
462
|
|
|
356
463
|
// --- Lifecycle guard: prevent split-brain daemon state ---
|
|
357
464
|
// If a daemon is already running, skip spawning a new one.
|
|
@@ -417,6 +524,11 @@ async function startDaemonWatchFromSource(
|
|
|
417
524
|
};
|
|
418
525
|
if (resources) {
|
|
419
526
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
527
|
+
env.VELLUM_WORKSPACE_DIR = join(
|
|
528
|
+
resources.instanceDir,
|
|
529
|
+
".vellum",
|
|
530
|
+
"workspace",
|
|
531
|
+
);
|
|
420
532
|
env.GATEWAY_SECURITY_DIR = join(
|
|
421
533
|
resources.instanceDir,
|
|
422
534
|
".vellum",
|
|
@@ -769,6 +881,8 @@ export async function startLocalDaemon(
|
|
|
769
881
|
resources: LocalInstanceResources,
|
|
770
882
|
options?: DaemonStartOptions,
|
|
771
883
|
): Promise<void> {
|
|
884
|
+
warnIfLegacyWorkspaceFallbackDetected(resources);
|
|
885
|
+
|
|
772
886
|
const foreground = options?.foreground ?? false;
|
|
773
887
|
// Check for a compiled daemon binary adjacent to the CLI executable.
|
|
774
888
|
// This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
|
|
@@ -779,7 +893,7 @@ export async function startLocalDaemon(
|
|
|
779
893
|
// In watch mode, skip the bundled binary and use source (bun --watch
|
|
780
894
|
// only works with source files, not compiled binaries).
|
|
781
895
|
|
|
782
|
-
const pidFile = resources
|
|
896
|
+
const pidFile = getDaemonPidPath(resources);
|
|
783
897
|
|
|
784
898
|
// If a daemon is already running, skip spawning a new one.
|
|
785
899
|
// This prevents cascading kill→restart cycles when multiple callers
|
|
@@ -902,6 +1016,11 @@ export async function startLocalDaemon(
|
|
|
902
1016
|
// all paths under the instance directory and listens on its own port.
|
|
903
1017
|
if (resources) {
|
|
904
1018
|
daemonEnv.BASE_DATA_DIR = resources.instanceDir;
|
|
1019
|
+
daemonEnv.VELLUM_WORKSPACE_DIR = join(
|
|
1020
|
+
resources.instanceDir,
|
|
1021
|
+
".vellum",
|
|
1022
|
+
"workspace",
|
|
1023
|
+
);
|
|
905
1024
|
daemonEnv.GATEWAY_SECURITY_DIR = join(
|
|
906
1025
|
resources.instanceDir,
|
|
907
1026
|
".vellum",
|
|
@@ -917,6 +1036,8 @@ export async function startLocalDaemon(
|
|
|
917
1036
|
daemonEnv.ACTOR_TOKEN_SIGNING_KEY = options.signingKey;
|
|
918
1037
|
}
|
|
919
1038
|
|
|
1039
|
+
applyIpcSocketDirOverride(daemonEnv);
|
|
1040
|
+
|
|
920
1041
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
921
1042
|
// see the file and fall through to the isDaemonResponsive() port check
|
|
922
1043
|
// instead of racing to spawn a duplicate daemon.
|
|
@@ -1071,9 +1192,9 @@ export async function startGateway(
|
|
|
1071
1192
|
VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
|
|
1072
1193
|
}
|
|
1073
1194
|
: {}),
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1195
|
+
// Pin gateway workspace/security paths to the named instance so parent
|
|
1196
|
+
// env vars cannot leak a different workspace. The gateway opens the
|
|
1197
|
+
// assistant DB directly for guardian bootstrap.
|
|
1077
1198
|
...(resources
|
|
1078
1199
|
? {
|
|
1079
1200
|
BASE_DATA_DIR: resources.instanceDir,
|
|
@@ -1090,6 +1211,9 @@ export async function startGateway(
|
|
|
1090
1211
|
}
|
|
1091
1212
|
: {}),
|
|
1092
1213
|
};
|
|
1214
|
+
|
|
1215
|
+
applyIpcSocketDirOverride(gatewayEnv);
|
|
1216
|
+
|
|
1093
1217
|
if (publicUrl) {
|
|
1094
1218
|
console.log(` Ingress URL: ${publicUrl}`);
|
|
1095
1219
|
}
|
|
@@ -1186,7 +1310,7 @@ export async function stopLocalProcesses(
|
|
|
1186
1310
|
const vellumDir = resources
|
|
1187
1311
|
? join(resources.instanceDir, ".vellum")
|
|
1188
1312
|
: join(homedir(), ".vellum");
|
|
1189
|
-
const daemonPidFile = resources
|
|
1313
|
+
const daemonPidFile = getDaemonPidPath(resources);
|
|
1190
1314
|
await stopProcessByPidFile(daemonPidFile, "daemon");
|
|
1191
1315
|
|
|
1192
1316
|
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",
|
|
@@ -40,6 +40,19 @@ export function getPlatformUrl(): string {
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the web app (Next.js) base URL for browser-facing pages like
|
|
45
|
+
* `/account/login`. Mirrors `VellumEnvironment.resolvedWebURL` on the
|
|
46
|
+
* Swift side.
|
|
47
|
+
*
|
|
48
|
+
* Resolution order:
|
|
49
|
+
* 1. `VELLUM_WEB_URL` env var (explicit override)
|
|
50
|
+
* 2. The current environment's seed web URL
|
|
51
|
+
*/
|
|
52
|
+
export function getWebUrl(): string {
|
|
53
|
+
return process.env.VELLUM_WEB_URL?.trim() || getCurrentEnvironment().webUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export function readPlatformToken(): string | null {
|
|
44
57
|
try {
|
|
45
58
|
return readFileSync(getPlatformTokenPath(), "utf-8").trim();
|
|
@@ -516,6 +529,30 @@ export async function checkExistingPlatformAssistant(
|
|
|
516
529
|
return active ?? null;
|
|
517
530
|
}
|
|
518
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Fetch all active assistants for the authenticated user from the platform.
|
|
534
|
+
* Returns an empty array on failure (non-fatal).
|
|
535
|
+
*/
|
|
536
|
+
export async function fetchPlatformAssistants(
|
|
537
|
+
token: string,
|
|
538
|
+
platformUrl?: string,
|
|
539
|
+
): Promise<HatchedAssistant[]> {
|
|
540
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
541
|
+
const url = `${resolvedUrl}/v1/assistants/`;
|
|
542
|
+
|
|
543
|
+
const response = await fetch(url, {
|
|
544
|
+
headers: await authHeaders(token, platformUrl),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
if (!response.ok) return [];
|
|
548
|
+
|
|
549
|
+
const body = (await response.json()) as {
|
|
550
|
+
results?: HatchedAssistant[];
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
return (body.results ?? []).filter((a) => a.status === "active");
|
|
554
|
+
}
|
|
555
|
+
|
|
519
556
|
export interface PlatformUser {
|
|
520
557
|
id: string;
|
|
521
558
|
email: string;
|
|
@@ -913,3 +950,181 @@ export async function platformPollImportStatus(
|
|
|
913
950
|
error: body.error,
|
|
914
951
|
};
|
|
915
952
|
}
|
|
953
|
+
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// Unified signed-url + job-status endpoints (teleport-gcs-unify)
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Discriminated union representing the unified migration job status shape
|
|
960
|
+
* returned by `GET /v1/migrations/jobs/{job_id}/` on both the platform and
|
|
961
|
+
* the local runtime.
|
|
962
|
+
*/
|
|
963
|
+
export type UnifiedJobStatus =
|
|
964
|
+
| {
|
|
965
|
+
jobId: string;
|
|
966
|
+
type: "export" | "import";
|
|
967
|
+
status: "processing";
|
|
968
|
+
}
|
|
969
|
+
| {
|
|
970
|
+
jobId: string;
|
|
971
|
+
type: "export" | "import";
|
|
972
|
+
status: "complete";
|
|
973
|
+
bundleKey?: string;
|
|
974
|
+
result?: unknown;
|
|
975
|
+
}
|
|
976
|
+
| {
|
|
977
|
+
jobId: string;
|
|
978
|
+
type: "export" | "import";
|
|
979
|
+
status: "failed";
|
|
980
|
+
error: string;
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
interface RawUnifiedJobStatus {
|
|
984
|
+
job_id: string;
|
|
985
|
+
type: "export" | "import";
|
|
986
|
+
status: "processing" | "complete" | "failed";
|
|
987
|
+
bundle_key?: string;
|
|
988
|
+
result?: unknown;
|
|
989
|
+
error?: string;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Normalise the wire-format job-status payload into the TypeScript
|
|
994
|
+
* discriminated union. Shared between platform and local-runtime helpers
|
|
995
|
+
* since both endpoints return the same shape.
|
|
996
|
+
*/
|
|
997
|
+
export function parseUnifiedJobStatus(
|
|
998
|
+
raw: RawUnifiedJobStatus,
|
|
999
|
+
): UnifiedJobStatus {
|
|
1000
|
+
if (raw.status === "processing") {
|
|
1001
|
+
return { jobId: raw.job_id, type: raw.type, status: "processing" };
|
|
1002
|
+
}
|
|
1003
|
+
if (raw.status === "complete") {
|
|
1004
|
+
return {
|
|
1005
|
+
jobId: raw.job_id,
|
|
1006
|
+
type: raw.type,
|
|
1007
|
+
status: "complete",
|
|
1008
|
+
bundleKey: raw.bundle_key,
|
|
1009
|
+
result: raw.result,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
jobId: raw.job_id,
|
|
1014
|
+
type: raw.type,
|
|
1015
|
+
status: "failed",
|
|
1016
|
+
error: raw.error ?? "Job failed without an error message",
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Request a signed URL from the platform for either uploading a new bundle
|
|
1022
|
+
* or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
|
|
1023
|
+
*
|
|
1024
|
+
* - `operation: "upload"` (optionally with `contentType` / `contentLength`)
|
|
1025
|
+
* returns a URL the CLI can PUT a bundle to.
|
|
1026
|
+
* - `operation: "download"` with a `bundleKey` returns a URL the local
|
|
1027
|
+
* runtime can GET the bundle from during an import-from-GCS flow.
|
|
1028
|
+
*
|
|
1029
|
+
* Retries once with a fresh org-ID cache on 401 to match the retry pattern
|
|
1030
|
+
* used by other authenticated platform helpers. 503 is bubbled up so
|
|
1031
|
+
* callers can decide to fall back (e.g. legacy inline upload).
|
|
1032
|
+
*/
|
|
1033
|
+
export async function platformRequestSignedUrl(
|
|
1034
|
+
params: {
|
|
1035
|
+
operation: "upload" | "download";
|
|
1036
|
+
bundleKey?: string;
|
|
1037
|
+
contentType?: string;
|
|
1038
|
+
contentLength?: number;
|
|
1039
|
+
},
|
|
1040
|
+
token: string,
|
|
1041
|
+
platformUrl?: string,
|
|
1042
|
+
): Promise<{
|
|
1043
|
+
url: string;
|
|
1044
|
+
bundleKey: string;
|
|
1045
|
+
expiresAt: string;
|
|
1046
|
+
maxContentLength?: number;
|
|
1047
|
+
}> {
|
|
1048
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
1049
|
+
const body: Record<string, unknown> = { operation: params.operation };
|
|
1050
|
+
if (params.bundleKey !== undefined) body.bundle_key = params.bundleKey;
|
|
1051
|
+
if (params.contentType !== undefined) body.content_type = params.contentType;
|
|
1052
|
+
if (params.contentLength !== undefined) {
|
|
1053
|
+
body.content_length = params.contentLength;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const doRequest = async (): Promise<Response> =>
|
|
1057
|
+
fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: await authHeaders(token, platformUrl),
|
|
1060
|
+
body: JSON.stringify(body),
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
let response = await doRequest();
|
|
1064
|
+
|
|
1065
|
+
if (response.status === 401) {
|
|
1066
|
+
// Invalidate the cached org-ID (if any) and retry once with a fresh
|
|
1067
|
+
// lookup. For session-token callers, a 401 frequently means the
|
|
1068
|
+
// cached org ID is stale — calling doRequest() again without clearing
|
|
1069
|
+
// the cache would just send the same stale header and fail again.
|
|
1070
|
+
orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
|
|
1071
|
+
response = await doRequest();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (response.status === 201 || response.status === 200) {
|
|
1075
|
+
const json = (await response.json()) as {
|
|
1076
|
+
url: string;
|
|
1077
|
+
bundle_key: string;
|
|
1078
|
+
expires_at: string;
|
|
1079
|
+
max_content_length?: number;
|
|
1080
|
+
};
|
|
1081
|
+
return {
|
|
1082
|
+
url: json.url,
|
|
1083
|
+
bundleKey: json.bundle_key,
|
|
1084
|
+
expiresAt: json.expires_at,
|
|
1085
|
+
maxContentLength: json.max_content_length,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (response.status === 503) {
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
`Signed URL endpoint unavailable (503) — caller may fall back`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const errorBody = (await response.json().catch(() => ({}))) as {
|
|
1096
|
+
detail?: string;
|
|
1097
|
+
};
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
errorBody.detail ??
|
|
1100
|
+
`Failed to request signed URL: ${response.status} ${response.statusText}`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Poll the unified job-status endpoint on the platform. Calls
|
|
1106
|
+
* `GET /v1/migrations/jobs/{jobId}/` and parses into {@link UnifiedJobStatus}.
|
|
1107
|
+
*/
|
|
1108
|
+
export async function platformPollJobStatus(
|
|
1109
|
+
jobId: string,
|
|
1110
|
+
token: string,
|
|
1111
|
+
platformUrl?: string,
|
|
1112
|
+
): Promise<UnifiedJobStatus> {
|
|
1113
|
+
const resolvedUrl = platformUrl || getPlatformUrl();
|
|
1114
|
+
const response = await fetch(`${resolvedUrl}/v1/migrations/jobs/${jobId}/`, {
|
|
1115
|
+
headers: await authHeaders(token, platformUrl),
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
if (response.status === 404) {
|
|
1119
|
+
throw new Error("Migration job not found");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (!response.ok) {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`Job status check failed: ${response.status} ${response.statusText}`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const raw = (await response.json()) as RawUnifiedJobStatus;
|
|
1129
|
+
return parseUnifiedJobStatus(raw);
|
|
1130
|
+
}
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { spawn } from "child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
|
|
3
3
|
import { basename, dirname, join } from "path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getBaseDir,
|
|
7
|
+
getDaemonPidPath,
|
|
8
|
+
loadAllAssistants,
|
|
9
|
+
} from "./assistant-config.js";
|
|
6
10
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
7
11
|
import {
|
|
8
12
|
stopOrphanedDaemonProcesses,
|
|
@@ -41,7 +45,7 @@ export async function retireLocal(
|
|
|
41
45
|
return;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
const daemonPidFile = resources
|
|
48
|
+
const daemonPidFile = getDaemonPidPath(resources);
|
|
45
49
|
const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
|
|
46
50
|
|
|
47
51
|
// Stop gateway via PID file — use a longer timeout because the gateway has a
|