mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5e17f000 | ||
|
|
20de118373 | ||
|
|
fd4e26ca9b | ||
|
|
f89e5604d2 | ||
|
|
ca3e55813e | ||
|
|
3fad128d02 | ||
|
|
0cb9da17cd | ||
|
|
1571f002c7 |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
|
||||
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
|
||||
|
||||
## 0.1.68 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Pjfl4RV+2keXdYWMPonsPkwPAbVNSgczmwSeQRwNSu4=";
|
||||
npmDepsHash = "sha256-J0SsiBBQYhsFzmwCg4NLOLhCXS1vqiegfJqPdoHWSKM=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
@@ -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
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -38445,7 +38445,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38571,10 +38571,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.68",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38616,7 +38616,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38665,7 +38665,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38701,7 +38701,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38727,7 +38727,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38742,12 +38742,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.68",
|
||||
"@getpaseo/relay": "0.1.68",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -39200,7 +39200,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.68",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
105
packages/cli/tests/26-daemon-launch-supervision.test.ts
Normal file
105
packages/cli/tests/26-daemon-launch-supervision.test.ts
Normal 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 ===");
|
||||
@@ -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 ===");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -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",
|
||||
@@ -58,8 +58,8 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.68",
|
||||
"@getpaseo/relay": "0.1.68",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,11 +77,33 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
|
||||
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> {
|
||||
const config = parseConfig(process.argv.slice(2));
|
||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
|
||||
const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
|
||||
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
const packagedNodeEntrypointRunner =
|
||||
process.env.ELECTRON_RUN_AS_NODE === "1"
|
||||
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
|
||||
@@ -85,6 +112,8 @@ async function main(): Promise<void> {
|
||||
applySherpaLoaderEnv(workerEnv);
|
||||
|
||||
const paseoHome = resolvePaseoHome(workerEnv);
|
||||
const persistedConfig = loadPersistedConfig(paseoHome);
|
||||
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
|
||||
|
||||
try {
|
||||
await acquirePidLock(paseoHome, null, {
|
||||
@@ -112,9 +141,7 @@ async function main(): Promise<void> {
|
||||
|
||||
runSupervisor({
|
||||
name: "DaemonRunner",
|
||||
startupMessage: config.devMode
|
||||
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
||||
: "Starting daemon worker (IPC restart enabled)",
|
||||
startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)",
|
||||
resolveWorkerEntry: () => workerEntry,
|
||||
workerArgs: config.workerArgs,
|
||||
workerEnv,
|
||||
@@ -134,7 +161,8 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
restartOnCrash: config.devMode,
|
||||
restartOnCrash: true,
|
||||
logFile: supervisorLogFile,
|
||||
onWorkerReady: async ({ listen }) => {
|
||||
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
|
||||
},
|
||||
|
||||
132
packages/server/scripts/supervisor.logging.test.ts
Normal file
132
packages/server/scripts/supervisor.logging.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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> | void;
|
||||
restartOnCrash?: boolean;
|
||||
onSupervisorExit?: () => Promise<void> | 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<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 => {
|
||||
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 => {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
import {
|
||||
ClaudeAgentClient,
|
||||
convertClaudeHistoryEntry,
|
||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||
} from "./claude-agent.js";
|
||||
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
@@ -364,6 +368,138 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||
expect(
|
||||
normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { Provider: "Claude" },
|
||||
},
|
||||
undefined,
|
||||
),
|
||||
).toEqual({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Claude" },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses fallback request questions when response only includes answers", () => {
|
||||
expect(
|
||||
normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
{
|
||||
answers: { Provider: "Codex" },
|
||||
},
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Codex" },
|
||||
});
|
||||
});
|
||||
|
||||
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const request = {
|
||||
id: "permission-question-1",
|
||||
provider: "claude",
|
||||
name: "AskUserQuestion",
|
||||
kind: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = new Promise<unknown>((resolve, reject) => {
|
||||
(
|
||||
session as unknown as {
|
||||
pendingPermissions: Map<
|
||||
string,
|
||||
{
|
||||
request: typeof request;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>;
|
||||
}
|
||||
).pendingPermissions.set(request.id, {
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await session.respondToPermission(request.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: { Provider: "Claude" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which provider should I use?",
|
||||
header: "Provider",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
answers: { "Which provider should I use?": "Claude" },
|
||||
},
|
||||
updatedPermissions: undefined,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -87,6 +87,61 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
|
||||
const fsPromises = promises;
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
updatedInput: AgentMetadata | undefined,
|
||||
fallbackInput: AgentMetadata | undefined,
|
||||
): AgentMetadata {
|
||||
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
|
||||
const base = isMetadata(updatedInput) ? updatedInput : {};
|
||||
// Paseo's shared question UI serializes answers by question header, but Claude's
|
||||
// AskUserQuestion tool expects answer keys to match the full question text. Merge
|
||||
// the original request payload back in so provider callbacks that only return
|
||||
// `{ answers }` still satisfy Claude's full tool input schema.
|
||||
const merged = { ...fallback, ...base };
|
||||
const questions =
|
||||
(Array.isArray(base.questions) ? base.questions : null) ??
|
||||
(Array.isArray(fallback.questions) ? fallback.questions : null);
|
||||
const answers = isMetadata(base.answers) ? base.answers : null;
|
||||
|
||||
if (!questions || !answers) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
const normalizedAnswers: Record<string, string> = {};
|
||||
for (const item of questions) {
|
||||
const question = isMetadata(item) ? item : null;
|
||||
if (!question) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionText = readNonEmptyString(question.question);
|
||||
if (!questionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const header = readNonEmptyString(question.header);
|
||||
const answer =
|
||||
readNonEmptyString(answers[questionText]) ??
|
||||
(header ? readNonEmptyString(answers[header]) : null);
|
||||
if (answer) {
|
||||
normalizedAnswers[questionText] = answer;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalizedAnswers).length === 0) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
return {
|
||||
...merged,
|
||||
answers: normalizedAnswers,
|
||||
};
|
||||
}
|
||||
|
||||
type TurnState = "idle" | "foreground" | "autonomous";
|
||||
|
||||
interface EventIdentifiers {
|
||||
@@ -1772,9 +1827,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updatedInput =
|
||||
pending.request.kind === "question"
|
||||
? normalizeClaudeAskUserQuestionUpdatedInput(
|
||||
response.updatedInput,
|
||||
pending.request.input ?? undefined,
|
||||
)
|
||||
: (response.updatedInput ?? pending.request.input ?? {});
|
||||
const result: PermissionResult = {
|
||||
behavior: "allow",
|
||||
updatedInput: response.updatedInput ?? pending.request.input ?? {},
|
||||
updatedInput,
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
|
||||
@@ -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<string, ProviderOverride>;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
}
|
||||
|
||||
@@ -840,16 +842,6 @@ export async function createPaseoDaemon(
|
||||
github,
|
||||
);
|
||||
|
||||
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
|
||||
process.send({
|
||||
type: "paseo:ready",
|
||||
listen:
|
||||
boundListenTarget.type === "tcp"
|
||||
? `${boundListenTarget.host}:${boundListenTarget.port}`
|
||||
: boundListenTarget.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
|
||||
@@ -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<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
|
||||
.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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -2,14 +2,16 @@ 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";
|
||||
|
||||
type SupervisorLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:shutdown";
|
||||
}
|
||||
| {
|
||||
type: "paseo:ready";
|
||||
listen: string;
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
reason?: string;
|
||||
@@ -24,9 +26,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);
|
||||
@@ -48,12 +49,10 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||
const { logger, config } = bootstrapFromEnvironment();
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
|
||||
let pidLockAcquired = false;
|
||||
|
||||
applyCliFlagOverrides(config);
|
||||
|
||||
@@ -89,10 +88,6 @@ async function main() {
|
||||
return 1;
|
||||
}
|
||||
await daemon.stop();
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
clearTimeout(forceExit);
|
||||
logger.info("Server closed");
|
||||
return options?.successExitCode ?? 0;
|
||||
@@ -151,11 +146,6 @@ async function main() {
|
||||
};
|
||||
|
||||
try {
|
||||
if (!supervised) {
|
||||
await acquirePidLock(paseoHome, null);
|
||||
pidLockAcquired = true;
|
||||
}
|
||||
|
||||
daemon = await createPaseoDaemon(
|
||||
{
|
||||
...config,
|
||||
@@ -164,40 +154,22 @@ async function main() {
|
||||
logger,
|
||||
);
|
||||
} catch (err) {
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.fatal({ err }, "Daemon bootstrap failed");
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
if (!supervised) {
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
const listen =
|
||||
listenTarget?.type === "tcp"
|
||||
? `${listenTarget.host}:${listenTarget.port}`
|
||||
: listenTarget?.path;
|
||||
if (!listen) {
|
||||
throw new Error("Daemon did not expose a listen target after startup");
|
||||
}
|
||||
await updatePidLock(paseoHome, { listen });
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
const listen =
|
||||
listenTarget?.type === "tcp"
|
||||
? `${listenTarget.host}:${listenTarget.port}`
|
||||
: listenTarget?.path;
|
||||
if (!listen) {
|
||||
throw new Error("Daemon did not expose a listen target after startup");
|
||||
}
|
||||
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
|
||||
} catch (err) {
|
||||
if (pidLockAcquired) {
|
||||
await releasePidLock(paseoHome);
|
||||
pidLockAcquired = false;
|
||||
}
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.fatal({ err }, "Daemon failed to start listening");
|
||||
throw err;
|
||||
}
|
||||
@@ -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<number | null>((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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
@@ -49,10 +42,8 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
|
||||
};
|
||||
|
||||
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<ReturnType<typeof normalizeLoggerConfigInput>>["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<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(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
71
packages/server/src/terminal/terminal-capture.ts
Normal file
71
packages/server/src/terminal/terminal-capture.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.68",
|
||||
"version": "0.1.69",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user