Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 3972cc1559 fix(app): avoid false never-online claims in diagnostics
A missing runtime timestamp does not establish connection history. Report it as unknown while preserving supplied timestamps.
2026-11-07 23:35:42 +00:00
paseo-bot[bot] d92495487f fix(website): enable the language menu after hydration (#9046)
The server-rendered trigger accepted clicks before its handler existed. Tie its enabled state to hydration and cover delayed scripts so an early click cannot silently disappear.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 21:28:12 +00:00
paseo-bot[bot] 19848899d0 Keep host identity and error details in desktop logs (#9038)
* fix(app): preserve host error details in desktop logs

Bind host context through the existing client logger and emit structured text so desktop console capture retains the error record, including for adopted probe clients.

* fix(app): retain discovered host identity and nested error causes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 21:19:18 +00:00
11 changed files with 243 additions and 141 deletions
@@ -46,6 +46,31 @@ function makeHost(): HostProfile {
};
}
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
return {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
}
describe("app diagnostics report", () => {
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
const report = formatServerInfoSection({
@@ -61,31 +86,11 @@ describe("app diagnostics report", () => {
test("formats connection rows without raw connection details", () => {
const host = makeHost();
const snapshot: HostRuntimeSnapshot = {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
const snapshot = makeSnapshot(host);
const report = formatHostRuntimeSection({ host, snapshot });
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
expect(report).toContain("direct TCP");
expect(report).toContain("relay");
expect(report).toContain("local socket");
@@ -97,6 +102,27 @@ describe("app diagnostics report", () => {
expect(report).not.toContain("tcp-password");
});
test.each(["connecting", "offline", "error"] as const)(
"does not claim a previously connected host was never online while %s",
(connectionStatus) => {
const host = makeHost();
const snapshot = makeSnapshot(host);
const report = formatHostRuntimeSection({
host,
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
});
expect(report).toContain("Last online: unknown");
expect(report).not.toContain("Last online: never");
},
);
test("does not infer connection history when the runtime has not started", () => {
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
expect(report).toContain("Last online: unknown");
});
test("redacts saved connection secrets from collected daemon and desktop text", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
? describeConnectionKind(snapshot.activeConnection.type)
: "none",
},
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
{ label: "Last error", value: snapshot?.lastError ?? "none" },
{
label: "Agent directory",
@@ -64,99 +64,6 @@ function makeRelayOnlyHost(serverId: string): HostProfile {
}
describe("DaemonStartService", () => {
it("reports a daemon that stops during startup without registering an incomplete host", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () =>
makeStatus({ status: "starting", serverId: "", listen: null }),
getDesktopDaemonStatus: async () =>
makeStatus({ status: "stopped", serverId: "", listen: null, pid: null }),
});
expect(await service.start()).toEqual({
ok: false,
error: "The local daemon stopped before startup completed.",
});
expect(service.isRunning()).toBe(false);
expect(fake.upserts).toEqual([]);
});
it("keeps startup pending until a slow daemon becomes ready without restarting it", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const starting = makeStatus({ status: "starting", serverId: "", listen: null });
const startDesktopDaemon = vi.fn(async () => starting);
const getDesktopDaemonStatus = vi
.fn()
.mockResolvedValueOnce(starting)
.mockResolvedValueOnce(makeStatus());
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon,
getDesktopDaemonStatus,
});
const result = service.start();
await vi.advanceTimersByTimeAsync(1000);
expect(service.isRunning()).toBe(true);
expect(service.getLastError()).toBeNull();
expect(fake.upserts).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
expect(await result).toEqual({ ok: true });
expect(startDesktopDaemon).toHaveBeenCalledTimes(1);
expect(fake.upserts).toEqual([
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("shows the daemon error if it exits while startup is pending", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ status: "starting", serverId: "" }),
getDesktopDaemonStatus: async () =>
makeStatus({ status: "errored", error: "provider startup failed" }),
});
const result = service.start();
await vi.advanceTimersByTimeAsync(1000);
expect(await result).toEqual({ ok: false, error: "provider startup failed" });
expect(fake.upserts).toEqual([]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("bounds the readiness wait and leaves a retryable error", async () => {
vi.useFakeTimers();
try {
const fake = createFakeStore();
const starting = makeStatus({ status: "starting", serverId: "" });
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => starting,
getDesktopDaemonStatus: async () => starting,
});
const result = service.start();
await vi.advanceTimersByTimeAsync(120000);
expect(await result).toEqual({
ok: false,
error: "The local daemon is still starting. Wait a moment and retry.",
});
expect(fake.upserts).toEqual([]);
expect(service.isRunning()).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("upserts the connection on a successful daemon start", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
@@ -1,8 +1,4 @@
import {
getDesktopDaemonStatus,
startDesktopDaemon,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { connectionFromListen } from "@/types/host-connection";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
@@ -18,7 +14,6 @@ type DaemonConnectionStore = Pick<HostRuntimeStore, "getHosts" | "upsertConnecti
export interface DaemonStartServiceDeps {
store: DaemonConnectionStore;
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
getDesktopDaemonStatus?: () => Promise<DesktopDaemonStatus>;
}
export async function upsertDesktopDaemonConnection(
@@ -53,7 +48,6 @@ export async function upsertDesktopDaemonConnection(
export class DaemonStartService {
private readonly store: DaemonConnectionStore;
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
private readonly invokeGetDesktopDaemonStatus: () => Promise<DesktopDaemonStatus>;
private readonly listeners = new Set<() => void>();
private lastError: string | null = null;
private inFlightCount = 0;
@@ -61,7 +55,6 @@ export class DaemonStartService {
constructor(deps: DaemonStartServiceDeps) {
this.store = deps.store;
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
this.invokeGetDesktopDaemonStatus = deps.getDesktopDaemonStatus ?? getDesktopDaemonStatus;
}
async start(): Promise<DaemonStartResult> {
@@ -86,21 +79,7 @@ export class DaemonStartService {
return { ok: true };
}
let daemon = await this.invokeStartDesktopDaemon();
// The desktop start command can return after its readiness deadline while
// the owned process keeps initializing. Keep the startup UI pending until
// that process can supply its host identity; do not launch it again.
const readinessDeadline = Date.now() + 120_000;
while (daemon.status === "starting") {
if (Date.now() >= readinessDeadline) {
return this.fail("The local daemon is still starting. Wait a moment and retry.");
}
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
daemon = await this.invokeGetDesktopDaemonStatus();
}
if (daemon.status !== "running") {
return this.fail(daemon.error ?? "The local daemon stopped before startup completed.");
}
const daemon = await this.invokeStartDesktopDaemon();
const result = await upsertDesktopDaemonConnection(this.store, daemon);
return result.ok ? result : this.fail(result.error);
} catch (error) {
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import { createHostLogger } from "./host-logger";
it("preserves the original transport cause and tolerates a cyclic cause chain", () => {
const lines: string[] = [];
const capture = (...values: unknown[]) => lines.push(values.map(String).join(" "));
const logger = createHostLogger(() => "test-host", {
log: capture,
warn: capture,
error: capture,
});
const transport = new Error("socket closed");
const wrapper = new Error("Subscription failed", { cause: transport });
Object.defineProperty(transport, "cause", { value: wrapper });
logger.error({ err: wrapper }, "subscription_error");
const record = JSON.parse(lines[0]!.slice("subscription_error ".length));
expect(record.err.cause.message).toBe("socket closed");
expect(record.err.cause.stack).toBe(transport.stack);
expect(record.err.cause.cause).toBe("[Circular]");
});
it("retains host and error details in the desktop's text-only console capture", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "offline-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
logger.warn(
{ err: { name: "Error", message: "malformed ready envelope" } },
"relay_e2ee_channel_error",
);
expect(lines).toEqual([
{
level: "warn",
text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}',
},
]);
});
it("keeps native Error messages and severity without failing on circular diagnostics", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "test-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
const record: { err: Error; self?: object } = { err: new Error("connection lost") };
record.self = record;
logger.error(record, "Connection failed");
expect(lines[0]?.level).toBe("error");
expect(lines[0]?.text).toContain('"message":"connection lost"');
expect(lines[0]?.text).toContain('"serverId":"test-host"');
expect(lines[0]?.text).toContain('"[Circular]"');
logger.info({ status: "offline" }, "Host status");
expect(lines[1]).toEqual({
level: "info",
text: 'Host status {"serverId":"test-host","status":"offline"}',
});
logger.debug({ status: "offline" }, "Host status");
expect(lines).toHaveLength(2);
});
+36
View File
@@ -0,0 +1,36 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
const details = JSON.stringify(
{ serverId: readServerId(), ...record },
(_key, value: unknown) => {
if (value && typeof value === "object") {
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (value instanceof Error) {
return {
...value,
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause,
};
}
}
return value;
},
);
return message ? `${message} ${details}` : details;
};
return {
debug: () => {},
info: (record, message) => output.log(format(record, message)),
warn: (record, message) => output.warn(format(record, message)),
error: (record, message) => output.error(format(record, message)),
};
}
+3
View File
@@ -1,5 +1,6 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostLogger } from "./host-logger";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
@@ -589,6 +590,7 @@ function createDefaultDeps(
runtimeGeneration,
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
logger: createHostLogger(() => host.serverId),
providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}),
@@ -669,6 +671,7 @@ function createDefaultDeps(
null,
),
reconnect: { enabled: false },
logger: createHostLogger(() => host.serverId),
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
import { createHostLogger } from "@/runtime/host-logger";
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
private connected = false;
constructor(
private readonly probe: FakeDaemonProbe,
@@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
}
this.connected = true;
}
getLastServerInfoMessage() {
if (!this.connected) return null;
return {
serverId: "srv_probe_test",
hostname: "probe-host",
@@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient {
}
async close(): Promise<void> {
this.connected = false;
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
diagnosticLines: string[] = [];
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
@@ -39,6 +45,12 @@ class FakeDaemonProbe {
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
createLogger: (readServerId) => {
const capture = (...values: unknown[]) => {
this.diagnosticLines.push(values.map(String).join(" "));
};
return createHostLogger(readServerId, { log: capture, warn: capture, error: capture });
},
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
@@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => {
probe = new FakeDaemonProbe();
});
it.each([undefined, "srv_probe_test"])(
"logs learned host identity on an adopted probe initially given %s",
async (serverId) => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ id: "direct:test", type: "directTcp", endpoint: "localhost:6767" },
{ serverId },
probe.deps,
);
try {
result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error");
const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length));
expect(record.serverId).toBe("srv_probe_test");
expect(record.err.message).toBe("socket closed");
await result.client.close();
expect(result.client.getLastServerInfoMessage()).toBeNull();
result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error");
const closedRecord = JSON.parse(
probe.diagnosticLines[1]!.slice("connection_error ".length),
);
expect(closedRecord.serverId).toBe("srv_probe_test");
expect(closedRecord.err.message).toBe("disconnected");
} finally {
await result.client.close();
}
},
);
it.each([
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
{
@@ -7,6 +7,7 @@ import {
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
import { createHostLogger } from "@/runtime/host-logger";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import {
@@ -29,6 +30,7 @@ export interface DaemonProbeClient {
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
createLogger: typeof createHostLogger;
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
@@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
createLogger: createHostLogger,
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
@@ -317,5 +320,9 @@ export async function connectToDaemon(
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, options, deps);
return connectAndProbe(config, resolveTimeout(connection, options), deps);
let serverId = options?.serverId;
config.logger = deps.createLogger(() => serverId);
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
serverId = result.serverId;
return result;
}
+30
View File
@@ -9,6 +9,23 @@ async function chooseLanguage(page: Page, language: string) {
await page.getByRole("menuitemradio", { name: language }).click();
}
async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise<void>) {
let releaseScripts!: () => void;
const scriptsReleased = new Promise<void>((resolve) => {
releaseScripts = resolve;
});
await page.route("**/*.js", async (route) => {
await scriptsReleased;
await route.continue();
});
try {
await run(releaseScripts);
} finally {
releaseScripts();
await page.unrouteAll({ behavior: "wait" });
}
}
function describeAlternates(links: Element[]): string[] {
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
}
@@ -114,6 +131,7 @@ test("shows the language as an abbreviation and lists every language by name", a
test("switches language from the keyboard", async ({ page }) => {
await page.goto("/");
await expect(languageButton(page)).toBeEnabled();
await languageButton(page).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
@@ -183,3 +201,15 @@ test("shows the changelog in Chinese", async ({ page }) => {
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
});
test("waits for scripts before enabling the language menu", async ({ page }) => {
await withPageScriptsHeld(page, async (releaseScripts) => {
await page.goto("/docs/cli#running-agents", { waitUntil: "commit" });
await expect(languageButton(page)).toBeVisible();
await expect(languageButton(page)).toBeDisabled();
releaseScripts();
await languageButton(page).click();
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible();
});
});
@@ -1,4 +1,4 @@
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import { Check, ChevronDown, Languages } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
@@ -33,6 +33,7 @@ type OpenFocus = "current" | "first" | "last";
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
*/
export function LanguageSelector() {
const hydrated = useHydrated();
const { t } = useTranslation();
const locale = useLocale();
const location = useLocation();
@@ -125,6 +126,7 @@ export function LanguageSelector() {
<button
ref={triggerRef}
type="button"
disabled={!hydrated}
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
aria-haspopup="menu"
aria-expanded={open}