Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
59 changes: 59 additions & 0 deletions cli/src/lib/__tests__/lifecycle-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn>;

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();
});
});
106 changes: 73 additions & 33 deletions cli/src/lib/hatch-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -143,7 +165,8 @@ export async function hatchLocal(
keepAlive: boolean = false,
configValues: Record<string, string> = {},
options: HatchLocalOptions = {},
): Promise<void> {
): Promise<HatchLocalResult> {
const reporter = options.reporter ?? consoleLifecycleReporter;
const provider =
options.setupProviderCredentials === false
? undefined
Expand All @@ -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")) {
Expand All @@ -175,44 +198,47 @@ 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=<your-key>");
console.warn("");
reporter.warn(" To fix, export your key before running vellum hatch:");
reporter.warn(" export ANTHROPIC_API_KEY=<your-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, {
defaultWorkspaceConfigPath,
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,
Comment on lines +234 to +236
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route nested hatch output through the reporter

When an in-process caller supplies options.reporter, this still invokes the nested lifecycle helpers without any reporting hook; startGateway (and the preceding startLocalDaemon) write status and warning messages directly with console.log/console.warn in cli/src/lib/local.ts. In a normal successful hatch this means the new reusable API still pollutes stdout/stderr and the caller cannot observe all lifecycle output via LifecycleReporter, defeating the purpose of avoiding subprocess stdout scraping. Please thread the reporter through these helpers or otherwise suppress their console output when a custom reporter is provided.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and correct — hatchLocal's own progress/log lines now route through the reporter, but the nested startLocalDaemon/startGateway helpers in lib/local.ts still console.log/console.warn directly, so an in-process caller with a custom reporter won't yet see every line through it.

Deliberately scoping that out of this PR rather than fixing it here. Threading the reporter through local.ts touches shared infrastructure with several callers, and it's better landed alongside the first real in-process consumer (the Electron adapter) so it can be validated against an actual LifecycleReporter rather than plumbed speculatively. Captured this as the explicit next layer in the PR description's "Scope boundary / known follow-up" section.

For the CLI today it's a non-issue: the default consoleLifecycleReporter already routes everything to the console, so output is unchanged. Resolving this thread with that decision noted.

});
} 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);
Expand All @@ -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.`,
Expand All @@ -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);

Expand All @@ -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...`,
Expand All @@ -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`;
Expand All @@ -306,7 +344,7 @@ export async function hatchLocal(
let consecutiveFailures = 0;

const shutdown = async (): Promise<void> => {
console.log("\nShutting down local processes...");
reporter.log("\nShutting down local processes...");
await stopLocalProcesses(resources);
process.exit(0);
};
Expand All @@ -330,12 +368,14 @@ export async function hatchLocal(
consecutiveFailures++;
}
if (consecutiveFailures >= MAX_FAILURES) {
console.log(
reporter.log(
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
);
await stopLocalProcesses(resources);
process.exit(1);
}
}
}

return result;
}
31 changes: 31 additions & 0 deletions cli/src/lib/lifecycle-reporter.ts
Original file line number Diff line number Diff line change
@@ -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),
};
Loading