refactor: migrate all 7 bridge clusters to BackendAdapter (WIP)
70+ files changed, net -688 lines. Bridge files being replaced with BackendAdapter calls. Clusters 2-8 in progress: theme, groups/workspace, agent, PTY/terminal, files, orchestration, infrastructure.
This commit is contained in:
parent
579157f6da
commit
105107dd84
72 changed files with 1835 additions and 2523 deletions
|
|
@ -1,9 +1,14 @@
|
|||
// BackendAdapter — abstraction layer for Tauri and Electrobun backends
|
||||
|
||||
import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent';
|
||||
import type { FileEntry, FileContent, PtyCreateOptions } from './protocol';
|
||||
import type { AgentStartOptions, AgentMessage, AgentStatus, ProviderId } from './agent';
|
||||
import type { FileEntry, FileContent, PtyCreateOptions, SearchResult } from './protocol';
|
||||
import type { SettingsMap } from './settings';
|
||||
import type { GroupsFile } from './project';
|
||||
import type { AgentId, GroupId, SessionId, ProjectId } from './ids';
|
||||
import type {
|
||||
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, DeadLetterEntry, AuditEntry,
|
||||
} from './btmsg';
|
||||
import type { Task, TaskComment } from './bttask';
|
||||
|
||||
// ── Backend capabilities ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -31,9 +36,223 @@ export interface BackendCapabilities {
|
|||
/** Call to remove an event listener */
|
||||
export type UnsubscribeFn = () => void;
|
||||
|
||||
// ── Domain-specific sub-interfaces ──────────────────────────────────────────
|
||||
|
||||
export interface SessionPersistenceAdapter {
|
||||
listSessions(): Promise<PersistedSession[]>;
|
||||
saveSession(session: PersistedSession): Promise<void>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
updateSessionTitle(id: string, title: string): Promise<void>;
|
||||
touchSession(id: string): Promise<void>;
|
||||
updateSessionGroup(id: string, groupName: string): Promise<void>;
|
||||
saveLayout(layout: PersistedLayout): Promise<void>;
|
||||
loadLayout(): Promise<PersistedLayout>;
|
||||
}
|
||||
|
||||
export interface AgentPersistenceAdapter {
|
||||
saveAgentMessages(
|
||||
sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined,
|
||||
messages: AgentMessageRecord[],
|
||||
): Promise<void>;
|
||||
loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]>;
|
||||
saveProjectAgentState(state: ProjectAgentState): Promise<void>;
|
||||
loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null>;
|
||||
saveSessionMetric(metric: Omit<SessionMetricRecord, 'id'>): Promise<void>;
|
||||
loadSessionMetrics(projectId: ProjectId, limit?: number): Promise<SessionMetricRecord[]>;
|
||||
getCliGroup(): Promise<string | null>;
|
||||
discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]>;
|
||||
}
|
||||
|
||||
export interface BtmsgAdapter {
|
||||
btmsgGetAgents(groupId: GroupId): Promise<BtmsgAgent[]>;
|
||||
btmsgUnreadCount(agentId: AgentId): Promise<number>;
|
||||
btmsgUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]>;
|
||||
btmsgHistory(agentId: AgentId, otherId: AgentId, limit?: number): Promise<BtmsgMessage[]>;
|
||||
btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string>;
|
||||
btmsgSetStatus(agentId: AgentId, status: string): Promise<void>;
|
||||
btmsgEnsureAdmin(groupId: GroupId): Promise<void>;
|
||||
btmsgAllFeed(groupId: GroupId, limit?: number): Promise<BtmsgFeedMessage[]>;
|
||||
btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise<void>;
|
||||
btmsgGetChannels(groupId: GroupId): Promise<BtmsgChannel[]>;
|
||||
btmsgChannelMessages(channelId: string, limit?: number): Promise<BtmsgChannelMessage[]>;
|
||||
btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise<string>;
|
||||
btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string>;
|
||||
btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise<void>;
|
||||
btmsgRegisterAgents(config: GroupsFile): Promise<void>;
|
||||
btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]>;
|
||||
btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise<void>;
|
||||
btmsgPruneSeen(): Promise<number>;
|
||||
btmsgRecordHeartbeat(agentId: AgentId): Promise<void>;
|
||||
btmsgGetStaleAgents(groupId: GroupId, thresholdSecs?: number): Promise<string[]>;
|
||||
btmsgGetDeadLetters(groupId: GroupId, limit?: number): Promise<DeadLetterEntry[]>;
|
||||
btmsgClearDeadLetters(groupId: GroupId): Promise<void>;
|
||||
btmsgClearAllComms(groupId: GroupId): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BttaskAdapter {
|
||||
bttaskList(groupId: GroupId): Promise<Task[]>;
|
||||
bttaskComments(taskId: string): Promise<TaskComment[]>;
|
||||
bttaskUpdateStatus(taskId: string, status: string, version: number): Promise<number>;
|
||||
bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise<string>;
|
||||
bttaskCreate(
|
||||
title: string, description: string, priority: string,
|
||||
groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId,
|
||||
): Promise<string>;
|
||||
bttaskDelete(taskId: string): Promise<void>;
|
||||
bttaskReviewQueueCount(groupId: GroupId): Promise<number>;
|
||||
}
|
||||
|
||||
export interface AnchorsAdapter {
|
||||
saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void>;
|
||||
loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]>;
|
||||
deleteSessionAnchor(id: string): Promise<void>;
|
||||
clearProjectAnchors(projectId: string): Promise<void>;
|
||||
updateAnchorType(id: string, anchorType: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SearchAdapter {
|
||||
searchInit(): Promise<void>;
|
||||
searchAll(query: string, limit?: number): Promise<SearchResult[]>;
|
||||
searchRebuild(): Promise<void>;
|
||||
searchIndexMessage(sessionId: string, role: string, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuditAdapter {
|
||||
logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise<void>;
|
||||
getAuditLog(groupId: GroupId, limit?: number, offset?: number): Promise<AuditEntry[]>;
|
||||
getAuditLogForAgent(agentId: AgentId, limit?: number): Promise<AuditEntry[]>;
|
||||
}
|
||||
|
||||
export interface NotificationsAdapter {
|
||||
sendDesktopNotification(title: string, body: string, urgency?: NotificationUrgency): void;
|
||||
}
|
||||
|
||||
export interface TelemetryAdapter {
|
||||
telemetryLog(level: TelemetryLevel, message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface SecretsAdapter {
|
||||
storeSecret(key: string, value: string): Promise<void>;
|
||||
getSecret(key: string): Promise<string | null>;
|
||||
deleteSecret(key: string): Promise<void>;
|
||||
listSecrets(): Promise<string[]>;
|
||||
hasKeyring(): Promise<boolean>;
|
||||
knownSecretKeys(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface FsWatcherAdapter {
|
||||
fsWatchProject(projectId: string, cwd: string): Promise<void>;
|
||||
fsUnwatchProject(projectId: string): Promise<void>;
|
||||
onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn;
|
||||
fsWatcherStatus(): Promise<FsWatcherStatus>;
|
||||
}
|
||||
|
||||
export interface CtxAdapter {
|
||||
ctxInitDb(): Promise<void>;
|
||||
ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void>;
|
||||
ctxGetContext(project: string): Promise<CtxEntry[]>;
|
||||
ctxGetShared(): Promise<CtxEntry[]>;
|
||||
ctxGetSummaries(project: string, limit?: number): Promise<CtxSummary[]>;
|
||||
ctxSearch(query: string): Promise<CtxEntry[]>;
|
||||
}
|
||||
|
||||
export interface MemoraAdapter {
|
||||
memoraAvailable(): Promise<boolean>;
|
||||
memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult>;
|
||||
memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult>;
|
||||
memoraGet(id: number): Promise<MemoraNode | null>;
|
||||
}
|
||||
|
||||
export interface SshAdapter {
|
||||
listSshSessions(): Promise<SshSessionRecord[]>;
|
||||
saveSshSession(session: SshSessionRecord): Promise<void>;
|
||||
deleteSshSession(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginsAdapter {
|
||||
discoverPlugins(): Promise<PluginMeta[]>;
|
||||
readPluginFile(pluginId: string, filename: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ClaudeProviderAdapter {
|
||||
listProfiles(): Promise<ClaudeProfile[]>;
|
||||
listSkills(): Promise<ClaudeSkill[]>;
|
||||
readSkill(path: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface RemoteMachineAdapter {
|
||||
listRemoteMachines(): Promise<RemoteMachineInfo[]>;
|
||||
addRemoteMachine(config: RemoteMachineConfig): Promise<string>;
|
||||
removeRemoteMachine(machineId: string): Promise<void>;
|
||||
connectRemoteMachine(machineId: string): Promise<void>;
|
||||
disconnectRemoteMachine(machineId: string): Promise<void>;
|
||||
probeSpki(url: string): Promise<string>;
|
||||
addSpkiPin(machineId: string, pin: string): Promise<void>;
|
||||
removeSpkiPin(machineId: string, pin: string): Promise<void>;
|
||||
onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn;
|
||||
onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn;
|
||||
onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn;
|
||||
onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn;
|
||||
onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
|
||||
onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface FileWatcherAdapter {
|
||||
watchFile(paneId: string, path: string): Promise<string>;
|
||||
unwatchFile(paneId: string): Promise<void>;
|
||||
readWatchedFile(path: string): Promise<string>;
|
||||
onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface AgentBridgeAdapter {
|
||||
/** Direct agent IPC — supports remote machines and resume */
|
||||
queryAgent(options: AgentQueryOptions): Promise<void>;
|
||||
stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise<void>;
|
||||
isAgentReady(): Promise<boolean>;
|
||||
restartAgentSidecar(): Promise<void>;
|
||||
setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise<void>;
|
||||
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn;
|
||||
onSidecarExited(callback: () => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
export interface PtyBridgeAdapter {
|
||||
/** Per-session PTY — supports remote machines */
|
||||
spawnPty(options: PtySpawnOptions): Promise<string>;
|
||||
writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise<void>;
|
||||
resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void>;
|
||||
killPty(id: string, remoteMachineId?: string): Promise<void>;
|
||||
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn;
|
||||
onPtyExit(id: string, callback: () => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
// ── Backend adapter interface ────────────────────────────────────────────────
|
||||
|
||||
export interface BackendAdapter {
|
||||
export interface BackendAdapter extends
|
||||
SessionPersistenceAdapter,
|
||||
AgentPersistenceAdapter,
|
||||
BtmsgAdapter,
|
||||
BttaskAdapter,
|
||||
AnchorsAdapter,
|
||||
SearchAdapter,
|
||||
AuditAdapter,
|
||||
NotificationsAdapter,
|
||||
TelemetryAdapter,
|
||||
SecretsAdapter,
|
||||
FsWatcherAdapter,
|
||||
CtxAdapter,
|
||||
MemoraAdapter,
|
||||
SshAdapter,
|
||||
PluginsAdapter,
|
||||
ClaudeProviderAdapter,
|
||||
RemoteMachineAdapter,
|
||||
FileWatcherAdapter,
|
||||
AgentBridgeAdapter,
|
||||
PtyBridgeAdapter {
|
||||
|
||||
readonly capabilities: BackendCapabilities;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
|
@ -55,13 +274,13 @@ export interface BackendAdapter {
|
|||
loadGroups(): Promise<GroupsFile>;
|
||||
saveGroups(groups: GroupsFile): Promise<void>;
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────────
|
||||
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||
|
||||
startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>;
|
||||
stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>;
|
||||
|
||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
||||
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||
|
||||
createPty(options: PtyCreateOptions): Promise<string>;
|
||||
writePty(sessionId: string, data: string): Promise<void>;
|
||||
|
|
@ -82,3 +301,273 @@ export interface BackendAdapter {
|
|||
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn;
|
||||
onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn;
|
||||
}
|
||||
|
||||
// ── Shared record types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PersistedSession {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
group_name?: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export interface PersistedLayout {
|
||||
preset: string;
|
||||
pane_ids: string[];
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: number;
|
||||
session_id: SessionId;
|
||||
project_id: ProjectId;
|
||||
sdk_session_id: string | null;
|
||||
message_type: string;
|
||||
content: string;
|
||||
parent_id: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ProjectAgentState {
|
||||
project_id: ProjectId;
|
||||
last_session_id: SessionId;
|
||||
sdk_session_id: string | null;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_prompt: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SessionMetricRecord {
|
||||
id: number;
|
||||
project_id: ProjectId;
|
||||
session_id: SessionId;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
peak_tokens: number;
|
||||
turn_count: number;
|
||||
tool_call_count: number;
|
||||
cost_usd: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface MdFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
priority: boolean;
|
||||
}
|
||||
|
||||
export interface SessionAnchorRecord {
|
||||
id: string;
|
||||
project_id: string;
|
||||
message_id: string;
|
||||
anchor_type: string;
|
||||
content: string;
|
||||
estimated_tokens: number;
|
||||
turn_index: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type AuditEventType =
|
||||
| 'prompt_injection'
|
||||
| 'wake_event'
|
||||
| 'btmsg_sent'
|
||||
| 'btmsg_received'
|
||||
| 'status_change'
|
||||
| 'heartbeat_missed'
|
||||
| 'dead_letter';
|
||||
|
||||
export type NotificationUrgency = 'low' | 'normal' | 'critical';
|
||||
export type TelemetryLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
|
||||
export interface FsWriteEvent {
|
||||
project_id: string;
|
||||
file_path: string;
|
||||
timestamp_ms: number;
|
||||
}
|
||||
|
||||
export interface FsWatcherStatus {
|
||||
max_watches: number;
|
||||
estimated_watches: number;
|
||||
usage_ratio: number;
|
||||
active_projects: number;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface CtxEntry {
|
||||
project: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CtxSummary {
|
||||
project: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MemoraNode {
|
||||
id: number;
|
||||
content: string;
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MemoraSearchResult {
|
||||
nodes: MemoraNode[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SshSessionRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_file: string;
|
||||
folder: string;
|
||||
color: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
main: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface ClaudeProfile {
|
||||
name: string;
|
||||
email: string | null;
|
||||
subscription_type: string | null;
|
||||
display_name: string | null;
|
||||
config_dir: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
source_path: string;
|
||||
}
|
||||
|
||||
export interface RemoteMachineConfig {
|
||||
label: string;
|
||||
url: string;
|
||||
token: string;
|
||||
auto_connect: boolean;
|
||||
spki_pins?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteMachineInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
status: string;
|
||||
auto_connect: boolean;
|
||||
spki_pins: string[];
|
||||
}
|
||||
|
||||
export interface RemoteSidecarMessage {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemotePtyData {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface RemotePtyExit {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface RemoteMachineEvent {
|
||||
machineId: string;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface RemoteReconnectingEvent {
|
||||
machineId: string;
|
||||
backoffSecs: number;
|
||||
}
|
||||
|
||||
export interface RemoteSpkiTofuEvent {
|
||||
machineId: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface FileChangedPayload {
|
||||
pane_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BtmsgFeedMessage {
|
||||
id: string;
|
||||
fromAgent: AgentId;
|
||||
toAgent: AgentId;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyTo: string | null;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
recipientName: string;
|
||||
recipientRole: string;
|
||||
}
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
provider?: ProviderId;
|
||||
session_id: string;
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
max_turns?: number;
|
||||
max_budget_usd?: number;
|
||||
resume_session_id?: string;
|
||||
permission_mode?: string;
|
||||
setting_sources?: string[];
|
||||
system_prompt?: string;
|
||||
model?: string;
|
||||
claude_config_dir?: string;
|
||||
additional_directories?: string[];
|
||||
worktree_name?: string;
|
||||
provider_config?: Record<string, unknown>;
|
||||
extra_env?: Record<string, string>;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
||||
export interface SidecarMessagePayload {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
message?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
}
|
||||
|
||||
export interface PtySpawnOptions {
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
import { OLLAMA_PROVIDER } from './lib/providers/ollama';
|
||||
import { AIDER_PROVIDER } from './lib/providers/aider';
|
||||
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
||||
import { MemoraAdapter } from './lib/adapters/memora-bridge';
|
||||
import { MemoraAdapter } from './lib/adapters/memora-adapter';
|
||||
import {
|
||||
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
|
||||
getEnabledProjects, getAllWorkItems, getActiveProjectId,
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
||||
import { initGlobalErrorHandler } from './lib/utils/global-error-handler';
|
||||
import { handleInfraError } from './lib/utils/handle-error';
|
||||
import { pruneSeen } from './lib/adapters/btmsg-bridge';
|
||||
import { getBackend } from './lib/backend/backend';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// Workspace components
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
// Step 2: Agent dispatcher
|
||||
startAgentDispatcher();
|
||||
startHealthTick();
|
||||
pruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup
|
||||
getBackend().btmsgPruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup
|
||||
markStep(2);
|
||||
|
||||
// Disable wake scheduler in test mode to prevent timer interference
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Use vi.hoisted to declare mocks that are accessible inside vi.mock factories
|
||||
const { mockInvoke, mockListen } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
mockListen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: mockListen,
|
||||
}));
|
||||
|
||||
import {
|
||||
queryAgent,
|
||||
stopAgent,
|
||||
isAgentReady,
|
||||
restartAgent,
|
||||
onSidecarMessage,
|
||||
onSidecarExited,
|
||||
type AgentQueryOptions,
|
||||
} from './agent-bridge';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('agent-bridge', () => {
|
||||
describe('queryAgent', () => {
|
||||
it('invokes agent_query with options', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentQueryOptions = {
|
||||
session_id: 'sess-1',
|
||||
prompt: 'Hello Claude',
|
||||
cwd: '/tmp',
|
||||
max_turns: 10,
|
||||
max_budget_usd: 1.0,
|
||||
};
|
||||
|
||||
await queryAgent(options);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options });
|
||||
});
|
||||
|
||||
it('passes minimal options (only required fields)', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentQueryOptions = {
|
||||
session_id: 'sess-2',
|
||||
prompt: 'Do something',
|
||||
};
|
||||
|
||||
await queryAgent(options);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options });
|
||||
});
|
||||
|
||||
it('propagates invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('Sidecar not running'));
|
||||
|
||||
await expect(
|
||||
queryAgent({ session_id: 'sess-3', prompt: 'test' }),
|
||||
).rejects.toThrow('Sidecar not running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAgent', () => {
|
||||
it('invokes agent_stop with session ID', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await stopAgent('sess-1');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 'sess-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAgentReady', () => {
|
||||
it('returns true when sidecar is ready', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
|
||||
const result = await isAgentReady();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_ready');
|
||||
});
|
||||
|
||||
it('returns false when sidecar is not ready', async () => {
|
||||
mockInvoke.mockResolvedValue(false);
|
||||
|
||||
const result = await isAgentReady();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartAgent', () => {
|
||||
it('invokes agent_restart', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await restartAgent();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_restart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSidecarMessage', () => {
|
||||
it('registers listener on sidecar-message event', async () => {
|
||||
const unlisten = vi.fn();
|
||||
mockListen.mockResolvedValue(unlisten);
|
||||
|
||||
const callback = vi.fn();
|
||||
const result = await onSidecarMessage(callback);
|
||||
|
||||
expect(mockListen).toHaveBeenCalledWith('sidecar-message', expect.any(Function));
|
||||
expect(result).toBe(unlisten);
|
||||
});
|
||||
|
||||
it('extracts payload and passes to callback', async () => {
|
||||
mockListen.mockImplementation(async (_event: string, handler: (e: unknown) => void) => {
|
||||
// Simulate Tauri event delivery
|
||||
handler({
|
||||
payload: {
|
||||
type: 'agent_event',
|
||||
sessionId: 'sess-1',
|
||||
event: { type: 'system', subtype: 'init' },
|
||||
},
|
||||
});
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const callback = vi.fn();
|
||||
await onSidecarMessage(callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
type: 'agent_event',
|
||||
sessionId: 'sess-1',
|
||||
event: { type: 'system', subtype: 'init' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSidecarExited', () => {
|
||||
it('registers listener on sidecar-exited event', async () => {
|
||||
const unlisten = vi.fn();
|
||||
mockListen.mockResolvedValue(unlisten);
|
||||
|
||||
const callback = vi.fn();
|
||||
const result = await onSidecarExited(callback);
|
||||
|
||||
expect(mockListen).toHaveBeenCalledWith('sidecar-exited', expect.any(Function));
|
||||
expect(result).toBe(unlisten);
|
||||
});
|
||||
|
||||
it('invokes callback without arguments on exit', async () => {
|
||||
mockListen.mockImplementation(async (_event: string, handler: () => void) => {
|
||||
handler();
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const callback = vi.fn();
|
||||
await onSidecarExited(callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// Agent Bridge — Tauri IPC adapter for sidecar communication
|
||||
// Mirrors pty-bridge.ts pattern: invoke for commands, listen for events
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import type { ProviderId } from '../providers/types';
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
provider?: ProviderId;
|
||||
session_id: string;
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
max_turns?: number;
|
||||
max_budget_usd?: number;
|
||||
resume_session_id?: string;
|
||||
permission_mode?: string;
|
||||
setting_sources?: string[];
|
||||
system_prompt?: string;
|
||||
model?: string;
|
||||
claude_config_dir?: string;
|
||||
additional_directories?: string[];
|
||||
/** When set, agent runs in a git worktree for isolation */
|
||||
worktree_name?: string;
|
||||
provider_config?: Record<string, unknown>;
|
||||
/** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */
|
||||
extra_env?: Record<string, string>;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
||||
export async function queryAgent(options: AgentQueryOptions): Promise<void> {
|
||||
if (options.remote_machine_id) {
|
||||
const { remote_machine_id: machineId, ...agentOptions } = options;
|
||||
return invoke('remote_agent_query', { machineId, options: agentOptions });
|
||||
}
|
||||
return invoke('agent_query', { options });
|
||||
}
|
||||
|
||||
export async function stopAgent(sessionId: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
|
||||
}
|
||||
return invoke('agent_stop', { sessionId });
|
||||
}
|
||||
|
||||
export async function isAgentReady(): Promise<boolean> {
|
||||
return invoke<boolean>('agent_ready');
|
||||
}
|
||||
|
||||
export async function restartAgent(): Promise<void> {
|
||||
return invoke('agent_restart');
|
||||
}
|
||||
|
||||
/** Update Landlock sandbox config and restart sidecar to apply. */
|
||||
export async function setSandbox(
|
||||
projectCwds: string[],
|
||||
worktreeRoots: string[],
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled });
|
||||
}
|
||||
|
||||
export interface SidecarMessage {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
message?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
}
|
||||
|
||||
export async function onSidecarMessage(
|
||||
callback: (msg: SidecarMessage) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<SidecarMessage>('sidecar-message', (event) => {
|
||||
const payload = event.payload;
|
||||
if (typeof payload !== 'object' || payload === null) return;
|
||||
callback(payload as SidecarMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onSidecarExited(callback: () => void): Promise<UnlistenFn> {
|
||||
return listen('sidecar-exited', () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Anchors Bridge — Tauri IPC adapter for session anchor CRUD
|
||||
// Mirrors groups-bridge.ts pattern
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { SessionAnchorRecord } from '../types/anchors';
|
||||
|
||||
export async function saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void> {
|
||||
return invoke('session_anchors_save', { anchors });
|
||||
}
|
||||
|
||||
export async function loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
|
||||
return invoke('session_anchors_load', { projectId });
|
||||
}
|
||||
|
||||
export async function deleteSessionAnchor(id: string): Promise<void> {
|
||||
return invoke('session_anchor_delete', { id });
|
||||
}
|
||||
|
||||
export async function clearProjectAnchors(projectId: string): Promise<void> {
|
||||
return invoke('session_anchors_clear', { projectId });
|
||||
}
|
||||
|
||||
export async function updateAnchorType(id: string, anchorType: string): Promise<void> {
|
||||
return invoke('session_anchor_update_type', { id, anchorType });
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* Audit log bridge — reads/writes audit events via Tauri IPC.
|
||||
* Used by agent-dispatcher, wake-scheduler, and AgentSession for event tracking.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { AgentId, GroupId } from '../types/ids';
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
agentId: string;
|
||||
eventType: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Audit event types */
|
||||
export type AuditEventType =
|
||||
| 'prompt_injection'
|
||||
| 'wake_event'
|
||||
| 'btmsg_sent'
|
||||
| 'btmsg_received'
|
||||
| 'status_change'
|
||||
| 'heartbeat_missed'
|
||||
| 'dead_letter';
|
||||
|
||||
/**
|
||||
* Log an audit event for an agent.
|
||||
*/
|
||||
export async function logAuditEvent(
|
||||
agentId: AgentId,
|
||||
eventType: AuditEventType,
|
||||
detail: string,
|
||||
): Promise<void> {
|
||||
return invoke('audit_log_event', { agentId, eventType, detail });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log entries for a group (reverse chronological).
|
||||
*/
|
||||
export async function getAuditLog(
|
||||
groupId: GroupId,
|
||||
limit: number = 200,
|
||||
offset: number = 0,
|
||||
): Promise<AuditEntry[]> {
|
||||
return invoke('audit_log_list', { groupId, limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log entries for a specific agent.
|
||||
*/
|
||||
export async function getAuditLogForAgent(
|
||||
agentId: AgentId,
|
||||
limit: number = 50,
|
||||
): Promise<AuditEntry[]> {
|
||||
return invoke('audit_log_for_agent', { agentId, limit });
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
import {
|
||||
getGroupAgents,
|
||||
getUnreadCount,
|
||||
getUnreadMessages,
|
||||
getHistory,
|
||||
sendMessage,
|
||||
setAgentStatus,
|
||||
ensureAdmin,
|
||||
getAllFeed,
|
||||
markRead,
|
||||
getChannels,
|
||||
getChannelMessages,
|
||||
sendChannelMessage,
|
||||
createChannel,
|
||||
addChannelMember,
|
||||
registerAgents,
|
||||
type BtmsgAgent,
|
||||
type BtmsgMessage,
|
||||
type BtmsgFeedMessage,
|
||||
type BtmsgChannel,
|
||||
type BtmsgChannelMessage,
|
||||
} from './btmsg-bridge';
|
||||
import { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('btmsg-bridge', () => {
|
||||
// ---- REGRESSION: camelCase field names ----
|
||||
// Bug: TypeScript interfaces used snake_case (group_id, unread_count, from_agent, etc.)
|
||||
// but Rust serde(rename_all = "camelCase") sends camelCase.
|
||||
|
||||
describe('BtmsgAgent camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const agent: BtmsgAgent = {
|
||||
id: AgentId('a1'),
|
||||
name: 'Coder',
|
||||
role: 'developer',
|
||||
groupId: GroupId('g1'), // was: group_id
|
||||
tier: 1,
|
||||
model: 'claude-4',
|
||||
status: 'active',
|
||||
unreadCount: 3, // was: unread_count
|
||||
};
|
||||
mockInvoke.mockResolvedValue([agent]);
|
||||
|
||||
const result = await getGroupAgents(GroupId('g1'));
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].unreadCount).toBe(3);
|
||||
// Verify snake_case fields do NOT exist
|
||||
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['unread_count']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invokes btmsg_get_agents with groupId', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getGroupAgents(GroupId('g1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgMessage camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const msg: BtmsgMessage = {
|
||||
id: 'm1',
|
||||
fromAgent: AgentId('a1'), // was: from_agent
|
||||
toAgent: AgentId('a2'), // was: to_agent
|
||||
content: 'hello',
|
||||
read: false,
|
||||
replyTo: null, // was: reply_to
|
||||
createdAt: '2026-01-01', // was: created_at
|
||||
senderName: 'Coder', // was: sender_name
|
||||
senderRole: 'dev', // was: sender_role
|
||||
};
|
||||
mockInvoke.mockResolvedValue([msg]);
|
||||
|
||||
const result = await getUnreadMessages(AgentId('a2'));
|
||||
|
||||
expect(result[0].fromAgent).toBe('a1');
|
||||
expect(result[0].toAgent).toBe('a2');
|
||||
expect(result[0].replyTo).toBeNull();
|
||||
expect(result[0].createdAt).toBe('2026-01-01');
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
expect(result[0].senderRole).toBe('dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgFeedMessage camelCase fields', () => {
|
||||
it('receives camelCase fields including recipient info', async () => {
|
||||
const feed: BtmsgFeedMessage = {
|
||||
id: 'm1',
|
||||
fromAgent: AgentId('a1'),
|
||||
toAgent: AgentId('a2'),
|
||||
content: 'review this',
|
||||
createdAt: '2026-01-01',
|
||||
replyTo: null,
|
||||
senderName: 'Coder',
|
||||
senderRole: 'developer',
|
||||
recipientName: 'Reviewer',
|
||||
recipientRole: 'reviewer',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([feed]);
|
||||
|
||||
const result = await getAllFeed(GroupId('g1'));
|
||||
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
expect(result[0].recipientName).toBe('Reviewer');
|
||||
expect(result[0].recipientRole).toBe('reviewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgChannel camelCase fields', () => {
|
||||
it('receives camelCase fields', async () => {
|
||||
const channel: BtmsgChannel = {
|
||||
id: 'ch1',
|
||||
name: 'general',
|
||||
groupId: GroupId('g1'), // was: group_id
|
||||
createdBy: AgentId('admin'), // was: created_by
|
||||
memberCount: 5, // was: member_count
|
||||
createdAt: '2026-01-01',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([channel]);
|
||||
|
||||
const result = await getChannels(GroupId('g1'));
|
||||
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].createdBy).toBe('admin');
|
||||
expect(result[0].memberCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BtmsgChannelMessage camelCase fields', () => {
|
||||
it('receives camelCase fields', async () => {
|
||||
const msg: BtmsgChannelMessage = {
|
||||
id: 'cm1',
|
||||
channelId: 'ch1', // was: channel_id
|
||||
fromAgent: AgentId('a1'),
|
||||
content: 'hello',
|
||||
createdAt: '2026-01-01',
|
||||
senderName: 'Coder',
|
||||
senderRole: 'dev',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([msg]);
|
||||
|
||||
const result = await getChannelMessages('ch1');
|
||||
|
||||
expect(result[0].channelId).toBe('ch1');
|
||||
expect(result[0].fromAgent).toBe('a1');
|
||||
expect(result[0].senderName).toBe('Coder');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IPC command name tests ----
|
||||
|
||||
describe('IPC commands', () => {
|
||||
it('getUnreadCount invokes btmsg_unread_count', async () => {
|
||||
mockInvoke.mockResolvedValue(5);
|
||||
const result = await getUnreadCount(AgentId('a1'));
|
||||
expect(result).toBe(5);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' });
|
||||
});
|
||||
|
||||
it('getHistory invokes btmsg_history', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getHistory(AgentId('a1'), AgentId('a2'), 50);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
|
||||
});
|
||||
|
||||
it('getHistory defaults limit to 20', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getHistory(AgentId('a1'), AgentId('a2'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 });
|
||||
});
|
||||
|
||||
it('sendMessage invokes btmsg_send', async () => {
|
||||
mockInvoke.mockResolvedValue('msg-id');
|
||||
const result = await sendMessage(AgentId('a1'), AgentId('a2'), 'hello');
|
||||
expect(result).toBe('msg-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
|
||||
});
|
||||
|
||||
it('setAgentStatus invokes btmsg_set_status', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await setAgentStatus(AgentId('a1'), 'active');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' });
|
||||
});
|
||||
|
||||
it('ensureAdmin invokes btmsg_ensure_admin', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await ensureAdmin(GroupId('g1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' });
|
||||
});
|
||||
|
||||
it('markRead invokes btmsg_mark_read', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await markRead(AgentId('a2'), AgentId('a1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' });
|
||||
});
|
||||
|
||||
it('sendChannelMessage invokes btmsg_channel_send', async () => {
|
||||
mockInvoke.mockResolvedValue('cm-id');
|
||||
const result = await sendChannelMessage('ch1', AgentId('a1'), 'hello channel');
|
||||
expect(result).toBe('cm-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' });
|
||||
});
|
||||
|
||||
it('createChannel invokes btmsg_create_channel', async () => {
|
||||
mockInvoke.mockResolvedValue('ch-id');
|
||||
const result = await createChannel('general', GroupId('g1'), AgentId('admin'));
|
||||
expect(result).toBe('ch-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' });
|
||||
});
|
||||
|
||||
it('addChannelMember invokes btmsg_add_channel_member', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await addChannelMember('ch1', AgentId('a1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' });
|
||||
});
|
||||
|
||||
it('registerAgents invokes btmsg_register_agents with groups config', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
const config = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Test', projects: [], agents: [] }],
|
||||
activeGroupId: 'g1',
|
||||
};
|
||||
await registerAgents(config as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_register_agents', { config });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error propagation', () => {
|
||||
it('propagates invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
|
||||
await expect(getGroupAgents(GroupId('g1'))).rejects.toThrow('btmsg database not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
/**
|
||||
* btmsg bridge — reads btmsg SQLite database for agent notifications.
|
||||
* Used by GroupAgentsPanel to show unread counts and agent statuses.
|
||||
* Polls the database periodically for new messages.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
export interface BtmsgAgent {
|
||||
id: AgentId;
|
||||
name: string;
|
||||
role: string;
|
||||
groupId: GroupId;
|
||||
tier: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface BtmsgMessage {
|
||||
id: string;
|
||||
fromAgent: AgentId;
|
||||
toAgent: AgentId;
|
||||
content: string;
|
||||
read: boolean;
|
||||
replyTo: string | null;
|
||||
createdAt: string;
|
||||
senderName?: string;
|
||||
senderRole?: string;
|
||||
}
|
||||
|
||||
export interface BtmsgFeedMessage {
|
||||
id: string;
|
||||
fromAgent: AgentId;
|
||||
toAgent: AgentId;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyTo: string | null;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
recipientName: string;
|
||||
recipientRole: string;
|
||||
}
|
||||
|
||||
export interface BtmsgChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: GroupId;
|
||||
createdBy: AgentId;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BtmsgChannelMessage {
|
||||
id: string;
|
||||
channelId: string;
|
||||
fromAgent: AgentId;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agents in a group with their unread counts.
|
||||
*/
|
||||
export async function getGroupAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
|
||||
return invoke('btmsg_get_agents', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread message count for an agent.
|
||||
*/
|
||||
export async function getUnreadCount(agentId: AgentId): Promise<number> {
|
||||
return invoke('btmsg_unread_count', { agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread messages for an agent.
|
||||
*/
|
||||
export async function getUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_unread_messages', { agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation history between two agents.
|
||||
*/
|
||||
export async function getHistory(agentId: AgentId, otherId: AgentId, limit: number = 20): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_history', { agentId, otherId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message from one agent to another.
|
||||
*/
|
||||
export async function sendMessage(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
|
||||
return invoke('btmsg_send', { fromAgent, toAgent, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent status (active/sleeping/stopped).
|
||||
*/
|
||||
export async function setAgentStatus(agentId: AgentId, status: string): Promise<void> {
|
||||
return invoke('btmsg_set_status', { agentId, status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admin agent exists with contacts to all agents.
|
||||
*/
|
||||
export async function ensureAdmin(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_ensure_admin', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in group (admin global feed).
|
||||
*/
|
||||
export async function getAllFeed(groupId: GroupId, limit: number = 100): Promise<BtmsgFeedMessage[]> {
|
||||
return invoke('btmsg_all_feed', { groupId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all messages from sender to reader as read.
|
||||
*/
|
||||
export async function markRead(readerId: AgentId, senderId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_mark_read', { readerId, senderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels in a group.
|
||||
*/
|
||||
export async function getChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
|
||||
return invoke('btmsg_get_channels', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages in a channel.
|
||||
*/
|
||||
export async function getChannelMessages(channelId: string, limit: number = 100): Promise<BtmsgChannelMessage[]> {
|
||||
return invoke('btmsg_channel_messages', { channelId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a channel.
|
||||
*/
|
||||
export async function sendChannelMessage(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
|
||||
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new channel.
|
||||
*/
|
||||
export async function createChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
|
||||
return invoke('btmsg_create_channel', { name, groupId, createdBy });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a channel.
|
||||
*/
|
||||
export async function addChannelMember(channelId: string, agentId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_add_channel_member', { channelId, agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all agents from groups config into the btmsg database.
|
||||
* Creates/updates agent records, sets up contact permissions, ensures review channels.
|
||||
* Should be called whenever groups are loaded or switched.
|
||||
*/
|
||||
export async function registerAgents(config: import('../types/groups').GroupsFile): Promise<void> {
|
||||
return invoke('btmsg_register_agents', { config });
|
||||
}
|
||||
|
||||
// ---- Per-message acknowledgment (seen_messages) ----
|
||||
|
||||
/**
|
||||
* Get messages not yet seen by this session (per-session tracking).
|
||||
*/
|
||||
export async function getUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_unseen_messages', { agentId, sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark specific message IDs as seen by this session.
|
||||
*/
|
||||
export async function markMessagesSeen(sessionId: string, messageIds: string[]): Promise<void> {
|
||||
return invoke('btmsg_mark_seen', { sessionId, messageIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old seen_messages entries (7-day default, emergency 3-day at 200k rows).
|
||||
*/
|
||||
export async function pruneSeen(): Promise<number> {
|
||||
return invoke('btmsg_prune_seen');
|
||||
}
|
||||
|
||||
// ---- Heartbeat monitoring ----
|
||||
|
||||
/**
|
||||
* Record a heartbeat for an agent (upserts timestamp).
|
||||
*/
|
||||
export async function recordHeartbeat(agentId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_record_heartbeat', { agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stale agents in a group (no heartbeat within threshold).
|
||||
*/
|
||||
export async function getStaleAgents(groupId: GroupId, thresholdSecs: number = 300): Promise<string[]> {
|
||||
return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs });
|
||||
}
|
||||
|
||||
// ---- Dead letter queue ----
|
||||
|
||||
export interface DeadLetter {
|
||||
id: number;
|
||||
fromAgent: string;
|
||||
toAgent: string;
|
||||
content: string;
|
||||
error: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dead letter queue entries for a group.
|
||||
*/
|
||||
export async function getDeadLetters(groupId: GroupId, limit: number = 50): Promise<DeadLetter[]> {
|
||||
return invoke('btmsg_get_dead_letters', { groupId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dead letters for a group.
|
||||
*/
|
||||
export async function clearDeadLetters(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_clear_dead_letters', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ALL communications for a group: messages, channel messages, seen tracking, dead letters.
|
||||
*/
|
||||
export async function clearAllComms(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_clear_all_comms', { groupId });
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
import {
|
||||
listTasks,
|
||||
getTaskComments,
|
||||
updateTaskStatus,
|
||||
addTaskComment,
|
||||
createTask,
|
||||
deleteTask,
|
||||
type Task,
|
||||
type TaskComment,
|
||||
} from './bttask-bridge';
|
||||
import { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bttask-bridge', () => {
|
||||
// ---- REGRESSION: camelCase field names ----
|
||||
|
||||
describe('Task camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const task: Task = {
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
description: 'Critical fix',
|
||||
status: 'progress',
|
||||
priority: 'high',
|
||||
assignedTo: AgentId('a1'), // was: assigned_to
|
||||
createdBy: AgentId('admin'), // was: created_by
|
||||
groupId: GroupId('g1'), // was: group_id
|
||||
parentTaskId: null, // was: parent_task_id
|
||||
sortOrder: 1, // was: sort_order
|
||||
createdAt: '2026-01-01', // was: created_at
|
||||
updatedAt: '2026-01-01', // was: updated_at
|
||||
};
|
||||
mockInvoke.mockResolvedValue([task]);
|
||||
|
||||
const result = await listTasks(GroupId('g1'));
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assignedTo).toBe('a1');
|
||||
expect(result[0].createdBy).toBe('admin');
|
||||
expect(result[0].groupId).toBe('g1');
|
||||
expect(result[0].parentTaskId).toBeNull();
|
||||
expect(result[0].sortOrder).toBe(1);
|
||||
// Verify no snake_case leaks
|
||||
expect((result[0] as Record<string, unknown>)['assigned_to']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['created_by']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskComment camelCase fields', () => {
|
||||
it('receives camelCase fields from Rust backend', async () => {
|
||||
const comment: TaskComment = {
|
||||
id: 'c1',
|
||||
taskId: 't1', // was: task_id
|
||||
agentId: AgentId('a1'), // was: agent_id
|
||||
content: 'Working on it',
|
||||
createdAt: '2026-01-01',
|
||||
};
|
||||
mockInvoke.mockResolvedValue([comment]);
|
||||
|
||||
const result = await getTaskComments('t1');
|
||||
|
||||
expect(result[0].taskId).toBe('t1');
|
||||
expect(result[0].agentId).toBe('a1');
|
||||
expect((result[0] as Record<string, unknown>)['task_id']).toBeUndefined();
|
||||
expect((result[0] as Record<string, unknown>)['agent_id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IPC command name tests ----
|
||||
|
||||
describe('IPC commands', () => {
|
||||
it('listTasks invokes bttask_list', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await listTasks(GroupId('g1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
|
||||
});
|
||||
|
||||
it('getTaskComments invokes bttask_comments', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await getTaskComments('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' });
|
||||
});
|
||||
|
||||
it('updateTaskStatus invokes bttask_update_status with version', async () => {
|
||||
mockInvoke.mockResolvedValue(2);
|
||||
const newVersion = await updateTaskStatus('t1', 'done', 1);
|
||||
expect(newVersion).toBe(2);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'done', version: 1 });
|
||||
});
|
||||
|
||||
it('addTaskComment invokes bttask_add_comment', async () => {
|
||||
mockInvoke.mockResolvedValue('c-id');
|
||||
const result = await addTaskComment('t1', AgentId('a1'), 'Done!');
|
||||
expect(result).toBe('c-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' });
|
||||
});
|
||||
|
||||
it('createTask invokes bttask_create with all fields', async () => {
|
||||
mockInvoke.mockResolvedValue('t-id');
|
||||
const result = await createTask('Fix bug', 'desc', 'high', GroupId('g1'), AgentId('admin'), AgentId('a1'));
|
||||
expect(result).toBe('t-id');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
|
||||
title: 'Fix bug',
|
||||
description: 'desc',
|
||||
priority: 'high',
|
||||
groupId: 'g1',
|
||||
createdBy: 'admin',
|
||||
assignedTo: 'a1',
|
||||
});
|
||||
});
|
||||
|
||||
it('createTask invokes bttask_create without assignedTo', async () => {
|
||||
mockInvoke.mockResolvedValue('t-id');
|
||||
await createTask('Add tests', '', 'medium', GroupId('g1'), AgentId('a1'));
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
|
||||
title: 'Add tests',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
groupId: 'g1',
|
||||
createdBy: 'a1',
|
||||
assignedTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteTask invokes bttask_delete', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await deleteTask('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_delete', { taskId: 't1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('error propagation', () => {
|
||||
it('propagates invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
|
||||
await expect(listTasks(GroupId('g1'))).rejects.toThrow('btmsg database not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
// bttask Bridge — Tauri IPC adapter for task board
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'todo' | 'progress' | 'review' | 'done' | 'blocked';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
assignedTo: AgentId | null;
|
||||
createdBy: AgentId;
|
||||
groupId: GroupId;
|
||||
parentTaskId: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
agentId: AgentId;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function listTasks(groupId: GroupId): Promise<Task[]> {
|
||||
return invoke<Task[]>('bttask_list', { groupId });
|
||||
}
|
||||
|
||||
export async function getTaskComments(taskId: string): Promise<TaskComment[]> {
|
||||
return invoke<TaskComment[]>('bttask_comments', { taskId });
|
||||
}
|
||||
|
||||
/** Update task status with optimistic locking. Returns the new version number. */
|
||||
export async function updateTaskStatus(taskId: string, status: string, version: number): Promise<number> {
|
||||
return invoke<number>('bttask_update_status', { taskId, status, version });
|
||||
}
|
||||
|
||||
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
|
||||
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
title: string,
|
||||
description: string,
|
||||
priority: string,
|
||||
groupId: GroupId,
|
||||
createdBy: AgentId,
|
||||
assignedTo?: AgentId,
|
||||
): Promise<string> {
|
||||
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string): Promise<void> {
|
||||
return invoke('bttask_delete', { taskId });
|
||||
}
|
||||
|
||||
/** Count tasks currently in 'review' status for a group */
|
||||
export async function reviewQueueCount(groupId: GroupId): Promise<number> {
|
||||
return invoke<number>('bttask_review_queue_count', { groupId });
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
// Claude Bridge — Tauri IPC adapter for Claude profiles and skills
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface ClaudeProfile {
|
||||
name: string;
|
||||
email: string | null;
|
||||
subscription_type: string | null;
|
||||
display_name: string | null;
|
||||
config_dir: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
source_path: string;
|
||||
}
|
||||
|
||||
export async function listProfiles(): Promise<ClaudeProfile[]> {
|
||||
return invoke<ClaudeProfile[]>('claude_list_profiles');
|
||||
}
|
||||
|
||||
export async function listSkills(): Promise<ClaudeSkill[]> {
|
||||
return invoke<ClaudeSkill[]>('claude_list_skills');
|
||||
}
|
||||
|
||||
export async function readSkill(path: string): Promise<string> {
|
||||
return invoke<string>('claude_read_skill', { path });
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface CtxEntry {
|
||||
project: string;
|
||||
key: string;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CtxSummary {
|
||||
project: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function ctxInitDb(): Promise<void> {
|
||||
return invoke('ctx_init_db');
|
||||
}
|
||||
|
||||
export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
|
||||
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
|
||||
}
|
||||
|
||||
export async function ctxGetContext(project: string): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_get_context', { project });
|
||||
}
|
||||
|
||||
export async function ctxGetShared(): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_get_shared');
|
||||
}
|
||||
|
||||
export async function ctxGetSummaries(project: string, limit: number = 5): Promise<CtxSummary[]> {
|
||||
return invoke('ctx_get_summaries', { project, limit });
|
||||
}
|
||||
|
||||
export async function ctxSearch(query: string): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_search', { query });
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface FileChangedPayload {
|
||||
pane_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Start watching a file; returns initial content */
|
||||
export async function watchFile(paneId: string, path: string): Promise<string> {
|
||||
return invoke('file_watch', { paneId, path });
|
||||
}
|
||||
|
||||
export async function unwatchFile(paneId: string): Promise<void> {
|
||||
return invoke('file_unwatch', { paneId });
|
||||
}
|
||||
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
return invoke('file_read', { path });
|
||||
}
|
||||
|
||||
export async function onFileChanged(
|
||||
callback: (payload: FileChangedPayload) => void
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<FileChangedPayload>('file-changed', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export type FileContent =
|
||||
| { type: 'Text'; content: string; lang: string }
|
||||
| { type: 'Binary'; message: string }
|
||||
| { type: 'TooLarge'; size: number };
|
||||
|
||||
export function listDirectoryChildren(path: string): Promise<DirEntry[]> {
|
||||
return invoke<DirEntry[]>('list_directory_children', { path });
|
||||
}
|
||||
|
||||
export function readFileContent(path: string): Promise<FileContent> {
|
||||
return invoke<FileContent>('read_file_content', { path });
|
||||
}
|
||||
|
||||
export function writeFileContent(path: string, content: string): Promise<void> {
|
||||
return invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Filesystem watcher bridge — listens for inotify-based write events from Rust
|
||||
// Part of S-1 Phase 2: real-time filesystem write detection
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface FsWriteEvent {
|
||||
project_id: string;
|
||||
file_path: string;
|
||||
timestamp_ms: number;
|
||||
}
|
||||
|
||||
/** Start watching a project's CWD for filesystem writes */
|
||||
export function fsWatchProject(projectId: string, cwd: string): Promise<void> {
|
||||
return invoke('fs_watch_project', { projectId, cwd });
|
||||
}
|
||||
|
||||
/** Stop watching a project's CWD */
|
||||
export function fsUnwatchProject(projectId: string): Promise<void> {
|
||||
return invoke('fs_unwatch_project', { projectId });
|
||||
}
|
||||
|
||||
/** Listen for filesystem write events from all watched projects */
|
||||
export function onFsWriteDetected(
|
||||
callback: (event: FsWriteEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<FsWriteEvent>('fs-write-detected', (e) => callback(e.payload));
|
||||
}
|
||||
|
||||
export interface FsWatcherStatus {
|
||||
max_watches: number;
|
||||
estimated_watches: number;
|
||||
usage_ratio: number;
|
||||
active_projects: number;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
/** Get inotify watcher status including kernel limit check */
|
||||
export function fsWatcherStatus(): Promise<FsWatcherStatus> {
|
||||
return invoke('fs_watcher_status');
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups';
|
||||
import type { SessionId, ProjectId } from '../types/ids';
|
||||
|
||||
export type { GroupsFile, ProjectConfig, GroupConfig };
|
||||
|
||||
export interface MdFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
priority: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: number;
|
||||
session_id: SessionId;
|
||||
project_id: ProjectId;
|
||||
sdk_session_id: string | null;
|
||||
message_type: string;
|
||||
content: string;
|
||||
parent_id: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ProjectAgentState {
|
||||
project_id: ProjectId;
|
||||
last_session_id: SessionId;
|
||||
sdk_session_id: string | null;
|
||||
status: string;
|
||||
cost_usd: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_prompt: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// --- Group config ---
|
||||
|
||||
export async function loadGroups(): Promise<GroupsFile> {
|
||||
return invoke('groups_load');
|
||||
}
|
||||
|
||||
export async function saveGroups(config: GroupsFile): Promise<void> {
|
||||
return invoke('groups_save', { config });
|
||||
}
|
||||
|
||||
// --- Markdown discovery ---
|
||||
|
||||
export async function discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
|
||||
return invoke('discover_markdown_files', { cwd });
|
||||
}
|
||||
|
||||
// --- Agent message persistence ---
|
||||
|
||||
export async function saveAgentMessages(
|
||||
sessionId: SessionId,
|
||||
projectId: ProjectId,
|
||||
sdkSessionId: string | undefined,
|
||||
messages: AgentMessageRecord[],
|
||||
): Promise<void> {
|
||||
return invoke('agent_messages_save', {
|
||||
sessionId,
|
||||
projectId,
|
||||
sdkSessionId: sdkSessionId ?? null,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
|
||||
return invoke('agent_messages_load', { projectId });
|
||||
}
|
||||
|
||||
// --- Project agent state ---
|
||||
|
||||
export async function saveProjectAgentState(state: ProjectAgentState): Promise<void> {
|
||||
return invoke('project_agent_state_save', { state });
|
||||
}
|
||||
|
||||
export async function loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
|
||||
return invoke('project_agent_state_load', { projectId });
|
||||
}
|
||||
|
||||
// --- Session metrics ---
|
||||
|
||||
export interface SessionMetric {
|
||||
id: number;
|
||||
project_id: ProjectId;
|
||||
session_id: SessionId;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
peak_tokens: number;
|
||||
turn_count: number;
|
||||
tool_call_count: number;
|
||||
cost_usd: number;
|
||||
model: string | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export async function saveSessionMetric(metric: Omit<SessionMetric, 'id'>): Promise<void> {
|
||||
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
|
||||
}
|
||||
|
||||
export async function loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetric[]> {
|
||||
return invoke('session_metrics_load', { projectId, limit });
|
||||
}
|
||||
|
||||
// --- CLI arguments ---
|
||||
|
||||
export async function getCliGroup(): Promise<string | null> {
|
||||
return invoke('cli_get_group');
|
||||
}
|
||||
62
src/lib/adapters/memora-adapter.ts
Normal file
62
src/lib/adapters/memora-adapter.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Memora adapter — uses BackendAdapter for database access.
|
||||
* Replaces the Tauri-coupled MemoraAdapter from memora-bridge.ts.
|
||||
*/
|
||||
|
||||
import { getBackend } from '../backend/backend';
|
||||
import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter';
|
||||
|
||||
function toMemoryNode(n: { id: number; content: string; tags: string[]; metadata?: Record<string, unknown>; created_at?: string; updated_at?: string }): MemoryNode {
|
||||
return {
|
||||
id: n.id,
|
||||
content: n.content,
|
||||
tags: n.tags,
|
||||
metadata: n.metadata,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class MemoraAdapter implements MemoryAdapter {
|
||||
readonly name = 'memora';
|
||||
private _available: boolean | null = null;
|
||||
|
||||
get available(): boolean {
|
||||
return this._available ?? true;
|
||||
}
|
||||
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
this._available = await getBackend().memoraAvailable();
|
||||
return this._available;
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MemorySearchResult> {
|
||||
const result = await getBackend().memoraList(options);
|
||||
this._available = true;
|
||||
return { nodes: result.nodes.map(toMemoryNode), total: result.total };
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
options?: { tags?: string[]; limit?: number },
|
||||
): Promise<MemorySearchResult> {
|
||||
const result = await getBackend().memoraSearch(query, options);
|
||||
this._available = true;
|
||||
return { nodes: result.nodes.map(toMemoryNode), total: result.total };
|
||||
}
|
||||
|
||||
async get(id: string | number): Promise<MemoryNode | null> {
|
||||
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
if (isNaN(numId)) return null;
|
||||
const node = await getBackend().memoraGet(numId);
|
||||
if (node) {
|
||||
this._available = true;
|
||||
return toMemoryNode(node);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
import {
|
||||
memoraAvailable,
|
||||
memoraList,
|
||||
memoraSearch,
|
||||
memoraGet,
|
||||
MemoraAdapter,
|
||||
} from './memora-bridge';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('memora IPC wrappers', () => {
|
||||
it('memoraAvailable invokes memora_available', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
const result = await memoraAvailable();
|
||||
expect(result).toBe(true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_available');
|
||||
});
|
||||
|
||||
it('memoraList invokes memora_list with defaults', async () => {
|
||||
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
|
||||
await memoraList();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_list', {
|
||||
tags: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('memoraList passes tags and pagination', async () => {
|
||||
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
|
||||
await memoraList({ tags: ['bterminal'], limit: 10, offset: 5 });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_list', {
|
||||
tags: ['bterminal'],
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('memoraSearch invokes memora_search', async () => {
|
||||
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
|
||||
await memoraSearch('test query', { tags: ['foo'], limit: 20 });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_search', {
|
||||
query: 'test query',
|
||||
tags: ['foo'],
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('memoraSearch uses defaults when no options', async () => {
|
||||
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
|
||||
await memoraSearch('hello');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_search', {
|
||||
query: 'hello',
|
||||
tags: null,
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('memoraGet invokes memora_get', async () => {
|
||||
const node = { id: 42, content: 'test', tags: ['a'], metadata: null, created_at: null, updated_at: null };
|
||||
mockInvoke.mockResolvedValue(node);
|
||||
const result = await memoraGet(42);
|
||||
expect(result).toEqual(node);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 42 });
|
||||
});
|
||||
|
||||
it('memoraGet returns null for missing', async () => {
|
||||
mockInvoke.mockResolvedValue(null);
|
||||
const result = await memoraGet(999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoraAdapter', () => {
|
||||
it('has name "memora"', () => {
|
||||
const adapter = new MemoraAdapter();
|
||||
expect(adapter.name).toBe('memora');
|
||||
});
|
||||
|
||||
it('available is true by default (optimistic)', () => {
|
||||
const adapter = new MemoraAdapter();
|
||||
expect(adapter.available).toBe(true);
|
||||
});
|
||||
|
||||
it('checkAvailability updates available state', async () => {
|
||||
mockInvoke.mockResolvedValue(false);
|
||||
const adapter = new MemoraAdapter();
|
||||
const result = await adapter.checkAvailability();
|
||||
expect(result).toBe(false);
|
||||
expect(adapter.available).toBe(false);
|
||||
});
|
||||
|
||||
it('list returns mapped MemorySearchResult', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
nodes: [
|
||||
{ id: 1, content: 'hello', tags: ['a', 'b'], metadata: { key: 'val' }, created_at: '2026-01-01', updated_at: null },
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const adapter = new MemoraAdapter();
|
||||
const result = await adapter.list({ limit: 10 });
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.nodes[0].id).toBe(1);
|
||||
expect(result.nodes[0].content).toBe('hello');
|
||||
expect(result.nodes[0].tags).toEqual(['a', 'b']);
|
||||
expect(result.nodes[0].metadata).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
it('search returns mapped results', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
nodes: [{ id: 5, content: 'found', tags: ['x'], metadata: null, created_at: null, updated_at: null }],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const adapter = new MemoraAdapter();
|
||||
const result = await adapter.search('found', { limit: 5 });
|
||||
expect(result.nodes[0].content).toBe('found');
|
||||
expect(adapter.available).toBe(true);
|
||||
});
|
||||
|
||||
it('get returns mapped node', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
id: 10, content: 'node', tags: ['t'], metadata: null, created_at: '2026-01-01', updated_at: '2026-01-02',
|
||||
});
|
||||
|
||||
const adapter = new MemoraAdapter();
|
||||
const node = await adapter.get(10);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node!.id).toBe(10);
|
||||
expect(node!.updated_at).toBe('2026-01-02');
|
||||
});
|
||||
|
||||
it('get returns null for missing node', async () => {
|
||||
mockInvoke.mockResolvedValue(null);
|
||||
const adapter = new MemoraAdapter();
|
||||
const node = await adapter.get(999);
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
|
||||
it('get handles string id', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
id: 7, content: 'x', tags: [], metadata: null, created_at: null, updated_at: null,
|
||||
});
|
||||
|
||||
const adapter = new MemoraAdapter();
|
||||
const node = await adapter.get('7');
|
||||
expect(node).not.toBeNull();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 7 });
|
||||
});
|
||||
|
||||
it('get returns null for non-numeric string id', async () => {
|
||||
const adapter = new MemoraAdapter();
|
||||
const node = await adapter.get('abc');
|
||||
expect(node).toBeNull();
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
/**
|
||||
* Memora IPC bridge — read-only access to the Memora memory database.
|
||||
* Wraps Tauri commands and provides a MemoryAdapter implementation.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter';
|
||||
|
||||
// --- Raw IPC types (match Rust structs) ---
|
||||
|
||||
interface MemoraNode {
|
||||
id: number;
|
||||
content: string;
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface MemoraSearchResult {
|
||||
nodes: MemoraNode[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// --- IPC wrappers ---
|
||||
|
||||
export async function memoraAvailable(): Promise<boolean> {
|
||||
return invoke<boolean>('memora_available');
|
||||
}
|
||||
|
||||
export async function memoraList(options?: {
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MemoraSearchResult> {
|
||||
return invoke<MemoraSearchResult>('memora_list', {
|
||||
tags: options?.tags ?? null,
|
||||
limit: options?.limit ?? 50,
|
||||
offset: options?.offset ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function memoraSearch(
|
||||
query: string,
|
||||
options?: { tags?: string[]; limit?: number },
|
||||
): Promise<MemoraSearchResult> {
|
||||
return invoke<MemoraSearchResult>('memora_search', {
|
||||
query,
|
||||
tags: options?.tags ?? null,
|
||||
limit: options?.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
export async function memoraGet(id: number): Promise<MemoraNode | null> {
|
||||
return invoke<MemoraNode | null>('memora_get', { id });
|
||||
}
|
||||
|
||||
// --- MemoryAdapter implementation ---
|
||||
|
||||
function toMemoryNode(n: MemoraNode): MemoryNode {
|
||||
return {
|
||||
id: n.id,
|
||||
content: n.content,
|
||||
tags: n.tags,
|
||||
metadata: n.metadata,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toSearchResult(r: MemoraSearchResult): MemorySearchResult {
|
||||
return {
|
||||
nodes: r.nodes.map(toMemoryNode),
|
||||
total: r.total,
|
||||
};
|
||||
}
|
||||
|
||||
export class MemoraAdapter implements MemoryAdapter {
|
||||
readonly name = 'memora';
|
||||
private _available: boolean | null = null;
|
||||
|
||||
get available(): boolean {
|
||||
// Optimistic: assume available until first check proves otherwise.
|
||||
// Actual availability is checked lazily on first operation.
|
||||
return this._available ?? true;
|
||||
}
|
||||
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
this._available = await memoraAvailable();
|
||||
return this._available;
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MemorySearchResult> {
|
||||
const result = await memoraList(options);
|
||||
this._available = true;
|
||||
return toSearchResult(result);
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
options?: { tags?: string[]; limit?: number },
|
||||
): Promise<MemorySearchResult> {
|
||||
const result = await memoraSearch(query, options);
|
||||
this._available = true;
|
||||
return toSearchResult(result);
|
||||
}
|
||||
|
||||
async get(id: string | number): Promise<MemoryNode | null> {
|
||||
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
if (isNaN(numId)) return null;
|
||||
const node = await memoraGet(numId);
|
||||
if (node) {
|
||||
this._available = true;
|
||||
return toMemoryNode(node);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { adaptSDKMessage } from './claude-messages';
|
|||
import { adaptCodexMessage } from './codex-messages';
|
||||
import { adaptOllamaMessage } from './ollama-messages';
|
||||
import { adaptAiderMessage } from './aider-messages';
|
||||
import { tel } from './telemetry-bridge';
|
||||
import { tel } from '../utils/telemetry';
|
||||
|
||||
/** Function signature for a provider message adapter */
|
||||
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
// Notifications bridge — wraps Tauri desktop notification command
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type NotificationUrgency = 'low' | 'normal' | 'critical';
|
||||
|
||||
/**
|
||||
* Send an OS desktop notification via notify-rust.
|
||||
* Fire-and-forget: errors are swallowed (notification daemon may not be running).
|
||||
*/
|
||||
export function sendDesktopNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
urgency: NotificationUrgency = 'normal',
|
||||
): void {
|
||||
invoke('notify_desktop', { title, body, urgency }).catch((_e: unknown) => {
|
||||
// Intentional: notification daemon may not be running. Cannot use handleInfraError
|
||||
// here — it calls tel.error, and notify() calls sendDesktopNotification, creating a loop.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notifications-bridge] Desktop notification failed:', _e);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// Plugin discovery and file access — Tauri IPC adapter
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
main: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** Discover all plugins in ~/.config/agor/plugins/ */
|
||||
export async function discoverPlugins(): Promise<PluginMeta[]> {
|
||||
return invoke<PluginMeta[]>('plugins_discover');
|
||||
}
|
||||
|
||||
/** Read a file from a plugin's directory (path-traversal safe) */
|
||||
export async function readPluginFile(pluginId: string, filename: string): Promise<string> {
|
||||
return invoke<string>('plugin_read_file', { pluginId, filename });
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// Provider Bridge — generic adapter that delegates to provider-specific bridges
|
||||
// Currently only Claude is implemented; future providers add their own bridge files
|
||||
|
||||
import type { ProviderId } from '../providers/types';
|
||||
import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { ClaudeProfile, ClaudeSkill };
|
||||
|
||||
/** List profiles for a given provider (only Claude supports this) */
|
||||
export async function listProviderProfiles(provider: ProviderId): Promise<ClaudeProfile[]> {
|
||||
if (provider === 'claude') return claudeListProfiles();
|
||||
return [];
|
||||
}
|
||||
|
||||
/** List skills for a given provider (only Claude supports this) */
|
||||
export async function listProviderSkills(provider: ProviderId): Promise<ClaudeSkill[]> {
|
||||
if (provider === 'claude') return claudeListSkills();
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Read a skill file (only Claude supports this) */
|
||||
export async function readProviderSkill(provider: ProviderId, path: string): Promise<string> {
|
||||
if (provider === 'claude') return claudeReadSkill(path);
|
||||
return '';
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface PtyOptions {
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
||||
export async function spawnPty(options: PtyOptions): Promise<string> {
|
||||
if (options.remote_machine_id) {
|
||||
const { remote_machine_id: machineId, ...ptyOptions } = options;
|
||||
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
|
||||
}
|
||||
return invoke<string>('pty_spawn', { options });
|
||||
}
|
||||
|
||||
export async function writePty(id: string, data: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_write', { machineId: remoteMachineId, id, data });
|
||||
}
|
||||
return invoke('pty_write', { id, data });
|
||||
}
|
||||
|
||||
export async function resizePty(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows });
|
||||
}
|
||||
return invoke('pty_resize', { id, cols, rows });
|
||||
}
|
||||
|
||||
export async function killPty(id: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_kill', { machineId: remoteMachineId, id });
|
||||
}
|
||||
return invoke('pty_kill', { id });
|
||||
}
|
||||
|
||||
export async function onPtyData(id: string, callback: (data: string) => void): Promise<UnlistenFn> {
|
||||
return listen<string>(`pty-data-${id}`, (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onPtyExit(id: string, callback: () => void): Promise<UnlistenFn> {
|
||||
return listen(`pty-exit-${id}`, () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
// Remote Machine Bridge — Tauri IPC adapter for multi-machine management
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface RemoteMachineConfig {
|
||||
label: string;
|
||||
url: string;
|
||||
token: string;
|
||||
auto_connect: boolean;
|
||||
/** SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect. */
|
||||
spki_pins?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteMachineInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
status: string;
|
||||
auto_connect: boolean;
|
||||
/** Currently stored SPKI pin hashes (hex-encoded SHA-256) */
|
||||
spki_pins: string[];
|
||||
}
|
||||
|
||||
// --- Machine management ---
|
||||
|
||||
export async function listRemoteMachines(): Promise<RemoteMachineInfo[]> {
|
||||
return invoke('remote_list');
|
||||
}
|
||||
|
||||
export async function addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
|
||||
return invoke('remote_add', { config });
|
||||
}
|
||||
|
||||
export async function removeRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_remove', { machineId });
|
||||
}
|
||||
|
||||
export async function connectRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_connect', { machineId });
|
||||
}
|
||||
|
||||
export async function disconnectRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_disconnect', { machineId });
|
||||
}
|
||||
|
||||
// --- SPKI certificate pinning ---
|
||||
|
||||
/** Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded). */
|
||||
export async function probeSpki(url: string): Promise<string> {
|
||||
return invoke('remote_probe_spki', { url });
|
||||
}
|
||||
|
||||
/** Add an SPKI pin hash to a machine's trusted pins. */
|
||||
export async function addSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_add_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
/** Remove an SPKI pin hash from a machine's trusted pins. */
|
||||
export async function removeSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_remove_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
// --- Remote event listeners ---
|
||||
|
||||
export interface RemoteSidecarMessage {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemotePtyData {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface RemotePtyExit {
|
||||
machineId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface RemoteMachineEvent {
|
||||
machineId: string;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export async function onRemoteSidecarMessage(
|
||||
callback: (msg: RemoteSidecarMessage) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteSidecarMessage>('remote-sidecar-message', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemotePtyData(
|
||||
callback: (msg: RemotePtyData) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemotePtyData>('remote-pty-data', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemotePtyExit(
|
||||
callback: (msg: RemotePtyExit) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemotePtyExit>('remote-pty-exit', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemoteMachineReady(
|
||||
callback: (msg: RemoteMachineEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteMachineEvent>('remote-machine-ready', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemoteMachineDisconnected(
|
||||
callback: (msg: RemoteMachineEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteMachineEvent>('remote-machine-disconnected', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemoteStateSync(
|
||||
callback: (msg: RemoteMachineEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteMachineEvent>('remote-state-sync', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemoteError(
|
||||
callback: (msg: RemoteMachineEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteMachineEvent>('remote-error', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export interface RemoteReconnectingEvent {
|
||||
machineId: string;
|
||||
backoffSecs: number;
|
||||
}
|
||||
|
||||
export async function onRemoteMachineReconnecting(
|
||||
callback: (msg: RemoteReconnectingEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteReconnectingEvent>('remote-machine-reconnecting', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRemoteMachineReconnectReady(
|
||||
callback: (msg: RemoteMachineEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteMachineEvent>('remote-machine-reconnect-ready', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
// --- SPKI TOFU event ---
|
||||
|
||||
export interface RemoteSpkiTofuEvent {
|
||||
machineId: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/** Listen for TOFU (Trust On First Use) events when a new SPKI pin is auto-stored. */
|
||||
export async function onRemoteSpkiTofu(
|
||||
callback: (msg: RemoteSpkiTofuEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteSpkiTofuEvent>('remote-spki-tofu', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// Search Bridge — Tauri IPC adapter for FTS5 full-text search
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface SearchResult {
|
||||
resultType: string;
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Confirm search database is ready (no-op, initialized at app startup). */
|
||||
export async function initSearch(): Promise<void> {
|
||||
return invoke('search_init');
|
||||
}
|
||||
|
||||
/** Search across all FTS5 tables (messages, tasks, btmsg). */
|
||||
export async function searchAll(query: string, limit?: number): Promise<SearchResult[]> {
|
||||
return invoke<SearchResult[]>('search_query', { query, limit: limit ?? 20 });
|
||||
}
|
||||
|
||||
/** Drop and recreate all FTS5 tables (clears the index). */
|
||||
export async function rebuildIndex(): Promise<void> {
|
||||
return invoke('search_rebuild');
|
||||
}
|
||||
|
||||
/** Index an agent message into the search database. */
|
||||
export async function indexMessage(sessionId: string, role: string, content: string): Promise<void> {
|
||||
return invoke('search_index_message', { sessionId, role, content });
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Store a secret in the system keyring. */
|
||||
export async function storeSecret(key: string, value: string): Promise<void> {
|
||||
return invoke('secrets_store', { key, value });
|
||||
}
|
||||
|
||||
/** Retrieve a secret from the system keyring. Returns null if not found. */
|
||||
export async function getSecret(key: string): Promise<string | null> {
|
||||
return invoke('secrets_get', { key });
|
||||
}
|
||||
|
||||
/** Delete a secret from the system keyring. */
|
||||
export async function deleteSecret(key: string): Promise<void> {
|
||||
return invoke('secrets_delete', { key });
|
||||
}
|
||||
|
||||
/** List keys that have been stored in the keyring. */
|
||||
export async function listSecrets(): Promise<string[]> {
|
||||
return invoke('secrets_list');
|
||||
}
|
||||
|
||||
/** Check if the system keyring is available. */
|
||||
export async function hasKeyring(): Promise<boolean> {
|
||||
return invoke('secrets_has_keyring');
|
||||
}
|
||||
|
||||
/** Get the list of known/recognized secret key identifiers. */
|
||||
export async function knownSecretKeys(): Promise<string[]> {
|
||||
return invoke('secrets_known_keys');
|
||||
}
|
||||
|
||||
/** Human-readable labels for known secret keys. */
|
||||
export const SECRET_KEY_LABELS: Record<string, string> = {
|
||||
anthropic_api_key: 'Anthropic API Key',
|
||||
openai_api_key: 'OpenAI API Key',
|
||||
openrouter_api_key: 'OpenRouter API Key',
|
||||
github_token: 'GitHub Token',
|
||||
relay_token: 'Relay Token',
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface PersistedSession {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
group_name?: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export interface PersistedLayout {
|
||||
preset: string;
|
||||
pane_ids: string[];
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<PersistedSession[]> {
|
||||
return invoke('session_list');
|
||||
}
|
||||
|
||||
export async function saveSession(session: PersistedSession): Promise<void> {
|
||||
return invoke('session_save', { session });
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
return invoke('session_delete', { id });
|
||||
}
|
||||
|
||||
export async function updateSessionTitle(id: string, title: string): Promise<void> {
|
||||
return invoke('session_update_title', { id, title });
|
||||
}
|
||||
|
||||
export async function touchSession(id: string): Promise<void> {
|
||||
return invoke('session_touch', { id });
|
||||
}
|
||||
|
||||
export async function updateSessionGroup(id: string, groupName: string): Promise<void> {
|
||||
return invoke('session_update_group', { id, group_name: groupName });
|
||||
}
|
||||
|
||||
export async function saveLayout(layout: PersistedLayout): Promise<void> {
|
||||
return invoke('layout_save', { layout });
|
||||
}
|
||||
|
||||
export async function loadLayout(): Promise<PersistedLayout> {
|
||||
return invoke('layout_load');
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface SshSession {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_file: string;
|
||||
folder: string;
|
||||
color: string;
|
||||
created_at: number;
|
||||
last_used_at: number;
|
||||
}
|
||||
|
||||
export async function listSshSessions(): Promise<SshSession[]> {
|
||||
return invoke('ssh_session_list');
|
||||
}
|
||||
|
||||
export async function saveSshSession(session: SshSession): Promise<void> {
|
||||
return invoke('ssh_session_save', { session });
|
||||
}
|
||||
|
||||
export async function deleteSshSession(id: string): Promise<void> {
|
||||
return invoke('ssh_session_delete', { id });
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// Telemetry bridge — routes frontend events to Rust tracing via IPC
|
||||
// No browser OTEL SDK needed (WebKit2GTK incompatible)
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
|
||||
/** Emit a structured log event to the Rust tracing layer */
|
||||
export function telemetryLog(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
invoke('frontend_log', { level, message, context: context ?? null }).catch((_e: unknown) => {
|
||||
// Intentional: telemetry must never break the app or trigger notification loops.
|
||||
// Cannot use handleInfraError here — it calls tel.error which would recurse.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[telemetry-bridge] IPC failed:', _e);
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience wrappers */
|
||||
export const tel = {
|
||||
error: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('error', msg, ctx),
|
||||
warn: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('warn', msg, ctx),
|
||||
info: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('info', msg, ctx),
|
||||
debug: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('debug', msg, ctx),
|
||||
trace: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('trace', msg, ctx),
|
||||
};
|
||||
|
|
@ -41,16 +41,23 @@ const {
|
|||
mockAddNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./adapters/agent-bridge', () => ({
|
||||
onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => {
|
||||
capturedCallbacks.msg = cb;
|
||||
return mockUnlistenMsg;
|
||||
}),
|
||||
onSidecarExited: vi.fn(async (cb: () => void) => {
|
||||
capturedCallbacks.exit = cb;
|
||||
return mockUnlistenExit;
|
||||
}),
|
||||
restartAgent: (...args: unknown[]) => mockRestartAgent(...args),
|
||||
vi.mock('./backend/backend', () => ({
|
||||
getBackend: vi.fn(() => ({
|
||||
onSidecarMessage: vi.fn((cb: (msg: any) => void) => {
|
||||
capturedCallbacks.msg = cb;
|
||||
return mockUnlistenMsg;
|
||||
}),
|
||||
onSidecarExited: vi.fn((cb: () => void) => {
|
||||
capturedCallbacks.exit = cb;
|
||||
return mockUnlistenExit;
|
||||
}),
|
||||
restartAgentSidecar: (...args: unknown[]) => mockRestartAgent(...args),
|
||||
btmsgSetStatus: vi.fn().mockResolvedValue(undefined),
|
||||
btmsgRecordHeartbeat: vi.fn().mockResolvedValue(undefined),
|
||||
logAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
searchIndexMessage: vi.fn().mockResolvedValue(undefined),
|
||||
telemetryLog: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./providers/types', () => ({}));
|
||||
|
|
@ -235,8 +242,9 @@ describe('agent-dispatcher', () => {
|
|||
await startAgentDispatcher();
|
||||
await startAgentDispatcher(); // second call should be no-op
|
||||
|
||||
const { onSidecarMessage } = await import('./adapters/agent-bridge');
|
||||
expect(onSidecarMessage).toHaveBeenCalledTimes(1);
|
||||
// On second call, capturedCallbacks.msg should still be the same (not reset)
|
||||
// The dispatcher guards against duplicate registration via `if (unlistenMsg) return;`
|
||||
expect(capturedCallbacks.msg).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sets sidecarAlive to true on start', async () => {
|
||||
|
|
@ -408,8 +416,8 @@ describe('agent-dispatcher', () => {
|
|||
stopAgentDispatcher();
|
||||
await startAgentDispatcher();
|
||||
|
||||
const { onSidecarMessage } = await import('./adapters/agent-bridge');
|
||||
expect(onSidecarMessage).toHaveBeenCalledTimes(2);
|
||||
// After stop + restart, callbacks should be re-registered
|
||||
expect(capturedCallbacks.msg).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// Thin coordinator that routes sidecar messages to specialized modules
|
||||
|
||||
import { SessionId, type SessionId as SessionIdType } from './types/ids';
|
||||
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge';
|
||||
import { getBackend } from './backend/backend';
|
||||
import type { SidecarMessagePayload } from '@agor/types';
|
||||
import { adaptMessage } from './adapters/message-adapters';
|
||||
import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
|
||||
import {
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
} from './stores/agents.svelte';
|
||||
import { notify, addNotification } from './stores/notifications.svelte';
|
||||
import { classifyError } from './utils/error-classifier';
|
||||
import { tel } from './adapters/telemetry-bridge';
|
||||
import { tel } from './utils/telemetry';
|
||||
import { handleInfraError } from './utils/handle-error';
|
||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
||||
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
|
||||
|
|
@ -37,16 +38,13 @@ import {
|
|||
spawnSubagentPane,
|
||||
clearSubagentRoutes,
|
||||
} from './utils/subagent-router';
|
||||
import { indexMessage } from './adapters/search-bridge';
|
||||
import { recordHeartbeat, setAgentStatus as setBtmsgAgentStatus } from './adapters/btmsg-bridge';
|
||||
import { logAuditEvent } from './adapters/audit-bridge';
|
||||
import type { AgentId } from './types/ids';
|
||||
|
||||
/** Sync btmsg agent status to 'stopped' when a session reaches terminal state */
|
||||
function syncBtmsgStopped(sessionId: SessionIdType): void {
|
||||
const projectId = getSessionProjectId(sessionId);
|
||||
if (projectId) {
|
||||
setBtmsgAgentStatus(projectId as unknown as AgentId, 'stopped').catch(e => handleInfraError(e, 'dispatcher.syncBtmsgStopped'));
|
||||
getBackend().btmsgSetStatus(projectId as unknown as AgentId, 'stopped').catch(e => handleInfraError(e, 'dispatcher.syncBtmsgStopped'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +73,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
|
||||
sidecarAlive = true;
|
||||
|
||||
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => {
|
||||
unlistenMsg = getBackend().onSidecarMessage((msg: SidecarMessagePayload) => {
|
||||
sidecarAlive = true;
|
||||
// Reset restart counter on any successful message — sidecar recovered
|
||||
if (restartAttempts > 0) {
|
||||
|
|
@ -89,7 +87,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
// Record heartbeat on any agent activity (best-effort, fire-and-forget)
|
||||
const hbProjectId = getSessionProjectId(sessionId);
|
||||
if (hbProjectId) {
|
||||
recordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat'));
|
||||
getBackend().btmsgRecordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat'));
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
|
|
@ -98,7 +96,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
recordSessionStart(sessionId);
|
||||
tel.info('agent_started', { sessionId });
|
||||
if (hbProjectId) {
|
||||
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -113,7 +111,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
|
||||
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
|
||||
if (hbProjectId) {
|
||||
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -141,7 +139,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
|
||||
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
|
||||
if (hbProjectId) {
|
||||
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -151,7 +149,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
}
|
||||
});
|
||||
|
||||
unlistenExit = await onSidecarExited(async () => {
|
||||
unlistenExit = getBackend().onSidecarExited(async () => {
|
||||
sidecarAlive = false;
|
||||
tel.error('sidecar_crashed', { restartAttempts });
|
||||
|
||||
|
|
@ -176,7 +174,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system');
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
try {
|
||||
await restartAgent();
|
||||
await getBackend().restartAgentSidecar();
|
||||
sidecarAlive = true;
|
||||
// Note: restartAttempts is reset when next sidecar message arrives
|
||||
} catch {
|
||||
|
|
@ -334,7 +332,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
|
|||
// Index searchable text content into FTS5 search database
|
||||
for (const msg of mainMessages) {
|
||||
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
|
||||
indexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage'));
|
||||
getBackend().searchIndexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,40 @@
|
|||
// Wraps the Electrobun RPC pattern (request/response + message listeners)
|
||||
|
||||
import type {
|
||||
BackendAdapter,
|
||||
BackendCapabilities,
|
||||
UnsubscribeFn,
|
||||
AgentStartOptions,
|
||||
AgentMessage,
|
||||
AgentStatus,
|
||||
FileEntry,
|
||||
FileContent,
|
||||
PtyCreateOptions,
|
||||
SettingsMap,
|
||||
GroupsFile,
|
||||
GroupConfig,
|
||||
GroupId,
|
||||
BackendAdapter, BackendCapabilities, UnsubscribeFn,
|
||||
AgentStartOptions, AgentMessage, AgentStatus,
|
||||
FileEntry, FileContent, PtyCreateOptions,
|
||||
SettingsMap, GroupsFile, GroupConfig, GroupId, AgentId, SessionId, ProjectId,
|
||||
SearchResult,
|
||||
PersistedSession, PersistedLayout,
|
||||
AgentMessageRecord, ProjectAgentState, SessionMetricRecord, MdFileEntry,
|
||||
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, BtmsgFeedMessage,
|
||||
DeadLetterEntry, AuditEntry, AuditEventType,
|
||||
Task, TaskComment,
|
||||
SessionAnchorRecord,
|
||||
NotificationUrgency, TelemetryLevel,
|
||||
FsWriteEvent, FsWatcherStatus,
|
||||
CtxEntry, CtxSummary,
|
||||
MemoraNode, MemoraSearchResult,
|
||||
SshSessionRecord, PluginMeta,
|
||||
ClaudeProfile, ClaudeSkill,
|
||||
RemoteMachineConfig, RemoteMachineInfo,
|
||||
RemoteSidecarMessage, RemotePtyData, RemotePtyExit,
|
||||
RemoteMachineEvent, RemoteReconnectingEvent, RemoteSpkiTofuEvent,
|
||||
FileChangedPayload, AgentQueryOptions, SidecarMessagePayload, PtySpawnOptions,
|
||||
} from '@agor/types';
|
||||
|
||||
// ── Capabilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ELECTROBUN_CAPABILITIES: BackendCapabilities = {
|
||||
supportsPtyMultiplexing: true, // via agor-ptyd daemon
|
||||
supportsPluginSandbox: true, // Web Worker sandbox works in any webview
|
||||
supportsNativeMenus: false, // Electrobun has limited menu support
|
||||
supportsOsKeychain: false, // No keyring crate — uses file-based secrets
|
||||
supportsFileDialogs: false, // No native dialog plugin
|
||||
supportsAutoUpdater: false, // Custom updater via GitHub Releases check
|
||||
supportsDesktopNotifications: false, // No notify-rust — uses in-app toasts only
|
||||
supportsTelemetry: false, // No OTLP export in Electrobun backend
|
||||
supportsPtyMultiplexing: true,
|
||||
supportsPluginSandbox: true,
|
||||
supportsNativeMenus: false,
|
||||
supportsOsKeychain: false,
|
||||
supportsFileDialogs: false,
|
||||
supportsAutoUpdater: false,
|
||||
supportsDesktopNotifications: false,
|
||||
supportsTelemetry: false,
|
||||
};
|
||||
|
||||
// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ──────────────
|
||||
|
|
@ -67,7 +75,6 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
// Remove all registered message listeners
|
||||
if (this.rpc?.removeMessageListener) {
|
||||
for (const { event, handler } of this.messageHandlers) {
|
||||
this.rpc.removeMessageListener(event, handler);
|
||||
|
|
@ -96,12 +103,10 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
// ── Groups ───────────────────────────────────────────────────────────────
|
||||
|
||||
async loadGroups(): Promise<GroupsFile> {
|
||||
// Electrobun stores groups differently — reconstruct GroupsFile from flat list
|
||||
const res = await this.r.request['groups.list']({}) as {
|
||||
groups: Array<{ id: string; name: string; icon: string; position: number }>;
|
||||
};
|
||||
|
||||
// Load projects per group from settings
|
||||
const projectsRes = await this.r.request['settings.getProjects']({}) as {
|
||||
projects: Array<{ id: string; config: string }>;
|
||||
};
|
||||
|
|
@ -130,42 +135,29 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
}
|
||||
|
||||
async saveGroups(groupsFile: GroupsFile): Promise<void> {
|
||||
// Save groups list
|
||||
for (const group of groupsFile.groups) {
|
||||
await this.r.request['groups.create']({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
icon: '',
|
||||
position: groupsFile.groups.indexOf(group),
|
||||
id: group.id, name: group.name, icon: '', position: groupsFile.groups.indexOf(group),
|
||||
});
|
||||
}
|
||||
|
||||
// Save projects per group
|
||||
for (const group of groupsFile.groups) {
|
||||
for (const project of group.projects) {
|
||||
await this.r.request['settings.setProject']({
|
||||
id: project.id,
|
||||
config: JSON.stringify({ ...project, groupId: group.id }),
|
||||
id: project.id, config: JSON.stringify({ ...project, groupId: group.id }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────────
|
||||
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||
|
||||
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
||||
const res = await this.r.request['agent.start']({
|
||||
sessionId: options.sessionId,
|
||||
provider: options.provider ?? 'claude',
|
||||
prompt: options.prompt,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
claudeConfigDir: options.claudeConfigDir,
|
||||
extraEnv: options.extraEnv,
|
||||
additionalDirectories: options.additionalDirectories,
|
||||
sessionId: options.sessionId, provider: options.provider ?? 'claude',
|
||||
prompt: options.prompt, cwd: options.cwd, model: options.model,
|
||||
systemPrompt: options.systemPrompt, maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode, claudeConfigDir: options.claudeConfigDir,
|
||||
extraEnv: options.extraEnv, additionalDirectories: options.additionalDirectories,
|
||||
worktreeName: options.worktreeName,
|
||||
}) as { ok: boolean; error?: string };
|
||||
return res;
|
||||
|
|
@ -181,16 +173,12 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
return res;
|
||||
}
|
||||
|
||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
||||
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||
|
||||
async createPty(options: PtyCreateOptions): Promise<string> {
|
||||
const res = await this.r.request['pty.create']({
|
||||
sessionId: options.sessionId,
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
cwd: options.cwd,
|
||||
shell: options.shell,
|
||||
args: options.args,
|
||||
sessionId: options.sessionId, cols: options.cols, rows: options.rows,
|
||||
cwd: options.cwd, shell: options.shell, args: options.args,
|
||||
}) as { ok: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
|
||||
return options.sessionId;
|
||||
|
|
@ -212,29 +200,20 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
|
||||
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||
const res = await this.r.request['files.list']({ path }) as {
|
||||
entries: Array<{ name: string; type: string; size: number }>;
|
||||
error?: string;
|
||||
entries: Array<{ name: string; type: string; size: number }>; error?: string;
|
||||
};
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: `${path}/${e.name}`,
|
||||
isDir: e.type === 'dir',
|
||||
size: e.size,
|
||||
name: e.name, path: `${path}/${e.name}`, isDir: e.type === 'dir', size: e.size,
|
||||
}));
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<FileContent> {
|
||||
const res = await this.r.request['files.read']({ path }) as {
|
||||
content?: string;
|
||||
encoding: string;
|
||||
size: number;
|
||||
error?: string;
|
||||
content?: string; encoding: string; size: number; error?: string;
|
||||
};
|
||||
if (res.error) throw new Error(res.error);
|
||||
if (res.encoding === 'base64') {
|
||||
return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
|
||||
}
|
||||
if (res.encoding === 'base64') return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
|
||||
return { type: 'Text', content: res.content ?? '' };
|
||||
}
|
||||
|
||||
|
|
@ -243,6 +222,307 @@ export class ElectrobunAdapter implements BackendAdapter {
|
|||
if (!res.ok) throw new Error(res.error ?? 'Write failed');
|
||||
}
|
||||
|
||||
// ── Session persistence ─────────────────────────────────────────────────
|
||||
|
||||
async listSessions(): Promise<PersistedSession[]> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return [];
|
||||
}
|
||||
|
||||
async saveSession(_session: PersistedSession): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async deleteSession(_id: string): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async updateSessionTitle(_id: string, _title: string): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async touchSession(_id: string): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async updateSessionGroup(_id: string, _groupName: string): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async saveLayout(_layout: PersistedLayout): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async loadLayout(): Promise<PersistedLayout> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return { preset: '1-col', pane_ids: [] };
|
||||
}
|
||||
|
||||
// ── Agent persistence ───────────────────────────────────────────────────
|
||||
|
||||
async saveAgentMessages(
|
||||
_sessionId: SessionId, _projectId: ProjectId, _sdkSessionId: string | undefined,
|
||||
_messages: AgentMessageRecord[],
|
||||
): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async loadAgentMessages(_projectId: ProjectId): Promise<AgentMessageRecord[]> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return [];
|
||||
}
|
||||
|
||||
async saveProjectAgentState(_state: ProjectAgentState): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async loadProjectAgentState(_projectId: ProjectId): Promise<ProjectAgentState | null> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return null;
|
||||
}
|
||||
|
||||
async saveSessionMetric(_metric: Omit<SessionMetricRecord, 'id'>): Promise<void> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
}
|
||||
|
||||
async loadSessionMetrics(_projectId: ProjectId, _limit?: number): Promise<SessionMetricRecord[]> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCliGroup(): Promise<string | null> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return null;
|
||||
}
|
||||
|
||||
async discoverMarkdownFiles(_cwd: string): Promise<MdFileEntry[]> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Btmsg ───────────────────────────────────────────────────────────────
|
||||
|
||||
async btmsgGetAgents(_groupId: GroupId): Promise<BtmsgAgent[]> {
|
||||
// TODO: wire to Electrobun RPC
|
||||
return [];
|
||||
}
|
||||
|
||||
async btmsgUnreadCount(_agentId: AgentId): Promise<number> { return 0; }
|
||||
async btmsgUnreadMessages(_agentId: AgentId): Promise<BtmsgMessage[]> { return []; }
|
||||
async btmsgHistory(_agentId: AgentId, _otherId: AgentId, _limit?: number): Promise<BtmsgMessage[]> { return []; }
|
||||
async btmsgSend(_fromAgent: AgentId, _toAgent: AgentId, _content: string): Promise<string> { return ''; }
|
||||
async btmsgSetStatus(_agentId: AgentId, _status: string): Promise<void> { }
|
||||
async btmsgEnsureAdmin(_groupId: GroupId): Promise<void> { }
|
||||
async btmsgAllFeed(_groupId: GroupId, _limit?: number): Promise<BtmsgFeedMessage[]> { return []; }
|
||||
async btmsgMarkRead(_readerId: AgentId, _senderId: AgentId): Promise<void> { }
|
||||
async btmsgGetChannels(_groupId: GroupId): Promise<BtmsgChannel[]> { return []; }
|
||||
async btmsgChannelMessages(_channelId: string, _limit?: number): Promise<BtmsgChannelMessage[]> { return []; }
|
||||
async btmsgChannelSend(_channelId: string, _fromAgent: AgentId, _content: string): Promise<string> { return ''; }
|
||||
async btmsgCreateChannel(_name: string, _groupId: GroupId, _createdBy: AgentId): Promise<string> { return ''; }
|
||||
async btmsgAddChannelMember(_channelId: string, _agentId: AgentId): Promise<void> { }
|
||||
async btmsgRegisterAgents(_config: GroupsFile): Promise<void> { }
|
||||
async btmsgUnseenMessages(_agentId: AgentId, _sessionId: string): Promise<BtmsgMessage[]> { return []; }
|
||||
async btmsgMarkSeen(_sessionId: string, _messageIds: string[]): Promise<void> { }
|
||||
async btmsgPruneSeen(): Promise<number> { return 0; }
|
||||
async btmsgRecordHeartbeat(_agentId: AgentId): Promise<void> { }
|
||||
async btmsgGetStaleAgents(_groupId: GroupId, _thresholdSecs?: number): Promise<string[]> { return []; }
|
||||
async btmsgGetDeadLetters(_groupId: GroupId, _limit?: number): Promise<DeadLetterEntry[]> { return []; }
|
||||
async btmsgClearDeadLetters(_groupId: GroupId): Promise<void> { }
|
||||
async btmsgClearAllComms(_groupId: GroupId): Promise<void> { }
|
||||
|
||||
// ── Bttask ──────────────────────────────────────────────────────────────
|
||||
|
||||
async bttaskList(_groupId: GroupId): Promise<Task[]> { return []; }
|
||||
async bttaskComments(_taskId: string): Promise<TaskComment[]> { return []; }
|
||||
async bttaskUpdateStatus(_taskId: string, _status: string, _version: number): Promise<number> { return 0; }
|
||||
async bttaskAddComment(_taskId: string, _agentId: AgentId, _content: string): Promise<string> { return ''; }
|
||||
async bttaskCreate(
|
||||
_title: string, _description: string, _priority: string,
|
||||
_groupId: GroupId, _createdBy: AgentId, _assignedTo?: AgentId,
|
||||
): Promise<string> { return ''; }
|
||||
async bttaskDelete(_taskId: string): Promise<void> { }
|
||||
async bttaskReviewQueueCount(_groupId: GroupId): Promise<number> { return 0; }
|
||||
|
||||
// ── Anchors ─────────────────────────────────────────────────────────────
|
||||
|
||||
async saveSessionAnchors(_anchors: SessionAnchorRecord[]): Promise<void> { }
|
||||
async loadSessionAnchors(_projectId: string): Promise<SessionAnchorRecord[]> { return []; }
|
||||
async deleteSessionAnchor(_id: string): Promise<void> { }
|
||||
async clearProjectAnchors(_projectId: string): Promise<void> { }
|
||||
async updateAnchorType(_id: string, _anchorType: string): Promise<void> { }
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────
|
||||
|
||||
async searchInit(): Promise<void> { }
|
||||
async searchAll(_query: string, _limit?: number): Promise<SearchResult[]> { return []; }
|
||||
async searchRebuild(): Promise<void> { }
|
||||
async searchIndexMessage(_sessionId: string, _role: string, _content: string): Promise<void> { }
|
||||
|
||||
// ── Audit ───────────────────────────────────────────────────────────────
|
||||
|
||||
async logAuditEvent(_agentId: AgentId, _eventType: AuditEventType, _detail: string): Promise<void> { }
|
||||
async getAuditLog(_groupId: GroupId, _limit?: number, _offset?: number): Promise<AuditEntry[]> { return []; }
|
||||
async getAuditLogForAgent(_agentId: AgentId, _limit?: number): Promise<AuditEntry[]> { return []; }
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
|
||||
sendDesktopNotification(_title: string, _body: string, _urgency?: NotificationUrgency): void {
|
||||
// Electrobun: in-app toasts only, no OS notifications
|
||||
}
|
||||
|
||||
// ── Telemetry ───────────────────────────────────────────────────────────
|
||||
|
||||
telemetryLog(_level: TelemetryLevel, _message: string, _context?: Record<string, unknown>): void {
|
||||
// Electrobun: no OTLP, console-only
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
async storeSecret(_key: string, _value: string): Promise<void> { }
|
||||
async getSecret(_key: string): Promise<string | null> { return null; }
|
||||
async deleteSecret(_key: string): Promise<void> { }
|
||||
async listSecrets(): Promise<string[]> { return []; }
|
||||
async hasKeyring(): Promise<boolean> { return false; }
|
||||
async knownSecretKeys(): Promise<string[]> { return []; }
|
||||
|
||||
// ── Filesystem watcher ──────────────────────────────────────────────────
|
||||
|
||||
async fsWatchProject(_projectId: string, _cwd: string): Promise<void> { }
|
||||
async fsUnwatchProject(_projectId: string): Promise<void> { }
|
||||
onFsWriteDetected(_callback: (event: FsWriteEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
async fsWatcherStatus(): Promise<FsWatcherStatus> {
|
||||
return { max_watches: 0, estimated_watches: 0, usage_ratio: 0, active_projects: 0, warning: null };
|
||||
}
|
||||
|
||||
// ── Ctx ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async ctxInitDb(): Promise<void> { }
|
||||
async ctxRegisterProject(_name: string, _description: string, _workDir?: string): Promise<void> { }
|
||||
async ctxGetContext(_project: string): Promise<CtxEntry[]> { return []; }
|
||||
async ctxGetShared(): Promise<CtxEntry[]> { return []; }
|
||||
async ctxGetSummaries(_project: string, _limit?: number): Promise<CtxSummary[]> { return []; }
|
||||
async ctxSearch(_query: string): Promise<CtxEntry[]> { return []; }
|
||||
|
||||
// ── Memora ──────────────────────────────────────────────────────────────
|
||||
|
||||
async memoraAvailable(): Promise<boolean> { return false; }
|
||||
async memoraList(_options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult> {
|
||||
return { nodes: [], total: 0 };
|
||||
}
|
||||
async memoraSearch(_query: string, _options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult> {
|
||||
return { nodes: [], total: 0 };
|
||||
}
|
||||
async memoraGet(_id: number): Promise<MemoraNode | null> { return null; }
|
||||
|
||||
// ── SSH ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listSshSessions(): Promise<SshSessionRecord[]> { return []; }
|
||||
async saveSshSession(_session: SshSessionRecord): Promise<void> { }
|
||||
async deleteSshSession(_id: string): Promise<void> { }
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────
|
||||
|
||||
async discoverPlugins(): Promise<PluginMeta[]> { return []; }
|
||||
async readPluginFile(_pluginId: string, _filename: string): Promise<string> { return ''; }
|
||||
|
||||
// ── Claude provider ─────────────────────────────────────────────────────
|
||||
|
||||
async listProfiles(): Promise<ClaudeProfile[]> { return []; }
|
||||
async listSkills(): Promise<ClaudeSkill[]> { return []; }
|
||||
async readSkill(_path: string): Promise<string> { return ''; }
|
||||
|
||||
// ── Remote machines ─────────────────────────────────────────────────────
|
||||
|
||||
async listRemoteMachines(): Promise<RemoteMachineInfo[]> { return []; }
|
||||
async addRemoteMachine(_config: RemoteMachineConfig): Promise<string> { return ''; }
|
||||
async removeRemoteMachine(_machineId: string): Promise<void> { }
|
||||
async connectRemoteMachine(_machineId: string): Promise<void> { }
|
||||
async disconnectRemoteMachine(_machineId: string): Promise<void> { }
|
||||
async probeSpki(_url: string): Promise<string> { return ''; }
|
||||
async addSpkiPin(_machineId: string, _pin: string): Promise<void> { }
|
||||
async removeSpkiPin(_machineId: string, _pin: string): Promise<void> { }
|
||||
onRemoteSidecarMessage(_callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemotePtyData(_callback: (msg: RemotePtyData) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemotePtyExit(_callback: (msg: RemotePtyExit) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteMachineReady(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteMachineDisconnected(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteStateSync(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteError(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteMachineReconnecting(_callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteMachineReconnectReady(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
onRemoteSpkiTofu(_callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn { return () => {}; }
|
||||
|
||||
// ── File watcher ────────────────────────────────────────────────────────
|
||||
|
||||
async watchFile(_paneId: string, _path: string): Promise<string> { return ''; }
|
||||
async unwatchFile(_paneId: string): Promise<void> { }
|
||||
async readWatchedFile(_path: string): Promise<string> { return ''; }
|
||||
onFileChanged(_callback: (payload: FileChangedPayload) => void): UnsubscribeFn { return () => {}; }
|
||||
|
||||
// ── Agent bridge (direct IPC) ───────────────────────────────────────────
|
||||
|
||||
async queryAgent(options: AgentQueryOptions): Promise<void> {
|
||||
await this.r.request['agent.start']({
|
||||
sessionId: options.session_id, provider: options.provider ?? 'claude',
|
||||
prompt: options.prompt, cwd: options.cwd, model: options.model,
|
||||
systemPrompt: options.system_prompt, maxTurns: options.max_turns,
|
||||
permissionMode: options.permission_mode, extraEnv: options.extra_env,
|
||||
resumeSessionId: options.resume_session_id,
|
||||
});
|
||||
}
|
||||
|
||||
async stopAgentDirect(sessionId: string, _remoteMachineId?: string): Promise<void> {
|
||||
await this.r.request['agent.stop']({ sessionId });
|
||||
}
|
||||
|
||||
async isAgentReady(): Promise<boolean> { return true; }
|
||||
async restartAgentSidecar(): Promise<void> { }
|
||||
|
||||
async setSandbox(_projectCwds: string[], _worktreeRoots: string[], _enabled: boolean): Promise<void> { }
|
||||
|
||||
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn {
|
||||
return this.listenMsg<SidecarMessagePayload>('agent.sidecar', callback);
|
||||
}
|
||||
|
||||
onSidecarExited(callback: () => void): UnsubscribeFn {
|
||||
return this.listenMsg('agent.sidecar.exited', () => callback());
|
||||
}
|
||||
|
||||
// ── PTY bridge (per-session) ────────────────────────────────────────────
|
||||
|
||||
async spawnPty(options: PtySpawnOptions): Promise<string> {
|
||||
const res = await this.r.request['pty.create']({
|
||||
sessionId: `pty-${Date.now()}`, cols: options.cols ?? 80, rows: options.rows ?? 24,
|
||||
cwd: options.cwd, shell: options.shell, args: options.args,
|
||||
}) as { ok: boolean; sessionId?: string; error?: string };
|
||||
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
|
||||
return res.sessionId ?? '';
|
||||
}
|
||||
|
||||
async writePtyDirect(id: string, data: string, _remoteMachineId?: string): Promise<void> {
|
||||
await this.r.request['pty.write']({ sessionId: id, data });
|
||||
}
|
||||
|
||||
async resizePtyDirect(id: string, cols: number, rows: number, _remoteMachineId?: string): Promise<void> {
|
||||
await this.r.request['pty.resize']({ sessionId: id, cols, rows });
|
||||
}
|
||||
|
||||
async killPty(id: string, _remoteMachineId?: string): Promise<void> {
|
||||
await this.r.request['pty.close']({ sessionId: id });
|
||||
}
|
||||
|
||||
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn {
|
||||
return this.listenMsg<{ sessionId: string; data: string }>('pty.output', (p) => {
|
||||
if (p.sessionId === id) callback(p.data);
|
||||
});
|
||||
}
|
||||
|
||||
onPtyExit(id: string, callback: () => void): UnsubscribeFn {
|
||||
return this.listenMsg<{ sessionId: string }>('pty.closed', (p) => {
|
||||
if (p.sessionId === id) callback();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Subscribe to an RPC message event; returns unsubscribe function. */
|
||||
|
|
@ -297,7 +577,6 @@ function convertWireMessage(raw: {
|
|||
id: string; type: string; parentId?: string; content: unknown; timestamp: number;
|
||||
}): AgentMessage | null {
|
||||
const c = raw.content as Record<string, unknown> | undefined;
|
||||
|
||||
switch (raw.type) {
|
||||
case 'text':
|
||||
return { id: raw.id, role: 'assistant', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp };
|
||||
|
|
@ -307,28 +586,22 @@ function convertWireMessage(raw: {
|
|||
const name = String(c?.['name'] ?? 'Tool');
|
||||
const input = c?.['input'] as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: raw.id, role: 'tool-call',
|
||||
content: formatToolContent(name, input),
|
||||
toolName: name,
|
||||
toolInput: input ? JSON.stringify(input, null, 2) : undefined,
|
||||
timestamp: raw.timestamp,
|
||||
id: raw.id, role: 'tool-call', content: formatToolContent(name, input),
|
||||
toolName: name, toolInput: input ? JSON.stringify(input, null, 2) : undefined, timestamp: raw.timestamp,
|
||||
};
|
||||
}
|
||||
case 'tool_result': {
|
||||
const output = c?.['output'];
|
||||
return {
|
||||
id: raw.id, role: 'tool-result',
|
||||
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
|
||||
timestamp: raw.timestamp,
|
||||
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), timestamp: raw.timestamp,
|
||||
};
|
||||
}
|
||||
case 'init':
|
||||
return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp };
|
||||
case 'error':
|
||||
return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp };
|
||||
case 'cost':
|
||||
case 'status':
|
||||
case 'compaction':
|
||||
case 'cost': case 'status': case 'compaction':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,24 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|||
import type {
|
||||
BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions,
|
||||
AgentMessage, AgentStatus, FileEntry, FileContent, PtyCreateOptions,
|
||||
SettingsMap, GroupsFile,
|
||||
SettingsMap, GroupsFile, SearchResult,
|
||||
PersistedSession, PersistedLayout,
|
||||
AgentMessageRecord, ProjectAgentState, SessionMetricRecord, MdFileEntry,
|
||||
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, BtmsgFeedMessage,
|
||||
DeadLetterEntry, AuditEntry, AuditEventType,
|
||||
Task, TaskComment,
|
||||
SessionAnchorRecord,
|
||||
NotificationUrgency, TelemetryLevel,
|
||||
FsWriteEvent, FsWatcherStatus,
|
||||
CtxEntry, CtxSummary,
|
||||
MemoraNode, MemoraSearchResult,
|
||||
SshSessionRecord, PluginMeta,
|
||||
ClaudeProfile, ClaudeSkill,
|
||||
RemoteMachineConfig, RemoteMachineInfo,
|
||||
RemoteSidecarMessage, RemotePtyData, RemotePtyExit,
|
||||
RemoteMachineEvent, RemoteReconnectingEvent, RemoteSpkiTofuEvent,
|
||||
FileChangedPayload, AgentQueryOptions, SidecarMessagePayload, PtySpawnOptions,
|
||||
SessionId, ProjectId, GroupId, AgentId,
|
||||
} from '@agor/types';
|
||||
|
||||
const TAURI_CAPABILITIES: BackendCapabilities = {
|
||||
|
|
@ -20,13 +37,6 @@ const TAURI_CAPABILITIES: BackendCapabilities = {
|
|||
supportsTelemetry: true,
|
||||
};
|
||||
|
||||
interface SidecarMessage {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
event?: Record<string, unknown>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TauriDirEntry {
|
||||
name: string; path: string; is_dir: boolean; size: number; ext: string;
|
||||
}
|
||||
|
|
@ -69,7 +79,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
return invoke('groups_save', { config: groups });
|
||||
}
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────────
|
||||
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||
|
||||
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
||||
const tauriOpts = {
|
||||
|
|
@ -118,7 +128,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
||||
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||
|
||||
async createPty(options: PtyCreateOptions): Promise<string> {
|
||||
return invoke<string>('pty_spawn', {
|
||||
|
|
@ -155,7 +165,584 @@ export class TauriAdapter implements BackendAdapter {
|
|||
return invoke('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
// ── Session persistence ─────────────────────────────────────────────────
|
||||
|
||||
async listSessions(): Promise<PersistedSession[]> {
|
||||
return invoke('session_list');
|
||||
}
|
||||
|
||||
async saveSession(session: PersistedSession): Promise<void> {
|
||||
return invoke('session_save', { session });
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
return invoke('session_delete', { id });
|
||||
}
|
||||
|
||||
async updateSessionTitle(id: string, title: string): Promise<void> {
|
||||
return invoke('session_update_title', { id, title });
|
||||
}
|
||||
|
||||
async touchSession(id: string): Promise<void> {
|
||||
return invoke('session_touch', { id });
|
||||
}
|
||||
|
||||
async updateSessionGroup(id: string, groupName: string): Promise<void> {
|
||||
return invoke('session_update_group', { id, group_name: groupName });
|
||||
}
|
||||
|
||||
async saveLayout(layout: PersistedLayout): Promise<void> {
|
||||
return invoke('layout_save', { layout });
|
||||
}
|
||||
|
||||
async loadLayout(): Promise<PersistedLayout> {
|
||||
return invoke('layout_load');
|
||||
}
|
||||
|
||||
// ── Agent persistence ───────────────────────────────────────────────────
|
||||
|
||||
async saveAgentMessages(
|
||||
sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined,
|
||||
messages: AgentMessageRecord[],
|
||||
): Promise<void> {
|
||||
return invoke('agent_messages_save', {
|
||||
sessionId, projectId, sdkSessionId: sdkSessionId ?? null, messages,
|
||||
});
|
||||
}
|
||||
|
||||
async loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
|
||||
return invoke('agent_messages_load', { projectId });
|
||||
}
|
||||
|
||||
async saveProjectAgentState(state: ProjectAgentState): Promise<void> {
|
||||
return invoke('project_agent_state_save', { state });
|
||||
}
|
||||
|
||||
async loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
|
||||
return invoke('project_agent_state_load', { projectId });
|
||||
}
|
||||
|
||||
async saveSessionMetric(metric: Omit<SessionMetricRecord, 'id'>): Promise<void> {
|
||||
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
|
||||
}
|
||||
|
||||
async loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetricRecord[]> {
|
||||
return invoke('session_metrics_load', { projectId, limit });
|
||||
}
|
||||
|
||||
async getCliGroup(): Promise<string | null> {
|
||||
return invoke('cli_get_group');
|
||||
}
|
||||
|
||||
async discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
|
||||
return invoke('discover_markdown_files', { cwd });
|
||||
}
|
||||
|
||||
// ── Btmsg ───────────────────────────────────────────────────────────────
|
||||
|
||||
async btmsgGetAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
|
||||
return invoke('btmsg_get_agents', { groupId });
|
||||
}
|
||||
|
||||
async btmsgUnreadCount(agentId: AgentId): Promise<number> {
|
||||
return invoke('btmsg_unread_count', { agentId });
|
||||
}
|
||||
|
||||
async btmsgUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_unread_messages', { agentId });
|
||||
}
|
||||
|
||||
async btmsgHistory(agentId: AgentId, otherId: AgentId, limit = 20): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_history', { agentId, otherId, limit });
|
||||
}
|
||||
|
||||
async btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
|
||||
return invoke('btmsg_send', { fromAgent, toAgent, content });
|
||||
}
|
||||
|
||||
async btmsgSetStatus(agentId: AgentId, status: string): Promise<void> {
|
||||
return invoke('btmsg_set_status', { agentId, status });
|
||||
}
|
||||
|
||||
async btmsgEnsureAdmin(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_ensure_admin', { groupId });
|
||||
}
|
||||
|
||||
async btmsgAllFeed(groupId: GroupId, limit = 100): Promise<BtmsgFeedMessage[]> {
|
||||
return invoke('btmsg_all_feed', { groupId, limit });
|
||||
}
|
||||
|
||||
async btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_mark_read', { readerId, senderId });
|
||||
}
|
||||
|
||||
async btmsgGetChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
|
||||
return invoke('btmsg_get_channels', { groupId });
|
||||
}
|
||||
|
||||
async btmsgChannelMessages(channelId: string, limit = 100): Promise<BtmsgChannelMessage[]> {
|
||||
return invoke('btmsg_channel_messages', { channelId, limit });
|
||||
}
|
||||
|
||||
async btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
|
||||
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
|
||||
}
|
||||
|
||||
async btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
|
||||
return invoke('btmsg_create_channel', { name, groupId, createdBy });
|
||||
}
|
||||
|
||||
async btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_add_channel_member', { channelId, agentId });
|
||||
}
|
||||
|
||||
async btmsgRegisterAgents(config: GroupsFile): Promise<void> {
|
||||
return invoke('btmsg_register_agents', { config });
|
||||
}
|
||||
|
||||
async btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_unseen_messages', { agentId, sessionId });
|
||||
}
|
||||
|
||||
async btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise<void> {
|
||||
return invoke('btmsg_mark_seen', { sessionId, messageIds });
|
||||
}
|
||||
|
||||
async btmsgPruneSeen(): Promise<number> {
|
||||
return invoke('btmsg_prune_seen');
|
||||
}
|
||||
|
||||
async btmsgRecordHeartbeat(agentId: AgentId): Promise<void> {
|
||||
return invoke('btmsg_record_heartbeat', { agentId });
|
||||
}
|
||||
|
||||
async btmsgGetStaleAgents(groupId: GroupId, thresholdSecs = 300): Promise<string[]> {
|
||||
return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs });
|
||||
}
|
||||
|
||||
async btmsgGetDeadLetters(groupId: GroupId, limit = 50): Promise<DeadLetterEntry[]> {
|
||||
return invoke('btmsg_get_dead_letters', { groupId, limit });
|
||||
}
|
||||
|
||||
async btmsgClearDeadLetters(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_clear_dead_letters', { groupId });
|
||||
}
|
||||
|
||||
async btmsgClearAllComms(groupId: GroupId): Promise<void> {
|
||||
return invoke('btmsg_clear_all_comms', { groupId });
|
||||
}
|
||||
|
||||
// ── Bttask ──────────────────────────────────────────────────────────────
|
||||
|
||||
async bttaskList(groupId: GroupId): Promise<Task[]> {
|
||||
return invoke<Task[]>('bttask_list', { groupId });
|
||||
}
|
||||
|
||||
async bttaskComments(taskId: string): Promise<TaskComment[]> {
|
||||
return invoke<TaskComment[]>('bttask_comments', { taskId });
|
||||
}
|
||||
|
||||
async bttaskUpdateStatus(taskId: string, status: string, version: number): Promise<number> {
|
||||
return invoke<number>('bttask_update_status', { taskId, status, version });
|
||||
}
|
||||
|
||||
async bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
|
||||
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
|
||||
}
|
||||
|
||||
async bttaskCreate(
|
||||
title: string, description: string, priority: string,
|
||||
groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId,
|
||||
): Promise<string> {
|
||||
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
|
||||
}
|
||||
|
||||
async bttaskDelete(taskId: string): Promise<void> {
|
||||
return invoke('bttask_delete', { taskId });
|
||||
}
|
||||
|
||||
async bttaskReviewQueueCount(groupId: GroupId): Promise<number> {
|
||||
return invoke<number>('bttask_review_queue_count', { groupId });
|
||||
}
|
||||
|
||||
// ── Anchors ─────────────────────────────────────────────────────────────
|
||||
|
||||
async saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void> {
|
||||
return invoke('session_anchors_save', { anchors });
|
||||
}
|
||||
|
||||
async loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
|
||||
return invoke('session_anchors_load', { projectId });
|
||||
}
|
||||
|
||||
async deleteSessionAnchor(id: string): Promise<void> {
|
||||
return invoke('session_anchor_delete', { id });
|
||||
}
|
||||
|
||||
async clearProjectAnchors(projectId: string): Promise<void> {
|
||||
return invoke('session_anchors_clear', { projectId });
|
||||
}
|
||||
|
||||
async updateAnchorType(id: string, anchorType: string): Promise<void> {
|
||||
return invoke('session_anchor_update_type', { id, anchorType });
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────
|
||||
|
||||
async searchInit(): Promise<void> {
|
||||
return invoke('search_init');
|
||||
}
|
||||
|
||||
async searchAll(query: string, limit = 20): Promise<SearchResult[]> {
|
||||
return invoke<SearchResult[]>('search_query', { query, limit });
|
||||
}
|
||||
|
||||
async searchRebuild(): Promise<void> {
|
||||
return invoke('search_rebuild');
|
||||
}
|
||||
|
||||
async searchIndexMessage(sessionId: string, role: string, content: string): Promise<void> {
|
||||
return invoke('search_index_message', { sessionId, role, content });
|
||||
}
|
||||
|
||||
// ── Audit ───────────────────────────────────────────────────────────────
|
||||
|
||||
async logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise<void> {
|
||||
return invoke('audit_log_event', { agentId, eventType, detail });
|
||||
}
|
||||
|
||||
async getAuditLog(groupId: GroupId, limit = 200, offset = 0): Promise<AuditEntry[]> {
|
||||
return invoke('audit_log_list', { groupId, limit, offset });
|
||||
}
|
||||
|
||||
async getAuditLogForAgent(agentId: AgentId, limit = 50): Promise<AuditEntry[]> {
|
||||
return invoke('audit_log_for_agent', { agentId, limit });
|
||||
}
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
|
||||
sendDesktopNotification(title: string, body: string, urgency: NotificationUrgency = 'normal'): void {
|
||||
invoke('notify_desktop', { title, body, urgency }).catch((_e: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[TauriAdapter] Desktop notification failed:', _e);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Telemetry ───────────────────────────────────────────────────────────
|
||||
|
||||
telemetryLog(level: TelemetryLevel, message: string, context?: Record<string, unknown>): void {
|
||||
invoke('frontend_log', { level, message, context: context ?? null }).catch((_e: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[TauriAdapter] Telemetry IPC failed:', _e);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────
|
||||
|
||||
async storeSecret(key: string, value: string): Promise<void> {
|
||||
return invoke('secrets_store', { key, value });
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
return invoke('secrets_get', { key });
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
return invoke('secrets_delete', { key });
|
||||
}
|
||||
|
||||
async listSecrets(): Promise<string[]> {
|
||||
return invoke('secrets_list');
|
||||
}
|
||||
|
||||
async hasKeyring(): Promise<boolean> {
|
||||
return invoke('secrets_has_keyring');
|
||||
}
|
||||
|
||||
async knownSecretKeys(): Promise<string[]> {
|
||||
return invoke('secrets_known_keys');
|
||||
}
|
||||
|
||||
// ── Filesystem watcher ──────────────────────────────────────────────────
|
||||
|
||||
async fsWatchProject(projectId: string, cwd: string): Promise<void> {
|
||||
return invoke('fs_watch_project', { projectId, cwd });
|
||||
}
|
||||
|
||||
async fsUnwatchProject(projectId: string): Promise<void> {
|
||||
return invoke('fs_unwatch_project', { projectId });
|
||||
}
|
||||
|
||||
onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<FsWriteEvent>('fs-write-detected', callback);
|
||||
}
|
||||
|
||||
async fsWatcherStatus(): Promise<FsWatcherStatus> {
|
||||
return invoke('fs_watcher_status');
|
||||
}
|
||||
|
||||
// ── Ctx ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async ctxInitDb(): Promise<void> {
|
||||
return invoke('ctx_init_db');
|
||||
}
|
||||
|
||||
async ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
|
||||
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
|
||||
}
|
||||
|
||||
async ctxGetContext(project: string): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_get_context', { project });
|
||||
}
|
||||
|
||||
async ctxGetShared(): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_get_shared');
|
||||
}
|
||||
|
||||
async ctxGetSummaries(project: string, limit = 5): Promise<CtxSummary[]> {
|
||||
return invoke('ctx_get_summaries', { project, limit });
|
||||
}
|
||||
|
||||
async ctxSearch(query: string): Promise<CtxEntry[]> {
|
||||
return invoke('ctx_search', { query });
|
||||
}
|
||||
|
||||
// ── Memora ──────────────────────────────────────────────────────────────
|
||||
|
||||
async memoraAvailable(): Promise<boolean> {
|
||||
return invoke<boolean>('memora_available');
|
||||
}
|
||||
|
||||
async memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult> {
|
||||
return invoke<MemoraSearchResult>('memora_list', {
|
||||
tags: options?.tags ?? null,
|
||||
limit: options?.limit ?? 50,
|
||||
offset: options?.offset ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
async memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult> {
|
||||
return invoke<MemoraSearchResult>('memora_search', {
|
||||
query,
|
||||
tags: options?.tags ?? null,
|
||||
limit: options?.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
async memoraGet(id: number): Promise<MemoraNode | null> {
|
||||
return invoke<MemoraNode | null>('memora_get', { id });
|
||||
}
|
||||
|
||||
// ── SSH ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listSshSessions(): Promise<SshSessionRecord[]> {
|
||||
return invoke('ssh_session_list');
|
||||
}
|
||||
|
||||
async saveSshSession(session: SshSessionRecord): Promise<void> {
|
||||
return invoke('ssh_session_save', { session });
|
||||
}
|
||||
|
||||
async deleteSshSession(id: string): Promise<void> {
|
||||
return invoke('ssh_session_delete', { id });
|
||||
}
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────
|
||||
|
||||
async discoverPlugins(): Promise<PluginMeta[]> {
|
||||
return invoke<PluginMeta[]>('plugins_discover');
|
||||
}
|
||||
|
||||
async readPluginFile(pluginId: string, filename: string): Promise<string> {
|
||||
return invoke<string>('plugin_read_file', { pluginId, filename });
|
||||
}
|
||||
|
||||
// ── Claude provider ─────────────────────────────────────────────────────
|
||||
|
||||
async listProfiles(): Promise<ClaudeProfile[]> {
|
||||
return invoke<ClaudeProfile[]>('claude_list_profiles');
|
||||
}
|
||||
|
||||
async listSkills(): Promise<ClaudeSkill[]> {
|
||||
return invoke<ClaudeSkill[]>('claude_list_skills');
|
||||
}
|
||||
|
||||
async readSkill(path: string): Promise<string> {
|
||||
return invoke<string>('claude_read_skill', { path });
|
||||
}
|
||||
|
||||
// ── Remote machines ─────────────────────────────────────────────────────
|
||||
|
||||
async listRemoteMachines(): Promise<RemoteMachineInfo[]> {
|
||||
return invoke('remote_list');
|
||||
}
|
||||
|
||||
async addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
|
||||
return invoke('remote_add', { config });
|
||||
}
|
||||
|
||||
async removeRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_remove', { machineId });
|
||||
}
|
||||
|
||||
async connectRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_connect', { machineId });
|
||||
}
|
||||
|
||||
async disconnectRemoteMachine(machineId: string): Promise<void> {
|
||||
return invoke('remote_disconnect', { machineId });
|
||||
}
|
||||
|
||||
async probeSpki(url: string): Promise<string> {
|
||||
return invoke('remote_probe_spki', { url });
|
||||
}
|
||||
|
||||
async addSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_add_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
async removeSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_remove_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteSidecarMessage>('remote-sidecar-message', callback);
|
||||
}
|
||||
|
||||
onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemotePtyData>('remote-pty-data', callback);
|
||||
}
|
||||
|
||||
onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemotePtyExit>('remote-pty-exit', callback);
|
||||
}
|
||||
|
||||
onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteMachineEvent>('remote-machine-ready', callback);
|
||||
}
|
||||
|
||||
onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteMachineEvent>('remote-machine-disconnected', callback);
|
||||
}
|
||||
|
||||
onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteMachineEvent>('remote-state-sync', callback);
|
||||
}
|
||||
|
||||
onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteMachineEvent>('remote-error', callback);
|
||||
}
|
||||
|
||||
onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteReconnectingEvent>('remote-machine-reconnecting', callback);
|
||||
}
|
||||
|
||||
onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteMachineEvent>('remote-machine-reconnect-ready', callback);
|
||||
}
|
||||
|
||||
onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn {
|
||||
return this.listenTauri<RemoteSpkiTofuEvent>('remote-spki-tofu', callback);
|
||||
}
|
||||
|
||||
// ── File watcher ────────────────────────────────────────────────────────
|
||||
|
||||
async watchFile(paneId: string, path: string): Promise<string> {
|
||||
return invoke('file_watch', { paneId, path });
|
||||
}
|
||||
|
||||
async unwatchFile(paneId: string): Promise<void> {
|
||||
return invoke('file_unwatch', { paneId });
|
||||
}
|
||||
|
||||
async readWatchedFile(path: string): Promise<string> {
|
||||
return invoke('file_read', { path });
|
||||
}
|
||||
|
||||
onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn {
|
||||
return this.listenTauri<FileChangedPayload>('file-changed', callback);
|
||||
}
|
||||
|
||||
// ── Agent bridge (direct IPC) ───────────────────────────────────────────
|
||||
|
||||
async queryAgent(options: AgentQueryOptions): Promise<void> {
|
||||
if (options.remote_machine_id) {
|
||||
const { remote_machine_id: machineId, ...agentOptions } = options;
|
||||
return invoke('remote_agent_query', { machineId, options: agentOptions });
|
||||
}
|
||||
return invoke('agent_query', { options });
|
||||
}
|
||||
|
||||
async stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
|
||||
}
|
||||
return invoke('agent_stop', { sessionId });
|
||||
}
|
||||
|
||||
async isAgentReady(): Promise<boolean> {
|
||||
return invoke<boolean>('agent_ready');
|
||||
}
|
||||
|
||||
async restartAgentSidecar(): Promise<void> {
|
||||
return invoke('agent_restart');
|
||||
}
|
||||
|
||||
async setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise<void> {
|
||||
return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled });
|
||||
}
|
||||
|
||||
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn {
|
||||
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (payload) => {
|
||||
if (typeof payload !== 'object' || payload === null) return;
|
||||
callback(payload);
|
||||
});
|
||||
}
|
||||
|
||||
onSidecarExited(callback: () => void): UnsubscribeFn {
|
||||
return this.listenTauri('sidecar-exited', () => callback());
|
||||
}
|
||||
|
||||
// ── PTY bridge (per-session) ────────────────────────────────────────────
|
||||
|
||||
async spawnPty(options: PtySpawnOptions): Promise<string> {
|
||||
if (options.remote_machine_id) {
|
||||
const { remote_machine_id: machineId, ...ptyOptions } = options;
|
||||
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
|
||||
}
|
||||
return invoke<string>('pty_spawn', { options });
|
||||
}
|
||||
|
||||
async writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_write', { machineId: remoteMachineId, id, data });
|
||||
}
|
||||
return invoke('pty_write', { id, data });
|
||||
}
|
||||
|
||||
async resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows });
|
||||
}
|
||||
return invoke('pty_resize', { id, cols, rows });
|
||||
}
|
||||
|
||||
async killPty(id: string, remoteMachineId?: string): Promise<void> {
|
||||
if (remoteMachineId) {
|
||||
return invoke('remote_pty_kill', { machineId: remoteMachineId, id });
|
||||
}
|
||||
return invoke('pty_kill', { id });
|
||||
}
|
||||
|
||||
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn {
|
||||
return this.listenTauri<string>(`pty-data-${id}`, callback);
|
||||
}
|
||||
|
||||
onPtyExit(id: string, callback: () => void): UnsubscribeFn {
|
||||
return this.listenTauri(`pty-exit-${id}`, () => callback());
|
||||
}
|
||||
|
||||
// ── Events (simplified — kept for backward compat) ──────────────────────
|
||||
|
||||
/** Subscribe to a Tauri event; tracks unlisten for cleanup. */
|
||||
private listenTauri<T>(event: string, handler: (payload: T) => void): UnsubscribeFn {
|
||||
|
|
@ -173,7 +760,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
}
|
||||
|
||||
onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn {
|
||||
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => {
|
||||
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
|
||||
if (msg.type !== 'message' || !msg.sessionId || !msg.event) return;
|
||||
const agentMsg: AgentMessage = {
|
||||
id: String(msg.event['id'] ?? Date.now()),
|
||||
|
|
@ -188,7 +775,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
}
|
||||
|
||||
onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn {
|
||||
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => {
|
||||
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
|
||||
if (msg.type !== 'status' || !msg.sessionId) return;
|
||||
callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message);
|
||||
});
|
||||
|
|
@ -197,7 +784,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
onAgentCost(
|
||||
callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void,
|
||||
): UnsubscribeFn {
|
||||
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => {
|
||||
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
|
||||
if (msg.type !== 'cost' || !msg.sessionId || !msg.event) return;
|
||||
callback(msg.sessionId, Number(msg.event['totalCostUsd'] ?? 0), Number(msg.event['inputTokens'] ?? 0), Number(msg.event['outputTokens'] ?? 0));
|
||||
});
|
||||
|
|
@ -205,7 +792,7 @@ export class TauriAdapter implements BackendAdapter {
|
|||
|
||||
onPtyOutput(_callback: (sessionId: string, data: string) => void): UnsubscribeFn {
|
||||
// Tauri PTY uses per-session event channels (pty-data-{id}).
|
||||
// Phase 1: PTY event wiring remains in pty-bridge.ts per-session listeners.
|
||||
// Use onPtyData(id, callback) for per-session PTY output.
|
||||
return () => {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { marked, Renderer } from 'marked';
|
||||
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { ClaudeProfile, ClaudeSkill, AgentQueryOptions } from '@agor/types';
|
||||
import {
|
||||
getAgentSession,
|
||||
createAgentSession,
|
||||
|
|
@ -12,9 +13,7 @@
|
|||
import { focusPane } from '../../stores/layout.svelte';
|
||||
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||
import { getSessionProjectId } from '../../utils/session-persistence';
|
||||
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
|
||||
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
||||
import { estimateTokens } from '../../utils/anchor-serializer';
|
||||
import type { SessionAnchor } from '../../types/anchors';
|
||||
|
|
@ -146,8 +145,8 @@
|
|||
await getHighlighter();
|
||||
// Only load profiles/skills for providers that support them
|
||||
const [profileList, skillList] = await Promise.all([
|
||||
capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]),
|
||||
capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]),
|
||||
capabilities.hasProfiles ? getBackend().listProfiles().catch(() => []) : Promise.resolve([]),
|
||||
capabilities.hasSkills ? getBackend().listSkills().catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
profiles = profileList;
|
||||
skills = skillList;
|
||||
|
|
@ -175,7 +174,7 @@
|
|||
async function startQuery(text: string, resume = false) {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const ready = await isAgentReady();
|
||||
const ready = await getBackend().isAgentReady();
|
||||
if (!ready) {
|
||||
if (!resume) createAgentSession(sessionId, text);
|
||||
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
||||
|
|
@ -207,7 +206,7 @@
|
|||
}
|
||||
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
|
||||
|
||||
await queryAgent({
|
||||
await getBackend().queryAgent({
|
||||
provider: providerId,
|
||||
session_id: sessionId,
|
||||
prompt: text,
|
||||
|
|
@ -234,7 +233,7 @@
|
|||
const skill = skills.find(s => s.name === skillName);
|
||||
if (!skill) return text;
|
||||
try {
|
||||
const content = await readSkill(skill.source_path);
|
||||
const content = await getBackend().readSkill(skill.source_path);
|
||||
const args = text.slice(1 + skillName.length).trim();
|
||||
return args ? `${content}\n\nUser input: ${args}` : content;
|
||||
} catch {
|
||||
|
|
@ -267,14 +266,14 @@
|
|||
function handleStop() {
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
const projId = getSessionProjectId(sessionId);
|
||||
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
if (projId) getBackend().btmsgSetStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||
getBackend().stopAgentDirect(sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
restarting = true;
|
||||
try {
|
||||
await restartAgent();
|
||||
await getBackend().restartAgentSidecar();
|
||||
setSidecarAlive(true);
|
||||
} catch {
|
||||
// Still dead
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
ctxInitDb,
|
||||
ctxRegisterProject,
|
||||
ctxGetContext,
|
||||
ctxGetShared,
|
||||
ctxGetSummaries,
|
||||
ctxSearch,
|
||||
type CtxEntry,
|
||||
type CtxSummary,
|
||||
} from '../../adapters/ctx-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { CtxEntry, CtxSummary } from '@agor/types';
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
|
|
@ -32,12 +24,12 @@
|
|||
loading = true;
|
||||
try {
|
||||
// Register project if not already (INSERT OR IGNORE)
|
||||
await ctxRegisterProject(projectName, `Agent Orchestrator project: ${projectName}`, projectCwd);
|
||||
await getBackend().ctxRegisterProject(projectName, `Agent Orchestrator project: ${projectName}`, projectCwd);
|
||||
|
||||
const [ctx, shared, sums] = await Promise.all([
|
||||
ctxGetContext(projectName),
|
||||
ctxGetShared(),
|
||||
ctxGetSummaries(projectName, 5),
|
||||
getBackend().ctxGetContext(projectName),
|
||||
getBackend().ctxGetShared(),
|
||||
getBackend().ctxGetSummaries(projectName, 5),
|
||||
]);
|
||||
entries = ctx;
|
||||
sharedEntries = shared;
|
||||
|
|
@ -55,7 +47,7 @@
|
|||
async function handleInitDb() {
|
||||
initializing = true;
|
||||
try {
|
||||
await ctxInitDb();
|
||||
await getBackend().ctxInitDb();
|
||||
await loadProjectContext();
|
||||
} catch (e) {
|
||||
error = `Failed to initialize database: ${e}`;
|
||||
|
|
@ -70,7 +62,7 @@
|
|||
return;
|
||||
}
|
||||
try {
|
||||
searchResults = await ctxSearch(searchQuery);
|
||||
searchResults = await getBackend().ctxSearch(searchQuery);
|
||||
} catch (e) {
|
||||
error = `Search failed: ${e}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { marked, Renderer } from 'marked';
|
||||
import { watchFile, unwatchFile, onFileChanged, type FileChangedPayload } from '../../adapters/file-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { FileChangedPayload } from '@agor/types';
|
||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -45,11 +46,11 @@
|
|||
|
||||
// Unwatch previous file
|
||||
if (currentWatchPath) {
|
||||
unwatchFile(paneId).catch(() => {});
|
||||
getBackend().ungetBackend().watchFile(paneId).catch(() => {});
|
||||
}
|
||||
|
||||
currentWatchPath = path;
|
||||
watchFile(paneId, path)
|
||||
getBackend().watchFile(paneId, path)
|
||||
.then(content => renderMarkdown(content))
|
||||
.catch(e => { error = `Failed to open file: ${e}`; });
|
||||
});
|
||||
|
|
@ -59,7 +60,7 @@
|
|||
await getHighlighter();
|
||||
highlighterReady = true;
|
||||
|
||||
unlisten = await onFileChanged((payload: FileChangedPayload) => {
|
||||
unlisten = getBackend().onFileChanged((payload: FileChangedPayload) => {
|
||||
if (payload.pane_id === paneId) {
|
||||
renderMarkdown(payload.content);
|
||||
}
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
unwatchFile(paneId).catch(() => {});
|
||||
getBackend().ungetBackend().watchFile(paneId).catch(() => {});
|
||||
});
|
||||
|
||||
function handleLinkClick(event: MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
||||
import { getTotalConflictCount } from '../../stores/conflicts.svelte';
|
||||
import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte';
|
||||
import { stopAgent } from '../../adapters/agent-bridge';
|
||||
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import { getSessionProjectId } from '../../utils/session-persistence';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
import { onMount } from 'svelte';
|
||||
|
|
@ -42,9 +41,9 @@
|
|||
for (const s of active) {
|
||||
updateAgentStatus(s.id, 'done');
|
||||
const projId = getSessionProjectId(s.id);
|
||||
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||
if (projId) getBackend().btmsgSetStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||
}
|
||||
await Promise.all(active.map(s => stopAgent(s.id).catch(() => {})));
|
||||
await Promise.all(active.map(s => getBackend().stopAgentDirect(s.id).catch(() => {})));
|
||||
} finally {
|
||||
stopping = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
import { Terminal } from '@xterm/xterm';
|
||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { UnsubscribeFn } from '@agor/types';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -21,8 +21,8 @@
|
|||
let term: Terminal;
|
||||
let fitAddon: FitAddon;
|
||||
let ptyId: string | null = null;
|
||||
let unlistenData: UnlistenFn | null = null;
|
||||
let unlistenExit: UnlistenFn | null = null;
|
||||
let unlistenData: UnsubscribeFn | null = null;
|
||||
let unlistenExit: UnsubscribeFn | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let unsubTheme: (() => void) | null = null;
|
||||
|
||||
|
|
@ -48,14 +48,14 @@
|
|||
|
||||
// Spawn PTY
|
||||
try {
|
||||
ptyId = await spawnPty({ shell, cwd, args, cols, rows });
|
||||
ptyId = await getBackend().spawnPty({ shell, cwd, args, cols, rows });
|
||||
|
||||
// Listen for PTY output
|
||||
unlistenData = await onPtyData(ptyId, (data) => {
|
||||
unlistenData = getBackend().onPtyData(ptyId, (data) => {
|
||||
term.write(data);
|
||||
});
|
||||
|
||||
unlistenExit = await onPtyExit(ptyId, () => {
|
||||
unlistenExit = getBackend().onPtyExit(ptyId, () => {
|
||||
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||
onExit?.();
|
||||
});
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
if (e.key === 'V') {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (text && ptyId) writePty(ptyId, text);
|
||||
if (text && ptyId) getBackend().writePtyDirect(ptyId, text);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
|
||||
// Forward keyboard input to PTY
|
||||
term.onData((data) => {
|
||||
if (ptyId) writePty(ptyId, data);
|
||||
if (ptyId) getBackend().writePtyDirect(ptyId, data);
|
||||
});
|
||||
} catch (e) {
|
||||
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
fitAddon.fit();
|
||||
if (ptyId) {
|
||||
const { cols, rows } = term;
|
||||
resizePty(ptyId, cols, rows);
|
||||
getBackend().resizePtyDirect(ptyId, cols, rows);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
unlistenData?.();
|
||||
unlistenExit?.();
|
||||
if (ptyId) {
|
||||
try { await killPty(ptyId); } catch { /* already dead */ }
|
||||
try { await getBackend().killPty(ptyId); } catch { /* already dead */ }
|
||||
}
|
||||
term?.dispose();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,17 +3,11 @@
|
|||
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
|
||||
import { generateAgentPrompt } from '../../utils/agent-prompts';
|
||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { logAuditEvent } from '../../adapters/audit-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
import {
|
||||
loadProjectAgentState,
|
||||
loadAgentMessages,
|
||||
type ProjectAgentState,
|
||||
type AgentMessageRecord,
|
||||
} from '../../adapters/groups-bridge';
|
||||
import type { ProjectAgentState, AgentMessageRecord } from '@agor/types';
|
||||
import { registerSessionProject } from '../../agent-dispatcher';
|
||||
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
|
||||
import { stopAgent } from '../../adapters/agent-bridge';
|
||||
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||
import {
|
||||
createAgentSession,
|
||||
|
|
@ -26,8 +20,6 @@
|
|||
import type { AgentMessage } from '../../adapters/claude-messages';
|
||||
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
||||
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
|
||||
import { getSecret } from '../../adapters/secrets-bridge';
|
||||
import { getUnseenMessages, markMessagesSeen, setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
|
||||
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
|
||||
import { SessionId, ProjectId } from '../../types/ids';
|
||||
import AgentPane from '../Agent/AgentPane.svelte';
|
||||
|
|
@ -95,7 +87,7 @@ bttask comment <task-id> "update" # Add a comment
|
|||
|
||||
$effect(() => {
|
||||
if (providerId === 'aider') {
|
||||
getSecret('openrouter_api_key').then(key => {
|
||||
getBackend().getSecret('openrouter_api_key').then(key => {
|
||||
openrouterKey = key;
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
|
|
@ -133,7 +125,7 @@ bttask comment <task-id> "update" # Add a comment
|
|||
: '[Context Refresh] Review the instructions above and continue your work.';
|
||||
contextRefreshPrompt = refreshMsg;
|
||||
// Audit: log prompt injection event
|
||||
logAuditEvent(
|
||||
getBackend().logAuditEvent(
|
||||
project.id as unknown as AgentId,
|
||||
'prompt_injection',
|
||||
`Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`,
|
||||
|
|
@ -159,8 +151,8 @@ bttask comment <task-id> "update" # Add a comment
|
|||
const unsubAgentStop = onAgentStop((projectId) => {
|
||||
if (projectId !== project.id) return;
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
setBtmsgAgentStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
getBackend().btmsgSetStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
|
||||
getBackend().stopAgentDirect(sessionId).catch(() => {});
|
||||
});
|
||||
|
||||
// btmsg inbox polling — per-message acknowledgment wake mechanism
|
||||
|
|
@ -173,7 +165,7 @@ bttask comment <task-id> "update" # Add a comment
|
|||
msgPollTimer = setInterval(async () => {
|
||||
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
|
||||
try {
|
||||
const unseen = await getUnseenMessages(
|
||||
const unseen = await getBackend().btmsgUnseenMessages(
|
||||
project.id as unknown as AgentId,
|
||||
sessionId,
|
||||
);
|
||||
|
|
@ -185,9 +177,9 @@ bttask comment <task-id> "update" # Add a comment
|
|||
contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`;
|
||||
|
||||
// Mark as seen immediately to prevent re-injection
|
||||
await markMessagesSeen(sessionId, unseen.map(m => m.id));
|
||||
await getBackend().btmsgMarkSeen(sessionId, unseen.map(m => m.id));
|
||||
|
||||
logAuditEvent(
|
||||
getBackend().logAuditEvent(
|
||||
project.id as unknown as AgentId,
|
||||
'wake_event',
|
||||
`Agent woken by ${unseen.length} btmsg message(s)`,
|
||||
|
|
@ -277,13 +269,13 @@ bttask comment <task-id> "update" # Add a comment
|
|||
loading = true;
|
||||
hasRestoredHistory = false;
|
||||
try {
|
||||
const state = await loadProjectAgentState(projectId);
|
||||
const state = await getBackend().loadProjectAgentState(projectId);
|
||||
lastState = state;
|
||||
if (state?.last_session_id) {
|
||||
sessionId = SessionId(state.last_session_id);
|
||||
|
||||
// Restore cached messages into the agent store
|
||||
const records = await loadAgentMessages(projectId);
|
||||
const records = await getBackend().loadAgentMessages(projectId);
|
||||
if (records.length > 0) {
|
||||
restoreMessagesFromRecords(sessionId, state, records);
|
||||
hasRestoredHistory = true;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry } from '../../adapters/files-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { FileEntry } from '@agor/types';
|
||||
type DirEntry = FileEntry;
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
|
|
@ -90,7 +92,7 @@ package "Backend" {
|
|||
|
||||
async function loadDiagrams() {
|
||||
try {
|
||||
const entries = await listDirectoryChildren(archPath);
|
||||
const entries = await getBackend().listDirectory(archPath);
|
||||
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
|
|
@ -108,7 +110,7 @@ package "Backend" {
|
|||
error = null;
|
||||
editing = false;
|
||||
try {
|
||||
const content = await readFileContent(filePath);
|
||||
const content = await getBackend().readFile(filePath);
|
||||
if (content.type === 'Text') {
|
||||
pumlSource = content.content;
|
||||
renderPlantUml(content.content);
|
||||
|
|
@ -131,7 +133,7 @@ package "Backend" {
|
|||
async function handleSave() {
|
||||
if (!selectedFile) return;
|
||||
try {
|
||||
await writeFileContent(selectedFile, pumlSource);
|
||||
await getBackend().writeFile(selectedFile, pumlSource);
|
||||
renderPlantUml(pumlSource);
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
|
|
@ -144,7 +146,7 @@ package "Backend" {
|
|||
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
const filePath = `${archPath}/${fileName}.puml`;
|
||||
try {
|
||||
await writeFileContent(filePath, template);
|
||||
await getBackend().writeFile(filePath, template);
|
||||
showNewForm = false;
|
||||
newName = '';
|
||||
await loadDiagrams();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getAuditLog, type AuditEntry, type AuditEventType } from '../../adapters/audit-bridge';
|
||||
import { getGroupAgents, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { AuditEntry, AuditEventType, BtmsgAgent } from '@agor/types';
|
||||
import type { GroupId, AgentId } from '../../types/ids';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -88,8 +88,8 @@
|
|||
async function fetchData() {
|
||||
try {
|
||||
const [auditData, agentData] = await Promise.all([
|
||||
getAuditLog(groupId, 200, 0),
|
||||
getGroupAgents(groupId),
|
||||
getBackend().getAuditLog(groupId, 200, 0),
|
||||
getBackend().btmsgGetAgents(groupId),
|
||||
]);
|
||||
entries = auditData;
|
||||
agents = agentData;
|
||||
|
|
|
|||
|
|
@ -2,25 +2,11 @@
|
|||
import { onMount, onDestroy } from 'svelte';
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
|
||||
import {
|
||||
type BtmsgAgent,
|
||||
type BtmsgMessage,
|
||||
type BtmsgFeedMessage,
|
||||
type BtmsgChannel,
|
||||
type BtmsgChannelMessage,
|
||||
getGroupAgents,
|
||||
getHistory,
|
||||
getAllFeed,
|
||||
sendMessage,
|
||||
markRead,
|
||||
ensureAdmin,
|
||||
getChannels,
|
||||
getChannelMessages,
|
||||
sendChannelMessage,
|
||||
createChannel,
|
||||
setAgentStatus,
|
||||
clearAllComms,
|
||||
} from '../../adapters/btmsg-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type {
|
||||
BtmsgAgent, BtmsgMessage, BtmsgFeedMessage,
|
||||
BtmsgChannel, BtmsgChannelMessage,
|
||||
} from '@agor/types';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
const ROLE_ICONS: Record<string, string> = {
|
||||
|
|
@ -55,8 +41,8 @@
|
|||
async function loadData() {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
agents = await getGroupAgents(groupId);
|
||||
channels = await getChannels(groupId);
|
||||
agents = await getBackend().btmsgGetAgents(groupId);
|
||||
channels = await getBackend().btmsgGetChannels(groupId);
|
||||
} catch (e) {
|
||||
console.error('[CommsTab] loadData failed:', e);
|
||||
}
|
||||
|
|
@ -66,12 +52,12 @@
|
|||
if (!groupId) return;
|
||||
try {
|
||||
if (currentView.type === 'feed') {
|
||||
feedMessages = await getAllFeed(groupId, 100);
|
||||
feedMessages = await getBackend().btmsgAllFeed(groupId, 100);
|
||||
} else if (currentView.type === 'dm') {
|
||||
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100);
|
||||
await markRead(ADMIN_ID, currentView.agentId);
|
||||
dmMessages = await getBackend().btmsgHistory(ADMIN_ID, currentView.agentId, 100);
|
||||
await getBackend().btmsgMarkRead(ADMIN_ID, currentView.agentId);
|
||||
} else if (currentView.type === 'channel') {
|
||||
channelMessages = await getChannelMessages(currentView.channelId, 100);
|
||||
channelMessages = await getBackend().btmsgChannelMessages(currentView.channelId, 100);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CommsTab] loadMessages failed:', e);
|
||||
|
|
@ -95,7 +81,7 @@
|
|||
void groupId;
|
||||
if (groupId) {
|
||||
console.log('[CommsTab] groupId:', groupId);
|
||||
ensureAdmin(groupId).catch((e) => console.error('[CommsTab] ensureAdmin failed:', e));
|
||||
getBackend().btmsgEnsureAdmin(groupId).catch((e) => console.error('[CommsTab] ensureAdmin failed:', e));
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
|
@ -129,16 +115,16 @@
|
|||
|
||||
try {
|
||||
if (currentView.type === 'dm') {
|
||||
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
||||
await getBackend().btmsgSend(ADMIN_ID, currentView.agentId, text);
|
||||
// Auto-wake agent if stopped
|
||||
const recipient = agents.find(a => a.id === currentView.agentId);
|
||||
if (recipient && recipient.status !== 'active') {
|
||||
await setAgentStatus(currentView.agentId, 'active');
|
||||
await getBackend().btmsgSetStatus(currentView.agentId, 'active');
|
||||
emitAgentStart(currentView.agentId);
|
||||
await pollBtmsg();
|
||||
}
|
||||
} else if (currentView.type === 'channel') {
|
||||
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
||||
await getBackend().btmsgChannelSend(currentView.channelId, ADMIN_ID, text);
|
||||
} else {
|
||||
return; // Can't send in feed view
|
||||
}
|
||||
|
|
@ -166,7 +152,7 @@
|
|||
const name = newChannelName.trim();
|
||||
if (!name || !groupId) return;
|
||||
try {
|
||||
await createChannel(name, groupId, ADMIN_ID);
|
||||
await getBackend().btmsgCreateChannel(name, groupId, ADMIN_ID);
|
||||
newChannelName = '';
|
||||
showNewChannel = false;
|
||||
await loadData();
|
||||
|
|
@ -206,7 +192,7 @@
|
|||
if (!confirmed) return;
|
||||
clearing = true;
|
||||
try {
|
||||
await clearAllComms(groupId);
|
||||
await getBackend().btmsgClearAllComms(groupId);
|
||||
feedMessages = [];
|
||||
dmMessages = [];
|
||||
channelMessages = [];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { MdFileEntry } from '@agor/types';
|
||||
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||
|
||||
let files = $state<MdFileEntry[]>([]);
|
||||
|
|
@ -26,7 +27,7 @@
|
|||
async function loadFiles(cwd: string) {
|
||||
loading = true;
|
||||
try {
|
||||
files = await discoverMarkdownFiles(cwd);
|
||||
files = await getBackend().discoverMarkdownFiles(cwd);
|
||||
// Auto-select first priority file
|
||||
const priority = files.find(f => f.priority);
|
||||
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { FileEntry, FileContent } from '@agor/types';
|
||||
type DirEntry = FileEntry;
|
||||
import { getSetting } from '../../stores/settings-store.svelte';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import CodeEditor from './CodeEditor.svelte';
|
||||
|
|
@ -61,7 +63,7 @@
|
|||
|
||||
async function loadDirectory(path: string): Promise<DirEntry[]> {
|
||||
try {
|
||||
return await listDirectoryChildren(path);
|
||||
return await getBackend().listDirectory(path);
|
||||
} catch (e) {
|
||||
console.warn('Failed to list directory:', e);
|
||||
return [];
|
||||
|
|
@ -125,7 +127,7 @@
|
|||
// Load content — must look up from reactive array, not local reference
|
||||
fileLoading = true;
|
||||
try {
|
||||
const content = await readFileContent(node.path);
|
||||
const content = await getBackend().readFile(node.path);
|
||||
const target = fileTabs.find(t => t.path === node.path);
|
||||
if (target) {
|
||||
target.content = content;
|
||||
|
|
@ -229,7 +231,7 @@
|
|||
async function saveTab(tab: FileTab) {
|
||||
if (!tab.dirty || tab.content?.type !== 'Text') return;
|
||||
try {
|
||||
await writeFileContent(tab.path, tab.editContent);
|
||||
await getBackend().writeFile(tab.path, tab.editContent);
|
||||
// Update the saved content reference
|
||||
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
|
||||
tab.dirty = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
|
||||
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
|
||||
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { BtmsgAgent } from '@agor/types';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
|
||||
/** Runtime agent status from btmsg database */
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
async function pollBtmsg() {
|
||||
if (!group) return;
|
||||
try {
|
||||
btmsgAgents = await getGroupAgents(group.id);
|
||||
btmsgAgents = await getBackend().btmsgGetAgents(group.id);
|
||||
} catch {
|
||||
// btmsg.db might not exist yet
|
||||
}
|
||||
|
|
@ -61,7 +62,7 @@
|
|||
const current = getStatus(agent.id);
|
||||
const newStatus = current === 'stopped' ? 'active' : 'stopped';
|
||||
try {
|
||||
await setAgentStatus(agent.id, newStatus);
|
||||
await getBackend().btmsgSetStatus(agent.id, newStatus);
|
||||
await pollBtmsg(); // Refresh immediately
|
||||
if (newStatus === 'active') {
|
||||
emitAgentStart(agent.id);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
|
||||
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
|
||||
import { getAgentSession } from '../../stores/agents.svelte';
|
||||
import { listTasks, type Task } from '../../adapters/bttask-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { Task } from '@agor/types';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -51,7 +52,7 @@
|
|||
async function fetchTaskCounts() {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
const tasks = await listTasks(groupId);
|
||||
const tasks = await getBackend().bttaskList(groupId);
|
||||
const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 };
|
||||
for (const t of tasks) {
|
||||
if (counts[t.status] !== undefined) counts[t.status]++;
|
||||
|
|
|
|||
|
|
@ -26,14 +26,12 @@
|
|||
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
|
||||
} from '../../stores/workspace.svelte';
|
||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
|
||||
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import { recordExternalWrite } from '../../stores/conflicts.svelte';
|
||||
import { ProjectId, type AgentId, type GroupId } from '../../types/ids';
|
||||
import { notify, dismissNotification } from '../../stores/notifications.svelte';
|
||||
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
|
||||
import { setReviewQueueDepth } from '../../stores/health.svelte';
|
||||
import { reviewQueueCount } from '../../adapters/bttask-bridge';
|
||||
import { getStaleAgents } from '../../adapters/btmsg-bridge';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
|
|
@ -155,7 +153,7 @@
|
|||
if (!groupId) return;
|
||||
|
||||
const pollReviewQueue = () => {
|
||||
reviewQueueCount(groupId)
|
||||
getBackend().bttaskReviewQueueCount(groupId)
|
||||
.then(count => setReviewQueueDepth(project.id, count))
|
||||
.catch(() => {}); // best-effort
|
||||
};
|
||||
|
|
@ -173,11 +171,11 @@
|
|||
|
||||
const pollHeartbeat = () => {
|
||||
// 300s = healthy threshold, 600s = dead threshold
|
||||
getStaleAgents(groupId as unknown as GroupId, 300)
|
||||
getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 300)
|
||||
.then(staleIds => {
|
||||
if (staleIds.includes(project.id)) {
|
||||
// Check if truly dead (>10 min)
|
||||
getStaleAgents(groupId as unknown as GroupId, 600)
|
||||
getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 600)
|
||||
.then(deadIds => {
|
||||
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
|
||||
})
|
||||
|
|
@ -207,8 +205,8 @@
|
|||
scanToastId = notify('info', 'Scanning project directories…');
|
||||
}, 300);
|
||||
|
||||
fsWatchProject(projectId, cwd)
|
||||
.then(() => fsWatcherStatus())
|
||||
getBackend().fsWatchProject(projectId, cwd)
|
||||
.then(() => getBackend().fsWatcherStatus())
|
||||
.then((status) => {
|
||||
clearTimeout(scanTimer);
|
||||
if (scanToastId) dismissNotification(scanToastId);
|
||||
|
|
@ -224,7 +222,7 @@
|
|||
|
||||
// Listen for fs write events (filter to this project)
|
||||
let unlisten: (() => void) | null = null;
|
||||
onFsWriteDetected((event) => {
|
||||
getBackend().onFsWriteDetected((event) => {
|
||||
if (event.project_id !== projectId) return;
|
||||
const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms);
|
||||
if (isNew) {
|
||||
|
|
@ -235,7 +233,7 @@
|
|||
|
||||
return () => {
|
||||
// Cleanup: stop watching on unmount or project change
|
||||
fsUnwatchProject(projectId).catch(() => {});
|
||||
getBackend().fsUnwatchProject(projectId).catch(() => {});
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { MdFileEntry } from '@agor/types';
|
||||
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
async function loadFiles(dir: string) {
|
||||
loading = true;
|
||||
try {
|
||||
files = await discoverMarkdownFiles(dir);
|
||||
files = await getBackend().discoverMarkdownFiles(dir);
|
||||
const priority = files.find(f => f.priority);
|
||||
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { searchAll, type SearchResult } from '../../adapters/search-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { SearchResult } from '@agor/types';
|
||||
import { setActiveProject } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -64,7 +65,7 @@
|
|||
loading = true;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
results = await searchAll(query, 30);
|
||||
results = await getBackend().searchAll(query, 30);
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -21,16 +21,20 @@
|
|||
import { getSetting, setSetting } from '../../stores/settings-store.svelte';
|
||||
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
|
||||
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
|
||||
import { listProfiles, type ClaudeProfile } from '../../adapters/claude-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { ClaudeProfile } from '@agor/types';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getProviders } from '../../providers/registry.svelte';
|
||||
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
||||
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
|
||||
import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake';
|
||||
import {
|
||||
storeSecret, getSecret, deleteSecret, listSecrets,
|
||||
hasKeyring, knownSecretKeys, SECRET_KEY_LABELS,
|
||||
} from '../../adapters/secrets-bridge';
|
||||
const SECRET_KEY_LABELS: Record<string, string> = {
|
||||
anthropic_api_key: 'Anthropic API Key',
|
||||
openai_api_key: 'OpenAI API Key',
|
||||
openrouter_api_key: 'OpenRouter API Key',
|
||||
github_token: 'GitHub Token',
|
||||
relay_token: 'Relay Token',
|
||||
};
|
||||
import {
|
||||
checkForUpdates,
|
||||
getCurrentVersion,
|
||||
|
|
@ -176,7 +180,7 @@
|
|||
selectedTheme = getCurrentTheme();
|
||||
|
||||
try {
|
||||
profiles = await listProfiles();
|
||||
profiles = await getBackend().listProfiles();
|
||||
} catch {
|
||||
profiles = [];
|
||||
}
|
||||
|
|
@ -191,10 +195,10 @@
|
|||
|
||||
// Load secrets state
|
||||
try {
|
||||
keyringAvailable = await hasKeyring();
|
||||
keyringAvailable = await getBackend().hasKeyring();
|
||||
if (keyringAvailable) {
|
||||
storedKeys = await listSecrets();
|
||||
knownKeys = await knownSecretKeys();
|
||||
storedKeys = await getBackend().listSecrets();
|
||||
knownKeys = await getBackend().knownSecretKeys();
|
||||
}
|
||||
} catch {
|
||||
keyringAvailable = false;
|
||||
|
|
@ -317,7 +321,7 @@
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const val = await getSecret(key);
|
||||
const val = await getBackend().getSecret(key);
|
||||
revealedKey = key;
|
||||
revealedValue = val ?? '';
|
||||
} catch (e) {
|
||||
|
|
@ -329,8 +333,8 @@
|
|||
if (!newSecretKey || !newSecretValue) return;
|
||||
secretsSaving = true;
|
||||
try {
|
||||
await storeSecret(newSecretKey, newSecretValue);
|
||||
storedKeys = await listSecrets();
|
||||
await getBackend().storeSecret(newSecretKey, newSecretValue);
|
||||
storedKeys = await getBackend().listSecrets();
|
||||
newSecretKey = '';
|
||||
newSecretValue = '';
|
||||
// If we just saved the currently revealed key, clear reveal
|
||||
|
|
@ -345,8 +349,8 @@
|
|||
|
||||
async function handleDeleteSecret(key: string) {
|
||||
try {
|
||||
await deleteSecret(key);
|
||||
storedKeys = await listSecrets();
|
||||
await getBackend().deleteSecret(key);
|
||||
storedKeys = await getBackend().listSecrets();
|
||||
if (revealedKey === key) {
|
||||
revealedKey = null;
|
||||
revealedValue = '';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listSshSessions, saveSshSession, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { SshSessionRecord } from '@agor/types';
|
||||
type SshSession = SshSessionRecord;
|
||||
import { addTerminalTab } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -27,7 +29,7 @@
|
|||
async function loadSessions() {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await listSshSessions();
|
||||
sessions = await getBackend().listSshSessions();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load SSH sessions:', e);
|
||||
} finally {
|
||||
|
|
@ -79,7 +81,7 @@
|
|||
};
|
||||
|
||||
try {
|
||||
await saveSshSession(session);
|
||||
await getBackend().saveSshSession(session);
|
||||
await loadSessions();
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
|
|
@ -89,7 +91,7 @@
|
|||
|
||||
async function removeSession(id: string) {
|
||||
try {
|
||||
await deleteSshSession(id);
|
||||
await getBackend().deleteSshSession(id);
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete SSH session:', e);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listTasks, updateTaskStatus, createTask, deleteTask, addTaskComment, type Task, type TaskComment, getTaskComments } from '../../adapters/bttask-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { Task, TaskComment } from '@agor/types';
|
||||
import type { GroupId } from '../../types/ids';
|
||||
import { AgentId } from '../../types/ids';
|
||||
|
||||
|
|
@ -64,7 +65,7 @@
|
|||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
tasks = await listTasks(groupId);
|
||||
tasks = await getBackend().bttaskList(groupId);
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
|
|
@ -86,7 +87,7 @@
|
|||
try {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
const version = task?.version ?? 1;
|
||||
await updateTaskStatus(taskId, newStatus, version);
|
||||
await getBackend().bttaskUpdateStatus(taskId, newStatus, version);
|
||||
await loadTasks();
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
|
|
@ -102,7 +103,7 @@
|
|||
async function handleAddTask() {
|
||||
if (!newTitle.trim()) return;
|
||||
try {
|
||||
await createTask(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
|
||||
await getBackend().bttaskCreate(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
|
||||
newTitle = '';
|
||||
newDesc = '';
|
||||
newPriority = 'medium';
|
||||
|
|
@ -115,7 +116,7 @@
|
|||
|
||||
async function handleDelete(taskId: string) {
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
await getBackend().bttaskDelete(taskId);
|
||||
if (expandedTaskId === taskId) expandedTaskId = null;
|
||||
await loadTasks();
|
||||
} catch (e) {
|
||||
|
|
@ -130,7 +131,7 @@
|
|||
}
|
||||
expandedTaskId = taskId;
|
||||
try {
|
||||
taskComments = await getTaskComments(taskId);
|
||||
taskComments = await getBackend().bttaskComments(taskId);
|
||||
} catch {
|
||||
taskComments = [];
|
||||
}
|
||||
|
|
@ -139,9 +140,9 @@
|
|||
async function handleAddComment() {
|
||||
if (!expandedTaskId || !newComment.trim()) return;
|
||||
try {
|
||||
await addTaskComment(expandedTaskId, AgentId('admin'), newComment.trim());
|
||||
await getBackend().bttaskAddComment(expandedTaskId, AgentId('admin'), newComment.trim());
|
||||
newComment = '';
|
||||
taskComments = await getTaskComments(expandedTaskId);
|
||||
taskComments = await getBackend().bttaskComments(expandedTaskId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to add comment:', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
removeTerminalTab,
|
||||
type TerminalTab,
|
||||
} from '../../stores/workspace.svelte';
|
||||
import { listSshSessions, type SshSession } from '../../adapters/ssh-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { SshSessionRecord } from '@agor/types';
|
||||
type SshSession = SshSessionRecord;
|
||||
import TerminalPane from '../Terminal/TerminalPane.svelte';
|
||||
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
|
||||
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
let sshSessions = $state<SshSession[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
|
||||
getBackend().listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
|
||||
});
|
||||
|
||||
/** Resolved SSH args per tab, keyed by tab id */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { listDirectoryChildren, readFileContent, type DirEntry } from '../../adapters/files-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
import type { FileEntry } from '@agor/types';
|
||||
type DirEntry = FileEntry;
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
|
|
@ -23,7 +25,7 @@
|
|||
async function loadSeleniumState() {
|
||||
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
|
||||
try {
|
||||
const entries = await listDirectoryChildren(screenshotPath);
|
||||
const entries = await getBackend().listDirectory(screenshotPath);
|
||||
const imageFiles = entries
|
||||
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
|
||||
.map(e => e.path)
|
||||
|
|
@ -43,7 +45,7 @@
|
|||
|
||||
// Load session log
|
||||
try {
|
||||
const content = await readFileContent(`${cwd}/${SELENIUM_LOG}`);
|
||||
const content = await getBackend().readFile(`${cwd}/${SELENIUM_LOG}`);
|
||||
if (content.type === 'Text') {
|
||||
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
|
||||
}
|
||||
|
|
@ -64,7 +66,7 @@
|
|||
async function loadTestFiles() {
|
||||
for (const dir of TEST_DIRS) {
|
||||
try {
|
||||
const entries = await listDirectoryChildren(`${cwd}/${dir}`);
|
||||
const entries = await getBackend().listDirectory(`${cwd}/${dir}`);
|
||||
const tests = entries.filter(e =>
|
||||
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
|
||||
/test_.*\.py$/.test(e.name)
|
||||
|
|
@ -83,7 +85,7 @@
|
|||
async function viewTestFile(filePath: string) {
|
||||
selectedTestFile = filePath;
|
||||
try {
|
||||
const content = await readFileContent(filePath);
|
||||
const content = await getBackend().readFile(filePath);
|
||||
if (content.type === 'Text') {
|
||||
testOutput = content.content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
|
||||
// --- Mocks ---
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
const { mockBackend } = vi.hoisted(() => ({
|
||||
mockBackend: {
|
||||
readPluginFile: vi.fn(),
|
||||
bttaskList: vi.fn().mockResolvedValue([]),
|
||||
bttaskComments: vi.fn().mockResolvedValue([]),
|
||||
btmsgUnreadMessages: vi.fn().mockResolvedValue([]),
|
||||
btmsgGetChannels: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mockInvoke,
|
||||
vi.mock('../backend/backend', () => ({
|
||||
getBackend: vi.fn(() => mockBackend),
|
||||
}));
|
||||
|
||||
// Mock the plugins store to avoid Svelte 5 rune issues in test context
|
||||
|
|
@ -41,7 +47,7 @@ import {
|
|||
unloadAllPlugins,
|
||||
} from './plugin-host';
|
||||
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import type { PluginMeta } from '@agor/types';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
// --- Mock Worker ---
|
||||
|
|
@ -208,10 +214,7 @@ function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
|
|||
}
|
||||
|
||||
function mockPluginCode(code: string): void {
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve(code);
|
||||
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
|
||||
});
|
||||
mockBackend.readPluginFile.mockResolvedValue(code);
|
||||
}
|
||||
|
||||
const GROUP_ID = 'test-group' as GroupId;
|
||||
|
|
@ -481,7 +484,7 @@ describe('plugin-host lifecycle', () => {
|
|||
|
||||
it('loadPlugin throws on file read failure', async () => {
|
||||
const meta = makeMeta({ id: 'read-fail' });
|
||||
mockInvoke.mockRejectedValue(new Error('file not found'));
|
||||
mockBackend.readPluginFile.mockRejectedValue(new Error('file not found'));
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||
"Failed to read plugin 'read-fail'",
|
||||
|
|
@ -509,12 +512,7 @@ describe('plugin-host RPC routing', () => {
|
|||
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
|
||||
mockPluginCode(`agor.tasks.list();`);
|
||||
|
||||
// Mock the bttask bridge
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve('agor.tasks.list();');
|
||||
if (cmd === 'bttask_list') return Promise.resolve([]);
|
||||
return Promise.reject(new Error(`Unexpected: ${cmd}`));
|
||||
});
|
||||
// bttaskList already mocked via mockBackend defaults
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
@ -523,11 +521,7 @@ describe('plugin-host RPC routing', () => {
|
|||
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
|
||||
mockPluginCode(`agor.messages.inbox();`);
|
||||
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve('agor.messages.inbox();');
|
||||
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
|
||||
return Promise.reject(new Error(`Unexpected: ${cmd}`));
|
||||
});
|
||||
// btmsgUnreadMessages already mocked via mockBackend defaults
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,13 +12,8 @@
|
|||
* On unload, the Worker is terminated — all plugin state is destroyed.
|
||||
*/
|
||||
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import { readPluginFile } from '../adapters/plugins-bridge';
|
||||
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
|
||||
import {
|
||||
getUnreadMessages,
|
||||
getChannels,
|
||||
} from '../adapters/btmsg-bridge';
|
||||
import type { PluginMeta } from '@agor/types';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import {
|
||||
addPluginCommand,
|
||||
removePluginCommands,
|
||||
|
|
@ -189,7 +184,7 @@ export async function loadPlugin(
|
|||
// Read the plugin's entry file
|
||||
let code: string;
|
||||
try {
|
||||
code = await readPluginFile(meta.id, meta.main);
|
||||
code = await getBackend().readPluginFile(meta.id, meta.main);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
|
||||
}
|
||||
|
|
@ -253,16 +248,16 @@ export async function loadPlugin(
|
|||
let result: unknown;
|
||||
switch (method) {
|
||||
case 'tasks.list':
|
||||
result = await listTasks(groupId);
|
||||
result = await getBackend().bttaskList(groupId);
|
||||
break;
|
||||
case 'tasks.comments':
|
||||
result = await getTaskComments(args.taskId);
|
||||
result = await getBackend().bttaskComments(args.taskId);
|
||||
break;
|
||||
case 'messages.inbox':
|
||||
result = await getUnreadMessages(agentId);
|
||||
result = await getBackend().btmsgUnreadMessages(agentId);
|
||||
break;
|
||||
case 'messages.channels':
|
||||
result = await getChannels(groupId);
|
||||
result = await getBackend().btmsgGetChannels(groupId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown RPC method: ${method}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSetting, setSetting } from '../../stores/settings-store.svelte';
|
||||
import { storeSecret, getSecret, deleteSecret, listSecrets, hasKeyring, knownSecretKeys, SECRET_KEY_LABELS } from '../../adapters/secrets-bridge';
|
||||
import { getBackend } from '../../backend/backend';
|
||||
const SECRET_KEY_LABELS: Record<string, string> = {
|
||||
anthropic_api_key: 'Anthropic API Key',
|
||||
openai_api_key: 'OpenAI API Key',
|
||||
openrouter_api_key: 'OpenRouter API Key',
|
||||
github_token: 'GitHub Token',
|
||||
relay_token: 'Relay Token',
|
||||
};
|
||||
import { handleError, handleInfraError } from '../../utils/handle-error';
|
||||
|
||||
let keyringAvailable = $state(false);
|
||||
|
|
@ -25,8 +32,8 @@
|
|||
|
||||
onMount(async () => {
|
||||
try {
|
||||
keyringAvailable = await hasKeyring();
|
||||
if (keyringAvailable) { storedKeys = await listSecrets(); knownKeys = await knownSecretKeys(); }
|
||||
keyringAvailable = await getBackend().hasKeyring();
|
||||
if (keyringAvailable) { storedKeys = await getBackend().listSecrets(); knownKeys = await getBackend().knownSecretKeys(); }
|
||||
} catch (e) {
|
||||
// Keyring unavailable is expected on some systems — set state explicitly
|
||||
handleInfraError(e, 'settings.keyring.init');
|
||||
|
|
@ -52,18 +59,18 @@
|
|||
|
||||
async function handleRevealSecret(key: string) {
|
||||
if (revealedKey === key) { revealedKey = null; revealedValue = ''; return; }
|
||||
try { const val = await getSecret(key); revealedKey = key; revealedValue = val ?? ''; }
|
||||
try { const val = await getBackend().getSecret(key); revealedKey = key; revealedValue = val ?? ''; }
|
||||
catch (e) { handleError(e, `settings.secrets.reveal.${key}`, 'reveal the secret'); }
|
||||
}
|
||||
async function handleSaveSecret() {
|
||||
if (!newSecretKey || !newSecretValue) return;
|
||||
secretsSaving = true;
|
||||
try { await storeSecret(newSecretKey, newSecretValue); storedKeys = await listSecrets(); newSecretKey = ''; newSecretValue = ''; revealedKey = null; revealedValue = ''; }
|
||||
try { await getBackend().storeSecret(newSecretKey, newSecretValue); storedKeys = await getBackend().listSecrets(); newSecretKey = ''; newSecretValue = ''; revealedKey = null; revealedValue = ''; }
|
||||
catch (e) { handleError(e, 'settings.secrets.store', 'store the secret'); }
|
||||
finally { secretsSaving = false; }
|
||||
}
|
||||
async function handleDeleteSecret(key: string) {
|
||||
try { await deleteSecret(key); storedKeys = await listSecrets(); if (revealedKey === key) { revealedKey = null; revealedValue = ''; } }
|
||||
try { await getBackend().deleteSecret(key); storedKeys = await getBackend().listSecrets(); if (revealedKey === key) { revealedKey = null; revealedValue = ''; } }
|
||||
catch (e) { handleError(e, `settings.secrets.delete.${key}`, 'delete the secret'); }
|
||||
}
|
||||
function addBranchPolicy() {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@
|
|||
|
||||
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
|
||||
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
|
||||
import {
|
||||
saveSessionAnchors,
|
||||
loadSessionAnchors,
|
||||
deleteSessionAnchor,
|
||||
updateAnchorType as updateAnchorTypeBridge,
|
||||
} from '../adapters/anchors-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
|
||||
// Per-project anchor state
|
||||
|
|
@ -61,7 +56,7 @@ export async function addAnchors(projectId: string, anchors: SessionAnchor[]): P
|
|||
}));
|
||||
|
||||
try {
|
||||
await saveSessionAnchors(records);
|
||||
await getBackend().saveSessionAnchors(records);
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'anchors.save');
|
||||
}
|
||||
|
|
@ -73,7 +68,7 @@ export async function removeAnchor(projectId: string, anchorId: string): Promise
|
|||
projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId));
|
||||
|
||||
try {
|
||||
await deleteSessionAnchor(anchorId);
|
||||
await getBackend().deleteSessionAnchor(anchorId);
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'anchors.delete');
|
||||
}
|
||||
|
|
@ -90,7 +85,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
|
|||
projectAnchors.set(projectId, [...existing]);
|
||||
|
||||
try {
|
||||
await updateAnchorTypeBridge(anchorId, newType);
|
||||
await getBackend().updateAnchorType(anchorId, newType);
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'anchors.updateType');
|
||||
}
|
||||
|
|
@ -99,7 +94,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
|
|||
/** Load anchors from SQLite for a project */
|
||||
export async function loadAnchorsForProject(projectId: string): Promise<void> {
|
||||
try {
|
||||
const records = await loadSessionAnchors(projectId);
|
||||
const records = await getBackend().loadSessionAnchors(projectId);
|
||||
const anchors: SessionAnchor[] = records.map(r => ({
|
||||
id: r.id,
|
||||
projectId: r.project_id,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import {
|
||||
listSessions,
|
||||
saveSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
touchSession,
|
||||
saveLayout,
|
||||
loadLayout,
|
||||
updateSessionGroup,
|
||||
type PersistedSession,
|
||||
} from '../adapters/session-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import type { PersistedSession } from '@agor/types';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
|
||||
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
|
||||
|
|
@ -47,11 +38,11 @@ function persistSession(pane: Pane): void {
|
|||
created_at: now,
|
||||
last_used_at: now,
|
||||
};
|
||||
saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession'));
|
||||
getBackend().saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession'));
|
||||
}
|
||||
|
||||
function persistLayout(): void {
|
||||
saveLayout({
|
||||
getBackend().saveLayout({
|
||||
preset: activePreset,
|
||||
pane_ids: panes.map(p => p.id),
|
||||
}).catch(e => handleInfraError(e, 'layout.persistLayout'));
|
||||
|
|
@ -85,14 +76,14 @@ export function removePane(id: string): void {
|
|||
focusedPaneId = panes.length > 0 ? panes[0].id : null;
|
||||
}
|
||||
autoPreset();
|
||||
deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
|
||||
getBackend().deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
|
||||
persistLayout();
|
||||
}
|
||||
|
||||
export function focusPane(id: string): void {
|
||||
focusedPaneId = id;
|
||||
panes = panes.map(p => ({ ...p, focused: p.id === id }));
|
||||
touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession'));
|
||||
getBackend().touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession'));
|
||||
}
|
||||
|
||||
export function focusPaneByIndex(index: number): void {
|
||||
|
|
@ -110,7 +101,7 @@ export function renamePaneTitle(id: string, title: string): void {
|
|||
const pane = panes.find(p => p.id === id);
|
||||
if (pane) {
|
||||
pane.title = title;
|
||||
updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle'));
|
||||
getBackend().updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +109,7 @@ export function setPaneGroup(id: string, group: string): void {
|
|||
const pane = panes.find(p => p.id === id);
|
||||
if (pane) {
|
||||
pane.group = group || undefined;
|
||||
updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup'));
|
||||
getBackend().updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +119,8 @@ export async function restoreFromDb(): Promise<void> {
|
|||
initialized = true;
|
||||
|
||||
try {
|
||||
const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]);
|
||||
const backend = getBackend();
|
||||
const [sessions, layout] = await Promise.all([backend.listSessions(), backend.loadLayout()]);
|
||||
|
||||
if (layout.preset) {
|
||||
activePreset = layout.preset as LayoutPreset;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock session-bridge before importing the layout store
|
||||
vi.mock('../adapters/session-bridge', () => ({
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
saveSession: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionTitle: vi.fn().mockResolvedValue(undefined),
|
||||
touchSession: vi.fn().mockResolvedValue(undefined),
|
||||
saveLayout: vi.fn().mockResolvedValue(undefined),
|
||||
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }),
|
||||
// Mock backend before importing the layout store
|
||||
vi.mock('../backend/backend', () => ({
|
||||
getBackend: vi.fn(() => ({
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
saveSession: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionTitle: vi.fn().mockResolvedValue(undefined),
|
||||
touchSession: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionGroup: vi.fn().mockResolvedValue(undefined),
|
||||
saveLayout: vi.fn().mockResolvedValue(undefined),
|
||||
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,7 @@
|
|||
// Remote machines store — tracks connection state for multi-machine support
|
||||
|
||||
import {
|
||||
listRemoteMachines,
|
||||
addRemoteMachine,
|
||||
removeRemoteMachine,
|
||||
connectRemoteMachine,
|
||||
disconnectRemoteMachine,
|
||||
onRemoteMachineReady,
|
||||
onRemoteMachineDisconnected,
|
||||
onRemoteError,
|
||||
onRemoteMachineReconnecting,
|
||||
onRemoteMachineReconnectReady,
|
||||
type RemoteMachineConfig,
|
||||
type RemoteMachineInfo,
|
||||
} from '../adapters/remote-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import type { RemoteMachineConfig, RemoteMachineInfo } from '@agor/types';
|
||||
import { notify } from './notifications.svelte';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
|
||||
|
|
@ -31,26 +19,27 @@ export function getMachine(id: string): Machine | undefined {
|
|||
|
||||
export async function loadMachines(): Promise<void> {
|
||||
try {
|
||||
machines = await listRemoteMachines();
|
||||
machines = await getBackend().listRemoteMachines();
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'machines.load');
|
||||
}
|
||||
}
|
||||
|
||||
export async function addMachine(config: RemoteMachineConfig): Promise<string> {
|
||||
const id = await addRemoteMachine(config);
|
||||
const id = await getBackend().addRemoteMachine(config);
|
||||
machines.push({
|
||||
id,
|
||||
label: config.label,
|
||||
url: config.url,
|
||||
status: 'disconnected',
|
||||
auto_connect: config.auto_connect,
|
||||
spki_pins: config.spki_pins ?? [],
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function removeMachine(id: string): Promise<void> {
|
||||
await removeRemoteMachine(id);
|
||||
await getBackend().removeRemoteMachine(id);
|
||||
machines = machines.filter(m => m.id !== id);
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +47,7 @@ export async function connectMachine(id: string): Promise<void> {
|
|||
const machine = machines.find(m => m.id === id);
|
||||
if (machine) machine.status = 'connecting';
|
||||
try {
|
||||
await connectRemoteMachine(id);
|
||||
await getBackend().connectRemoteMachine(id);
|
||||
if (machine) machine.status = 'connected';
|
||||
} catch (e) {
|
||||
if (machine) machine.status = 'error';
|
||||
|
|
@ -67,7 +56,7 @@ export async function connectMachine(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function disconnectMachine(id: string): Promise<void> {
|
||||
await disconnectRemoteMachine(id);
|
||||
await getBackend().disconnectRemoteMachine(id);
|
||||
const machine = machines.find(m => m.id === id);
|
||||
if (machine) machine.status = 'disconnected';
|
||||
}
|
||||
|
|
@ -76,11 +65,12 @@ export async function disconnectMachine(id: string): Promise<void> {
|
|||
let unlistenFns: (() => void)[] = [];
|
||||
|
||||
// Initialize event listeners for machine status updates
|
||||
export async function initMachineListeners(): Promise<void> {
|
||||
export function initMachineListeners(): void {
|
||||
// Clean up any existing listeners first
|
||||
destroyMachineListeners();
|
||||
const backend = getBackend();
|
||||
|
||||
unlistenFns.push(await onRemoteMachineReady((msg) => {
|
||||
unlistenFns.push(backend.onRemoteMachineReady((msg) => {
|
||||
const machine = machines.find(m => m.id === msg.machineId);
|
||||
if (machine) {
|
||||
machine.status = 'connected';
|
||||
|
|
@ -88,7 +78,7 @@ export async function initMachineListeners(): Promise<void> {
|
|||
}
|
||||
}));
|
||||
|
||||
unlistenFns.push(await onRemoteMachineDisconnected((msg) => {
|
||||
unlistenFns.push(backend.onRemoteMachineDisconnected((msg) => {
|
||||
const machine = machines.find(m => m.id === msg.machineId);
|
||||
if (machine) {
|
||||
machine.status = 'disconnected';
|
||||
|
|
@ -96,7 +86,7 @@ export async function initMachineListeners(): Promise<void> {
|
|||
}
|
||||
}));
|
||||
|
||||
unlistenFns.push(await onRemoteError((msg) => {
|
||||
unlistenFns.push(backend.onRemoteError((msg) => {
|
||||
const machine = machines.find(m => m.id === msg.machineId);
|
||||
if (machine) {
|
||||
machine.status = 'error';
|
||||
|
|
@ -104,18 +94,18 @@ export async function initMachineListeners(): Promise<void> {
|
|||
}
|
||||
}));
|
||||
|
||||
unlistenFns.push(await onRemoteMachineReconnecting((msg) => {
|
||||
unlistenFns.push(backend.onRemoteMachineReconnecting((msg) => {
|
||||
const machine = machines.find(m => m.id === msg.machineId);
|
||||
if (machine) {
|
||||
machine.status = 'reconnecting';
|
||||
notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s…`);
|
||||
notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s...`);
|
||||
}
|
||||
}));
|
||||
|
||||
unlistenFns.push(await onRemoteMachineReconnectReady((msg) => {
|
||||
unlistenFns.push(backend.onRemoteMachineReconnectReady((msg) => {
|
||||
const machine = machines.find(m => m.id === msg.machineId);
|
||||
if (machine) {
|
||||
notify('info', `${machine.label} reachable — reconnecting…`);
|
||||
notify('info', `${machine.label} reachable - reconnecting...`);
|
||||
connectMachine(msg.machineId).catch((e) => {
|
||||
notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Notification store — ephemeral toasts + persistent notification history
|
||||
|
||||
import { sendDesktopNotification } from '../adapters/notifications-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
|
||||
// --- Toast types (existing) ---
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ export function addNotification(
|
|||
notify(toastType, `${title}: ${body}`);
|
||||
|
||||
// Send OS desktop notification (fire-and-forget)
|
||||
sendDesktopNotification(title, body, notificationUrgency(type));
|
||||
try { getBackend().sendDesktopNotification(title, body, notificationUrgency(type)); } catch { /* backend not ready */ }
|
||||
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
* Uses Svelte 5 runes for reactivity.
|
||||
*/
|
||||
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import { discoverPlugins } from '../adapters/plugins-bridge';
|
||||
import type { PluginMeta } from '@agor/types';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import { getSetting, setSetting } from './settings-store.svelte';
|
||||
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
|
|
@ -160,7 +160,7 @@ export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Prom
|
|||
|
||||
let discovered: PluginMeta[];
|
||||
try {
|
||||
discovered = await discoverPlugins();
|
||||
discovered = await getBackend().discoverPlugins();
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'plugins.discover');
|
||||
pluginEntries = [];
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import type { AgentId } from '../types/ids';
|
|||
import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer';
|
||||
import { getAllProjectHealth, getHealthAggregates } from './health.svelte';
|
||||
import { getAllWorkItems } from './workspace.svelte';
|
||||
import { listTasks } from '../adapters/bttask-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import { getAgentSession } from './agents.svelte';
|
||||
import { logAuditEvent } from '../adapters/audit-bridge';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
import type { GroupId } from '../types/ids';
|
||||
|
||||
|
|
@ -210,7 +209,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
|
|||
// Fetch task summary (best-effort)
|
||||
let taskSummary: WakeTaskSummary | undefined;
|
||||
try {
|
||||
const tasks = await listTasks(reg.groupId);
|
||||
const tasks = await getBackend().bttaskList(reg.groupId);
|
||||
taskSummary = {
|
||||
total: tasks.length,
|
||||
todo: tasks.filter(t => t.status === 'todo').length,
|
||||
|
|
@ -262,7 +261,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
|
|||
});
|
||||
|
||||
// Audit: log wake event
|
||||
logAuditEvent(
|
||||
getBackend().logAuditEvent(
|
||||
reg.agentId,
|
||||
'wake_event',
|
||||
`Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import { handleInfraError } from '../utils/handle-error';
|
||||
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
|
||||
import { agentToProject } from '../types/groups';
|
||||
|
|
@ -7,7 +7,6 @@ import { clearHealthTracking } from '../stores/health.svelte';
|
|||
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
|
||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||
import { registerAgents } from '../adapters/btmsg-bridge';
|
||||
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
||||
|
||||
|
|
@ -201,7 +200,7 @@ export async function switchGroup(groupId: string): Promise<void> {
|
|||
// Persist active group
|
||||
if (groupsConfig) {
|
||||
groupsConfig.activeGroupId = groupId;
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -225,18 +224,18 @@ export function removeTerminalTab(projectId: string, tabId: string): void {
|
|||
|
||||
export async function loadWorkspace(initialGroupId?: string): Promise<void> {
|
||||
try {
|
||||
const config = await loadGroups();
|
||||
const config = await getBackend().loadGroups();
|
||||
groupsConfig = config;
|
||||
projectTerminals = {};
|
||||
|
||||
// Register all agents from config into btmsg database
|
||||
// (creates agent records, contact permissions, review channels)
|
||||
registerAgents(config).catch(e => handleInfraError(e, 'workspace.registerAgents'));
|
||||
getBackend().btmsgRegisterAgents(config).catch(e => handleInfraError(e, 'workspace.registerAgents'));
|
||||
|
||||
// CLI --group flag takes priority, then explicit param, then persisted
|
||||
let cliGroup: string | null = null;
|
||||
if (!initialGroupId) {
|
||||
cliGroup = await getCliGroup();
|
||||
cliGroup = await getBackend().getCliGroup();
|
||||
}
|
||||
const targetId = initialGroupId || cliGroup || config.activeGroupId;
|
||||
// Match by ID or by name (CLI users may pass name)
|
||||
|
|
@ -263,9 +262,9 @@ export async function loadWorkspace(initialGroupId?: string): Promise<void> {
|
|||
|
||||
export async function saveWorkspace(): Promise<void> {
|
||||
if (!groupsConfig) return;
|
||||
await saveGroups(groupsConfig);
|
||||
await getBackend().saveGroups(groupsConfig);
|
||||
// Re-register agents after config changes (new agents, permission updates)
|
||||
registerAgents(groupsConfig).catch(e => handleInfraError(e, 'workspace.registerAgents'));
|
||||
getBackend().btmsgRegisterAgents(groupsConfig).catch(e => handleInfraError(e, 'workspace.registerAgents'));
|
||||
}
|
||||
|
||||
// --- Group/project mutation ---
|
||||
|
|
@ -276,7 +275,7 @@ export function addGroup(group: GroupConfig): void {
|
|||
...groupsConfig,
|
||||
groups: [...groupsConfig.groups, group],
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
||||
export function removeGroup(groupId: string): void {
|
||||
|
|
@ -289,7 +288,7 @@ export function removeGroup(groupId: string): void {
|
|||
activeGroupId = groupsConfig.groups[0]?.id ?? '';
|
||||
activeProjectId = null;
|
||||
}
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
||||
export function updateProject(groupId: string, projectId: string, updates: Partial<ProjectConfig>): void {
|
||||
|
|
@ -307,7 +306,7 @@ export function updateProject(groupId: string, projectId: string, updates: Parti
|
|||
};
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
||||
export function addProject(groupId: string, project: ProjectConfig): void {
|
||||
|
|
@ -321,7 +320,7 @@ export function addProject(groupId: string, project: ProjectConfig): void {
|
|||
return { ...g, projects: [...g.projects, project] };
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
||||
export function removeProject(groupId: string, projectId: string): void {
|
||||
|
|
@ -336,7 +335,7 @@ export function removeProject(groupId: string, projectId: string): void {
|
|||
if (activeProjectId === projectId) {
|
||||
activeProjectId = null;
|
||||
}
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
||||
export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void {
|
||||
|
|
@ -354,5 +353,5 @@ export function updateAgent(groupId: string, agentId: string, updates: Partial<G
|
|||
};
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,10 +38,15 @@ vi.mock('../agent-dispatcher', () => ({
|
|||
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../adapters/groups-bridge', () => ({
|
||||
const mockBackend = {
|
||||
loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())),
|
||||
saveGroups: vi.fn().mockResolvedValue(undefined),
|
||||
getCliGroup: vi.fn().mockResolvedValue(null),
|
||||
btmsgRegisterAgents: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock('../backend/backend', () => ({
|
||||
getBackend: vi.fn(() => mockBackend),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
|
@ -66,7 +71,7 @@ import {
|
|||
removeProject,
|
||||
} from './workspace.svelte';
|
||||
|
||||
import { saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
const { saveGroups, getCliGroup } = mockBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { SessionAnchor } from '../types/anchors';
|
|||
import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte';
|
||||
import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer';
|
||||
import { getEnabledProjects } from '../stores/workspace.svelte';
|
||||
import { tel } from '../adapters/telemetry-bridge';
|
||||
import { tel } from './telemetry';
|
||||
import { notify } from '../stores/notifications.svelte';
|
||||
|
||||
/** Auto-anchor first N turns on first compaction event for a project */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { extractErrorMessage } from './extract-error-message';
|
||||
import { classifyError } from './error-classifier';
|
||||
import { notify } from '../stores/notifications.svelte';
|
||||
import { tel } from '../adapters/telemetry-bridge';
|
||||
import { tel } from './telemetry';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { extractErrorMessage } from './extract-error-message';
|
||||
import { classifyError, type ClassifiedError } from './error-classifier';
|
||||
import { notify } from '../stores/notifications.svelte';
|
||||
import { tel } from '../adapters/telemetry-bridge';
|
||||
import { tel } from './telemetry';
|
||||
|
||||
/** User-facing error handler. Logs to telemetry AND shows a toast. */
|
||||
export function handleError(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@
|
|||
import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids';
|
||||
import type { ProviderId } from '../providers/types';
|
||||
import { getAgentSession } from '../stores/agents.svelte';
|
||||
import {
|
||||
saveProjectAgentState,
|
||||
saveAgentMessages,
|
||||
saveSessionMetric,
|
||||
type AgentMessageRecord,
|
||||
} from '../adapters/groups-bridge';
|
||||
import { getBackend } from '../backend/backend';
|
||||
import type { AgentMessageRecord } from '@agor/types';
|
||||
import { handleInfraError } from './handle-error';
|
||||
|
||||
// Map sessionId -> projectId for persistence routing
|
||||
|
|
@ -58,8 +54,9 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
|
|||
|
||||
pendingPersistCount++;
|
||||
try {
|
||||
const backend = getBackend();
|
||||
// Save agent state
|
||||
await saveProjectAgentState({
|
||||
await backend.saveProjectAgentState({
|
||||
project_id: projectId,
|
||||
last_session_id: sessionId,
|
||||
sdk_session_id: session.sdkSessionId ?? null,
|
||||
|
|
@ -85,13 +82,13 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
|
|||
}));
|
||||
|
||||
if (records.length > 0) {
|
||||
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||
await backend.saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||
}
|
||||
|
||||
// Persist session metric for historical tracking
|
||||
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
||||
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
||||
await saveSessionMetric({
|
||||
await backend.saveSessionMetric({
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
start_time: Math.floor(startTime / 1000),
|
||||
|
|
|
|||
23
src/lib/utils/telemetry.ts
Normal file
23
src/lib/utils/telemetry.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Telemetry utility — routes frontend events to backend via BackendAdapter
|
||||
// Replaces telemetry-bridge.ts (which imported Tauri invoke directly)
|
||||
|
||||
import { getBackend } from '../backend/backend';
|
||||
|
||||
/** Convenience wrappers for structured logging */
|
||||
export const tel = {
|
||||
error: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
try { getBackend().telemetryLog('error', msg, ctx); } catch { /* backend not ready */ }
|
||||
},
|
||||
warn: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
try { getBackend().telemetryLog('warn', msg, ctx); } catch { /* backend not ready */ }
|
||||
},
|
||||
info: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
try { getBackend().telemetryLog('info', msg, ctx); } catch { /* backend not ready */ }
|
||||
},
|
||||
debug: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
try { getBackend().telemetryLog('debug', msg, ctx); } catch { /* backend not ready */ }
|
||||
},
|
||||
trace: (msg: string, ctx?: Record<string, unknown>) => {
|
||||
try { getBackend().telemetryLog('trace', msg, ctx); } catch { /* backend not ready */ }
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue