feat: @agor/stores package (3 stores) + 58 BackendAdapter tests

@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)
This commit is contained in:
Hibryda 2026-03-22 04:45:56 +01:00
parent 5e1fd62ed9
commit f0850f0785
22 changed files with 1389 additions and 25 deletions

View file

@ -334,6 +334,42 @@ export class BtmsgDb {
return id;
}
// ── Feature 7: Channel membership management ─────────────────────────────
joinChannel(channelId: string, agentId: string): void {
// Validate channel exists
const ch = this.db.query<{ id: string }, [string]>(
"SELECT id FROM channels WHERE id = ?"
).get(channelId);
if (!ch) throw new Error(`Channel '${channelId}' not found`);
this.db.query(
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)"
).run(channelId, agentId);
}
leaveChannel(channelId: string, agentId: string): void {
this.db.query(
"DELETE FROM channel_members WHERE channel_id = ? AND agent_id = ?"
).run(channelId, agentId);
}
getChannelMembers(channelId: string): Array<{ agentId: string; name: string; role: string }> {
return this.db.query<{
agent_id: string; name: string; role: string;
}, [string]>(
`SELECT cm.agent_id, a.name, a.role
FROM channel_members cm
JOIN agents a ON cm.agent_id = a.id
WHERE cm.channel_id = ?
ORDER BY a.name`
).all(channelId).map(r => ({
agentId: r.agent_id,
name: r.name,
role: r.role,
}));
}
// ── Heartbeats ───────────────────────────────────────────────────────────
heartbeat(agentId: string): void {

View file

@ -1,11 +1,18 @@
/**
* btmsg + bttask RPC handlers.
* Feature 4: Push events on data changes (bttask.changed, btmsg.newMessage).
*/
import type { BtmsgDb } from "../btmsg-db.ts";
import type { BttaskDb } from "../bttask-db.ts";
export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
type RpcSend = { send: Record<string, (...args: unknown[]) => void> };
export function createBtmsgHandlers(btmsgDb: BtmsgDb, rpcRef?: RpcSend) {
function pushNewMessage(groupId: string, channelId?: string) {
try { rpcRef?.send?.["btmsg.newMessage"]?.({ groupId, channelId }); } catch { /* non-critical */ }
}
return {
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record<string, unknown>) => {
try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; }
@ -16,7 +23,13 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; }
},
"btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); return { ok: true, messageId }; }
try {
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
// Feature 4: Push DM notification
const sender = btmsgDb.getAgents("").find(a => a.id === fromAgent);
pushNewMessage(sender?.groupId ?? "");
return { ok: true, messageId };
}
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.sendMessage]", err); return { ok: false, error }; }
},
"btmsg.listMessages": ({ agentId, otherId, limit }: { agentId: string; otherId: string; limit?: number }) => {
@ -40,9 +53,29 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; }
},
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); return { ok: true, messageId }; }
try {
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
// Feature 4: Push channel message notification
const channels = btmsgDb.listChannels("");
const ch = channels.find(c => c.id === channelId);
pushNewMessage(ch?.groupId ?? "", channelId);
return { ok: true, messageId };
}
catch (err) { console.error("[btmsg.sendChannelMessage]", err); return { ok: false }; }
},
// Feature 7: Join/leave channel membership
"btmsg.joinChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
try { btmsgDb.joinChannel(channelId, agentId); return { ok: true }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.joinChannel]", err); return { ok: false, error }; }
},
"btmsg.leaveChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
try { btmsgDb.leaveChannel(channelId, agentId); return { ok: true }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.leaveChannel]", err); return { ok: false, error }; }
},
"btmsg.getChannelMembers": ({ channelId }: { channelId: string }) => {
try { return { members: btmsgDb.getChannelMembers(channelId) }; }
catch (err) { console.error("[btmsg.getChannelMembers]", err); return { members: [] }; }
},
"btmsg.heartbeat": ({ agentId }: { agentId: string }) => {
try { btmsgDb.heartbeat(agentId); return { ok: true }; }
catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; }
@ -62,22 +95,34 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
};
}
export function createBttaskHandlers(bttaskDb: BttaskDb) {
export function createBttaskHandlers(bttaskDb: BttaskDb, rpcRef?: RpcSend) {
function pushChanged(groupId: string) {
try { rpcRef?.send?.["bttask.changed"]?.({ groupId }); } catch { /* non-critical */ }
}
return {
"bttask.listTasks": ({ groupId }: { groupId: string }) => {
try { return { tasks: bttaskDb.listTasks(groupId) }; }
catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; }
},
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record<string, unknown>) => {
try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); return { ok: true, taskId }; }
try {
const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string);
pushChanged(groupId as string);
return { ok: true, taskId };
}
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.createTask]", err); return { ok: false, error }; }
},
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }: { taskId: string; status: string; expectedVersion: number }) => {
try { const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); return { ok: true, newVersion }; }
try {
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
pushChanged(""); // groupId unknown here, frontend will reload
return { ok: true, newVersion };
}
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.updateTaskStatus]", err); return { ok: false, error }; }
},
"bttask.deleteTask": ({ taskId }: { taskId: string }) => {
try { bttaskDb.deleteTask(taskId); return { ok: true }; }
try { bttaskDb.deleteTask(taskId); pushChanged(""); return { ok: true }; }
catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; }
},
"bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => {

