feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user