Update files
This commit is contained in:
@@ -118,6 +118,7 @@ export default async function globalSetup() {
|
|||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASEO_HOME: paseoHome,
|
PASEO_HOME: paseoHome,
|
||||||
|
PASEO_SERVER_ID: 'e2e-test-daemon',
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
|
|||||||
@@ -71,3 +71,119 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
|||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
await expect(input).toBeEditable({ timeout: 30000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const testDaemon = {
|
||||||
|
id: 'e2e-test-daemon',
|
||||||
|
label: 'localhost',
|
||||||
|
endpoints: [`127.0.0.1:${daemonPort}`],
|
||||||
|
relay: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(
|
||||||
|
({ daemon }) => {
|
||||||
|
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||||
|
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||||
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||||
|
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||||
|
localStorage.removeItem('@paseo:settings');
|
||||||
|
},
|
||||||
|
{ daemon: testDaemon }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
// Host should be auto-selected (no manual selection required).
|
||||||
|
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||||
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||||
|
await expect(input).toBeEditable({ timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adopts daemon-provided serverId for legacy host ids', async ({ page }) => {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const legacyId = 'legacy-daemon-id';
|
||||||
|
const daemon = {
|
||||||
|
id: legacyId,
|
||||||
|
label: 'localhost',
|
||||||
|
endpoints: [`127.0.0.1:${daemonPort}`],
|
||||||
|
relay: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page.evaluate(
|
||||||
|
({ daemon, legacyId }) => {
|
||||||
|
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||||
|
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||||
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||||
|
localStorage.setItem(
|
||||||
|
'@paseo:create-agent-preferences',
|
||||||
|
JSON.stringify({
|
||||||
|
serverId: legacyId,
|
||||||
|
provider: 'claude',
|
||||||
|
providerPreferences: {
|
||||||
|
claude: { model: 'haiku' },
|
||||||
|
codex: { model: 'gpt-5.1-codex-mini' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
localStorage.removeItem('@paseo:settings');
|
||||||
|
},
|
||||||
|
{ daemon, legacyId }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
// Wait for server_info handshake to rekey registry + preferences.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||||
|
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||||
|
if (!raw || !prefsRaw) return false;
|
||||||
|
const registry = JSON.parse(raw);
|
||||||
|
const prefs = JSON.parse(prefsRaw);
|
||||||
|
if (!Array.isArray(registry) || registry.length !== 1) return false;
|
||||||
|
if (registry[0]?.id !== 'e2e-test-daemon') return false;
|
||||||
|
if (prefs?.serverId !== 'e2e-test-daemon') return false;
|
||||||
|
const legacyIds = registry[0]?.metadata?.legacyIds;
|
||||||
|
return Array.isArray(legacyIds) && legacyIds.includes('legacy-daemon-id');
|
||||||
|
},
|
||||||
|
{ timeout: 20000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||||
|
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
||||||
|
return { raw, prefsRaw };
|
||||||
|
});
|
||||||
|
if (!snapshot.raw || !snapshot.prefsRaw) {
|
||||||
|
throw new Error('Expected registry + preferences to be present after rekey.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = JSON.parse(snapshot.raw) as any[];
|
||||||
|
expect(registry[0].id).toBe('e2e-test-daemon');
|
||||||
|
expect(registry[0].metadata.legacyIds).toContain('legacy-daemon-id');
|
||||||
|
|
||||||
|
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||||
|
expect(prefs.serverId).toBe('e2e-test-daemon');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useEffect } from "react";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||||
|
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
|
|
||||||
export default function AgentReadyRoute() {
|
export default function AgentReadyRoute() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { daemons } = useDaemonRegistry();
|
||||||
const { serverId, agentId } = useLocalSearchParams<{
|
const { serverId, agentId } = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof serverId !== "string" || typeof agentId !== "string") return;
|
||||||
|
const canonical = resolveCanonicalDaemonId(daemons, serverId);
|
||||||
|
if (!canonical || canonical === serverId) return;
|
||||||
|
router.replace(`/agent/${canonical}/${agentId}` as any);
|
||||||
|
}, [agentId, daemons, router, serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentReadyScreen
|
<AgentReadyScreen
|
||||||
serverId={typeof serverId === "string" ? serverId : ""}
|
serverId={typeof serverId === "string" ? serverId : ""}
|
||||||
@@ -14,4 +25,3 @@ export default function AgentReadyRoute() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function DropdownMenuTrigger({
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
style={({ pressed, hovered }) => {
|
style={({ pressed, hovered }) => {
|
||||||
if (typeof style === "function") {
|
if (typeof style === "function") {
|
||||||
return style({ pressed, hovered, open: ctx.open });
|
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||||
}
|
}
|
||||||
return style;
|
return style;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ interface DaemonRegistryContextValue {
|
|||||||
removeDaemon: (id: string) => Promise<void>;
|
removeDaemon: (id: string) => Promise<void>;
|
||||||
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
|
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
|
||||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||||
|
adoptDaemonServerId: (currentId: string, serverId: string) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||||
@@ -129,6 +130,72 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
await persist(remaining);
|
await persist(remaining);
|
||||||
}, [persist, readDaemons]);
|
}, [persist, readDaemons]);
|
||||||
|
|
||||||
|
const adoptDaemonServerId = useCallback(
|
||||||
|
async (currentId: string, serverId: string) => {
|
||||||
|
const trimmed = serverId.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("serverId is required");
|
||||||
|
}
|
||||||
|
if (currentId === trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = readDaemons();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const idx = existing.findIndex((daemon) => daemon.id === currentId);
|
||||||
|
if (idx === -1) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = existing[idx];
|
||||||
|
const otherIdx = existing.findIndex((daemon) => daemon.id === trimmed);
|
||||||
|
|
||||||
|
const addLegacyId = (metadata: Record<string, unknown> | null | undefined, legacyId: string) => {
|
||||||
|
const prev = (metadata ?? {}) as Record<string, unknown>;
|
||||||
|
const legacyIdsRaw = prev.legacyIds;
|
||||||
|
const legacyIds = Array.isArray(legacyIdsRaw)
|
||||||
|
? legacyIdsRaw.filter((value) => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
if (!legacyIds.includes(legacyId)) {
|
||||||
|
legacyIds.push(legacyId);
|
||||||
|
}
|
||||||
|
return { ...prev, legacyIds };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge into existing canonical entry if it exists.
|
||||||
|
if (otherIdx !== -1) {
|
||||||
|
const canonical = existing[otherIdx];
|
||||||
|
const merged: HostProfile = {
|
||||||
|
...canonical,
|
||||||
|
label: canonical.label?.trim() ? canonical.label : current.label,
|
||||||
|
endpoints: Array.from(new Set([...(canonical.endpoints ?? []), ...(current.endpoints ?? [])])),
|
||||||
|
daemonPublicKeyB64: canonical.daemonPublicKeyB64 ?? current.daemonPublicKeyB64,
|
||||||
|
relay: canonical.relay ?? current.relay ?? null,
|
||||||
|
createdAt: canonical.createdAt ?? current.createdAt,
|
||||||
|
updatedAt: now,
|
||||||
|
metadata: addLegacyId(canonical.metadata, currentId),
|
||||||
|
};
|
||||||
|
const next = existing.filter((daemon) => daemon.id !== currentId);
|
||||||
|
next[otherIdx > idx ? otherIdx - 1 : otherIdx] = merged;
|
||||||
|
await persist(next);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: HostProfile = {
|
||||||
|
...current,
|
||||||
|
id: trimmed,
|
||||||
|
updatedAt: now,
|
||||||
|
metadata: addLegacyId(current.metadata, currentId),
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = [...existing];
|
||||||
|
next[idx] = updated;
|
||||||
|
await persist(next);
|
||||||
|
return trimmed;
|
||||||
|
},
|
||||||
|
[persist, readDaemons]
|
||||||
|
);
|
||||||
|
|
||||||
const upsertDaemonFromOffer = useCallback(
|
const upsertDaemonFromOffer = useCallback(
|
||||||
async (offer: ConnectionOfferV1) => {
|
async (offer: ConnectionOfferV1) => {
|
||||||
const existing = readDaemons();
|
const existing = readDaemons();
|
||||||
@@ -194,6 +261,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
addDaemon,
|
addDaemon,
|
||||||
updateDaemon,
|
updateDaemon,
|
||||||
removeDaemon,
|
removeDaemon,
|
||||||
|
adoptDaemonServerId,
|
||||||
upsertDaemonFromOffer,
|
upsertDaemonFromOffer,
|
||||||
upsertDaemonFromOfferUrl,
|
upsertDaemonFromOfferUrl,
|
||||||
};
|
};
|
||||||
@@ -389,3 +457,16 @@ export function buildDirectDaemonWsUrl(profile: HostProfile): string {
|
|||||||
}
|
}
|
||||||
return buildDaemonWebSocketUrl(endpoint);
|
return buildDaemonWebSocketUrl(endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCanonicalDaemonId(daemons: HostProfile[], id: string): string | null {
|
||||||
|
const direct = daemons.find((daemon) => daemon.id === id);
|
||||||
|
if (direct) return direct.id;
|
||||||
|
|
||||||
|
for (const daemon of daemons) {
|
||||||
|
const legacyIds = (daemon.metadata as any)?.legacyIds;
|
||||||
|
if (Array.isArray(legacyIds) && legacyIds.some((value) => value === id)) {
|
||||||
|
return daemon.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState, Platform } from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
import { useDaemonClient } from "@/hooks/use-daemon-client";
|
||||||
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
@@ -23,6 +23,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
|
|||||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useDaemonConnections } from "./daemon-connections-context";
|
import { useDaemonConnections } from "./daemon-connections-context";
|
||||||
|
import { useDaemonRegistry } from "./daemon-registry-context";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
@@ -259,18 +260,23 @@ interface SessionProviderProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||||
|
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||||
|
|
||||||
// SessionProvider: Daemon client message handler that updates Zustand store
|
// SessionProvider: Daemon client message handler that updates Zustand store
|
||||||
export function SessionProvider({
|
export function SessionProvider({
|
||||||
children,
|
children,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverId,
|
serverId,
|
||||||
}: SessionProviderProps) {
|
}: SessionProviderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const client = useDaemonClient(serverUrl);
|
const client = useDaemonClient(serverUrl);
|
||||||
const [connectionSnapshot, setConnectionSnapshot] =
|
const [connectionSnapshot, setConnectionSnapshot] =
|
||||||
useState<DaemonConnectionSnapshot>(() =>
|
useState<DaemonConnectionSnapshot>(() =>
|
||||||
mapConnectionState(client.getConnectionState(), client.lastError)
|
mapConnectionState(client.getConnectionState(), client.lastError)
|
||||||
);
|
);
|
||||||
const { updateConnectionStatus } = useDaemonConnections();
|
const { updateConnectionStatus } = useDaemonConnections();
|
||||||
|
const { adoptDaemonServerId } = useDaemonRegistry();
|
||||||
|
|
||||||
// Zustand store actions
|
// Zustand store actions
|
||||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||||
@@ -349,6 +355,7 @@ export function SessionProvider({
|
|||||||
);
|
);
|
||||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||||
const appStateRef = useRef(AppState.currentState);
|
const appStateRef = useRef(AppState.currentState);
|
||||||
|
const hasAdoptedServerIdRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||||
@@ -443,6 +450,47 @@ export function SessionProvider({
|
|||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
// Daemon identity handshake: adopt stable serverId from the daemon.
|
||||||
|
useEffect(() => {
|
||||||
|
return client.on("status", (message) => {
|
||||||
|
if (message.type !== "status") return;
|
||||||
|
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
||||||
|
if (payload?.status !== "server_info") return;
|
||||||
|
|
||||||
|
const remoteServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||||
|
if (!remoteServerId) return;
|
||||||
|
|
||||||
|
// Avoid loops if multiple server_info messages arrive.
|
||||||
|
if (hasAdoptedServerIdRef.current) return;
|
||||||
|
hasAdoptedServerIdRef.current = true;
|
||||||
|
|
||||||
|
if (remoteServerId === serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const canonicalId = await adoptDaemonServerId(serverId, remoteServerId);
|
||||||
|
|
||||||
|
// Migrate persisted "create agent" preferences if they reference the old id.
|
||||||
|
try {
|
||||||
|
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as { serverId?: unknown } & Record<string, unknown>;
|
||||||
|
if (typeof parsed.serverId === "string" && parsed.serverId === serverId) {
|
||||||
|
const next = { ...parsed, serverId: canonicalId };
|
||||||
|
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||||
|
queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Session] Failed to migrate form preferences serverId", { error });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.warn("[Session] Failed to adopt daemon serverId", { error });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [adoptDaemonServerId, client, queryClient, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateSessionConnection(serverId, connectionSnapshot);
|
updateSessionConnection(serverId, connectionSnapshot);
|
||||||
}, [serverId, connectionSnapshot, updateSessionConnection]);
|
}, [serverId, connectionSnapshot, updateSessionConnection]);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type UseAgentFormStateOptions = {
|
|||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isCreateFlow?: boolean;
|
isCreateFlow?: boolean;
|
||||||
isTargetDaemonReady?: boolean;
|
isTargetDaemonReady?: boolean;
|
||||||
|
onlineServerIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseAgentFormStateResult = {
|
type UseAgentFormStateResult = {
|
||||||
@@ -230,6 +231,7 @@ export function useAgentFormState(
|
|||||||
isVisible = true,
|
isVisible = true,
|
||||||
isCreateFlow = true,
|
isCreateFlow = true,
|
||||||
isTargetDaemonReady = true,
|
isTargetDaemonReady = true,
|
||||||
|
onlineServerIds = [],
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -357,6 +359,32 @@ export function useAgentFormState(
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auto-select the first online host when:
|
||||||
|
// - no URL override
|
||||||
|
// - no stored preference applied
|
||||||
|
// - user hasn't manually picked a host in this session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible || !isCreateFlow) return;
|
||||||
|
if (isPreferencesLoading) return;
|
||||||
|
if (!hasResolvedRef.current) return;
|
||||||
|
if (userModified.serverId) return;
|
||||||
|
if (combinedInitialValues?.serverId !== undefined) return;
|
||||||
|
if (formStateRef.current.serverId) return;
|
||||||
|
|
||||||
|
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
||||||
|
if (!candidate) return;
|
||||||
|
|
||||||
|
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
|
||||||
|
}, [
|
||||||
|
combinedInitialValues?.serverId,
|
||||||
|
isCreateFlow,
|
||||||
|
isPreferencesLoading,
|
||||||
|
isVisible,
|
||||||
|
onlineServerIds.join("|"),
|
||||||
|
userModified.serverId,
|
||||||
|
validServerIds,
|
||||||
|
]);
|
||||||
|
|
||||||
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isCreateFlow) return;
|
if (!isVisible || !isCreateFlow) return;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
@@ -147,6 +147,33 @@ export function DraftAgentScreen({
|
|||||||
const resolvedModel = getParamValue(params.model);
|
const resolvedModel = getParamValue(params.model);
|
||||||
const resolvedWorkingDir = getParamValue(params.workingDir);
|
const resolvedWorkingDir = getParamValue(params.workingDir);
|
||||||
|
|
||||||
|
const onlineServerIds = useMemo(() => {
|
||||||
|
if (daemons.length === 0) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const daemon of daemons) {
|
||||||
|
const status = connectionStates.get(daemon.id)?.status ?? "idle";
|
||||||
|
if (status === "online") out.push(daemon.id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [connectionStates, daemons]);
|
||||||
|
|
||||||
|
// If the URL contains a legacy host id, redirect to the canonical daemon-provided id.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedServerId) return;
|
||||||
|
const canonical = resolveCanonicalDaemonId(daemons, resolvedServerId);
|
||||||
|
if (!canonical || canonical === resolvedServerId) return;
|
||||||
|
|
||||||
|
const nextParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (key === "serverId") continue;
|
||||||
|
if (typeof value === "string") nextParams[key] = value;
|
||||||
|
if (Array.isArray(value) && typeof value[0] === "string") nextParams[key] = value[0] as string;
|
||||||
|
}
|
||||||
|
nextParams.serverId = canonical;
|
||||||
|
|
||||||
|
router.replace({ pathname: "/", params: nextParams as any });
|
||||||
|
}, [daemons, params, resolvedServerId, router]);
|
||||||
|
|
||||||
const initialValues = useMemo((): CreateAgentInitialValues => {
|
const initialValues = useMemo((): CreateAgentInitialValues => {
|
||||||
const values: CreateAgentInitialValues = {};
|
const values: CreateAgentInitialValues = {};
|
||||||
if (resolvedWorkingDir) {
|
if (resolvedWorkingDir) {
|
||||||
@@ -187,6 +214,7 @@ export function DraftAgentScreen({
|
|||||||
initialValues,
|
initialValues,
|
||||||
isVisible,
|
isVisible,
|
||||||
isCreateFlow: true,
|
isCreateFlow: true,
|
||||||
|
onlineServerIds,
|
||||||
});
|
});
|
||||||
const hostEntry = selectedServerId
|
const hostEntry = selectedServerId
|
||||||
? connectionStates.get(selectedServerId)
|
? connectionStates.get(selectedServerId)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
} from "./connection-offer.js";
|
} from "./connection-offer.js";
|
||||||
import { printPairingQrIfEnabled } from "./pairing-qr.js";
|
import { printPairingQrIfEnabled } from "./pairing-qr.js";
|
||||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||||
|
import { getOrCreateServerId } from "./server-id.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -104,7 +105,8 @@ export async function createPaseoDaemon(
|
|||||||
rootLogger: Logger
|
rootLogger: Logger
|
||||||
): Promise<PaseoDaemon> {
|
): Promise<PaseoDaemon> {
|
||||||
const logger = rootLogger.child({ module: "bootstrap" });
|
const logger = rootLogger.child({ module: "bootstrap" });
|
||||||
const connectionSessionId = randomUUID();
|
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||||
|
const connectionSessionId = serverId;
|
||||||
let relayTransport: RelayTransportController | null = null;
|
let relayTransport: RelayTransportController | null = null;
|
||||||
|
|
||||||
const staticDir = config.staticDir;
|
const staticDir = config.staticDir;
|
||||||
@@ -416,6 +418,7 @@ export async function createPaseoDaemon(
|
|||||||
const wsServer = new VoiceAssistantWebSocketServer(
|
const wsServer = new VoiceAssistantWebSocketServer(
|
||||||
httpServer,
|
httpServer,
|
||||||
logger,
|
logger,
|
||||||
|
serverId,
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
downloadTokenStore,
|
downloadTokenStore,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { execSync } from "node:child_process";
|
|||||||
import {
|
import {
|
||||||
createDaemonTestContext,
|
createDaemonTestContext,
|
||||||
type DaemonTestContext,
|
type DaemonTestContext,
|
||||||
|
DaemonClient,
|
||||||
} from "./test-utils/index.js";
|
} from "./test-utils/index.js";
|
||||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||||
import {
|
import {
|
||||||
@@ -88,6 +89,29 @@ describe("daemon client E2E", () => {
|
|||||||
expect(deleteResult.error).toBeTruthy();
|
expect(deleteResult.error).toBeTruthy();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
test("emits server_info on websocket connect", async () => {
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const infoPromise = waitForSignal<{ serverId: string }>(5000, (resolve) => {
|
||||||
|
const unsubscribe = client.on("status", (message) => {
|
||||||
|
if (message.type !== "status") return;
|
||||||
|
const payload = message.payload as { status?: unknown; serverId?: unknown };
|
||||||
|
if (payload.status !== "server_info") return;
|
||||||
|
if (typeof payload.serverId !== "string" || payload.serverId.trim().length === 0) return;
|
||||||
|
resolve({ serverId: payload.serverId.trim() });
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
const info = await infoPromise;
|
||||||
|
expect(info.serverId.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
test("matches request IDs for concurrent session requests", async () => {
|
test("matches request IDs for concurrent session requests", async () => {
|
||||||
const firstRequestId = `list-${Date.now()}-a`;
|
const firstRequestId = `list-${Date.now()}-a`;
|
||||||
const secondRequestId = `list-${Date.now()}-b`;
|
const secondRequestId = `list-${Date.now()}-b`;
|
||||||
|
|||||||
@@ -101,13 +101,16 @@ describe("Relay transport (plaintext) - daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
ws.on("message", (data) => {
|
||||||
clearTimeout(timeout);
|
|
||||||
try {
|
try {
|
||||||
resolve(JSON.parse(data.toString()));
|
const parsed = JSON.parse(data.toString());
|
||||||
|
if (parsed && typeof parsed === "object" && "type" in parsed && (parsed as any).type === "pong") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(parsed);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
reject(err);
|
reject(err);
|
||||||
} finally {
|
|
||||||
ws.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getOrCreateServerId } from "./server-id.js";
|
||||||
|
|
||||||
|
function tmpHome(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "paseo-server-id-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getOrCreateServerId", () => {
|
||||||
|
let home: string;
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.PASEO_SERVER_ID;
|
||||||
|
home = tmpHome();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and persists a stable id per PASEO_HOME", () => {
|
||||||
|
const first = getOrCreateServerId(home);
|
||||||
|
const second = getOrCreateServerId(home);
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(first.startsWith("srv_")).toBe(true);
|
||||||
|
|
||||||
|
const idPath = path.join(home, "server-id");
|
||||||
|
expect(existsSync(idPath)).toBe(true);
|
||||||
|
expect(readFileSync(idPath, "utf8").trim()).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects and persists PASEO_SERVER_ID override", () => {
|
||||||
|
process.env.PASEO_SERVER_ID = "test-daemon-id";
|
||||||
|
const id = getOrCreateServerId(home);
|
||||||
|
expect(id).toBe("test-daemon-id");
|
||||||
|
|
||||||
|
const idPath = path.join(home, "server-id");
|
||||||
|
expect(existsSync(idPath)).toBe(true);
|
||||||
|
expect(readFileSync(idPath, "utf8").trim()).toBe("test-daemon-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
type LoggerLike = {
|
||||||
|
child(bindings: Record<string, unknown>): LoggerLike;
|
||||||
|
info(...args: any[]): void;
|
||||||
|
warn(...args: any[]): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_ID_FILENAME = "server-id";
|
||||||
|
|
||||||
|
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||||
|
return logger?.child({ module: "server-id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerIdPath(paseoHome: string): string {
|
||||||
|
return path.join(paseoHome, SERVER_ID_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateServerId(): string {
|
||||||
|
// 9 bytes -> 12 base64url chars; keep it short + URL-safe.
|
||||||
|
const rand = randomBytes(9).toString("base64url");
|
||||||
|
return `srv_${rand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable daemon identifier scoped to a given $PASEO_HOME.
|
||||||
|
*
|
||||||
|
* - Persisted to `$PASEO_HOME/server-id`
|
||||||
|
* - Can be overridden via `PASEO_SERVER_ID` (useful for tests)
|
||||||
|
*/
|
||||||
|
export function getOrCreateServerId(
|
||||||
|
paseoHome: string,
|
||||||
|
options?: { env?: NodeJS.ProcessEnv; logger?: LoggerLike }
|
||||||
|
): string {
|
||||||
|
const env = options?.env ?? process.env;
|
||||||
|
const log = getLogger(options?.logger);
|
||||||
|
const serverIdPath = getServerIdPath(paseoHome);
|
||||||
|
|
||||||
|
const envOverride =
|
||||||
|
typeof env.PASEO_SERVER_ID === "string" && env.PASEO_SERVER_ID.trim().length > 0
|
||||||
|
? env.PASEO_SERVER_ID.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (envOverride) {
|
||||||
|
// Persist the override for consistent identity across restarts.
|
||||||
|
if (!existsSync(serverIdPath)) {
|
||||||
|
try {
|
||||||
|
writeFileSync(serverIdPath, `${envOverride}\n`, "utf8");
|
||||||
|
log?.info({ serverId: envOverride }, "Persisted PASEO_SERVER_ID override");
|
||||||
|
} catch (error) {
|
||||||
|
log?.warn({ error }, "Failed to persist PASEO_SERVER_ID override");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return envOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(serverIdPath)) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(serverIdPath, "utf8");
|
||||||
|
const parsed = raw.trim();
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log?.warn({ error }, "Failed to read server-id file, regenerating");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = generateServerId();
|
||||||
|
try {
|
||||||
|
writeFileSync(serverIdPath, `${created}\n`, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
log?.warn({ error }, "Failed to persist serverId (continuing with in-memory id)");
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
|
serverId: string,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentStorage: AgentStorage,
|
agentStorage: AgentStorage,
|
||||||
downloadTokenStore: DownloadTokenStore,
|
downloadTokenStore: DownloadTokenStore,
|
||||||
@@ -51,6 +52,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.bridge = new WebSocketSessionBridge(
|
this.bridge = new WebSocketSessionBridge(
|
||||||
this.logger,
|
this.logger,
|
||||||
|
serverId,
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
downloadTokenStore,
|
downloadTokenStore,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class WebSocketSessionBridge {
|
|||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly sessions: Map<WebSocket, Session> = new Map();
|
private readonly sessions: Map<WebSocket, Session> = new Map();
|
||||||
private clientIdCounter = 0;
|
private clientIdCounter = 0;
|
||||||
|
private readonly serverId: string;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentStorage;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
@@ -48,6 +49,7 @@ export class WebSocketSessionBridge {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
|
serverId: string,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentStorage: AgentStorage,
|
agentStorage: AgentStorage,
|
||||||
downloadTokenStore: DownloadTokenStore,
|
downloadTokenStore: DownloadTokenStore,
|
||||||
@@ -65,6 +67,7 @@ export class WebSocketSessionBridge {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-session-bridge" });
|
this.logger = logger.child({ module: "websocket-session-bridge" });
|
||||||
|
this.serverId = serverId;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
@@ -121,6 +124,18 @@ export class WebSocketSessionBridge {
|
|||||||
|
|
||||||
this.sessions.set(ws, session);
|
this.sessions.set(ws, session);
|
||||||
|
|
||||||
|
// Advertise stable server identity immediately on connect (used for URL/shareable IDs).
|
||||||
|
this.sendToClient(
|
||||||
|
ws,
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: this.serverId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
connectionLogger.info(
|
connectionLogger.info(
|
||||||
{ clientId, totalSessions: this.sessions.size },
|
{ clientId, totalSessions: this.sessions.size },
|
||||||
"Client connected"
|
"Client connected"
|
||||||
|
|||||||
Reference in New Issue
Block a user