@vellumai/cli 0.7.0 → 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/README.md +49 -0
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +475 -0
- package/src/__tests__/config-utils.test.ts +35 -48
- package/src/__tests__/teleport.test.ts +86 -28
- package/src/commands/backup.ts +117 -71
- package/src/commands/client.ts +10 -9
- package/src/commands/exec.ts +21 -8
- package/src/commands/hatch.ts +2 -6
- package/src/commands/login.ts +15 -33
- package/src/commands/logs.ts +2 -7
- package/src/commands/ps.ts +41 -6
- package/src/commands/restore.ts +26 -47
- package/src/commands/ssh.ts +2 -5
- package/src/commands/teleport.ts +38 -24
- package/src/commands/tunnel.ts +2 -7
- package/src/commands/upgrade.ts +108 -7
- package/src/components/DefaultMainScreen.tsx +25 -3
- package/src/index.ts +2 -7
- package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
- package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
- 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 +34 -16
- package/src/lib/cli-error.ts +1 -0
- package/src/lib/client-identity.ts +1 -1
- package/src/lib/config-utils.ts +1 -97
- package/src/lib/docker.ts +2 -2
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local-runtime-client.ts +81 -28
- package/src/lib/local.ts +27 -58
- package/src/lib/platform-client.ts +1 -220
- package/src/lib/platform-releases.ts +23 -0
- 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 +127 -48
- package/src/lib/tui-log.ts +60 -0
- package/src/lib/xdg-log.ts +10 -4
|
@@ -19,6 +19,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
|
19
19
|
import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
|
|
20
20
|
import { checkHealth } from "../lib/health-check";
|
|
21
21
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
22
|
+
import { tuiLog } from "../lib/tui-log";
|
|
22
23
|
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
23
24
|
import {
|
|
24
25
|
getTerminalCapabilities,
|
|
@@ -354,22 +355,35 @@ async function* streamEvents(
|
|
|
354
355
|
): AsyncGenerator<SseEvent> {
|
|
355
356
|
const params = new URLSearchParams({ conversationKey });
|
|
356
357
|
const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
|
|
358
|
+
const clientHeaders = getClientRegistrationHeaders();
|
|
359
|
+
tuiLog.info("sse connect", { url, clientHeaders });
|
|
357
360
|
const response = await fetch(url, {
|
|
358
361
|
headers: {
|
|
359
362
|
Accept: "text/event-stream",
|
|
360
363
|
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
361
|
-
...
|
|
364
|
+
...clientHeaders,
|
|
362
365
|
},
|
|
363
366
|
signal,
|
|
364
367
|
});
|
|
365
368
|
|
|
369
|
+
tuiLog.info("sse response", {
|
|
370
|
+
status: response.status,
|
|
371
|
+
statusText: response.statusText,
|
|
372
|
+
contentType: response.headers.get("content-type"),
|
|
373
|
+
});
|
|
374
|
+
|
|
366
375
|
if (!response.ok) {
|
|
367
376
|
const body = await response.text().catch(() => "");
|
|
377
|
+
tuiLog.error("sse connection failed", {
|
|
378
|
+
status: response.status,
|
|
379
|
+
body: body.slice(0, 500),
|
|
380
|
+
});
|
|
368
381
|
throw new Error(
|
|
369
382
|
`SSE connection failed (${response.status}): ${body || response.statusText}`,
|
|
370
383
|
);
|
|
371
384
|
}
|
|
372
385
|
if (!response.body) {
|
|
386
|
+
tuiLog.error("sse response has no body");
|
|
373
387
|
throw new Error("No response body from SSE endpoint");
|
|
374
388
|
}
|
|
375
389
|
|
|
@@ -1653,6 +1667,10 @@ function ChatApp({
|
|
|
1653
1667
|
|
|
1654
1668
|
try {
|
|
1655
1669
|
const health = await checkHealthRuntime(runtimeUrl);
|
|
1670
|
+
tuiLog.info("health check", {
|
|
1671
|
+
status: health.status,
|
|
1672
|
+
message: health.message,
|
|
1673
|
+
});
|
|
1656
1674
|
h.hideSpinner();
|
|
1657
1675
|
h.updateHealthStatus(health.status);
|
|
1658
1676
|
if (health.status === "healthy" || health.status === "ok") {
|
|
@@ -1850,9 +1868,12 @@ function ChatApp({
|
|
|
1850
1868
|
break;
|
|
1851
1869
|
}
|
|
1852
1870
|
}
|
|
1853
|
-
} catch {
|
|
1871
|
+
} catch (sseErr) {
|
|
1854
1872
|
// Stream ended — only report if not intentionally aborted
|
|
1855
1873
|
if (!sseAc.signal.aborted) {
|
|
1874
|
+
tuiLog.warn("sse stream disconnected", {
|
|
1875
|
+
error: String(sseErr),
|
|
1876
|
+
});
|
|
1856
1877
|
handleRef_.current?.addStatus(
|
|
1857
1878
|
"SSE stream disconnected — will reconnect on next message",
|
|
1858
1879
|
"yellow",
|
|
@@ -1869,10 +1890,11 @@ function ChatApp({
|
|
|
1869
1890
|
setConnectionState("connected");
|
|
1870
1891
|
return true;
|
|
1871
1892
|
} catch (err) {
|
|
1893
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1894
|
+
tuiLog.error("connection failed", { error: msg });
|
|
1872
1895
|
h.hideSpinner();
|
|
1873
1896
|
connectingRef.current = false;
|
|
1874
1897
|
h.updateHealthStatus("unreachable");
|
|
1875
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1876
1898
|
setConnectionState("error");
|
|
1877
1899
|
setConnectionError(msg);
|
|
1878
1900
|
h.addStatus(
|
package/src/index.ts
CHANGED
|
@@ -27,9 +27,7 @@ import { upgrade } from "./commands/upgrade";
|
|
|
27
27
|
import { use } from "./commands/use";
|
|
28
28
|
import { wake } from "./commands/wake";
|
|
29
29
|
import {
|
|
30
|
-
|
|
31
|
-
findAssistantByName,
|
|
32
|
-
loadLatestAssistant,
|
|
30
|
+
resolveAssistant,
|
|
33
31
|
setActiveAssistant,
|
|
34
32
|
} from "./lib/assistant-config";
|
|
35
33
|
import { loadGuardianToken } from "./lib/guardian-token";
|
|
@@ -129,10 +127,7 @@ function applyNoColorFlags(argv: string[]): void {
|
|
|
129
127
|
* Otherwise return false so the caller can fall back to help text.
|
|
130
128
|
*/
|
|
131
129
|
async function tryLaunchClient(): Promise<boolean> {
|
|
132
|
-
const
|
|
133
|
-
const entry = activeName
|
|
134
|
-
? findAssistantByName(activeName)
|
|
135
|
-
: loadLatestAssistant();
|
|
130
|
+
const entry = resolveAssistant();
|
|
136
131
|
|
|
137
132
|
if (!entry) return false;
|
|
138
133
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import type { AssistantEntry } from "../assistant-config.js";
|
|
3
4
|
import {
|
|
4
5
|
MigrationInProgressError,
|
|
5
6
|
localRuntimeExportToGcs,
|
|
@@ -10,6 +11,17 @@ import {
|
|
|
10
11
|
const RUNTIME_URL = "http://127.0.0.1:8765";
|
|
11
12
|
const TOKEN = "local-bearer-token";
|
|
12
13
|
|
|
14
|
+
// All tests in this file exercise the local/docker code path (cloud="local"),
|
|
15
|
+
// which builds `{runtimeUrl}/v1/migrations/<subpath>` URLs and uses
|
|
16
|
+
// guardian-token bearer auth. The platform path (cloud="vellum") is covered
|
|
17
|
+
// by `runtime-url.test.ts` (URL construction) and the teleport tests
|
|
18
|
+
// (call-site wiring).
|
|
19
|
+
const ENTRY: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> = {
|
|
20
|
+
cloud: "local",
|
|
21
|
+
runtimeUrl: RUNTIME_URL,
|
|
22
|
+
assistantId: "ast-test-1",
|
|
23
|
+
};
|
|
24
|
+
|
|
13
25
|
interface CapturedCall {
|
|
14
26
|
url: string;
|
|
15
27
|
method: string;
|
|
@@ -82,7 +94,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
82
94
|
});
|
|
83
95
|
globalThis.fetch = fetchMock;
|
|
84
96
|
|
|
85
|
-
const result = await localRuntimeExportToGcs(
|
|
97
|
+
const result = await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
86
98
|
uploadUrl: "https://storage.example/signed/abc",
|
|
87
99
|
description: "teleport export",
|
|
88
100
|
});
|
|
@@ -108,7 +120,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
108
120
|
});
|
|
109
121
|
globalThis.fetch = fetchMock;
|
|
110
122
|
|
|
111
|
-
await localRuntimeExportToGcs(
|
|
123
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
112
124
|
uploadUrl: "https://storage.example/signed/abc",
|
|
113
125
|
});
|
|
114
126
|
|
|
@@ -132,7 +144,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
132
144
|
globalThis.fetch = fetchMock;
|
|
133
145
|
|
|
134
146
|
try {
|
|
135
|
-
await localRuntimeExportToGcs(
|
|
147
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
136
148
|
uploadUrl: "https://storage.example/signed/abc",
|
|
137
149
|
});
|
|
138
150
|
throw new Error("expected to throw");
|
|
@@ -156,7 +168,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
156
168
|
globalThis.fetch = fetchMock;
|
|
157
169
|
|
|
158
170
|
try {
|
|
159
|
-
await localRuntimeExportToGcs(
|
|
171
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
160
172
|
uploadUrl: "https://storage.example/signed/abc",
|
|
161
173
|
});
|
|
162
174
|
throw new Error("expected to throw");
|
|
@@ -182,7 +194,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
182
194
|
globalThis.fetch = fetchMock;
|
|
183
195
|
|
|
184
196
|
try {
|
|
185
|
-
await localRuntimeExportToGcs(
|
|
197
|
+
await localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
186
198
|
uploadUrl: "https://storage.example/signed/abc",
|
|
187
199
|
});
|
|
188
200
|
throw new Error("expected to throw");
|
|
@@ -201,7 +213,7 @@ describe("localRuntimeExportToGcs", () => {
|
|
|
201
213
|
globalThis.fetch = fetchMock;
|
|
202
214
|
|
|
203
215
|
await expect(
|
|
204
|
-
localRuntimeExportToGcs(
|
|
216
|
+
localRuntimeExportToGcs(ENTRY, TOKEN, {
|
|
205
217
|
uploadUrl: "https://storage.example/signed/abc",
|
|
206
218
|
}),
|
|
207
219
|
).rejects.toThrow(/500/);
|
|
@@ -222,7 +234,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
222
234
|
});
|
|
223
235
|
globalThis.fetch = fetchMock;
|
|
224
236
|
|
|
225
|
-
const result = await localRuntimeImportFromGcs(
|
|
237
|
+
const result = await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
226
238
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
227
239
|
});
|
|
228
240
|
|
|
@@ -250,7 +262,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
250
262
|
globalThis.fetch = fetchMock;
|
|
251
263
|
|
|
252
264
|
try {
|
|
253
|
-
await localRuntimeImportFromGcs(
|
|
265
|
+
await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
254
266
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
255
267
|
});
|
|
256
268
|
throw new Error("expected to throw");
|
|
@@ -275,7 +287,7 @@ describe("localRuntimeImportFromGcs", () => {
|
|
|
275
287
|
globalThis.fetch = fetchMock;
|
|
276
288
|
|
|
277
289
|
try {
|
|
278
|
-
await localRuntimeImportFromGcs(
|
|
290
|
+
await localRuntimeImportFromGcs(ENTRY, TOKEN, {
|
|
279
291
|
bundleUrl: "https://storage.example/signed/dl-xyz",
|
|
280
292
|
});
|
|
281
293
|
throw new Error("expected to throw");
|
|
@@ -302,11 +314,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
302
314
|
});
|
|
303
315
|
globalThis.fetch = fetchMock;
|
|
304
316
|
|
|
305
|
-
const status = await localRuntimePollJobStatus(
|
|
306
|
-
RUNTIME_URL,
|
|
307
|
-
TOKEN,
|
|
308
|
-
"poll-1",
|
|
309
|
-
);
|
|
317
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-1");
|
|
310
318
|
|
|
311
319
|
expect(status).toEqual({
|
|
312
320
|
jobId: "poll-1",
|
|
@@ -332,11 +340,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
332
340
|
});
|
|
333
341
|
globalThis.fetch = fetchMock;
|
|
334
342
|
|
|
335
|
-
const status = await localRuntimePollJobStatus(
|
|
336
|
-
RUNTIME_URL,
|
|
337
|
-
TOKEN,
|
|
338
|
-
"poll-2",
|
|
339
|
-
);
|
|
343
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-2");
|
|
340
344
|
|
|
341
345
|
expect(status.status).toBe("complete");
|
|
342
346
|
if (status.status === "complete") {
|
|
@@ -358,11 +362,7 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
358
362
|
});
|
|
359
363
|
globalThis.fetch = fetchMock;
|
|
360
364
|
|
|
361
|
-
const status = await localRuntimePollJobStatus(
|
|
362
|
-
RUNTIME_URL,
|
|
363
|
-
TOKEN,
|
|
364
|
-
"poll-3",
|
|
365
|
-
);
|
|
365
|
+
const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-3");
|
|
366
366
|
|
|
367
367
|
expect(status.status).toBe("failed");
|
|
368
368
|
if (status.status === "failed") {
|
|
@@ -377,7 +377,104 @@ describe("localRuntimePollJobStatus", () => {
|
|
|
377
377
|
globalThis.fetch = fetchMock;
|
|
378
378
|
|
|
379
379
|
await expect(
|
|
380
|
-
localRuntimePollJobStatus(
|
|
380
|
+
localRuntimePollJobStatus(ENTRY, TOKEN, "missing"),
|
|
381
381
|
).rejects.toThrow(/Migration job not found/);
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Platform-managed assistants (cloud="vellum") route through the platform's
|
|
387
|
+
// wildcard runtime proxy at `/v1/assistants/<id>/migrations/...` with
|
|
388
|
+
// platform-token auth (NOT guardian-token bearer). This block asserts the
|
|
389
|
+
// actual URL and headers built by the helpers — not mocked, not abstracted.
|
|
390
|
+
// Regression guard for the routing bug fixed in this PR.
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
const VELLUM_ENTRY: Pick<
|
|
393
|
+
AssistantEntry,
|
|
394
|
+
"cloud" | "runtimeUrl" | "assistantId"
|
|
395
|
+
> = {
|
|
396
|
+
cloud: "vellum",
|
|
397
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
398
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
399
|
+
};
|
|
400
|
+
// `vak_` prefix bypasses `fetchOrganizationId` (org-scoped API keys); the
|
|
401
|
+
// auth header collapses to a single `Authorization: Bearer vak_...` so this
|
|
402
|
+
// test stays free of network mocks.
|
|
403
|
+
const VAK_TOKEN = "vak_platform-token";
|
|
404
|
+
|
|
405
|
+
describe("vellum-cloud routing through wildcard proxy", () => {
|
|
406
|
+
test("export-to-gcs URL has /v1/assistants/<id>/migrations/ prefix and uses platform-token bearer (no guardian)", async () => {
|
|
407
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
408
|
+
return new Response(
|
|
409
|
+
JSON.stringify({ job_id: "wp-export-1", status: "pending" }),
|
|
410
|
+
{ status: 202, headers: { "Content-Type": "application/json" } },
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
globalThis.fetch = fetchMock;
|
|
414
|
+
|
|
415
|
+
const result = await localRuntimeExportToGcs(VELLUM_ENTRY, VAK_TOKEN, {
|
|
416
|
+
uploadUrl: "https://storage.example/signed/x",
|
|
417
|
+
description: "teleport export",
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(result.jobId).toBe("wp-export-1");
|
|
421
|
+
expect(calls[0]!.url).toBe(
|
|
422
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs`,
|
|
423
|
+
);
|
|
424
|
+
expect(calls[0]!.method).toBe("POST");
|
|
425
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
426
|
+
expect(calls[0]!.body).toEqual({
|
|
427
|
+
upload_url: "https://storage.example/signed/x",
|
|
428
|
+
description: "teleport export",
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("import-from-gcs URL has /v1/assistants/<id>/migrations/ prefix", async () => {
|
|
433
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
434
|
+
return new Response(
|
|
435
|
+
JSON.stringify({ job_id: "wp-import-1", status: "pending" }),
|
|
436
|
+
{ status: 202 },
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
globalThis.fetch = fetchMock;
|
|
440
|
+
|
|
441
|
+
await localRuntimeImportFromGcs(VELLUM_ENTRY, VAK_TOKEN, {
|
|
442
|
+
bundleUrl: "https://storage.example/download/y",
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
expect(calls[0]!.url).toBe(
|
|
446
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs`,
|
|
447
|
+
);
|
|
448
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("jobs/<id> URL has /v1/assistants/<id>/migrations/ prefix (NOT the dedicated platform endpoint)", async () => {
|
|
452
|
+
const { calls, fetchMock } = captureFetch(() => {
|
|
453
|
+
return new Response(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
job_id: "wp-export-1",
|
|
456
|
+
status: "complete",
|
|
457
|
+
type: "export",
|
|
458
|
+
bundle_key: "exports/org-1/x.vbundle",
|
|
459
|
+
}),
|
|
460
|
+
{ status: 200 },
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
globalThis.fetch = fetchMock;
|
|
464
|
+
|
|
465
|
+
const status = await localRuntimePollJobStatus(
|
|
466
|
+
VELLUM_ENTRY,
|
|
467
|
+
VAK_TOKEN,
|
|
468
|
+
"wp-export-1",
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
expect(calls[0]!.url).toBe(
|
|
472
|
+
`https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/wp-export-1`,
|
|
473
|
+
);
|
|
474
|
+
expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
|
|
475
|
+
expect(status.status).toBe("complete");
|
|
476
|
+
if (status.status === "complete") {
|
|
477
|
+
expect(status.bundleKey).toBe("exports/org-1/x.vbundle");
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -291,7 +291,7 @@ describe("platformRequestSignedUrl", () => {
|
|
|
291
291
|
expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
-
test("
|
|
294
|
+
test("5xx error response → surfaces platform detail message", async () => {
|
|
295
295
|
const { fetchMock } = captureFetch(() => {
|
|
296
296
|
return new Response(JSON.stringify({ detail: "temporarily down" }), {
|
|
297
297
|
status: 503,
|
|
@@ -305,7 +305,7 @@ describe("platformRequestSignedUrl", () => {
|
|
|
305
305
|
VAK_TOKEN,
|
|
306
306
|
PLATFORM_URL,
|
|
307
307
|
),
|
|
308
|
-
).rejects.toThrow(/
|
|
308
|
+
).rejects.toThrow(/temporarily down/);
|
|
309
309
|
});
|
|
310
310
|
});
|
|
311
311
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { AssistantEntry } from "../assistant-config.js";
|
|
4
|
+
import { resolveRuntimeMigrationUrl } from "../runtime-url.js";
|
|
5
|
+
|
|
6
|
+
function makeEntry(
|
|
7
|
+
overrides: Partial<AssistantEntry> & {
|
|
8
|
+
cloud: string;
|
|
9
|
+
runtimeUrl: string;
|
|
10
|
+
assistantId: string;
|
|
11
|
+
},
|
|
12
|
+
): Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> {
|
|
13
|
+
return {
|
|
14
|
+
cloud: overrides.cloud,
|
|
15
|
+
runtimeUrl: overrides.runtimeUrl,
|
|
16
|
+
assistantId: overrides.assistantId,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("resolveRuntimeMigrationUrl", () => {
|
|
21
|
+
test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
|
|
22
|
+
const entry = makeEntry({
|
|
23
|
+
cloud: "local",
|
|
24
|
+
runtimeUrl: "http://localhost:7821",
|
|
25
|
+
assistantId: "ast-local-1",
|
|
26
|
+
});
|
|
27
|
+
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
28
|
+
"http://localhost:7821/v1/migrations/export-to-gcs",
|
|
29
|
+
);
|
|
30
|
+
expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
|
|
31
|
+
"http://localhost:7821/v1/migrations/import-from-gcs",
|
|
32
|
+
);
|
|
33
|
+
expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
|
|
34
|
+
"http://localhost:7821/v1/migrations/jobs/job-abc",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
|
|
39
|
+
const entry = makeEntry({
|
|
40
|
+
cloud: "docker",
|
|
41
|
+
runtimeUrl: "http://localhost:7831",
|
|
42
|
+
assistantId: "ast-docker-1",
|
|
43
|
+
});
|
|
44
|
+
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
45
|
+
"http://localhost:7831/v1/migrations/export-to-gcs",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("vellum (platform-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
|
|
50
|
+
const entry = makeEntry({
|
|
51
|
+
cloud: "vellum",
|
|
52
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
53
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
54
|
+
});
|
|
55
|
+
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
56
|
+
"https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
|
|
57
|
+
);
|
|
58
|
+
expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
|
|
59
|
+
"https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
|
|
60
|
+
);
|
|
61
|
+
expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
|
|
62
|
+
"https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("dev platform URL still routes through the wildcard prefix", () => {
|
|
67
|
+
const entry = makeEntry({
|
|
68
|
+
cloud: "vellum",
|
|
69
|
+
runtimeUrl: "https://dev-platform.vellum.ai",
|
|
70
|
+
assistantId: "ast-dev-1",
|
|
71
|
+
});
|
|
72
|
+
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
73
|
+
"https://dev-platform.vellum.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a non-vellum, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
|
|
78
|
+
const entry = makeEntry({
|
|
79
|
+
cloud: "gcp",
|
|
80
|
+
runtimeUrl: "http://10.0.0.5:7821",
|
|
81
|
+
assistantId: "ast-gcp-1",
|
|
82
|
+
});
|
|
83
|
+
expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
|
|
84
|
+
"http://10.0.0.5:7821/v1/migrations/export-to-gcs",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
parseSentinelOutput,
|
|
5
|
+
stripAnsi,
|
|
6
|
+
} from "../terminal-session.js";
|
|
7
|
+
|
|
8
|
+
const START = "__VELLUM_EXEC_START_1234__";
|
|
9
|
+
const END = "__VELLUM_EXEC_END_1234__";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// stripAnsi
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
describe("stripAnsi", () => {
|
|
16
|
+
test("removes SGR color codes", () => {
|
|
17
|
+
expect(stripAnsi("\x1b[32mINFO\x1b[39m hello")).toBe("INFO hello");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("removes OSC title sequences", () => {
|
|
21
|
+
expect(stripAnsi("\x1b]0;title\x07prompt$ ")).toBe("prompt$ ");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("removes carriage returns", () => {
|
|
25
|
+
expect(stripAnsi("line1\r\nline2\r\n")).toBe("line1\nline2\n");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("removes bracket-paste mode escapes", () => {
|
|
29
|
+
expect(stripAnsi("\x1b[?2004hroot$ ")).toBe("root$ ");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("removes charset designator sequences", () => {
|
|
33
|
+
expect(stripAnsi("\x1b(Bhello")).toBe("hello");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("passes through plain text unchanged", () => {
|
|
37
|
+
expect(stripAnsi("just plain text")).toBe("just plain text");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("handles mixed ANSI sequences", () => {
|
|
41
|
+
const raw =
|
|
42
|
+
"\x1b[?2004hroot:/workspace$ \r\x1b[K\rroot:/workspace$ echo hello\r\nhello\r\n";
|
|
43
|
+
const clean = stripAnsi(raw);
|
|
44
|
+
expect(clean).not.toContain("\x1b");
|
|
45
|
+
expect(clean).not.toContain("\r");
|
|
46
|
+
expect(clean).toContain("hello");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// parseSentinelOutput
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe("parseSentinelOutput", () => {
|
|
55
|
+
test("extracts output between sentinels", () => {
|
|
56
|
+
const cleaned = [
|
|
57
|
+
`echo '${START}'; ls; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
|
|
58
|
+
START,
|
|
59
|
+
"file1.txt",
|
|
60
|
+
"file2.txt",
|
|
61
|
+
END,
|
|
62
|
+
"__VELLUM_EXIT_0",
|
|
63
|
+
].join("\n");
|
|
64
|
+
|
|
65
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
66
|
+
expect(result.output).toBe("file1.txt\nfile2.txt");
|
|
67
|
+
expect(result.exitCode).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("extracts non-zero exit code", () => {
|
|
71
|
+
const cleaned = [
|
|
72
|
+
`echo '${START}'; false; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
|
|
73
|
+
START,
|
|
74
|
+
END,
|
|
75
|
+
"__VELLUM_EXIT_1",
|
|
76
|
+
].join("\n");
|
|
77
|
+
|
|
78
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
79
|
+
expect(result.output).toBe("");
|
|
80
|
+
expect(result.exitCode).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles exit code 127 (command not found)", () => {
|
|
84
|
+
const cleaned = [
|
|
85
|
+
START,
|
|
86
|
+
"bash: nosuchcmd: command not found",
|
|
87
|
+
END,
|
|
88
|
+
"__VELLUM_EXIT_127",
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
92
|
+
expect(result.output).toBe("bash: nosuchcmd: command not found");
|
|
93
|
+
expect(result.exitCode).toBe(127);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("uses last start sentinel (skips command echo)", () => {
|
|
97
|
+
// The command echo contains the sentinel text, then the actual output
|
|
98
|
+
// sentinel comes later. Parser must pick the last START, not the echo.
|
|
99
|
+
const cleaned = [
|
|
100
|
+
`root$ echo '${START}'; mycommand; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
|
|
101
|
+
START,
|
|
102
|
+
"real output here",
|
|
103
|
+
END,
|
|
104
|
+
"__VELLUM_EXIT_0",
|
|
105
|
+
].join("\n");
|
|
106
|
+
|
|
107
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
108
|
+
expect(result.output).toBe("real output here");
|
|
109
|
+
expect(result.exitCode).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("regression: end sentinel in echo before start sentinel in output", () => {
|
|
113
|
+
// This was the original bug: backward search found END in the echo
|
|
114
|
+
// (line 0) before START in the output (line 1), giving endIdx < startIdx.
|
|
115
|
+
const cleaned = [
|
|
116
|
+
`echo '${START}'; cmd; echo '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
|
|
117
|
+
START,
|
|
118
|
+
"[INFO] Running clawhub command",
|
|
119
|
+
' args: ["search"]',
|
|
120
|
+
' cwd: "/workspace"',
|
|
121
|
+
].join("\n");
|
|
122
|
+
|
|
123
|
+
// No end sentinel in actual output yet (stream was cut short in old code)
|
|
124
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
125
|
+
// Should still return the partial output (no end sentinel → take everything)
|
|
126
|
+
expect(result.output).toContain("[INFO] Running clawhub command");
|
|
127
|
+
expect(result.output).toContain('cwd: "/workspace"');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("handles multiline output with special characters", () => {
|
|
131
|
+
const cleaned = [
|
|
132
|
+
START,
|
|
133
|
+
"📤 Resend Email Setup [installed]",
|
|
134
|
+
" ID: resend-setup",
|
|
135
|
+
' Set up and send emails via a user-provided Resend account (BYO email provider)',
|
|
136
|
+
"",
|
|
137
|
+
"Community registry (1):",
|
|
138
|
+
"",
|
|
139
|
+
" resend-setup [installed]",
|
|
140
|
+
END,
|
|
141
|
+
"__VELLUM_EXIT_0",
|
|
142
|
+
].join("\n");
|
|
143
|
+
|
|
144
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
145
|
+
expect(result.output).toContain("📤 Resend Email Setup");
|
|
146
|
+
expect(result.output).toContain("Community registry (1):");
|
|
147
|
+
expect(result.exitCode).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("returns empty output and exit code 0 when no sentinels found", () => {
|
|
151
|
+
const cleaned = "just some random output\nwith no sentinels\n";
|
|
152
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
153
|
+
// Falls back to entire output (trimmed)
|
|
154
|
+
expect(result.output).toBe(
|
|
155
|
+
"just some random output\nwith no sentinels",
|
|
156
|
+
);
|
|
157
|
+
expect(result.exitCode).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("handles output with only start sentinel (no end)", () => {
|
|
161
|
+
const cleaned = [
|
|
162
|
+
START,
|
|
163
|
+
"partial output",
|
|
164
|
+
"more output",
|
|
165
|
+
].join("\n");
|
|
166
|
+
|
|
167
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
168
|
+
expect(result.output).toBe("partial output\nmore output");
|
|
169
|
+
expect(result.exitCode).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("handles real-world verbose trace structure", () => {
|
|
173
|
+
// Simulates the full cleaned output from a real exec session
|
|
174
|
+
const cleaned = [
|
|
175
|
+
"root:/workspace$ root:/workspace$ " +
|
|
176
|
+
`echo '${START}'; 'assistant' 'skills' 'search' 'resend-setup'; __ec=$?; echo ` +
|
|
177
|
+
` '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
|
|
178
|
+
START,
|
|
179
|
+
"[13:06:38.851] INFO (761 on pod-0): [clawhub] Running clawhub command",
|
|
180
|
+
' args: [',
|
|
181
|
+
' "search",',
|
|
182
|
+
' "resend-setup",',
|
|
183
|
+
' "--limit",',
|
|
184
|
+
' "10"',
|
|
185
|
+
" ]",
|
|
186
|
+
' cwd: "/workspace"',
|
|
187
|
+
"Bundled & installed skills (1):",
|
|
188
|
+
"",
|
|
189
|
+
" 📤 Resend Email Setup [installed]",
|
|
190
|
+
" ID: resend-setup",
|
|
191
|
+
"",
|
|
192
|
+
END,
|
|
193
|
+
"__VELLUM_EXIT_0",
|
|
194
|
+
].join("\n");
|
|
195
|
+
|
|
196
|
+
const result = parseSentinelOutput(cleaned, START, END);
|
|
197
|
+
expect(result.output).toContain("Bundled & installed skills (1):");
|
|
198
|
+
expect(result.output).toContain("📤 Resend Email Setup [installed]");
|
|
199
|
+
expect(result.output).toContain("[clawhub] Running clawhub command");
|
|
200
|
+
expect(result.exitCode).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
});
|