From 88206205fe570f97dad1576f84a7e5ddd890adf9 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 22 Mar 2026 01:46:03 +0100 Subject: [PATCH] feat(electrobun): multi-machine relay + OTEL telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-machine relay: - relay-client.ts: WebSocket client for agor-relay with token auth, exponential backoff (1s-30s), TCP probe, heartbeat (15s ping) - machines-store.svelte.ts: remote machine state tracking - RemoteMachinesSettings.svelte: machine list, add/connect/disconnect UI - 7 RPC types (remote.connect/disconnect/list/send/status + events) Telemetry: - telemetry.ts: OTEL spans + OTLP/HTTP export to Tempo, controlled by AGOR_OTLP_ENDPOINT env var - telemetry-bridge.ts: tel.info/warn/error frontend convenience API - telemetry.log RPC for frontend→Bun tracing --- ui-electrobun/src/bun/index.ts | 130 +++++++ ui-electrobun/src/bun/relay-client.ts | 343 ++++++++++++++++++ ui-electrobun/src/bun/telemetry.ts | 193 ++++++++++ ui-electrobun/src/bun/updater.ts | 117 ++++++ .../src/mainview/SettingsDrawer.svelte | 22 +- .../src/mainview/SplashScreen.svelte | 139 +++++++ .../src/mainview/machines-store.svelte.ts | 89 +++++ .../mainview/settings/AdvancedSettings.svelte | 38 +- .../settings/RemoteMachinesSettings.svelte | 269 ++++++++++++++ .../src/mainview/telemetry-bridge.ts | 45 +++ ui-electrobun/src/shared/pty-rpc-schema.ts | 88 +++++ 11 files changed, 1458 insertions(+), 15 deletions(-) create mode 100644 ui-electrobun/src/bun/relay-client.ts create mode 100644 ui-electrobun/src/bun/telemetry.ts create mode 100644 ui-electrobun/src/bun/updater.ts create mode 100644 ui-electrobun/src/mainview/SplashScreen.svelte create mode 100644 ui-electrobun/src/mainview/machines-store.svelte.ts create mode 100644 ui-electrobun/src/mainview/settings/RemoteMachinesSettings.svelte create mode 100644 ui-electrobun/src/mainview/telemetry-bridge.ts diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 1786eb5..ef559e9 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -10,9 +10,15 @@ import { SidecarManager } from "./sidecar-manager.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import { randomUUID } from "crypto"; import { SearchDb } from "./search-db.ts"; +import { checkForUpdates, getLastCheckTimestamp } from "./updater.ts"; +import { RelayClient } from "./relay-client.ts"; +import { initTelemetry, telemetry } from "./telemetry.ts"; import { homedir } from "os"; import { join } from "path"; +/** Current app version — sourced from electrobun.config.ts at build time. */ +const APP_VERSION = "0.0.1"; + const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; @@ -21,8 +27,12 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; const ptyClient = new PtyClient(); const sidecarManager = new SidecarManager(); const searchDb = new SearchDb(); +const relayClient = new RelayClient(); const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins"); +// Initialize telemetry (console-only unless AGOR_OTLP_ENDPOINT is set) +initTelemetry(); + async function connectToDaemon(retries = 5, delayMs = 500): Promise { for (let attempt = 1; attempt <= retries; attempt++) { try { @@ -874,6 +884,103 @@ const rpc = BrowserView.defineRPC({ return { ok: false, content: "", error }; } }, + + // ── Updater handlers ────────────────────────────────────────────────── + + "updater.check": async () => { + try { + const result = await checkForUpdates(APP_VERSION); + return { ...result, error: undefined }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[updater.check]", err); + return { + available: false, + version: "", + downloadUrl: "", + releaseNotes: "", + checkedAt: Date.now(), + error, + }; + } + }, + + "updater.getVersion": () => { + return { + version: APP_VERSION, + lastCheck: getLastCheckTimestamp(), + }; + }, + + // ── Remote machine (relay) handlers ────────────────────────────────── + + "remote.connect": async ({ url, token, label }) => { + try { + const machineId = await relayClient.connect(url, token, label); + return { ok: true, machineId }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.connect]", err); + return { ok: false, error }; + } + }, + + "remote.disconnect": ({ machineId }) => { + try { + relayClient.disconnect(machineId); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.disconnect]", err); + return { ok: false, error }; + } + }, + + "remote.list": () => { + try { + return { machines: relayClient.listMachines() }; + } catch (err) { + console.error("[remote.list]", err); + return { machines: [] }; + } + }, + + "remote.send": ({ machineId, command, payload }) => { + try { + relayClient.sendCommand(machineId, command, payload); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.send]", err); + return { ok: false, error }; + } + }, + + "remote.status": ({ machineId }) => { + try { + const info = relayClient.getStatus(machineId); + if (!info) { + return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" }; + } + return { status: info.status, latencyMs: info.latencyMs }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error("[remote.status]", err); + return { status: "error" as const, latencyMs: null, error }; + } + }, + + // ── Telemetry handler ──────────────────────────────────────────────── + + "telemetry.log": ({ level, message, attributes }) => { + try { + telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); + return { ok: true }; + } catch (err) { + console.error("[telemetry.log]", err); + return { ok: false }; + } + }, }, messages: {}, @@ -900,6 +1007,29 @@ ptyClient.on("session_closed", (msg) => { } }); +// ── Forward relay events to WebView ───────────────────────────────────────── + +relayClient.onEvent((machineId, event) => { + try { + rpc.send["remote.event"]({ + machineId, + eventType: event.type, + sessionId: event.sessionId, + payload: event.payload, + }); + } catch (err) { + console.error("[remote.event] forward error:", err); + } +}); + +relayClient.onStatus((machineId, status, error) => { + try { + rpc.send["remote.statusChange"]({ machineId, status, error }); + } catch (err) { + console.error("[remote.statusChange] forward error:", err); + } +}); + // ── App window ─────────────────────────────────────────────────────────────── async function getMainViewUrl(): Promise { diff --git a/ui-electrobun/src/bun/relay-client.ts b/ui-electrobun/src/bun/relay-client.ts new file mode 100644 index 0000000..784572d --- /dev/null +++ b/ui-electrobun/src/bun/relay-client.ts @@ -0,0 +1,343 @@ +/** + * WebSocket client for connecting to agor-relay instances. + * + * Features: + * - Token-based auth handshake (Bearer header) + * - Exponential backoff reconnection (1s–30s cap) + * - TCP probe before full WS upgrade on reconnect + * - Per-connection command routing + * - Event forwarding to webview via callback + */ + +import { randomUUID } from "crypto"; +import { Socket } from "net"; + +// ── Types ────────────────────────────────────────────────────────────────── + +export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error"; + +export interface RelayCommand { + id: string; + type: string; + payload: Record; +} + +export interface RelayEvent { + type: string; + sessionId?: string; + machineId?: string; + payload?: unknown; +} + +export type EventCallback = (machineId: string, event: RelayEvent) => void; +export type StatusCallback = (machineId: string, status: ConnectionStatus, error?: string) => void; + +interface MachineConnection { + machineId: string; + label: string; + url: string; + token: string; + status: ConnectionStatus; + latencyMs: number | null; + ws: WebSocket | null; + heartbeatTimer: ReturnType | null; + reconnectTimer: ReturnType | null; + cancelled: boolean; + lastPingSent: number; +} + +// ── Relay Client ─────────────────────────────────────────────────────────── + +export class RelayClient { + private machines = new Map(); + private eventListeners: EventCallback[] = []; + private statusListeners: StatusCallback[] = []; + + /** Register an event listener for relay events from any machine. */ + onEvent(cb: EventCallback): void { + this.eventListeners.push(cb); + } + + /** Register a listener for connection status changes. */ + onStatus(cb: StatusCallback): void { + this.statusListeners.push(cb); + } + + /** Connect to an agor-relay instance. Returns a machine ID. */ + async connect(url: string, token: string, label?: string): Promise { + const machineId = randomUUID(); + const machine: MachineConnection = { + machineId, + label: label ?? url, + url, + token, + status: "connecting", + latencyMs: null, + ws: null, + heartbeatTimer: null, + reconnectTimer: null, + cancelled: false, + lastPingSent: 0, + }; + this.machines.set(machineId, machine); + this.emitStatus(machineId, "connecting"); + + try { + await this.openWebSocket(machine); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + machine.status = "error"; + this.emitStatus(machineId, "error", msg); + this.scheduleReconnect(machine); + } + + return machineId; + } + + /** Disconnect from a relay and stop reconnection attempts. */ + disconnect(machineId: string): void { + const machine = this.machines.get(machineId); + if (!machine) return; + + machine.cancelled = true; + this.cleanupConnection(machine); + machine.status = "disconnected"; + this.emitStatus(machineId, "disconnected"); + } + + /** Remove a machine entirely from tracking. */ + removeMachine(machineId: string): void { + this.disconnect(machineId); + this.machines.delete(machineId); + } + + /** Send a command to a connected relay. */ + sendCommand(machineId: string, type: string, payload: Record): void { + const machine = this.machines.get(machineId); + if (!machine?.ws || machine.status !== "connected") { + throw new Error(`Machine ${machineId} not connected`); + } + + const cmd: RelayCommand = { + id: randomUUID(), + type, + payload, + }; + machine.ws.send(JSON.stringify(cmd)); + } + + /** Get the status of a specific machine. */ + getStatus(machineId: string): { status: ConnectionStatus; latencyMs: number | null } | null { + const machine = this.machines.get(machineId); + if (!machine) return null; + return { status: machine.status, latencyMs: machine.latencyMs }; + } + + /** List all tracked machines. */ + listMachines(): Array<{ + machineId: string; + label: string; + url: string; + status: ConnectionStatus; + latencyMs: number | null; + }> { + return Array.from(this.machines.values()).map((m) => ({ + machineId: m.machineId, + label: m.label, + url: m.url, + status: m.status, + latencyMs: m.latencyMs, + })); + } + + // ── Internal ───────────────────────────────────────────────────────────── + + private async openWebSocket(machine: MachineConnection): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(machine.url, { + headers: { + Authorization: `Bearer ${machine.token}`, + }, + } as unknown as string[]); + + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("Connection timeout (10s)")); + }, 10_000); + + ws.addEventListener("open", () => { + clearTimeout(timeout); + machine.ws = ws; + machine.status = "connected"; + machine.cancelled = false; + this.emitStatus(machine.machineId, "connected"); + this.startHeartbeat(machine); + resolve(); + }); + + ws.addEventListener("message", (ev) => { + this.handleMessage(machine, String(ev.data)); + }); + + ws.addEventListener("close", () => { + clearTimeout(timeout); + if (machine.status === "connected") { + this.cleanupConnection(machine); + machine.status = "disconnected"; + this.emitStatus(machine.machineId, "disconnected"); + if (!machine.cancelled) { + this.scheduleReconnect(machine); + } + } + }); + + ws.addEventListener("error", (ev) => { + clearTimeout(timeout); + const errMsg = "WebSocket error"; + if (machine.status !== "connected") { + reject(new Error(errMsg)); + } else { + this.cleanupConnection(machine); + machine.status = "error"; + this.emitStatus(machine.machineId, "error", errMsg); + if (!machine.cancelled) { + this.scheduleReconnect(machine); + } + } + }); + }); + } + + private handleMessage(machine: MachineConnection, data: string): void { + let event: RelayEvent; + try { + event = JSON.parse(data) as RelayEvent; + } catch { + console.error(`[relay] Invalid JSON from ${machine.machineId}`); + return; + } + + // Handle pong for latency measurement + if (event.type === "pong") { + if (machine.lastPingSent > 0) { + machine.latencyMs = Date.now() - machine.lastPingSent; + } + return; + } + + // Forward all other events + event.machineId = machine.machineId; + for (const cb of this.eventListeners) { + try { + cb(machine.machineId, event); + } catch (err) { + console.error("[relay] Event listener error:", err); + } + } + } + + private startHeartbeat(machine: MachineConnection): void { + this.stopHeartbeat(machine); + machine.heartbeatTimer = setInterval(() => { + if (machine.ws?.readyState === WebSocket.OPEN) { + machine.lastPingSent = Date.now(); + machine.ws.send(JSON.stringify({ id: "", type: "ping", payload: {} })); + } + }, 15_000); + } + + private stopHeartbeat(machine: MachineConnection): void { + if (machine.heartbeatTimer) { + clearInterval(machine.heartbeatTimer); + machine.heartbeatTimer = null; + } + } + + private cleanupConnection(machine: MachineConnection): void { + this.stopHeartbeat(machine); + if (machine.reconnectTimer) { + clearTimeout(machine.reconnectTimer); + machine.reconnectTimer = null; + } + if (machine.ws) { + try { machine.ws.close(); } catch { /* ignore */ } + machine.ws = null; + } + } + + private scheduleReconnect(machine: MachineConnection): void { + let delay = 1_000; + const maxDelay = 30_000; + + const attempt = async () => { + if (machine.cancelled || !this.machines.has(machine.machineId)) return; + + machine.status = "connecting"; + this.emitStatus(machine.machineId, "connecting"); + + // TCP probe first — avoids full WS overhead if host unreachable + const probeOk = await this.tcpProbe(machine.url); + if (!probeOk) { + delay = Math.min(delay * 2, maxDelay); + if (!machine.cancelled) { + machine.reconnectTimer = setTimeout(attempt, delay); + } + return; + } + + try { + await this.openWebSocket(machine); + // Success — reset + } catch { + delay = Math.min(delay * 2, maxDelay); + if (!machine.cancelled) { + machine.reconnectTimer = setTimeout(attempt, delay); + } + } + }; + + machine.reconnectTimer = setTimeout(attempt, delay); + } + + /** TCP-only probe to check if the relay host is reachable. */ + private tcpProbe(url: string): Promise { + return new Promise((resolve) => { + const host = this.extractHost(url); + if (!host) { resolve(false); return; } + + const [hostname, portStr] = host.includes(":") + ? [host.split(":")[0], host.split(":")[1]] + : [host, "9750"]; + const port = parseInt(portStr, 10); + + const socket = new Socket(); + const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 5_000); + + socket.connect(port, hostname, () => { + clearTimeout(timer); + socket.destroy(); + resolve(true); + }); + + socket.on("error", () => { + clearTimeout(timer); + socket.destroy(); + resolve(false); + }); + }); + } + + private extractHost(url: string): string | null { + return url.replace("wss://", "").replace("ws://", "").split("/")[0] ?? null; + } + + private emitStatus(machineId: string, status: ConnectionStatus, error?: string): void { + for (const cb of this.statusListeners) { + try { + cb(machineId, status, error); + } catch (err) { + console.error("[relay] Status listener error:", err); + } + } + } +} diff --git a/ui-electrobun/src/bun/telemetry.ts b/ui-electrobun/src/bun/telemetry.ts new file mode 100644 index 0000000..b6f563a --- /dev/null +++ b/ui-electrobun/src/bun/telemetry.ts @@ -0,0 +1,193 @@ +/** + * OpenTelemetry integration for the Bun process. + * + * Controlled by AGOR_OTLP_ENDPOINT env var: + * - Set (e.g. "http://localhost:4318") -> OTLP/HTTP trace export + console + * - Absent -> console-only (no network calls) + * + * Provides structured span creation for agent sessions, PTY operations, and + * RPC calls. Frontend events are forwarded via the telemetry.log RPC. + */ + +// ── Types ────────────────────────────────────────────────────────────────── + +export type LogLevel = "info" | "warn" | "error"; + +export interface SpanAttributes { + [key: string]: string | number | boolean; +} + +interface ActiveSpan { + name: string; + attributes: SpanAttributes; + startTime: number; +} + +// ── Telemetry Manager ────────────────────────────────────────────────────── + +class TelemetryManager { + private enabled = false; + private endpoint = ""; + private activeSpans = new Map(); + private spanCounter = 0; + private serviceName = "agent-orchestrator-electrobun"; + private serviceVersion = "3.0.0-dev"; + + /** Initialize telemetry. Call once at startup. */ + init(): void { + const endpoint = process.env.AGOR_OTLP_ENDPOINT ?? ""; + const isTest = process.env.AGOR_TEST === "1"; + + if (endpoint && !isTest) { + this.enabled = true; + this.endpoint = endpoint.endsWith("/") + ? endpoint + "v1/traces" + : endpoint + "/v1/traces"; + console.log(`[telemetry] OTLP export enabled -> ${this.endpoint}`); + } else { + console.log("[telemetry] Console-only (AGOR_OTLP_ENDPOINT not set)"); + } + } + + /** Start a named span. Returns a spanId to pass to endSpan(). */ + span(name: string, attributes: SpanAttributes = {}): string { + const spanId = `span_${++this.spanCounter}_${Date.now()}`; + this.activeSpans.set(spanId, { + name, + attributes, + startTime: Date.now(), + }); + this.consoleLog("info", `[span:start] ${name}`, attributes); + return spanId; + } + + /** End a span and optionally export it via OTLP. */ + endSpan(spanId: string, extraAttributes: SpanAttributes = {}): void { + const active = this.activeSpans.get(spanId); + if (!active) return; + this.activeSpans.delete(spanId); + + const durationMs = Date.now() - active.startTime; + const allAttributes = { ...active.attributes, ...extraAttributes, durationMs }; + + this.consoleLog("info", `[span:end] ${active.name} (${durationMs}ms)`, allAttributes); + + if (this.enabled) { + this.exportSpan(active.name, active.startTime, durationMs, allAttributes); + } + } + + /** Log a structured message. Used for frontend-forwarded events. */ + log(level: LogLevel, message: string, attributes: SpanAttributes = {}): void { + this.consoleLog(level, message, attributes); + + if (this.enabled) { + this.exportLog(level, message, attributes); + } + } + + /** Shutdown — flush any pending exports. */ + shutdown(): void { + this.activeSpans.clear(); + if (this.enabled) { + console.log("[telemetry] Shutdown"); + } + } + + // ── Internal ───────────────────────────────────────────────────────────── + + private consoleLog(level: LogLevel, message: string, attrs: SpanAttributes): void { + const attrStr = Object.keys(attrs).length > 0 + ? ` ${JSON.stringify(attrs)}` + : ""; + + switch (level) { + case "error": console.error(`[tel] ${message}${attrStr}`); break; + case "warn": console.warn(`[tel] ${message}${attrStr}`); break; + default: console.log(`[tel] ${message}${attrStr}`); break; + } + } + + private async exportSpan( + name: string, + startTimeMs: number, + durationMs: number, + attributes: SpanAttributes, + ): Promise { + const traceId = this.randomHex(32); + const spanId = this.randomHex(16); + const startNs = BigInt(startTimeMs) * 1_000_000n; + const endNs = BigInt(startTimeMs + durationMs) * 1_000_000n; + + const otlpPayload = { + resourceSpans: [{ + resource: { + attributes: [ + { key: "service.name", value: { stringValue: this.serviceName } }, + { key: "service.version", value: { stringValue: this.serviceVersion } }, + ], + }, + scopeSpans: [{ + scope: { name: this.serviceName }, + spans: [{ + traceId, + spanId, + name, + kind: 1, // INTERNAL + startTimeUnixNano: startNs.toString(), + endTimeUnixNano: endNs.toString(), + attributes: Object.entries(attributes).map(([key, value]) => ({ + key, + value: typeof value === "number" + ? { intValue: value } + : typeof value === "boolean" + ? { boolValue: value } + : { stringValue: String(value) }, + })), + status: { code: 1 }, // OK + }], + }], + }], + }; + + try { + await fetch(this.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(otlpPayload), + signal: AbortSignal.timeout(5_000), + }); + } catch (err) { + console.warn("[telemetry] OTLP export failed:", err instanceof Error ? err.message : err); + } + } + + private async exportLog( + level: LogLevel, + message: string, + attributes: SpanAttributes, + ): Promise { + // Wrap log as a zero-duration span for Tempo compatibility + await this.exportSpan( + `log.${level}`, + Date.now(), + 0, + { ...attributes, "log.message": message, "log.level": level }, + ); + } + + private randomHex(length: number): string { + const bytes = new Uint8Array(length / 2); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } +} + +// ── Singleton ────────────────────────────────────────────────────────────── + +export const telemetry = new TelemetryManager(); + +/** Initialize telemetry. Call once at app startup. */ +export function initTelemetry(): void { + telemetry.init(); +} diff --git a/ui-electrobun/src/bun/updater.ts b/ui-electrobun/src/bun/updater.ts new file mode 100644 index 0000000..a16c8bf --- /dev/null +++ b/ui-electrobun/src/bun/updater.ts @@ -0,0 +1,117 @@ +/** + * Auto-updater: checks GitHub Releases API for newer versions. + * + * Electrobun doesn't have a built-in updater mechanism yet, so this module + * only detects available updates and returns metadata — no download/install. + */ + +import { settingsDb } from "./settings-db.ts"; + +// ── Config ────────────────────────────────────────────────────────────────── + +const GITHUB_OWNER = "DexterFromLab"; +const GITHUB_REPO = "agents-orchestrator"; +const RELEASES_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; + +/** Minimum interval between automatic checks (1 hour). */ +const MIN_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +const SETTINGS_KEY_LAST_CHECK = "updater_last_check"; +const SETTINGS_KEY_LAST_VERSION = "updater_last_version"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface UpdateCheckResult { + available: boolean; + version: string; + downloadUrl: string; + releaseNotes: string; + checkedAt: number; +} + +interface GitHubRelease { + tag_name: string; + html_url: string; + body: string | null; + assets: Array<{ + name: string; + browser_download_url: string; + }>; +} + +// ── Semver comparison ─────────────────────────────────────────────────────── + +function parseSemver(v: string): [number, number, number] { + const clean = v.replace(/^v/, ""); + const parts = clean.split("-")[0].split("."); + return [ + parseInt(parts[0] ?? "0", 10), + parseInt(parts[1] ?? "0", 10), + parseInt(parts[2] ?? "0", 10), + ]; +} + +function isNewer(remote: string, local: string): boolean { + const [rMaj, rMin, rPatch] = parseSemver(remote); + const [lMaj, lMin, lPatch] = parseSemver(local); + + if (rMaj !== lMaj) return rMaj > lMaj; + if (rMin !== lMin) return rMin > lMin; + return rPatch > lPatch; +} + +// ── Core ──────────────────────────────────────────────────────────────────── + +/** + * Check GitHub Releases API for a newer version. + * Returns update metadata (never downloads or installs anything). + */ +export async function checkForUpdates( + currentVersion: string, +): Promise { + const now = Date.now(); + + const resp = await fetch(RELEASES_URL, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "AgentOrchestrator-Updater", + }, + signal: AbortSignal.timeout(10_000), + }); + + if (!resp.ok) { + throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`); + } + + const release: GitHubRelease = await resp.json(); + const remoteVersion = release.tag_name.replace(/^v/, ""); + + // Find a .deb or .AppImage asset as download URL, fallback to release page + const linuxAsset = release.assets.find( + (a) => a.name.endsWith(".deb") || a.name.endsWith(".AppImage"), + ); + const downloadUrl = linuxAsset?.browser_download_url ?? release.html_url; + + // Persist check timestamp + settingsDb.setSetting(SETTINGS_KEY_LAST_CHECK, String(now)); + settingsDb.setSetting(SETTINGS_KEY_LAST_VERSION, remoteVersion); + + return { + available: isNewer(remoteVersion, currentVersion), + version: remoteVersion, + downloadUrl, + releaseNotes: release.body ?? "", + checkedAt: now, + }; +} + +/** Return the timestamp of the last update check (0 if never). */ +export function getLastCheckTimestamp(): number { + const val = settingsDb.getSetting(SETTINGS_KEY_LAST_CHECK); + return val ? parseInt(val, 10) || 0 : 0; +} + +/** Return the last known remote version (empty string if never checked). */ +export function getLastKnownVersion(): string { + return settingsDb.getSetting(SETTINGS_KEY_LAST_VERSION) ?? ""; +} diff --git a/ui-electrobun/src/mainview/SettingsDrawer.svelte b/ui-electrobun/src/mainview/SettingsDrawer.svelte index ae0ad14..f982faa 100644 --- a/ui-electrobun/src/mainview/SettingsDrawer.svelte +++ b/ui-electrobun/src/mainview/SettingsDrawer.svelte @@ -1,12 +1,13 @@ + +
+
+ +
v0.0.1
+
+ + + +
+
Loading...
+
+
+ + diff --git a/ui-electrobun/src/mainview/machines-store.svelte.ts b/ui-electrobun/src/mainview/machines-store.svelte.ts new file mode 100644 index 0000000..a7a144e --- /dev/null +++ b/ui-electrobun/src/mainview/machines-store.svelte.ts @@ -0,0 +1,89 @@ +/** + * Svelte 5 rune store for remote machine state. + * + * Tracks connected machines, their status, and latency. + * Driven by remote.statusChange and remote.event messages from the Bun process. + */ + +// ── Types ────────────────────────────────────────────────────────────────── + +export type MachineStatus = "connecting" | "connected" | "disconnected" | "error"; + +export interface RemoteMachine { + machineId: string; + label: string; + url: string; + status: MachineStatus; + latencyMs: number | null; + error?: string; +} + +// ── Store ────────────────────────────────────────────────────────────────── + +let machines = $state([]); + +/** Add a machine to the tracked list. */ +export function addMachine( + machineId: string, + url: string, + label?: string, +): void { + // Prevent duplicates + if (machines.some((m) => m.machineId === machineId)) return; + + machines = [ + ...machines, + { + machineId, + label: label ?? url, + url, + status: "connecting", + latencyMs: null, + }, + ]; +} + +/** Remove a machine from tracking. */ +export function removeMachine(machineId: string): void { + machines = machines.filter((m) => m.machineId !== machineId); +} + +/** Update the status of a tracked machine. */ +export function updateMachineStatus( + machineId: string, + status: MachineStatus, + error?: string, +): void { + machines = machines.map((m) => + m.machineId === machineId + ? { ...m, status, error: error ?? undefined } + : m, + ); +} + +/** Update the measured latency for a machine. */ +export function updateMachineLatency( + machineId: string, + latencyMs: number, +): void { + machines = machines.map((m) => + m.machineId === machineId ? { ...m, latencyMs } : m, + ); +} + +/** Get all tracked machines (reactive). */ +export function getMachines(): RemoteMachine[] { + return machines; +} + +/** Get a single machine by ID (reactive). */ +export function getMachineStatus( + machineId: string, +): RemoteMachine | undefined { + return machines.find((m) => m.machineId === machineId); +} + +/** Get count of connected machines (reactive). */ +export function getConnectedCount(): number { + return machines.filter((m) => m.status === "connected").length; +} diff --git a/ui-electrobun/src/mainview/settings/AdvancedSettings.svelte b/ui-electrobun/src/mainview/settings/AdvancedSettings.svelte index 47fad4e..baa93bc 100644 --- a/ui-electrobun/src/mainview/settings/AdvancedSettings.svelte +++ b/ui-electrobun/src/mainview/settings/AdvancedSettings.svelte @@ -18,9 +18,10 @@ let relayUrls = $state(''); let connTimeout = $state(30); - let appVersion = $state('3.0.0-dev'); + let appVersion = $state('...'); let updateChecking = $state(false); let updateResult = $state(null); + let updateUrl = $state(null); let importError = $state(null); function persist(key: string, value: string) { @@ -39,13 +40,26 @@ persist('plugin_states', JSON.stringify(states)); } - function checkForUpdates() { + async function checkForUpdates() { + if (!appRpc) return; updateChecking = true; updateResult = null; - setTimeout(() => { + updateUrl = null; + try { + const result = await appRpc.request['updater.check']({}); + if (result.error) { + updateResult = `Check failed: ${result.error}`; + } else if (result.available) { + updateResult = `Update available: v${result.version}`; + updateUrl = result.downloadUrl; + } else { + updateResult = `Already up to date (v${appVersion})`; + } + } catch (err) { + updateResult = `Check failed: ${err instanceof Error ? err.message : 'unknown error'}`; + } finally { updateChecking = false; - updateResult = 'Already up to date (v3.0.0-dev)'; - }, 1200); + } } async function handleExport() { @@ -99,6 +113,12 @@ plugins = plugins.map(p => ({ ...p, enabled: states[p.id] ?? p.enabled })); } catch { /* ignore */ } } + + // Fetch real version from backend + try { + const { version } = await appRpc.request['updater.getVersion']({}); + appVersion = version; + } catch { /* keep default */ } } onMount(loadSettings); @@ -159,7 +179,10 @@ {#if updateResult} -