View file

@ -3,8 +3,9 @@
*/
import type { RelayClient } from "../relay-client.ts";
import type { SettingsDb } from "../settings-db.ts";
export function createRemoteHandlers(relayClient: RelayClient) {
export function createRemoteHandlers(relayClient: RelayClient, settingsDb?: SettingsDb) {
return {
// Fix #4 (Codex audit): relay-client.connect() now returns { ok, machineId, error }
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
@ -74,5 +75,23 @@ export function createRemoteHandlers(relayClient: RelayClient) {
return { status: "error" as const, latencyMs: null, error };
}
},
// Feature 3: Remote credential vault
"remote.getStoredCredentials": () => {
if (!settingsDb) return { credentials: [] };
return { credentials: settingsDb.listRelayCredentials() };
},
"remote.storeCredential": ({ url, token, label }: { url: string; token: string; label?: string }) => {
if (!settingsDb) return { ok: false };
try { settingsDb.storeRelayCredential(url, token, label); return { ok: true }; }
catch (err) { console.error("[remote.storeCredential]", err); return { ok: false }; }
},
"remote.deleteCredential": ({ url }: { url: string }) => {
if (!settingsDb) return { ok: false };
try { settingsDb.deleteRelayCredential(url); return { ok: true }; }
catch (err) { console.error("[remote.deleteCredential]", err); return { ok: false }; }
},
};
}

View file

@ -94,11 +94,11 @@ const ptyHandlers = createPtyHandlers(ptyClient);
const filesHandlers = createFilesHandlers();
const settingsHandlers = createSettingsHandlers(settingsDb);
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef);
const btmsgHandlers = createBtmsgHandlers(btmsgDb);
const bttaskHandlers = createBttaskHandlers(bttaskDb);
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
const searchHandlers = createSearchHandlers(searchDb);
const pluginHandlers = createPluginHandlers();
const remoteHandlers = createRemoteHandlers(relayClient);
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
// ── RPC definition ─────────────────────────────────────────────────────────
@ -218,6 +218,17 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
} 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 }; }

View file

@ -265,6 +265,70 @@ export class SettingsDb {
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
}
// ── Remote credential vault (Feature 3) ──────────────────────────────────
private getMachineKey(): string {
try {
const h = require("os").hostname();
return h || "agor-default-key";
} catch {
return "agor-default-key";
}
}
private xorObfuscate(text: string, key: string): string {
const result: number[] = [];
for (let i = 0; i < text.length; i++) {
result.push(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return Buffer.from(result).toString("base64");
}
private xorDeobfuscate(encoded: string, key: string): string {
const buf = Buffer.from(encoded, "base64");
const result: string[] = [];
for (let i = 0; i < buf.length; i++) {
result.push(String.fromCharCode(buf[i] ^ key.charCodeAt(i % key.length)));
}
return result.join("");
}
storeRelayCredential(url: string, token: string, label?: string): void {
const key = this.getMachineKey();
const obfuscated = this.xorObfuscate(token, key);
const data = JSON.stringify({ url, token: obfuscated, label: label ?? url });
this.setSetting(`relay_cred_${url}`, data);
}
getRelayCredential(url: string): { url: string; token: string; label: string } | null {
const raw = this.getSetting(`relay_cred_${url}`);
if (!raw) return null;
try {
const data = JSON.parse(raw) as { url: string; token: string; label: string };
const key = this.getMachineKey();
return { url: data.url, token: this.xorDeobfuscate(data.token, key), label: data.label };
} catch {
return null;
}
}
listRelayCredentials(): Array<{ url: string; label: string }> {
const all = this.getAll();
const results: Array<{ url: string; label: string }> = [];
for (const [k, v] of Object.entries(all)) {
if (!k.startsWith("relay_cred_")) continue;
try {
const data = JSON.parse(v) as { url: string; label: string };
results.push({ url: data.url, label: data.label });
} catch { /* skip malformed */ }
}
return results;
}
deleteRelayCredential(url: string): void {
this.db.query("DELETE FROM settings WHERE key = ?").run(`relay_cred_${url}`);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
close(): void {

View file

@ -136,6 +136,8 @@ function findNodeRuntime(): string {
const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session
// Fix #12 (Codex audit): Max NDJSON line size — prevent OOM on malformed output
const MAX_LINE_SIZE = 10 * 1024 * 1024; // 10 MB
// Feature 5: Max total pending stdout buffer per session (50 MB)
const MAX_PENDING_BUFFER = 50 * 1024 * 1024;
// ── SidecarManager ───────────────────────────────────────────────────────────
@ -378,6 +380,13 @@ export class SidecarManager {
continue;
}
// Feature 5: Backpressure guard — pause if total buffer exceeds 50MB
if (buffer.length > MAX_PENDING_BUFFER) {
console.warn(`[sidecar] Buffer exceeded ${MAX_PENDING_BUFFER} bytes for ${sessionId}, pausing read`);
// Drain what we can and skip the rest
buffer = buffer.slice(-MAX_LINE_SIZE);
}
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();