mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Restore managed agents on launch and recover chat after relay restarts (#93)
This commit is contained in:
@@ -36,6 +36,7 @@ const overrides = new Map([
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 650],
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
|
||||
["src/shared/api/tauri.ts", 950],
|
||||
]);
|
||||
|
||||
|
||||
45
desktop/src-tauri/src/commands/agent_settings.rs
Normal file
45
desktop/src-tauri/src/commands/agent_settings.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
build_managed_agent_summary, find_managed_agent_mut, load_managed_agents,
|
||||
save_managed_agents, sync_managed_agent_processes, ManagedAgentSummary,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_managed_agent_start_on_app_launch(
|
||||
pubkey: String,
|
||||
start_on_app_launch: bool,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
{
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
record.start_on_app_launch = start_on_app_launch;
|
||||
record.updated_at = now_iso();
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
build_managed_agent_summary(&app, record, &runtimes)
|
||||
}
|
||||
@@ -224,6 +224,8 @@ pub async fn create_managed_agent(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
start_on_app_launch: input.start_on_app_launch,
|
||||
runtime_pid: None,
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agent_settings;
|
||||
mod agents;
|
||||
mod channels;
|
||||
mod identity;
|
||||
@@ -6,6 +7,7 @@ mod messages;
|
||||
mod profile;
|
||||
mod tokens;
|
||||
|
||||
pub use agent_settings::*;
|
||||
pub use agents::*;
|
||||
pub use channels::*;
|
||||
pub use identity::*;
|
||||
|
||||
@@ -5,13 +5,86 @@ mod models;
|
||||
mod relay;
|
||||
mod util;
|
||||
|
||||
use app_state::build_app_state;
|
||||
use app_state::{build_app_state, AppState};
|
||||
use commands::*;
|
||||
use managed_agents::{
|
||||
find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process,
|
||||
stop_managed_agent_process, sync_managed_agent_processes,
|
||||
};
|
||||
use tauri::{Manager, RunEvent};
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
|
||||
let pubkeys_to_restore = records
|
||||
.iter()
|
||||
.filter(|record| record.start_on_app_launch)
|
||||
.map(|record| record.pubkey.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for pubkey in pubkeys_to_restore {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
match start_managed_agent_process(app, record, &mut runtimes) {
|
||||
Ok(()) => {
|
||||
changed = true;
|
||||
}
|
||||
Err(error) => {
|
||||
record.updated_at = util::now_iso();
|
||||
record.last_error = Some(error);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
|
||||
|
||||
for record in records.iter_mut() {
|
||||
if record.runtime_pid.is_none() && !runtimes.contains_key(&record.pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
stop_managed_agent_process(record, &mut runtimes)?;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
@@ -21,6 +94,14 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(build_app_state())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(error) = restore_managed_agents_on_launch(&app_handle) {
|
||||
eprintln!("sprout-desktop: failed to restore managed agents: {error}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_identity,
|
||||
get_profile,
|
||||
@@ -67,12 +148,21 @@ pub fn run() {
|
||||
create_managed_agent,
|
||||
start_managed_agent,
|
||||
stop_managed_agent,
|
||||
set_managed_agent_start_on_app_launch,
|
||||
delete_managed_agent,
|
||||
mint_managed_agent_token,
|
||||
get_managed_agent_log,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
if matches!(event, RunEvent::ExitRequested { .. }) {
|
||||
if let Err(error) = shutdown_managed_agents(app_handle) {
|
||||
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, process::Command};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
@@ -11,6 +11,58 @@ use crate::{
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_is_running(pid: u32) -> bool {
|
||||
Command::new("kill")
|
||||
.arg("-0")
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn process_is_running(_pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process(pid: u32) -> Result<(), String> {
|
||||
let pid_arg = pid.to_string();
|
||||
let status = Command::new("kill")
|
||||
.arg("-TERM")
|
||||
.arg(&pid_arg)
|
||||
.status()
|
||||
.map_err(|error| format!("failed to terminate process {pid}: {error}"))?;
|
||||
if !status.success() && process_is_running(pid) {
|
||||
return Err(format!("failed to terminate process {pid}: signal was rejected"));
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
if !process_is_running(pid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let kill_status = Command::new("kill")
|
||||
.arg("-KILL")
|
||||
.arg(&pid_arg)
|
||||
.status()
|
||||
.map_err(|error| format!("failed to kill process {pid}: {error}"))?;
|
||||
if !kill_status.success() && process_is_running(pid) {
|
||||
return Err(format!("failed to kill process {pid}: signal was rejected"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process(_pid: u32) -> Result<(), String> {
|
||||
Err("managed agent shutdown after app restart is only supported on Unix".to_string())
|
||||
}
|
||||
|
||||
pub fn sync_managed_agent_processes(
|
||||
records: &mut [ManagedAgentRecord],
|
||||
runtimes: &mut HashMap<String, ManagedAgentProcess>,
|
||||
@@ -38,6 +90,7 @@ pub fn sync_managed_agent_processes(
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) {
|
||||
record.updated_at = now_iso();
|
||||
record.runtime_pid = None;
|
||||
record.last_stopped_at = Some(now_iso());
|
||||
record.last_exit_code = status.code();
|
||||
record.last_error = if status.success() {
|
||||
@@ -55,6 +108,27 @@ pub fn sync_managed_agent_processes(
|
||||
runtimes.remove(&pubkey);
|
||||
}
|
||||
|
||||
for record in records.iter_mut() {
|
||||
if runtimes.contains_key(&record.pubkey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(pid) = record.runtime_pid else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if process_is_running(pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
record.runtime_pid = None;
|
||||
record.updated_at = now_iso();
|
||||
if record.last_stopped_at.is_none() {
|
||||
record.last_stopped_at = Some(now_iso());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -63,12 +137,21 @@ pub fn build_managed_agent_summary(
|
||||
record: &ManagedAgentRecord,
|
||||
runtimes: &HashMap<String, ManagedAgentProcess>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid));
|
||||
let (status, pid, log_path) = if let Some(runtime) = runtimes.get(&record.pubkey) {
|
||||
(
|
||||
"running".to_string(),
|
||||
Some(runtime.child.id()),
|
||||
runtime.log_path.display().to_string(),
|
||||
)
|
||||
} else if let Some(pid) = persisted_pid {
|
||||
(
|
||||
"running".to_string(),
|
||||
Some(pid),
|
||||
managed_agent_log_path(app, &record.pubkey)?
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"stopped".to_string(),
|
||||
@@ -99,6 +182,7 @@ pub fn build_managed_agent_summary(
|
||||
last_stopped_at: record.last_stopped_at.clone(),
|
||||
last_exit_code: record.last_exit_code,
|
||||
last_error: record.last_error.clone(),
|
||||
start_on_app_launch: record.start_on_app_launch,
|
||||
log_path,
|
||||
})
|
||||
}
|
||||
@@ -131,6 +215,16 @@ pub fn start_managed_agent_process(
|
||||
runtimes.remove(&record.pubkey);
|
||||
}
|
||||
|
||||
if let Some(pid) = record.runtime_pid {
|
||||
if process_is_running(pid) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
record.runtime_pid = None;
|
||||
}
|
||||
|
||||
let log_path = managed_agent_log_path(app, &record.pubkey)?;
|
||||
append_log_marker(
|
||||
&log_path,
|
||||
@@ -198,6 +292,7 @@ pub fn start_managed_agent_process(
|
||||
|
||||
let now = now_iso();
|
||||
record.updated_at = now.clone();
|
||||
record.runtime_pid = Some(child.id());
|
||||
record.last_started_at = Some(now);
|
||||
record.last_stopped_at = None;
|
||||
record.last_exit_code = None;
|
||||
@@ -215,6 +310,18 @@ pub fn stop_managed_agent_process(
|
||||
runtimes: &mut HashMap<String, ManagedAgentProcess>,
|
||||
) -> Result<(), String> {
|
||||
let Some(mut runtime) = runtimes.remove(&record.pubkey) else {
|
||||
if let Some(pid) = record.runtime_pid {
|
||||
if process_is_running(pid) {
|
||||
terminate_process(pid)?;
|
||||
}
|
||||
|
||||
let now = now_iso();
|
||||
record.runtime_pid = None;
|
||||
record.updated_at = now.clone();
|
||||
record.last_stopped_at = Some(now);
|
||||
record.last_exit_code = None;
|
||||
record.last_error = None;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -224,6 +331,7 @@ pub fn stop_managed_agent_process(
|
||||
.wait()
|
||||
.map_err(|error| format!("failed to wait for agent shutdown: {error}"))?;
|
||||
let now = now_iso();
|
||||
record.runtime_pid = None;
|
||||
record.updated_at = now.clone();
|
||||
record.last_stopped_at = Some(now);
|
||||
record.last_exit_code = status.code();
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct ManagedAgentRecord {
|
||||
#[serde(default = "default_agent_parallelism")]
|
||||
pub parallelism: u32,
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default = "default_start_on_app_launch")]
|
||||
pub start_on_app_launch: bool,
|
||||
#[serde(default)]
|
||||
pub runtime_pid: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_started_at: Option<String>,
|
||||
@@ -62,6 +66,7 @@ pub struct ManagedAgentSummary {
|
||||
pub last_stopped_at: Option<String>,
|
||||
pub last_exit_code: Option<i32>,
|
||||
pub last_error: Option<String>,
|
||||
pub start_on_app_launch: bool,
|
||||
pub log_path: String,
|
||||
}
|
||||
|
||||
@@ -85,6 +90,8 @@ pub struct CreateManagedAgentRequest {
|
||||
pub token_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub spawn_after_create: bool,
|
||||
#[serde(default = "default_start_on_app_launch")]
|
||||
pub start_on_app_launch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -167,3 +174,7 @@ pub const DEFAULT_AGENT_PARALLELISM: u32 = 1;
|
||||
fn default_agent_parallelism() -> u32 {
|
||||
DEFAULT_AGENT_PARALLELISM
|
||||
}
|
||||
|
||||
fn default_start_on_app_launch() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
698
desktop/src/shared/api/relayClientSession.ts
Normal file
698
desktop/src/shared/api/relayClientSession.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
20
desktop/src/shared/api/tauriManagedAgents.ts
Normal file
20
desktop/src/shared/api/tauriManagedAgents.ts
Normal 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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user