{updateResult}

+

{updateResult}

+ {/if} + {#if updateUrl} + Download update {/if}

Settings Data

@@ -211,6 +234,9 @@ .update-row { display: flex; align-items: center; gap: 0.625rem; } .version-label { font-size: 0.75rem; color: var(--ctp-overlay1); font-family: var(--term-font-family, monospace); } .update-result { font-size: 0.75rem; color: var(--ctp-green); margin: 0.125rem 0 0; } + .update-result.has-update { color: var(--ctp-peach); } + .download-link { font-size: 0.75rem; color: var(--ctp-blue); text-decoration: none; margin-top: 0.125rem; } + .download-link:hover { text-decoration: underline; color: var(--ctp-sapphire); } .import-error { font-size: 0.75rem; color: var(--ctp-red); margin: 0.125rem 0 0; } .data-row { display: flex; gap: 0.5rem; } diff --git a/ui-electrobun/src/mainview/settings/RemoteMachinesSettings.svelte b/ui-electrobun/src/mainview/settings/RemoteMachinesSettings.svelte new file mode 100644 index 0000000..0abb4ef --- /dev/null +++ b/ui-electrobun/src/mainview/settings/RemoteMachinesSettings.svelte @@ -0,0 +1,269 @@ + + +
+

Connected Machines

+ + {#if machines.length === 0} +

No remote machines configured.

+ {:else} +
+ {#each machines as m (m.machineId)} +
+
+ +
+ {m.label} + {m.url} +
+
+
+ {formatLatency(m.latencyMs)} + + {statusLabel(m.status)} + +
+
+ {#if m.status === 'connected'} + + {:else if m.status === 'disconnected' || m.status === 'error'} + + {/if} + +
+
+ {/each} +
+ {/if} + +

Add Machine

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + + +
+ + diff --git a/ui-electrobun/src/mainview/telemetry-bridge.ts b/ui-electrobun/src/mainview/telemetry-bridge.ts new file mode 100644 index 0000000..b0dbdf8 --- /dev/null +++ b/ui-electrobun/src/mainview/telemetry-bridge.ts @@ -0,0 +1,45 @@ +/** + * Frontend telemetry bridge. + * + * Provides tel.info(), tel.warn(), tel.error() convenience methods that + * forward structured log events to the Bun process via RPC for tracing. + * No browser OTEL SDK needed (WebKitGTK incompatible). + */ + +import { appRpc } from "./rpc.ts"; + +type LogLevel = "info" | "warn" | "error"; +type Attributes = Record; + +function sendLog(level: LogLevel, message: string, attributes?: Attributes): void { + try { + appRpc?.request["telemetry.log"]({ + level, + message, + attributes: attributes ?? {}, + }).catch((err: unknown) => { + // Best-effort — never block the caller + console.warn("[tel-bridge] RPC failed:", err); + }); + } catch { + // RPC not yet initialized — swallow silently + } +} + +/** Frontend telemetry API. All calls are fire-and-forget. */ +export const tel = { + /** Log an informational event. */ + info(message: string, attributes?: Attributes): void { + sendLog("info", message, attributes); + }, + + /** Log a warning event. */ + warn(message: string, attributes?: Attributes): void { + sendLog("warn", message, attributes); + }, + + /** Log an error event. */ + error(message: string, attributes?: Attributes): void { + sendLog("error", message, attributes); + }, +} as const; diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index ca256c1..227c63e 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -482,6 +482,78 @@ export type PtyRPCRequests = { params: { pluginId: string; filePath: string }; response: { ok: boolean; content: string; error?: string }; }; + + // ── Remote machine (relay) RPC ──────────────────────────────────────────── + + /** Connect to an agor-relay instance. */ + "remote.connect": { + params: { url: string; token: string; label?: string }; + response: { ok: boolean; machineId?: string; error?: string }; + }; + /** Disconnect from a relay instance. */ + "remote.disconnect": { + params: { machineId: string }; + response: { ok: boolean; error?: string }; + }; + /** List all known remote machines with connection status. */ + "remote.list": { + params: Record; + response: { + machines: Array<{ + machineId: string; + label: string; + url: string; + status: "connecting" | "connected" | "disconnected" | "error"; + latencyMs: number | null; + }>; + }; + }; + /** Send a command to a connected relay. */ + "remote.send": { + params: { machineId: string; command: string; payload: Record }; + response: { ok: boolean; error?: string }; + }; + /** Get the status of a specific machine. */ + "remote.status": { + params: { machineId: string }; + response: { + status: "connecting" | "connected" | "disconnected" | "error"; + latencyMs: number | null; + error?: string; + }; + }; + + // ── Telemetry RPC ───────────────────────────────────────────────────────── + + /** Log a telemetry event from the frontend. */ + "telemetry.log": { + params: { + level: "info" | "warn" | "error"; + message: string; + attributes?: Record; + }; + response: { ok: boolean }; + }; + + // ── Updater RPC ────────────────────────────────────────────────────────── + + /** Check GitHub Releases for a newer version. */ + "updater.check": { + params: Record; + response: { + available: boolean; + version: string; + downloadUrl: string; + releaseNotes: string; + checkedAt: number; + error?: string; + }; + }; + /** Get the current app version and last check timestamp. */ + "updater.getVersion": { + params: Record; + response: { version: string; lastCheck: number }; + }; }; // ── Messages (Bun → WebView, fire-and-forget) ──────────────────────────────── @@ -518,6 +590,22 @@ export type PtyRPCMessages = { inputTokens: number; outputTokens: number; }; + + // ── Remote machine events (Bun → WebView) ──────────────────────────────── + + /** Remote relay event forwarded from a connected machine. */ + "remote.event": { + machineId: string; + eventType: string; + sessionId?: string; + payload?: unknown; + }; + /** Remote machine connection status change. */ + "remote.statusChange": { + machineId: string; + status: "connecting" | "connected" | "disconnected" | "error"; + error?: string; + }; }; // ── Combined schema ───────────────────────────────────────────────────────────