diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 758ee3f6d..4a2d1bef9 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -1,6 +1,10 @@ # 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 @@ -85,7 +89,7 @@ await client.close(); 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 diff --git a/nix/package.nix b/nix/package.nix index b3c2bc07f..d0343c119 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -123,7 +123,7 @@ buildNpmPackage rec { # Create wrapper for the server entry point (for systemd / direct use) mkdir -p $out/bin 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 # Create wrapper for the CLI diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index ca192fbba..22a8fdba6 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -573,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess { const tsxBin = execSync("which tsx").toString().trim(); const { openAiUsable, localModelsDir } = args.dictation; - const child = spawn(tsxBin, ["src/server/index.ts"], { + const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { cwd: serverDir, env: { ...process.env, diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts new file mode 100644 index 000000000..f6da53c79 --- /dev/null +++ b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts @@ -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("node:child_process"); + return { + ...actual, + spawnSync: mocks.spawnSync, + }; +}); + +vi.mock("@getpaseo/server", async () => { + const actual = await vi.importActual("@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"); + }); +}); diff --git a/packages/cli/tests/26-daemon-launch-supervision.test.ts b/packages/cli/tests/26-daemon-launch-supervision.test.ts new file mode 100644 index 000000000..e0e00bd00 --- /dev/null +++ b/packages/cli/tests/26-daemon-launch-supervision.test.ts @@ -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; +}; +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 ==="); diff --git a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts b/packages/cli/tests/26-daemon-restart-unsupervised.test.ts deleted file mode 100644 index 9cc2e847c..000000000 --- a/packages/cli/tests/26-daemon-restart-unsupervised.test.ts +++ /dev/null @@ -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 { - 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, - timeoutMs: number, - message: string, -): Promise { - const deadline = Date.now() + timeoutMs; - - async function poll(): Promise { - 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 { - 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 { - const deadline = Date.now() + timeoutMs; - - async function poll(): Promise { - 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 { - 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 ==="); diff --git a/packages/server/package.json b/packages/server/package.json index a215881ca..0cc1068f8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,12 +29,12 @@ }, "scripts": { "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: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');\"", "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", "generate:config-schema": "tsx scripts/generate-config-schema.ts", "speech:models": "tsx scripts/list-speech-models.ts", diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index e47462e18..6cef37140 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -26,7 +26,10 @@ const supervisorArgs = [ const supervisor = spawn(process.execPath, supervisorArgs, { stdio: "inherit", - env: process.env, + env: { + ...process.env, + PASEO_LOG_FORMAT: process.env.PASEO_LOG_FORMAT ?? "pretty", + }, }); function exitCodeForSignal(signal: NodeJS.Signals): number { diff --git a/packages/server/scripts/supervision-parity.test.ts b/packages/server/scripts/supervision-parity.test.ts index 5b80aeb4b..960fca1ef 100644 --- a/packages/server/scripts/supervision-parity.test.ts +++ b/packages/server/scripts/supervision-parity.test.ts @@ -15,6 +15,17 @@ describe("supervision parity", () => { 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", () => { const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8"); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index feea4c6fc..23637c476 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -8,9 +8,14 @@ import { updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; +import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { runSupervisor } from "./supervisor.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 { devMode: boolean; workerArgs: string[]; @@ -33,10 +38,10 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { function resolveWorkerEntry(): string { const candidates = [ - fileURLToPath(new URL("../server/server/index.js", import.meta.url)), - fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)), - fileURLToPath(new URL("../src/server/index.ts", import.meta.url)), - fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)), + fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)), + fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)), + fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)), + fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)), ]; for (const candidate of candidates) { @@ -49,7 +54,7 @@ function resolveWorkerEntry(): 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)) { throw new Error(`Dev worker entry not found: ${candidate}`); } @@ -72,6 +77,28 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str return existsSync(runnerPath) ? runnerPath : null; } +function resolveSupervisorLogFile( + paseoHome: string, + persistedConfig: ReturnType, +) { + 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 { const config = parseConfig(process.argv.slice(2)); const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry(); @@ -85,6 +112,8 @@ async function main(): Promise { applySherpaLoaderEnv(workerEnv); const paseoHome = resolvePaseoHome(workerEnv); + const persistedConfig = loadPersistedConfig(paseoHome); + const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig); try { await acquirePidLock(paseoHome, null, { @@ -135,6 +164,7 @@ async function main(): Promise { }) : undefined, restartOnCrash: config.devMode, + logFile: supervisorLogFile, onWorkerReady: async ({ listen }) => { await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); }, diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts new file mode 100644 index 000000000..45b3720e4 --- /dev/null +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -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"); + }); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index d37712ff3..a50643e5a 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -1,4 +1,15 @@ 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 = | { @@ -28,6 +39,7 @@ interface SupervisorOptions { onWorkerReady?: (message: { listen: string }) => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; + logFile?: SupervisorLogFileOptions; } function describeExit(code: number | null, signal: NodeJS.Signals | null): string { @@ -59,6 +71,31 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | 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 { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); @@ -70,11 +107,39 @@ export function runSupervisor(options: SupervisorOptions): void { let restarting = false; let shuttingDown = false; let exiting = false; + const logStream = createSupervisorLogStream(options.logFile); + + const writeDurableChunk = (chunk: string | Buffer): void => { + logStream?.write(chunk); + }; + + const writeLifecycleLog = (message: string, fields: Record = {}): 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 => { process.stderr.write(`[${options.name}] ${message}\n`); + writeLifecycleLog(message); }; + const closeLogStream = (): Promise => + new Promise((resolve) => { + if (!logStream) { + resolve(); + return; + } + logStream.end(resolve); + }); + const exitSupervisor = (code: number): void => { if (exiting) { return; @@ -85,6 +150,7 @@ export function runSupervisor(options: SupervisorOptions): void { const message = error instanceof Error ? error.message : String(error); log(`Supervisor exit cleanup failed: ${message}`); }) + .then(closeLogStream) .finally(() => { process.exit(code); }); @@ -103,19 +169,30 @@ export function runSupervisor(options: SupervisorOptions): void { } const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null; + writeLifecycleLog("Spawning worker", { workerEntry }); if (spawnSpec) { child = spawn(spawnSpec.command, spawnSpec.args, { - stdio: ["inherit", "inherit", "inherit", "ipc"], + stdio: ["inherit", "pipe", "pipe", "ipc"], env: spawnSpec.env ?? workerEnv, }); } else { child = fork(workerEntry, workerArgs, { - stdio: "inherit", + stdio: ["inherit", "pipe", "pipe", "ipc"], env: workerEnv, 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) => { const lifecycleMessage = parseLifecycleMessage(msg); if (!lifecycleMessage) { @@ -123,6 +200,7 @@ export function runSupervisor(options: SupervisorOptions): void { } if (lifecycleMessage.type === "paseo:ready") { + writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( (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") { + writeLifecycleLog("Worker requested shutdown"); requestShutdown("Shutdown requested by worker"); return; } + writeLifecycleLog( + "Worker requested restart", + lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {}, + ); requestRestart("Restart requested by worker"); }); - child.on("exit", (code, signal) => { + child.on("close", (code, signal) => { const exitDescriptor = describeExit(code, signal); + writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); if (shuttingDown) { log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); @@ -151,17 +235,21 @@ export function runSupervisor(options: SupervisorOptions): void { const crashed = restartOnCrash && - ((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL")); + ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); if (restarting || crashed) { restarting = false; - log(`Worker exited (${exitDescriptor}). Restarting worker...`); + log( + crashed + ? `Worker crashed (${exitDescriptor}). Restarting worker...` + : `Worker exited (${exitDescriptor}). Restarting worker...`, + ); spawnWorker(); return; } 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; } restarting = true; + writeLifecycleLog("Restart requested", { reason }); log(`${reason}. Stopping worker for restart...`); child.kill("SIGTERM"); }; @@ -180,6 +269,7 @@ export function runSupervisor(options: SupervisorOptions): void { } shuttingDown = true; restarting = false; + writeLifecycleLog("Supervisor shutdown requested", { reason }); log(`${reason}. Stopping worker...`); if (!child) { exitSupervisor(0); @@ -196,5 +286,6 @@ export function runSupervisor(options: SupervisorOptions): void { process.on("SIGTERM", () => forwardSignal("SIGTERM")); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); + writeLifecycleLog(options.startupMessage); spawnWorker(); } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 58fbb4647..57d774f3e 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import { captureTerminalLines } from "../../terminal/terminal.js"; +import { captureTerminalLines } from "../../terminal/terminal-capture.js"; import type { AgentWorktreeSetupContinuation, CreatePaseoWorktreeSetupContinuationInput, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 03fd6282d..021adfcf5 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; +import type { PersistedConfig } from "./persisted-config.js"; import { ScriptRouteStore, createScriptProxyMiddleware, @@ -201,6 +202,7 @@ export interface PaseoDaemonConfig { downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; + log?: PersistedConfig["log"]; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index eda33690f..3bd4b52a8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -3,7 +3,12 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js"; import { z } from "zod"; 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 { AgentProviderRuntimeSettingsMap, @@ -35,6 +40,14 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } +function normalizeLogEnv(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + return value.trim().toLowerCase(); +} + export type CliConfigOverrides = Partial<{ listen: string; relayEnabled: boolean; @@ -43,6 +56,24 @@ export type CliConfigOverrides = Partial<{ hostnames: HostnamesConfig; }>; +function resolveLogConfigFromEnv( + env: NodeJS.ProcessEnv, + persisted: ReturnType, +): 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 .union([z.string(), z.null(), z.undefined()]) .transform((value): string | null => @@ -271,5 +302,6 @@ export function loadConfig( voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), providerOverrides, + log: resolveLogConfigFromEnv(env, persisted), }; } diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 3ce0aa71d..7e4e3d166 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -6,7 +6,6 @@ import os from "node:os"; import { mkdtemp, rm } from "node:fs/promises"; import { Writable } from "node:stream"; import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; import { generateLocalPairingOffer } from "../pairing-offer.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 port = await getAvailablePort(); - const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url)); - const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx"); + const serverRoot = path.resolve(import.meta.dirname, "../../.."); + const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts"); + const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx"); const env = { ...process.env, @@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { }; const stdoutLines: string[] = []; - const proc = spawn(tsxBin, [indexPath, "--no-relay"], { + const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], { env, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/daemon-worker.ts similarity index 97% rename from packages/server/src/server/index.ts rename to packages/server/src/server/daemon-worker.ts index 5358428ad..5d87ccec2 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -2,7 +2,6 @@ import { createPaseoDaemon } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; import { createRootLogger } from "./logger.js"; -import { loadPersistedConfig } from "./persisted-config.js"; import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js"; import type { DaemonLifecycleIntent } from "./bootstrap.js"; @@ -24,9 +23,8 @@ interface BootstrapResult { function bootstrapFromEnvironment(): BootstrapResult { try { const paseoHome = resolvePaseoHome(); - const persistedConfig = loadPersistedConfig(paseoHome); - const logger = createRootLogger(persistedConfig, { paseoHome }); const config = loadConfig(paseoHome); + const logger = createRootLogger({ log: config.log }, { paseoHome, file: false }); return { paseoHome, logger, config }; } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/server/src/server/logger.test.ts b/packages/server/src/server/logger.test.ts index 2de8ba1a5..1044fda1d 100644 --- a/packages/server/src/server/logger.test.ts +++ b/packages/server/src/server/logger.test.ts @@ -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 { 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 { loadConfig } from "./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((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + + expect(code, stderr).toBe(0); + return { stdout, stderr }; +} + describe("resolveLogConfig", () => { - const originalEnv = process.env; const paseoHome = "/tmp/paseo-logger-tests"; - beforeEach(() => { - 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", () => { + it("defaults to stdout JSON without file logging", () => { const result = resolveLogConfig(undefined, { paseoHome }); + expect(result).toEqual({ - level: "debug", + level: "info", console: { level: "info", - format: "pretty", - }, - file: { - level: "debug", - path: path.join(paseoHome, "daemon.log"), - rotate: { - maxSize: "10m", - maxFiles: 2, - }, + format: "json", }, }); }); - 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 = { log: { console: { @@ -50,7 +85,7 @@ describe("resolveLogConfig", () => { }, file: { level: "debug", - path: "/tmp/custom.log", + path: "logs/programmatic.log", rotate: { maxSize: "25m", maxFiles: 5, @@ -58,9 +93,8 @@ describe("resolveLogConfig", () => { }, }, }; - const result = resolveLogConfig(config, { paseoHome }); - expect(result).toEqual({ + expect(resolveLogConfig(config, { paseoHome })).toEqual({ level: "debug", console: { level: "warn", @@ -68,166 +102,98 @@ describe("resolveLogConfig", () => { }, file: { level: "debug", - path: "/tmp/custom.log", - rotate: { - maxSize: "25m", - maxFiles: 5, - }, + path: path.join(paseoHome, "logs", "programmatic.log"), }, }); }); - - it("uses env vars over config.json values", () => { - process.env.PASEO_LOG_CONSOLE_LEVEL = "error"; - process.env.PASEO_LOG_FILE_LEVEL = "fatal"; - process.env.PASEO_LOG_FORMAT = "json"; - process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log"; - process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m"; - process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4"; - - const config: PersistedConfig = { - log: { - console: { - level: "info", - format: "pretty", - }, - file: { - level: "trace", - path: "/tmp/will-be-overridden.log", - rotate: { - maxSize: "30m", - maxFiles: 8, - }, - }, - }, - }; - - const result = resolveLogConfig(config, { paseoHome }); - expect(result).toEqual({ - level: "error", - console: { - level: "error", - format: "json", - }, - file: { - level: "fatal", - path: path.resolve(paseoHome, "logs/daemon-custom.log"), - rotate: { - maxSize: "15m", - maxFiles: 4, - }, - }, - }); - }); - - it("keeps backwards compatibility for legacy log.level and log.format", () => { - const config: PersistedConfig = { - log: { - level: "warn", - format: "json", - }, - }; - - const result = resolveLogConfig(config, { paseoHome }); - expect(result).toEqual({ - level: "warn", - console: { - level: "warn", - format: "json", - }, - file: { - level: "warn", - path: path.join(paseoHome, "daemon.log"), - rotate: { - maxSize: "10m", - maxFiles: 2, - }, - }, - }); - }); - - it("keeps backwards compatibility for legacy env vars", () => { - process.env.PASEO_LOG = "error"; - process.env.PASEO_LOG_FORMAT = "json"; - - const result = resolveLogConfig(undefined, { paseoHome }); - expect(result).toEqual({ - level: "error", - console: { - level: "error", - format: "json", - }, - file: { - level: "error", - path: path.join(paseoHome, "daemon.log"), - rotate: { - maxSize: "10m", - maxFiles: 2, - }, - }, - }); - }); - - 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); - } - }); +}); + +describe("loadConfig logger config", () => { + it("applies log format env at the config boundary", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-")); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ version: 1, log: { format: "json" } }), + ); + + const config = loadConfig(paseoHome, { + env: { PASEO_LOG_FORMAT: "pretty" }, + }); + + expect(config.log?.format).toBe("pretty"); + expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty"); + }); +}); + +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 { stdout } = await runLoggerFixture(` + const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} }); + logger.info({ proof: "stdout-default" }, "default logger"); + logger.flush(); + `); + + expect(stdout).toContain('"proof":"stdout-default"'); + expect(stdout).toContain('"msg":"default logger"'); + expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false); + expect(existsSync(missingLogDir)).toBe(false); + }); + + 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( + { log: { file: { path: ${JSON.stringify(logPath)} } } }, + { paseoHome: ${JSON.stringify(paseoHome)} }, + ); + logger.info({ proof: "file-explicit" }, "explicit file logger"); + logger.flush(); + `); + + const logText = await readFile(logPath, "utf8"); + const files = await readdir(path.dirname(logPath)); + + expect(logText).toContain('"proof":"file-explicit"'); + expect(logText).toContain('"msg":"explicit file logger"'); + expect(files).toEqual(["programmatic.log"]); + }); + + it("can disable file output for supervised workers", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-")); + const logPath = path.join(paseoHome, "daemon.log"); + + const { stdout } = await runLoggerFixture(` + const logger = createRootLogger( + { log: { file: { path: ${JSON.stringify(logPath)} } } }, + { paseoHome: ${JSON.stringify(paseoHome)}, file: false }, + ); + logger.info({ proof: "stdout-only" }, "worker logger"); + logger.flush(); + `); + + expect(stdout).toContain('"proof":"stdout-only"'); + expect(stdout).toContain('"msg":"worker logger"'); + expect(existsSync(logPath)).toBe(false); + }); + + it("keeps pretty output available as a format choice", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-")); + + const { stdout } = await runLoggerFixture(` + const logger = createRootLogger({ level: "info", format: "pretty" }, { + paseoHome: ${JSON.stringify(paseoHome)}, + }); + logger.info("pretty logger"); + logger.flush(); + `); + + expect(stdout).toContain("pretty logger"); + }); }); diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index 2c303ca4a..024aa5280 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -1,8 +1,7 @@ -import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import path from "node:path"; import pino from "pino"; import pretty from "pino-pretty"; -import { createStream as createRotatingFileStream } from "rotating-file-stream"; import type { PersistedConfig } from "./persisted-config.js"; import { resolvePaseoHome } from "./paseo-home.js"; @@ -15,13 +14,9 @@ export interface ResolvedLogConfig { level: LogLevel; format: LogFormat; }; - file: { + file?: { level: LogLevel; path: string; - rotate: { - maxSize: string; - maxFiles: number; - }; }; } @@ -34,11 +29,9 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined; interface ResolveLogConfigOptions { paseoHome?: string; - env?: NodeJS.ProcessEnv; + file?: boolean; } -const LOG_LEVELS: Set = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); -const LOG_FORMATS: Set = new Set(["pretty", "json"]); const LOG_LEVEL_PRIORITIES: Record = { trace: 10, debug: 20, @@ -49,10 +42,8 @@ const LOG_LEVEL_PRIORITIES: Record = { }; 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_ROTATE_SIZE = "10m"; -const DEFAULT_FILE_ROTATE_MAX_FILES = 2; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; const REDACT_PATHS = [ "authorization", @@ -69,33 +60,6 @@ const REDACT_PATHS = [ "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 { const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME); if (!configuredPath) { @@ -125,7 +89,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined if (options?.paseoHome) { return options.paseoHome; } - return resolvePaseoHome(options?.env ?? process.env); + return resolvePaseoHome(); } function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined { @@ -150,126 +114,50 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): 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 { consoleLevel: LogLevel; - fileLevel: LogLevel; + fileLevel?: LogLevel; consoleFormat: LogFormat; } function resolveLogLevelsAndFormat( - env: NodeJS.ProcessEnv, persistedLog: NonNullable>["log"] | undefined, ): LogLevelResolution { - const envGlobalLevel = parseLogLevel(env.PASEO_LOG); const persistedGlobalLevel = persistedLog?.level; const consoleLevel: LogLevel = - parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ?? - envGlobalLevel ?? - persistedLog?.console?.level ?? - persistedGlobalLevel ?? - DEFAULT_CONSOLE_LEVEL; - const fileLevel: LogLevel = - parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ?? - envGlobalLevel ?? - persistedLog?.file?.level ?? - persistedGlobalLevel ?? - DEFAULT_FILE_LEVEL; + persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL; + const fileLevel = persistedLog?.file + ? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL) + : undefined; 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 }; } -interface RotateResolution { - maxSize: string; - maxFiles: number; -} - -function resolveRotateConfig( - env: NodeJS.ProcessEnv, - persistedLog: NonNullable>["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( configInput: LoggerConfigInput, options?: ResolveLogConfigOptions, ): ResolvedLogConfig { const persistedConfig = normalizeLoggerConfigInput(configInput); - const env = options?.env ?? process.env; const paseoHome = resolveConfiguredPaseoHome(options); const persistedLog = persistedConfig?.log; - const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog); - const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path); - const rotate = resolveRotateConfig(env, persistedLog); + const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog); + const file = + options?.file !== false && persistedLog?.file + ? { + level: fileLevel ?? DEFAULT_FILE_LEVEL, + path: resolveFilePath(paseoHome, persistedLog.file.path), + } + : undefined; return { - level: minLogLevel([consoleLevel, fileLevel]), + level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]), console: { level: consoleLevel, format: consoleFormat, }, - file: { - level: fileLevel, - path: filePath, - rotate, - }, + ...(file ? { file } : {}), }; } @@ -278,32 +166,26 @@ export function createRootLogger( options?: ResolveLogConfigOptions, ): pino.Logger { 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 consoleStream = + const stream = config.console.format === "pretty" ? pretty({ colorize: true, singleLine: true, ignore: "pid,hostname", + destination: config.file?.path ?? 1, }) - : pino.destination({ dest: 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, - }); + : pino.destination({ dest: config.file?.path ?? 1, sync: false }); return pino( - { level: config.level, redact: { paths: REDACT_PATHS, remove: true } }, - pino.multistream([ - { level: config.console.level, stream: consoleStream }, - { level: config.file.level, stream: fileStream }, - ]), + { + level: config.file?.level ?? config.console.level, + redact: { paths: REDACT_PATHS, remove: true }, + }, + stream, ); } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index ecc00a843..d825dfe8a 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,8 +9,8 @@ import { } 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"]); -const LogFormatSchema = z.enum(["pretty", "json"]); +export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); +export const LogFormatSchema = z.enum(["pretty", "json"]); const LogConfigSchema = z .object({ diff --git a/packages/server/src/terminal/terminal-capture.ts b/packages/server/src/terminal/terminal-capture.ts new file mode 100644 index 000000000..677f54236 --- /dev/null +++ b/packages/server/src/terminal/terminal-capture.ts @@ -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, + }; +} diff --git a/packages/server/src/terminal/terminal-manager-factory.test.ts b/packages/server/src/terminal/terminal-manager-factory.test.ts deleted file mode 100644 index 583718cd8..000000000 --- a/packages/server/src/terminal/terminal-manager-factory.test.ts +++ /dev/null @@ -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"); -}); diff --git a/packages/server/src/terminal/terminal-manager-factory.ts b/packages/server/src/terminal/terminal-manager-factory.ts index a2d6a5d26..d0fbd2845 100644 --- a/packages/server/src/terminal/terminal-manager-factory.ts +++ b/packages/server/src/terminal/terminal-manager-factory.ts @@ -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"; -export type TerminalBackend = "in-process" | "worker"; - -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(); +export function createConfiguredTerminalManager(): TerminalManager { + return createWorkerTerminalManager(); } diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 42df3ff89..4bbba1a18 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -1,5 +1,5 @@ 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 { TerminalWorkerRequest, diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 7a8e8da71..8542384e9 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -5,7 +5,7 @@ import type { TerminalStateSnapshot, } from "./terminal.js"; import type { TerminalState } from "../shared/messages.js"; -import type { CaptureTerminalLinesResult } from "./terminal.js"; +import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; export interface WorkerTerminalInfo { id: string; diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index e3906ef57..401758876 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -6,9 +6,13 @@ import { tmpdir, userInfo } from "node:os"; import { basename, dirname, join } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import stripAnsi from "strip-ansi"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; +export { + captureTerminalLines, + type CaptureTerminalLinesOptions, + type CaptureTerminalLinesResult, +} from "./terminal-capture.js"; const { Terminal } = xterm; const require = createRequire(import.meta.url); @@ -96,17 +100,6 @@ interface BuildTerminalEnvironmentInput { zshShellIntegrationDir?: string; } -export interface CaptureTerminalLinesOptions { - start?: number; - end?: number; - stripAnsi?: boolean; -} - -export interface CaptureTerminalLinesResult { - lines: string[]; - totalLines: number; -} - interface EnsureNodePtySpawnHelperExecutableOptions { packageRoot?: string; platform?: NodeJS.Platform; @@ -523,63 +516,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { 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 { const { cwd, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index cec80ae67..18c69dece 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -5,7 +5,6 @@ import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; -import * as pty from "node-pty"; import stripAnsi from "strip-ansi"; import { buildStringCommandShellInvocation } from "./string-command-shell.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 { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; -import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; const execFileAsync = promisify(execFile); @@ -465,54 +463,29 @@ async function execSetupCommandStreamed(options: { cwd: options.cwd, }); - const spawnWithPipes = () => { - const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); - const child = spawnProcess(shellInvocation.shell, shellInvocation.args, { - cwd: options.cwd, - env: options.env, - stdio: ["ignore", "pipe", "pipe"], - }); + const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); + const child = spawnProcess(shellInvocation.shell, shellInvocation.args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); - child.stdout?.on("data", (chunk: Buffer | string) => { - emitOutput("stdout", chunk.toString()); - }); + child.stdout?.on("data", (chunk: Buffer | string) => { + emitOutput("stdout", chunk.toString()); + }); - child.stderr?.on("data", (chunk: Buffer | string) => { - emitOutput("stderr", chunk.toString()); - }); + child.stderr?.on("data", (chunk: Buffer | string) => { + emitOutput("stderr", chunk.toString()); + }); - 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) { + child.on("error", (error) => { emitOutput("stderr", error instanceof Error ? error.message : String(error)); - spawnWithPipes(); - } + finish(null); + }); + + child.on("close", (code) => { + finish(typeof code === "number" ? code : null); + }); }); }