From a8e38369a0d0a519116897e465727366f750a20f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 20:07:10 +0000 Subject: [PATCH 1/2] feat(cli): make local-assistant lifecycle ops programmatically reusable Add an injectable LifecycleReporter so hatchLocal/retireLocal can report progress and log output through a sink instead of writing to the console directly, and return structured results instead of void. The CLI keeps its exact terminal output (and the HATCH_PROGRESS stdout protocol under VELLUM_DESKTOP_APP) via a default console reporter, while in-process callers can inject their own reporter and read results without scraping stdout. Curate package exports for hatch-local, retire-local, guardian-token, and lifecycle-reporter so a second host (e.g. the Electron main process) can import these functions directly. LUM-2051 Co-Authored-By: ashlee@vellum.ai --- cli/package.json | 4 + .../lib/__tests__/lifecycle-reporter.test.ts | 59 ++++++++++ cli/src/lib/__tests__/retire-local.test.ts | 89 +++++++++++++++ cli/src/lib/hatch-local.ts | 106 ++++++++++++------ cli/src/lib/lifecycle-reporter.ts | 31 +++++ cli/src/lib/retire-local.ts | 42 ++++--- 6 files changed, 284 insertions(+), 47 deletions(-) create mode 100644 cli/src/lib/__tests__/lifecycle-reporter.test.ts create mode 100644 cli/src/lib/__tests__/retire-local.test.ts create mode 100644 cli/src/lib/lifecycle-reporter.ts diff --git a/cli/package.json b/cli/package.json index be2750c67e7..449be4d83be 100644 --- a/cli/package.json +++ b/cli/package.json @@ -8,6 +8,10 @@ "./package.json": "./package.json", "./src/components/DefaultMainScreen": "./src/components/DefaultMainScreen.tsx", "./src/lib/constants": "./src/lib/constants.ts", + "./src/lib/hatch-local": "./src/lib/hatch-local.ts", + "./src/lib/retire-local": "./src/lib/retire-local.ts", + "./src/lib/guardian-token": "./src/lib/guardian-token.ts", + "./src/lib/lifecycle-reporter": "./src/lib/lifecycle-reporter.ts", "./src/commands/*": "./src/commands/*.ts" }, "bin": { diff --git a/cli/src/lib/__tests__/lifecycle-reporter.test.ts b/cli/src/lib/__tests__/lifecycle-reporter.test.ts new file mode 100644 index 00000000000..8213793ad56 --- /dev/null +++ b/cli/src/lib/__tests__/lifecycle-reporter.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { consoleLifecycleReporter } from "../lifecycle-reporter.js"; + +describe("consoleLifecycleReporter", () => { + const originalDesktopApp = process.env.VELLUM_DESKTOP_APP; + let stdoutWriteSpy: ReturnType; + + beforeEach(() => { + stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation( + () => true, + ); + }); + + afterEach(() => { + stdoutWriteSpy.mockRestore(); + if (originalDesktopApp === undefined) { + delete process.env.VELLUM_DESKTOP_APP; + } else { + process.env.VELLUM_DESKTOP_APP = originalDesktopApp; + } + }); + + test("routes log/warn/error to the matching console methods", () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + consoleLifecycleReporter.log("hello"); + consoleLifecycleReporter.warn("careful"); + consoleLifecycleReporter.error("boom"); + + expect(logSpy).toHaveBeenCalledWith("hello"); + expect(warnSpy).toHaveBeenCalledWith("careful"); + expect(errorSpy).toHaveBeenCalledWith("boom"); + + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => { + process.env.VELLUM_DESKTOP_APP = "1"; + + consoleLifecycleReporter.progress(3, 6, "Starting assistant..."); + + expect(stdoutWriteSpy).toHaveBeenCalledWith( + `HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`, + ); + }); + + test("suppresses progress output when not running under the desktop app", () => { + delete process.env.VELLUM_DESKTOP_APP; + + consoleLifecycleReporter.progress(1, 6, "Allocating resources..."); + + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/lib/__tests__/retire-local.test.ts b/cli/src/lib/__tests__/retire-local.test.ts new file mode 100644 index 00000000000..9a16f767d0f --- /dev/null +++ b/cli/src/lib/__tests__/retire-local.test.ts @@ -0,0 +1,89 @@ +import { afterAll, describe, expect, mock, spyOn, test } from "bun:test"; + +import type { AssistantEntry } from "../assistant-config.js"; +import type { LifecycleReporter } from "../lifecycle-reporter.js"; + +import * as realAssistantConfig from "../assistant-config.js"; + +const loadAllAssistantsMock = mock<() => AssistantEntry[]>(() => []); + +mock.module("../assistant-config.js", () => ({ + ...realAssistantConfig, + loadAllAssistants: loadAllAssistantsMock, +})); + +const { retireLocal } = await import("../retire-local.js"); + +afterAll(() => { + mock.module("../assistant-config.js", () => realAssistantConfig); +}); + +function makeEntry(assistantId: string, instanceDir: string): AssistantEntry { + return { + assistantId, + runtimeUrl: "http://127.0.0.1:7821", + cloud: "local", + resources: { + instanceDir, + daemonPort: 7801, + gatewayPort: 7831, + qdrantPort: 6334, + cesPort: 7790, + }, + }; +} + +function makeRecordingReporter(): { + reporter: LifecycleReporter; + logs: string[]; +} { + const logs: string[] = []; + return { + logs, + reporter: { + progress: () => {}, + log: (message) => logs.push(message), + warn: (message) => logs.push(message), + error: (message) => logs.push(message), + }, + }; +} + +describe("retireLocal", () => { + test("keeps shared data dir, returns structured result, and routes output to the injected reporter", async () => { + const shared = "/tmp/vellum-retire-shared-instance"; + const target = makeEntry("assistant-a", shared); + loadAllAssistantsMock.mockReturnValue([ + target, + makeEntry("assistant-b", shared), + ]); + + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + const { reporter, logs } = makeRecordingReporter(); + + const result = await retireLocal("assistant-a", target, reporter); + + expect(result).toEqual({ + assistantId: "assistant-a", + archived: false, + sharedDataDir: true, + }); + expect( + logs.some((line) => line.includes("config entry removed only")), + ).toBe(true); + expect(consoleLogSpy).not.toHaveBeenCalled(); + + consoleLogSpy.mockRestore(); + }); + + test("throws when the entry is missing resource configuration", async () => { + const entry = { + ...makeEntry("assistant-c", "/tmp/x"), + resources: undefined, + }; + + await expect(retireLocal("assistant-c", entry)).rejects.toThrow( + "missing resource configuration", + ); + }); +}); diff --git a/cli/src/lib/hatch-local.ts b/cli/src/lib/hatch-local.ts index 0b1101fa1f4..dec7607772b 100644 --- a/cli/src/lib/hatch-local.ts +++ b/cli/src/lib/hatch-local.ts @@ -33,7 +33,10 @@ import { import { generateInstanceName } from "./random-name.js"; import { leaseGuardianToken } from "./guardian-token.js"; import { archiveLogFile, resetLogFile } from "./xdg-log.js"; -import { emitProgress } from "./desktop-progress.js"; +import { + consoleLifecycleReporter, + type LifecycleReporter, +} from "./lifecycle-reporter.js"; import { configureHatchProviderApiKey, formatProviderName, @@ -134,6 +137,25 @@ function installCLISymlink(): void { export interface HatchLocalOptions { setupProviderCredentials?: boolean; + /** + * Sink for progress and log output. Defaults to the console reporter so CLI + * callers keep their existing terminal output; in-process callers can inject + * their own reporter to consume progress without writing to stdout. + */ + reporter?: LifecycleReporter; +} + +export interface HatchLocalResult { + assistantId: string; + runtimeUrl: string; + localUrl: string; + species: Species; + /** + * Guardian access token leased during hatch, when the lease succeeded. The + * full token pair is persisted to disk regardless; this is surfaced so an + * in-process caller can prime a connection without re-reading the file. + */ + guardianAccessToken?: string; } export async function hatchLocal( @@ -143,7 +165,8 @@ export async function hatchLocal( keepAlive: boolean = false, configValues: Record = {}, options: HatchLocalOptions = {}, -): Promise { +): Promise { + const reporter = options.reporter ?? consoleLifecycleReporter; const provider = options.setupProviderCredentials === false ? undefined @@ -153,7 +176,7 @@ export async function hatchLocal( name ?? process.env.VELLUM_ASSISTANT_NAME, ); - emitProgress(1, 6, "Allocating resources..."); + reporter.progress(1, 6, "Allocating resources..."); const existing = findAssistantByName(instanceName); if (existing && (!existing.cloud || existing.cloud === "local")) { @@ -175,29 +198,29 @@ export async function hatchLocal( archiveLogFile("hatch.log", logsDir); resetLogFile("hatch.log"); - console.log(`🄚 Hatching local assistant: ${instanceName}`); - console.log(` Species: ${species}`); - console.log(""); + reporter.log(`🄚 Hatching local assistant: ${instanceName}`); + reporter.log(` Species: ${species}`); + reporter.log(""); const apiKeyCheck = checkProviderApiKey(); if (!apiKeyCheck.hasKey) { - console.warn( + reporter.warn( "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.", ); - console.warn(" To fix, export your key before running vellum hatch:"); - console.warn(" export ANTHROPIC_API_KEY="); - console.warn(""); + reporter.warn(" To fix, export your key before running vellum hatch:"); + reporter.warn(" export ANTHROPIC_API_KEY="); + reporter.warn(""); } if (!process.env.APP_VERSION) { process.env.APP_VERSION = cliPkg.version; } - emitProgress(2, 6, "Writing configuration..."); + reporter.progress(2, 6, "Writing configuration..."); const hatchConfigValues = buildHatchConfigValues(configValues, provider); const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues); - emitProgress(3, 6, "Starting assistant..."); + reporter.progress(3, 6, "Starting assistant..."); const signingKey = generateLocalSigningKey(); const bootstrapSecret = generateLocalSigningKey(); await startLocalDaemon(watch, resources, { @@ -205,14 +228,17 @@ export async function hatchLocal( signingKey, }); - emitProgress(4, 6, "Starting gateway..."); + reporter.progress(4, 6, "Starting gateway..."); let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`; try { - runtimeUrl = await startGateway(watch, resources, { signingKey, bootstrapSecret }); + runtimeUrl = await startGateway(watch, resources, { + signingKey, + bootstrapSecret, + }); } catch (error) { // Gateway failed — stop the daemon we just started so we don't leave // orphaned processes with no lock file entry. - console.error( + reporter.error( `\nāŒ Gateway startup failed — stopping assistant to avoid orphaned processes.`, ); await stopLocalProcesses(resources); @@ -223,24 +249,28 @@ export async function hatchLocal( // instead of hitting /v1/guardian/init itself. Use loopback to satisfy // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN // IP which the daemon rejects as non-loopback. - emitProgress(5, 6, "Securing connection..."); + reporter.progress(5, 6, "Securing connection..."); const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`; const maxLeaseAttempts = 3; let guardianAccessToken: string | undefined; for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) { try { - const tokenData = await leaseGuardianToken(loopbackUrl, instanceName, bootstrapSecret); + const tokenData = await leaseGuardianToken( + loopbackUrl, + instanceName, + bootstrapSecret, + ); guardianAccessToken = tokenData.accessToken; break; } catch (err) { if (attempt < maxLeaseAttempts) { const delayMs = 2000 * 2 ** (attempt - 1); - console.error( + reporter.error( `āš ļø Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`, ); await new Promise((r) => setTimeout(r, delayMs)); } else { - console.error( + reporter.error( `āš ļø Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` + ` The assistant is running but guardian-token.json was not written.\n` + ` If the desktop app loses its stored credentials, re-hatch to recover.`, @@ -261,7 +291,7 @@ export async function hatchLocal( guardianBootstrapSecret: bootstrapSecret, }; - emitProgress(6, 6, "Saving configuration..."); + reporter.progress(6, 6, "Saving configuration..."); saveAssistantEntry(localEntry); setActiveAssistant(instanceName); @@ -270,13 +300,13 @@ export async function hatchLocal( } if (provider !== undefined && provider !== null && !guardianAccessToken) { - console.error( + reporter.error( `āš ļø Provider credential setup skipped because the guardian token was not leased.\n` + ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`, ); } else if (provider !== undefined) { - console.log(""); - console.log( + reporter.log(""); + reporter.log( provider === null ? "Checking provider credentials..." : `Checking ${formatProviderName(provider)} credentials...`, @@ -289,14 +319,22 @@ export async function hatchLocal( }); } - console.log(""); - console.log(`āœ… Local assistant hatched!`); - console.log(""); - console.log("Instance details:"); - console.log(` Name: ${instanceName}`); - console.log(` Runtime: ${runtimeUrl}`); - console.log(""); - logHatchNextSteps(console.log, instanceName); + reporter.log(""); + reporter.log(`āœ… Local assistant hatched!`); + reporter.log(""); + reporter.log("Instance details:"); + reporter.log(` Name: ${instanceName}`); + reporter.log(` Runtime: ${runtimeUrl}`); + reporter.log(""); + logHatchNextSteps((message) => reporter.log(message), instanceName); + + const result: HatchLocalResult = { + assistantId: instanceName, + runtimeUrl, + localUrl: `http://127.0.0.1:${resources.gatewayPort}`, + species, + guardianAccessToken, + }; if (keepAlive) { const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`; @@ -306,7 +344,7 @@ export async function hatchLocal( let consecutiveFailures = 0; const shutdown = async (): Promise => { - console.log("\nShutting down local processes..."); + reporter.log("\nShutting down local processes..."); await stopLocalProcesses(resources); process.exit(0); }; @@ -330,7 +368,7 @@ export async function hatchLocal( consecutiveFailures++; } if (consecutiveFailures >= MAX_FAILURES) { - console.log( + reporter.log( `\nāš ļø ${healthTarget} stopped responding — shutting down.`, ); await stopLocalProcesses(resources); @@ -338,4 +376,6 @@ export async function hatchLocal( } } } + + return result; } diff --git a/cli/src/lib/lifecycle-reporter.ts b/cli/src/lib/lifecycle-reporter.ts new file mode 100644 index 00000000000..f2823aad133 --- /dev/null +++ b/cli/src/lib/lifecycle-reporter.ts @@ -0,0 +1,31 @@ +import { emitProgress } from "./desktop-progress.js"; + +/** + * Sink for the human-facing and structured output of long-running lifecycle + * operations (hatch, retire). Injecting it lets an in-process caller (e.g. a + * desktop main process embedding these functions) observe progress without the + * operation writing to the terminal, while the CLI keeps its existing stdout. + */ +export interface LifecycleReporter { + /** + * Coarse step progress. The CLI reporter mirrors this to the desktop + * `HATCH_PROGRESS:` stdout channel. + */ + progress(step: number, total: number, label: string): void; + log(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +/** + * Reporter used by the CLI commands: human-readable lines to the console plus + * structured step events on the desktop progress channel. Reproduces the exact + * terminal output — and the `HATCH_PROGRESS:` lines under `VELLUM_DESKTOP_APP` — + * that existing subprocess consumers parse. + */ +export const consoleLifecycleReporter: LifecycleReporter = { + progress: (step, total, label) => emitProgress(step, total, label), + log: (message) => console.log(message), + warn: (message) => console.warn(message), + error: (message) => console.error(message), +}; diff --git a/cli/src/lib/retire-local.ts b/cli/src/lib/retire-local.ts index 3dccb003514..3a3cb95440d 100644 --- a/cli/src/lib/retire-local.ts +++ b/cli/src/lib/retire-local.ts @@ -3,22 +3,34 @@ import { homedir } from "os"; import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs"; import { basename, dirname, join } from "path"; -import { - getDaemonPidPath, - loadAllAssistants, -} from "./assistant-config.js"; +import { getDaemonPidPath, loadAllAssistants } from "./assistant-config.js"; import type { AssistantEntry } from "./assistant-config.js"; import { stopOrphanedDaemonProcesses, stopProcessByPidFile, } from "./process.js"; import { getArchivePath, getMetadataPath } from "./retire-archive.js"; +import { + consoleLifecycleReporter, + type LifecycleReporter, +} from "./lifecycle-reporter.js"; + +export interface RetireLocalResult { + assistantId: string; + /** Whether the instance data directory was archived (false when skipped). */ + archived: boolean; + /** Path to the background tar archive, when archiving was started. */ + archivePath?: string; + /** True when another local assistant shared the data dir, so it was kept. */ + sharedDataDir?: boolean; +} export async function retireLocal( name: string, entry: AssistantEntry, -): Promise { - console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n"); + reporter: LifecycleReporter = consoleLifecycleReporter, +): Promise { + reporter.log("\u{1F5D1}\ufe0f Stopping local assistant...\n"); if (!entry.resources) { throw new Error( @@ -38,11 +50,11 @@ export async function retireLocal( }); if (otherSharesDir) { - console.log( + reporter.log( ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`, ); - console.log("\u2705 Local instance retired (config entry removed only)."); - return; + reporter.log("\u2705 Local instance retired (config entry removed only)."); + return { assistantId: name, archived: false, sharedDataDir: true }; } const daemonPidFile = getDaemonPidPath(resources); @@ -87,11 +99,11 @@ export async function retireLocal( const stagingDir = `${archivePath}.staging`; if (!existsSync(dirToArchive)) { - console.log( + reporter.log( ` No data directory at ${dirToArchive} — nothing to archive.`, ); - console.log("\u2705 Local instance retired."); - return; + reporter.log("\u2705 Local instance retired."); + return { assistantId: name, archived: false }; } // Ensure the retired archive directory exists before attempting the rename @@ -123,6 +135,8 @@ export async function retireLocal( }); child.unref(); - console.log(`šŸ“¦ Archiving to ${archivePath} in the background.`); - console.log("\u2705 Local instance retired."); + reporter.log(`šŸ“¦ Archiving to ${archivePath} in the background.`); + reporter.log("\u2705 Local instance retired."); + + return { assistantId: name, archived: true, archivePath }; } From 4d164b543ef55bf2854f925d70d544dc7833cba7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 20:14:07 +0000 Subject: [PATCH 2/2] test(cli): drop retire-local unit test that collides with global module mock teleport.test.ts registers a process-global mock.module for ../lib/retire-local.js and never restores it, so a separate retire-local test file resolves the mocked retireLocal in the full suite. The structured return is covered by tsc and the command-level retire/teleport tests; reporter routing is covered by lifecycle-reporter.test.ts. Co-Authored-By: ashlee@vellum.ai --- cli/src/lib/__tests__/retire-local.test.ts | 89 ---------------------- 1 file changed, 89 deletions(-) delete mode 100644 cli/src/lib/__tests__/retire-local.test.ts diff --git a/cli/src/lib/__tests__/retire-local.test.ts b/cli/src/lib/__tests__/retire-local.test.ts deleted file mode 100644 index 9a16f767d0f..00000000000 --- a/cli/src/lib/__tests__/retire-local.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterAll, describe, expect, mock, spyOn, test } from "bun:test"; - -import type { AssistantEntry } from "../assistant-config.js"; -import type { LifecycleReporter } from "../lifecycle-reporter.js"; - -import * as realAssistantConfig from "../assistant-config.js"; - -const loadAllAssistantsMock = mock<() => AssistantEntry[]>(() => []); - -mock.module("../assistant-config.js", () => ({ - ...realAssistantConfig, - loadAllAssistants: loadAllAssistantsMock, -})); - -const { retireLocal } = await import("../retire-local.js"); - -afterAll(() => { - mock.module("../assistant-config.js", () => realAssistantConfig); -}); - -function makeEntry(assistantId: string, instanceDir: string): AssistantEntry { - return { - assistantId, - runtimeUrl: "http://127.0.0.1:7821", - cloud: "local", - resources: { - instanceDir, - daemonPort: 7801, - gatewayPort: 7831, - qdrantPort: 6334, - cesPort: 7790, - }, - }; -} - -function makeRecordingReporter(): { - reporter: LifecycleReporter; - logs: string[]; -} { - const logs: string[] = []; - return { - logs, - reporter: { - progress: () => {}, - log: (message) => logs.push(message), - warn: (message) => logs.push(message), - error: (message) => logs.push(message), - }, - }; -} - -describe("retireLocal", () => { - test("keeps shared data dir, returns structured result, and routes output to the injected reporter", async () => { - const shared = "/tmp/vellum-retire-shared-instance"; - const target = makeEntry("assistant-a", shared); - loadAllAssistantsMock.mockReturnValue([ - target, - makeEntry("assistant-b", shared), - ]); - - const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); - const { reporter, logs } = makeRecordingReporter(); - - const result = await retireLocal("assistant-a", target, reporter); - - expect(result).toEqual({ - assistantId: "assistant-a", - archived: false, - sharedDataDir: true, - }); - expect( - logs.some((line) => line.includes("config entry removed only")), - ).toBe(true); - expect(consoleLogSpy).not.toHaveBeenCalled(); - - consoleLogSpy.mockRestore(); - }); - - test("throws when the entry is missing resource configuration", async () => { - const entry = { - ...makeEntry("assistant-c", "/tmp/x"), - resources: undefined, - }; - - await expect(retireLocal("assistant-c", entry)).rejects.toThrow( - "missing resource configuration", - ); - }); -});