feat: implement daemon connectivity updates and relay protocol versioning

This commit is contained in:
Mohamed Boudra
2026-02-20 19:27:56 +07:00
parent 6dad3212da
commit 0350f00840
49 changed files with 4146 additions and 1351 deletions
@@ -0,0 +1,60 @@
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
type PendingPermissionEntry = {
key: string;
agentId: string;
request: Agent["pendingPermissions"][number];
};
export function buildAgentDirectoryState(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): {
agents: Map<string, Agent>;
pendingPermissions: Map<string, PendingPermissionEntry>;
} {
const agents = new Map<string, Agent>();
const pendingPermissions = new Map<string, PendingPermissionEntry>();
for (const entry of input.entries) {
const normalized = normalizeAgentSnapshot(entry.agent, input.serverId);
const projectPlacement = resolveProjectPlacement({
projectPlacement: entry.project,
cwd: normalized.cwd,
});
const agent: Agent = {
...normalized,
projectPlacement,
};
agents.set(agent.id, agent);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
return { agents, pendingPermissions };
}
export function applyFetchedAgentDirectory(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): { agents: Map<string, Agent> } {
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
const store = useSessionStore.getState();
store.setAgents(input.serverId, agents);
for (const agent of agents.values()) {
store.setAgentLastActivity(agent.id, agent.lastActivityAt);
}
store.setPendingPermissions(input.serverId, pendingPermissions);
store.setInitializingAgents(input.serverId, new Map());
store.setHasHydratedAgents(input.serverId, true);
return { agents };
}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("client-session-key", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("returns stored client session key when present", async () => {
asyncStorageMock.getItem.mockResolvedValue("clsk_existing");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_existing");
expect(asyncStorageMock.getItem).toHaveBeenCalledTimes(1);
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("creates and persists a client session key when missing", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
const mod = await import("./client-session-key");
const key = await mod.getOrCreateClientSessionKey();
expect(key).toBe("clsk_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-session-key-v1",
"clsk_123456781234123412341234567890ab"
);
});
});
@@ -0,0 +1,54 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const CLIENT_SESSION_KEY_STORAGE_KEY = "@paseo:client-session-key-v1";
let cachedClientSessionKey: string | null = null;
let inFlightClientSessionKey: Promise<string> | null = null;
function generateClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_${randomUuid}`;
}
function normalizeStoredClientSessionKey(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function getOrCreateClientSessionKey(): Promise<string> {
if (cachedClientSessionKey) {
return cachedClientSessionKey;
}
if (inFlightClientSessionKey) {
return inFlightClientSessionKey;
}
inFlightClientSessionKey = (async () => {
const storedValue = await AsyncStorage.getItem(CLIENT_SESSION_KEY_STORAGE_KEY);
const existing = normalizeStoredClientSessionKey(storedValue);
if (existing) {
cachedClientSessionKey = existing;
return existing;
}
const nextValue = generateClientSessionKey();
await AsyncStorage.setItem(CLIENT_SESSION_KEY_STORAGE_KEY, nextValue);
cachedClientSessionKey = nextValue;
return nextValue;
})();
try {
return await inFlightClientSessionKey;
} finally {
inFlightClientSessionKey = null;
}
}
+5 -1
View File
@@ -30,6 +30,10 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
clientSessionKey?: string;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
deriveProjectPlacementFromCwd,
resolveProjectPlacement,
} from "./project-placement";
describe("project-placement", () => {
it("derives fallback placement from cwd", () => {
const placement = deriveProjectPlacementFromCwd("/Users/test/repo");
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe("/Users/test/repo");
expect(placement.checkout.isGit).toBe(false);
});
it("normalizes paseo worktree paths into the parent repo key", () => {
const placement = deriveProjectPlacementFromCwd(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
});
it("prefers an existing placement when present", () => {
const existing = {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "/Users/test/repo",
isGit: true as const,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
},
};
const resolved = resolveProjectPlacement({
projectPlacement: existing,
cwd: "/Users/test/repo",
});
expect(resolved).toBe(existing);
});
});
@@ -0,0 +1,32 @@
import type { ProjectPlacementPayload } from "@server/shared/messages";
import { deriveProjectKey, deriveProjectName } from "@/utils/agent-grouping";
function normalizeWorkingDirectory(cwd: string): string {
const trimmed = cwd.trim();
return trimmed.length > 0 ? trimmed : ".";
}
export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayload {
const normalizedCwd = normalizeWorkingDirectory(cwd);
const projectKey = deriveProjectKey(normalizedCwd);
return {
projectKey,
projectName: deriveProjectName(projectKey),
checkout: {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
};
}
export function resolveProjectPlacement(input: {
projectPlacement: ProjectPlacementPayload | null | undefined;
cwd: string;
}): ProjectPlacementPayload {
return input.projectPlacement ?? deriveProjectPlacementFromCwd(input.cwd);
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientSessionKey?: string; url?: string }> = [];
class MockDaemonClient {
private statusHandlers = new Set<
(
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
>();
public lastError: string | null = null;
constructor(config: { clientSessionKey?: string; url?: string }) {
createdConfigs.push(config);
}
subscribeConnectionStatus(): () => void {
return () => undefined;
}
on(
event: "status",
handler: (
message: {
type: "status";
payload: { status: string; serverId: string; hostname: string | null };
}
) => void
): () => void {
if (event === "status") {
this.statusHandlers.add(handler);
}
return () => {
this.statusHandlers.delete(handler);
};
}
async connect(): Promise<void> {
const message = {
type: "status" as const,
payload: {
status: "server_info",
serverId: "srv_probe_test",
hostname: "probe-host",
},
};
for (const handler of this.statusHandlers) {
handler(message);
}
}
async ping(): Promise<{ rttMs: number }> {
return { rttMs: 42 };
}
async close(): Promise<void> {
return;
}
}
return {
MockDaemonClient,
createdConfigs,
};
});
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
describe("test-daemon-connection probe client identity", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
});
it("uses isolated probe clientSessionKey values for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
await mod.measureConnectionLatency({
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(second?.clientSessionKey).toMatch(/^clsk_probe_/);
expect(first?.clientSessionKey).not.toBe(second?.clientSessionKey);
});
});
@@ -4,6 +4,16 @@ import { parseServerInfoStatusPayload } from "@server/shared/messages";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientSessionKey(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `clsk_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -41,15 +51,23 @@ export class DaemonConnectionTestError extends Error {
}
}
function buildClientConfig(connection: HostConnection, serverId?: string): DaemonClientConfig {
async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientSessionKey = createProbeClientSessionKey();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientSessionKey,
suppressSendErrors: true,
...(tauriTransportFactory ? { transportFactory: tauriTransportFactory } : {}),
};
if (connection.type === "direct") {
return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint) };
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint, { clientSessionKey }),
};
}
if (!serverId) {
@@ -58,7 +76,11 @@ function buildClientConfig(connection: HostConnection, serverId?: string): Daemo
return {
...base,
url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, serverId }),
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
serverId,
clientSessionKey,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
};
}
@@ -142,7 +164,7 @@ export async function probeConnection(
connection: HostConnection,
options?: ProbeOptions,
): Promise<{ serverId: string; hostname: string | null }> {
const config = buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId);
const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
await client.close().catch(() => undefined);
return { serverId, hostname };
@@ -152,7 +174,7 @@ export async function measureConnectionLatency(
connection: HostConnection,
options?: ProbeOptions,
): Promise<number> {
const config = buildClientConfig(connection, options?.serverId);
const config = await buildClientConfig(connection, options?.serverId);
const { client } = await connectAndProbe(config, resolveTimeout(connection, options));
try {
const { rttMs } = await client.ping({ timeoutMs: 5000 });