Restore managed agents on launch and recover chat after relay restarts (#93)

This commit is contained in:
Wes
2026-03-17 13:48:02 -07:00
committed by GitHub
parent 6ecd04819e
commit 0d73fe7632
22 changed files with 1131 additions and 493 deletions

View File

@@ -18,6 +18,7 @@ import {
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauri";
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
import type {
CreateManagedAgentInput,
ManagedAgent,
@@ -155,6 +156,24 @@ export function useStopManagedAgentMutation() {
});
}
export function useSetManagedAgentStartOnAppLaunchMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
pubkey,
startOnAppLaunch,
}: {
pubkey: string;
startOnAppLaunch: boolean;
}) => setManagedAgentStartOnAppLaunch(pubkey, startOnAppLaunch),
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
},
});
}
export function useDeleteManagedAgentMutation() {
const queryClient = useQueryClient();

View File

@@ -7,6 +7,7 @@ import {
useManagedAgentsQuery,
useMintManagedAgentTokenMutation,
useRelayAgentsQuery,
useSetManagedAgentStartOnAppLaunchMutation,
useStartManagedAgentMutation,
useStopManagedAgentMutation,
} from "@/features/agents/hooks";
@@ -28,6 +29,7 @@ export function AgentsView() {
const managedAgentsQuery = useManagedAgentsQuery();
const startMutation = useStartManagedAgentMutation();
const stopMutation = useStopManagedAgentMutation();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
const deleteMutation = useDeleteManagedAgentMutation();
const mintTokenMutation = useMintManagedAgentTokenMutation();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
@@ -124,6 +126,32 @@ export function AgentsView() {
}
}
async function handleToggleStartOnAppLaunch(
pubkey: string,
startOnAppLaunch: boolean,
) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
try {
const updated = await startOnLaunchMutation.mutateAsync({
pubkey,
startOnAppLaunch,
});
setActionNoticeMessage(
updated.startOnAppLaunch
? `Will start ${updated.name} automatically when the desktop app opens.`
: `${updated.name} will stay manual-start only.`,
);
} catch (error) {
setActionErrorMessage(
error instanceof Error
? error.message
: "Failed to update startup preference.",
);
}
}
async function handleMintToken(pubkey: string, name: string) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
@@ -177,6 +205,7 @@ export function AgentsView() {
const isActionPending =
startMutation.isPending ||
stopMutation.isPending ||
startOnLaunchMutation.isPending ||
deleteMutation.isPending ||
mintTokenMutation.isPending;
@@ -218,6 +247,9 @@ export function AgentsView() {
onStop={(pubkey) => {
void handleStop(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void handleToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
}}
selectedAgentPubkey={selectedAgent?.pubkey ?? null}
/>

View File

@@ -48,6 +48,7 @@ export function CreateAgentDialog({
const [relayUrl, setRelayUrl] = React.useState("");
const [mintToken, setMintToken] = React.useState(true);
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true);
const [tokenName, setTokenName] = React.useState("");
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
() => new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES),
@@ -128,6 +129,7 @@ export function CreateAgentDialog({
setRelayUrl("");
setMintToken(true);
setSpawnAfterCreate(true);
setStartOnAppLaunch(true);
setTokenName("");
setSelectedScopes(new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES));
setAcpCommand("sprout-acp");
@@ -215,6 +217,7 @@ export function CreateAgentDialog({
tokenName: tokenName.trim() || undefined,
tokenScopes: [...selectedScopes],
spawnAfterCreate,
startOnAppLaunch,
};
const created = await createMutation.mutateAsync(input);
handleOpenChange(false);
@@ -258,12 +261,16 @@ export function CreateAgentDialog({
setMintToken((current) => !current);
}
}}
onToggleStartOnAppLaunch={() => {
setStartOnAppLaunch((current) => !current);
}}
onToggleSpawnAfterCreate={() => {
if (!spawnToggleDisabled) {
setSpawnAfterCreate((current) => !current);
}
}}
prereqs={prereqs}
startOnAppLaunch={startOnAppLaunch}
spawnAfterCreate={spawnAfterCreate}
spawnToggleDisabled={spawnToggleDisabled}
/>

View File

@@ -261,9 +261,11 @@ export function CreateAgentOptionToggles({
mintToken,
mintToggleDisabled,
prereqs,
startOnAppLaunch,
spawnAfterCreate,
spawnToggleDisabled,
onToggleMintToken,
onToggleStartOnAppLaunch,
onToggleSpawnAfterCreate,
}: {
isMintSupported: boolean;
@@ -271,13 +273,15 @@ export function CreateAgentOptionToggles({
mintToken: boolean;
mintToggleDisabled: boolean;
prereqs: ManagedAgentPrereqs | null;
startOnAppLaunch: boolean;
spawnAfterCreate: boolean;
spawnToggleDisabled: boolean;
onToggleMintToken: () => void;
onToggleStartOnAppLaunch: () => void;
onToggleSpawnAfterCreate: () => void;
}) {
return (
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<button
aria-pressed={mintToken}
className={cn(
@@ -299,6 +303,26 @@ export function CreateAgentOptionToggles({
</p>
</button>
<button
aria-pressed={startOnAppLaunch}
className={cn(
"rounded-2xl border px-4 py-3 text-left transition-colors",
startOnAppLaunch
? "border-primary bg-primary/10"
: "border-border/70 bg-background/70",
)}
onClick={onToggleStartOnAppLaunch}
type="button"
>
<p className="text-sm font-semibold tracking-tight">
Start on app launch
</p>
<p className="mt-1 text-sm text-muted-foreground">
Reopen this local ACP harness automatically when the desktop app
starts.
</p>
</button>
<button
aria-pressed={spawnAfterCreate}
className={cn(

View File

@@ -15,6 +15,7 @@ import { formatTimestamp, truncatePubkey } from "./agentUi";
export function ManagedAgentCard({
agent,
onToggleStartOnAppLaunch,
onAddToChannel,
isSelected,
onDelete,
@@ -24,6 +25,7 @@ export function ManagedAgentCard({
onStop,
}: {
agent: ManagedAgent;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
onAddToChannel: (agent: ManagedAgent) => void;
isSelected: boolean;
onDelete: (pubkey: string) => void;
@@ -137,6 +139,19 @@ export function ManagedAgentCard({
Mint token
</Button>
<Button
onClick={() =>
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
}
size="sm"
type="button"
variant="outline"
>
{agent.startOnAppLaunch
? "Launch on open: on"
: "Launch on open: off"}
</Button>
<Button
onClick={() => onDelete(agent.pubkey)}
size="sm"

View File

@@ -21,6 +21,7 @@ export function ManagedAgentsSection({
onSelect,
onStart,
onStop,
onToggleStartOnAppLaunch,
}: {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
@@ -37,6 +38,7 @@ export function ManagedAgentsSection({
onSelect: (pubkey: string) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
}) {
return (
<section className="space-y-4">
@@ -119,6 +121,11 @@ export function ManagedAgentsSection({
onStop(pubkey);
}
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
if (!isActionPending) {
onToggleStartOnAppLaunch(pubkey, startOnAppLaunch);
}
}}
/>
))}

View File

@@ -178,6 +178,17 @@ export function useChannelSubscription(channel: Channel | null) {
let isDisposed = false;
let cleanup: (() => Promise<void>) | undefined;
const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
void syncLatestHistory().catch((error) => {
if (!isDisposed) {
console.error(
"Failed to refresh channel history after reconnecting",
channelId,
error,
);
}
});
});
relayClient
.subscribeToChannel(channelId, (event) => {
@@ -209,6 +220,7 @@ export function useChannelSubscription(channel: Channel | null) {
return () => {
isDisposed = true;
disposeReconnectListener();
if (cleanup) {
void cleanup();
}

View File

@@ -6,8 +6,6 @@ import type { Channel, RelayEvent } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_V2,
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
@@ -40,8 +38,6 @@ function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||
event.kind === KIND_STREAM_MESSAGE_EDIT ||
event.kind === KIND_STREAM_MESSAGE_DIFF
);
}

View File

@@ -1,482 +1,3 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import {
createAuthEvent,
getRelayWsUrl,
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
import {
CHANNEL_EVENT_KINDS,
KIND_STREAM_MESSAGE,
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
import {
getTextPayload,
sortEvents,
type PendingEvent,
type RelaySubscription,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
class RelayClient {
private wsId: number | null = null;
private relayUrl: string | null = null;
private connectPromise: Promise<void> | null = null;
private authRequest: {
pendingEventId: string;
resolve: () => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
async fetchChannelHistory(channelId: string, limit = 50) {
await this.ensureConnected();
return new Promise<RelayEvent[]>((resolve, reject) => {
const subId = `history-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
reject(new Error("Timed out while loading channel history."));
}, 8_000);
this.subscriptions.set(subId, {
mode: "history",
events: [],
resolve,
reject,
timeout,
});
void this.sendRaw([
"REQ",
subId,
this.buildChannelFilter(channelId, limit),
]).catch((error) => {
window.clearTimeout(timeout);
this.subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request channel history."),
);
});
});
}
async sendMessage(
channelId: string,
content: string,
mentionPubkeys: string[] = [],
extraTags: string[][] = [],
) {
await this.ensureConnected();
const tags: string[][] = [["h", channelId]];
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
for (const tag of extraTags) {
tags.push(tag);
}
const event = await signRelayEvent({
kind: KIND_STREAM_MESSAGE,
content: content.trim(),
tags,
});
return this.publishEvent(
event,
"Timed out while sending the message.",
"Failed to send the message.",
);
}
async sendPresence(status: PresenceStatus) {
await this.ensureConnected();
const event = await signRelayEvent({
kind: 20001,
content: status,
tags: [],
});
return this.publishEvent(
event,
"Timed out while updating presence.",
"Failed to update presence.",
);
}
async subscribeToChannel(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent);
}
async subscribeToTypingIndicators(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(
{
kinds: [KIND_TYPING_INDICATOR],
"#h": [channelId],
limit: 10,
},
onEvent,
);
}
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
}
async preconnect() {
await this.ensureConnected();
}
private async ensureConnected() {
if (this.connectPromise) {
return this.connectPromise;
}
if (this.wsId !== null) {
return;
}
this.connectPromise = this.connect();
try {
await this.connectPromise;
} finally {
this.connectPromise = null;
}
}
private async connect() {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const onMessageChannel = new Channel<unknown>((message) => {
void this.handleWsMessage(message);
});
this.wsId = await invoke<number>("plugin:websocket|connect", {
url: this.relayUrl,
onMessage: onMessageChannel,
config: {},
});
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.authRequest = null;
this.resetConnection(
new Error("Timed out while waiting for relay authentication."),
);
reject(new Error("Timed out while waiting for relay authentication."));
}, 8_000);
this.authRequest = {
pendingEventId: "",
resolve,
reject,
timeout,
};
});
}
private buildChannelFilter(
channelId: string,
limit: number,
): RelaySubscriptionFilter {
return {
kinds: [...CHANNEL_EVENT_KINDS],
"#h": [channelId],
limit,
};
}
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {
return {
kinds: [...CHANNEL_EVENT_KINDS],
limit,
};
}
private async subscribe(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
) {
await this.ensureConnected();
const subId = `live-${crypto.randomUUID()}`;
let resolveReady = () => {
return;
};
const ready = new Promise<void>((resolve) => {
resolveReady = () => {
window.clearTimeout(fallbackTimeout);
resolve();
};
});
const fallbackTimeout = window.setTimeout(() => {
resolveReady();
}, 250);
this.subscriptions.set(subId, {
mode: "live",
onEvent,
resolveReady,
});
await this.sendRaw(["REQ", subId, filter]);
await ready;
return async () => {
const active = this.subscriptions.get(subId);
if (!active || active.mode !== "live") {
return;
}
this.subscriptions.delete(subId);
await this.closeSubscription(subId);
};
}
private async sendRaw(payload: unknown[]) {
if (this.wsId === null) {
throw new Error("Relay socket is not connected.");
}
await invoke("plugin:websocket|send", {
id: this.wsId,
message: {
type: "Text",
data: JSON.stringify(payload),
},
});
}
private async closeSubscription(subId: string) {
if (this.wsId === null) {
return;
}
await this.sendRaw(["CLOSE", subId]);
}
private publishEvent(
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
) {
return new Promise<RelayEvent>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingEvents.delete(event.id);
reject(new Error(timeoutMessage));
}, 8_000);
this.pendingEvents.set(event.id, {
event,
resolve,
reject,
timeout,
});
void this.sendRaw(["EVENT", event]).catch((error) => {
window.clearTimeout(timeout);
this.pendingEvents.delete(event.id);
reject(error instanceof Error ? error : new Error(sendErrorMessage));
});
});
}
private async handleWsMessage(message: unknown) {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Close"
) {
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
const payload = getTextPayload(message);
if (!payload) {
return;
}
let data: unknown;
try {
data = JSON.parse(payload);
} catch {
return;
}
if (!Array.isArray(data) || data.length === 0) {
return;
}
const [type, ...rest] = data;
if (type === "AUTH" && typeof rest[0] === "string") {
await this.handleAuthChallenge(rest[0]);
return;
}
if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
this.handleEvent(rest[0], rest[1] as RelayEvent);
return;
}
if (
type === "OK" &&
typeof rest[0] === "string" &&
typeof rest[1] === "boolean"
) {
this.handleOk(
rest[0],
rest[1],
typeof rest[2] === "string" ? rest[2] : "",
);
return;
}
if (type === "EOSE" && typeof rest[0] === "string") {
this.handleEose(rest[0]);
}
}
private async handleAuthChallenge(challenge: string) {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const event = await createAuthEvent({
challenge,
relayUrl: this.relayUrl,
});
if (!this.authRequest) {
return;
}
this.authRequest.pendingEventId = event.id;
await this.sendRaw(["AUTH", event]);
}
private handleEvent(subId: string, event: RelayEvent) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "history") {
subscription.events.push(event);
return;
}
subscription.onEvent(event);
}
private handleEose(subId: string) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "live") {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
return;
}
window.clearTimeout(subscription.timeout);
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
}
private handleOk(eventId: string, success: boolean, message: string) {
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
window.clearTimeout(this.authRequest.timeout);
const authRequest = this.authRequest;
this.authRequest = null;
if (success) {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error);
}
return;
}
const pendingEvent = this.pendingEvents.get(eventId);
if (!pendingEvent) {
return;
}
window.clearTimeout(pendingEvent.timeout);
this.pendingEvents.delete(eventId);
if (success) {
pendingEvent.resolve(pendingEvent.event);
} else {
pendingEvent.reject(new Error(message || "Relay rejected the event."));
}
}
private resetConnection(error: Error) {
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {
return;
},
);
}
this.wsId = null;
if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
this.authRequest.reject(error);
this.authRequest = null;
}
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode === "history") {
window.clearTimeout(subscription.timeout);
subscription.reject(error);
}
this.subscriptions.delete(subId);
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
window.clearTimeout(pendingEvent.timeout);
pendingEvent.reject(error);
this.pendingEvents.delete(eventId);
}
}
}
import { RelayClient } from "@/shared/api/relayClientSession";
export const relayClient = new RelayClient();

View File

@@ -0,0 +1,698 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import {
createAuthEvent,
getRelayWsUrl,
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
import {
CHANNEL_EVENT_KINDS,
KIND_STREAM_MESSAGE,
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
import {
getTextPayload,
sortEvents,
type PendingEvent,
type RelaySubscription,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_REPLAY_SKEW_SECS = 5;
export class RelayClient {
private wsId: number | null = null;
private relayUrl: string | null = null;
private connectPromise: Promise<void> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
private keepAliveRequested = false;
private authRequest: {
pendingEventId: string;
resolve: () => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
private reconnectListeners = new Set<() => void>();
private hasConnectedOnce = false;
private notifyReconnectListeners = false;
async fetchChannelHistory(channelId: string, limit = 50) {
await this.ensureConnected();
return new Promise<RelayEvent[]>((resolve, reject) => {
const subId = `history-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
reject(new Error("Timed out while loading channel history."));
}, 8_000);
this.subscriptions.set(subId, {
mode: "history",
events: [],
resolve,
reject,
timeout,
});
void this.sendRaw([
"REQ",
subId,
this.buildChannelFilter(channelId, limit),
]).catch((error) => {
window.clearTimeout(timeout);
this.subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request channel history."),
);
});
});
}
async sendMessage(
channelId: string,
content: string,
mentionPubkeys: string[] = [],
extraTags: string[][] = [],
) {
await this.ensureConnected();
const tags: string[][] = [["h", channelId]];
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
for (const tag of extraTags) {
tags.push(tag);
}
const event = await signRelayEvent({
kind: KIND_STREAM_MESSAGE,
content: content.trim(),
tags,
});
return this.publishEvent(
event,
"Timed out while sending the message.",
"Failed to send the message.",
);
}
async sendPresence(status: PresenceStatus) {
await this.ensureConnected();
const event = await signRelayEvent({
kind: 20001,
content: status,
tags: [],
});
return this.publishEvent(
event,
"Timed out while updating presence.",
"Failed to update presence.",
);
}
async subscribeToChannel(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent);
}
async subscribeToTypingIndicators(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(
{
kinds: [KIND_TYPING_INDICATOR],
"#h": [channelId],
limit: 10,
},
onEvent,
);
}
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
return this.subscribe(this.buildGlobalStreamFilter(50), onEvent);
}
async preconnect() {
this.keepAliveRequested = true;
await this.ensureConnected();
}
subscribeToReconnects(listener: () => void) {
this.reconnectListeners.add(listener);
return () => {
this.reconnectListeners.delete(listener);
};
}
private async ensureConnected() {
if (this.connectPromise) {
return this.connectPromise;
}
if (this.wsId !== null) {
return;
}
if (this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
const connectPromise = this.connect();
this.connectPromise = connectPromise;
try {
await connectPromise;
} finally {
if (this.connectPromise === connectPromise) {
this.connectPromise = null;
}
}
}
private async connect() {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const onMessageChannel = new Channel<unknown>((message) => {
void this.handleWsMessage(message);
});
this.wsId = await invoke<number>("plugin:websocket|connect", {
url: this.relayUrl,
onMessage: onMessageChannel,
config: {},
});
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.authRequest = null;
this.resetConnection(
new Error("Timed out while waiting for relay authentication."),
);
reject(new Error("Timed out while waiting for relay authentication."));
}, 8_000);
this.authRequest = {
pendingEventId: "",
resolve,
reject,
timeout,
};
});
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
await this.replayLiveSubscriptions();
this.emitReconnectIfNeeded();
}
private buildChannelFilter(
channelId: string,
limit: number,
): RelaySubscriptionFilter {
return {
kinds: [...CHANNEL_EVENT_KINDS],
"#h": [channelId],
limit,
};
}
private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter {
return {
kinds: [...CHANNEL_EVENT_KINDS],
limit,
};
}
private async subscribe(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
) {
await this.ensureConnected();
const subId = `live-${crypto.randomUUID()}`;
let resolveReady = () => {
return;
};
const ready = new Promise<void>((resolve) => {
resolveReady = () => {
window.clearTimeout(fallbackTimeout);
resolve();
};
});
const fallbackTimeout = window.setTimeout(() => {
resolveReady();
}, 250);
this.subscriptions.set(subId, {
mode: "live",
filter,
onEvent,
resolveReady,
});
try {
await this.sendRawWithReconnectRetry(
["REQ", subId, filter],
"Failed to restore relay subscription.",
);
} catch (error) {
window.clearTimeout(fallbackTimeout);
this.subscriptions.delete(subId);
throw error;
}
await ready;
return async () => {
const active = this.subscriptions.get(subId);
if (!active || active.mode !== "live") {
return;
}
this.subscriptions.delete(subId);
await this.closeSubscription(subId);
};
}
private async sendRaw(payload: unknown[]) {
if (this.wsId === null) {
throw new Error("Relay socket is not connected.");
}
await invoke("plugin:websocket|send", {
id: this.wsId,
message: {
type: "Text",
data: JSON.stringify(payload),
},
});
}
private normalizeRelayError(error: unknown, fallbackMessage: string) {
return error instanceof Error ? error : new Error(fallbackMessage);
}
private recoverFromSocketFailure(
error: unknown,
fallbackMessage: string,
): Error {
const normalizedError = this.normalizeRelayError(error, fallbackMessage);
this.resetConnection(normalizedError);
return normalizedError;
}
private async sendRawWithReconnectRetry(
payload: unknown[],
fallbackMessage: string,
) {
try {
await this.sendRaw(payload);
} catch (error) {
const normalizedError = this.recoverFromSocketFailure(
error,
fallbackMessage,
);
try {
await this.ensureConnected();
await this.sendRaw(payload);
} catch (retryError) {
throw this.recoverFromSocketFailure(
retryError,
normalizedError.message,
);
}
}
}
private async closeSubscription(subId: string) {
if (this.wsId === null) {
return;
}
await this.sendRaw(["CLOSE", subId]);
}
private publishEvent(
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
) {
return new Promise<RelayEvent>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingEvents.delete(event.id);
reject(new Error(timeoutMessage));
}, 8_000);
this.pendingEvents.set(event.id, {
event,
resolve,
reject,
timeout,
});
void this.sendRaw(["EVENT", event]).catch(async (error) => {
const pendingEvent = this.pendingEvents.get(event.id);
this.pendingEvents.delete(event.id);
const normalizedError = this.recoverFromSocketFailure(
error,
sendErrorMessage,
);
try {
await this.ensureConnected();
if (!pendingEvent) {
throw normalizedError;
}
this.pendingEvents.set(event.id, pendingEvent);
await this.sendRaw(["EVENT", event]);
} catch (retryError) {
window.clearTimeout(timeout);
this.pendingEvents.delete(event.id);
reject(
this.recoverFromSocketFailure(retryError, normalizedError.message),
);
}
});
});
}
private async handleWsMessage(message: unknown) {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Close"
) {
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
const payload = getTextPayload(message);
if (!payload) {
return;
}
let data: unknown;
try {
data = JSON.parse(payload);
} catch {
return;
}
if (!Array.isArray(data) || data.length === 0) {
return;
}
const [type, ...rest] = data;
if (type === "AUTH" && typeof rest[0] === "string") {
await this.handleAuthChallenge(rest[0]);
return;
}
if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
this.handleEvent(rest[0], rest[1] as RelayEvent);
return;
}
if (
type === "OK" &&
typeof rest[0] === "string" &&
typeof rest[1] === "boolean"
) {
this.handleOk(
rest[0],
rest[1],
typeof rest[2] === "string" ? rest[2] : "",
);
return;
}
if (type === "EOSE" && typeof rest[0] === "string") {
this.handleEose(rest[0]);
}
}
private async handleAuthChallenge(challenge: string) {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const event = await createAuthEvent({
challenge,
relayUrl: this.relayUrl,
});
if (!this.authRequest) {
return;
}
this.authRequest.pendingEventId = event.id;
await this.sendRaw(["AUTH", event]);
}
private handleEvent(subId: string, event: RelayEvent) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "history") {
subscription.events.push(event);
return;
}
subscription.lastSeenCreatedAt = Math.max(
subscription.lastSeenCreatedAt ?? 0,
event.created_at,
);
subscription.onEvent(event);
}
private handleEose(subId: string) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "live") {
subscription.resolveReady?.();
subscription.resolveReady = undefined;
return;
}
window.clearTimeout(subscription.timeout);
this.subscriptions.delete(subId);
void this.closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
}
private handleOk(eventId: string, success: boolean, message: string) {
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
window.clearTimeout(this.authRequest.timeout);
const authRequest = this.authRequest;
this.authRequest = null;
if (success) {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error, { reconnect: false });
}
return;
}
const pendingEvent = this.pendingEvents.get(eventId);
if (!pendingEvent) {
return;
}
window.clearTimeout(pendingEvent.timeout);
this.pendingEvents.delete(eventId);
if (success) {
pendingEvent.resolve(pendingEvent.event);
} else {
pendingEvent.reject(new Error(message || "Relay rejected the event."));
}
}
private hasLiveSubscriptions() {
for (const subscription of this.subscriptions.values()) {
if (subscription.mode === "live") {
return true;
}
}
return false;
}
private buildReplayFilter(filter: RelaySubscriptionFilter, since?: number) {
if (since === undefined) {
return filter;
}
return {
...filter,
since: filter.since === undefined ? since : Math.max(filter.since, since),
};
}
private async replayLiveSubscriptions() {
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode !== "live") {
continue;
}
const replaySince =
subscription.lastSeenCreatedAt === undefined
? undefined
: Math.max(
0,
subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS,
);
try {
await this.sendRaw([
"REQ",
subId,
this.buildReplayFilter(subscription.filter, replaySince),
]);
} catch (error) {
const reconnectError =
error instanceof Error
? error
: new Error("Failed to restore relay subscriptions.");
this.resetConnection(reconnectError);
throw reconnectError;
}
}
}
private scheduleReconnect() {
if (
this.reconnectTimeout ||
this.wsId !== null ||
(!this.keepAliveRequested && !this.hasLiveSubscriptions())
) {
return;
}
const delay = this.reconnectDelayMs;
this.reconnectDelayMs = Math.min(
this.reconnectDelayMs * 2,
RECONNECT_MAX_DELAY_MS,
);
this.reconnectTimeout = window.setTimeout(() => {
this.reconnectTimeout = null;
void this.ensureConnected().catch(() => {
this.scheduleReconnect();
});
}, delay);
}
private emitReconnectIfNeeded() {
const shouldNotifyReconnectListeners =
this.hasConnectedOnce && this.notifyReconnectListeners;
this.hasConnectedOnce = true;
this.notifyReconnectListeners = false;
if (!shouldNotifyReconnectListeners) {
return;
}
for (const listener of this.reconnectListeners) {
try {
listener();
} catch (error) {
console.error("Failed to handle relay reconnect", error);
}
}
}
private resetConnection(
error: Error,
options?: {
reconnect?: boolean;
},
) {
if (options?.reconnect !== false && this.hasConnectedOnce) {
this.notifyReconnectListeners = true;
}
if (options?.reconnect === false && this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {
return;
},
);
}
this.wsId = null;
if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
this.authRequest.reject(error);
this.authRequest = null;
}
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode === "history") {
window.clearTimeout(subscription.timeout);
subscription.reject(error);
this.subscriptions.delete(subId);
continue;
}
subscription.resolveReady?.();
subscription.resolveReady = undefined;
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
window.clearTimeout(pendingEvent.timeout);
pendingEvent.reject(error);
this.pendingEvents.delete(eventId);
}
if (options?.reconnect !== false) {
this.scheduleReconnect();
}
}
}

