Daemon hardening epic

This commit is contained in:
Mohamed Boudra
2026-05-05 18:19:45 +07:00
parent 1571f002c7
commit 0cb9da17cd
27 changed files with 806 additions and 710 deletions

View File

@@ -1,6 +1,10 @@
# Ad-hoc daemon testing # Ad-hoc daemon testing
Spin up an isolated daemon programmatically without touching the main daemon on port 6767. Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
## Quick start ## Quick start
@@ -85,7 +89,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs await daemon.close(); // stops daemon + cleans up temp dirs
``` ```
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above). The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods ## Common client methods

View File

@@ -123,7 +123,7 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use) # Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \ makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \ --add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--set NODE_ENV production --set NODE_ENV production
# Create wrapper for the CLI # Create wrapper for the CLI

View File

@@ -573,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const tsxBin = execSync("which tsx").toString().trim(); const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation; const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["src/server/index.ts"], { const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir, cwd: serverDir,
env: { env: {
...process.env, ...process.env,

View File

@@ -0,0 +1,80 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242;
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
});

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env npx tsx
/**
* Regression: executable daemon launch commands must enter the supervisor.
* The worker entry remains an implementation detail of supervisor-entrypoint.
*/
import assert from "node:assert";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const repoRoot = join(import.meta.dirname, "../../..");
const serverPackagePath = join(repoRoot, "packages/server/package.json");
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
const serverConnectionOfferE2ePath = join(
repoRoot,
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
);
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
const nixPackagePath = join(repoRoot, "nix/package.nix");
function assertNoDirectWorkerLaunch(label: string, command: string): void {
assert(
!command.includes("src/server/index.ts"),
`${label} must not launch src/server/index.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/index.js"),
`${label} must not launch dist/server/server/index.js directly: ${command}`,
);
assert(
!command.includes("src/server/daemon-worker.ts"),
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/daemon-worker.js"),
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
);
}
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
assertNoDirectWorkerLaunch(label, source);
assert(
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
`${label} must not spawn ../index.ts directly`,
);
}
console.log("=== Daemon Launch Supervision Regression ===\n");
console.log("Test 1: server package scripts launch supervisor-entrypoint");
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
scripts?: Record<string, string>;
};
const startScript = serverPackage.scripts?.start ?? "";
const devScript = serverPackage.scripts?.dev ?? "";
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
assertNoDirectWorkerLaunch("server start script", startScript);
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
assertNoDirectWorkerLaunch("server dev script", devScript);
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
console.log("✓ server package scripts enter supervisor\n");
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
assert(
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
);
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
console.log("✓ app e2e setup enters supervisor\n");
console.log("Test 3: server daemon e2e process launch enters supervisor");
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
assert(
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
"server daemon e2e process launch should use supervisor-entrypoint.ts",
);
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
console.log("✓ server daemon e2e process launch enters supervisor\n");
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
assert(
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
);
assert(
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
);
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
const nixPackage = await readFile(nixPackagePath, "utf-8");
assert(
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
);
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
console.log("=== Daemon launch supervision regression test passed ===");

View File

@@ -1,198 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface ExitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
if (client) {
await client.close().catch(() => undefined);
return true;
}
if (Date.now() >= deadline) return false;
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid;
} catch {
return null;
}
}
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
daemonProcess.stdout?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
daemonProcess.stderr?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () =>
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
(await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined);
}
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(
exit.code,
0,
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
);
await waitFor(
async () => (await readPidLockPid(paseoHome)) === null,
15000,
"pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Unsupervised restart regression test passed ===");

View File

@@ -29,12 +29,12 @@
}, },
"scripts": { "scripts": {
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts", "dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' scripts/dev-runner.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"", "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build", "prepack": "npm run build",
"start": "node dist/server/server/index.js", "start": "node dist/scripts/supervisor-entrypoint.js",
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit", "typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts", "generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts", "speech:models": "tsx scripts/list-speech-models.ts",

View File

@@ -26,7 +26,10 @@ const supervisorArgs = [
const supervisor = spawn(process.execPath, supervisorArgs, { const supervisor = spawn(process.execPath, supervisorArgs, {
stdio: "inherit", stdio: "inherit",
env: process.env, env: {
...process.env,
PASEO_LOG_FORMAT: process.env.PASEO_LOG_FORMAT ?? "pretty",
},
}); });
function exitCodeForSignal(signal: NodeJS.Signals): number { function exitCodeForSignal(signal: NodeJS.Signals): number {

View File

@@ -15,6 +15,17 @@ describe("supervision parity", () => {
expect(daemonRunnerCalls + devRunnerCalls).toBe(1); expect(daemonRunnerCalls + devRunnerCalls).toBe(1);
}); });
test("supervisor worker implementation is not server/index", () => {
const daemonRunner = readFileSync(
new URL("./supervisor-entrypoint.ts", import.meta.url),
"utf8",
);
expect(daemonRunner).toContain("daemon-worker");
expect(daemonRunner).not.toContain("server/server/index.js");
expect(daemonRunner).not.toContain("src/server/index.ts");
});
test("dev runner waits asynchronously for supervisor shutdown", () => { test("dev runner waits asynchronously for supervisor shutdown", () => {
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8"); const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");

View File

@@ -8,9 +8,14 @@ import {
updatePidLock, updatePidLock,
} from "../src/server/pid-lock.js"; } from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig { interface DaemonRunnerConfig {
devMode: boolean; devMode: boolean;
workerArgs: string[]; workerArgs: string[];
@@ -33,10 +38,10 @@ function parseConfig(argv: string[]): DaemonRunnerConfig {
function resolveWorkerEntry(): string { function resolveWorkerEntry(): string {
const candidates = [ const candidates = [
fileURLToPath(new URL("../server/server/index.js", import.meta.url)), fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)), fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)), fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)), fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)),
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
@@ -49,7 +54,7 @@ function resolveWorkerEntry(): string {
} }
function resolveDevWorkerEntry(): string { function resolveDevWorkerEntry(): string {
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url)); const candidate = fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url));
if (!existsSync(candidate)) { if (!existsSync(candidate)) {
throw new Error(`Dev worker entry not found: ${candidate}`); throw new Error(`Dev worker entry not found: ${candidate}`);
} }
@@ -72,6 +77,28 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null; return existsSync(runnerPath) ? runnerPath : null;
} }
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> { async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2)); const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
@@ -85,6 +112,8 @@ async function main(): Promise<void> {
applySherpaLoaderEnv(workerEnv); applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv); const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
try { try {
await acquirePidLock(paseoHome, null, { await acquirePidLock(paseoHome, null, {
@@ -135,6 +164,7 @@ async function main(): Promise<void> {
}) })
: undefined, : undefined,
restartOnCrash: config.devMode, restartOnCrash: config.devMode,
logFile: supervisorLogFile,
onWorkerReady: async ({ listen }) => { onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
}, },

View File

@@ -0,0 +1,132 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
log: string;
stdout: string;
stderr: string;
}> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-log-"));
const logPath = path.join(tempDir, "daemon.log");
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(workerPath, options.workerSource);
await writeFile(
runnerPath,
`
import { runSupervisor } from ${JSON.stringify(pathToFileURL(supervisorPath).href)};
runSupervisor({
name: "TestSupervisor",
startupMessage: "starting fixture",
resolveWorkerEntry: () => ${JSON.stringify(workerPath)},
workerArgs: [],
workerEnv: process.env,
workerExecArgv: [],
restartOnCrash: ${JSON.stringify(options.restartOnCrash ?? false)},
logFile: {
path: ${JSON.stringify(logPath)},
rotate: { maxSize: "1m", maxFiles: 2 },
},
});
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const { code, signal } = await new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("supervisor fixture timed out"));
}, 10000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode, exitSignal) => {
clearTimeout(timeout);
resolve({ code: exitCode, signal: exitSignal });
});
});
const log = await readFile(logPath, "utf8");
return { code, signal, log, stdout, stderr };
}
describe("supervisor durable logging", () => {
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('{"level":30,"msg":"worker-json-stdout"}\\n');
process.stderr.write('{"level":50,"msg":"worker-json-stderr"}\\n');
process.exit(0);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"worker-json-stdout"');
expect(result.log).toContain('"worker-json-stderr"');
expect(result.stdout).toContain('"worker-json-stdout"');
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('raw stdout line\\n');
process.stderr.write('raw stderr line\\n');
process.exit(0);
`,
});
expect(result.log).toContain("raw stdout line\n");
expect(result.log).toContain("raw stderr line\n");
});
test("logs worker signal exits even when the worker cannot log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
});
});

View File

@@ -1,4 +1,15 @@
import { fork, spawn, type ChildProcess } from "child_process"; import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
interface SupervisorLogFileOptions {
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
}
type WorkerLifecycleMessage = type WorkerLifecycleMessage =
| { | {
@@ -28,6 +39,7 @@ interface SupervisorOptions {
onWorkerReady?: (message: { listen: string }) => Promise<void> | void; onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
restartOnCrash?: boolean; restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void; onSupervisorExit?: () => Promise<void> | void;
logFile?: SupervisorLogFileOptions;
} }
function describeExit(code: number | null, signal: NodeJS.Signals | null): string { function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
@@ -59,6 +71,31 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return null; return null;
} }
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
mkdirSync(path.dirname(options.path), { recursive: true });
return createRotatingFileStream(path.basename(options.path), {
path: path.dirname(options.path),
size: toRotatingFileStreamSize(options.rotate.maxSize),
maxFiles: options.rotate.maxFiles,
});
}
export function runSupervisor(options: SupervisorOptions): void { export function runSupervisor(options: SupervisorOptions): void {
const restartOnCrash = options.restartOnCrash ?? false; const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2); const workerArgs = options.workerArgs ?? process.argv.slice(2);
@@ -70,11 +107,39 @@ export function runSupervisor(options: SupervisorOptions): void {
let restarting = false; let restarting = false;
let shuttingDown = false; let shuttingDown = false;
let exiting = false; let exiting = false;
const logStream = createSupervisorLogStream(options.logFile);
const writeDurableChunk = (chunk: string | Buffer): void => {
logStream?.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
writeDurableChunk(
`${JSON.stringify({
level: "info",
time: new Date().toISOString(),
pid: process.pid,
name: options.name,
msg: message,
...fields,
})}\n`,
);
};
const log = (message: string): void => { const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`); process.stderr.write(`[${options.name}] ${message}\n`);
writeLifecycleLog(message);
}; };
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const exitSupervisor = (code: number): void => { const exitSupervisor = (code: number): void => {
if (exiting) { if (exiting) {
return; return;
@@ -85,6 +150,7 @@ export function runSupervisor(options: SupervisorOptions): void {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`); log(`Supervisor exit cleanup failed: ${message}`);
}) })
.then(closeLogStream)
.finally(() => { .finally(() => {
process.exit(code); process.exit(code);
}); });
@@ -103,19 +169,30 @@ export function runSupervisor(options: SupervisorOptions): void {
} }
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null; const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
writeLifecycleLog("Spawning worker", { workerEntry });
if (spawnSpec) { if (spawnSpec) {
child = spawn(spawnSpec.command, spawnSpec.args, { child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["inherit", "inherit", "inherit", "ipc"], stdio: ["inherit", "pipe", "pipe", "ipc"],
env: spawnSpec.env ?? workerEnv, env: spawnSpec.env ?? workerEnv,
}); });
} else { } else {
child = fork(workerEntry, workerArgs, { child = fork(workerEntry, workerArgs, {
stdio: "inherit", stdio: ["inherit", "pipe", "pipe", "ipc"],
env: workerEnv, env: workerEnv,
execArgv: workerExecArgv, execArgv: workerExecArgv,
}); });
} }
child.stdout?.on("data", (chunk: Buffer) => {
process.stdout.write(chunk);
writeDurableChunk(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
writeDurableChunk(chunk);
});
child.on("message", (msg: unknown) => { child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg); const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) { if (!lifecycleMessage) {
@@ -123,6 +200,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} }
if (lifecycleMessage.type === "paseo:ready") { if (lifecycleMessage.type === "paseo:ready") {
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
(error) => { (error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -133,15 +211,21 @@ export function runSupervisor(options: SupervisorOptions): void {
} }
if (lifecycleMessage.type === "paseo:shutdown") { if (lifecycleMessage.type === "paseo:shutdown") {
writeLifecycleLog("Worker requested shutdown");
requestShutdown("Shutdown requested by worker"); requestShutdown("Shutdown requested by worker");
return; return;
} }
writeLifecycleLog(
"Worker requested restart",
lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {},
);
requestRestart("Restart requested by worker"); requestRestart("Restart requested by worker");
}); });
child.on("exit", (code, signal) => { child.on("close", (code, signal) => {
const exitDescriptor = describeExit(code, signal); const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
if (shuttingDown) { if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
@@ -151,17 +235,21 @@ export function runSupervisor(options: SupervisorOptions): void {
const crashed = const crashed =
restartOnCrash && restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL")); ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) { if (restarting || crashed) {
restarting = false; restarting = false;
log(`Worker exited (${exitDescriptor}). Restarting worker...`); log(
crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`,
);
spawnWorker(); spawnWorker();
return; return;
} }
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 0); exitSupervisor(typeof code === "number" ? code : 1);
}); });
}; };
@@ -170,6 +258,7 @@ export function runSupervisor(options: SupervisorOptions): void {
return; return;
} }
restarting = true; restarting = true;
writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`); log(`${reason}. Stopping worker for restart...`);
child.kill("SIGTERM"); child.kill("SIGTERM");
}; };
@@ -180,6 +269,7 @@ export function runSupervisor(options: SupervisorOptions): void {
} }
shuttingDown = true; shuttingDown = true;
restarting = false; restarting = false;
writeLifecycleLog("Supervisor shutdown requested", { reason });
log(`${reason}. Stopping worker...`); log(`${reason}. Stopping worker...`);
if (!child) { if (!child) {
exitSupervisor(0); exitSupervisor(0);
@@ -196,5 +286,6 @@ export function runSupervisor(options: SupervisorOptions): void {
process.on("SIGTERM", () => forwardSignal("SIGTERM")); process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
writeLifecycleLog(options.startupMessage);
spawnWorker(); spawnWorker();
} }

View File

@@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js"; import { captureTerminalLines } from "../../terminal/terminal-capture.js";
import type { import type {
AgentWorktreeSetupContinuation, AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput, CreatePaseoWorktreeSetupContinuationInput,

View File

@@ -126,6 +126,7 @@ import type {
AgentProviderRuntimeSettingsMap, AgentProviderRuntimeSettingsMap,
ProviderOverride, ProviderOverride,
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import { import {
ScriptRouteStore, ScriptRouteStore,
createScriptProxyMiddleware, createScriptProxyMiddleware,
@@ -201,6 +202,7 @@ export interface PaseoDaemonConfig {
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap; agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>; providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
} }

View File

@@ -3,7 +3,12 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod"; import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js"; import type { PaseoDaemonConfig } from "./bootstrap.js";
import { loadPersistedConfig } from "./persisted-config.js"; import {
loadPersistedConfig,
LogFormatSchema,
LogLevelSchema,
type PersistedConfig,
} from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { import type {
AgentProviderRuntimeSettingsMap, AgentProviderRuntimeSettingsMap,
@@ -35,6 +40,14 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
return undefined; return undefined;
} }
function normalizeLogEnv(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
return value.trim().toLowerCase();
}
export type CliConfigOverrides = Partial<{ export type CliConfigOverrides = Partial<{
listen: string; listen: string;
relayEnabled: boolean; relayEnabled: boolean;
@@ -43,6 +56,24 @@ export type CliConfigOverrides = Partial<{
hostnames: HostnamesConfig; hostnames: HostnamesConfig;
}>; }>;
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
if (!envLogLevel.success && !envLogFormat.success) {
return persisted.log;
}
return {
...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
};
}
const OptionalVoiceLlmProviderSchema = z const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()]) .union([z.string(), z.null(), z.undefined()])
.transform((value): string | null => .transform((value): string | null =>
@@ -271,5 +302,6 @@ export function loadConfig(
voiceLlmModel: voiceLlm.model, voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides), agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides, providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
}; };
} }

View File

@@ -6,7 +6,6 @@ import os from "node:os";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream"; import { Writable } from "node:stream";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js"; import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -202,8 +201,9 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-")); const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-"));
const port = await getAvailablePort(); const port = await getAvailablePort();
const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url)); const serverRoot = path.resolve(import.meta.dirname, "../../..");
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx"); const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts");
const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx");
const env = { const env = {
...process.env, ...process.env,
@@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
}; };
const stdoutLines: string[] = []; const stdoutLines: string[] = [];
const proc = spawn(tsxBin, [indexPath, "--no-relay"], { const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], {
env, env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });

View File

@@ -2,7 +2,6 @@ import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js"; import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js"; import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js"; import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js"; import type { DaemonLifecycleIntent } from "./bootstrap.js";
@@ -24,9 +23,8 @@ interface BootstrapResult {
function bootstrapFromEnvironment(): BootstrapResult { function bootstrapFromEnvironment(): BootstrapResult {
try { try {
const paseoHome = resolvePaseoHome(); const paseoHome = resolvePaseoHome();
const persistedConfig = loadPersistedConfig(paseoHome);
const logger = createRootLogger(persistedConfig, { paseoHome });
const config = loadConfig(paseoHome); const config = loadConfig(paseoHome);
const logger = createRootLogger({ log: config.log }, { paseoHome, file: false });
return { paseoHome, logger, config }; return { paseoHome, logger, config };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);

View File

@@ -1,47 +1,82 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveLogConfig } from "./logger.js"; import { resolveLogConfig } from "./logger.js";
import { loadConfig } from "./config.js";
import type { PersistedConfig } from "./persisted-config.js"; import type { PersistedConfig } from "./persisted-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
const loggerModuleUrl = new URL("./logger.ts", import.meta.url).href;
async function runLoggerFixture(source: string): Promise<{ stdout: string; stderr: string }> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-logger-fixture-"));
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(
runnerPath,
`
import { createRootLogger } from ${JSON.stringify(loggerModuleUrl)};
${source}
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(code, stderr).toBe(0);
return { stdout, stderr };
}
describe("resolveLogConfig", () => { describe("resolveLogConfig", () => {
const originalEnv = process.env;
const paseoHome = "/tmp/paseo-logger-tests"; const paseoHome = "/tmp/paseo-logger-tests";
beforeEach(() => { it("defaults to stdout JSON without file logging", () => {
process.env = { ...originalEnv };
delete process.env.PASEO_LOG;
delete process.env.PASEO_LOG_FORMAT;
delete process.env.PASEO_LOG_CONSOLE_LEVEL;
delete process.env.PASEO_LOG_FILE_LEVEL;
delete process.env.PASEO_LOG_FILE_PATH;
delete process.env.PASEO_LOG_FILE_ROTATE_SIZE;
delete process.env.PASEO_LOG_FILE_ROTATE_COUNT;
});
afterEach(() => {
process.env = originalEnv;
});
it("returns dual-sink defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined, { paseoHome }); const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({ expect(result).toEqual({
level: "debug", level: "info",
console: { console: {
level: "info", level: "info",
format: "pretty", format: "json",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
}, },
}); });
}); });
it("uses config.json destination-specific values over defaults", () => { it("keeps legacy level and format as stdout configuration", () => {
const result = resolveLogConfig({ level: "warn", format: "pretty" }, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "pretty",
},
});
});
it("enables file output only when log.file is present", () => {
const config: PersistedConfig = { const config: PersistedConfig = {
log: { log: {
console: { console: {
@@ -50,7 +85,7 @@ describe("resolveLogConfig", () => {
}, },
file: { file: {
level: "debug", level: "debug",
path: "/tmp/custom.log", path: "logs/programmatic.log",
rotate: { rotate: {
maxSize: "25m", maxSize: "25m",
maxFiles: 5, maxFiles: 5,
@@ -58,9 +93,8 @@ describe("resolveLogConfig", () => {
}, },
}, },
}; };
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({ expect(resolveLogConfig(config, { paseoHome })).toEqual({
level: "debug", level: "debug",
console: { console: {
level: "warn", level: "warn",
@@ -68,166 +102,98 @@ describe("resolveLogConfig", () => {
}, },
file: { file: {
level: "debug", level: "debug",
path: "/tmp/custom.log", path: path.join(paseoHome, "logs", "programmatic.log"),
rotate: {
maxSize: "25m",
maxFiles: 5,
},
}, },
}); });
}); });
});
it("uses env vars over config.json values", () => {
process.env.PASEO_LOG_CONSOLE_LEVEL = "error"; describe("loadConfig logger config", () => {
process.env.PASEO_LOG_FILE_LEVEL = "fatal"; it("applies log format env at the config boundary", async () => {
process.env.PASEO_LOG_FORMAT = "json"; const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-"));
process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log"; const paseoHome = path.join(root, ".paseo");
process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m"; await mkdir(paseoHome, { recursive: true });
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4"; await writeFile(
path.join(paseoHome, "config.json"),
const config: PersistedConfig = { JSON.stringify({ version: 1, log: { format: "json" } }),
log: { );
console: {
level: "info", const config = loadConfig(paseoHome, {
format: "pretty", env: { PASEO_LOG_FORMAT: "pretty" },
}, });
file: {
level: "trace", expect(config.log?.format).toBe("pretty");
path: "/tmp/will-be-overridden.log", expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty");
rotate: { });
maxSize: "30m", });
maxFiles: 8,
}, describe("createRootLogger", () => {
}, it("writes JSON to stdout by default and does not initialize file logging", async () => {
}, const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
}; const missingLogDir = path.join(paseoHome, "logs");
const result = resolveLogConfig(config, { paseoHome }); const { stdout } = await runLoggerFixture(`
expect(result).toEqual({ const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
level: "error", logger.info({ proof: "stdout-default" }, "default logger");
console: { logger.flush();
level: "error", `);
format: "json",
}, expect(stdout).toContain('"proof":"stdout-default"');
file: { expect(stdout).toContain('"msg":"default logger"');
level: "fatal", expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false);
path: path.resolve(paseoHome, "logs/daemon-custom.log"), expect(existsSync(missingLogDir)).toBe(false);
rotate: { });
maxSize: "15m",
maxFiles: 4, it("writes to an explicit file target without creating rotation files", async () => {
}, const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-file-"));
}, const logPath = path.join(paseoHome, "logs", "programmatic.log");
});
}); await runLoggerFixture(`
const logger = createRootLogger(
it("keeps backwards compatibility for legacy log.level and log.format", () => { { log: { file: { path: ${JSON.stringify(logPath)} } } },
const config: PersistedConfig = { { paseoHome: ${JSON.stringify(paseoHome)} },
log: { );
level: "warn", logger.info({ proof: "file-explicit" }, "explicit file logger");
format: "json", logger.flush();
}, `);
};
const logText = await readFile(logPath, "utf8");
const result = resolveLogConfig(config, { paseoHome }); const files = await readdir(path.dirname(logPath));
expect(result).toEqual({
level: "warn", expect(logText).toContain('"proof":"file-explicit"');
console: { expect(logText).toContain('"msg":"explicit file logger"');
level: "warn", expect(files).toEqual(["programmatic.log"]);
format: "json", });
},
file: { it("can disable file output for supervised workers", async () => {
level: "warn", const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-"));
path: path.join(paseoHome, "daemon.log"), const logPath = path.join(paseoHome, "daemon.log");
rotate: {
maxSize: "10m", const { stdout } = await runLoggerFixture(`
maxFiles: 2, const logger = createRootLogger(
}, { log: { file: { path: ${JSON.stringify(logPath)} } } },
}, { paseoHome: ${JSON.stringify(paseoHome)}, file: false },
}); );
}); logger.info({ proof: "stdout-only" }, "worker logger");
logger.flush();
it("keeps backwards compatibility for legacy env vars", () => { `);
process.env.PASEO_LOG = "error";
process.env.PASEO_LOG_FORMAT = "json"; expect(stdout).toContain('"proof":"stdout-only"');
expect(stdout).toContain('"msg":"worker logger"');
const result = resolveLogConfig(undefined, { paseoHome }); expect(existsSync(logPath)).toBe(false);
expect(result).toEqual({ });
level: "error",
console: { it("keeps pretty output available as a format choice", async () => {
level: "error", const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-"));
format: "json",
}, const { stdout } = await runLoggerFixture(`
file: { const logger = createRootLogger({ level: "info", format: "pretty" }, {
level: "error", paseoHome: ${JSON.stringify(paseoHome)},
path: path.join(paseoHome, "daemon.log"), });
rotate: { logger.info("pretty logger");
maxSize: "10m", logger.flush();
maxFiles: 2, `);
},
}, expect(stdout).toContain("pretty logger");
}); });
});
it("supports partial destination config and retains defaults", () => {
const config: PersistedConfig = {
log: {
console: {
level: "warn",
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("ignores invalid rotate count env var and falls back to config value", () => {
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0";
const config: PersistedConfig = {
log: {
file: {
rotate: {
maxFiles: 7,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result.file.rotate.maxFiles).toBe(7);
});
it("supports all log levels for destination-specific env vars", () => {
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
];
for (const level of levels) {
process.env.PASEO_LOG_CONSOLE_LEVEL = level;
process.env.PASEO_LOG_FILE_LEVEL = level;
const result = resolveLogConfig(undefined, { paseoHome });
expect(result.console.level).toBe(level);
expect(result.file.level).toBe(level);
expect(result.level).toBe(level);
}
});
}); });

View File

@@ -1,8 +1,7 @@
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs"; import { mkdirSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import pretty from "pino-pretty"; import pretty from "pino-pretty";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import type { PersistedConfig } from "./persisted-config.js"; import type { PersistedConfig } from "./persisted-config.js";
import { resolvePaseoHome } from "./paseo-home.js"; import { resolvePaseoHome } from "./paseo-home.js";
@@ -15,13 +14,9 @@ export interface ResolvedLogConfig {
level: LogLevel; level: LogLevel;
format: LogFormat; format: LogFormat;
}; };
file: { file?: {
level: LogLevel; level: LogLevel;
path: string; path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
}; };
} }
@@ -34,11 +29,9 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
interface ResolveLogConfigOptions { interface ResolveLogConfigOptions {
paseoHome?: string; paseoHome?: string;
env?: NodeJS.ProcessEnv; file?: boolean;
} }
const LOG_LEVELS: Set<LogLevel> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
const LOG_FORMATS: Set<LogFormat> = new Set(["pretty", "json"]);
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = { const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
trace: 10, trace: 10,
debug: 20, debug: 20,
@@ -49,10 +42,8 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
}; };
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info"; const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty"; const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
const DEFAULT_FILE_LEVEL: LogLevel = "debug"; const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [ const REDACT_PATHS = [
"authorization", "authorization",
@@ -69,33 +60,6 @@ const REDACT_PATHS = [
"req.headers.Sec-WebSocket-Protocol", "req.headers.Sec-WebSocket-Protocol",
]; ];
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
return undefined;
}
return value as LogLevel;
}
function parseLogFormat(value: string | undefined): LogFormat | undefined {
if (!value || !LOG_FORMATS.has(value as LogFormat)) {
return undefined;
}
return value as LogFormat;
}
function parsePositiveInteger(value: string | undefined): number | undefined {
if (!value || value.trim().length === 0) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string { function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string {
const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME); const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (!configuredPath) { if (!configuredPath) {
@@ -125,7 +89,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined
if (options?.paseoHome) { if (options?.paseoHome) {
return options.paseoHome; return options.paseoHome;
} }
return resolvePaseoHome(options?.env ?? process.env); return resolvePaseoHome();
} }
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined { function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
@@ -150,126 +114,50 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig
return config as PersistedConfig; return config as PersistedConfig;
} }
function rotateOnRestart(filePath: string, maxFiles: number): void {
if (!existsSync(filePath)) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
try {
renameSync(filePath, path.join(dir, `${ts}-00-${base}`));
} catch {
return;
}
// Clean up old rotated logs beyond maxFiles.
// Both our restart-rotated files (YYYYMMDD-HHMM-00-daemon.log) and
// rotating-file-stream's size-rotated files (YYYYMMDD-HHMM-NN-daemon.log)
// end with -${base} and sort chronologically by name.
const rotatedFiles = readdirSync(dir)
.filter((f) => f.endsWith(`-${base}`) && f !== base)
.sort()
.toReversed();
for (const file of rotatedFiles.slice(maxFiles)) {
try {
unlinkSync(path.join(dir, file));
} catch {}
}
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
interface LogLevelResolution { interface LogLevelResolution {
consoleLevel: LogLevel; consoleLevel: LogLevel;
fileLevel: LogLevel; fileLevel?: LogLevel;
consoleFormat: LogFormat; consoleFormat: LogFormat;
} }
function resolveLogLevelsAndFormat( function resolveLogLevelsAndFormat(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined, persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): LogLevelResolution { ): LogLevelResolution {
const envGlobalLevel = parseLogLevel(env.PASEO_LOG);
const persistedGlobalLevel = persistedLog?.level; const persistedGlobalLevel = persistedLog?.level;
const consoleLevel: LogLevel = const consoleLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ?? persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL;
envGlobalLevel ?? const fileLevel = persistedLog?.file
persistedLog?.console?.level ?? ? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL)
persistedGlobalLevel ?? : undefined;
DEFAULT_CONSOLE_LEVEL;
const fileLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
envGlobalLevel ??
persistedLog?.file?.level ??
persistedGlobalLevel ??
DEFAULT_FILE_LEVEL;
const consoleFormat: LogFormat = const consoleFormat: LogFormat =
parseLogFormat(env.PASEO_LOG_FORMAT) ?? persistedLog?.console?.format ?? persistedLog?.format ?? DEFAULT_CONSOLE_FORMAT;
persistedLog?.console?.format ??
persistedLog?.format ??
DEFAULT_CONSOLE_FORMAT;
return { consoleLevel, fileLevel, consoleFormat }; return { consoleLevel, fileLevel, consoleFormat };
} }
interface RotateResolution {
maxSize: string;
maxFiles: number;
}
function resolveRotateConfig(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): RotateResolution {
return {
maxSize:
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
persistedLog?.file?.rotate?.maxSize ||
DEFAULT_FILE_ROTATE_SIZE,
maxFiles:
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
persistedLog?.file?.rotate?.maxFiles ??
DEFAULT_FILE_ROTATE_MAX_FILES,
};
}
export function resolveLogConfig( export function resolveLogConfig(
configInput: LoggerConfigInput, configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions, options?: ResolveLogConfigOptions,
): ResolvedLogConfig { ): ResolvedLogConfig {
const persistedConfig = normalizeLoggerConfigInput(configInput); const persistedConfig = normalizeLoggerConfigInput(configInput);
const env = options?.env ?? process.env;
const paseoHome = resolveConfiguredPaseoHome(options); const paseoHome = resolveConfiguredPaseoHome(options);
const persistedLog = persistedConfig?.log; const persistedLog = persistedConfig?.log;
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog); const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog);
const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path); const file =
const rotate = resolveRotateConfig(env, persistedLog); options?.file !== false && persistedLog?.file
? {
level: fileLevel ?? DEFAULT_FILE_LEVEL,
path: resolveFilePath(paseoHome, persistedLog.file.path),
}
: undefined;
return { return {
level: minLogLevel([consoleLevel, fileLevel]), level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]),
console: { console: {
level: consoleLevel, level: consoleLevel,
format: consoleFormat, format: consoleFormat,
}, },
file: { ...(file ? { file } : {}),
level: fileLevel,
path: filePath,
rotate,
},
}; };
} }
@@ -278,32 +166,26 @@ export function createRootLogger(
options?: ResolveLogConfigOptions, options?: ResolveLogConfigOptions,
): pino.Logger { ): pino.Logger {
const config = resolveLogConfig(configInput, options); const config = resolveLogConfig(configInput, options);
if (config.file) {
mkdirSync(path.dirname(config.file.path), { recursive: true });
}
mkdirSync(path.dirname(config.file.path), { recursive: true }); const stream =
const consoleStream =
config.console.format === "pretty" config.console.format === "pretty"
? pretty({ ? pretty({
colorize: true, colorize: true,
singleLine: true, singleLine: true,
ignore: "pid,hostname", ignore: "pid,hostname",
destination: config.file?.path ?? 1,
}) })
: pino.destination({ dest: 1, sync: false }); : pino.destination({ dest: config.file?.path ?? 1, sync: false });
rotateOnRestart(config.file.path, config.file.rotate.maxFiles);
const fileStream = createRotatingFileStream(path.basename(config.file.path), {
path: path.dirname(config.file.path),
size: toRotatingFileStreamSize(config.file.rotate.maxSize),
maxFiles: config.file.rotate.maxFiles,
});
return pino( return pino(
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } }, {
pino.multistream([ level: config.file?.level ?? config.console.level,
{ level: config.console.level, stream: consoleStream }, redact: { paths: REDACT_PATHS, remove: true },
{ level: config.file.level, stream: fileStream }, },
]), stream,
); );
} }

