mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7ade90fd | ||
|
|
19848899d0 |
@@ -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);
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -485,6 +485,43 @@ describe("toAgentPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStoredAgentPayload features", () => {
|
||||
it.each([null, "2026-11-01T00:00:00.000Z"])(
|
||||
"preserves saved feature definitions like live snapshots (archivedAt=%s)",
|
||||
(archivedAt) => {
|
||||
const features: AgentFeature[] = [
|
||||
createFeature({ value: false }),
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Service tier",
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal" },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const agent = createManagedAgent({ features });
|
||||
const record = { ...toStoredAgentRecord(agent), archivedAt };
|
||||
|
||||
const payload = buildStoredAgentPayload(record, [agent.provider]);
|
||||
|
||||
expect(payload.features).toEqual(features);
|
||||
expect(payload.features).toEqual(toAgentPayload(agent).features);
|
||||
expect(payload.features).not.toBe(record.features);
|
||||
expect(payload.features?.[0]).not.toBe(record.features?.[0]);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses an empty feature list for older records without saved definitions", () => {
|
||||
const record = toStoredAgentRecord(createManagedAgent());
|
||||
delete record.features;
|
||||
|
||||
expect(buildStoredAgentPayload(record, [record.provider]).features).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toRecentProviderSessionDescriptorPayload", () => {
|
||||
it("projects provider import rows to provider-opaque public recent sessions", () => {
|
||||
const session: ImportableProviderSession & { provider: string } = {
|
||||
|
||||
@@ -263,6 +263,7 @@ export function buildStoredAgentPayload(
|
||||
capabilities: defaultCapabilities,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
availableModes: [],
|
||||
features: normalizeFeatures(record.features),
|
||||
pendingPermissions: [],
|
||||
persistence,
|
||||
title: record.title ?? null,
|
||||
|
||||
Reference in New Issue
Block a user