@agor/stores: - theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted - Original files replaced with re-exports (zero consumer changes needed) - pnpm workspace + Vite/tsconfig aliases configured BackendAdapter tests (58 new): - backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam) - tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params) - electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs) Total: 523 tests passing (was 465, +58)
309 lines
15 KiB
TypeScript
309 lines
15 KiB
TypeScript
/**
|
|
* Electrobun Bun process — thin router that delegates to domain handler modules.
|
|
*
|
|
* Fix #15: Extracted handlers into src/bun/handlers/ for SRP.
|
|
* Fix #2: Path traversal guard on files.list/read/write via path-guard.ts.
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
|
|
import { PtyClient } from "./pty-client.ts";
|
|
import { settingsDb } from "./settings-db.ts";
|
|
import { sessionDb } from "./session-db.ts";
|
|
import { btmsgDb } from "./btmsg-db.ts";
|
|
import { bttaskDb } from "./bttask-db.ts";
|
|
import { SidecarManager } from "./sidecar-manager.ts";
|
|
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
|
|
import { Database } from "bun:sqlite";
|
|
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";
|
|
|
|
// Handler modules
|
|
import { createPtyHandlers } from "./handlers/pty-handlers.ts";
|
|
import { createFilesHandlers } from "./handlers/files-handlers.ts";
|
|
import { createSettingsHandlers } from "./handlers/settings-handlers.ts";
|
|
import { createAgentHandlers } from "./handlers/agent-handlers.ts";
|
|
import { createBtmsgHandlers, createBttaskHandlers } from "./handlers/btmsg-handlers.ts";
|
|
import { createSearchHandlers } from "./handlers/search-handlers.ts";
|
|
import { createPluginHandlers } from "./handlers/plugin-handlers.ts";
|
|
import { createRemoteHandlers } from "./handlers/remote-handlers.ts";
|
|
|
|
/** Current app version — sourced from electrobun.config.ts at build time. */
|
|
const APP_VERSION = "0.0.1";
|
|
|
|
const DEV_SERVER_PORT = 9760;
|
|
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
|
|
|
// ── Services ───────────────────────────────────────────────────────────────
|
|
|
|
const ptyClient = new PtyClient();
|
|
const sidecarManager = new SidecarManager();
|
|
const searchDb = new SearchDb();
|
|
const relayClient = new RelayClient();
|
|
|
|
initTelemetry();
|
|
|
|
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
try {
|
|
await ptyClient.connect();
|
|
console.log("[agor-ptyd] Connected to PTY daemon");
|
|
return true;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (attempt < retries) {
|
|
console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms...`);
|
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
delayMs = Math.min(delayMs * 2, 4000);
|
|
} else {
|
|
console.error(`[agor-ptyd] Could not connect after ${retries} attempts: ${msg}`);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Clone helpers ──────────────────────────────────────────────────────────
|
|
|
|
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
|
|
|
|
async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branchName: string): Promise<{ ok: boolean; error?: string }> {
|
|
const proc = Bun.spawn(
|
|
["git", "worktree", "add", worktreePath, "-b", branchName],
|
|
{ cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" }
|
|
);
|
|
const exitCode = await proc.exited;
|
|
if (exitCode !== 0) {
|
|
const errText = await new Response(proc.stderr).text();
|
|
return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
// ── Build handler maps ───────────────────────────────────────────────────
|
|
|
|
// Placeholder rpc for agent handler — will be set after rpc creation
|
|
let rpcRef: { send: Record<string, (...args: unknown[]) => void> } = { send: {} };
|
|
|
|
const ptyHandlers = createPtyHandlers(ptyClient);
|
|
const filesHandlers = createFilesHandlers();
|
|
const settingsHandlers = createSettingsHandlers(settingsDb);
|
|
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef);
|
|
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
|
|
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
|
|
const searchHandlers = createSearchHandlers(searchDb);
|
|
const pluginHandlers = createPluginHandlers();
|
|
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
|
|
|
|
// ── RPC definition ─────────────────────────────────────────────────────────
|
|
|
|
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|
maxRequestTime: 15_000,
|
|
handlers: {
|
|
requests: {
|
|
// PTY
|
|
...ptyHandlers,
|
|
// Files (with path traversal guard)
|
|
...filesHandlers,
|
|
// Settings + Groups + Themes
|
|
...settingsHandlers,
|
|
// Agents + Session persistence
|
|
...agentHandlers,
|
|
// btmsg + bttask
|
|
...btmsgHandlers,
|
|
...bttaskHandlers,
|
|
// Search
|
|
...searchHandlers,
|
|
// Plugins
|
|
...pluginHandlers,
|
|
// Remote
|
|
...remoteHandlers,
|
|
|
|
// ── Project clone handler ──────────────────────────────────────────
|
|
"project.clone": async ({ projectId, branchName }) => {
|
|
try {
|
|
if (!BRANCH_RE.test(branchName)) {
|
|
return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." };
|
|
}
|
|
const source = settingsDb.getProject(projectId);
|
|
if (!source) return { ok: false, error: `Project not found: ${projectId}` };
|
|
|
|
const mainRepoPath = source.mainRepoPath ?? source.cwd;
|
|
const allProjects = settingsDb.listProjects();
|
|
const existingClones = allProjects.filter(
|
|
(p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf)
|
|
);
|
|
if (existingClones.length >= 3) return { ok: false, error: "Maximum 3 clones per project reached" };
|
|
|
|
const cloneIndex = existingClones.length + 1;
|
|
const wtSuffix = randomUUID().slice(0, 8);
|
|
const worktreePath = `${mainRepoPath}-wt-${wtSuffix}`;
|
|
|
|
const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName);
|
|
if (!gitResult.ok) return { ok: false, error: gitResult.error };
|
|
|
|
const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`;
|
|
const cloneConfig = {
|
|
id: cloneId, name: `${source.name} [${branchName}]`,
|
|
cwd: worktreePath, accent: source.accent, provider: source.provider,
|
|
profile: source.profile, model: source.model,
|
|
groupId: source.groupId ?? "dev", mainRepoPath,
|
|
cloneOf: projectId, worktreePath, worktreeBranch: branchName, cloneIndex,
|
|
};
|
|
settingsDb.setProject(cloneId, cloneConfig);
|
|
return { ok: true, project: { id: cloneId, config: JSON.stringify(cloneConfig) } };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[project.clone]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
// ── Window controls ────────────────────────────────────────────────
|
|
"window.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } },
|
|
"window.maximize": () => {
|
|
try {
|
|
const frame = mainWindow.getFrame();
|
|
if (frame.x <= 0 && frame.y <= 0) mainWindow.unmaximize(); else mainWindow.maximize();
|
|
return { ok: true };
|
|
} catch (err) { console.error("[window.maximize]", err); return { ok: false }; }
|
|
},
|
|
"window.close": () => { try { mainWindow.close(); return { ok: true }; } catch (err) { console.error("[window.close]", err); return { ok: false }; } },
|
|
"window.getFrame": () => { try { return mainWindow.getFrame(); } catch { return { x: 0, y: 0, width: 1400, height: 900 }; } },
|
|
"window.setPosition": ({ x, y }: { x: number; y: number }) => { try { mainWindow.setPosition(x, y); return { ok: true }; } catch { return { ok: false }; } },
|
|
|
|
// ── Keybindings ────────────────────────────────────────────────────
|
|
"keybindings.getAll": () => { try { return { keybindings: settingsDb.getKeybindings() }; } catch (err) { console.error("[keybindings.getAll]", err); return { keybindings: {} }; } },
|
|
"keybindings.set": ({ id, chord }) => { try { settingsDb.setKeybinding(id, chord); return { ok: true }; } catch (err) { console.error("[keybindings.set]", err); return { ok: false }; } },
|
|
"keybindings.reset": ({ id }) => { try { settingsDb.deleteKeybinding(id); return { ok: true }; } catch (err) { console.error("[keybindings.reset]", err); return { ok: false }; } },
|
|
|
|
// ── Updater ────────────────────────────────────────────────────────
|
|
"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": () => ({ version: APP_VERSION, lastCheck: getLastCheckTimestamp() }),
|
|
|
|
// ── Memora (read-only) ────────────────────────────────────────────
|
|
"memora.search": ({ query, limit }) => {
|
|
try {
|
|
const dbPath = join(homedir(), ".local", "share", "memora", "memories.db");
|
|
if (!fs.existsSync(dbPath)) return { memories: [] };
|
|
const db = new Database(dbPath, { readonly: true });
|
|
try {
|
|
const rows = db.query("SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?").all(`%${query}%`, limit ?? 20);
|
|
return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> };
|
|
} finally { db.close(); }
|
|
} catch (err) { console.error("[memora.search]", err); return { memories: [] }; }
|
|
},
|
|
"memora.list": ({ limit, tag }) => {
|
|
try {
|
|
const dbPath = join(homedir(), ".local", "share", "memora", "memories.db");
|
|
if (!fs.existsSync(dbPath)) return { memories: [] };
|
|
const db = new Database(dbPath, { readonly: true });
|
|
try {
|
|
let sql = "SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories";
|
|
const params: unknown[] = [];
|
|
if (tag) { sql += " WHERE tags LIKE ?"; params.push(`%${tag}%`); }
|
|
sql += " ORDER BY updated_at DESC LIMIT ?";
|
|
params.push(limit ?? 20);
|
|
const rows = db.query(sql).all(...params);
|
|
return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> };
|
|
} finally { db.close(); }
|
|
} catch (err) { console.error("[memora.list]", err); return { memories: [] }; }
|
|
},
|
|
|
|
// ── Feature 8: Diagnostics ─────────────────────────────────────────
|
|
"diagnostics.stats": () => {
|
|
return {
|
|
ptyConnected: ptyClient.isConnected,
|
|
relayConnections: relayClient.listMachines().filter(m => m.status === "connected").length,
|
|
activeSidecars: sidecarManager.listSessions().filter(s => s.status === "running").length,
|
|
rpcCallCount: 0, // Placeholder — Electrobun doesn't expose RPC call count
|
|
droppedEvents: 0,
|
|
};
|
|
},
|
|
|
|
// ── Telemetry ─────────────────────────────────────────────────────
|
|
"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: {},
|
|
},
|
|
});
|
|
|
|
// Wire rpcRef so agent handlers can forward events to webview
|
|
rpcRef.send = rpc.send;
|
|
|
|
// ── Forward daemon events to WebView ──────────────────────────────────────
|
|
|
|
ptyClient.on("session_output", (msg) => {
|
|
if (msg.type !== "session_output") return;
|
|
try { rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); }
|
|
catch (err) { console.error("[pty.output] forward error:", err); }
|
|
});
|
|
|
|
ptyClient.on("session_closed", (msg) => {
|
|
if (msg.type !== "session_closed") return;
|
|
try { rpc.send["pty.closed"]({ sessionId: msg.session_id, exitCode: msg.exit_code }); }
|
|
catch (err) { console.error("[pty.closed] forward error:", err); }
|
|
});
|
|
|
|
// ── 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<string> {
|
|
const channel = await Updater.localInfo.channel();
|
|
if (channel === "dev") {
|
|
try {
|
|
await fetch(DEV_SERVER_URL, { method: "HEAD" });
|
|
console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`);
|
|
return DEV_SERVER_URL;
|
|
} catch {
|
|
console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support.");
|
|
}
|
|
}
|
|
return "views://mainview/index.html";
|
|
}
|
|
|
|
connectToDaemon();
|
|
|
|
const url = await getMainViewUrl();
|
|
|
|
const savedX = Number(settingsDb.getSetting("win_x") ?? 100);
|
|
const savedY = Number(settingsDb.getSetting("win_y") ?? 100);
|
|
const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400);
|
|
const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900);
|
|
|
|
const mainWindow = new BrowserWindow({
|
|
title: "Agent Orchestrator",
|
|
titleBarStyle: "default",
|
|
url,
|
|
rpc,
|
|
frame: {
|
|
width: isNaN(savedWidth) ? 1400 : savedWidth,
|
|
height: isNaN(savedHeight) ? 900 : savedHeight,
|
|
x: isNaN(savedX) ? 100 : savedX,
|
|
y: isNaN(savedY) ? 100 : savedY,
|
|
},
|
|
});
|
|
|
|
console.log("Agent Orchestrator (Electrobun) started!");
|