View File

@@ -4,6 +4,7 @@ export type RelaySubscriptionFilter = {
kinds: number[];
"#h"?: string[];
limit: number;
since?: number;
};
type HistorySubscription = {
@@ -16,8 +17,10 @@ type HistorySubscription = {
type LiveSubscription = {
mode: "live";
filter: RelaySubscriptionFilter;
onEvent: (event: RelayEvent) => void;
resolveReady?: () => void;
lastSeenCreatedAt?: number;
};
export type PendingEvent = {

View File

@@ -217,7 +217,7 @@ type RawRelayAgent = {
status: RelayAgent["status"];
};
type RawManagedAgent = {
export type RawManagedAgent = {
pubkey: string;
name: string;
relay_url: string;
@@ -238,6 +238,7 @@ type RawManagedAgent = {
last_exit_code: number | null;
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
};
type RawCreateManagedAgentResponse = {
@@ -303,7 +304,7 @@ function toTauriError(error: unknown): Error {
}
}
async function invokeTauri<T>(
export async function invokeTauri<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
@@ -735,7 +736,7 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent {
};
}
function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
return {
pubkey: agent.pubkey,
name: agent.name,
@@ -757,6 +758,7 @@ function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
lastExitCode: agent.last_exit_code,
lastError: agent.last_error,
logPath: agent.log_path,
startOnAppLaunch: agent.start_on_app_launch,
};
}
@@ -846,6 +848,7 @@ export async function createManagedAgent(
tokenScopes: input.tokenScopes,
tokenName: input.tokenName,
spawnAfterCreate: input.spawnAfterCreate,
startOnAppLaunch: input.startOnAppLaunch,
},
},
);

View File

@@ -0,0 +1,20 @@
import {
fromRawManagedAgent,
invokeTauri,
type RawManagedAgent,
} from "@/shared/api/tauri";
import type { ManagedAgent } from "@/shared/api/types";
export async function setManagedAgentStartOnAppLaunch(
pubkey: string,
startOnAppLaunch: boolean,
): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>(
"set_managed_agent_start_on_app_launch",
{
pubkey,
startOnAppLaunch,
},
);
return fromRawManagedAgent(response);
}

View File

@@ -272,6 +272,7 @@ export type ManagedAgent = {
lastExitCode: number | null;
lastError: string | null;
logPath: string;
startOnAppLaunch: boolean;
};
export type CreateManagedAgentInput = {
@@ -288,6 +289,7 @@ export type CreateManagedAgentInput = {
tokenScopes?: TokenScope[];
tokenName?: string;
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
};
export type CreateManagedAgentResponse = {

View File

@@ -231,6 +231,7 @@ type RawManagedAgent = {
last_exit_code: number | null;
last_error: string | null;
log_path: string;
start_on_app_launch: boolean;
};
type RawCreateManagedAgentResponse = {
@@ -500,6 +501,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
last_exit_code: agent.last_exit_code,
last_error: agent.last_error,
log_path: agent.log_path,
start_on_app_launch: agent.start_on_app_launch,
};
}
@@ -2142,6 +2144,7 @@ async function handleCreateManagedAgent(args: {
tokenScopes?: string[];
tokenName?: string;
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
};
}): Promise<RawCreateManagedAgentResponse> {
const name = args.input.name.trim();
@@ -2179,6 +2182,7 @@ async function handleCreateManagedAgent(args: {
last_exit_code: null,
last_error: null,
log_path: `/tmp/mock-agent-${pubkey}.log`,
start_on_app_launch: args.input.startOnAppLaunch ?? true,
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
api_token: token,
log_lines: [
@@ -2253,6 +2257,16 @@ async function handleDeleteManagedAgent(args: {
syncMockRelayAgentsFromManagedAgents();
}
async function handleSetManagedAgentStartOnAppLaunch(args: {
pubkey: string;
startOnAppLaunch: boolean;
}): Promise<RawManagedAgent> {
const agent = getMockManagedAgent(args.pubkey);
agent.start_on_app_launch = args.startOnAppLaunch;
agent.updated_at = new Date().toISOString();
return cloneManagedAgent(agent);
}
async function handleMintManagedAgentToken(args: {
input: {
pubkey: string;
@@ -2848,6 +2862,12 @@ export function maybeInstallE2eTauriMocks() {
return handleStopManagedAgent(
payload as Parameters<typeof handleStopManagedAgent>[0],
);
case "set_managed_agent_start_on_app_launch":
return handleSetManagedAgentStartOnAppLaunch(
payload as Parameters<
typeof handleSetManagedAgentStartOnAppLaunch
>[0],
);
case "delete_managed_agent":
return handleDeleteManagedAgent(
payload as Parameters<typeof handleDeleteManagedAgent>[0],