View File

@@ -9,8 +9,8 @@ import {
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]); export const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z const LogConfigSchema = z
.object({ .object({

View File

@@ -0,0 +1,71 @@
import stripAnsi from "strip-ansi";
import type { TerminalCell } from "../shared/messages.js";
import type { TerminalSession } from "./terminal.js";
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}

View File

@@ -1,10 +0,0 @@
import { expect, it } from "vitest";
import { resolveTerminalBackend } from "./terminal-manager-factory.js";
it("uses the worker terminal backend by default", () => {
expect(resolveTerminalBackend({})).toBe("worker");
});
it("allows explicitly opting back into the in-process terminal backend", () => {
expect(resolveTerminalBackend({ PASEO_TERMINAL_BACKEND: "in-process" })).toBe("in-process");
});

View File

@@ -1,18 +1,6 @@
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; import type { TerminalManager } from "./terminal-manager.js";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
export type TerminalBackend = "in-process" | "worker"; export function createConfiguredTerminalManager(): TerminalManager {
return createWorkerTerminalManager();
export function resolveTerminalBackend(env: NodeJS.ProcessEnv = process.env): TerminalBackend {
return env.PASEO_TERMINAL_BACKEND === "in-process" ? "in-process" : "worker";
}
export function createConfiguredTerminalManager(options?: {
backend?: TerminalBackend;
}): TerminalManager {
const backend = options?.backend ?? resolveTerminalBackend();
if (backend === "worker") {
return createWorkerTerminalManager();
}
return createTerminalManager();
} }

View File

@@ -1,5 +1,5 @@
import { createTerminalManager } from "./terminal-manager.js"; import { createTerminalManager } from "./terminal-manager.js";
import { captureTerminalLines } from "./terminal.js"; import { captureTerminalLines } from "./terminal-capture.js";
import type { TerminalSession } from "./terminal.js"; import type { TerminalSession } from "./terminal.js";
import type { import type {
TerminalWorkerRequest, TerminalWorkerRequest,

View File

@@ -5,7 +5,7 @@ import type {
TerminalStateSnapshot, TerminalStateSnapshot,
} from "./terminal.js"; } from "./terminal.js";
import type { TerminalState } from "../shared/messages.js"; import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
export interface WorkerTerminalInfo { export interface WorkerTerminalInfo {
id: string; id: string;

View File

@@ -6,9 +6,13 @@ import { tmpdir, userInfo } from "node:os";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import { createExternalProcessEnv } from "../server/paseo-env.js"; import { createExternalProcessEnv } from "../server/paseo-env.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js"; import type { TerminalCell, TerminalState } from "../shared/messages.js";
export {
captureTerminalLines,
type CaptureTerminalLinesOptions,
type CaptureTerminalLinesResult,
} from "./terminal-capture.js";
const { Terminal } = xterm; const { Terminal } = xterm;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -96,17 +100,6 @@ interface BuildTerminalEnvironmentInput {
zshShellIntegrationDir?: string; zshShellIntegrationDir?: string;
} }
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
interface EnsureNodePtySpawnHelperExecutableOptions { interface EnsureNodePtySpawnHelperExecutableOptions {
packageRoot?: string; packageRoot?: string;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
@@ -523,63 +516,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit); return lines.slice(-limit);
} }
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> { export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const { const {
cwd, cwd,

View File

@@ -5,7 +5,6 @@ import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net"; import net from "node:net";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js"; import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js"; import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
@@ -30,7 +29,6 @@ import { runGitCommand } from "./run-git-command.js";
import { spawnProcess } from "./spawn.js"; import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js"; import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js"; import { createExternalProcessEnv } from "../server/paseo-env.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -465,54 +463,29 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd, cwd: options.cwd,
}); });
const spawnWithPipes = () => { const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, { cwd: options.cwd,
cwd: options.cwd, env: options.env,
env: options.env, stdio: ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"], });
});
child.stdout?.on("data", (chunk: Buffer | string) => { child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString()); emitOutput("stdout", chunk.toString());
}); });
child.stderr?.on("data", (chunk: Buffer | string) => { child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString()); emitOutput("stderr", chunk.toString());
}); });
child.on("error", (error) => { child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
};
try {
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
name: "xterm-color",
cols: 120,
rows: 30,
});
terminal.onData((data) => {
emitOutput("stdout", data);
});
terminal.onExit(({ exitCode }) => {
finish(typeof exitCode === "number" ? exitCode : null);
});
} catch (error) {
emitOutput("stderr", error instanceof Error ? error.message : String(error)); emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes(); finish(null);
} });
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
}); });
} }