mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* Extract client SDK package * Polish SDK client identity defaults * Build client before dependent CI jobs * Restore daemon client server export * Extract protocol and client SDK packages * Fix provider override schema validation * Fix app test daemon client imports * Simplify workspace build targets * Fix CLI test server build bootstrap * Run SDK package tests in CI * Fix rebase package split drift * Restore lockfile registry metadata * Update SDK config test for prompt default * Move terminal stream router test to client package * Fix rebase drift for protocol imports * Fix SDK agent capability fixture * Restore legacy server client exports * Fix server export compatibility test * Advertise custom mode icon client capability * Remove server daemon-client exports * Format rebased mode control import * Fix rebase drift for protocol imports Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split shared/ paths that this branch moves into @getpaseo/protocol. Redirect those imports to the protocol package so typecheck stays green after the rebase.
257 lines
8.2 KiB
TypeScript
257 lines
8.2 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
|
|
/**
|
|
* Regression: app-style restart requests must trigger supervised worker restart
|
|
* (worker PID changes) while keeping the daemon healthy.
|
|
*/
|
|
|
|
import assert from "node:assert";
|
|
import { spawn, spawnSync, 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 { $ } from "zx";
|
|
import { tryConnectToDaemon } from "../src/utils/client.ts";
|
|
import { getAvailablePort } from "./helpers/network.ts";
|
|
|
|
$.verbose = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function readWorkerPid(supervisorPid: number): number | null {
|
|
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
|
|
if (result.status !== 0 || result.error) {
|
|
return null;
|
|
}
|
|
|
|
for (const line of result.stdout.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const [pidToken, ppidToken] = trimmed.split(/\s+/);
|
|
const pid = Number.parseInt(pidToken ?? "", 10);
|
|
const ppid = Number.parseInt(ppidToken ?? "", 10);
|
|
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
|
|
continue;
|
|
}
|
|
if (ppid === supervisorPid && pid > 0) {
|
|
return pid;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface DaemonStatus {
|
|
localDaemon: string | null;
|
|
pid: number | null;
|
|
}
|
|
|
|
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
|
const result =
|
|
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
|
|
if (result.exitCode !== 0) {
|
|
return { localDaemon: null, pid: null };
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
|
|
const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null;
|
|
const pid =
|
|
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
|
? parsed.pid
|
|
: null;
|
|
return { localDaemon, pid };
|
|
} catch {
|
|
return { localDaemon: null, pid: null };
|
|
}
|
|
}
|
|
|
|
async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise<string> {
|
|
const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => "");
|
|
return `${recentLogs}\n${durableLogs}`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
console.log("=== Daemon Restart (supervisor regression) ===\n");
|
|
|
|
const port = await getAvailablePort();
|
|
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-supervisor-"));
|
|
const cliRoot = join(import.meta.dirname, "..");
|
|
const host = `127.0.0.1:${port}`;
|
|
|
|
let supervisorProcess: ChildProcess | null = null;
|
|
let recentSupervisorLogs = "";
|
|
|
|
try {
|
|
console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME");
|
|
|
|
supervisorProcess = spawn(
|
|
process.execPath,
|
|
["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
|
|
{
|
|
cwd: cliRoot,
|
|
env: {
|
|
...process.env,
|
|
...testEnv,
|
|
PASEO_HOME: paseoHome,
|
|
PASEO_LISTEN: host,
|
|
PASEO_RELAY_ENABLED: "false",
|
|
CI: "true",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
|
|
supervisorProcess.stdout?.on("data", (chunk) => {
|
|
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
|
});
|
|
supervisorProcess.stderr?.on("data", (chunk) => {
|
|
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
|
});
|
|
|
|
await waitFor(
|
|
async () => {
|
|
const status = await readDaemonStatus(paseoHome);
|
|
return (
|
|
status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
|
|
);
|
|
},
|
|
120000,
|
|
"daemon did not become running in time",
|
|
);
|
|
|
|
const statusBeforeRestart = await readDaemonStatus(paseoHome);
|
|
const supervisorPid = statusBeforeRestart.pid;
|
|
assert.strictEqual(
|
|
statusBeforeRestart.localDaemon,
|
|
"running",
|
|
"daemon should be running before restart",
|
|
);
|
|
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
|
|
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
|
|
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
|
|
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
|
|
assert(
|
|
isProcessRunning(workerPidBeforeRestart),
|
|
"worker process should be running before restart",
|
|
);
|
|
console.log(
|
|
`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`,
|
|
);
|
|
|
|
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
|
|
const client = await tryConnectToDaemon({ host, timeout: 5000 });
|
|
assert(client, "daemon client should connect");
|
|
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);
|
|
}
|
|
|
|
await waitFor(
|
|
() => {
|
|
const workerPid = readWorkerPid(supervisorPid);
|
|
return (
|
|
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
|
|
);
|
|
},
|
|
20000,
|
|
"worker pid did not change after restart request",
|
|
);
|
|
|
|
const workerPidAfterRestart = readWorkerPid(supervisorPid);
|
|
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
|
|
assert.notStrictEqual(
|
|
workerPidAfterRestart,
|
|
workerPidBeforeRestart,
|
|
"worker pid should change after restart",
|
|
);
|
|
|
|
const statusAfterRestart = await readDaemonStatus(paseoHome);
|
|
assert.strictEqual(
|
|
statusAfterRestart.localDaemon,
|
|
"running",
|
|
"daemon should stay running after restart",
|
|
);
|
|
assert.strictEqual(
|
|
statusAfterRestart.pid,
|
|
supervisorPid,
|
|
"supervisor pid should remain stable across restart",
|
|
);
|
|
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
|
|
assert(
|
|
capturedSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."),
|
|
`restart should route through supervisor restart intent, logs:\n${capturedSupervisorLogs}`,
|
|
);
|
|
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
|
} finally {
|
|
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
|
supervisorProcess.kill("SIGTERM");
|
|
await waitFor(
|
|
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
|
|
5000,
|
|
"supervisor cleanup timed out",
|
|
).catch(() => {
|
|
supervisorProcess?.kill("SIGKILL");
|
|
});
|
|
}
|
|
|
|
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
|
|
await rm(paseoHome, { recursive: true, force: true });
|
|
}
|
|
|
|
if (recentSupervisorLogs.trim().length === 0) {
|
|
console.log("(no supervisor logs captured)");
|
|
}
|
|
|
|
console.log("=== Supervisor restart regression test passed ===");
|