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
|
// BackendAdapter — abstraction layer for Tauri and Electrobun backends
|
||||||
|
|
||||||
import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent';
|
import type { AgentStartOptions, AgentMessage, AgentStatus, ProviderId } from './agent';
|
||||||
import type { FileEntry, FileContent, PtyCreateOptions } from './protocol';
|
import type { FileEntry, FileContent, PtyCreateOptions, SearchResult } from './protocol';
|
||||||
import type { SettingsMap } from './settings';
|
import type { SettingsMap } from './settings';
|
||||||
import type { GroupsFile } from './project';
|
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 ─────────────────────────────────────────────────────
|
// ── Backend capabilities ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -31,9 +36,223 @@ export interface BackendCapabilities {
|
||||||
/** Call to remove an event listener */
|
/** Call to remove an event listener */
|
||||||
export type UnsubscribeFn = () => void;
|
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 ────────────────────────────────────────────────
|
// ── 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;
|
readonly capabilities: BackendCapabilities;
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -55,13 +274,13 @@ export interface BackendAdapter {
|
||||||
loadGroups(): Promise<GroupsFile>;
|
loadGroups(): Promise<GroupsFile>;
|
||||||
saveGroups(groups: GroupsFile): Promise<void>;
|
saveGroups(groups: GroupsFile): Promise<void>;
|
||||||
|
|
||||||
// ── Agent ────────────────────────────────────────────────────────────────
|
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>;
|
startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>;
|
||||||
stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>;
|
stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>;
|
sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
|
|
||||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
createPty(options: PtyCreateOptions): Promise<string>;
|
createPty(options: PtyCreateOptions): Promise<string>;
|
||||||
writePty(sessionId: string, data: string): Promise<void>;
|
writePty(sessionId: string, data: string): Promise<void>;
|
||||||
|
|
@ -82,3 +301,273 @@ export interface BackendAdapter {
|
||||||
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn;
|
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn;
|
||||||
onPtyClosed(callback: (sessionId: string, exitCode: number | null) => 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 { OLLAMA_PROVIDER } from './lib/providers/ollama';
|
||||||
import { AIDER_PROVIDER } from './lib/providers/aider';
|
import { AIDER_PROVIDER } from './lib/providers/aider';
|
||||||
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
||||||
import { MemoraAdapter } from './lib/adapters/memora-bridge';
|
import { MemoraAdapter } from './lib/adapters/memora-adapter';
|
||||||
import {
|
import {
|
||||||
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
|
loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
|
||||||
getEnabledProjects, getAllWorkItems, getActiveProjectId,
|
getEnabledProjects, getAllWorkItems, getActiveProjectId,
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
||||||
import { initGlobalErrorHandler } from './lib/utils/global-error-handler';
|
import { initGlobalErrorHandler } from './lib/utils/global-error-handler';
|
||||||
import { handleInfraError } from './lib/utils/handle-error';
|
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';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// Workspace components
|
// Workspace components
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
// Step 2: Agent dispatcher
|
// Step 2: Agent dispatcher
|
||||||
startAgentDispatcher();
|
startAgentDispatcher();
|
||||||
startHealthTick();
|
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);
|
markStep(2);
|
||||||
|
|
||||||
// Disable wake scheduler in test mode to prevent timer interference
|
// 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 { adaptCodexMessage } from './codex-messages';
|
||||||
import { adaptOllamaMessage } from './ollama-messages';
|
import { adaptOllamaMessage } from './ollama-messages';
|
||||||
import { adaptAiderMessage } from './aider-messages';
|
import { adaptAiderMessage } from './aider-messages';
|
||||||
import { tel } from './telemetry-bridge';
|
import { tel } from '../utils/telemetry';
|
||||||
|
|
||||||
/** Function signature for a provider message adapter */
|
/** Function signature for a provider message adapter */
|
||||||
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
|
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(),
|
mockAddNotification: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./adapters/agent-bridge', () => ({
|
vi.mock('./backend/backend', () => ({
|
||||||
onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => {
|
getBackend: vi.fn(() => ({
|
||||||
capturedCallbacks.msg = cb;
|
onSidecarMessage: vi.fn((cb: (msg: any) => void) => {
|
||||||
return mockUnlistenMsg;
|
capturedCallbacks.msg = cb;
|
||||||
}),
|
return mockUnlistenMsg;
|
||||||
onSidecarExited: vi.fn(async (cb: () => void) => {
|
}),
|
||||||
capturedCallbacks.exit = cb;
|
onSidecarExited: vi.fn((cb: () => void) => {
|
||||||
return mockUnlistenExit;
|
capturedCallbacks.exit = cb;
|
||||||
}),
|
return mockUnlistenExit;
|
||||||
restartAgent: (...args: unknown[]) => mockRestartAgent(...args),
|
}),
|
||||||
|
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', () => ({}));
|
vi.mock('./providers/types', () => ({}));
|
||||||
|
|
@ -235,8 +242,9 @@ describe('agent-dispatcher', () => {
|
||||||
await startAgentDispatcher();
|
await startAgentDispatcher();
|
||||||
await startAgentDispatcher(); // second call should be no-op
|
await startAgentDispatcher(); // second call should be no-op
|
||||||
|
|
||||||
const { onSidecarMessage } = await import('./adapters/agent-bridge');
|
// On second call, capturedCallbacks.msg should still be the same (not reset)
|
||||||
expect(onSidecarMessage).toHaveBeenCalledTimes(1);
|
// The dispatcher guards against duplicate registration via `if (unlistenMsg) return;`
|
||||||
|
expect(capturedCallbacks.msg).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets sidecarAlive to true on start', async () => {
|
it('sets sidecarAlive to true on start', async () => {
|
||||||
|
|
@ -408,8 +416,8 @@ describe('agent-dispatcher', () => {
|
||||||
stopAgentDispatcher();
|
stopAgentDispatcher();
|
||||||
await startAgentDispatcher();
|
await startAgentDispatcher();
|
||||||
|
|
||||||
const { onSidecarMessage } = await import('./adapters/agent-bridge');
|
// After stop + restart, callbacks should be re-registered
|
||||||
expect(onSidecarMessage).toHaveBeenCalledTimes(2);
|
expect(capturedCallbacks.msg).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
// Thin coordinator that routes sidecar messages to specialized modules
|
// Thin coordinator that routes sidecar messages to specialized modules
|
||||||
|
|
||||||
import { SessionId, type SessionId as SessionIdType } from './types/ids';
|
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 { adaptMessage } from './adapters/message-adapters';
|
||||||
import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
|
import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,7 +17,7 @@ import {
|
||||||
} from './stores/agents.svelte';
|
} from './stores/agents.svelte';
|
||||||
import { notify, addNotification } from './stores/notifications.svelte';
|
import { notify, addNotification } from './stores/notifications.svelte';
|
||||||
import { classifyError } from './utils/error-classifier';
|
import { classifyError } from './utils/error-classifier';
|
||||||
import { tel } from './adapters/telemetry-bridge';
|
import { tel } from './utils/telemetry';
|
||||||
import { handleInfraError } from './utils/handle-error';
|
import { handleInfraError } from './utils/handle-error';
|
||||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
||||||
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
|
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
|
||||||
|
|
@ -37,16 +38,13 @@ import {
|
||||||
spawnSubagentPane,
|
spawnSubagentPane,
|
||||||
clearSubagentRoutes,
|
clearSubagentRoutes,
|
||||||
} from './utils/subagent-router';
|
} 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';
|
import type { AgentId } from './types/ids';
|
||||||
|
|
||||||
/** Sync btmsg agent status to 'stopped' when a session reaches terminal state */
|
/** Sync btmsg agent status to 'stopped' when a session reaches terminal state */
|
||||||
function syncBtmsgStopped(sessionId: SessionIdType): void {
|
function syncBtmsgStopped(sessionId: SessionIdType): void {
|
||||||
const projectId = getSessionProjectId(sessionId);
|
const projectId = getSessionProjectId(sessionId);
|
||||||
if (projectId) {
|
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;
|
sidecarAlive = true;
|
||||||
|
|
||||||
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => {
|
unlistenMsg = getBackend().onSidecarMessage((msg: SidecarMessagePayload) => {
|
||||||
sidecarAlive = true;
|
sidecarAlive = true;
|
||||||
// Reset restart counter on any successful message — sidecar recovered
|
// Reset restart counter on any successful message — sidecar recovered
|
||||||
if (restartAttempts > 0) {
|
if (restartAttempts > 0) {
|
||||||
|
|
@ -89,7 +87,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
// Record heartbeat on any agent activity (best-effort, fire-and-forget)
|
// Record heartbeat on any agent activity (best-effort, fire-and-forget)
|
||||||
const hbProjectId = getSessionProjectId(sessionId);
|
const hbProjectId = getSessionProjectId(sessionId);
|
||||||
if (hbProjectId) {
|
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) {
|
switch (msg.type) {
|
||||||
|
|
@ -98,7 +96,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
recordSessionStart(sessionId);
|
recordSessionStart(sessionId);
|
||||||
tel.info('agent_started', { sessionId });
|
tel.info('agent_started', { sessionId });
|
||||||
if (hbProjectId) {
|
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;
|
break;
|
||||||
|
|
||||||
|
|
@ -113,7 +111,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
|
notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
|
||||||
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
|
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
|
||||||
if (hbProjectId) {
|
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;
|
break;
|
||||||
|
|
||||||
|
|
@ -141,7 +139,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
|
|
||||||
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
|
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
|
||||||
if (hbProjectId) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +149,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
unlistenExit = await onSidecarExited(async () => {
|
unlistenExit = getBackend().onSidecarExited(async () => {
|
||||||
sidecarAlive = false;
|
sidecarAlive = false;
|
||||||
tel.error('sidecar_crashed', { restartAttempts });
|
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');
|
addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system');
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
try {
|
try {
|
||||||
await restartAgent();
|
await getBackend().restartAgentSidecar();
|
||||||
sidecarAlive = true;
|
sidecarAlive = true;
|
||||||
// Note: restartAttempts is reset when next sidecar message arrives
|
// Note: restartAttempts is reset when next sidecar message arrives
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -334,7 +332,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
|
||||||
// Index searchable text content into FTS5 search database
|
// Index searchable text content into FTS5 search database
|
||||||
for (const msg of mainMessages) {
|
for (const msg of mainMessages) {
|
||||||
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
|
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)
|
// Wraps the Electrobun RPC pattern (request/response + message listeners)
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BackendAdapter,
|
BackendAdapter, BackendCapabilities, UnsubscribeFn,
|
||||||
BackendCapabilities,
|
AgentStartOptions, AgentMessage, AgentStatus,
|
||||||
UnsubscribeFn,
|
FileEntry, FileContent, PtyCreateOptions,
|
||||||
AgentStartOptions,
|
SettingsMap, GroupsFile, GroupConfig, GroupId, AgentId, SessionId, ProjectId,
|
||||||
AgentMessage,
|
SearchResult,
|
||||||
AgentStatus,
|
PersistedSession, PersistedLayout,
|
||||||
FileEntry,
|
AgentMessageRecord, ProjectAgentState, SessionMetricRecord, MdFileEntry,
|
||||||
FileContent,
|
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, BtmsgFeedMessage,
|
||||||
PtyCreateOptions,
|
DeadLetterEntry, AuditEntry, AuditEventType,
|
||||||
SettingsMap,
|
Task, TaskComment,
|
||||||
GroupsFile,
|
SessionAnchorRecord,
|
||||||
GroupConfig,
|
NotificationUrgency, TelemetryLevel,
|
||||||
GroupId,
|
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';
|
} from '@agor/types';
|
||||||
|
|
||||||
// ── Capabilities ─────────────────────────────────────────────────────────────
|
// ── Capabilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ELECTROBUN_CAPABILITIES: BackendCapabilities = {
|
const ELECTROBUN_CAPABILITIES: BackendCapabilities = {
|
||||||
supportsPtyMultiplexing: true, // via agor-ptyd daemon
|
supportsPtyMultiplexing: true,
|
||||||
supportsPluginSandbox: true, // Web Worker sandbox works in any webview
|
supportsPluginSandbox: true,
|
||||||
supportsNativeMenus: false, // Electrobun has limited menu support
|
supportsNativeMenus: false,
|
||||||
supportsOsKeychain: false, // No keyring crate — uses file-based secrets
|
supportsOsKeychain: false,
|
||||||
supportsFileDialogs: false, // No native dialog plugin
|
supportsFileDialogs: false,
|
||||||
supportsAutoUpdater: false, // Custom updater via GitHub Releases check
|
supportsAutoUpdater: false,
|
||||||
supportsDesktopNotifications: false, // No notify-rust — uses in-app toasts only
|
supportsDesktopNotifications: false,
|
||||||
supportsTelemetry: false, // No OTLP export in Electrobun backend
|
supportsTelemetry: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ──────────────
|
// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ──────────────
|
||||||
|
|
@ -67,7 +75,6 @@ export class ElectrobunAdapter implements BackendAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
// Remove all registered message listeners
|
|
||||||
if (this.rpc?.removeMessageListener) {
|
if (this.rpc?.removeMessageListener) {
|
||||||
for (const { event, handler } of this.messageHandlers) {
|
for (const { event, handler } of this.messageHandlers) {
|
||||||
this.rpc.removeMessageListener(event, handler);
|
this.rpc.removeMessageListener(event, handler);
|
||||||
|
|
@ -96,12 +103,10 @@ export class ElectrobunAdapter implements BackendAdapter {
|
||||||
// ── Groups ───────────────────────────────────────────────────────────────
|
// ── Groups ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async loadGroups(): Promise<GroupsFile> {
|
async loadGroups(): Promise<GroupsFile> {
|
||||||
// Electrobun stores groups differently — reconstruct GroupsFile from flat list
|
|
||||||
const res = await this.r.request['groups.list']({}) as {
|
const res = await this.r.request['groups.list']({}) as {
|
||||||
groups: Array<{ id: string; name: string; icon: string; position: number }>;
|
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 {
|
const projectsRes = await this.r.request['settings.getProjects']({}) as {
|
||||||
projects: Array<{ id: string; config: string }>;
|
projects: Array<{ id: string; config: string }>;
|
||||||
};
|
};
|
||||||
|
|
@ -130,42 +135,29 @@ export class ElectrobunAdapter implements BackendAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveGroups(groupsFile: GroupsFile): Promise<void> {
|
async saveGroups(groupsFile: GroupsFile): Promise<void> {
|
||||||
// Save groups list
|
|
||||||
for (const group of groupsFile.groups) {
|
for (const group of groupsFile.groups) {
|
||||||
await this.r.request['groups.create']({
|
await this.r.request['groups.create']({
|
||||||
id: group.id,
|
id: group.id, name: group.name, icon: '', position: groupsFile.groups.indexOf(group),
|
||||||
name: group.name,
|
|
||||||
icon: '',
|
|
||||||
position: groupsFile.groups.indexOf(group),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save projects per group
|
|
||||||
for (const group of groupsFile.groups) {
|
for (const group of groupsFile.groups) {
|
||||||
for (const project of group.projects) {
|
for (const project of group.projects) {
|
||||||
await this.r.request['settings.setProject']({
|
await this.r.request['settings.setProject']({
|
||||||
id: project.id,
|
id: project.id, config: JSON.stringify({ ...project, groupId: group.id }),
|
||||||
config: JSON.stringify({ ...project, groupId: group.id }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent ────────────────────────────────────────────────────────────────
|
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
||||||
const res = await this.r.request['agent.start']({
|
const res = await this.r.request['agent.start']({
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId, provider: options.provider ?? 'claude',
|
||||||
provider: options.provider ?? 'claude',
|
prompt: options.prompt, cwd: options.cwd, model: options.model,
|
||||||
prompt: options.prompt,
|
systemPrompt: options.systemPrompt, maxTurns: options.maxTurns,
|
||||||
cwd: options.cwd,
|
permissionMode: options.permissionMode, claudeConfigDir: options.claudeConfigDir,
|
||||||
model: options.model,
|
extraEnv: options.extraEnv, additionalDirectories: options.additionalDirectories,
|
||||||
systemPrompt: options.systemPrompt,
|
|
||||||
maxTurns: options.maxTurns,
|
|
||||||
permissionMode: options.permissionMode,
|
|
||||||
claudeConfigDir: options.claudeConfigDir,
|
|
||||||
extraEnv: options.extraEnv,
|
|
||||||
additionalDirectories: options.additionalDirectories,
|
|
||||||
worktreeName: options.worktreeName,
|
worktreeName: options.worktreeName,
|
||||||
}) as { ok: boolean; error?: string };
|
}) as { ok: boolean; error?: string };
|
||||||
return res;
|
return res;
|
||||||
|
|
@ -181,16 +173,12 @@ export class ElectrobunAdapter implements BackendAdapter {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async createPty(options: PtyCreateOptions): Promise<string> {
|
async createPty(options: PtyCreateOptions): Promise<string> {
|
||||||
const res = await this.r.request['pty.create']({
|
const res = await this.r.request['pty.create']({
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId, cols: options.cols, rows: options.rows,
|
||||||
cols: options.cols,
|
cwd: options.cwd, shell: options.shell, args: options.args,
|
||||||
rows: options.rows,
|
|
||||||
cwd: options.cwd,
|
|
||||||
shell: options.shell,
|
|
||||||
args: options.args,
|
|
||||||
}) as { ok: boolean; error?: string };
|
}) as { ok: boolean; error?: string };
|
||||||
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
|
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
|
||||||
return options.sessionId;
|
return options.sessionId;
|
||||||
|
|
@ -212,29 +200,20 @@ export class ElectrobunAdapter implements BackendAdapter {
|
||||||
|
|
||||||
async listDirectory(path: string): Promise<FileEntry[]> {
|
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||||
const res = await this.r.request['files.list']({ path }) as {
|
const res = await this.r.request['files.list']({ path }) as {
|
||||||
entries: Array<{ name: string; type: string; size: number }>;
|
entries: Array<{ name: string; type: string; size: number }>; error?: string;
|
||||||
error?: string;
|
|
||||||
};
|
};
|
||||||
if (res.error) throw new Error(res.error);
|
if (res.error) throw new Error(res.error);
|
||||||
return res.entries.map((e) => ({
|
return res.entries.map((e) => ({
|
||||||
name: e.name,
|
name: e.name, path: `${path}/${e.name}`, isDir: e.type === 'dir', size: e.size,
|
||||||
path: `${path}/${e.name}`,
|
|
||||||
isDir: e.type === 'dir',
|
|
||||||
size: e.size,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(path: string): Promise<FileContent> {
|
async readFile(path: string): Promise<FileContent> {
|
||||||
const res = await this.r.request['files.read']({ path }) as {
|
const res = await this.r.request['files.read']({ path }) as {
|
||||||
content?: string;
|
content?: string; encoding: string; size: number; error?: string;
|
||||||
encoding: string;
|
|
||||||
size: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
};
|
||||||
if (res.error) throw new Error(res.error);
|
if (res.error) throw new Error(res.error);
|
||||||
if (res.encoding === 'base64') {
|
if (res.encoding === 'base64') return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
|
||||||
return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
|
|
||||||
}
|
|
||||||
return { type: 'Text', content: res.content ?? '' };
|
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');
|
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 ───────────────────────────────────────────────────────────────
|
// ── Events ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Subscribe to an RPC message event; returns unsubscribe function. */
|
/** 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;
|
id: string; type: string; parentId?: string; content: unknown; timestamp: number;
|
||||||
}): AgentMessage | null {
|
}): AgentMessage | null {
|
||||||
const c = raw.content as Record<string, unknown> | undefined;
|
const c = raw.content as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
switch (raw.type) {
|
switch (raw.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return { id: raw.id, role: 'assistant', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp };
|
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 name = String(c?.['name'] ?? 'Tool');
|
||||||
const input = c?.['input'] as Record<string, unknown> | undefined;
|
const input = c?.['input'] as Record<string, unknown> | undefined;
|
||||||
return {
|
return {
|
||||||
id: raw.id, role: 'tool-call',
|
id: raw.id, role: 'tool-call', content: formatToolContent(name, input),
|
||||||
content: formatToolContent(name, input),
|
toolName: name, toolInput: input ? JSON.stringify(input, null, 2) : undefined, timestamp: raw.timestamp,
|
||||||
toolName: name,
|
|
||||||
toolInput: input ? JSON.stringify(input, null, 2) : undefined,
|
|
||||||
timestamp: raw.timestamp,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
const output = c?.['output'];
|
const output = c?.['output'];
|
||||||
return {
|
return {
|
||||||
id: raw.id, role: 'tool-result',
|
id: raw.id, role: 'tool-result',
|
||||||
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
|
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), timestamp: raw.timestamp,
|
||||||
timestamp: raw.timestamp,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'init':
|
case 'init':
|
||||||
return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp };
|
return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp };
|
||||||
case 'error':
|
case 'error':
|
||||||
return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp };
|
return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp };
|
||||||
case 'cost':
|
case 'cost': case 'status': case 'compaction':
|
||||||
case 'status':
|
|
||||||
case 'compaction':
|
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,24 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import type {
|
import type {
|
||||||
BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions,
|
BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions,
|
||||||
AgentMessage, AgentStatus, FileEntry, FileContent, PtyCreateOptions,
|
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';
|
} from '@agor/types';
|
||||||
|
|
||||||
const TAURI_CAPABILITIES: BackendCapabilities = {
|
const TAURI_CAPABILITIES: BackendCapabilities = {
|
||||||
|
|
@ -20,13 +37,6 @@ const TAURI_CAPABILITIES: BackendCapabilities = {
|
||||||
supportsTelemetry: true,
|
supportsTelemetry: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SidecarMessage {
|
|
||||||
type: string;
|
|
||||||
sessionId?: string;
|
|
||||||
event?: Record<string, unknown>;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TauriDirEntry {
|
interface TauriDirEntry {
|
||||||
name: string; path: string; is_dir: boolean; size: number; ext: string;
|
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 });
|
return invoke('groups_save', { config: groups });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent ────────────────────────────────────────────────────────────────
|
// ── Agent (simplified) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
|
||||||
const tauriOpts = {
|
const tauriOpts = {
|
||||||
|
|
@ -118,7 +128,7 @@ export class TauriAdapter implements BackendAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PTY ──────────────────────────────────────────────────────────────────
|
// ── PTY (simplified) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async createPty(options: PtyCreateOptions): Promise<string> {
|
async createPty(options: PtyCreateOptions): Promise<string> {
|
||||||
return invoke<string>('pty_spawn', {
|
return invoke<string>('pty_spawn', {
|
||||||
|
|
@ -155,7 +165,584 @@ export class TauriAdapter implements BackendAdapter {
|
||||||
return invoke('write_file_content', { path, content });
|
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. */
|
/** Subscribe to a Tauri event; tracks unlisten for cleanup. */
|
||||||
private listenTauri<T>(event: string, handler: (payload: T) => void): UnsubscribeFn {
|
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 {
|
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;
|
if (msg.type !== 'message' || !msg.sessionId || !msg.event) return;
|
||||||
const agentMsg: AgentMessage = {
|
const agentMsg: AgentMessage = {
|
||||||
id: String(msg.event['id'] ?? Date.now()),
|
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 {
|
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;
|
if (msg.type !== 'status' || !msg.sessionId) return;
|
||||||
callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message);
|
callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message);
|
||||||
});
|
});
|
||||||
|
|
@ -197,7 +784,7 @@ export class TauriAdapter implements BackendAdapter {
|
||||||
onAgentCost(
|
onAgentCost(
|
||||||
callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void,
|
callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void,
|
||||||
): UnsubscribeFn {
|
): UnsubscribeFn {
|
||||||
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => {
|
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
|
||||||
if (msg.type !== 'cost' || !msg.sessionId || !msg.event) return;
|
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));
|
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 {
|
onPtyOutput(_callback: (sessionId: string, data: string) => void): UnsubscribeFn {
|
||||||
// Tauri PTY uses per-session event channels (pty-data-{id}).
|
// 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 () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { marked, Renderer } from 'marked';
|
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 {
|
import {
|
||||||
getAgentSession,
|
getAgentSession,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
|
@ -12,9 +13,7 @@
|
||||||
import { focusPane } from '../../stores/layout.svelte';
|
import { focusPane } from '../../stores/layout.svelte';
|
||||||
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||||
import { getSessionProjectId } from '../../utils/session-persistence';
|
import { getSessionProjectId } from '../../utils/session-persistence';
|
||||||
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
|
|
||||||
import type { AgentId } from '../../types/ids';
|
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 { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
||||||
import { estimateTokens } from '../../utils/anchor-serializer';
|
import { estimateTokens } from '../../utils/anchor-serializer';
|
||||||
import type { SessionAnchor } from '../../types/anchors';
|
import type { SessionAnchor } from '../../types/anchors';
|
||||||
|
|
@ -146,8 +145,8 @@
|
||||||
await getHighlighter();
|
await getHighlighter();
|
||||||
// Only load profiles/skills for providers that support them
|
// Only load profiles/skills for providers that support them
|
||||||
const [profileList, skillList] = await Promise.all([
|
const [profileList, skillList] = await Promise.all([
|
||||||
capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]),
|
capabilities.hasProfiles ? getBackend().listProfiles().catch(() => []) : Promise.resolve([]),
|
||||||
capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]),
|
capabilities.hasSkills ? getBackend().listSkills().catch(() => []) : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
profiles = profileList;
|
profiles = profileList;
|
||||||
skills = skillList;
|
skills = skillList;
|
||||||
|
|
@ -175,7 +174,7 @@
|
||||||
async function startQuery(text: string, resume = false) {
|
async function startQuery(text: string, resume = false) {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
const ready = await isAgentReady();
|
const ready = await getBackend().isAgentReady();
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
if (!resume) createAgentSession(sessionId, text);
|
if (!resume) createAgentSession(sessionId, text);
|
||||||
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
const { updateAgentStatus } = await import('../../stores/agents.svelte');
|
||||||
|
|
@ -207,7 +206,7 @@
|
||||||
}
|
}
|
||||||
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
|
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
|
||||||
|
|
||||||
await queryAgent({
|
await getBackend().queryAgent({
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
|
|
@ -234,7 +233,7 @@
|
||||||
const skill = skills.find(s => s.name === skillName);
|
const skill = skills.find(s => s.name === skillName);
|
||||||
if (!skill) return text;
|
if (!skill) return text;
|
||||||
try {
|
try {
|
||||||
const content = await readSkill(skill.source_path);
|
const content = await getBackend().readSkill(skill.source_path);
|
||||||
const args = text.slice(1 + skillName.length).trim();
|
const args = text.slice(1 + skillName.length).trim();
|
||||||
return args ? `${content}\n\nUser input: ${args}` : content;
|
return args ? `${content}\n\nUser input: ${args}` : content;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -267,14 +266,14 @@
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
updateAgentStatus(sessionId, 'done');
|
updateAgentStatus(sessionId, 'done');
|
||||||
const projId = getSessionProjectId(sessionId);
|
const projId = getSessionProjectId(sessionId);
|
||||||
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
if (projId) getBackend().btmsgSetStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||||
stopAgent(sessionId).catch(() => {});
|
getBackend().stopAgentDirect(sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestart() {
|
async function handleRestart() {
|
||||||
restarting = true;
|
restarting = true;
|
||||||
try {
|
try {
|
||||||
await restartAgent();
|
await getBackend().restartAgentSidecar();
|
||||||
setSidecarAlive(true);
|
setSidecarAlive(true);
|
||||||
} catch {
|
} catch {
|
||||||
// Still dead
|
// Still dead
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import { getBackend } from '../../backend/backend';
|
||||||
ctxInitDb,
|
import type { CtxEntry, CtxSummary } from '@agor/types';
|
||||||
ctxRegisterProject,
|
|
||||||
ctxGetContext,
|
|
||||||
ctxGetShared,
|
|
||||||
ctxGetSummaries,
|
|
||||||
ctxSearch,
|
|
||||||
type CtxEntry,
|
|
||||||
type CtxSummary,
|
|
||||||
} from '../../adapters/ctx-bridge';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
|
@ -32,12 +24,12 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
// Register project if not already (INSERT OR IGNORE)
|
// 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([
|
const [ctx, shared, sums] = await Promise.all([
|
||||||
ctxGetContext(projectName),
|
getBackend().ctxGetContext(projectName),
|
||||||
ctxGetShared(),
|
getBackend().ctxGetShared(),
|
||||||
ctxGetSummaries(projectName, 5),
|
getBackend().ctxGetSummaries(projectName, 5),
|
||||||
]);
|
]);
|
||||||
entries = ctx;
|
entries = ctx;
|
||||||
sharedEntries = shared;
|
sharedEntries = shared;
|
||||||
|
|
@ -55,7 +47,7 @@
|
||||||
async function handleInitDb() {
|
async function handleInitDb() {
|
||||||
initializing = true;
|
initializing = true;
|
||||||
try {
|
try {
|
||||||
await ctxInitDb();
|
await getBackend().ctxInitDb();
|
||||||
await loadProjectContext();
|
await loadProjectContext();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = `Failed to initialize database: ${e}`;
|
error = `Failed to initialize database: ${e}`;
|
||||||
|
|
@ -70,7 +62,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
searchResults = await ctxSearch(searchQuery);
|
searchResults = await getBackend().ctxSearch(searchQuery);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = `Search failed: ${e}`;
|
error = `Search failed: ${e}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { marked, Renderer } from 'marked';
|
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';
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -45,11 +46,11 @@
|
||||||
|
|
||||||
// Unwatch previous file
|
// Unwatch previous file
|
||||||
if (currentWatchPath) {
|
if (currentWatchPath) {
|
||||||
unwatchFile(paneId).catch(() => {});
|
getBackend().ungetBackend().watchFile(paneId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentWatchPath = path;
|
currentWatchPath = path;
|
||||||
watchFile(paneId, path)
|
getBackend().watchFile(paneId, path)
|
||||||
.then(content => renderMarkdown(content))
|
.then(content => renderMarkdown(content))
|
||||||
.catch(e => { error = `Failed to open file: ${e}`; });
|
.catch(e => { error = `Failed to open file: ${e}`; });
|
||||||
});
|
});
|
||||||
|
|
@ -59,7 +60,7 @@
|
||||||
await getHighlighter();
|
await getHighlighter();
|
||||||
highlighterReady = true;
|
highlighterReady = true;
|
||||||
|
|
||||||
unlisten = await onFileChanged((payload: FileChangedPayload) => {
|
unlisten = getBackend().onFileChanged((payload: FileChangedPayload) => {
|
||||||
if (payload.pane_id === paneId) {
|
if (payload.pane_id === paneId) {
|
||||||
renderMarkdown(payload.content);
|
renderMarkdown(payload.content);
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +72,7 @@
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
unwatchFile(paneId).catch(() => {});
|
getBackend().ungetBackend().watchFile(paneId).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleLinkClick(event: MouseEvent) {
|
function handleLinkClick(event: MouseEvent) {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
|
||||||
import { getTotalConflictCount } from '../../stores/conflicts.svelte';
|
import { getTotalConflictCount } from '../../stores/conflicts.svelte';
|
||||||
import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte';
|
import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte';
|
||||||
import { stopAgent } from '../../adapters/agent-bridge';
|
import { getBackend } from '../../backend/backend';
|
||||||
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
|
|
||||||
import { getSessionProjectId } from '../../utils/session-persistence';
|
import { getSessionProjectId } from '../../utils/session-persistence';
|
||||||
import type { AgentId } from '../../types/ids';
|
import type { AgentId } from '../../types/ids';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
@ -42,9 +41,9 @@
|
||||||
for (const s of active) {
|
for (const s of active) {
|
||||||
updateAgentStatus(s.id, 'done');
|
updateAgentStatus(s.id, 'done');
|
||||||
const projId = getSessionProjectId(s.id);
|
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 {
|
} finally {
|
||||||
stopping = false;
|
stopping = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
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 { 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';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -21,8 +21,8 @@
|
||||||
let term: Terminal;
|
let term: Terminal;
|
||||||
let fitAddon: FitAddon;
|
let fitAddon: FitAddon;
|
||||||
let ptyId: string | null = null;
|
let ptyId: string | null = null;
|
||||||
let unlistenData: UnlistenFn | null = null;
|
let unlistenData: UnsubscribeFn | null = null;
|
||||||
let unlistenExit: UnlistenFn | null = null;
|
let unlistenExit: UnsubscribeFn | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let unsubTheme: (() => void) | null = null;
|
let unsubTheme: (() => void) | null = null;
|
||||||
|
|
||||||
|
|
@ -48,14 +48,14 @@
|
||||||
|
|
||||||
// Spawn PTY
|
// Spawn PTY
|
||||||
try {
|
try {
|
||||||
ptyId = await spawnPty({ shell, cwd, args, cols, rows });
|
ptyId = await getBackend().spawnPty({ shell, cwd, args, cols, rows });
|
||||||
|
|
||||||
// Listen for PTY output
|
// Listen for PTY output
|
||||||
unlistenData = await onPtyData(ptyId, (data) => {
|
unlistenData = getBackend().onPtyData(ptyId, (data) => {
|
||||||
term.write(data);
|
term.write(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
unlistenExit = await onPtyExit(ptyId, () => {
|
unlistenExit = getBackend().onPtyExit(ptyId, () => {
|
||||||
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||||
onExit?.();
|
onExit?.();
|
||||||
});
|
});
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
}
|
}
|
||||||
if (e.key === 'V') {
|
if (e.key === 'V') {
|
||||||
navigator.clipboard.readText().then(text => {
|
navigator.clipboard.readText().then(text => {
|
||||||
if (text && ptyId) writePty(ptyId, text);
|
if (text && ptyId) getBackend().writePtyDirect(ptyId, text);
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
|
|
||||||
// Forward keyboard input to PTY
|
// Forward keyboard input to PTY
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (ptyId) writePty(ptyId, data);
|
if (ptyId) getBackend().writePtyDirect(ptyId, data);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
|
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (ptyId) {
|
if (ptyId) {
|
||||||
const { cols, rows } = term;
|
const { cols, rows } = term;
|
||||||
resizePty(ptyId, cols, rows);
|
getBackend().resizePtyDirect(ptyId, cols, rows);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
unlistenData?.();
|
unlistenData?.();
|
||||||
unlistenExit?.();
|
unlistenExit?.();
|
||||||
if (ptyId) {
|
if (ptyId) {
|
||||||
try { await killPty(ptyId); } catch { /* already dead */ }
|
try { await getBackend().killPty(ptyId); } catch { /* already dead */ }
|
||||||
}
|
}
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,11 @@
|
||||||
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
|
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
|
||||||
import { generateAgentPrompt } from '../../utils/agent-prompts';
|
import { generateAgentPrompt } from '../../utils/agent-prompts';
|
||||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||||
import { logAuditEvent } from '../../adapters/audit-bridge';
|
import { getBackend } from '../../backend/backend';
|
||||||
import type { AgentId } from '../../types/ids';
|
import type { AgentId } from '../../types/ids';
|
||||||
import {
|
import type { ProjectAgentState, AgentMessageRecord } from '@agor/types';
|
||||||
loadProjectAgentState,
|
|
||||||
loadAgentMessages,
|
|
||||||
type ProjectAgentState,
|
|
||||||
type AgentMessageRecord,
|
|
||||||
} from '../../adapters/groups-bridge';
|
|
||||||
import { registerSessionProject } from '../../agent-dispatcher';
|
import { registerSessionProject } from '../../agent-dispatcher';
|
||||||
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
|
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
|
||||||
import { stopAgent } from '../../adapters/agent-bridge';
|
|
||||||
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
|
@ -26,8 +20,6 @@
|
||||||
import type { AgentMessage } from '../../adapters/claude-messages';
|
import type { AgentMessage } from '../../adapters/claude-messages';
|
||||||
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
||||||
import { loadAnchorsForProject } from '../../stores/anchors.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 { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
|
||||||
import { SessionId, ProjectId } from '../../types/ids';
|
import { SessionId, ProjectId } from '../../types/ids';
|
||||||
import AgentPane from '../Agent/AgentPane.svelte';
|
import AgentPane from '../Agent/AgentPane.svelte';
|
||||||
|
|
@ -95,7 +87,7 @@ bttask comment <task-id> "update" # Add a comment
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (providerId === 'aider') {
|
if (providerId === 'aider') {
|
||||||
getSecret('openrouter_api_key').then(key => {
|
getBackend().getSecret('openrouter_api_key').then(key => {
|
||||||
openrouterKey = key;
|
openrouterKey = key;
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -133,7 +125,7 @@ bttask comment <task-id> "update" # Add a comment
|
||||||
: '[Context Refresh] Review the instructions above and continue your work.';
|
: '[Context Refresh] Review the instructions above and continue your work.';
|
||||||
contextRefreshPrompt = refreshMsg;
|
contextRefreshPrompt = refreshMsg;
|
||||||
// Audit: log prompt injection event
|
// Audit: log prompt injection event
|
||||||
logAuditEvent(
|
getBackend().logAuditEvent(
|
||||||
project.id as unknown as AgentId,
|
project.id as unknown as AgentId,
|
||||||
'prompt_injection',
|
'prompt_injection',
|
||||||
`Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`,
|
`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) => {
|
const unsubAgentStop = onAgentStop((projectId) => {
|
||||||
if (projectId !== project.id) return;
|
if (projectId !== project.id) return;
|
||||||
updateAgentStatus(sessionId, 'done');
|
updateAgentStatus(sessionId, 'done');
|
||||||
setBtmsgAgentStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
|
getBackend().btmsgSetStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
|
||||||
stopAgent(sessionId).catch(() => {});
|
getBackend().stopAgentDirect(sessionId).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// btmsg inbox polling — per-message acknowledgment wake mechanism
|
// btmsg inbox polling — per-message acknowledgment wake mechanism
|
||||||
|
|
@ -173,7 +165,7 @@ bttask comment <task-id> "update" # Add a comment
|
||||||
msgPollTimer = setInterval(async () => {
|
msgPollTimer = setInterval(async () => {
|
||||||
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
|
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
|
||||||
try {
|
try {
|
||||||
const unseen = await getUnseenMessages(
|
const unseen = await getBackend().btmsgUnseenMessages(
|
||||||
project.id as unknown as AgentId,
|
project.id as unknown as AgentId,
|
||||||
sessionId,
|
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"\`.`;
|
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
|
// 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,
|
project.id as unknown as AgentId,
|
||||||
'wake_event',
|
'wake_event',
|
||||||
`Agent woken by ${unseen.length} btmsg message(s)`,
|
`Agent woken by ${unseen.length} btmsg message(s)`,
|
||||||
|
|
@ -277,13 +269,13 @@ bttask comment <task-id> "update" # Add a comment
|
||||||
loading = true;
|
loading = true;
|
||||||
hasRestoredHistory = false;
|
hasRestoredHistory = false;
|
||||||
try {
|
try {
|
||||||
const state = await loadProjectAgentState(projectId);
|
const state = await getBackend().loadProjectAgentState(projectId);
|
||||||
lastState = state;
|
lastState = state;
|
||||||
if (state?.last_session_id) {
|
if (state?.last_session_id) {
|
||||||
sessionId = SessionId(state.last_session_id);
|
sessionId = SessionId(state.last_session_id);
|
||||||
|
|
||||||
// Restore cached messages into the agent store
|
// Restore cached messages into the agent store
|
||||||
const records = await loadAgentMessages(projectId);
|
const records = await getBackend().loadAgentMessages(projectId);
|
||||||
if (records.length > 0) {
|
if (records.length > 0) {
|
||||||
restoreMessagesFromRecords(sessionId, state, records);
|
restoreMessagesFromRecords(sessionId, state, records);
|
||||||
hasRestoredHistory = true;
|
hasRestoredHistory = true;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 {
|
interface Props {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
@ -90,7 +92,7 @@ package "Backend" {
|
||||||
|
|
||||||
async function loadDiagrams() {
|
async function loadDiagrams() {
|
||||||
try {
|
try {
|
||||||
const entries = await listDirectoryChildren(archPath);
|
const entries = await getBackend().listDirectory(archPath);
|
||||||
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
|
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
|
||||||
} catch {
|
} catch {
|
||||||
// Directory might not exist yet
|
// Directory might not exist yet
|
||||||
|
|
@ -108,7 +110,7 @@ package "Backend" {
|
||||||
error = null;
|
error = null;
|
||||||
editing = false;
|
editing = false;
|
||||||
try {
|
try {
|
||||||
const content = await readFileContent(filePath);
|
const content = await getBackend().readFile(filePath);
|
||||||
if (content.type === 'Text') {
|
if (content.type === 'Text') {
|
||||||
pumlSource = content.content;
|
pumlSource = content.content;
|
||||||
renderPlantUml(content.content);
|
renderPlantUml(content.content);
|
||||||
|
|
@ -131,7 +133,7 @@ package "Backend" {
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
try {
|
try {
|
||||||
await writeFileContent(selectedFile, pumlSource);
|
await getBackend().writeFile(selectedFile, pumlSource);
|
||||||
renderPlantUml(pumlSource);
|
renderPlantUml(pumlSource);
|
||||||
editing = false;
|
editing = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -144,7 +146,7 @@ package "Backend" {
|
||||||
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
|
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||||
const filePath = `${archPath}/${fileName}.puml`;
|
const filePath = `${archPath}/${fileName}.puml`;
|
||||||
try {
|
try {
|
||||||
await writeFileContent(filePath, template);
|
await getBackend().writeFile(filePath, template);
|
||||||
showNewForm = false;
|
showNewForm = false;
|
||||||
newName = '';
|
newName = '';
|
||||||
await loadDiagrams();
|
await loadDiagrams();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { getAuditLog, type AuditEntry, type AuditEventType } from '../../adapters/audit-bridge';
|
import { getBackend } from '../../backend/backend';
|
||||||
import { getGroupAgents, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
import type { AuditEntry, AuditEventType, BtmsgAgent } from '@agor/types';
|
||||||
import type { GroupId, AgentId } from '../../types/ids';
|
import type { GroupId, AgentId } from '../../types/ids';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -88,8 +88,8 @@
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const [auditData, agentData] = await Promise.all([
|
const [auditData, agentData] = await Promise.all([
|
||||||
getAuditLog(groupId, 200, 0),
|
getBackend().getAuditLog(groupId, 200, 0),
|
||||||
getGroupAgents(groupId),
|
getBackend().btmsgGetAgents(groupId),
|
||||||
]);
|
]);
|
||||||
entries = auditData;
|
entries = auditData;
|
||||||
agents = agentData;
|
agents = agentData;
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,11 @@
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||||
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
|
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
|
||||||
import {
|
import { getBackend } from '../../backend/backend';
|
||||||
type BtmsgAgent,
|
import type {
|
||||||
type BtmsgMessage,
|
BtmsgAgent, BtmsgMessage, BtmsgFeedMessage,
|
||||||
type BtmsgFeedMessage,
|
BtmsgChannel, BtmsgChannelMessage,
|
||||||
type BtmsgChannel,
|
} from '@agor/types';
|
||||||
type BtmsgChannelMessage,
|
|
||||||
getGroupAgents,
|
|
||||||
getHistory,
|
|
||||||
getAllFeed,
|
|
||||||
sendMessage,
|
|
||||||
markRead,
|
|
||||||
ensureAdmin,
|
|
||||||
getChannels,
|
|
||||||
getChannelMessages,
|
|
||||||
sendChannelMessage,
|
|
||||||
createChannel,
|
|
||||||
setAgentStatus,
|
|
||||||
clearAllComms,
|
|
||||||
} from '../../adapters/btmsg-bridge';
|
|
||||||
|
|
||||||
const ADMIN_ID = 'admin';
|
const ADMIN_ID = 'admin';
|
||||||
const ROLE_ICONS: Record<string, string> = {
|
const ROLE_ICONS: Record<string, string> = {
|
||||||
|
|
@ -55,8 +41,8 @@
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
try {
|
try {
|
||||||
agents = await getGroupAgents(groupId);
|
agents = await getBackend().btmsgGetAgents(groupId);
|
||||||
channels = await getChannels(groupId);
|
channels = await getBackend().btmsgGetChannels(groupId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[CommsTab] loadData failed:', e);
|
console.error('[CommsTab] loadData failed:', e);
|
||||||
}
|
}
|
||||||
|
|
@ -66,12 +52,12 @@
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
try {
|
try {
|
||||||
if (currentView.type === 'feed') {
|
if (currentView.type === 'feed') {
|
||||||
feedMessages = await getAllFeed(groupId, 100);
|
feedMessages = await getBackend().btmsgAllFeed(groupId, 100);
|
||||||
} else if (currentView.type === 'dm') {
|
} else if (currentView.type === 'dm') {
|
||||||
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100);
|
dmMessages = await getBackend().btmsgHistory(ADMIN_ID, currentView.agentId, 100);
|
||||||
await markRead(ADMIN_ID, currentView.agentId);
|
await getBackend().btmsgMarkRead(ADMIN_ID, currentView.agentId);
|
||||||
} else if (currentView.type === 'channel') {
|
} else if (currentView.type === 'channel') {
|
||||||
channelMessages = await getChannelMessages(currentView.channelId, 100);
|
channelMessages = await getBackend().btmsgChannelMessages(currentView.channelId, 100);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[CommsTab] loadMessages failed:', e);
|
console.error('[CommsTab] loadMessages failed:', e);
|
||||||
|
|
@ -95,7 +81,7 @@
|
||||||
void groupId;
|
void groupId;
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
console.log('[CommsTab] groupId:', 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();
|
loadData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -129,16 +115,16 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentView.type === 'dm') {
|
if (currentView.type === 'dm') {
|
||||||
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
await getBackend().btmsgSend(ADMIN_ID, currentView.agentId, text);
|
||||||
// Auto-wake agent if stopped
|
// Auto-wake agent if stopped
|
||||||
const recipient = agents.find(a => a.id === currentView.agentId);
|
const recipient = agents.find(a => a.id === currentView.agentId);
|
||||||
if (recipient && recipient.status !== 'active') {
|
if (recipient && recipient.status !== 'active') {
|
||||||
await setAgentStatus(currentView.agentId, 'active');
|
await getBackend().btmsgSetStatus(currentView.agentId, 'active');
|
||||||
emitAgentStart(currentView.agentId);
|
emitAgentStart(currentView.agentId);
|
||||||
await pollBtmsg();
|
await pollBtmsg();
|
||||||
}
|
}
|
||||||
} else if (currentView.type === 'channel') {
|
} else if (currentView.type === 'channel') {
|
||||||
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
await getBackend().btmsgChannelSend(currentView.channelId, ADMIN_ID, text);
|
||||||
} else {
|
} else {
|
||||||
return; // Can't send in feed view
|
return; // Can't send in feed view
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +152,7 @@
|
||||||
const name = newChannelName.trim();
|
const name = newChannelName.trim();
|
||||||
if (!name || !groupId) return;
|
if (!name || !groupId) return;
|
||||||
try {
|
try {
|
||||||
await createChannel(name, groupId, ADMIN_ID);
|
await getBackend().btmsgCreateChannel(name, groupId, ADMIN_ID);
|
||||||
newChannelName = '';
|
newChannelName = '';
|
||||||
showNewChannel = false;
|
showNewChannel = false;
|
||||||
await loadData();
|
await loadData();
|
||||||
|
|
@ -206,7 +192,7 @@
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
clearing = true;
|
clearing = true;
|
||||||
try {
|
try {
|
||||||
await clearAllComms(groupId);
|
await getBackend().btmsgClearAllComms(groupId);
|
||||||
feedMessages = [];
|
feedMessages = [];
|
||||||
dmMessages = [];
|
dmMessages = [];
|
||||||
channelMessages = [];
|
channelMessages = [];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
|
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';
|
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||||
|
|
||||||
let files = $state<MdFileEntry[]>([]);
|
let files = $state<MdFileEntry[]>([]);
|
||||||
|
|
@ -26,7 +27,7 @@
|
||||||
async function loadFiles(cwd: string) {
|
async function loadFiles(cwd: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
files = await discoverMarkdownFiles(cwd);
|
files = await getBackend().discoverMarkdownFiles(cwd);
|
||||||
// Auto-select first priority file
|
// Auto-select first priority file
|
||||||
const priority = files.find(f => f.priority);
|
const priority = files.find(f => f.priority);
|
||||||
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script lang="ts">
|
<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 { getSetting } from '../../stores/settings-store.svelte';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import CodeEditor from './CodeEditor.svelte';
|
import CodeEditor from './CodeEditor.svelte';
|
||||||
|
|
@ -61,7 +63,7 @@
|
||||||
|
|
||||||
async function loadDirectory(path: string): Promise<DirEntry[]> {
|
async function loadDirectory(path: string): Promise<DirEntry[]> {
|
||||||
try {
|
try {
|
||||||
return await listDirectoryChildren(path);
|
return await getBackend().listDirectory(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to list directory:', e);
|
console.warn('Failed to list directory:', e);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -125,7 +127,7 @@
|
||||||
// Load content — must look up from reactive array, not local reference
|
// Load content — must look up from reactive array, not local reference
|
||||||
fileLoading = true;
|
fileLoading = true;
|
||||||
try {
|
try {
|
||||||
const content = await readFileContent(node.path);
|
const content = await getBackend().readFile(node.path);
|
||||||
const target = fileTabs.find(t => t.path === node.path);
|
const target = fileTabs.find(t => t.path === node.path);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.content = content;
|
target.content = content;
|
||||||
|
|
@ -229,7 +231,7 @@
|
||||||
async function saveTab(tab: FileTab) {
|
async function saveTab(tab: FileTab) {
|
||||||
if (!tab.dirty || tab.content?.type !== 'Text') return;
|
if (!tab.dirty || tab.content?.type !== 'Text') return;
|
||||||
try {
|
try {
|
||||||
await writeFileContent(tab.path, tab.editContent);
|
await getBackend().writeFile(tab.path, tab.editContent);
|
||||||
// Update the saved content reference
|
// Update the saved content reference
|
||||||
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
|
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
|
||||||
tab.dirty = false;
|
tab.dirty = false;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
|
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
|
||||||
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
|
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';
|
import type { AgentId } from '../../types/ids';
|
||||||
|
|
||||||
/** Runtime agent status from btmsg database */
|
/** Runtime agent status from btmsg database */
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
async function pollBtmsg() {
|
async function pollBtmsg() {
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
try {
|
try {
|
||||||
btmsgAgents = await getGroupAgents(group.id);
|
btmsgAgents = await getBackend().btmsgGetAgents(group.id);
|
||||||
} catch {
|
} catch {
|
||||||
// btmsg.db might not exist yet
|
// btmsg.db might not exist yet
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +62,7 @@
|
||||||
const current = getStatus(agent.id);
|
const current = getStatus(agent.id);
|
||||||
const newStatus = current === 'stopped' ? 'active' : 'stopped';
|
const newStatus = current === 'stopped' ? 'active' : 'stopped';
|
||||||
try {
|
try {
|
||||||
await setAgentStatus(agent.id, newStatus);
|
await getBackend().btmsgSetStatus(agent.id, newStatus);
|
||||||
await pollBtmsg(); // Refresh immediately
|
await pollBtmsg(); // Refresh immediately
|
||||||
if (newStatus === 'active') {
|
if (newStatus === 'active') {
|
||||||
emitAgentStart(agent.id);
|
emitAgentStart(agent.id);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
|
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
|
||||||
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
|
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
|
||||||
import { getAgentSession } from '../../stores/agents.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';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -51,7 +52,7 @@
|
||||||
async function fetchTaskCounts() {
|
async function fetchTaskCounts() {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
try {
|
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 };
|
const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 };
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (counts[t.status] !== undefined) counts[t.status]++;
|
if (counts[t.status] !== undefined) counts[t.status]++;
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@
|
||||||
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
|
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
|
||||||
} from '../../stores/workspace.svelte';
|
} from '../../stores/workspace.svelte';
|
||||||
import { getProjectHealth, setStallThreshold } from '../../stores/health.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 { recordExternalWrite } from '../../stores/conflicts.svelte';
|
||||||
import { ProjectId, type AgentId, type GroupId } from '../../types/ids';
|
import { ProjectId, type AgentId, type GroupId } from '../../types/ids';
|
||||||
import { notify, dismissNotification } from '../../stores/notifications.svelte';
|
import { notify, dismissNotification } from '../../stores/notifications.svelte';
|
||||||
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
|
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
|
||||||
import { setReviewQueueDepth } from '../../stores/health.svelte';
|
import { setReviewQueueDepth } from '../../stores/health.svelte';
|
||||||
import { reviewQueueCount } from '../../adapters/bttask-bridge';
|
|
||||||
import { getStaleAgents } from '../../adapters/btmsg-bridge';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: ProjectConfig;
|
project: ProjectConfig;
|
||||||
|
|
@ -155,7 +153,7 @@
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
|
|
||||||
const pollReviewQueue = () => {
|
const pollReviewQueue = () => {
|
||||||
reviewQueueCount(groupId)
|
getBackend().bttaskReviewQueueCount(groupId)
|
||||||
.then(count => setReviewQueueDepth(project.id, count))
|
.then(count => setReviewQueueDepth(project.id, count))
|
||||||
.catch(() => {}); // best-effort
|
.catch(() => {}); // best-effort
|
||||||
};
|
};
|
||||||
|
|
@ -173,11 +171,11 @@
|
||||||
|
|
||||||
const pollHeartbeat = () => {
|
const pollHeartbeat = () => {
|
||||||
// 300s = healthy threshold, 600s = dead threshold
|
// 300s = healthy threshold, 600s = dead threshold
|
||||||
getStaleAgents(groupId as unknown as GroupId, 300)
|
getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 300)
|
||||||
.then(staleIds => {
|
.then(staleIds => {
|
||||||
if (staleIds.includes(project.id)) {
|
if (staleIds.includes(project.id)) {
|
||||||
// Check if truly dead (>10 min)
|
// Check if truly dead (>10 min)
|
||||||
getStaleAgents(groupId as unknown as GroupId, 600)
|
getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 600)
|
||||||
.then(deadIds => {
|
.then(deadIds => {
|
||||||
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
|
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
|
||||||
})
|
})
|
||||||
|
|
@ -207,8 +205,8 @@
|
||||||
scanToastId = notify('info', 'Scanning project directories…');
|
scanToastId = notify('info', 'Scanning project directories…');
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
fsWatchProject(projectId, cwd)
|
getBackend().fsWatchProject(projectId, cwd)
|
||||||
.then(() => fsWatcherStatus())
|
.then(() => getBackend().fsWatcherStatus())
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
clearTimeout(scanTimer);
|
clearTimeout(scanTimer);
|
||||||
if (scanToastId) dismissNotification(scanToastId);
|
if (scanToastId) dismissNotification(scanToastId);
|
||||||
|
|
@ -224,7 +222,7 @@
|
||||||
|
|
||||||
// Listen for fs write events (filter to this project)
|
// Listen for fs write events (filter to this project)
|
||||||
let unlisten: (() => void) | null = null;
|
let unlisten: (() => void) | null = null;
|
||||||
onFsWriteDetected((event) => {
|
getBackend().onFsWriteDetected((event) => {
|
||||||
if (event.project_id !== projectId) return;
|
if (event.project_id !== projectId) return;
|
||||||
const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms);
|
const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
|
|
@ -235,7 +233,7 @@
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup: stop watching on unmount or project change
|
// Cleanup: stop watching on unmount or project change
|
||||||
fsUnwatchProject(projectId).catch(() => {});
|
getBackend().fsUnwatchProject(projectId).catch(() => {});
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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';
|
import MarkdownPane from '../Markdown/MarkdownPane.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -31,7 +32,7 @@
|
||||||
async function loadFiles(dir: string) {
|
async function loadFiles(dir: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
files = await discoverMarkdownFiles(dir);
|
files = await getBackend().discoverMarkdownFiles(dir);
|
||||||
const priority = files.find(f => f.priority);
|
const priority = files.find(f => f.priority);
|
||||||
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
selectedPath = priority?.path ?? files[0]?.path ?? null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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';
|
import { setActiveProject } from '../../stores/workspace.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -64,7 +65,7 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
debounceTimer = setTimeout(async () => {
|
debounceTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
results = await searchAll(query, 30);
|
results = await getBackend().searchAll(query, 30);
|
||||||
} catch {
|
} catch {
|
||||||
results = [];
|
results = [];
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,20 @@
|
||||||
import { getSetting, setSetting } from '../../stores/settings-store.svelte';
|
import { getSetting, setSetting } from '../../stores/settings-store.svelte';
|
||||||
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
|
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
|
||||||
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { getProviders } from '../../providers/registry.svelte';
|
import { getProviders } from '../../providers/registry.svelte';
|
||||||
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
import type { ProviderId, ProviderSettings } from '../../providers/types';
|
||||||
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
|
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 { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake';
|
||||||
import {
|
const SECRET_KEY_LABELS: Record<string, string> = {
|
||||||
storeSecret, getSecret, deleteSecret, listSecrets,
|
anthropic_api_key: 'Anthropic API Key',
|
||||||
hasKeyring, knownSecretKeys, SECRET_KEY_LABELS,
|
openai_api_key: 'OpenAI API Key',
|
||||||
} from '../../adapters/secrets-bridge';
|
openrouter_api_key: 'OpenRouter API Key',
|
||||||
|
github_token: 'GitHub Token',
|
||||||
|
relay_token: 'Relay Token',
|
||||||
|
};
|
||||||
import {
|
import {
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
getCurrentVersion,
|
getCurrentVersion,
|
||||||
|
|
@ -176,7 +180,7 @@
|
||||||
selectedTheme = getCurrentTheme();
|
selectedTheme = getCurrentTheme();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
profiles = await listProfiles();
|
profiles = await getBackend().listProfiles();
|
||||||
} catch {
|
} catch {
|
||||||
profiles = [];
|
profiles = [];
|
||||||
}
|
}
|
||||||
|
|
@ -191,10 +195,10 @@
|
||||||
|
|
||||||
// Load secrets state
|
// Load secrets state
|
||||||
try {
|
try {
|
||||||
keyringAvailable = await hasKeyring();
|
keyringAvailable = await getBackend().hasKeyring();
|
||||||
if (keyringAvailable) {
|
if (keyringAvailable) {
|
||||||
storedKeys = await listSecrets();
|
storedKeys = await getBackend().listSecrets();
|
||||||
knownKeys = await knownSecretKeys();
|
knownKeys = await getBackend().knownSecretKeys();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
keyringAvailable = false;
|
keyringAvailable = false;
|
||||||
|
|
@ -317,7 +321,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const val = await getSecret(key);
|
const val = await getBackend().getSecret(key);
|
||||||
revealedKey = key;
|
revealedKey = key;
|
||||||
revealedValue = val ?? '';
|
revealedValue = val ?? '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -329,8 +333,8 @@
|
||||||
if (!newSecretKey || !newSecretValue) return;
|
if (!newSecretKey || !newSecretValue) return;
|
||||||
secretsSaving = true;
|
secretsSaving = true;
|
||||||
try {
|
try {
|
||||||
await storeSecret(newSecretKey, newSecretValue);
|
await getBackend().storeSecret(newSecretKey, newSecretValue);
|
||||||
storedKeys = await listSecrets();
|
storedKeys = await getBackend().listSecrets();
|
||||||
newSecretKey = '';
|
newSecretKey = '';
|
||||||
newSecretValue = '';
|
newSecretValue = '';
|
||||||
// If we just saved the currently revealed key, clear reveal
|
// If we just saved the currently revealed key, clear reveal
|
||||||
|
|
@ -345,8 +349,8 @@
|
||||||
|
|
||||||
async function handleDeleteSecret(key: string) {
|
async function handleDeleteSecret(key: string) {
|
||||||
try {
|
try {
|
||||||
await deleteSecret(key);
|
await getBackend().deleteSecret(key);
|
||||||
storedKeys = await listSecrets();
|
storedKeys = await getBackend().listSecrets();
|
||||||
if (revealedKey === key) {
|
if (revealedKey === key) {
|
||||||
revealedKey = null;
|
revealedKey = null;
|
||||||
revealedValue = '';
|
revealedValue = '';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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';
|
import { addTerminalTab } from '../../stores/workspace.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -27,7 +29,7 @@
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
sessions = await listSshSessions();
|
sessions = await getBackend().listSshSessions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load SSH sessions:', e);
|
console.warn('Failed to load SSH sessions:', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -79,7 +81,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveSshSession(session);
|
await getBackend().saveSshSession(session);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -89,7 +91,7 @@
|
||||||
|
|
||||||
async function removeSession(id: string) {
|
async function removeSession(id: string) {
|
||||||
try {
|
try {
|
||||||
await deleteSshSession(id);
|
await getBackend().deleteSshSession(id);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to delete SSH session:', e);
|
console.warn('Failed to delete SSH session:', e);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
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 type { GroupId } from '../../types/ids';
|
||||||
import { AgentId } from '../../types/ids';
|
import { AgentId } from '../../types/ids';
|
||||||
|
|
||||||
|
|
@ -64,7 +65,7 @@
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
try {
|
try {
|
||||||
tasks = await listTasks(groupId);
|
tasks = await getBackend().bttaskList(groupId);
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
|
@ -86,7 +87,7 @@
|
||||||
try {
|
try {
|
||||||
const task = tasks.find(t => t.id === taskId);
|
const task = tasks.find(t => t.id === taskId);
|
||||||
const version = task?.version ?? 1;
|
const version = task?.version ?? 1;
|
||||||
await updateTaskStatus(taskId, newStatus, version);
|
await getBackend().bttaskUpdateStatus(taskId, newStatus, version);
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message ?? String(e);
|
const msg = e?.message ?? String(e);
|
||||||
|
|
@ -102,7 +103,7 @@
|
||||||
async function handleAddTask() {
|
async function handleAddTask() {
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
try {
|
try {
|
||||||
await createTask(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
|
await getBackend().bttaskCreate(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
|
||||||
newTitle = '';
|
newTitle = '';
|
||||||
newDesc = '';
|
newDesc = '';
|
||||||
newPriority = 'medium';
|
newPriority = 'medium';
|
||||||
|
|
@ -115,7 +116,7 @@
|
||||||
|
|
||||||
async function handleDelete(taskId: string) {
|
async function handleDelete(taskId: string) {
|
||||||
try {
|
try {
|
||||||
await deleteTask(taskId);
|
await getBackend().bttaskDelete(taskId);
|
||||||
if (expandedTaskId === taskId) expandedTaskId = null;
|
if (expandedTaskId === taskId) expandedTaskId = null;
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -130,7 +131,7 @@
|
||||||
}
|
}
|
||||||
expandedTaskId = taskId;
|
expandedTaskId = taskId;
|
||||||
try {
|
try {
|
||||||
taskComments = await getTaskComments(taskId);
|
taskComments = await getBackend().bttaskComments(taskId);
|
||||||
} catch {
|
} catch {
|
||||||
taskComments = [];
|
taskComments = [];
|
||||||
}
|
}
|
||||||
|
|
@ -139,9 +140,9 @@
|
||||||
async function handleAddComment() {
|
async function handleAddComment() {
|
||||||
if (!expandedTaskId || !newComment.trim()) return;
|
if (!expandedTaskId || !newComment.trim()) return;
|
||||||
try {
|
try {
|
||||||
await addTaskComment(expandedTaskId, AgentId('admin'), newComment.trim());
|
await getBackend().bttaskAddComment(expandedTaskId, AgentId('admin'), newComment.trim());
|
||||||
newComment = '';
|
newComment = '';
|
||||||
taskComments = await getTaskComments(expandedTaskId);
|
taskComments = await getBackend().bttaskComments(expandedTaskId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to add comment:', e);
|
console.warn('Failed to add comment:', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
removeTerminalTab,
|
removeTerminalTab,
|
||||||
type TerminalTab,
|
type TerminalTab,
|
||||||
} from '../../stores/workspace.svelte';
|
} 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 TerminalPane from '../Terminal/TerminalPane.svelte';
|
||||||
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
|
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
|
||||||
|
|
||||||
|
|
@ -15,7 +17,7 @@
|
||||||
let sshSessions = $state<SshSession[]>([]);
|
let sshSessions = $state<SshSession[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
|
getBackend().listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Resolved SSH args per tab, keyed by tab id */
|
/** Resolved SSH args per tab, keyed by tab id */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
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 {
|
interface Props {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|
@ -23,7 +25,7 @@
|
||||||
async function loadSeleniumState() {
|
async function loadSeleniumState() {
|
||||||
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
|
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
|
||||||
try {
|
try {
|
||||||
const entries = await listDirectoryChildren(screenshotPath);
|
const entries = await getBackend().listDirectory(screenshotPath);
|
||||||
const imageFiles = entries
|
const imageFiles = entries
|
||||||
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
|
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
|
||||||
.map(e => e.path)
|
.map(e => e.path)
|
||||||
|
|
@ -43,7 +45,7 @@
|
||||||
|
|
||||||
// Load session log
|
// Load session log
|
||||||
try {
|
try {
|
||||||
const content = await readFileContent(`${cwd}/${SELENIUM_LOG}`);
|
const content = await getBackend().readFile(`${cwd}/${SELENIUM_LOG}`);
|
||||||
if (content.type === 'Text') {
|
if (content.type === 'Text') {
|
||||||
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
|
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +66,7 @@
|
||||||
async function loadTestFiles() {
|
async function loadTestFiles() {
|
||||||
for (const dir of TEST_DIRS) {
|
for (const dir of TEST_DIRS) {
|
||||||
try {
|
try {
|
||||||
const entries = await listDirectoryChildren(`${cwd}/${dir}`);
|
const entries = await getBackend().listDirectory(`${cwd}/${dir}`);
|
||||||
const tests = entries.filter(e =>
|
const tests = entries.filter(e =>
|
||||||
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
|
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
|
||||||
/test_.*\.py$/.test(e.name)
|
/test_.*\.py$/.test(e.name)
|
||||||
|
|
@ -83,7 +85,7 @@
|
||||||
async function viewTestFile(filePath: string) {
|
async function viewTestFile(filePath: string) {
|
||||||
selectedTestFile = filePath;
|
selectedTestFile = filePath;
|
||||||
try {
|
try {
|
||||||
const content = await readFileContent(filePath);
|
const content = await getBackend().readFile(filePath);
|
||||||
if (content.type === 'Text') {
|
if (content.type === 'Text') {
|
||||||
testOutput = content.content;
|
testOutput = content.content;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
|
|
||||||
const { mockInvoke } = vi.hoisted(() => ({
|
const { mockBackend } = vi.hoisted(() => ({
|
||||||
mockInvoke: vi.fn(),
|
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', () => ({
|
vi.mock('../backend/backend', () => ({
|
||||||
invoke: mockInvoke,
|
getBackend: vi.fn(() => mockBackend),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the plugins store to avoid Svelte 5 rune issues in test context
|
// Mock the plugins store to avoid Svelte 5 rune issues in test context
|
||||||
|
|
@ -41,7 +47,7 @@ import {
|
||||||
unloadAllPlugins,
|
unloadAllPlugins,
|
||||||
} from './plugin-host';
|
} from './plugin-host';
|
||||||
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
|
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';
|
import type { GroupId, AgentId } from '../types/ids';
|
||||||
|
|
||||||
// --- Mock Worker ---
|
// --- Mock Worker ---
|
||||||
|
|
@ -208,10 +214,7 @@ function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockPluginCode(code: string): void {
|
function mockPluginCode(code: string): void {
|
||||||
mockInvoke.mockImplementation((cmd: string) => {
|
mockBackend.readPluginFile.mockResolvedValue(code);
|
||||||
if (cmd === 'plugin_read_file') return Promise.resolve(code);
|
|
||||||
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_ID = 'test-group' as GroupId;
|
const GROUP_ID = 'test-group' as GroupId;
|
||||||
|
|
@ -481,7 +484,7 @@ describe('plugin-host lifecycle', () => {
|
||||||
|
|
||||||
it('loadPlugin throws on file read failure', async () => {
|
it('loadPlugin throws on file read failure', async () => {
|
||||||
const meta = makeMeta({ id: 'read-fail' });
|
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(
|
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
|
||||||
"Failed to read plugin 'read-fail'",
|
"Failed to read plugin 'read-fail'",
|
||||||
|
|
@ -509,12 +512,7 @@ describe('plugin-host RPC routing', () => {
|
||||||
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
|
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
|
||||||
mockPluginCode(`agor.tasks.list();`);
|
mockPluginCode(`agor.tasks.list();`);
|
||||||
|
|
||||||
// Mock the bttask bridge
|
// bttaskList already mocked via mockBackend defaults
|
||||||
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}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
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'] });
|
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
|
||||||
mockPluginCode(`agor.messages.inbox();`);
|
mockPluginCode(`agor.messages.inbox();`);
|
||||||
|
|
||||||
mockInvoke.mockImplementation((cmd: string) => {
|
// btmsgUnreadMessages already mocked via mockBackend defaults
|
||||||
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}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
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.
|
* On unload, the Worker is terminated — all plugin state is destroyed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
import type { PluginMeta } from '@agor/types';
|
||||||
import { readPluginFile } from '../adapters/plugins-bridge';
|
import { getBackend } from '../backend/backend';
|
||||||
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
|
|
||||||
import {
|
|
||||||
getUnreadMessages,
|
|
||||||
getChannels,
|
|
||||||
} from '../adapters/btmsg-bridge';
|
|
||||||
import {
|
import {
|
||||||
addPluginCommand,
|
addPluginCommand,
|
||||||
removePluginCommands,
|
removePluginCommands,
|
||||||
|
|
@ -189,7 +184,7 @@ export async function loadPlugin(
|
||||||
// Read the plugin's entry file
|
// Read the plugin's entry file
|
||||||
let code: string;
|
let code: string;
|
||||||
try {
|
try {
|
||||||
code = await readPluginFile(meta.id, meta.main);
|
code = await getBackend().readPluginFile(meta.id, meta.main);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${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;
|
let result: unknown;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'tasks.list':
|
case 'tasks.list':
|
||||||
result = await listTasks(groupId);
|
result = await getBackend().bttaskList(groupId);
|
||||||
break;
|
break;
|
||||||
case 'tasks.comments':
|
case 'tasks.comments':
|
||||||
result = await getTaskComments(args.taskId);
|
result = await getBackend().bttaskComments(args.taskId);
|
||||||
break;
|
break;
|
||||||
case 'messages.inbox':
|
case 'messages.inbox':
|
||||||
result = await getUnreadMessages(agentId);
|
result = await getBackend().btmsgUnreadMessages(agentId);
|
||||||
break;
|
break;
|
||||||
case 'messages.channels':
|
case 'messages.channels':
|
||||||
result = await getChannels(groupId);
|
result = await getBackend().btmsgGetChannels(groupId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown RPC method: ${method}`);
|
throw new Error(`Unknown RPC method: ${method}`);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getSetting, setSetting } from '../../stores/settings-store.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';
|
import { handleError, handleInfraError } from '../../utils/handle-error';
|
||||||
|
|
||||||
let keyringAvailable = $state(false);
|
let keyringAvailable = $state(false);
|
||||||
|
|
@ -25,8 +32,8 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
keyringAvailable = await hasKeyring();
|
keyringAvailable = await getBackend().hasKeyring();
|
||||||
if (keyringAvailable) { storedKeys = await listSecrets(); knownKeys = await knownSecretKeys(); }
|
if (keyringAvailable) { storedKeys = await getBackend().listSecrets(); knownKeys = await getBackend().knownSecretKeys(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keyring unavailable is expected on some systems — set state explicitly
|
// Keyring unavailable is expected on some systems — set state explicitly
|
||||||
handleInfraError(e, 'settings.keyring.init');
|
handleInfraError(e, 'settings.keyring.init');
|
||||||
|
|
@ -52,18 +59,18 @@
|
||||||
|
|
||||||
async function handleRevealSecret(key: string) {
|
async function handleRevealSecret(key: string) {
|
||||||
if (revealedKey === key) { revealedKey = null; revealedValue = ''; return; }
|
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'); }
|
catch (e) { handleError(e, `settings.secrets.reveal.${key}`, 'reveal the secret'); }
|
||||||
}
|
}
|
||||||
async function handleSaveSecret() {
|
async function handleSaveSecret() {
|
||||||
if (!newSecretKey || !newSecretValue) return;
|
if (!newSecretKey || !newSecretValue) return;
|
||||||
secretsSaving = true;
|
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'); }
|
catch (e) { handleError(e, 'settings.secrets.store', 'store the secret'); }
|
||||||
finally { secretsSaving = false; }
|
finally { secretsSaving = false; }
|
||||||
}
|
}
|
||||||
async function handleDeleteSecret(key: string) {
|
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'); }
|
catch (e) { handleError(e, `settings.secrets.delete.${key}`, 'delete the secret'); }
|
||||||
}
|
}
|
||||||
function addBranchPolicy() {
|
function addBranchPolicy() {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
|
|
||||||
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
|
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
|
||||||
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
|
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
|
||||||
import {
|
import { getBackend } from '../backend/backend';
|
||||||
saveSessionAnchors,
|
|
||||||
loadSessionAnchors,
|
|
||||||
deleteSessionAnchor,
|
|
||||||
updateAnchorType as updateAnchorTypeBridge,
|
|
||||||
} from '../adapters/anchors-bridge';
|
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
|
|
||||||
// Per-project anchor state
|
// Per-project anchor state
|
||||||
|
|
@ -61,7 +56,7 @@ export async function addAnchors(projectId: string, anchors: SessionAnchor[]): P
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveSessionAnchors(records);
|
await getBackend().saveSessionAnchors(records);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleInfraError(e, 'anchors.save');
|
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));
|
projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteSessionAnchor(anchorId);
|
await getBackend().deleteSessionAnchor(anchorId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleInfraError(e, 'anchors.delete');
|
handleInfraError(e, 'anchors.delete');
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +85,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
|
||||||
projectAnchors.set(projectId, [...existing]);
|
projectAnchors.set(projectId, [...existing]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateAnchorTypeBridge(anchorId, newType);
|
await getBackend().updateAnchorType(anchorId, newType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleInfraError(e, 'anchors.updateType');
|
handleInfraError(e, 'anchors.updateType');
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +94,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
|
||||||
/** Load anchors from SQLite for a project */
|
/** Load anchors from SQLite for a project */
|
||||||
export async function loadAnchorsForProject(projectId: string): Promise<void> {
|
export async function loadAnchorsForProject(projectId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const records = await loadSessionAnchors(projectId);
|
const records = await getBackend().loadSessionAnchors(projectId);
|
||||||
const anchors: SessionAnchor[] = records.map(r => ({
|
const anchors: SessionAnchor[] = records.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
projectId: r.project_id,
|
projectId: r.project_id,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import {
|
import { getBackend } from '../backend/backend';
|
||||||
listSessions,
|
import type { PersistedSession } from '@agor/types';
|
||||||
saveSession,
|
|
||||||
deleteSession,
|
|
||||||
updateSessionTitle,
|
|
||||||
touchSession,
|
|
||||||
saveLayout,
|
|
||||||
loadLayout,
|
|
||||||
updateSessionGroup,
|
|
||||||
type PersistedSession,
|
|
||||||
} from '../adapters/session-bridge';
|
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
|
|
||||||
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
|
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
|
||||||
|
|
@ -47,11 +38,11 @@ function persistSession(pane: Pane): void {
|
||||||
created_at: now,
|
created_at: now,
|
||||||
last_used_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 {
|
function persistLayout(): void {
|
||||||
saveLayout({
|
getBackend().saveLayout({
|
||||||
preset: activePreset,
|
preset: activePreset,
|
||||||
pane_ids: panes.map(p => p.id),
|
pane_ids: panes.map(p => p.id),
|
||||||
}).catch(e => handleInfraError(e, 'layout.persistLayout'));
|
}).catch(e => handleInfraError(e, 'layout.persistLayout'));
|
||||||
|
|
@ -85,14 +76,14 @@ export function removePane(id: string): void {
|
||||||
focusedPaneId = panes.length > 0 ? panes[0].id : null;
|
focusedPaneId = panes.length > 0 ? panes[0].id : null;
|
||||||
}
|
}
|
||||||
autoPreset();
|
autoPreset();
|
||||||
deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
|
getBackend().deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
|
||||||
persistLayout();
|
persistLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusPane(id: string): void {
|
export function focusPane(id: string): void {
|
||||||
focusedPaneId = id;
|
focusedPaneId = id;
|
||||||
panes = panes.map(p => ({ ...p, focused: p.id === 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 {
|
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);
|
const pane = panes.find(p => p.id === id);
|
||||||
if (pane) {
|
if (pane) {
|
||||||
pane.title = title;
|
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);
|
const pane = panes.find(p => p.id === id);
|
||||||
if (pane) {
|
if (pane) {
|
||||||
pane.group = group || undefined;
|
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;
|
initialized = true;
|
||||||
|
|
||||||
try {
|
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) {
|
if (layout.preset) {
|
||||||
activePreset = layout.preset as LayoutPreset;
|
activePreset = layout.preset as LayoutPreset;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Mock session-bridge before importing the layout store
|
// Mock backend before importing the layout store
|
||||||
vi.mock('../adapters/session-bridge', () => ({
|
vi.mock('../backend/backend', () => ({
|
||||||
listSessions: vi.fn().mockResolvedValue([]),
|
getBackend: vi.fn(() => ({
|
||||||
saveSession: vi.fn().mockResolvedValue(undefined),
|
listSessions: vi.fn().mockResolvedValue([]),
|
||||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
saveSession: vi.fn().mockResolvedValue(undefined),
|
||||||
updateSessionTitle: vi.fn().mockResolvedValue(undefined),
|
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||||
touchSession: vi.fn().mockResolvedValue(undefined),
|
updateSessionTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
saveLayout: vi.fn().mockResolvedValue(undefined),
|
touchSession: vi.fn().mockResolvedValue(undefined),
|
||||||
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }),
|
updateSessionGroup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveLayout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,7 @@
|
||||||
// Remote machines store — tracks connection state for multi-machine support
|
// Remote machines store — tracks connection state for multi-machine support
|
||||||
|
|
||||||
import {
|
import { getBackend } from '../backend/backend';
|
||||||
listRemoteMachines,
|
import type { RemoteMachineConfig, RemoteMachineInfo } from '@agor/types';
|
||||||
addRemoteMachine,
|
|
||||||
removeRemoteMachine,
|
|
||||||
connectRemoteMachine,
|
|
||||||
disconnectRemoteMachine,
|
|
||||||
onRemoteMachineReady,
|
|
||||||
onRemoteMachineDisconnected,
|
|
||||||
onRemoteError,
|
|
||||||
onRemoteMachineReconnecting,
|
|
||||||
onRemoteMachineReconnectReady,
|
|
||||||
type RemoteMachineConfig,
|
|
||||||
type RemoteMachineInfo,
|
|
||||||
} from '../adapters/remote-bridge';
|
|
||||||
import { notify } from './notifications.svelte';
|
import { notify } from './notifications.svelte';
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
|
|
||||||
|
|
@ -31,26 +19,27 @@ export function getMachine(id: string): Machine | undefined {
|
||||||
|
|
||||||
export async function loadMachines(): Promise<void> {
|
export async function loadMachines(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
machines = await listRemoteMachines();
|
machines = await getBackend().listRemoteMachines();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleInfraError(e, 'machines.load');
|
handleInfraError(e, 'machines.load');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMachine(config: RemoteMachineConfig): Promise<string> {
|
export async function addMachine(config: RemoteMachineConfig): Promise<string> {
|
||||||
const id = await addRemoteMachine(config);
|
const id = await getBackend().addRemoteMachine(config);
|
||||||
machines.push({
|
machines.push({
|
||||||
id,
|
id,
|
||||||
label: config.label,
|
label: config.label,
|
||||||
url: config.url,
|
url: config.url,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
auto_connect: config.auto_connect,
|
auto_connect: config.auto_connect,
|
||||||
|
spki_pins: config.spki_pins ?? [],
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMachine(id: string): Promise<void> {
|
export async function removeMachine(id: string): Promise<void> {
|
||||||
await removeRemoteMachine(id);
|
await getBackend().removeRemoteMachine(id);
|
||||||
machines = machines.filter(m => m.id !== 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);
|
const machine = machines.find(m => m.id === id);
|
||||||
if (machine) machine.status = 'connecting';
|
if (machine) machine.status = 'connecting';
|
||||||
try {
|
try {
|
||||||
await connectRemoteMachine(id);
|
await getBackend().connectRemoteMachine(id);
|
||||||
if (machine) machine.status = 'connected';
|
if (machine) machine.status = 'connected';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (machine) machine.status = 'error';
|
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> {
|
export async function disconnectMachine(id: string): Promise<void> {
|
||||||
await disconnectRemoteMachine(id);
|
await getBackend().disconnectRemoteMachine(id);
|
||||||
const machine = machines.find(m => m.id === id);
|
const machine = machines.find(m => m.id === id);
|
||||||
if (machine) machine.status = 'disconnected';
|
if (machine) machine.status = 'disconnected';
|
||||||
}
|
}
|
||||||
|
|
@ -76,11 +65,12 @@ export async function disconnectMachine(id: string): Promise<void> {
|
||||||
let unlistenFns: (() => void)[] = [];
|
let unlistenFns: (() => void)[] = [];
|
||||||
|
|
||||||
// Initialize event listeners for machine status updates
|
// Initialize event listeners for machine status updates
|
||||||
export async function initMachineListeners(): Promise<void> {
|
export function initMachineListeners(): void {
|
||||||
// Clean up any existing listeners first
|
// Clean up any existing listeners first
|
||||||
destroyMachineListeners();
|
destroyMachineListeners();
|
||||||
|
const backend = getBackend();
|
||||||
|
|
||||||
unlistenFns.push(await onRemoteMachineReady((msg) => {
|
unlistenFns.push(backend.onRemoteMachineReady((msg) => {
|
||||||
const machine = machines.find(m => m.id === msg.machineId);
|
const machine = machines.find(m => m.id === msg.machineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
machine.status = 'connected';
|
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);
|
const machine = machines.find(m => m.id === msg.machineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
machine.status = 'disconnected';
|
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);
|
const machine = machines.find(m => m.id === msg.machineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
machine.status = 'error';
|
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);
|
const machine = machines.find(m => m.id === msg.machineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
machine.status = 'reconnecting';
|
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);
|
const machine = machines.find(m => m.id === msg.machineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
notify('info', `${machine.label} reachable — reconnecting…`);
|
notify('info', `${machine.label} reachable - reconnecting...`);
|
||||||
connectMachine(msg.machineId).catch((e) => {
|
connectMachine(msg.machineId).catch((e) => {
|
||||||
notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`);
|
notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Notification store — ephemeral toasts + persistent notification history
|
// Notification store — ephemeral toasts + persistent notification history
|
||||||
|
|
||||||
import { sendDesktopNotification } from '../adapters/notifications-bridge';
|
import { getBackend } from '../backend/backend';
|
||||||
|
|
||||||
// --- Toast types (existing) ---
|
// --- Toast types (existing) ---
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ export function addNotification(
|
||||||
notify(toastType, `${title}: ${body}`);
|
notify(toastType, `${title}: ${body}`);
|
||||||
|
|
||||||
// Send OS desktop notification (fire-and-forget)
|
// 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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
* Uses Svelte 5 runes for reactivity.
|
* Uses Svelte 5 runes for reactivity.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
import type { PluginMeta } from '@agor/types';
|
||||||
import { discoverPlugins } from '../adapters/plugins-bridge';
|
import { getBackend } from '../backend/backend';
|
||||||
import { getSetting, setSetting } from './settings-store.svelte';
|
import { getSetting, setSetting } from './settings-store.svelte';
|
||||||
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
|
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
|
|
@ -160,7 +160,7 @@ export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Prom
|
||||||
|
|
||||||
let discovered: PluginMeta[];
|
let discovered: PluginMeta[];
|
||||||
try {
|
try {
|
||||||
discovered = await discoverPlugins();
|
discovered = await getBackend().discoverPlugins();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleInfraError(e, 'plugins.discover');
|
handleInfraError(e, 'plugins.discover');
|
||||||
pluginEntries = [];
|
pluginEntries = [];
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ import type { AgentId } from '../types/ids';
|
||||||
import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer';
|
import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer';
|
||||||
import { getAllProjectHealth, getHealthAggregates } from './health.svelte';
|
import { getAllProjectHealth, getHealthAggregates } from './health.svelte';
|
||||||
import { getAllWorkItems } from './workspace.svelte';
|
import { getAllWorkItems } from './workspace.svelte';
|
||||||
import { listTasks } from '../adapters/bttask-bridge';
|
import { getBackend } from '../backend/backend';
|
||||||
import { getAgentSession } from './agents.svelte';
|
import { getAgentSession } from './agents.svelte';
|
||||||
import { logAuditEvent } from '../adapters/audit-bridge';
|
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
import { handleInfraError } from '../utils/handle-error';
|
||||||
import type { GroupId } from '../types/ids';
|
import type { GroupId } from '../types/ids';
|
||||||
|
|
||||||
|
|
@ -210,7 +209,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
|
||||||
// Fetch task summary (best-effort)
|
// Fetch task summary (best-effort)
|
||||||
let taskSummary: WakeTaskSummary | undefined;
|
let taskSummary: WakeTaskSummary | undefined;
|
||||||
try {
|
try {
|
||||||
const tasks = await listTasks(reg.groupId);
|
const tasks = await getBackend().bttaskList(reg.groupId);
|
||||||
taskSummary = {
|
taskSummary = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
todo: tasks.filter(t => t.status === 'todo').length,
|
todo: tasks.filter(t => t.status === 'todo').length,
|
||||||
|
|
@ -262,7 +261,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audit: log wake event
|
// Audit: log wake event
|
||||||
logAuditEvent(
|
getBackend().logAuditEvent(
|
||||||
reg.agentId,
|
reg.agentId,
|
||||||
'wake_event',
|
'wake_event',
|
||||||
`Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`,
|
`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 { handleInfraError } from '../utils/handle-error';
|
||||||
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
|
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
|
||||||
import { agentToProject } 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 { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||||
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
|
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
|
||||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||||
import { registerAgents } from '../adapters/btmsg-bridge';
|
|
||||||
|
|
||||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
||||||
|
|
||||||
|
|
@ -201,7 +200,7 @@ export async function switchGroup(groupId: string): Promise<void> {
|
||||||
// Persist active group
|
// Persist active group
|
||||||
if (groupsConfig) {
|
if (groupsConfig) {
|
||||||
groupsConfig.activeGroupId = groupId;
|
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> {
|
export async function loadWorkspace(initialGroupId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = await loadGroups();
|
const config = await getBackend().loadGroups();
|
||||||
groupsConfig = config;
|
groupsConfig = config;
|
||||||
projectTerminals = {};
|
projectTerminals = {};
|
||||||
|
|
||||||
// Register all agents from config into btmsg database
|
// Register all agents from config into btmsg database
|
||||||
// (creates agent records, contact permissions, review channels)
|
// (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
|
// CLI --group flag takes priority, then explicit param, then persisted
|
||||||
let cliGroup: string | null = null;
|
let cliGroup: string | null = null;
|
||||||
if (!initialGroupId) {
|
if (!initialGroupId) {
|
||||||
cliGroup = await getCliGroup();
|
cliGroup = await getBackend().getCliGroup();
|
||||||
}
|
}
|
||||||
const targetId = initialGroupId || cliGroup || config.activeGroupId;
|
const targetId = initialGroupId || cliGroup || config.activeGroupId;
|
||||||
// Match by ID or by name (CLI users may pass name)
|
// 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> {
|
export async function saveWorkspace(): Promise<void> {
|
||||||
if (!groupsConfig) return;
|
if (!groupsConfig) return;
|
||||||
await saveGroups(groupsConfig);
|
await getBackend().saveGroups(groupsConfig);
|
||||||
// Re-register agents after config changes (new agents, permission updates)
|
// 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 ---
|
// --- Group/project mutation ---
|
||||||
|
|
@ -276,7 +275,7 @@ export function addGroup(group: GroupConfig): void {
|
||||||
...groupsConfig,
|
...groupsConfig,
|
||||||
groups: [...groupsConfig.groups, group],
|
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 {
|
export function removeGroup(groupId: string): void {
|
||||||
|
|
@ -289,7 +288,7 @@ export function removeGroup(groupId: string): void {
|
||||||
activeGroupId = groupsConfig.groups[0]?.id ?? '';
|
activeGroupId = groupsConfig.groups[0]?.id ?? '';
|
||||||
activeProjectId = null;
|
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 {
|
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 {
|
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] };
|
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 {
|
export function removeProject(groupId: string, projectId: string): void {
|
||||||
|
|
@ -336,7 +335,7 @@ export function removeProject(groupId: string, projectId: string): void {
|
||||||
if (activeProjectId === projectId) {
|
if (activeProjectId === projectId) {
|
||||||
activeProjectId = null;
|
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 {
|
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),
|
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../adapters/groups-bridge', () => ({
|
const mockBackend = {
|
||||||
loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())),
|
loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())),
|
||||||
saveGroups: vi.fn().mockResolvedValue(undefined),
|
saveGroups: vi.fn().mockResolvedValue(undefined),
|
||||||
getCliGroup: vi.fn().mockResolvedValue(null),
|
getCliGroup: vi.fn().mockResolvedValue(null),
|
||||||
|
btmsgRegisterAgents: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../backend/backend', () => ({
|
||||||
|
getBackend: vi.fn(() => mockBackend),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -66,7 +71,7 @@ import {
|
||||||
removeProject,
|
removeProject,
|
||||||
} from './workspace.svelte';
|
} from './workspace.svelte';
|
||||||
|
|
||||||
import { saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
const { saveGroups, getCliGroup } = mockBackend;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { SessionAnchor } from '../types/anchors';
|
||||||
import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte';
|
import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte';
|
||||||
import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer';
|
import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer';
|
||||||
import { getEnabledProjects } from '../stores/workspace.svelte';
|
import { getEnabledProjects } from '../stores/workspace.svelte';
|
||||||
import { tel } from '../adapters/telemetry-bridge';
|
import { tel } from './telemetry';
|
||||||
import { notify } from '../stores/notifications.svelte';
|
import { notify } from '../stores/notifications.svelte';
|
||||||
|
|
||||||
/** Auto-anchor first N turns on first compaction event for a project */
|
/** Auto-anchor first N turns on first compaction event for a project */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { extractErrorMessage } from './extract-error-message';
|
import { extractErrorMessage } from './extract-error-message';
|
||||||
import { classifyError } from './error-classifier';
|
import { classifyError } from './error-classifier';
|
||||||
import { notify } from '../stores/notifications.svelte';
|
import { notify } from '../stores/notifications.svelte';
|
||||||
import { tel } from '../adapters/telemetry-bridge';
|
import { tel } from './telemetry';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { extractErrorMessage } from './extract-error-message';
|
import { extractErrorMessage } from './extract-error-message';
|
||||||
import { classifyError, type ClassifiedError } from './error-classifier';
|
import { classifyError, type ClassifiedError } from './error-classifier';
|
||||||
import { notify } from '../stores/notifications.svelte';
|
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. */
|
/** User-facing error handler. Logs to telemetry AND shows a toast. */
|
||||||
export function handleError(
|
export function handleError(
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,8 @@
|
||||||
import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids';
|
import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids';
|
||||||
import type { ProviderId } from '../providers/types';
|
import type { ProviderId } from '../providers/types';
|
||||||
import { getAgentSession } from '../stores/agents.svelte';
|
import { getAgentSession } from '../stores/agents.svelte';
|
||||||
import {
|
import { getBackend } from '../backend/backend';
|
||||||
saveProjectAgentState,
|
import type { AgentMessageRecord } from '@agor/types';
|
||||||
saveAgentMessages,
|
|
||||||
saveSessionMetric,
|
|
||||||
type AgentMessageRecord,
|
|
||||||
} from '../adapters/groups-bridge';
|
|
||||||
import { handleInfraError } from './handle-error';
|
import { handleInfraError } from './handle-error';
|
||||||
|
|
||||||
// Map sessionId -> projectId for persistence routing
|
// Map sessionId -> projectId for persistence routing
|
||||||
|
|
@ -58,8 +54,9 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
|
||||||
|
|
||||||
pendingPersistCount++;
|
pendingPersistCount++;
|
||||||
try {
|
try {
|
||||||
|
const backend = getBackend();
|
||||||
// Save agent state
|
// Save agent state
|
||||||
await saveProjectAgentState({
|
await backend.saveProjectAgentState({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
last_session_id: sessionId,
|
last_session_id: sessionId,
|
||||||
sdk_session_id: session.sdkSessionId ?? null,
|
sdk_session_id: session.sdkSessionId ?? null,
|
||||||
|
|
@ -85,13 +82,13 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (records.length > 0) {
|
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
|
// Persist session metric for historical tracking
|
||||||
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
|
||||||
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
|
||||||
await saveSessionMetric({
|
await backend.saveSessionMetric({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
start_time: Math.floor(startTime / 1000),
|
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