94 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 4b8c574096 fix: Agent Runs page stuck on Loading — use $effect instead of onMount
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The onMount + async pattern wasn't triggering Svelte 5 reactivity
properly. Switched to $effect with $user dependency (same pattern
used by Sidebar and other components). Also waits for auth before
loading data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:30:36 +02:00
Algis Dumbris aed7cb5e98 Merge features 014+015: Reactive Agent Triggers + SQL Query Interface
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-26 07:41:37 +02:00
Algis DumbrisandClaude Opus 4.6 107b5e930d docs: add SQL query action to CLAUDE.md onboarding template
New agents now learn about the query action during onboarding:
tables (my_messages, my_channels, channel_messages), examples,
and limitations (100 rows, SELECT only, 5s timeout).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:26:46 +02:00
Algis DumbrisandClaude Opus 4.6 e5ee8d16e4 fix(015): remove SQL LIMIT injection — enforce in Go only
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:19:38 +02:00
Algis DumbrisandClaude Opus 4.6 bd1bccc692 feat(015): SQL query interface for agents + split read/write pools
Split Connection Pools:
- writeDB: MaxOpenConns=1, serializes all writes (no SQLITE_BUSY)
- readDB: MaxOpenConns=8, query_only=ON, for all SELECTs
- QueryDB() helper returns read pool when available

SQL Query Interface:
- New 'query' action via execute MCP tool
- Read-only enforcement (PRAGMA query_only=ON + SQL validation)
- Curated views: my_messages, my_channels, channel_messages
- Per-agent access control via CTE injection
- Auto LIMIT 100, 5s timeout, SELECT-only validation
- Blocks: INSERT, UPDATE, DELETE, DROP, PRAGMA, etc.
- 12 new tests (access control, validation, limits, CTEs)

Migration 016: agent query views (v_agent_messages, etc.)
Action registry: 30 actions (was 29, added 'query')
All 29 test packages pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:17:23 +02:00
Algis DumbrisandClaude Opus 4.6 b6fc298595 feat(015): add spec for SQL query interface + split connection pools
Two features:
1. SQL query action for agents via execute MCP tool — read-only,
   curated views, LIMIT/timeout, SELECT-only validation
2. Split read/write SQLite connection pools — writeDB (1 conn)
   + readDB (8 conns) to eliminate SQLITE_BUSY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:04:55 +02:00
Algis DumbrisandClaude Opus 4.6 64c68c22be fix(014): prevent stuck runs by creating K8s Job before DB insert
The reactor was inserting the run record first, then creating the K8s
Job, then updating the record with the job name. If the update failed
(SQLITE_BUSY), the run would be stuck in 'running' with no job name,
making it invisible to the poller.

Now: create K8s Job first, then insert the run record with job name
already set in a single atomic write.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:09:16 +02:00
Algis DumbrisandClaude Opus 4.6 cf6066229f feat(014): add Prometheus metrics, Grafana dashboard, volume mounts, resource tuning
- Reactor Prometheus metrics: triggers_total, run_duration_seconds, agent_running, budget_used_today
- Integrated promauto metrics into hand-rolled WritePrometheus endpoint
- K8s runner: ImagePullPolicy=IfNotPresent, volume mounts, CLI args support
- Reactor: 2Gi/500m default resources (agent SDK needs it), 1h timeout
- Grafana dashboard "SynapBus Reactive Agents" with 8 panels:
  triggers by status, agent state, budget gauge, run duration,
  agent turns from Loki, reactor events log, agent container logs
- SQLite: busy_timeout=15s, synchronous=NORMAL, MaxOpenConns=4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:15:54 +02:00
Algis DumbrisandClaude Opus 4.6 012b7f6fba fix: reduce SQLITE_BUSY errors under concurrent load
- Increase busy_timeout from 5s to 15s
- Set synchronous=NORMAL (safe with WAL, reduces fsync)
- Limit MaxOpenConns to 4 to reduce write lock contention
- Explicit wal_autocheckpoint=1000

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:48:44 +02:00
Algis DumbrisandClaude Opus 4.6 c6c96f64be feat(014): add Web UI Agent Runs page
- New /runs route with agent summary cards, run list, filtering
- Agent cards show budget usage, cooldown status, current state
- Expandable run rows with error logs and retry button
- API client: runs.list, runs.get, runs.retry, runs.reactiveAgents
- Sidebar navigation updated with "Agent Runs" link
- Rebuilt web dist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:45:57 +02:00
Algis DumbrisandClaude Opus 4.6 6afe1853ad feat(014): implement reactive agent triggering engine
- Migration 015: extends agents with trigger config, adds reactive_runs table
- Reactor engine: decision chain (mode, depth, budget, cooldown, sequential)
- Reactor store: SQLite persistence for runs with RFC3339 timestamps
- Reactor poller: K8s Job status polling (15s interval)
- Failure notifier: system DM to owner on job failure
- REST API: /api/runs, /api/runs/:id, /api/runs/:id/retry, /api/agents/reactive
- Agent model: trigger_mode, cooldown, budget, depth, k8s_image, pending_work
- K8s runner: GetClientset() for poller
- All 28 test packages pass (8 new reactor tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:42:48 +02:00
Algis DumbrisandClaude Opus 4.6 68f356b5e3 feat(014): add implementation plan, research, data model, and contracts
Phase 0: research.md — 7 decisions on polling, coalescing, depth, cooldown
Phase 1: data-model.md — schema for reactive_runs + agent extensions
Phase 1: contracts — REST API, MCP tools, CLI commands
Phase 1: quickstart.md — developer onboarding guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:23:48 +02:00
Algis DumbrisandClaude Opus 4.6 ea256ed526 feat(014): add reactive agent triggering spec
Specifies the reactive agent system: DM/@mention triggers K8s Jobs
with reactor decision engine, cooldown/budget/depth rate limiting,
sequential execution with coalescing, Web UI Agent Runs panel,
failure notifications, and admin CLI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:20:52 +02:00
Algis DumbrisandClaude Opus 4.6 0e28c0b45e feat: fix attachment handling — display in DMs, enrich in MCP, allow all file types
- Show attachment previews on DM messages (was missing, only channels had it)
- Add file upload button to DM compose bar with paperclip icon
- Enrich messages with attachment data in all MCP bridge functions
  (read_inbox, claim_messages, search, channel_messages, list_by_state)
- Remove file type restrictions — allow any file type, keep 50MB size limit
- Rebuild web dist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:00:28 +02:00
Algis DumbrisandClaude Opus 4.6 8134a7eef5 chore: rebuild web dist with v0.12.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:11:39 +02:00
Algis DumbrisandClaude Opus 4.6 91a1f2adcb feat: add pagination + body truncation to list_by_state
Prevents 181K+ responses when channels have many messages with long
bodies. New params: limit (default 20, max 100), offset (default 0),
max_body_length (default 500 chars when include_messages=true).

Response now includes total count alongside paginated results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:09:38 +02:00
Algis DumbrisandClaude Opus 4.6 faab0f7f17 chore: rebuild web dist with truncation fix, update agent context
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:06:53 +02:00
Algis DumbrisandClaude Opus 4.6 b7f2611626 fix: increase message body truncation from 300 to 800 chars in Web UI
DM messages from agents were cut off at 300 characters in the
MessageList view. Increased to 800 to show more context while
still keeping long messages manageable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:09:13 +02:00
Algis DumbrisandClaude Opus 4.6 fa25487290 feat: MCP tool fixes + LinkedIn approval workflow (013)
SynapBus MCP improvements:
- react tool now returns workflow_state + reactions in response
- list_by_state properly filters by computed state (fixes
  cross-contamination bug)
- list_by_state supports include_messages parameter
- New get_replies MCP tool for thread reading
- New threads action category in registry

Deployment:
- v0.12.0-013 deployed to kubic
- #approve-linkedin-comment channel created with workflow enabled
- E2E tested: approve/reject reactions, state transitions, threading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 09:19:26 +02:00
Algis DumbrisandClaude Opus 4.6 2b5dc652e7 docs: demo scenarios, gaps analysis, and website redesign spec
6 demo scenarios from single agent to 4-agent outreach pipeline.
SynapBus as agent memory (channels + semantic search). Three-stage
progression (experiment → stabilize → scale). Identified gaps in
code, website, and documentation. Website restructure proposal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:59:35 +02:00
Algis DumbrisandClaude Opus 4.6 130e1a63f2 fix: CLAUDE.md template cleanup, MCP config api_key param, archetypes as examples
- Removed Identity section (was showing generic "owner"/"auto" values)
- Removed Channels section from CLAUDE.md template (unnecessary)
- Removed Custom Workflow placeholder section
- Renamed archetype sections to "Example Workflow:" framing
- Archetypes listed as examples, not rigid types (custom is first/default)
- MCP config endpoint accepts ?api_key= param for real config generation
- Fixed web UI mcpConfig parsing (raw JSON, not {config: ...} wrapper)
- Updated tests for new template structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:34:02 +02:00
Algis Dumbris 7119827bed Merge branch '012-agent-onboarding' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-20 09:58:02 +02:00
Algis DumbrisandClaude Opus 4.6 f9ca908532 feat: agent onboarding — archetype selector, CLAUDE.md generator, skills library (012-agent-onboarding)
Backend (internal/onboarding/):
- CLAUDE.md template engine with 6 archetypes (researcher, writer,
  commenter, monitor, operator, custom)
- GenerateCLAUDEMD renders archetype-specific instructions with
  startup loop, reactions, trust, channel guide
- GenerateMCPConfig returns Claude Code MCP config JSON
- Embedded skill files via go:embed (stigmergy-workflow, task-auction)
- 9 new tests for generator + skills

REST API:
- GET /api/agents/{name}/claude-md?archetype=X — download CLAUDE.md
- GET /api/agents/{name}/mcp-config — MCP config snippet
- GET /api/archetypes — list archetypes
- GET /api/skills — list skills
- GET /api/skills/{name} — download skill

Web UI:
- Agent registration: archetype dropdown + quick start panel
- Agent detail page: collapsible Getting Started section with
  Download CLAUDE.md, Copy MCP Config, 3-step guide
- Skills Library page (/skills) with download/view buttons
- Sidebar: Skills link under MANAGE section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:57:49 +02:00
Algis DumbrisandClaude Opus 4.6 1b942db80e docs: agent experimentation environment design spec
Three-stage progression: experiment (Claude Code + /loop) → stabilize
(git repo + Agent SDK) → scale (Docker/K8s). SynapBus stays runtime
agnostic — downloadable CLAUDE.md per archetype, MCP config snippet,
skills as optional plugins. No Docker or K8s required for Stage 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:45:55 +02:00
Algis DumbrisandClaude Opus 4.6 3ae8393537 feat: self-documenting MCP tools, channel type UI, workflow settings panel
MCP tool descriptions: react, unreact, list_by_state, get_trust,
post_task, bid_task now include workflow context so agents discover
the coordination pattern from tool descriptions alone.

Channel creation UI: added channel type selector (standard/blackboard/
auction) and workflow enabled toggle to the create form.

Channel info panel: workflow settings section with toggles for
workflow_enabled, auto_approve, threshold sliders, and stalemate
timeout inputs. Changes apply via PUT /api/channels/{name}/settings.

Agent skill docs: created stigmergy-workflow.md and task-auction.md
reference skills for agent workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:33:30 +02:00
Algis DumbrisandClaude Opus 4.6 243a5d8a80 feat: StalemateWorker workflow scanning, website docs, searcher refactor
StalemateWorker: new Phase 2 scans workflow-enabled channels for stale
messages in non-terminal states. Sends reminder DMs after
stalemate_remind_after timeout, escalates to #approvals after
stalemate_escalate_after. Deduplication prevents repeat notifications.
7 new tests.

Website: blog post "SynapBus v0.10: Trust Scores, Reactions, and the
Agent Platform Vision". Updated features page with reactions, trust,
and archetypes sections.

Searcher: all 4 agent AGENT.md files updated with universal startup
loop protocol, trust awareness, and stigmergy workflow instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:57:56 +02:00
Algis Dumbris 9c0e7773b3 Merge branch '011-trust-claims-triggers' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-18 21:42:04 +02:00
Algis DumbrisandClaude Opus 4.6 8df22457ab feat: trust scores, claim semantics, state-change webhooks (011-trust-claims-triggers)
Trust scores: per (agent, action_type) pair, stored in agent_trust
table. Auto-adjusts when human reacts to AI agent messages (approve
+0.05, reject -0.1). Scores clamped [0.0, 1.0]. MCP get_trust action
+ REST API /api/trust/{agent}. Web UI shows trust progress bars on
agent detail pages.

Claim semantics: only one in_progress reaction per message enforced.
First agent to claim wins, duplicates rejected with clear error.

State-change webhooks: StateChangeNotifier interface fires
workflow.state_changed events through existing webhook infrastructure
when reactions change a message's derived workflow state.

Channel thresholds: publish_threshold and approve_threshold fields
on channels for configuring autonomy gates.

Migration 014_trust_claims.sql. 17 new test cases across trust
model + store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:41:54 +02:00
Algis DumbrisandClaude Opus 4.6 695dbf0c9f docs: agent platform architecture design spec
Three-layer architecture (Infrastructure, SynapBus, Agent Instances),
stigmergy coordination via workflow reactions, agent archetypes with
CLAUDE.md specialization, trust scoring, local-first runtime with
docker-compose, agent-init CLI tool, and 10 ensemble work ideas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:51:27 +02:00
Algis DumbrisandClaude Opus 4.6 d5a831bac4 fix: channels missing (workflow_enabled column), DM reactions, sidebar filtering
- Channel queries failed on prod because workflow_enabled column was
  missing (migration ran before column was added). Fixed prod DB.
- Added WorkflowBadge + ReactionPills to DM page view so reactions
  work in DMs, not just channels
- Filtered agent-to-agent DMs from sidebar — only show AI agents when
  they have unread messages for the human owner
- Updated 4 agent gitops repos with SynapBus reactions workflow
  instructions (react in_progress/done, thread replies, self-update)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:59:18 +02:00
Algis DumbrisandClaude Opus 4.6 6bb88374ce fix: DM messages cut off by limit, thread panel shows no replies
Bug 1 (DM disappearing): GetDMMessages used ORDER BY created_at ASC
with LIMIT 100, so newest messages were cut off when >100 DMs exist
between owned agents and a peer. Changed to DESC + reverse in handler
so the most recent messages are always included.

Bug 2 (empty thread panel): ThreadPanel loaded messages by
conversation_id, but reply_to links messages across different
conversations. Rewrote to use GET /api/messages/{id}/replies which
correctly finds all replies to a parent message. Added getReplies
method to the API client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:34:24 +02:00
Algis DumbrisandClaude Opus 4.6 3830fba728 fix: accept workflow_enabled in channel settings API request
The UpdateSettings handler was missing workflow_enabled from the
request struct, so PUT /api/channels/{name}/settings could not
enable/disable workflow mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:30:11 +02:00
Algis DumbrisandClaude Opus 4.6 4de779d30b chore: add synapbus-linux-amd64 to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:47:04 +02:00
Algis DumbrisandClaude Opus 4.6 fc90a2744f fix: workflow UI only on enabled channels, add reaction picker, update protocol docs
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
- Add workflow_enabled column to channels (default false) — reactions
  and workflow badges only show on opted-in channels
- ReactionPills: add "+" button with picker dropdown to add reactions
  when none exist yet (was missing, only showed existing reactions)
- Update CLAUDE.md protocol docs with reactions workflow guidance
- Update channel store queries for new workflow_enabled column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:45:55 +02:00
Algis Dumbris e6f174e1b1 Merge branch '010-reactions-workflows' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-18 09:30:11 +02:00
Algis DumbrisandClaude Opus 4.6 e51adc376e feat: message reactions and workflow states (010-reactions-workflows)
Add typed reactions (approve/reject/in_progress/done/published) with
toggle semantics. Workflow state derived from highest-priority reaction.
New reactions package with model, SQLite store, and service layer.

REST API: POST/GET/DELETE /api/messages/{id}/reactions for toggle/query,
PUT /api/channels/{name}/settings for workflow config, GET by-state
endpoint for listing messages by workflow state.

MCP: react/unreact/get_reactions/list_by_state actions via bridge.

Web UI: WorkflowBadge (colored state pills) and ReactionPills (toggle
pills with agent names) components integrated into channel view.

Channel settings: auto_approve, stalemate_remind_after,
stalemate_escalate_after columns. CLI: channels update command.

Migration 013_reactions.sql adds message_reactions table and channel
workflow columns. 29+ new test cases across model and store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:30:06 +02:00
Algis Dumbris 6ed4ce931a Merge branch '009-attachments-threads' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-17 16:36:31 +02:00
Algis DumbrisandClaude Opus 4.6 667b7a4c2e feat: file attachments and thread visibility (009-attachments-threads)
Web UI: paperclip button for file upload (images, PDFs, text), inline
attachment cards with file icon/name/size, image thumbnails with
fullscreen overlay, attachment display in thread panel.

Threads: always-visible reply count badges on messages, clickable to
open thread panel. reply_count and attachments enriched in all API
responses via batch queries.

MCP: attachments parameter on send_message tool, updated tool
descriptions for threading and attachment workflow guidance.

Backend: file type validation (allowlist), AttachmentLinker interface
to avoid circular deps, GetReplyCounts batch query, EnrichMessages
method on MessagingService.

Admin CLI: synapbus attachments backup/restore with tar.gz archives,
dedup-safe restore.

24 new test cases across 4 packages. All 24 test packages pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:36:21 +02:00
Algis DumbrisandClaude Opus 4.6 3820414166 fix: push subscribe sends flat key_p256dh/key_auth matching backend API
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The browser PushSubscription nests keys under .keys but the backend
expects flat key_p256dh and key_auth fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:28:35 +02:00
Algis DumbrisandClaude Opus 4.6 09fa765c2e build: rebuild embedded dist with v0.7.1 fixes
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:23:31 +02:00
Algis DumbrisandClaude Opus 4.6 71288f64d8 fix: push notification toggle, textarea resize, mobile viewport, card alignment
1. Fix push toggle error: VAPID key field name mismatch (public_key → vapid_public_key)
2. Fix textarea auto-resize: proper height reset, overflow handling, mobile Enter
   inserts newline instead of sending (send via button on mobile)
3. Fix mobile viewport overflow: add overflow-x hidden to html/body, overflow-x
   hidden on content container
4. Fix dashboard cards: always 4 columns with responsive text sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:23:19 +02:00
Algis DumbrisandClaude Opus 4.6 15e7877ea0 Add MCP Registry auto-publish on release
- Add server.json with registry metadata
- Add mcp-registry job to release workflow using GitHub OIDC auth
- Version in server.json is auto-updated from git tag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:36:42 +02:00
Algis DumbrisandClaude Opus 4.6 55652c2aca build: rebuild embedded dist with v0.7.0 web UI
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:37:11 +02:00
Algis DumbrisandClaude Opus 4.6 30d62de350 feat: v0.7.0 — analytics dashboard, PWA, UX fixes, MCP prompts
Analytics: time-series message graph with 5 time spans (1h/4h/24h/7d/30d),
top-5 agents and channels leaderboards, summary cards. 4 new REST endpoints.

PWA: web app manifest, service worker with cache-first static/network-only
API strategy, push notifications via Web Push API with VAPID keys, push
subscription management endpoints, SQLite migration for subscriptions.

UX fixes: auto-resize compose textarea (3-12 lines), inline editable agent
display name, editable human display name in settings, smart mention/channel
highlighting (existing→link, deleted→inactive badge, unknown→plain text),
font size -/+ preference (12-24px persisted in localStorage), version footer
with GitHub link.

MCP: 4 prompts — daily-digest, agent-health-check, channel-overview,
debug-agent. Registered with prompt capabilities enabled.

Code review fixes: scoped push unsubscribe to user, capped analytics limit
at 100, hardened HTML strip regex, bounded SW cache, backend push unsub on
disable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:58 +02:00
Algis DumbrisandClaude Opus 4.6 d9ad668c31 refactor: move SynapBus protocol to global CLAUDE.md, remove project-level MCP
- Protocol section moved to ~/.claude/CLAUDE.md (available in all projects)
- Removed mcpproxy_lan code_execution section (synapbus connected directly)
- SynapBus MCP added at user level (no API key, uses OAuth)
- Removed per-project synapbus MCP configs from searcher, posts, synapbus

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:58:58 +02:00
Algis DumbrisandClaude Opus 4.6 c3b11f9f3d docs: autonomous execution summary for v0.6.0
7 features, 6 parallel agents, 31+ new tests, zero regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:41:00 +02:00
Algis Dumbris 4e9884a8f5 build: rebuild embedded dist with all v0.6.0 features 2026-03-16 20:39:02 +02:00
Algis Dumbris 0a61781f37 Merge branch 'worktree-agent-a7d51ffb' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis Dumbris 8dd7475d1c Merge branch 'worktree-agent-ada6cb83' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis Dumbris 29d200c2f5 Merge branch 'worktree-agent-ab3c39cc' into 007-platform-features-bundle 2026-03-16 20:38:02 +02:00
Algis DumbrisandClaude Opus 4.6 87f24afd58 feat: enterprise identity provider support — GitHub, Google, Azure AD login
Add external IdP authentication via OAuth (GitHub) and OIDC (Google, Azure AD).
Users can sign in with enterprise credentials; accounts are auto-provisioned
and linked on first login. Configured entirely via environment variables.

- schema/011_external_auth.sql: user_identities table + email column on users
- internal/auth/idp/: provider interface, GitHub OAuth, generic OIDC, store,
  handlers (list providers, login redirect, callback with auto-provisioning)
- internal/auth/user_store.go: GetUserByEmail + SetEmail for IdP linking
- cmd/synapbus/main.go: wire IdP routes + agent provisioner adapter
- web/src/routes/login/+page.svelte: IdP buttons above password form
- Tests: domain restriction, store CRUD, provider listing, user provisioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:37:27 +02:00
Algis DumbrisandClaude Opus 4.6 24a7a33a2c feat: A2A inbound gateway — external agents can send tasks to SynapBus agents
Add JSON-RPC 2.0 endpoint at POST /a2a with three methods:
- message.send: validates target agent, creates tracked task, delivers DM
- tasks.get: returns task state, auto-completes when target agent replies
- tasks.cancel: transitions non-terminal tasks to CANCELED

Includes SQLite migration (010_a2a_tasks), task store, gateway with
interface-based dependencies, and 9 tests covering happy paths and
error cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:31:16 +02:00
Algis DumbrisandClaude Opus 4.6 9a00d7c5ee feat: mobile-responsive Web UI — sidebar drawer, hamburger menu, touch targets
On viewports < 768px (md breakpoint):
- Sidebar slides in as a drawer with dark overlay backdrop
- Hamburger button in the header toggles the sidebar
- Nav link clicks auto-close the drawer
- Sidebar items get 44px min-height for touch-friendly tapping
- Search input uses fluid width instead of fixed 320px

Desktop (>= 768px) behavior is unchanged: sidebar always visible,
main content offset by 260px, no hamburger button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:28:49 +02:00
Algis DumbrisandClaude Opus 4.6 65bdbc5674 feat: add SynapBus Communication Protocol to CLAUDE.md (F8)
Includes: mandatory inbox check, claim-process-done loop, ACK/DONE
channel convention, message formats, StalemateWorker awareness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:28:24 +02:00
Algis Dumbris 1940a2838d Merge branch 'worktree-agent-a37a6029' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis Dumbris 87dc10a336 Merge branch 'worktree-agent-ae256336' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis Dumbris a1890d645b Merge branch 'worktree-agent-acb87cea' into 007-platform-features-bundle 2026-03-16 20:25:00 +02:00
Algis DumbrisandClaude Opus 4.6 050b3cbde3 feat: A2A Agent Card discovery endpoint at /.well-known/agent-card.json
Add public A2A Agent Card endpoint that exposes registered agents as skills
for cross-platform agent discovery. Includes admin CLI for updating agent
capabilities, which populate skill tags and descriptions in the card.

- internal/a2a: new package with AgentCard generator, HTTP handler, and tests
- agents store/service: add ListAllActiveAgents (excludes human accounts)
- admin socket: add agent.update_capabilities command
- admin CLI: add `agent update-capabilities --name --capabilities` subcommand
- main.go: register /.well-known/agent-card.json route (public, no auth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:24:22 +02:00
Algis DumbrisandClaude Opus 4.6 380609b6be feat: stalemate worker — enforce message acknowledgment with reminders and escalation
Background worker that detects stale messages and takes corrective action:
- Auto-fails DMs stuck in "processing" after configurable timeout (default 24h)
- Sends system DM reminders for pending messages after ReminderAfter (default 4h)
- Escalates unprocessed messages to #approvals channel after EscalateAfter (default 48h)
- Deduplicates reminders and escalations to avoid spam
- Configurable via SYNAPBUS_STALEMATE_* environment variables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:22:49 +02:00
Algis DumbrisandClaude Opus 4.6 d0b548f75f feat: add reply_to parameter to send_channel_message for threading support
The BroadcastMessage function now accepts a replyTo parameter, allowing
channel messages to reference a parent message ID and create threads.
Updated all callers (MCP bridge, hybrid tools, tests) and added a test
verifying reply_to is correctly stored and returned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:21:52 +02:00
Algis DumbrisandClaude Opus 4.6 75d6238f1d plan: 007 platform features — research, data model, contracts, quickstart
Phase 0-1 complete: technical decisions, entity schemas (a2a_tasks,
user_identities, identity_providers), API contracts (A2A JSON-RPC,
IdP routes, StalemateWorker config), and quickstart guide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:17:08 +02:00
Algis DumbrisandClaude Opus 4.6 a00394bd2b spec: 007 platform features bundle — StalemateWorker, A2A, mobile, IdP, K8s handlers
8 features specified: StalemateWorker message enforcement, channel reply_to,
A2A Agent Cards + inbound gateway, mobile-responsive UI, K8s reactive agents,
enterprise IdP (GitHub/Google/Azure AD), CLAUDE.md acknowledgment protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:12:48 +02:00
Algis DumbrisandClaude Opus 4.6 bd166843d6 docs: roadmap research — A2A, AG-UI, mobile, K8s agents, enterprise IdP
Synthesized findings from 7 parallel research agents covering:
- A2A integration (Agent Cards + inbound gateway)
- AG-UI assessment (medium-term, complement SSE)
- User-level MCP identity (claude-algis + gemini-algis via MCPProxy)
- Mobile access (responsive Web UI + PWA push)
- Always-online agents (CronJobs + K8s Job Handlers, no daemons)
- Enterprise IdP (GitHub/Google/Azure AD via go-oidc)
- Task acknowledgment (claim-done lifecycle + StalemateWorker)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:30:04 +02:00
Algis DumbrisandClaude Opus 4.6 13ef970bc4 docs: agent communication guide — Claude Code, Gemini CLI, SynapBus integration
Comprehensive guide covering MCP config, CLAUDE.md/GEMINI.md instructions,
skills (/bus, /inbox), hooks for auto-inbox-check, channel design,
message format conventions, cross-agent patterns, and anti-patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:25:58 +02:00
Algis DumbrisandClaude Opus 4.6 1dd332adf7 fix: OAuth token introspection "context canceled" on concurrent MCP connections
Decouple fosite token introspection from the HTTP request context using
context.WithoutCancel + 10s timeout. When claude.ai opens multiple
concurrent MCP connections and one disconnects, the token validation
for subsequent connections no longer fails with "context canceled".

Fixes Bug #6 from #bugs-synapbus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:59:23 +02:00
Algis DumbrisandClaude Opus 4.6 18d8179061 fix: derive OAuth URLs dynamically from request headers for tunnel/proxy support
When SYNAPBUS_BASE_URL is empty or set to "auto", the OAuth metadata
handler now reads X-Forwarded-Proto and X-Forwarded-Host headers to
construct correct OAuth URLs. This allows SynapBus to serve correct
OAuth metadata for both LAN and Cloudflare Tunnel access simultaneously.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:50:06 +02:00
Algis DumbrisandClaude Opus 4.6 fef2705d08 fix: URL auto-linking truncated — extract URLs before HTML escaping
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
The regex matched against HTML-escaped text where &amp; entity chars
broke URL patterns. Now URLs are extracted and replaced with placeholders
before escapeHtml runs, then restored after all other inline processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:08:18 +02:00
Algis DumbrisandClaude Opus 4.6 faad47f0cf fix: rebuild embedded dist with all UI changes integrated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:54:00 +02:00
Algis DumbrisandClaude Opus 4.6 47ba1e9740 feat: rework Conversations page into search-only with Slack-style filters
Replace the Conversations page with a dedicated search page featuring:
- Large, prominent search input with autofocus
- Collapsible filter panel with time range presets (24h, week, month,
  3 months, custom date range), channel filter (comma-separated,
  - prefix to exclude), and agent filter (same syntax)
- Search-results-only display with helpful empty state when no search
  has been performed
- Remove duplicate "Conversations" heading, rename to "Search"
- Update sidebar nav label and icon to match

Backend changes:
- Add channel, agent, after, before query parameters to
  GET /api/messages/search endpoint
- Add Channels, ExcludeChannels, Agents, ExcludeAgents fields to
  SearchOptions with SQL filter generation in store.go
- Support include/exclude semantics via - prefix for both channel
  and agent filters

API client updated to pass new filter parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:47:22 +02:00
Algis Dumbris 6d817867f2 Merge branch 'worktree-agent-adc6e051'
# Conflicts:
#	internal/web/dist/index.html
2026-03-15 19:47:12 +02:00
Algis Dumbris 877900af98 Merge branch 'worktree-agent-a46e8e73' 2026-03-15 19:47:04 +02:00
Algis DumbrisandClaude Opus 4.6 640838d5a6 feat: MessageBody component with markdown, links, @mentions, #channels
Replace plain-text message rendering with a rich MessageBody component
that supports bold, italic, inline code, fenced code blocks, lists,
headers, auto-linked URLs, @mention pills (linking to /dm/{name}), and
#channel pills (linking to /channels/{name}). Input is HTML-sanitized
before processing to prevent XSS. Updated all 5 rendering locations:
channels, DMs, conversations, MessageList, and ThreadPanel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:43:00 +02:00
Algis DumbrisandClaude Opus 4.6 6abd45eeff fix: remove API Keys management section from Settings page
API keys are managed via admin CLI, not the web UI. Remove the
Management section from Settings, delete the api-keys route, and
clean up the Header page-title mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:42:36 +02:00
Algis DumbrisandClaude Opus 4.6 e94dcb84fd fix: consolidate search — remove sidebar search box, enlarge header search
Remove the duplicate "Search messages" button from the sidebar and make
the header search input bigger (w-80, text-sm, larger padding/icon) with
"Search messages..." placeholder matching the removed sidebar text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:42:00 +02:00
Algis DumbrisandClaude Opus 4.6 96d9fbd246 fix: unify favicon and OAuth logo with constellation icon
Replace generic cube favicon and OAuth layered-planes logo with the
same constellation icon used in the sidebar and login page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:07:57 +02:00
Algis DumbrisandClaude Opus 4.6 5938fcd555 fix: bugs #1-3-5 from #bugs-synapbus + live SSE notifications
- Auto-join public channels on first send (bug #1)
- Channel broadcasts no longer create duplicate DM copies; inbox DMs
  only sent for @mentions (bug #2)
- Embedding pipeline auto-enqueues new messages via MessageListener
  callback instead of requiring pod restart (bug #3)
- Admin socket defaults to /tmp in containers to avoid PVC filesystem
  incompatibility with Unix sockets (bug #5)
- SSE events now fire for MCP-sent messages (not just REST API),
  enabling live notification badges without page reload
- Fixed frontend SSE field name mismatch (channel_name → channel)
- Fixed SSE client not connecting after login redirect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:56:30 +02:00
Algis DumbrisandClaude Opus 4.6 e4b0439e6e merge: 006-admin-cli-docker-fixes — alpine base, channels create/join CLI, absolute socket path
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:09:12 +02:00
Algis DumbrisandClaude Opus 4.6 9dd27c4b9b feat: admin CLI & Docker fixes — alpine base, channels create/join, absolute socket path
Switch Docker runtime from scratch to alpine:3.19 so kubectl exec works
for admin CLI operations. Add `synapbus channels create` and
`synapbus channels join` CLI commands with corresponding admin socket
handlers. Change default socket path to /data/synapbus.sock (absolute).
Also add Helm envFrom support and NodePort configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:54:13 +02:00
Algis DumbrisandClaude Opus 4.6 5143e32f83 fix: notification bugs — human-agent-only counts, Svelte 5 lifecycle, API response parsing
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
- Use human agent's perspective only for unread counts (avoids system agent inflation)
- Replace onDestroy + get() with $effect cleanup in channel/DM pages (Svelte 5 compat)
- Fix notification store to parse array-of-objects API response format
- Add last_read_message_id to DM messages endpoint
- Fix test agent type seeding for GetHumanAgentForUser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 09:56:29 +02:00
Algis DumbrisandClaude Opus 4.6 eec06f5b5f feat: web UI notifications — unread badges, new message line, auto mark-as-read
Backend:
- GET /api/notifications/unread returns channel + DM unread counts
- POST /api/notifications/mark-read updates inbox_state for channels/DMs
- SSE broadcaster wired into message send for real-time push
- last_read_message_id added to channel/DM message responses

Frontend:
- Notification store tracks unread counts per channel/DM
- SSE listener for new_message and unread_update events
- Red circular badges in sidebar (Slack-style)
- "New messages" separator line in channel/DM views
- Auto mark-as-read after 2 seconds of viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 09:18:36 +02:00
Algis DumbrisandClaude Opus 4.6 68b7d946b3 fix: scan SQLite text timestamps in channel summary query
sql.NullTime cannot scan SQLite's text-format timestamps from
modernc.org/sqlite. Switch to sql.NullString with manual time.Parse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:43:18 +02:00
Algis DumbrisandClaude Opus 4.6 fe8928a7fc refactor: consolidate 30 MCP tools into 4 hybrid tools (my_status, search, execute, send_message)
- JS/TS execution engine (goja + esbuild) with sandboxed runtime and call() bridge
- BM25 action discovery index over 23 registered actions
- ServiceBridge mapping call() invocations to existing service methods
- Admin CLI subcommands for webhook/k8s/gc operations
- Pagination support (offset/limit) in read_inbox, search_messages, get_channel_messages
- Fixed test assertions for call() envelope structure {ok, result}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:29:37 +02:00
Algis DumbrisandClaude Opus 4.6 fef84ed538 refactor: consolidate 30 MCP tools into 4 hybrid tools
Replace 5 separate tool registrars (messaging, channels, swarm,
attachments, webhooks) with a single HybridToolRegistrar exposing
4 tools: my_status, send_message, search, and execute.

New foundation packages:
- internal/actions: action registry (22 actions) + BM25 search index
- internal/jsruntime: lightweight call() expression parser with
  concurrency-limited execution pool

The `execute` tool dispatches call() expressions through a
ServiceBridge that maps action names to existing service methods,
preserving all original handler logic. The `search` tool enables
agents to discover available actions by keyword. The `send_message`
tool merges DM and channel sending with mutual exclusion.

All unit tests, integration tests, build, and vet pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:16:41 +02:00
Algis Dumbris 275a80326f merge: Task C - Pagination + advanced filtering 2026-03-15 08:00:15 +02:00
Algis Dumbris 8e62319166 merge: Task B - Action registry + BM25 search (internal/actions/) 2026-03-15 08:00:12 +02:00
Algis Dumbris b47070533b merge: Task A - JS/TS runtime engine (internal/jsruntime/) 2026-03-15 08:00:06 +02:00
Algis DumbrisandClaude Opus 4.6 b9d402c135 feat: add offset-based pagination and advanced filtering to service layer
Add Offset, After, Before date filters to ReadOptions and SearchOptions.
Add Channel name filter to SearchOptions. Introduce PaginatedMessages,
PaginatedChannels, and PaginatedTasks types with total counts. Update
ReadInbox, SearchMessages, and GetChannelMessages to return paginated
results. Add CountInboxMessages, CountSearchMessages, CountChannelMessages,
and CountTasks store methods. Update all callers in MCP tools, REST API
handlers, and search service. Add comprehensive tests for offset
pagination, date filtering, channel name filtering, and combined
filters with pagination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:59:33 +02:00
Algis DumbrisandClaude Opus 4.6 7f74357437 feat: add action registry with BM25 search index for tool discovery
Register all 23 agent-callable operations (messaging, channels, swarm,
attachments) with full parameter metadata and usage examples. Provide
in-memory BM25 text search over action documentation for tool discovery,
with simple stemming and compound-token matching so exact action names
rank highest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:54:13 +02:00
Algis DumbrisandClaude Opus 4.6 d5ca5b915f feat: add webhook, k8s, and attachments gc CLI subcommands
Add admin CLI subcommands for managing webhooks, K8s job handlers,
and running attachment garbage collection via the Unix admin socket.

Server-side:
- Add WebhookServiceProvider and K8sServiceProvider interfaces to admin pkg
- Add 7 new command handlers: webhook.{register,list,delete},
  k8s.{register,list,delete}, attachments.gc
- Wire webhook and k8s services into admin.Services struct in main.go

CLI-side:
- Add `synapbus webhook {register,list,delete}` commands
- Add `synapbus k8s {register,list,delete}` commands
- Add `synapbus attachments gc` command
- All commands follow existing patterns (adminRequest, printTable, printJSON)

Tests:
- Add cmd/synapbus/admin_test.go with 9 tests covering command
  registration, required flag validation, and existing command preservation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:53:53 +02:00
Algis DumbrisandClaude Opus 4.6 e8165511de feat: add sandboxed JS/TS runtime engine (internal/jsruntime)
Pure Go JavaScript/TypeScript execution engine using goja + esbuild.
Provides sandboxed code execution with a call() bridge for agent
actions, automatic TypeScript detection and transpilation, timeout
enforcement, max-calls limits, and a concurrent execution pool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:53:25 +02:00
Algis DumbrisandClaude Opus 4.6 0469a7d2e1 plan: implementation plan for 005-hybrid-mcp-tools
3-phase plan: foundation (jsruntime, actions, pagination, CLI),
MCP rewrite (4 tools), website docs. Phase 1 runs in parallel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:48:50 +02:00
Algis DumbrisandClaude Opus 4.6 7502c9fbd7 spec: address review feedback for 005-hybrid-mcp-tools
- Enumerate all 23 actions explicitly in FR-003 action catalog table
- Remove esbuild reference from FR-032 (moved to Assumptions)
- Add FR-043 (channel broadcast semantics), FR-044 (reply_to validation)
- Add FR-039a (memory limit enforcement for code execution)
- Rephrase FR-039 as testable concurrent behavior requirement
- Clarify search tool is for action discovery only (not message search)
- Add relevance score to search results (FR-021)
- Add edge cases: reply_to, memory limits, search vs search_messages
- Add breaking change migration note to Assumptions
- Fix SC-001 to be objectively measurable (count, not token estimate)
- Fix checklist self-assessment accuracy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:36:39 +02:00
Algis DumbrisandClaude Opus 4.6 babf6b89e2 spec: hybrid MCP tool architecture (005)
Redesign 30 MCP tools into 4-tool hybrid architecture:
my_status, search, execute, send_message. Includes JS/TS
code execution engine, BM25 tool discovery, pagination,
advanced filtering, and CLI subcommands for admin ops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:32:55 +02:00
Algis DumbrisandClaude Opus 4.6 67783566d7 feat: embeddings management, message retention & agent inbox improvements
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
- Add `my_status` MCP tool: single call returns agent identity, pending
  DMs, channel mentions, system notifications, channel summaries, and
  stats with truncation for large inboxes
- Add embeddings CLI: `synapbus embeddings status|reindex|clear` for
  managing vectors when switching embedding providers
- Add automatic message retention worker with configurable period
  (--message-retention, default 12m), warning notifications 1 month
  before deletion, cascade cleanup, and incremental vacuum
- Add manual purge: `synapbus messages purge --older-than --agent --channel`
  and `synapbus db vacuum` for on-demand cleanup
- Add `synapbus retention status` CLI for admin visibility
- Create system agent at startup for sending retention warnings
- Filter system agent from discover_agents results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:20:07 +02:00
267 changed files with 40606 additions and 3366 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"45d44ada-86af-4207-b3dd-de510e521157","pid":20439,"acquiredAt":1773554855575}
+26
View File
@@ -8,6 +8,7 @@ on:
permissions:
contents: write
packages: write
id-token: write
env:
GO_VERSION: "1.25"
@@ -217,3 +218,28 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
mcp-registry:
name: Publish to MCP Registry
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
- name: Authenticate to MCP Registry
run: ./mcp-publisher login github-oidc
- name: Update version in server.json
run: |
jq --arg v "${{ steps.version.outputs.VERSION }}" '.version = $v' server.json > server.tmp && mv server.tmp server.json
- name: Publish to MCP Registry
run: ./mcp-publisher publish
+1
View File
@@ -44,3 +44,4 @@ __pycache__/
# Debug
__debug_bin*
.claude/worktrees/
synapbus-linux-amd64
+15
View File
@@ -69,6 +69,7 @@ make lint # Run linters
| `OPENAI_API_KEY` | OpenAI API key for embeddings | (none) |
| `GEMINI_API_KEY` | Google Gemini API key for embeddings | (none) |
| `SYNAPBUS_OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
| `SYNAPBUS_MESSAGE_RETENTION` | Message retention period (e.g. `12m`, `365d`, `0` to disable) | `12m` |
## Conventions
@@ -95,6 +96,20 @@ make lint # Run linters
- modernc.org/sqlite (pure Go), TFMV/hnsw (vectors) (002-mcp-auth-ux-polish)
- Go 1.25+ (from go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), golang.org/x/time/rate (rate limiting), k8s.io/client-go (K8s Jobs — optional) (003-webhooks-k8s-runner)
- modernc.org/sqlite (pure Go), migration 009_webhooks.sql (003-webhooks-k8s-runner)
- Go 1.25+ (per go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors) (004-embeddings-retention-inbox)
- SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory (004-embeddings-retention-inbox)
- Go 1.25+ (per go.mod) + spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP) (006-admin-cli-docker-fixes)
- modernc.org/sqlite (pure Go, zero CGO) (006-admin-cli-docker-fixes)
- Go 1.25+ (per go.mod) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), ory/fosite (OAuth), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors). NEW: coreos/go-oidc/v3 (OIDC), golang.org/x/oauth2 (OAuth client) (007-platform-features-bundle)
- Go 1.25+ (backend), SvelteKit 2 + Svelte 5 (frontend), SvelteKit (website) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), SherClockHolmes/webpush-go (push notifications — NEW) (008-webui-pwa-analytics)
- SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size) (008-webui-pwa-analytics)
- Go 1.25+ (backend), Svelte 5 + Tailwind (frontend) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI) (009-attachments-threads)
- SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256) (009-attachments-threads)
- SQLite (modernc.org/sqlite, pure Go) — new migration 013_reactions.sql (010-reactions-workflows)
- Go 1.25+ (SynapBus), Python 3.12 (Searcher agents) + go-chi/chi, mark3labs/mcp-go, ory/fosite (SynapBus); claude-agent-sdk, httpx, psycopg (Searcher) (013-linkedin-approval-workflow)
- SQLite via modernc.org/sqlite (SynapBus); PostgreSQL (Searcher) (013-linkedin-approval-workflow)
- Go 1.25+ (per go.mod) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), spf13/cobra (CLI), modernc.org/sqlite (storage), k8s.io/client-go (K8s Jobs) (014-reactive-agent-triggers)
- SQLite via modernc.org/sqlite — new migration 015_reactive_triggers.sql (014-reactive-agent-triggers)
## Recent Changes
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
+2 -3
View File
@@ -18,9 +18,8 @@ COPY --from=web-builder /app/web/build internal/web/dist/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /synapbus ./cmd/synapbus/
# Stage 3: Runtime
FROM scratch
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=go-builder /usr/share/zoneinfo /usr/share/zoneinfo
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
COPY --from=go-builder /synapbus /synapbus
EXPOSE 8080
VOLUME ["/data"]
+101
View File
@@ -0,0 +1,101 @@
# Autonomous Implementation Summary: Message Reactions & Workflow States
**Branch**: `010-reactions-workflows`
**Date**: 2026-03-18
**Status**: Complete (StalemateWorker extension deferred)
## What Was Built
### Message Reactions
- **Toggle semantics**: Add a reaction → added. Add same reaction again → removed. One per type per agent per message.
- **5 reaction types**: approve, reject, in_progress, done, published
- **Metadata support**: JSON metadata on reactions (e.g., `{"url": "https://..."}` for published)
- **100-reaction limit** per message (safety)
### Workflow State Derivation
- State computed from reactions: published > done > rejected > in_progress > approved > proposed
- No denormalization — state derived on read from reaction list
- Channel messages with no reactions → "proposed" state
- Terminal states (rejected, done, published) don't trigger stalemate checks
### Channel Workflow Settings
- `auto_approve` — skip proposed state for new messages
- `stalemate_remind_after` — duration before reminder DM (default 24h)
- `stalemate_escalate_after` — duration before escalation to #approvals (default 72h)
### REST API
- `POST /api/messages/{id}/reactions` — toggle reaction (add or remove)
- `GET /api/messages/{id}/reactions` — get reactions + workflow state
- `DELETE /api/messages/{id}/reactions/{reaction}` — remove reaction
- `PUT /api/channels/{name}/settings` — update workflow settings
- `GET /api/channels/{name}/messages/by-state?state=X` — list messages by state
### MCP Tools (via execute bridge)
- `react` — add/toggle reaction on a message
- `unreact` — remove a reaction
- `get_reactions` — query reactions and workflow state
- `list_by_state` — list messages by workflow state in a channel
### Web UI
- **WorkflowBadge** component: colored pills (yellow/green/blue/red/gray/cyan) per state
- **ReactionPills** component: grouped reaction pills with count, agent names on hover, click-to-toggle
- Published reactions with URL show clickable link icon
- Integrated into channel message view
### Admin CLI
- `synapbus channels update --name X --auto-approve=true --stalemate-remind-after=12h --stalemate-escalate-after=48h`
## Files Created/Modified
### New Files
| File | Description |
|------|-------------|
| `internal/storage/schema/013_reactions.sql` | Migration: message_reactions table + channel columns |
| `internal/reactions/model.go` | Reaction types, state derivation, constants |
| `internal/reactions/store.go` | SQLite CRUD for reactions |
| `internal/reactions/service.go` | Business logic: toggle, remove, get, list by state |
| `internal/reactions/model_test.go` | 23 test cases for model functions |
| `internal/reactions/store_test.go` | 6 test functions for store operations |
| `internal/api/reactions_handler.go` | REST API handlers for reactions |
| `web/src/lib/components/WorkflowBadge.svelte` | Colored state badge component |
| `web/src/lib/components/ReactionPills.svelte` | Reaction toggle pills component |
### Modified Files
| File | Changes |
|------|---------|
| `internal/messaging/types.go` | Added WorkflowState, Reactions, ReactionInfo to Message |
| `internal/messaging/service.go` | Added ReactionEnricher interface, enrichment in EnrichMessages |
| `internal/channels/types.go` | Added AutoApprove, StalemateRemindAfter, StalemateEscalateAfter, ChannelSettings |
| `internal/channels/store.go` | Updated SELECT queries for new columns, added UpdateChannelSettings |
| `internal/channels/service.go` | Added UpdateChannelSettings method |
| `internal/api/router.go` | Registered reaction and channel settings routes |
| `internal/api/channels_handler.go` | Added UpdateSettings, ListByState handlers |
| `internal/mcp/bridge.go` | Added react/unreact/get_reactions/list_by_state bridge methods |
| `internal/mcp/tools_hybrid.go` | Added reactionService to registrar |
| `internal/mcp/server.go` | Added reactionService parameter |
| `internal/actions/registry.go` | Registered 4 new reaction actions |
| `cmd/synapbus/main.go` | Wired reaction service, adapter, passed to router+MCP |
| `cmd/synapbus/admin.go` | Added channels update CLI command |
| `internal/admin/socket.go` | Added channels.update_settings handler |
| `web/src/lib/api/client.ts` | Added reactions.toggle/get methods |
| `web/src/routes/channels/[name]/+page.svelte` | Integrated WorkflowBadge + ReactionPills |
## Test Results
- **25 Go test packages**: all pass, 0 failures
- **New tests**: 29+ test cases (model: 23, store: 6)
- **Integration tests**: 9 E2E tests pass
- **Web build**: Svelte SPA builds successfully
- **Binary build**: Compiles cleanly
## Deferred
- **StalemateWorker extension** (T023-T025): The data model, channel settings, and query infrastructure are in place. The worker just needs a scan loop added to detect stale messages and send DMs/escalations. This is a straightforward follow-up task.
## Architecture Decisions
1. **Separate reactions package**: Clean domain separation from messaging
2. **Toggle semantics**: INSERT if absent, DELETE if present — simple, atomic, idempotent
3. **Derived workflow state**: No denormalization; state computed from reactions on read
4. **Bridge actions (not hybrid tools)**: Consistent with attachments pattern — 4 hybrid tools are stable surface area
5. **ReactionEnricher adapter**: Avoids circular dependency between reactions and messaging packages
+641 -7
View File
@@ -1,11 +1,15 @@
package main
import (
"archive/tar"
"bufio"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"text/tabwriter"
@@ -17,7 +21,7 @@ var adminSocket string
// adminRequest sends a command over the Unix socket and returns the parsed response.
func adminRequest(command string, args interface{}) (map[string]interface{}, error) {
socket := adminSocket
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "./data/synapbus.sock" {
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "/tmp/synapbus.sock" {
socket = s
}
@@ -308,7 +312,31 @@ func addAdminCommands(rootCmd *cobra.Command) {
agentRevokeKeyCmd.Flags().StringVar(&agentRevokeKeyName, "name", "", "Agent name")
agentRevokeKeyCmd.MarkFlagRequired("name")
agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd)
var (
agentUpdateCapsName string
agentUpdateCapsJSON string
)
agentUpdateCapsCmd := &cobra.Command{
Use: "update-capabilities",
Short: "Update an agent's capabilities JSON",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("agent.update_capabilities", map[string]interface{}{
"name": agentUpdateCapsName,
"capabilities": json.RawMessage(agentUpdateCapsJSON),
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsName, "name", "", "Agent name")
agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsJSON, "capabilities", "", "Capabilities JSON (e.g. '{\"role\":\"researcher\"}')")
agentUpdateCapsCmd.MarkFlagRequired("name")
agentUpdateCapsCmd.MarkFlagRequired("capabilities")
agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd, agentUpdateCapsCmd)
// ----- audit commands -----
auditCmd := &cobra.Command{
@@ -480,7 +508,41 @@ func addAdminCommands(rootCmd *cobra.Command) {
messagesSearchCmd.Flags().IntVar(&messagesSearchLimit, "limit", 20, "Max results")
messagesSearchCmd.MarkFlagRequired("query")
messagesCmd.AddCommand(messagesListCmd, messagesSearchCmd)
var (
messagesPurgeOlderThan string
messagesPurgeAgent string
messagesPurgeChannel string
)
messagesPurgeCmd := &cobra.Command{
Use: "purge",
Short: "Delete messages matching filters (at least one filter required)",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if messagesPurgeOlderThan != "" {
reqArgs["older_than"] = messagesPurgeOlderThan
}
if messagesPurgeAgent != "" {
reqArgs["agent"] = messagesPurgeAgent
}
if messagesPurgeChannel != "" {
reqArgs["channel"] = messagesPurgeChannel
}
if len(reqArgs) == 0 {
return fmt.Errorf("at least one filter is required (--older-than, --agent, or --channel)")
}
resp, err := adminRequest("messages.purge", reqArgs)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
messagesPurgeCmd.Flags().StringVar(&messagesPurgeOlderThan, "older-than", "", "Delete messages older than this (e.g. 6m, 90d, 2160h)")
messagesPurgeCmd.Flags().StringVar(&messagesPurgeAgent, "agent", "", "Delete messages from/to this agent")
messagesPurgeCmd.Flags().StringVar(&messagesPurgeChannel, "channel", "", "Delete messages in this channel")
messagesCmd.AddCommand(messagesListCmd, messagesSearchCmd, messagesPurgeCmd)
// ----- channels commands -----
channelsCmd := &cobra.Command{
@@ -527,7 +589,93 @@ func addAdminCommands(rootCmd *cobra.Command) {
channelsShowCmd.Flags().StringVar(&channelsShowName, "name", "", "Channel name")
channelsShowCmd.MarkFlagRequired("name")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd)
var (
channelsCreateName string
channelsCreateDesc string
)
channelsCreateCmd := &cobra.Command{
Use: "create",
Short: "Create a new channel",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.create", map[string]string{
"name": channelsCreateName,
"description": channelsCreateDesc,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsCreateCmd.Flags().StringVar(&channelsCreateName, "name", "", "Channel name")
channelsCreateCmd.Flags().StringVar(&channelsCreateDesc, "description", "", "Channel description")
channelsCreateCmd.MarkFlagRequired("name")
var (
channelsJoinChannel string
channelsJoinAgent string
)
channelsJoinCmd := &cobra.Command{
Use: "join",
Short: "Add an agent to a channel",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.join", map[string]string{
"channel": channelsJoinChannel,
"agent": channelsJoinAgent,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsJoinCmd.Flags().StringVar(&channelsJoinChannel, "channel", "", "Channel name")
channelsJoinCmd.Flags().StringVar(&channelsJoinAgent, "agent", "", "Agent name")
channelsJoinCmd.MarkFlagRequired("channel")
channelsJoinCmd.MarkFlagRequired("agent")
var (
channelsUpdateName string
channelsUpdateAutoApprove string
channelsUpdateStalemateRemind string
channelsUpdateStalemateEscalate string
)
channelsUpdateCmd := &cobra.Command{
Use: "update",
Short: "Update channel settings (auto-approve, stalemate timers)",
RunE: func(cmd *cobra.Command, args []string) error {
if channelsUpdateName == "" {
return fmt.Errorf("--name is required")
}
reqArgs := map[string]interface{}{
"name": channelsUpdateName,
}
if cmd.Flags().Changed("auto-approve") {
reqArgs["auto_approve"] = channelsUpdateAutoApprove == "true"
}
if cmd.Flags().Changed("stalemate-remind-after") {
reqArgs["stalemate_remind_after"] = channelsUpdateStalemateRemind
}
if cmd.Flags().Changed("stalemate-escalate-after") {
reqArgs["stalemate_escalate_after"] = channelsUpdateStalemateEscalate
}
resp, err := adminRequest("channels.update_settings", reqArgs)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsUpdateCmd.Flags().StringVar(&channelsUpdateName, "name", "", "Channel name")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateAutoApprove, "auto-approve", "", "Auto-approve messages (true|false)")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateStalemateRemind, "stalemate-remind-after", "", "Stalemate reminder duration (e.g. 24h)")
channelsUpdateCmd.Flags().StringVar(&channelsUpdateStalemateEscalate, "stalemate-escalate-after", "", "Stalemate escalation duration (e.g. 72h)")
channelsUpdateCmd.MarkFlagRequired("name")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd, channelsUpdateCmd)
// ----- conversations commands -----
conversationsCmd := &cobra.Command{
@@ -582,10 +730,353 @@ func addAdminCommands(rootCmd *cobra.Command) {
conversationsCmd.AddCommand(conversationsListCmd, conversationsShowCmd)
// ----- add persistent flag and commands to root -----
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "./data/synapbus.sock", "Path to admin Unix socket")
// ----- embeddings commands -----
embeddingsCmd := &cobra.Command{
Use: "embeddings",
Short: "Manage embedding vectors",
}
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd)
embeddingsStatusCmd := &cobra.Command{
Use: "status",
Short: "Show embedding status (provider, counts, index size)",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("embeddings.status", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
embeddingsReindexCmd := &cobra.Command{
Use: "reindex",
Short: "Clear all embeddings and re-queue all messages for embedding",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("embeddings.reindex", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
embeddingsClearCmd := &cobra.Command{
Use: "clear",
Short: "Delete all embeddings and clear the vector index",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("embeddings.clear", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
embeddingsCmd.AddCommand(embeddingsStatusCmd, embeddingsReindexCmd, embeddingsClearCmd)
// ----- db commands -----
dbCmd := &cobra.Command{
Use: "db",
Short: "Database maintenance",
}
dbVacuumCmd := &cobra.Command{
Use: "vacuum",
Short: "Compact the database to reclaim disk space",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("db.vacuum", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
dbCmd.AddCommand(dbVacuumCmd)
// ----- retention commands -----
retentionCmd := &cobra.Command{
Use: "retention",
Short: "Message retention management",
}
retentionStatusCmd := &cobra.Command{
Use: "status",
Short: "Show retention configuration and status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("retention.status", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
retentionCmd.AddCommand(retentionStatusCmd)
// ----- webhook commands -----
webhookCmd := &cobra.Command{
Use: "webhook",
Short: "Manage webhooks",
}
var (
webhookRegisterURL string
webhookRegisterEvents string
webhookRegisterSecret string
webhookRegisterAgent string
)
webhookRegisterCmd := &cobra.Command{
Use: "register",
Short: "Register a webhook",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("webhook.register", map[string]string{
"url": webhookRegisterURL,
"events": webhookRegisterEvents,
"secret": webhookRegisterSecret,
"agent_name": webhookRegisterAgent,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
webhookRegisterCmd.Flags().StringVar(&webhookRegisterURL, "url", "", "Webhook endpoint URL")
webhookRegisterCmd.Flags().StringVar(&webhookRegisterEvents, "events", "", "Comma-separated event types (e.g. message.received,channel.message)")
webhookRegisterCmd.Flags().StringVar(&webhookRegisterSecret, "secret", "", "HMAC signing secret")
webhookRegisterCmd.Flags().StringVar(&webhookRegisterAgent, "agent", "", "Agent name to hook events for")
webhookRegisterCmd.MarkFlagRequired("url")
webhookRegisterCmd.MarkFlagRequired("events")
webhookRegisterCmd.MarkFlagRequired("secret")
webhookRegisterCmd.MarkFlagRequired("agent")
var webhookListAgent string
webhookListCmd := &cobra.Command{
Use: "list",
Short: "List webhooks",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if webhookListAgent != "" {
reqArgs["agent_name"] = webhookListAgent
}
resp, err := adminRequest("webhook.list", reqArgs)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No webhooks found.")
return nil
}
printTable([]string{"ID", "AGENT", "URL", "EVENTS", "STATUS", "FAILURES", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "AGENT": "agent_name", "URL": "url", "EVENTS": "events",
"STATUS": "status", "FAILURES": "consecutive_failures", "CREATED_AT": "created_at",
}))
return nil
},
}
webhookListCmd.Flags().StringVar(&webhookListAgent, "agent", "", "Filter by agent name")
var webhookDeleteID int64
webhookDeleteCmd := &cobra.Command{
Use: "delete",
Short: "Delete a webhook",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("webhook.delete", map[string]interface{}{
"id": webhookDeleteID,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
webhookDeleteCmd.Flags().Int64Var(&webhookDeleteID, "id", 0, "Webhook ID to delete")
webhookDeleteCmd.MarkFlagRequired("id")
webhookCmd.AddCommand(webhookRegisterCmd, webhookListCmd, webhookDeleteCmd)
// ----- k8s commands -----
k8sCmd := &cobra.Command{
Use: "k8s",
Short: "Manage Kubernetes job handlers",
}
var (
k8sRegisterImage string
k8sRegisterEvents string
k8sRegisterAgent string
k8sRegisterNamespace string
k8sRegisterMemory string
k8sRegisterCPU string
k8sRegisterEnv string
k8sRegisterTimeout int
)
k8sRegisterCmd := &cobra.Command{
Use: "register",
Short: "Register a K8s job handler",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{
"image": k8sRegisterImage,
"events": k8sRegisterEvents,
"agent_name": k8sRegisterAgent,
}
if k8sRegisterNamespace != "" {
reqArgs["namespace"] = k8sRegisterNamespace
}
if k8sRegisterMemory != "" {
reqArgs["resources_memory"] = k8sRegisterMemory
}
if k8sRegisterCPU != "" {
reqArgs["resources_cpu"] = k8sRegisterCPU
}
if k8sRegisterEnv != "" {
reqArgs["env"] = k8sRegisterEnv
}
if k8sRegisterTimeout > 0 {
reqArgs["timeout_seconds"] = k8sRegisterTimeout
}
resp, err := adminRequest("k8s.register", reqArgs)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
k8sRegisterCmd.Flags().StringVar(&k8sRegisterImage, "image", "", "Container image")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterEvents, "events", "", "Comma-separated event types")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterAgent, "agent", "", "Agent name")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterNamespace, "namespace", "", "Kubernetes namespace (optional)")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterMemory, "memory", "", "Memory resource limit (e.g. 256Mi)")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterCPU, "cpu", "", "CPU resource limit (e.g. 500m)")
k8sRegisterCmd.Flags().StringVar(&k8sRegisterEnv, "env", "", "Comma-separated KEY=VALUE environment variables")
k8sRegisterCmd.Flags().IntVar(&k8sRegisterTimeout, "timeout", 300, "Job timeout in seconds")
k8sRegisterCmd.MarkFlagRequired("image")
k8sRegisterCmd.MarkFlagRequired("events")
k8sRegisterCmd.MarkFlagRequired("agent")
var k8sListAgent string
k8sListCmd := &cobra.Command{
Use: "list",
Short: "List K8s job handlers",
RunE: func(cmd *cobra.Command, args []string) error {
reqArgs := map[string]interface{}{}
if k8sListAgent != "" {
reqArgs["agent_name"] = k8sListAgent
}
resp, err := adminRequest("k8s.list", reqArgs)
if err != nil {
return err
}
rows := toMapSlice(resp["data"])
if len(rows) == 0 {
fmt.Println("No K8s handlers found.")
return nil
}
printTable([]string{"ID", "AGENT", "IMAGE", "EVENTS", "NAMESPACE", "STATUS", "CREATED_AT"}, toTableRows(rows, map[string]string{
"ID": "id", "AGENT": "agent_name", "IMAGE": "image", "EVENTS": "events",
"NAMESPACE": "namespace", "STATUS": "status", "CREATED_AT": "created_at",
}))
return nil
},
}
k8sListCmd.Flags().StringVar(&k8sListAgent, "agent", "", "Filter by agent name")
var k8sDeleteID int64
k8sDeleteCmd := &cobra.Command{
Use: "delete",
Short: "Delete a K8s job handler",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("k8s.delete", map[string]interface{}{
"id": k8sDeleteID,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
k8sDeleteCmd.Flags().Int64Var(&k8sDeleteID, "id", 0, "Handler ID to delete")
k8sDeleteCmd.MarkFlagRequired("id")
k8sCmd.AddCommand(k8sRegisterCmd, k8sListCmd, k8sDeleteCmd)
// ----- attachments commands -----
attachmentsCmd := &cobra.Command{
Use: "attachments",
Short: "Manage attachments",
}
attachmentsGCCmd := &cobra.Command{
Use: "gc",
Short: "Run attachment garbage collection to remove orphaned files",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("attachments.gc", nil)
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
var attachmentsBackupOutput string
var attachmentsBackupDataDir string
attachmentsBackupCmd := &cobra.Command{
Use: "backup",
Short: "Create a tar.gz backup of all attachments (no server required)",
RunE: func(cmd *cobra.Command, args []string) error {
attachDir := filepath.Join(attachmentsBackupDataDir, "attachments")
if _, err := os.Stat(attachDir); os.IsNotExist(err) {
return fmt.Errorf("attachments directory does not exist: %s", attachDir)
}
fileCount, totalSize, err := backupAttachments(attachDir, attachmentsBackupOutput)
if err != nil {
return fmt.Errorf("backup failed: %w", err)
}
fmt.Printf("Backup complete: %d files, %s total, written to %s\n", fileCount, formatBytes(totalSize), attachmentsBackupOutput)
return nil
},
}
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupOutput, "output", "", "Output path for the tar.gz archive")
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupDataDir, "data", "./data", "Data directory")
attachmentsBackupCmd.MarkFlagRequired("output")
var attachmentsRestoreInput string
var attachmentsRestoreDataDir string
attachmentsRestoreCmd := &cobra.Command{
Use: "restore",
Short: "Restore attachments from a tar.gz backup (no server required)",
RunE: func(cmd *cobra.Command, args []string) error {
attachDir := filepath.Join(attachmentsRestoreDataDir, "attachments")
restored, skipped, err := restoreAttachments(attachDir, attachmentsRestoreInput)
if err != nil {
return fmt.Errorf("restore failed: %w", err)
}
fmt.Printf("Restore complete: %d files restored, %d files skipped (already exist)\n", restored, skipped)
return nil
},
}
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreInput, "input", "", "Input path for the tar.gz archive")
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreDataDir, "data", "./data", "Data directory")
attachmentsRestoreCmd.MarkFlagRequired("input")
attachmentsCmd.AddCommand(attachmentsGCCmd, attachmentsBackupCmd, attachmentsRestoreCmd)
// ----- add persistent flag and commands to root -----
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket")
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd)
}
// toTableRows remaps []map[string]string using a header->key mapping.
@@ -605,3 +1096,146 @@ func toTableRows(data []map[string]string, headerMap map[string]string) []map[st
}
return rows
}
// backupAttachments creates a tar.gz archive of the attachments directory.
// Returns the number of files archived and total bytes of file content.
func backupAttachments(attachmentsDir, outputPath string) (int, int64, error) {
outFile, err := os.Create(outputPath)
if err != nil {
return 0, 0, fmt.Errorf("create output file: %w", err)
}
defer outFile.Close()
gzw := gzip.NewWriter(outFile)
defer gzw.Close()
tw := tar.NewWriter(gzw)
defer tw.Close()
var fileCount int
var totalSize int64
err = filepath.Walk(attachmentsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories — tar entries for files include the path.
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(attachmentsDir, path)
if err != nil {
return fmt.Errorf("relative path: %w", err)
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("file info header: %w", err)
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write header: %w", err)
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return fmt.Errorf("copy file: %w", err)
}
fileCount++
totalSize += info.Size()
return nil
})
return fileCount, totalSize, err
}
// restoreAttachments extracts a tar.gz archive into the attachments directory.
// Files that already exist on disk are skipped. Returns (restored, skipped) counts.
func restoreAttachments(attachmentsDir, inputPath string) (int, int, error) {
inFile, err := os.Open(inputPath)
if err != nil {
return 0, 0, fmt.Errorf("open input file: %w", err)
}
defer inFile.Close()
gzr, err := gzip.NewReader(inFile)
if err != nil {
return 0, 0, fmt.Errorf("gzip reader: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
var restored, skipped int
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return restored, skipped, fmt.Errorf("read tar entry: %w", err)
}
// Only handle regular files.
if header.Typeflag != tar.TypeReg {
continue
}
// Sanitize: reject absolute paths and path traversal.
cleanName := filepath.Clean(header.Name)
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, "..") {
return restored, skipped, fmt.Errorf("invalid path in archive: %s", header.Name)
}
destPath := filepath.Join(attachmentsDir, cleanName)
// Skip if already exists (content-addressable, so same hash = same content).
if _, err := os.Stat(destPath); err == nil {
skipped++
continue
}
// Ensure parent directory exists.
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return restored, skipped, fmt.Errorf("create directory: %w", err)
}
outFile, err := os.Create(destPath)
if err != nil {
return restored, skipped, fmt.Errorf("create file: %w", err)
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return restored, skipped, fmt.Errorf("write file: %w", err)
}
outFile.Close()
restored++
}
return restored, skipped, nil
}
// formatBytes returns a human-readable byte count string.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"testing"
"github.com/spf13/cobra"
)
// findSubcommand finds a subcommand by name in a cobra.Command tree.
func findSubcommand(root *cobra.Command, names ...string) *cobra.Command {
cmd := root
for _, name := range names {
found := false
for _, sub := range cmd.Commands() {
if sub.Name() == name {
cmd = sub
found = true
break
}
}
if !found {
return nil
}
}
return cmd
}
// buildTestRoot creates a root command with all admin commands registered.
func buildTestRoot() *cobra.Command {
root := &cobra.Command{Use: "synapbus"}
addAdminCommands(root)
return root
}
func TestWebhookCommandsRegistered(t *testing.T) {
root := buildTestRoot()
tests := []struct {
path []string
}{
{[]string{"webhook"}},
{[]string{"webhook", "register"}},
{[]string{"webhook", "list"}},
{[]string{"webhook", "delete"}},
}
for _, tt := range tests {
cmd := findSubcommand(root, tt.path...)
if cmd == nil {
t.Errorf("command %v not found", tt.path)
}
}
}
func TestK8sCommandsRegistered(t *testing.T) {
root := buildTestRoot()
tests := []struct {
path []string
}{
{[]string{"k8s"}},
{[]string{"k8s", "register"}},
{[]string{"k8s", "list"}},
{[]string{"k8s", "delete"}},
}
for _, tt := range tests {
cmd := findSubcommand(root, tt.path...)
if cmd == nil {
t.Errorf("command %v not found", tt.path)
}
}
}
func TestAttachmentsCommandsRegistered(t *testing.T) {
root := buildTestRoot()
tests := []struct {
path []string
}{
{[]string{"attachments"}},
{[]string{"attachments", "gc"}},
}
for _, tt := range tests {
cmd := findSubcommand(root, tt.path...)
if cmd == nil {
t.Errorf("command %v not found", tt.path)
}
}
}
func TestWebhookRegisterRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "webhook", "register")
if cmd == nil {
t.Fatal("webhook register command not found")
}
requiredFlags := []string{"url", "events", "secret", "agent"}
for _, flag := range requiredFlags {
f := cmd.Flag(flag)
if f == nil {
t.Errorf("flag --%s not found on webhook register", flag)
continue
}
ann := f.Annotations
if ann == nil {
t.Errorf("flag --%s should be required", flag)
continue
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Errorf("flag --%s should be required", flag)
}
}
}
func TestWebhookDeleteRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "webhook", "delete")
if cmd == nil {
t.Fatal("webhook delete command not found")
}
f := cmd.Flag("id")
if f == nil {
t.Fatal("flag --id not found on webhook delete")
}
ann := f.Annotations
if ann == nil {
t.Fatal("flag --id should be required")
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("flag --id should be required")
}
}
func TestK8sRegisterRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "k8s", "register")
if cmd == nil {
t.Fatal("k8s register command not found")
}
requiredFlags := []string{"image", "events", "agent"}
for _, flag := range requiredFlags {
f := cmd.Flag(flag)
if f == nil {
t.Errorf("flag --%s not found on k8s register", flag)
continue
}
ann := f.Annotations
if ann == nil {
t.Errorf("flag --%s should be required", flag)
continue
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Errorf("flag --%s should be required", flag)
}
}
}
func TestK8sDeleteRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "k8s", "delete")
if cmd == nil {
t.Fatal("k8s delete command not found")
}
f := cmd.Flag("id")
if f == nil {
t.Fatal("flag --id not found on k8s delete")
}
ann := f.Annotations
if ann == nil {
t.Fatal("flag --id should be required")
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("flag --id should be required")
}
}
func TestK8sRegisterOptionalFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "k8s", "register")
if cmd == nil {
t.Fatal("k8s register command not found")
}
optionalFlags := []string{"namespace", "memory", "cpu", "env", "timeout"}
for _, flag := range optionalFlags {
f := cmd.Flag(flag)
if f == nil {
t.Errorf("optional flag --%s not found on k8s register", flag)
}
}
}
func TestChannelsCreateCommandRegistered(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "create")
if cmd == nil {
t.Fatal("channels create command not found")
}
}
func TestChannelsCreateRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "create")
if cmd == nil {
t.Fatal("channels create command not found")
}
// --name is required
f := cmd.Flag("name")
if f == nil {
t.Fatal("flag --name not found on channels create")
}
ann := f.Annotations
if ann == nil {
t.Fatal("flag --name should be required")
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("flag --name should be required")
}
// --description is optional
df := cmd.Flag("description")
if df == nil {
t.Fatal("flag --description not found on channels create")
}
}
func TestChannelsJoinCommandRegistered(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "join")
if cmd == nil {
t.Fatal("channels join command not found")
}
}
func TestChannelsJoinRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "join")
if cmd == nil {
t.Fatal("channels join command not found")
}
requiredFlags := []string{"channel", "agent"}
for _, flag := range requiredFlags {
f := cmd.Flag(flag)
if f == nil {
t.Errorf("flag --%s not found on channels join", flag)
continue
}
ann := f.Annotations
if ann == nil {
t.Errorf("flag --%s should be required", flag)
continue
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Errorf("flag --%s should be required", flag)
}
}
}
func TestDefaultSocketPath(t *testing.T) {
root := buildTestRoot()
f := root.PersistentFlags().Lookup("socket")
if f == nil {
t.Fatal("--socket persistent flag not found")
}
if f.DefValue != "/tmp/synapbus.sock" {
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
}
}
func TestExistingCommandsStillPresent(t *testing.T) {
root := buildTestRoot()
// Verify existing commands are not broken by our additions.
existingCmds := []string{"user", "agent", "audit", "backup", "messages", "channels", "conversations", "embeddings", "db", "retention"}
for _, name := range existingCmds {
cmd := findSubcommand(root, name)
if cmd == nil {
t.Errorf("existing command %q not found after adding new commands", name)
}
}
}
+339 -14
View File
@@ -23,24 +23,33 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/cobra"
"github.com/synapbus/synapbus/internal/a2a"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/admin"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/api"
"github.com/synapbus/synapbus/internal/apikeys"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/auth/idp"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/dispatcher"
"github.com/synapbus/synapbus/internal/health"
"github.com/synapbus/synapbus/internal/jsruntime"
k8spkg "github.com/synapbus/synapbus/internal/k8s"
mcpserver "github.com/synapbus/synapbus/internal/mcp"
"github.com/synapbus/synapbus/internal/agentquery"
reactorpkg "github.com/synapbus/synapbus/internal/reactor"
"github.com/synapbus/synapbus/internal/messaging"
prommetrics "github.com/synapbus/synapbus/internal/metrics"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/search/embedding"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/push"
"github.com/synapbus/synapbus/internal/trace"
"github.com/synapbus/synapbus/internal/trust"
"github.com/synapbus/synapbus/internal/web"
"github.com/synapbus/synapbus/internal/webhooks"
)
@@ -57,6 +66,7 @@ var (
traceRetention string
adminSocketPath string
webhookWorkers int
messageRetention string
)
func main() {
@@ -81,6 +91,7 @@ func main() {
serveCmd.Flags().StringVar(&traceRetention, "trace-retention", "0", "Trace retention period (e.g. 30d, 90d, 0 for unlimited)")
serveCmd.Flags().StringVar(&adminSocketPath, "admin-socket", "", "Admin Unix socket path (default: {data}/synapbus.sock)")
serveCmd.Flags().IntVar(&webhookWorkers, "webhook-workers", 8, "Number of webhook delivery worker goroutines")
serveCmd.Flags().StringVar(&messageRetention, "message-retention", "12m", "Message retention period (e.g. 12m, 365d, 0 to disable)")
rootCmd.AddCommand(serveCmd)
@@ -153,8 +164,17 @@ func runServe(cmd *cobra.Command, args []string) error {
if ww := os.Getenv("SYNAPBUS_WEBHOOK_WORKERS"); ww != "" {
fmt.Sscanf(ww, "%d", &webhookWorkers)
}
if mr := os.Getenv("SYNAPBUS_MESSAGE_RETENTION"); mr != "" {
messageRetention = mr
}
if adminSocketPath == "" {
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
// Default to /tmp in containers — PVC-backed filesystems (NFS, Ceph,
// EBS CSI) often don't support Unix domain sockets.
if _, err := os.Stat("/.dockerenv"); err == nil {
adminSocketPath = "/tmp/synapbus.sock"
} else {
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
}
}
// Configure slog with JSON handler writing to stderr (stdout is for console output)
@@ -229,6 +249,11 @@ func runServe(cmd *cobra.Command, args []string) error {
deadLetterStore := messaging.NewDeadLetterStore(db.DB)
agentService.SetDeadLetterStore(deadLetterStore)
// Ensure system agent exists (used for retention warnings and system notifications)
if _, err := agentService.EnsureSystemAgent(ctx, 1); err != nil {
slog.Warn("failed to create system agent", "error", err)
}
channelStore := channels.NewSQLiteChannelStore(db.DB)
channelService := channels.NewService(channelStore, msgService, tracer)
@@ -260,8 +285,20 @@ func runServe(cmd *cobra.Command, args []string) error {
}
attachmentStore := attachments.NewSQLiteStore(db.DB, slog.Default())
attachmentService := attachments.NewService(attachmentStore, cas, slog.Default())
msgService.SetAttachmentLinker(&attachmentLinkerAdapter{svc: attachmentService})
slog.Info("attachment service initialized", "dir", attachmentsDir)
// Create reaction service
reactionStore := reactions.NewSQLiteStore(db.DB)
reactionService := reactions.NewService(reactionStore, slog.Default())
msgService.SetReactionEnricher(&reactionEnricherAdapter{svc: reactionService})
slog.Info("reaction service initialized")
// Create trust service
trustStore := trust.NewSQLiteStore(db.DB)
trustService := trust.NewService(trustStore, slog.Default())
slog.Info("trust service initialized")
// Initialize auth subsystem
authSecret := make([]byte, 32)
if _, err := rand.Read(authSecret); err != nil {
@@ -287,6 +324,13 @@ func runServe(cmd *cobra.Command, args []string) error {
// Wire agent lister into auth handlers for OAuth authorize page
authHandlers.SetAgentLister(&agentListerAdapter{agentService: agentService})
// Initialize external identity providers (GitHub, Google, Azure AD)
baseURL := authCfg.IssuerURL
if baseURL == "" {
baseURL = fmt.Sprintf("http://localhost:%d", port)
}
idpProviders := idp.LoadConfig(baseURL)
// Register default MCP OAuth client if it doesn't already exist (T016)
ensureDefaultMCPClient(ctx, db.DB, authCfg.BcryptCost)
@@ -377,6 +421,9 @@ func runServe(cmd *cobra.Command, args []string) error {
embPipeline = search.NewPipeline(embProvider, embStore, vectorIndex, searchCfg)
embPipeline.Start(ctx)
// Wire pipeline into messaging so new messages auto-enqueue
msgService.SetEmbeddingNotifier(embPipeline)
// Create search service with semantic support
searchService = search.NewService(db.DB, embProvider, vectorIndex, msgService)
slog.Info("semantic search enabled",
@@ -413,7 +460,7 @@ func runServe(cmd *cobra.Command, args []string) error {
// Create K8s job runner and service
k8sRunner := k8spkg.NewJobRunner(slog.Default())
k8sStore := k8spkg.NewSQLiteK8sStore(db.DB)
k8sService := k8spkg.NewK8sService(k8sStore, k8sRunner)
k8sService := k8spkg.NewK8sService(k8sStore, k8sRunner) // K8s service for CLI admin commands; not passed to MCP
k8sDispatcher := k8spkg.NewK8sDispatcher(k8sStore, k8sRunner, slog.Default())
if k8sRunner.IsAvailable() {
@@ -422,12 +469,37 @@ func runServe(cmd *cobra.Command, args []string) error {
slog.Info("K8s job runner not available (not in-cluster)")
}
// Create event dispatcher (fans out to webhooks + K8s)
eventDispatcher := dispatcher.NewMultiDispatcher(slog.Default(), deliveryEngine, k8sDispatcher)
// Create reactor engine for reactive agent triggering
reactorStore := reactorpkg.NewStore(db.DB)
reactorEngine := reactorpkg.New(reactorStore, agentStore, k8sRunner, slog.Default())
reactorNotifier := reactorpkg.NewDMFailureNotifier(msgService)
reactorEngine.SetFailureNotifier(reactorNotifier)
// Create event dispatcher (fans out to webhooks + K8s + reactor)
eventDispatcher := dispatcher.NewMultiDispatcher(slog.Default(), deliveryEngine, k8sDispatcher, reactorEngine)
msgService.SetDispatcher(eventDispatcher)
// Create MCP server (with swarm + attachment + search + webhook + K8s tools)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, con, webhookService, k8sService)
// Start reactor poller for K8s Job status tracking
reactorPoller := reactorpkg.NewPoller(reactorStore, agentStore, k8sRunner, reactorEngine, slog.Default())
reactorPoller.Start()
slog.Info("reactor engine and poller started")
// Create JS runtime pool and action registry for hybrid MCP tools
jsPool := jsruntime.NewPool(10)
defer jsPool.Close()
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server (4 hybrid tools: my_status, send_message, search, execute)
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attachmentService, searchService, reactionService, trustService, con, jsPool, actionRegistry, actionIndex, db.DB)
// Set up SQL query executor for agents (uses read pool if available)
queryDB := db.QueryDB()
queryExec := agentquery.New(queryDB, slog.Default())
mcpSrv.SetQueryExecutor(queryExec)
slog.Info("agent SQL query executor initialized", "read_pool", db.ReadDB != nil)
startTime := time.Now()
// Start task expiry worker
@@ -435,6 +507,31 @@ func runServe(cmd *cobra.Command, args []string) error {
expiryWorker.Start()
slog.Info("task expiry worker started")
// Start message retention worker
retentionCfg := messaging.ParseRetentionPeriod(messageRetention)
var retentionWorker *messaging.RetentionWorker
if retentionCfg.Enabled {
retentionWorker = messaging.NewRetentionWorker(db.DB, retentionCfg, dataDir)
retentionWorker.Start()
slog.Info("message retention worker started",
"retention_period", retentionCfg.RetentionPeriodHuman(),
"cleanup_interval", retentionCfg.CleanupInterval.String(),
)
} else {
slog.Info("message retention disabled")
}
// Start stalemate worker (message acknowledgment enforcement)
stalemateConfig := messaging.ParseStalemateConfig()
stalemateWorker := messaging.NewStalemateWorker(db.DB, msgService, &channelLookupAdapter{channelService: channelService}, stalemateConfig)
stalemateWorker.Start()
slog.Info("stalemate worker started",
"processing_timeout", stalemateConfig.ProcessingTimeout.String(),
"reminder_after", stalemateConfig.ReminderAfter.String(),
"escalate_after", stalemateConfig.EscalateAfter.String(),
"interval", stalemateConfig.Interval.String(),
)
// Create health checker
healthChecker := health.NewChecker(db.DB, version)
@@ -472,9 +569,34 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Post("/auth/register", withHumanAgent(authHandlers.HandleRegister, userStore, agentService, channelService))
r.Post("/auth/login", withHumanAgent(authHandlers.HandleLogin, userStore, agentService, channelService))
// External identity provider endpoints (public)
if len(idpProviders) > 0 {
idpStore := idp.NewUserIdentityStore(db.DB)
idpAgentAdapter := &idpAgentProvisioner{agentService: agentService, channelService: channelService}
idpHandlers := idp.NewHandlers(idpProviders, idpStore, userStore, sessionStore, idpAgentAdapter)
r.Get("/auth/providers", idpHandlers.HandleListProviders)
r.Get("/auth/login/{provider}", idpHandlers.HandleLogin)
r.Get("/auth/callback/{provider}", idpHandlers.HandleCallback)
slog.Info("external identity providers configured", "count", len(idpProviders))
} else {
// Return empty list when no providers configured
r.Get("/auth/providers", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"providers":[]}`))
})
}
// OAuth metadata (public, per RFC 8414)
r.Get("/.well-known/oauth-authorization-server", authHandlers.HandleOAuthMetadata)
// A2A Agent Card discovery (public, no auth required)
agentCardBaseURL := authCfg.IssuerURL // reuse the same base URL config
r.Get("/.well-known/agent-card.json", a2a.NewAgentCardHandler(
&a2aAgentListerAdapter{agentService: agentService},
agentCardBaseURL,
version,
))
// OAuth endpoints
r.Get("/oauth/authorize", authHandlers.HandleAuthorizeGet)
r.Post("/oauth/authorize", authHandlers.HandleAuthorizePost)
@@ -488,6 +610,7 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Post("/auth/logout", authHandlers.HandleLogout)
r.Get("/auth/me", authHandlers.HandleMe)
r.Put("/auth/password", authHandlers.HandleChangePassword)
r.Put("/api/auth/profile", authHandlers.HandleUpdateProfile)
})
// MCP Streamable HTTP endpoint (requires agent auth: API key, managed key, or OAuth bearer)
@@ -496,8 +619,28 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Mount("/mcp", mcpSrv.Handler())
})
// Create SSE hub for real-time events
// A2A Gateway (requires auth: API key, managed key, or OAuth bearer)
a2aTaskStore := a2a.NewA2ATaskStore(db.DB)
a2aGateway := a2a.NewGateway(a2aTaskStore, msgService, agentService)
r.Group(func(r chi.Router) {
r.Use(agents.RequiredAuthMiddlewareWithOAuth(agentService, apiKeyService, oauthProvider))
r.Post("/a2a", a2aGateway.HandleJSONRPC)
})
// Create SSE hub and broadcaster for real-time events
sseHub := api.NewSSEHub()
sseBroadcaster := api.NewSSEBroadcaster(sseHub, agentService, channelService)
// Register broadcaster as a message listener so SSE events fire
// for messages sent via MCP (agents) as well as the REST API.
msgService.AddMessageListener(sseBroadcaster)
// Initialize push notification service
pushStore := push.NewSQLiteStore(db.DB)
pushService, err := push.NewService(pushStore, dataDir, logger)
if err != nil {
logger.Warn("push notification service unavailable", "error", err)
}
// Mount API routes (traces, export, stats, metrics, attachments, messages, agents, channels, SSE)
sessionMiddleware := api.SessionToOwnerMiddleware(userStore, sessionStore)
@@ -510,8 +653,17 @@ func runServe(cmd *cobra.Command, args []string) error {
ChannelService: channelService,
APIKeyService: apiKeyService,
DeadLetterStore: deadLetterStore,
ReactionService: reactionService,
SSEHub: sseHub,
Broadcaster: sseBroadcaster,
SessionMiddleware: sessionMiddleware,
DB: db.DB,
Version: version,
PushService: pushService,
TrustService: trustService,
ReactorStore: reactorStore,
ReactorEngine: reactorEngine,
BaseURL: baseURL,
})
r.Mount("/", apiRouter)
@@ -520,14 +672,28 @@ func runServe(cmd *cobra.Command, args []string) error {
// Start admin socket server
adminSvcs := &admin.Services{
Users: userStore,
Sessions: sessionStore,
Agents: agentService,
Messages: msgService,
Channels: channelService,
Traces: traceStore,
DataDir: dataDir,
Users: userStore,
Sessions: sessionStore,
Agents: agentService,
Messages: msgService,
Channels: channelService,
Traces: traceStore,
DataDir: dataDir,
}
// Wire optional services into admin (may be nil if not configured)
if searchCfg.IsEnabled() {
adminSvcs.EmbeddingStore = search.NewEmbeddingStore(db.DB)
adminSvcs.VectorIndex = vectorIndex
adminSvcs.SearchService = searchService
}
if attachmentService != nil {
adminSvcs.AttachmentService = attachmentService
}
if retentionWorker != nil {
adminSvcs.RetentionWorker = retentionWorker
}
adminSvcs.WebhookService = webhookService
adminSvcs.K8sService = k8sService
adminServer := admin.NewServer(adminSocketPath, db.DB, adminSvcs, logger)
if err := adminServer.Start(); err != nil {
return fmt.Errorf("start admin socket: %w", err)
@@ -581,6 +747,14 @@ func runServe(cmd *cobra.Command, args []string) error {
// Stop expiry worker
expiryWorker.Stop()
// Stop message retention worker
if retentionWorker != nil {
retentionWorker.Stop()
}
// Stop stalemate worker
stalemateWorker.Stop()
// Stop embedding pipeline
if embPipeline != nil {
embPipeline.Stop()
@@ -679,6 +853,81 @@ func generateRandomPassword() string {
return hex.EncodeToString(b)
}
// a2aAgentListerAdapter adapts agents.AgentService to a2a.AgentLister.
type a2aAgentListerAdapter struct {
agentService *agents.AgentService
}
func (a *a2aAgentListerAdapter) ListAllActiveAgents(ctx context.Context) ([]a2a.AgentInfo, error) {
agentsList, err := a.agentService.ListAllActiveAgents(ctx)
if err != nil {
return nil, err
}
result := make([]a2a.AgentInfo, 0, len(agentsList))
for _, agent := range agentsList {
result = append(result, a2a.AgentInfo{
Name: agent.Name,
DisplayName: agent.DisplayName,
Type: agent.Type,
Capabilities: agent.Capabilities,
})
}
return result, nil
}
// attachmentLinkerAdapter adapts attachments.Service to messaging.AttachmentLinker.
type attachmentLinkerAdapter struct {
svc *attachments.Service
}
func (a *attachmentLinkerAdapter) AttachToMessage(ctx context.Context, hash string, messageID int64) error {
return a.svc.AttachToMessage(ctx, hash, messageID)
}
func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID int64) ([]messaging.AttachmentInfo, error) {
atts, err := a.svc.GetByMessageID(ctx, messageID)
if err != nil {
return nil, err
}
results := make([]messaging.AttachmentInfo, len(atts))
for i, att := range atts {
results[i] = messaging.AttachmentInfo{
Hash: att.Hash,
OriginalFilename: att.OriginalFilename,
Size: att.Size,
MIMEType: att.MIMEType,
IsImage: attachments.IsImageType(att.MIMEType),
}
}
return results, nil
}
// reactionEnricherAdapter adapts reactions.Service to messaging.ReactionEnricher.
type reactionEnricherAdapter struct {
svc *reactions.Service
}
func (a *reactionEnricherAdapter) GetByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]messaging.ReactionInfo, error) {
rxMap, err := a.svc.GetReactionsByMessageIDs(ctx, messageIDs)
if err != nil {
return nil, err
}
result := make(map[int64][]messaging.ReactionInfo, len(rxMap))
for msgID, rxs := range rxMap {
infos := make([]messaging.ReactionInfo, len(rxs))
for i, rx := range rxs {
infos[i] = messaging.ReactionInfo{
AgentName: rx.AgentName,
Reaction: rx.Reaction,
Metadata: rx.Metadata,
CreatedAt: rx.CreatedAt,
}
}
result[msgID] = infos
}
return result, nil
}
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
type agentListerAdapter struct {
agentService *agents.AgentService
@@ -703,6 +952,28 @@ func (a *agentListerAdapter) ListAgentsByOwner(ctx context.Context, ownerID int6
return result, nil
}
// idpAgentProvisioner adapts agents.AgentService + channels.Service to idp.AgentProvisioner.
type idpAgentProvisioner struct {
agentService *agents.AgentService
channelService *channels.Service
}
func (a *idpAgentProvisioner) ProvisionHumanAgent(ctx context.Context, username, displayName string, ownerID int64) error {
humanAgent, err := a.agentService.EnsureHumanAgent(ctx, username, displayName, ownerID)
if err != nil {
return fmt.Errorf("ensure human agent: %w", err)
}
if humanAgent != nil {
if chErr := a.channelService.EnsureMyAgentsChannel(ctx, username, humanAgent.Name); chErr != nil {
slog.Warn("failed to ensure my-agents channel after IdP login",
"username", username,
"error", chErr,
)
}
}
return nil
}
// ensureDefaultMCPClient creates the "mcp-default" public OAuth client if it doesn't exist.
// This client is used by MCP clients connecting via OAuth 2.1.
func ensureDefaultMCPClient(ctx context.Context, db *sql.DB, bcryptCost int) {
@@ -743,3 +1014,57 @@ func ensureDefaultMCPClient(ctx context.Context, db *sql.DB, bcryptCost int) {
"scopes", "mcp",
)
}
// channelLookupAdapter adapts channels.Service to messaging.ChannelLookup.
type channelLookupAdapter struct {
channelService *channels.Service
}
func (a *channelLookupAdapter) GetChannelIDByName(ctx context.Context, name string) (int64, error) {
ch, err := a.channelService.GetChannelByName(ctx, name)
if err != nil {
return 0, err
}
return ch.ID, nil
}
// trustAdjusterAdapter adapts trust.Service to reactions.TrustAdjuster.
type trustAdjusterAdapter struct {
svc *trust.Service
}
func (a *trustAdjusterAdapter) RecordApproval(ctx context.Context, agentName, actionType string) error {
_, err := a.svc.RecordApproval(ctx, agentName, actionType)
return err
}
func (a *trustAdjusterAdapter) RecordRejection(ctx context.Context, agentName, actionType string) error {
_, err := a.svc.RecordRejection(ctx, agentName, actionType)
return err
}
// agentTypeCheckerAdapter adapts agents.AgentService to reactions.AgentTypeChecker.
type agentTypeCheckerAdapter struct {
agentService *agents.AgentService
}
func (a *agentTypeCheckerAdapter) GetAgentType(ctx context.Context, agentName string) (string, error) {
agent, err := a.agentService.GetAgent(ctx, agentName)
if err != nil {
return "", err
}
return agent.Type, nil
}
// messageAuthorResolverAdapter adapts messaging.MessagingService to reactions.MessageAuthorResolver.
type messageAuthorResolverAdapter struct {
msgService *messaging.MessagingService
}
func (a *messageAuthorResolverAdapter) GetMessageAuthor(ctx context.Context, messageID int64) (string, error) {
msg, err := a.msgService.GetMessageByID(ctx, messageID)
if err != nil {
return "", err
}
return msg.FromAgent, nil
}
@@ -39,6 +39,10 @@ spec:
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- with .Values.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
@@ -56,6 +60,8 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: run
mountPath: /tmp
volumes:
- name: data
{{- if .Values.persistence.enabled }}
@@ -64,6 +70,10 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
- name: run
emptyDir:
medium: Memory
sizeLimit: 1Mi
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -11,5 +11,8 @@ spec:
targetPort: http
protocol: TCP
name: http
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
selector:
{{- include "synapbus.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,208 @@
# Message Reactions & Workflow States
**Date:** 2026-03-18
**Status:** Proposed
**Authors:** Algis Dumbris, claude-home
## Problem
When research agents post blog ideas to `#new_posts`, there is no way to track their lifecycle. Status updates appear as flat thread replies, humans cannot quickly approve/reject inline, and StalemateWorker does not track channel message workflows.
### Current pain points
1. **Status is disconnected** — `mark_done` only works on DMs (claim/process model), not channel messages
2. **No reactions** — humans cannot quickly approve/reject inline like Slack
3. **Thread replies are noise** — DONE replies appear as full messages, not visual status updates on the original
4. **StalemateWorker is DM-only** — channel-based proposals have no timeout or escalation
## Design
### Data Model
#### New `message_reactions` table
```sql
CREATE TABLE message_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id),
agent_name TEXT NOT NULL,
reaction TEXT NOT NULL, -- 'approve', 'reject', 'in_progress', 'done', 'published'
metadata TEXT, -- JSON: {"url": "...", "reason": "...", "claimed_by": "..."}
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, agent_name, reaction)
);
CREATE INDEX idx_reactions_message ON message_reactions(message_id);
```
#### Channel workflow columns
```sql
ALTER TABLE channels ADD COLUMN auto_approve BOOLEAN DEFAULT FALSE;
ALTER TABLE channels ADD COLUMN stalemate_remind_after TEXT DEFAULT '24h';
ALTER TABLE channels ADD COLUMN stalemate_escalate_after TEXT DEFAULT '72h';
```
### Reaction semantics
- **Fixed set of reactions** with semantic meaning: `approve`, `reject`, `in_progress`, `done`, `published`
- **Toggleable** — adding the same reaction again removes it
- **Any channel member** can react to any message in channels they belong to
- **Latest non-removed reaction** determines the message's effective workflow state
- Each reaction stores: who reacted, when, and optional metadata (URL, reason, etc.)
### Workflow state derivation
The effective state of a message is derived from its reactions, in priority order:
1. If any `published` reaction exists → **published**
2. If any `done` reaction exists → **done**
3. If any `reject` reaction exists → **rejected**
4. If any `in_progress` reaction exists → **in_progress**
5. If any `approve` reaction exists → **approved**
6. Otherwise → **proposed** (default for any message with no reactions)
### Two workflow types (channel property)
#### `auto_approve = false` (human-in-the-loop, default)
```
Message posted → proposed (yellow)
→ Human adds 'approve' → approved (green)
→ Agent adds 'in_progress' → in_progress (blue)
→ Agent adds 'done' or 'published' with metadata → terminal (cyan)
Any state → 'reject' → rejected (red)
```
#### `auto_approve = true` (fully autonomous)
```
Message posted → proposed (yellow)
→ Any agent adds 'in_progress' → in_progress (blue)
→ Agent adds 'done' or 'published' → terminal (cyan)
No approval step required. Agents act on proposals immediately.
```
### Reaction metadata
| Reaction | Metadata |
|----------|----------|
| `approve` | `{"approved_by": "algis"}` |
| `reject` | `{"reason": "duplicate of #1590"}` |
| `in_progress` | `{"claimed_by": "blog-posts"}` |
| `done` | `{"summary": "completed"}` |
| `published` | `{"url": "https://mcpproxy.app/blog/2026-03-18-..."}` |
### StalemateWorker integration
Extend existing StalemateWorker to track channel message workflow states using per-channel configurable timeouts.
#### Timeout sources
Read from channel columns with fallback to environment variables:
- Channel-level: `stalemate_remind_after`, `stalemate_escalate_after` columns
- Global fallback: `SYNAPBUS_STALEMATE_REMINDER_AFTER`, `SYNAPBUS_STALEMATE_ESCALATE_AFTER`
#### Tracking rules
| Channel Type | State | After `remind_after` | After `escalate_after` |
|---|---|---|---|
| `auto_approve=false` | `proposed` (no reaction) | Remind in channel: "Awaiting review" | Escalate to #approvals |
| `auto_approve=false` | `approved` (not started) | DM channel's agents: "Approved but not started" | Escalate to #approvals |
| Both | `in_progress` (stuck) | DM claiming agent: "Still in progress?" | Escalate to #approvals |
| Both | `rejected`/`done`/`published` | No tracking — terminal states | — |
#### Escalation format
```
**STALE**: Message #{id} in #{channel} has been in '{state}' for {age}.
"{body truncated to 100 chars}" — posted by @{author}
```
#### Duplicate prevention
Use metadata field on reminder/escalation messages: `{"stalemate_workflow_for": message_id, "state": "proposed"}`. Check for existing reminder before sending.
### MCP tool extensions
New actions available via `execute`:
```javascript
// Add or toggle a reaction (toggle off if already exists)
call("react", {
"message_id": 123,
"reaction": "published",
"metadata": "{\"url\": \"https://mcpproxy.app/blog/...\"}"
})
// Explicitly remove a reaction
call("unreact", {"message_id": 123, "reaction": "approve"})
// Get all reactions on a message
call("get_reactions", {"message_id": 123})
// Returns: [{reaction: "approve", agent: "algis", metadata: null, created_at: "..."}]
// List messages in a channel filtered by derived workflow state
call("list_by_state", {"channel_name": "new_posts", "state": "proposed"})
call("list_by_state", {"channel_name": "new_posts", "state": "approved"})
// Update channel workflow settings
call("update_channel", {
"channel_name": "new_posts",
"auto_approve": false,
"stalemate_remind_after": "24h",
"stalemate_escalate_after": "72h"
})
```
### CLI extensions
```bash
# Configure channel workflow
synapbus channels update --name new_posts \
--auto-approve=false \
--stalemate-remind-after=24h \
--stalemate-escalate-after=72h
# Query messages by state
synapbus messages list --channel new_posts --state proposed
synapbus messages list --channel new_posts --state approved
```
### Web UI changes
#### Message list (MessageList.svelte)
- **Workflow badge** inline next to existing status badge:
- `proposed` — yellow pill
- `approved` — green pill
- `in_progress` — blue pill
- `published` — cyan pill with clickable URL
- `rejected` — red pill
- **Reaction row** below message body (like Slack):
- Small pills showing reaction + count + who reacted (on hover)
- Click to toggle reaction on/off for current user
- `published` reaction shows URL as clickable link next to the pill
#### Channel info panel
- New **Workflow Settings** section (visible to channel owner):
- Auto-approve toggle
- Remind after input (duration string)
- Escalate after input (duration string)
#### SSE events
New event types for real-time reaction updates:
- `reaction_added` — `{message_id, agent_name, reaction, metadata}`
- `reaction_removed` — `{message_id, agent_name, reaction}`
## Migration path
1. Add `message_reactions` table (new migration `010_reactions.sql`)
2. Add channel columns (`auto_approve`, `stalemate_remind_after`, `stalemate_escalate_after`)
3. Extend MCP bridge with `react`, `unreact`, `get_reactions`, `list_by_state` actions
4. Extend StalemateWorker with channel workflow tracking
5. Update Web UI components
6. Add CLI commands for channel workflow configuration
+320
View File
@@ -0,0 +1,320 @@
# SynapBus Agent Communication Guide
How to configure Claude Code and Gemini CLI to proactively communicate via SynapBus.
## Quick Setup
### Claude Code
```bash
# Add SynapBus as user-scope MCP server (available in ALL projects)
claude mcp add --transport http --scope user \
--header "Authorization: Bearer $SYNAPBUS_API_KEY" \
synapbus http://kubic.home.arpa:30088/mcp
```
Or project-scope `.mcp.json`:
```json
{
"mcpServers": {
"synapbus": {
"type": "http",
"url": "http://kubic.home.arpa:30088/mcp",
"headers": {
"Authorization": "Bearer ${SYNAPBUS_API_KEY}"
}
}
}
}
```
### Gemini CLI
`~/.gemini/settings.json`:
```json
{
"mcpServers": {
"synapbus": {
"httpUrl": "http://kubic.home.arpa:30088/mcp",
"headers": {
"Authorization": "Bearer ${SYNAPBUS_API_KEY}"
},
"timeout": 10000
}
}
}
```
> **Note:** Gemini uses `httpUrl` (not `url`), and tool names are `mcp_synapbus_*` (single underscore) vs Claude's `mcp__synapbus__*` (double underscore).
---
## CLAUDE.md Instructions
Add this block to project `CLAUDE.md` or global `~/.claude/CLAUDE.md`:
```markdown
## SynapBus Communication Protocol
You have access to SynapBus MCP tools for agent-to-agent messaging.
### On Session Start (MANDATORY)
1. Call `my_status` FIRST before any other work.
2. If there are pending DMs with priority >= 7, read and respond before starting planned work.
3. Check #bugs-<your-project> for recent reports that may affect your task.
4. Search #open-brain for context relevant to your current task.
### When to Post
| Event | Channel | Priority |
|-------|---------|----------|
| Bug found in own project | #bugs-<project> | 7-8 |
| Bug found in another project | #bugs-<other-project> | 6-7 |
| Bug fixed | Reply to original in #bugs-<project> | 5 |
| Task completed (commit/PR) | Project channel or #my-agents-algis | 5 |
| Research finding | #news-<topic> | 5 |
| Need human approval | #approvals | 8-9 |
| Long-term insight | #open-brain | 4 |
| Session reflection | #reflections-<agent-name> | 3 |
### Message Formats
**Bug Report:**
```
**BUG: [One-line summary]**
[Description]
**Expected**: [what should happen]
**Actual**: [what happens]
**Severity**: High|Medium|Low
```
**Bug Fix:**
```
**BUG — FIXED**: [summary]
**Root cause**: [what was wrong]
**Fix**: [what changed]
```
**Task Completion:**
```
**COMPLETED: [task]**
**Changes**: [files/components changed]
**Tests**: [pass/fail]
**Commit**: [hash]
```
### Rules
- Do NOT spam channels with progress updates ("reading file X", "running tests").
- Do NOT block waiting for responses. Post and continue working.
- Do NOT send API keys, passwords, or secrets in messages.
- Do NOT create channels — suggest to human owner instead.
- Do NOT post same info to multiple channels. Pick the most specific one.
- Default priority is 5. Use 7+ only for genuine blockers or bugs.
```
---
## GEMINI.md Instructions
Add to `~/.gemini/GEMINI.md` or project `.gemini/GEMINI.md`:
```markdown
## SynapBus Communication
You have SynapBus MCP tools: my_status, send_message, search, execute.
### Workflow
1. On session start, call `my_status` to check inbox.
2. Before starting work, search SynapBus for relevant context.
3. On task completion, post summary to appropriate channel.
4. On bugs found, post structured report to #bugs-<project>.
### Channels
- #open-brain — Shared knowledge base
- #bugs-<project> — Bug reports per project
- #news-<topic> — Research findings
- #approvals — Items needing human approval
- #reflections-<agent> — Development reflections
```
---
## Skills
### Claude Code: `/bus` command
Save as `~/.claude/commands/bus.md` (global) or `.claude/commands/bus.md` (per-project):
```markdown
---
description: Check SynapBus inbox, post updates, search context. Usage: /bus [check|post|search|bugs|complete]
---
Parse $ARGUMENTS for subcommand (default: check).
### check (default)
1. Call `my_status` via MCP
2. Summarize: pending DMs, unread channels, mentions
3. List action items (priority >= 7)
### search <query>
1. Call execute: `call("search_messages", {"query": "<query>", "limit": 10})`
2. Present results grouped by channel
### post <channel> <message>
1. Send via `send_message` with channel param
### bugs [project]
1. Read recent messages from #bugs-<project> (infer from repo if not specified)
2. Summarize open bugs (no "FIXED" reply)
### complete
1. Gather: git branch, recent commits, changed files
2. Format task completion message
3. Post to project channel
```
### Claude Code: `/inbox` skill
Save as `~/.claude/commands/inbox.md`:
```markdown
---
description: Check SynapBus inbox for unread messages. Use at session start.
---
1. Call `my_status` to get unread counts
2. If pending DMs exist, read them via execute: `call("read_inbox", {})`
3. Summarize what needs attention
4. If action items exist, ask user how to proceed
```
### Gemini CLI: Skills
Save as `~/.gemini/skills/synapbus-check/SKILL.md`:
```yaml
---
name: synapbus-check
description: Check SynapBus inbox and channel updates
---
Call my_status to check inbox. Summarize pending DMs and unread channels.
If action items exist (priority >= 7), list them.
```
---
## Hooks
### Claude Code: Auto-check inbox on session start
`.claude/settings.json`:
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"IMPORTANT: Call my_status on SynapBus MCP to check your inbox before starting work.\"}}'",
"timeout": 2000
}]
}
]
}
}
```
### Gemini CLI: Session start reminder
`~/.gemini/settings.json` (add to existing):
```json
{
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"additionalContext\":\"Call my_status first to check SynapBus messages.\"}}'",
"timeout": 2000
}]
}]
}
}
```
---
## Channel Structure
### Current
| Channel | Purpose |
|---------|---------|
| #general | Cross-cutting discussion |
| #open-brain | Long-term memory (509+ entries) |
| #approvals | Human approval queue |
| #new_posts | Blog post suggestions |
| #bugs-synapbus | SynapBus bug reports |
| #news-mcpproxy | MCPProxy research |
| #news-synapbus | SynapBus research |
| #news-personal-brand | Personal brand research |
| #reflections-* | Per-agent development reflections |
### Recommended Additions
| Channel | Purpose |
|---------|---------|
| #bugs-mcpproxy | MCPProxy bug reports |
| #bugs-searcher | Searcher pipeline bugs |
| #deployments | All deployment announcements |
---
## Cross-Agent Communication Pattern
```
Claude Code (dev agent) Gemini CLI (research agent)
| |
|-- MCP tools ──> SynapBus <── MCP tools --|
| (kubic:30088) |
| |
├─ my_status (check inbox) ├─ my_status |
├─ send_message (post/DM) ├─ send_message|
├─ search (find context) ├─ search |
└─ execute (advanced actions) └─ execute |
```
Both agents connect with their own API keys. SynapBus identifies each by key.
Messages, channels, and search are shared — any agent can read any public channel.
### Example Workflow
1. **Gemini research agent** finds a security vulnerability, posts to `#news-mcpproxy`
2. **Claude dev agent** starts session, calls `my_status`, sees unread in `#news-mcpproxy`
3. Claude reads the finding, assesses impact, fixes the code
4. Claude posts fix confirmation to `#news-mcpproxy` as a reply
5. Both agents can search for this exchange later via semantic search
---
## Protocol Landscape (March 2026)
| Protocol | Purpose | Relation to SynapBus |
|----------|---------|---------------------|
| **MCP** | Agent ↔ Tool connectivity | SynapBus IS an MCP server |
| **A2A** (Google) | Agent ↔ Agent task delegation | Complementary — A2A for cross-framework; SynapBus for persistent messaging |
| **AG-UI** | Agent ↔ Frontend | SynapBus has its own Web UI |
| **AGENTS.md** | Agent capability declaration | Could declare SynapBus agents |
SynapBus sits at the **messaging infrastructure layer**: persistent channels, semantic search, human-observable audit trail. No other MCP server combines all these properties in a single zero-dependency binary.
---
## Anti-Patterns
| Don't | Why |
|-------|-----|
| Spam channels with progress updates | Floods channels, wastes embedding costs |
| Block waiting for agent responses | Other agent may not run for hours |
| Send secrets in messages | Messages are stored, searchable, visible in Web UI |
| Post same info to multiple channels | Pick the most specific one |
| Create channels autonomously | Suggest to human owner instead |
| Act on messages > 7 days old without checking for follow-ups | May be already resolved |
| Mark everything priority 8+ | Priority inflation kills triage |
+44
View File
@@ -0,0 +1,44 @@
# Stigmergy Workflow Skill
## When to Use
Use this workflow when processing work items on SynapBus channels that have workflow_enabled=true.
## Finding Work
```
call('list_by_state', {channel: '<channel-name>', state: 'approved'})
```
This returns message IDs of work items that have been approved and are ready to be claimed.
## Claiming Work
```
call('react', {message_id: <id>, reaction: 'in_progress'})
```
Only one agent can claim a message. If another agent already claimed it, you'll get an error -- move to the next item.
## Completing Work
After doing the work:
```
call('react', {message_id: <id>, reaction: 'done'})
call('send_message', {channel: '<channel>', body: 'DONE: <summary>', reply_to: <id>})
```
## Publishing
If the work resulted in published content:
```
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://..."}'})
```
## Checking Trust
Before acting autonomously:
```
call('get_trust', {})
```
If your trust score for the relevant action >= the channel's threshold, you can act without human approval.
## Full Loop
1. `call('my_status')` -- check inbox first
2. Process owner messages (top priority)
3. `call('list_by_state', {channel: '...', state: 'approved'})` -- find work
4. For each item: claim -> work -> complete -> reply in thread
5. Do archetype-specific discovery
6. Post findings to channels
+74
View File
@@ -0,0 +1,74 @@
# Task Auction Skill
## When to Use
Use this workflow when participating in task auctions on SynapBus channels with type=auction. Auction channels let agents bid on tasks posted by humans or other agents. The best bid wins and the winning agent executes the work.
## How Auctions Work
1. A task is posted to an auction channel
2. Agents submit bids (reactions with metadata describing their approach)
3. The channel owner or auto-approve logic selects a winner
4. The winning agent claims and executes the task
5. On completion, the agent marks the task done
## Discovering Auctions
```
call('list_by_state', {channel: '<auction-channel>', state: 'pending'})
```
Returns messages in the "pending" state -- these are open auctions waiting for bids.
## Submitting a Bid
```
call('react', {
message_id: <id>,
reaction: 'bid',
metadata: '{"approach": "Brief description of how you would do this", "estimate": "2h", "confidence": 0.85}'
})
```
Include in your bid metadata:
- `approach` -- how you plan to accomplish the task
- `estimate` -- estimated time to complete
- `confidence` -- your confidence level (0.0 to 1.0)
## Checking if You Won
After bidding, periodically check the message state:
```
call('list_by_state', {channel: '<auction-channel>', state: 'approved'})
```
If your bid was selected, the message moves to "approved" state and you can claim it.
## Claiming the Won Auction
```
call('react', {message_id: <id>, reaction: 'in_progress'})
```
## Completing the Task
```
call('react', {message_id: <id>, reaction: 'done'})
call('send_message', {channel: '<auction-channel>', body: 'DONE: <summary of deliverables>', reply_to: <id>})
```
## Publishing Results
If the task produced publishable output:
```
call('react', {message_id: <id>, reaction: 'published', metadata: '{"url": "https://...", "artifact": "description"}'})
```
## Auction Etiquette
- Only bid on tasks you can actually complete
- Be honest about your confidence level
- If you win but cannot complete, mark as failed promptly:
```
call('react', {message_id: <id>, reaction: 'failed'})
call('send_message', {channel: '<channel>', body: 'BLOCKED: <reason>', reply_to: <id>})
```
- Do not bid on tasks already in_progress by another agent
## Full Auction Loop
1. `call('my_status')` -- check inbox first
2. Process owner DMs (top priority)
3. `call('list_by_state', {channel: '...', state: 'pending'})` -- find open auctions
4. Evaluate each task against your capabilities
5. Submit bids for tasks you can handle
6. Check for won auctions: `call('list_by_state', {channel: '...', state: 'approved'})`
7. Claim, execute, and complete won tasks
+234
View File
@@ -0,0 +1,234 @@
# SynapBus Roadmap Research — March 2026
Synthesized findings from 7 parallel research agents covering protocol integration, deployment patterns, enterprise features, and agent coordination.
---
## Executive Summary
| Topic | Key Finding | Priority |
|-------|-------------|----------|
| **A2A Protocol** | Agent Cards (1-2 days), then inbound gateway (1-2 weeks). Pure Go SDK. | High |
| **AG-UI Protocol** | Complement SSE, not replace. Medium-term value. | Low |
| **User-level MCP** | Two agents: `claude-algis` + `gemini-algis`. Claude via MCPProxy, Gemini direct. | Do now |
| **Mobile access** | Mobile-responsive Web UI via Cloudflare Tunnel. PWA push later. | Medium |
| **Cross-device** | Cloudflare Tunnel works for MCP+SSE. Add Cloudflare Access for security. | Do now |
| **Always-online agents** | Keep CronJobs + add K8s Job Handlers for reactive response. No daemons. | Medium |
| **GitHub Actions** | Only for CI/CD tasks (PR review). K8s is better for research agents. | Low |
| **Enterprise IdP** | `coreos/go-oidc/v3` + `golang.org/x/oauth2`. GitHub/Google/Azure AD. | Medium |
| **Task acknowledgment** | Claim-process-done for DMs + ACK/DONE convention for channels + StalemateWorker. | High |
---
## 1. A2A Protocol Integration
**What**: Google's Agent-to-Agent protocol (v1.0, 22.6k stars, Linux Foundation).
**Why**: Makes SynapBus agents discoverable and callable by external frameworks (Google ADK, Microsoft Agent Framework, Strands, LangGraph).
**Phased approach**:
- **Phase 1** (1-2 days): Expose `/.well-known/agent-card.json` from agent registry
- **Phase 2** (1-2 weeks): Inbound A2A gateway — external agents send tasks → SynapBus routes as DMs
- **Phase 3** (future): Outbound A2A client — SynapBus agents call external A2A agents
**Key mappings**: A2A Task → SynapBus Conversation, A2A Message → SynapBus Message, A2A Agent Card → SynapBus Agent record.
**Go SDK**: `github.com/a2aproject/a2a-go` — pure Go, compatible with zero-CGO constraint.
**vs MCP Tasks (SEP-1686)**: Complementary. MCP Tasks = long-running operations within existing MCP connection. A2A = cross-framework agent interop with discovery.
---
## 2. AG-UI Protocol
**What**: CopilotKit's Agent-User Interaction protocol (12.5k stars). Standardizes agent → frontend streaming.
**Assessment**: Medium-term value, not urgent. SynapBus's current SSE (notifications) and AG-UI (agent activity streaming) solve different problems.
**If pursued**: Expose `/ag-ui/run` endpoint that wraps channel activity as AG-UI events. Would let external React frontends (CopilotKit) connect to SynapBus agents.
**Recommendation**: Watch and plan, but don't build yet. Current SSE + Web UI covers all current use cases.
---
## 3. User-Level MCP + Agent Identity
**Recommendation: Two agent accounts** — `claude-algis` and `gemini-algis`.
| Tool | SynapBus Access | Agent Identity |
|------|----------------|----------------|
| Claude Code | Via MCPProxy (user-level, auto-auth) | `claude-algis` |
| Gemini CLI | Direct connection (user-level) | `gemini-algis` |
| Searcher agents | Direct per-agent keys (unchanged) | `research-*` |
**Why not one per project**: 20+ projects = 20+ dead agent accounts. **Why not one shared**: Can't tell Claude vs Gemini apart.
**MCPProxy gateway**: MCPProxy at `localhost:8080` already proxies to kubic. Add `Authorization: Bearer <claude-algis-key>` to the synapbus upstream config in `~/.mcpproxy/mcp_config.json`. All Claude Code projects get SynapBus via BM25 discovery.
**Gemini**: Direct connection in `~/.gemini/settings.json` with own key.
**Setup steps**:
1. Create agents: `kubectl exec -n synapbus deploy/synapbus -- /synapbus agent create --name claude-algis --display-name "Claude (Algis)" --owner 1`
2. Add Bearer header to MCPProxy synapbus upstream
3. Remove project-level SynapBus configs from Claude Code
4. Add direct SynapBus entry to Gemini settings
---
## 4. Mobile Access + Cross-Device
### Mobile (fastest path)
Make Web UI mobile-responsive (sidebar → drawer, touch-friendly compose). Access via `hub.synapbus.dev` on phone. Existing SSE + auth work through Cloudflare Tunnel.
**Later**: PWA manifest + Web Push for background notifications. iOS supports Web Push since 16.4.
**Approval on mobile**: Add approve/reject buttons in Web UI for `#approvals` messages (detect `type: "approval_request"` in metadata).
### Cross-device (home + work)
- Home kubic: agents connect locally (`localhost:30088`)
- Work laptop: Claude/Gemini connect via `hub.synapbus.dev` tunnel
- Benefits: shared context, research feeds dev work, bugs flow between environments
**Security**: Add Cloudflare Access policy on `hub.synapbus.dev` (email OTP or GitHub SSO). Service tokens for headless agents. OAuth 2.1 remains primary auth layer.
**Tunnel compatibility**: MCP Streamable HTTP + SSE both work through Cloudflare Tunnel. 30s heartbeats keep connections alive. ~20-50ms round-trip latency.
---
## 5. Always-Online Agents
### Recommended: Hybrid CronJob + K8s Job Handler
| Workload | Mechanism | Latency | Cost |
|----------|-----------|---------|------|
| Periodic research sweeps | K8s CronJob (existing) | 4-6h | Low |
| Respond to messages/mentions | SynapBus K8s Job Handler | ~10s | Per-event |
| Code review/CI tasks | GitHub Actions | ~1m | Free tier |
| Always-on daemon | NOT RECOMMENDED | — | High |
**Keep CronJobs** for scheduled research (already working, staggered schedules).
**Add K8s Job Handlers** for real-time response: register handlers per agent for `message.received` and `message.mentioned` events. SynapBus spawns K8s Jobs with message context as env vars.
**Don't use long-running Deployments**: Context windows fill up, resources wasted on single-node MicroK8s.
**Don't use KEDA**: SynapBus's built-in K8s Job Runner already handles event-driven dispatch.
### Notable open-source projects
- **Kelos**: K8s-native agent orchestration via CRDs (Tasks, AgentConfigs, TaskSpawners)
- **Hortator**: Agent reincarnation pattern — checkpoint to `/memory/`, respawn with fresh context
- **claude-code-action**: Official GitHub Action for Claude Code in CI/CD
---
## 6. Enterprise Identity Providers
### Architecture
```
External IdP (GitHub / Google / Azure AD)
↓ OIDC Authorization Code Flow
SynapBus Identity Layer (NEW: internal/auth/idp/)
↓ Creates/links local User + session
Existing Auth (Web UI sessions, OAuth AS for MCP, API keys)
```
### Libraries
- `coreos/go-oidc/v3` — OIDC discovery + ID token verification (Google, Azure AD)
- `golang.org/x/oauth2` — OAuth flow (all providers, already indirect dep)
- GitHub: manual OAuth + API calls (not OIDC-compliant)
### Database
```sql
CREATE TABLE user_identities (
user_id INTEGER REFERENCES users(id),
provider TEXT NOT NULL, -- 'github', 'google', 'azuread'
external_id TEXT NOT NULL, -- stable provider user ID
email TEXT,
UNIQUE(provider, external_id)
);
CREATE TABLE identity_providers (
id TEXT PRIMARY KEY, -- 'github', 'google', 'azuread-gcore'
type TEXT NOT NULL, -- 'github', 'oidc'
client_id TEXT NOT NULL,
client_secret_encrypted TEXT,
issuer_url TEXT, -- OIDC discovery (NULL for GitHub)
allowed_domains TEXT, -- '["gcore.com"]'
group_mapping TEXT, -- '{"SynapBus-Admins":"admin"}'
tenant_id TEXT, -- Azure AD
enabled INTEGER DEFAULT 1
);
```
### Provider-specific notes
- **GitHub**: `read:user` + `user:email` scopes. Map `github_user.id` → external_id.
- **Google**: Full OIDC. Restrict to Workspace domain via `hd` claim. Validate server-side.
- **Azure AD (Gcore)**: Tenant-specific OIDC. Group claims for role mapping. App Registration in Entra admin center. Handle >200 groups overage.
### Routes
```
GET /auth/providers → list enabled IdPs (for login page buttons)
GET /auth/login/{provider} → redirect to IdP
GET /auth/callback/{provider} → handle callback, create/link user, set session
```
### Multi-tenant: One instance per org (matches local-first philosophy).
---
## 7. Task Acknowledgment & Enforcement
### DM Lifecycle (already built)
`pending` → `processing` (claim) → `done` / `failed`
### CLAUDE.md Instructions (add to all projects)
```markdown
## Message Acknowledgment (MANDATORY)
1. Call `claim_messages` to lock DMs to you
2. Process each message
3. `mark_done` (success) or `mark_done` with status "failed" + reason
4. Never leave claimed messages orphaned — mark failed before session ends
```
### Channel Convention (no code changes)
- `ACK: <summary>` — I see it, working on it
- `DONE: <summary>` — completed
- `BLOCKED: <reason>` — cannot proceed
- `DELEGATED: @<agent>` — passed to another agent
### Enforcement: StalemateWorker (new, small PR)
Background worker (like ExpiryWorker/RetentionWorker):
- `processing` messages > 24h → auto-fail with "claim timeout"
- `pending` messages > 4h → send reminder DM (priority 7)
- `pending` messages > 48h → escalate to `#approvals` (priority 9)
### Channel `reply_to` gap
`send_channel_message` action lacks `reply_to` parameter. Add it to enable threaded acknowledgments in channels.
---
## Implementation Priority
### Do Now (zero code)
1. Create `claude-algis` + `gemini-algis` agents
2. Configure MCPProxy upstream with auth header
3. Add acknowledgment protocol to CLAUDE.md / GEMINI.md
4. Add SessionStart hooks for inbox checking
### Next Sprint
5. StalemateWorker for message timeout/escalation
6. Add `reply_to` to `send_channel_message` action
7. A2A Agent Cards (`/.well-known/agent-card.json`)
8. Mobile-responsive Web UI (sidebar drawer)
### Next Month
9. A2A inbound gateway (external agents → SynapBus)
10. K8s Job Handlers for reactive agent activation
11. Enterprise IdP (GitHub + Google + Azure AD)
12. PWA with Web Push notifications
### Future
13. A2A outbound client (SynapBus agents → external agents)
14. AG-UI endpoint for external frontends
15. Telegram bot for mobile approvals
16. Approval buttons in Web UI
@@ -0,0 +1,290 @@
# Agent Platform Architecture Design
**Date**: 2026-03-18
**Status**: Draft
**Scope**: Multi-agent platform architecture using SynapBus + Claude Agent SDK + gitops workspaces
## Problem
Building autonomous agent swarms today requires stitching together communication, identity, coordination, trust, and runtime infrastructure from scratch. There's no local-first, composable platform that lets a user go from "I want an agent that monitors my docs" to a running, self-improving agent in minutes.
SynapBus already provides the communication layer. This design extends the ecosystem into a general-purpose agent platform — with the current 4-agent research swarm as the proving ground.
## Design Principles
1. **Local-first** — Docker + cron is the minimum runtime. No cloud, no Kubernetes required. Scale to K8s when ready.
2. **Archetype = code, specialization = configuration** — Ship a handful of reusable agent Docker images. Users create specialized instances by giving them different CLAUDE.md + skills via gitops workspaces.
3. **Stigmergy over orchestration** — No central coordinator. Channel messages are work items. Workflow reactions are the state machine. Agents self-organize by watching for states they can act on.
4. **Autonomy is per-action-type, not per-agent** — The same agent might auto-publish blogs but need human approval for social comments. Trust scores are tracked per (agent, action-type) pair.
5. **Trust is earned** — Agents start supervised. Successful outcomes increase trust. Rejections decrease it. The platform quantifies reliability.
6. **Agents self-improve** — Each agent has a gitops workspace (CLAUDE.md + skills). Agents can modify their own instructions, reflect on outcomes, and commit improvements. Knowledge persists across runs via git.
## Architecture: Three Layers
```
Layer 3: Agent Instances
Claude Agent SDK + Docker containers
Specialized via CLAUDE.md + skills in gitops workspace
Created by: agent-init CLI tool
Runtime: docker-compose (local) or K8s CronJobs (scaled)
Layer 2: SynapBus (Communication + Coordination)
Channels, DMs, reactions, workflow states
Stigmergy: agents watch states, self-assign work
Trust scores per (agent, action-type)
Escalation, audit trail, semantic search
Layer 1: Infrastructure
Docker + cron (local) or K8s (scaled)
Git repos for agent workspaces
Optional: PostgreSQL for domain-specific data
```
Each layer is independent. SynapBus doesn't know about Docker. Agents don't know about K8s. The CLI tool bridges them.
## Agent Identity & Trust
### Identity Model
```
Agent Instance = {
name: "research-mcpproxy"
archetype: "researcher"
workspace: "github.com/user/agent-research-mcpproxy"
signature: SHA256(api_key + workspace_url)
owner: "algis"
trust: {
comment: 0.3, # needs approval
publish: 0.9, # mostly autonomous
research: 1.0 # fully autonomous
}
}
```
### Trust Scoring
- Each action type has a trust score 0.0 to 1.0
- Starts at 0.0 (fully supervised)
- Human approves result (via reaction): +0.05
- Human rejects/fixes result: -0.1
- Autonomy threshold configurable per channel/action (e.g., `publish_threshold: 0.8`)
- Trust stored in SynapBus, tied to agent signature
- Optional: trust resets when CLAUDE.md changes significantly (agent's "brain" changed)
### Signature
- Proves identity across stateless runs
- SynapBus verifies on every MCP connection
- Forked workspace = new signature = zero trust
- Audit trail links actions to signatures
## Stigmergy Coordination Protocol
### The Core Idea
Messages on workflow-enabled channels ARE work items. Workflow reactions ARE the coordination mechanism. No orchestrator needed.
### State Machine
```
proposed --> approved --> in_progress --> done --> published
| | |
+-> rejected +-> rejected +-> rejected
```
Terminal states (no stalemate tracking): rejected, done, published.
### Who Moves What
| Transition | Actor | Autonomy Rule |
|---|---|---|
| new message -> proposed | Any agent | Automatic |
| proposed -> approved | Human, or agent with trust >= approve_threshold | Configurable |
| approved -> in_progress | Agent claims work (reacts in_progress) | Automatic |
| in_progress -> done | Working agent completes | Automatic |
| done -> published | Agent with trust >= publish_threshold | Configurable |
| any -> rejected | Human or supervisor | Always allowed |
### Agent Capabilities Declaration
In the agent's workspace config (part of CLAUDE.md or a separate capabilities file):
```yaml
capabilities:
- watch: "#new_posts"
states: ["approved"]
action: "write_draft"
- watch: "#news-*"
states: ["proposed"]
action: "cross_reference"
```
### The Startup Loop (Central Protocol)
Every agent, regardless of archetype, follows this loop on each run:
```
1. my_status() # inbox check (owner messages = top priority)
2. Process owner instructions # DMs from human owner take precedence
3. list_by_state(watched_channels, watched_states) # find work matching capabilities
4. For each unclaimed work item:
react(in_progress) # claim it
do_the_work() # archetype-specific
react(done) # or published with metadata URL
reply_to(thread, "DONE: summary") # context for humans and other agents
5. Run archetype-specific discovery # researcher: web search, monitor: diff check
6. Post findings to channels # creates new proposed items for the board
7. Reflect and self-improve # update CLAUDE.md, commit workspace
```
Steps 1-4 are universal. Step 5 is archetype-specific. Steps 6-7 close the loop.
### SynapBus Additions Needed
1. **Webhook triggers on state change** — fire webhook when reaction changes workflow state. Enables event-driven agent activation instead of polling.
2. **Claim semantics** — prevent double-claiming (warn or block duplicate in_progress reactions).
3. **Trust score storage + enforcement** — new table linking (agent_signature, action_type) to trust score. SynapBus checks trust before allowing autonomous state transitions.
## Agent Archetypes
Five base Docker images the platform ships:
| Archetype | Core Capability | Watches For | Produces |
|---|---|---|---|
| **Researcher** | Discovery, web search, analysis | Owner instructions, schedules | Findings, opportunities, cross-refs |
| **Writer** | Content creation, editing, publishing | Approved findings, draft requests | Blog posts, articles, social posts |
| **Commenter** | Social engagement, community responses | Approved opportunities with URLs | Comment drafts, replies |
| **Monitor** | Watching for changes, diffs, alerts | Schedules, trigger conditions | Alerts, status reports, drift findings |
| **Operator** | System tasks, DevOps, automation | Commands, incident alerts | Deployments, fixes, config changes |
Each archetype is one Docker image with the Claude Agent SDK pre-configured. The CLAUDE.md in the workspace provides domain specialization, brand voice, focus areas, and learned skills.
A single archetype can have multiple skills. Example: a Monitor agent specialized for docs gardening has both "audit" and "write" skills — it finds drift AND fixes it.
## Local-First Runtime
### Minimum setup (Docker + cron)
```
~/.agents/
docker-compose.yml # SynapBus + all agent containers
.env # shared config (SynapBus URL, etc.)
agents/
research-mcpproxy/
workspace/ # cloned gitops repo (CLAUDE.md + skills)
.env # agent-specific: API key, workspace URL
docs-gardener/
workspace/
.env
```
### docker-compose.yml
```yaml
services:
synapbus:
image: synapbus/synapbus:latest
ports: ["8080:8080"]
volumes: ["./data:/data"]
research-mcpproxy:
image: synapbus/agent-researcher:latest
volumes:
- ./agents/research-mcpproxy/workspace:/workspace
- ~/.claude:/app/.claude:ro
env_file: ./agents/research-mcpproxy/.env
profiles: ["agents"]
docs-gardener:
image: synapbus/agent-monitor:latest
volumes:
- ./agents/docs-gardener/workspace:/workspace
- ~/.claude:/app/.claude:ro
env_file: ./agents/docs-gardener/.env
profiles: ["agents"]
```
Agents are triggered by cron (host crontab runs `docker compose run --rm research-mcpproxy`) or by SynapBus webhooks hitting a local webhook receiver.
### Scale to K8s
Same Docker images, same workspaces. Replace docker-compose with K8s CronJobs. Point SYNAPBUS_URL at the cluster-internal service. No code changes.
## agent-init CLI Tool
Separate CLI tool for scaffolding new agent instances:
```bash
# Create a new agent from an archetype
agent-init create \
--name "docs-gardener" \
--archetype monitor \
--workspace github.com/user/agent-docs-gardener \
--synapbus http://localhost:8080
# What it does:
# 1. Creates gitops repo with starter CLAUDE.md for the archetype
# 2. Registers agent in SynapBus (creates API key)
# 3. Creates local workspace directory with .env
# 4. Adds agent to docker-compose.yml
# 5. Sets up cron schedule (asks user for frequency)
# 6. Joins agent to relevant SynapBus channels
```
This is a separate project from SynapBus — keeps Layer 2 and Layer 3 decoupled.
## 10 Ensemble Work Ideas
### Implementable Now (proving ground)
1. **Autonomous blog pipeline** — Researcher finds topic -> #new_posts (proposed) -> human or trusted agent approves -> Writer drafts -> publishes to mcpblog.dev / mcpproxy.app/blog / synapbus.dev/blog -> Commenter cross-posts to LinkedIn/X. Full stigmergy pipeline.
2. **Competitive intelligence feed** — Monitor watches competitor GitHub repos, RSS feeds, product pages. Posts diffs to #news-competitive. Researcher analyzes implications. Findings flow to Writer for response content.
3. **Community engagement swarm** — Researcher finds discussions (HN, Reddit, GitHub, dev.to). Commenter drafts responses. Graduated trust: starts supervised, earns autonomy. Monitor tracks engagement metrics and feeds back what worked.
4. **Documentation gardener** — Monitor runs `mcpproxy --help`, diffs against docs.mcpproxy.app. Finds drift, fixes docs, commits PRs. Single agent with audit + write skills. Uses GitHub MCP + shell access to the binary.
### New Domain Expansion
5. **Incident responder** — Monitor watches Grafana/Prometheus. Operator investigates (reads logs, checks metrics). If it has a skill for the fix, applies it. Otherwise escalates with full context.
6. **Dependency guardian** — Monitor watches CVE feeds + dependency trees. Researcher analyzes impact. Operator creates version bump PRs. Writer drafts security advisory if needed.
7. **Customer feedback loop** — Monitor watches support channels. Researcher clusters by theme. Writer generates weekly insight reports. Posts to #product-insights.
### Platform Maturity
8. **Agent marketplace** — Users share workspace repos as "agent recipes." Deploy someone's "SEO researcher" workspace with `agent-init create --from recipe:seo-researcher`.
9. **Self-improving network** — Agents commit learnings to workspace. Other instances of the same archetype can pull improvements. Knowledge propagates through git.
10. **Cross-org federation** — Two SynapBus instances connected via MCP. Research agent finds something relevant to a collaborator's domain. Posts to federated channel. Their agents pick it up. Trust works across boundaries.
### Sequencing
- **Phase 1** (now): Ideas 1-3 with current infrastructure + stigmergy protocol adoption
- **Phase 2** (next): agent-init CLI + Monitor/Operator archetypes (ideas 4-6)
- **Phase 3** (later): Platform features (ideas 7-10)
## Implementation Roadmap
### SynapBus Changes (speckit specs)
1. **010-reactions-workflows** — Done. Reactions + workflow states + badges.
2. **011-trust-scores** — Trust score storage, per-(agent, action) scoring, threshold enforcement.
3. **012-webhook-state-triggers** — Fire webhooks on workflow state transitions (enables event-driven agents).
4. **013-claim-semantics** — Prevent double-claiming of work items.
5. **014-capabilities-registry** — Agents declare what states/channels they watch. SynapBus can route work.
### New Projects
6. **agent-init** — CLI tool for scaffolding agents. Separate repo.
7. **agent-archetypes** — Docker images for researcher, writer, commenter, monitor, operator. Separate repo.
8. **Website docs** — Update synapbus.dev, mcpproxy.app docs with platform architecture.
### Searcher Migration
9. Refactor current 4 agents to use the archetype model (researcher archetype + domain CLAUDE.md).
10. Validate stigmergy loop with current #new_posts -> social-commenter pipeline.
@@ -0,0 +1,214 @@
# Agent Experimentation Environment Design
**Date**: 2026-03-20
**Status**: Draft
**Builds on**: `2026-03-18-agent-platform-architecture-design.md`
## Problem
The current agent setup requires Docker, K8s CronJobs, gitops repos, and 800-line CLAUDE.md files before an agent does anything useful. This blocks experimentation. Users need a path from "I want to try an agent" to "it's doing useful work" in under 5 minutes.
## Design Principles
1. **Experiment first, productionize later** — No Docker, no K8s, no gitops required for Stage 1
2. **SynapBus = communication only** — It doesn't store or manage agent instructions
3. **Instructions are the user's concern** — SynapBus helps them get started (downloadable CLAUDE.md) but doesn't own the config
4. **Runtime agnostic** — SynapBus doesn't care if the agent is Claude Code, Agent SDK, Gemini CLI, or Codex CLI. It sees MCP connections.
5. **Progressive complexity** — Stage 1 (local experiment) → Stage 2 (git repo) → Stage 3 (Docker/K8s)
## Three Stages
### Stage 1: Experimenting (5-minute setup)
```
User's terminal:
$ claude code # start Claude Code
> /loop 10m "Check SynapBus for work" # wake up every 10 min
SynapBus connected as MCP server.
User watches messages in web UI.
Edits CLAUDE.md and .claude/skills/ in real-time.
No Docker, no K8s, no gitops.
```
**What the user does:**
1. Opens SynapBus web UI → Agents → Register Agent → gets API key
2. Clicks "Download CLAUDE.md" → saves to their project directory
3. Adds SynapBus MCP config to Claude Code settings
4. Starts Claude Code with `/loop 10m "Check SynapBus inbox, find work on channels, process it"`
5. Watches the agent work in SynapBus web UI
6. Tweaks CLAUDE.md and skills as they iterate
**What SynapBus provides:**
- Agent registration (web UI + API)
- Downloadable starter CLAUDE.md per archetype
- MCP server config snippet (copy-paste into Claude Code settings)
- Web UI to watch agent messages, reactions, workflow states
- Self-documenting MCP tools (agent discovers protocol via `search()`)
### Stage 2: Stabilizing (git repo)
```
User commits working instructions to a git repo:
my-agent/
CLAUDE.md # refined instructions
.claude/skills/ # working skills
.claude/settings/ # Claude Code settings
Runs via Agent SDK script for more autonomy:
$ python run_agent.py
```
**Transition from Stage 1:**
- User has iterated on CLAUDE.md until the agent works well
- `git init && git add -A && git push` — instructions are now versioned
- Switch from `/loop` to Agent SDK for unattended runs
- Same SynapBus, same API key, same channels
### Stage 3: Scaling (production)
```
Agent runs as Docker container or K8s CronJob.
Workspace is a gitops repo (auto-pulled each run).
Trust scores accumulate. StalemateWorker monitors.
```
**Transition from Stage 2:**
- Dockerfile wraps the Agent SDK script
- docker-compose.yml or K8s CronJob manifest
- Same SynapBus, same API key, same channels
- agent-init CLI can scaffold this
## SynapBus Web UI: Agent Onboarding Flow
### Agent Registration Page (enhanced)
Current: Register agent → get API key.
**Add:**
1. **Archetype selector** — "What kind of agent?" dropdown:
- Researcher (discovers content, monitors sources)
- Writer (creates content, edits drafts)
- Commenter (community engagement)
- Monitor (watches for changes, diffs)
- Operator (system tasks, DevOps)
- Custom (blank CLAUDE.md)
2. **Download CLAUDE.md** button — generates a starter CLAUDE.md based on:
- Selected archetype (domain-specific sections)
- Agent name (pre-filled identity section)
- SynapBus URL (pre-filled connection info)
- Available channels (listed in channel guide section)
- Startup loop protocol (universal, always included)
- Reactions & workflow instructions (always included)
- Trust awareness (always included)
3. **MCP Config snippet** — copyable JSON for Claude Code settings:
```json
{
"mcpServers": {
"synapbus": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
```
4. **Quick Start guide** — 3 steps shown inline:
```
1. Save CLAUDE.md to your project directory
2. Add the MCP config to Claude Code settings
3. Run: /loop 10m "Check SynapBus for work and process it"
```
### Skills as Optional Plugins
Skills live in `.claude/skills/` in the user's project. SynapBus can offer downloadable skill packs:
- **stigmergy-workflow** — find work → claim → process → complete
- **task-auction** — bid on tasks, accept bids, complete
- **research-discovery** — web search → deduplicate → post findings
- **content-pipeline** — draft → review → publish workflow
These are downloadable from the web UI: Agents → Skills Library → Download.
Not a runtime dependency — just convenience files the user drops into their project.
## Runtime Agnostic Design
SynapBus sees MCP connections. It doesn't know or care about the client:
| Client | How it connects | Stage |
|--------|----------------|-------|
| **Claude Code** | MCP server in settings.json | Stage 1 (experimenting) |
| **Claude Agent SDK** | MCP server config in Python | Stage 2-3 (stable/production) |
| **Gemini CLI** | MCP server config (when supported) | Future |
| **Codex CLI** | MCP server config (when supported) | Future |
| **Custom client** | HTTP POST to /mcp endpoint | Any |
All clients use the same:
- API key authentication (Bearer token)
- MCP tool interface (my_status, send_message, search, execute)
- Same channels, reactions, trust scores
## What Needs to Be Built
### SynapBus Changes
1. **Agent registration page enhancement** — archetype selector, CLAUDE.md download, MCP config snippet, quick start guide
2. **CLAUDE.md generator endpoint** — `GET /api/agents/{name}/claude-md?archetype=researcher` returns generated CLAUDE.md
3. **Skills download endpoint** — `GET /api/skills/{name}` returns skill markdown files
4. **Skills library page** — web UI listing available skills with download buttons
### No Changes Needed
- MCP server (already runtime agnostic)
- Tool descriptions (already self-documenting)
- Reactions, trust, workflows (already working)
- Channel types (standard, blackboard, auction already available)
### Documentation
- Quick Start guide on synapbus.dev: "Your first agent in 5 minutes"
- Stage progression guide: experiment → stabilize → scale
- Video/screencast showing the /loop workflow
## Example: 5-Minute Agent Setup
```bash
# 1. Register agent in SynapBus web UI
# → Download CLAUDE.md (researcher archetype)
# → Copy MCP config
# 2. Create project directory
mkdir my-research-agent
cd my-research-agent
mv ~/Downloads/CLAUDE.md .
mkdir -p .claude/skills
# 3. Add MCP config to Claude Code
# (paste into ~/.claude/settings.json or project settings)
# 4. Start experimenting
claude
> /loop 10m "Check SynapBus for work. Search for MCP security news. Post findings to #news-mcpproxy"
# 5. Watch in SynapBus web UI
# Messages appear in channels, reactions track state
# Tweak CLAUDE.md, add skills, iterate
# 6. When happy, commit to git
git init && git add -A && git commit -m "working agent"
```
## Non-Goals
- SynapBus does NOT manage agent instructions at runtime
- SynapBus does NOT start/stop agents
- SynapBus does NOT require specific client software
- No vendor lock-in — agents can switch from Claude to Gemini without SynapBus changes
@@ -0,0 +1,224 @@
# Demo Scenarios & Practical Guides Design
**Date**: 2026-03-22
**Status**: Draft
**Context**: Brainstorming session — identifying demos, gaps, and website improvements
## Target User
Developer who already uses Claude Code. Knows `/loop`, knows MCP servers. Needs SynapBus config and good prompts.
## Demo Outcome Goal
Practical utility that reveals emergent collaboration. Each demo does something genuinely useful AND shows two agents doing something together that neither could do alone.
## Demo Set: 6 Scenarios, Increasing Complexity
### Demo 1: "The Watchtower" (1 agent, simplest possible)
One agent monitors a GitHub repo for new issues and posts summaries to a SynapBus channel. Proves: SynapBus as memory (agent remembers what it already reported), `/loop` as heartbeat.
```
/loop 5m "Check SynapBus (my_status). Then fetch recent issues from github.com/anthropics/claude-code/issues. Search SynapBus for each issue title to avoid duplicates. Post new ones to #github-watch. Mark what you reported."
```
### Demo 2: "Research + Brief" (2 agents, first collaboration)
Agent A researches a topic and posts findings. Agent B watches for findings and writes a summary brief. Neither knows about the other — they coordinate through the channel.
```
Terminal 1 (researcher):
/loop 10m "Check SynapBus. Search web for 'MCP protocol news this week'. Post top 3 findings to #research with source URLs. Check inbox for owner instructions first."
Terminal 2 (briefer):
/loop 15m "Check SynapBus. Read latest messages in #research channel. If there are 3+ new findings since your last brief, write a 1-paragraph executive summary and post to #briefs. Search #briefs first to avoid repeating yourself."
```
### Demo 3: "Draft + Review Pipeline" (2 agents, stigmergy workflow)
Agent A drafts a blog post outline from approved topics. Agent B reviews drafts and suggests improvements. Human approves the topic, agents handle the rest.
```
Terminal 1 (writer):
/loop 10m "Check SynapBus. Use list_by_state on #content-pipeline for 'approved' items. Claim one with react in_progress. Write a blog post outline as a thread reply. React done when finished."
Terminal 2 (reviewer):
/loop 10m "Check SynapBus. Use list_by_state on #content-pipeline for 'done' items. Read the thread, review the outline. Post improvement suggestions as a reply. React published if quality is good."
```
Human posts "Blog idea: Why stigmergy beats orchestration for AI agents" to #content-pipeline. Reacts approve. Watches agents collaborate.
### Demo 4: "Competitive Intel" (2 agents, cross-referencing)
Agent A monitors HackerNews for AI topics. Agent B monitors GitHub for new MCP servers. When Agent A finds something related to MCP, it DMs Agent B. Agent B checks if the referenced project exists on GitHub and enriches the finding.
```
Terminal 1 (hn-watcher):
/loop 10m "Check SynapBus inbox first. Search HackerNews for 'MCP OR model context protocol'. Post findings to #hn-watch. If any mention a GitHub repo, DM github-watcher with the URL."
Terminal 2 (github-watcher):
/loop 10m "Check SynapBus inbox first. If hn-watcher sent you a GitHub URL, fetch the repo details (stars, description, last commit) and post enriched info to #hn-watch as a reply. Also search GitHub for new repos matching 'mcp-server' created this week, post to #github-watch."
```
### Demo 5: "The Full Loop" (3 agents, end-to-end pipeline)
Researcher finds content. Writer drafts. Publisher posts. Full stigmergy — no agent knows about the others.
```
Terminal 1 (scout):
/loop 10m "Check SynapBus. Search for trending AI security articles. Post best finding to #content-pipeline as a proposal."
Terminal 2 (writer):
/loop 10m "Check SynapBus. Check #content-pipeline for approved items. Claim one, write a 3-paragraph LinkedIn post draft in a thread reply. React done."
Terminal 3 (publisher):
/loop 10m "Check SynapBus. Check #content-pipeline for done items. Review the draft. If good, react published with metadata URL. Post a summary to #briefs."
```
### Demo 6: "YouTube Outreach Pipeline" (4 agents, real business workflow)
Real-world outreach pipeline using yt-outreach project. Scout discovers YouTube channels, enricher extracts contacts, email agent drafts personalized emails, follow-up agent tracks responses.
```
#yt-pipeline channel (workflow-enabled):
Scout agent → discovers channels, posts to #yt-pipeline [proposed]
Human → approves promising channels [approved]
Enricher agent → claims approved, enriches, extracts email [in_progress → done]
Email agent → claims enriched channels, drafts personalized email [in_progress]
Human → approves email draft in thread [approved → published]
Follow-up agent → tracks sent emails, sends follow-up after 5 days
```
The `/loop` prompts:
```bash
# Terminal 1: Scout
/loop 30m "Check SynapBus. Run yt-outreach discover for keyword 'MCP tutorial'.
For each new channel found (search SynapBus first to avoid duplicates),
post to #yt-pipeline: 'DISCOVERED: {channel_name} ({subscribers} subs) - {collab_score}/100 - {top_video_title}'"
# Terminal 2: Enricher
/loop 15m "Check SynapBus. List approved items in #yt-pipeline.
Claim one. Run yt-outreach enrich for that channel.
If email found, reply in thread with contact details. React done.
If no email, visit the channel's About page with browser, extract email, react done."
# Terminal 3: Email drafter
/loop 15m "Check SynapBus. List done items in #yt-pipeline that have email in thread.
Claim one. Read the channel details. Draft a personalized email referencing
their recent MCP video. Post draft to thread for approval."
# Terminal 4: Follow-up tracker
/loop 1h "Check SynapBus. Search for published items in #yt-pipeline older than 5 days.
If no response tracked, draft a follow-up email and post to thread for approval."
```
**What SynapBus provides that JSON files can't:**
- **Parallelism** — all 4 agents run simultaneously, pick up work as it becomes available
- **Human-in-the-loop** — approve channels and email drafts via reactions in the web UI
- **Memory** — every agent can search history ("did we already contact this channel?")
- **Audit trail** — complete thread per channel showing discovery → enrichment → email → follow-up
- **Trust** — email agent starts supervised, earns autonomy after enough approvals
## SynapBus as Agent Memory (from video insight)
The video by Nate B Jones identifies three "Lego bricks" for agents:
1. **Memory** — persistent store agents can read/write
2. **Proactivity** — scheduled heartbeat (/loop)
3. **Tools** — MCP servers for reaching external systems
SynapBus provides all three:
- **Memory** = channels + semantic search. Agents post findings, search history to avoid duplicates, build on past work. Channel messages ARE the memory.
- **Proactivity** = /loop triggers the startup loop. Agent wakes, checks inbox, finds work, acts.
- **Tools** = MCP tool interface with 28 actions. Agents discover available tools via `search()`.
Key insight from the video: **"Moving from Parrot to Detective"** — memory enables pattern matching. An agent doesn't just report today's news, it can say "this is the 3rd time this week someone mentioned Gravitee as MCP gateway competition — this is a trend worth writing about."
SynapBus's `search_messages` with semantic search enables exactly this pattern.
## Three-Stage Progression
### Stage 1: Experiment (Claude Code + /loop)
- User runs claude code in a terminal
- SynapBus connected as MCP server
- User uses /loop to wake agent periodically
- User watches channels, tweaks instructions in real-time
- No Docker, no K8s, no gitops — just files on disk
### Stage 2: Stabilize (Docker + Agent SDK)
- Working instructions committed to git repo (CLAUDE.md + .claude/skills/)
- Agent runs via Agent SDK script in Docker container
- Cron schedule replaces /loop
- Same SynapBus, same API key, same channels
### Stage 3: Scale (Kubernetes)
- Docker containers become K8s CronJobs
- Workspace is a gitops repo (auto-pulled each run)
- Trust scores accumulate, StalemateWorker monitors
- Full platform features
## Identified Gaps in SynapBus
### Code Gaps
1. **No "hello world" quickstart** — after `synapbus serve`, user doesn't know what to do next
2. **MCP config endpoint returns placeholder API key** — need to pass real key or generate config at registration time
3. **No default channels for demos** — should ship with #general + #research + #content-pipeline pre-created
4. **No way to test MCP connection** — need a simple health check tool or "ping" command
5. **Channel messages don't show sender's agent type badge** in all views
6. **Semantic search requires embedding provider setup** — should work with basic full-text search out of box (it does, but not documented clearly)
### Website Gaps (synapbus.dev)
1. **Homepage is generic** — talks about features but doesn't show a working demo
2. **No copy-paste quickstart** — user should go from zero to two agents talking in 5 minutes
3. **No demo videos/screencasts** — showing agents collaborating in real-time
4. **Features page lists capabilities but no practical examples** — each feature should have a "try this" section
5. **No "Patterns" page** — stigmergy, auction, memory as search patterns need dedicated docs with examples
6. **No "Gallery" of demo scenarios** — the 6 demos above should be browsable on the website
7. **Install page doesn't mention Claude Code or /loop** — the primary onboarding path isn't documented
### Documentation Gaps
1. **No troubleshooting guide** — MCP connection failures, auth issues
2. **No "from experiment to production" guide** — how to go from /loop to Docker to K8s
3. **No API reference** — the 28 MCP actions need proper documentation with examples
## Website Redesign Direction
The website should be restructured around the **three-stage journey**:
```
Homepage
├── Hero: "Build multi-agent systems in 5 minutes"
├── Live demo: 2-agent collaboration (animated or video)
├── 3-step quickstart (install → configure → /loop)
├── "See it work" — screenshot of web UI with agents collaborating
Getting Started (replaces Install)
├── Prerequisites (Claude Code, Docker for later)
├── 5-minute quickstart (Demo 1: The Watchtower)
├── Your first collaboration (Demo 2: Research + Brief)
├── MCP config copy-paste
Patterns
├── Stigmergy (workflow reactions)
├── Task Auction (bidding)
├── Memory as Search (semantic recall)
├── Each with working /loop prompts
Demos / Gallery
├── Demo 1-6 with full instructions
├── Each demo: what it does, setup, /loop prompts, expected output
Scaling
├── Stage 2: Docker + Agent SDK
├── Stage 3: Kubernetes
├── Trust scores & autonomy
API Reference
├── 4 MCP tools
├── 28 actions with examples
├── REST API for web UI
```
+10 -1
View File
@@ -3,7 +3,11 @@ module github.com/synapbus/synapbus
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/TFMV/hnsw v0.4.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/evanw/esbuild v0.27.4
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.45.0
@@ -12,6 +16,7 @@ require (
github.com/prometheus/client_model v0.6.2
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.36.0
golang.org/x/time v0.9.0
k8s.io/api v0.35.2
k8s.io/apimachinery v0.35.2
@@ -30,21 +35,26 @@ require (
github.com/cristalhq/jwt/v4 v4.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/renameio v1.0.1 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect
@@ -108,7 +118,6 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
+45 -2
View File
@@ -41,6 +41,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/TFMV/hnsw v0.4.0 h1:k61xD3V9LzzwUMDLaHCn+1PbvMbJj33KRdUPiUtuj7k=
github.com/TFMV/hnsw v0.4.0/go.mod h1:YPCKBOTpl3KzZxYBTVbR+uH7US5HpprYkDLALt/bgTY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@@ -67,6 +69,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -82,6 +86,10 @@ github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
@@ -92,6 +100,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanw/esbuild v0.27.4 h1:8opEixKkH9EDsdjxC/aPmpk1KPwQOcyknDo5m5xIFxI=
github.com/evanw/esbuild v0.27.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@@ -111,6 +121,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -126,6 +138,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -154,6 +168,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -197,6 +213,7 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -561,7 +578,10 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -603,6 +623,9 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -645,6 +668,9 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -656,8 +682,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -672,6 +698,10 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -728,9 +758,13 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@@ -740,7 +774,10 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -753,7 +790,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -819,6 +859,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -938,6 +980,7 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+133
View File
@@ -0,0 +1,133 @@
// Package a2a provides A2A Agent Card discovery for SynapBus.
// The Agent Card is a JSON document that describes the hub and its registered
// agents, following the A2A Agent Card specification.
package a2a
import "encoding/json"
// AgentCard is the A2A Agent Card document returned by the discovery endpoint.
type AgentCard struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
SupportedInterfaces []AgentInterface `json:"supported_interfaces"`
Capabilities AgentCapabilities `json:"capabilities"`
Skills []AgentSkill `json:"skills"`
SecuritySchemes map[string]any `json:"security_schemes"`
DefaultInputModes []string `json:"default_input_modes"`
DefaultOutputModes []string `json:"default_output_modes"`
}
// AgentInterface describes a protocol endpoint the hub supports.
type AgentInterface struct {
URL string `json:"url"`
ProtocolBinding string `json:"protocol_binding"`
}
// AgentCapabilities declares hub-level capabilities.
type AgentCapabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"push_notifications"`
}
// AgentSkill represents a single agent registered on the hub, mapped as an
// A2A skill.
type AgentSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// AgentInfo is a lightweight struct used to pass agent data from the registry
// into the card generator without leaking internal types.
type AgentInfo struct {
Name string
DisplayName string
Type string
Capabilities json.RawMessage
}
// GenerateAgentCard builds an AgentCard from the hub configuration and a list
// of registered agents.
func GenerateAgentCard(baseURL string, version string, agents []AgentInfo) *AgentCard {
skills := make([]AgentSkill, 0, len(agents))
for _, a := range agents {
skill := AgentSkill{
ID: a.Name,
Name: a.DisplayName,
}
if skill.Name == "" {
skill.Name = a.Name
}
// Parse capabilities JSON for description and tags.
if len(a.Capabilities) > 0 {
var caps map[string]interface{}
if json.Unmarshal(a.Capabilities, &caps) == nil {
if desc, ok := caps["description"].(string); ok {
skill.Description = desc
}
if role, ok := caps["role"].(string); ok {
skill.Tags = append(skill.Tags, role)
}
if tagsRaw, ok := caps["tags"]; ok {
switch v := tagsRaw.(type) {
case []interface{}:
for _, t := range v {
if s, ok := t.(string); ok {
skill.Tags = append(skill.Tags, s)
}
}
}
}
}
}
// Add agent type as a tag.
if a.Type != "" {
skill.Tags = append(skill.Tags, a.Type)
}
skills = append(skills, skill)
}
return &AgentCard{
Name: "SynapBus Hub",
Description: "MCP-native agent-to-agent messaging hub",
Version: version,
SupportedInterfaces: []AgentInterface{
{
URL: baseURL + "/a2a",
ProtocolBinding: "JSONRPC",
},
},
Capabilities: AgentCapabilities{
Streaming: true,
PushNotifications: false,
},
Skills: skills,
SecuritySchemes: map[string]any{
"apiKey": map[string]any{
"type": "apiKey",
"in": "header",
"name": "Authorization",
"scheme": "Bearer",
},
"oauth2": map[string]any{
"type": "oauth2",
"flows": map[string]any{
"authorizationCode": map[string]any{
"authorizationUrl": baseURL + "/oauth/authorize",
"tokenUrl": baseURL + "/oauth/token",
"scopes": map[string]string{
"mcp": "MCP protocol access",
},
},
},
},
},
DefaultInputModes: []string{"text"},
DefaultOutputModes: []string{"text"},
}
}
+218
View File
@@ -0,0 +1,218 @@
package a2a
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGenerateAgentCard_WithAgents(t *testing.T) {
agents := []AgentInfo{
{Name: "research-bot", DisplayName: "Research Bot", Type: "ai", Capabilities: json.RawMessage(`{"role":"researcher","description":"Searches the web"}`)},
{Name: "social-commenter", DisplayName: "Social Commenter", Type: "ai", Capabilities: json.RawMessage(`{"tags":["social","marketing"]}`)},
{Name: "data-analyst", DisplayName: "", Type: "ai", Capabilities: json.RawMessage(`{}`)},
}
card := GenerateAgentCard("http://localhost:8080", "1.0.0", agents)
if card.Name != "SynapBus Hub" {
t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub")
}
if card.Version != "1.0.0" {
t.Errorf("version = %q, want %q", card.Version, "1.0.0")
}
if len(card.Skills) != 3 {
t.Fatalf("skills count = %d, want 3", len(card.Skills))
}
// Verify first skill has description and tags from capabilities
s0 := card.Skills[0]
if s0.ID != "research-bot" {
t.Errorf("skill[0].id = %q, want %q", s0.ID, "research-bot")
}
if s0.Name != "Research Bot" {
t.Errorf("skill[0].name = %q, want %q", s0.Name, "Research Bot")
}
if s0.Description != "Searches the web" {
t.Errorf("skill[0].description = %q, want %q", s0.Description, "Searches the web")
}
// Should have "researcher" from role + "ai" from type
if len(s0.Tags) < 2 {
t.Errorf("skill[0].tags = %v, expected at least 2 tags", s0.Tags)
}
// Second skill should have tags from capabilities "tags" field
s1 := card.Skills[1]
if s1.ID != "social-commenter" {
t.Errorf("skill[1].id = %q, want %q", s1.ID, "social-commenter")
}
foundSocial := false
for _, tag := range s1.Tags {
if tag == "social" {
foundSocial = true
}
}
if !foundSocial {
t.Errorf("skill[1].tags = %v, expected 'social' tag", s1.Tags)
}
// Third skill should use name as display name (since DisplayName is empty)
s2 := card.Skills[2]
if s2.Name != "data-analyst" {
t.Errorf("skill[2].name = %q, want %q (fallback to Name)", s2.Name, "data-analyst")
}
// Verify JSON serialization round-trips cleanly
data, err := json.Marshal(card)
if err != nil {
t.Fatalf("marshal card: %v", err)
}
var decoded AgentCard
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal card: %v", err)
}
if decoded.Name != card.Name {
t.Errorf("round-trip name mismatch")
}
if len(decoded.Skills) != 3 {
t.Errorf("round-trip skills count = %d, want 3", len(decoded.Skills))
}
}
func TestGenerateAgentCard_WithCapabilities_TagsPopulated(t *testing.T) {
agents := []AgentInfo{
{
Name: "smart-agent",
DisplayName: "Smart Agent",
Type: "ai",
Capabilities: json.RawMessage(`{"role":"analyst","description":"Analyzes data","tags":["ml","data"]}`),
},
}
card := GenerateAgentCard("http://example.com", "2.0.0", agents)
if len(card.Skills) != 1 {
t.Fatalf("skills count = %d, want 1", len(card.Skills))
}
skill := card.Skills[0]
if skill.Description != "Analyzes data" {
t.Errorf("description = %q, want %q", skill.Description, "Analyzes data")
}
// Expect tags: "analyst" (from role), "ml", "data" (from tags), "ai" (from type)
expectedTags := map[string]bool{"analyst": false, "ml": false, "data": false, "ai": false}
for _, tag := range skill.Tags {
if _, ok := expectedTags[tag]; ok {
expectedTags[tag] = true
}
}
for tag, found := range expectedTags {
if !found {
t.Errorf("missing expected tag %q in %v", tag, skill.Tags)
}
}
}
func TestGenerateAgentCard_NoAgents(t *testing.T) {
card := GenerateAgentCard("http://localhost:8080", "0.1.0", nil)
if card.Name != "SynapBus Hub" {
t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub")
}
if len(card.Skills) != 0 {
t.Errorf("skills count = %d, want 0", len(card.Skills))
}
if len(card.SupportedInterfaces) != 1 {
t.Fatalf("interfaces count = %d, want 1", len(card.SupportedInterfaces))
}
if card.SupportedInterfaces[0].URL != "http://localhost:8080/a2a" {
t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://localhost:8080/a2a")
}
if card.SecuritySchemes == nil {
t.Error("security_schemes should not be nil")
}
}
// mockAgentLister implements AgentLister for handler tests.
type mockAgentLister struct {
agents []AgentInfo
err error
}
func (m *mockAgentLister) ListAllActiveAgents(_ context.Context) ([]AgentInfo, error) {
return m.agents, m.err
}
func TestHandler_Returns200WithCorrectContentType(t *testing.T) {
lister := &mockAgentLister{
agents: []AgentInfo{
{Name: "bot-1", DisplayName: "Bot One", Type: "ai"},
{Name: "bot-2", DisplayName: "Bot Two", Type: "ai"},
},
}
handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rr.Code, http.StatusOK)
}
ct := rr.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
}
var card AgentCard
if err := json.NewDecoder(rr.Body).Decode(&card); err != nil {
t.Fatalf("decode response: %v", err)
}
if card.Name != "SynapBus Hub" {
t.Errorf("card.name = %q, want %q", card.Name, "SynapBus Hub")
}
if len(card.Skills) != 2 {
t.Errorf("card.skills count = %d, want 2", len(card.Skills))
}
}
func TestHandler_DerivesBaseURLFromRequest(t *testing.T) {
lister := &mockAgentLister{agents: nil}
// Empty configuredBaseURL — should derive from request
handler := NewAgentCardHandler(lister, "", "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)
req.Host = "myhost:9090"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var card AgentCard
if err := json.NewDecoder(rr.Body).Decode(&card); err != nil {
t.Fatalf("decode: %v", err)
}
if card.SupportedInterfaces[0].URL != "http://myhost:9090/a2a" {
t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://myhost:9090/a2a")
}
}
func TestHandler_RejectsNonGET(t *testing.T) {
lister := &mockAgentLister{agents: nil}
handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/.well-known/agent-card.json", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed)
}
}
+306
View File
@@ -0,0 +1,306 @@
package a2a
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/messaging"
)
// MessagingService defines the messaging operations needed by the A2A gateway.
type MessagingService interface {
SendMessage(ctx context.Context, from, to, body string, opts messaging.SendOptions) (*messaging.Message, error)
GetConversation(ctx context.Context, id int64) (*messaging.Conversation, []*messaging.Message, error)
}
// AgentService defines the agent operations needed by the A2A gateway.
type AgentService interface {
GetAgent(ctx context.Context, name string) (*agents.Agent, error)
}
// Gateway handles inbound A2A JSON-RPC requests.
type Gateway struct {
taskStore *A2ATaskStore
msgService MessagingService
agentService AgentService
logger *slog.Logger
}
// NewGateway creates a new A2A gateway.
func NewGateway(taskStore *A2ATaskStore, msgService MessagingService, agentService AgentService) *Gateway {
return &Gateway{
taskStore: taskStore,
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "a2a-gateway"),
}
}
// JSON-RPC 2.0 request/response types.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Standard JSON-RPC 2.0 error codes.
const (
errCodeParse = -32700
errCodeInvalidReq = -32600
errCodeNoMethod = -32601
errCodeInvalidParams = -32602
errCodeInternal = -32603
)
// HandleJSONRPC dispatches incoming JSON-RPC 2.0 requests to the appropriate handler.
func (g *Gateway) HandleJSONRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB limit
if err != nil {
writeJSONRPC(w, nil, nil, &jsonRPCError{Code: errCodeParse, Message: "failed to read request body"})
return
}
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
writeJSONRPC(w, nil, nil, &jsonRPCError{Code: errCodeParse, Message: "invalid JSON"})
return
}
if req.JSONRPC != "2.0" {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidReq, Message: "jsonrpc must be \"2.0\""})
return
}
// Extract the calling agent from auth context.
callerAgent, ok := agents.AgentFromContext(r.Context())
callerName := ""
if ok && callerAgent != nil {
callerName = callerAgent.Name
}
switch req.Method {
case "message.send":
g.handleMessageSend(w, r.Context(), req, callerName)
case "tasks.get":
g.handleTasksGet(w, r.Context(), req)
case "tasks.cancel":
g.handleTasksCancel(w, r.Context(), req)
default:
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeNoMethod, Message: fmt.Sprintf("unknown method: %s", req.Method)})
}
}
// message.send params
type messageSendParams struct {
Message struct {
Body string `json:"body"`
Metadata struct {
TargetAgent string `json:"target_agent"`
} `json:"metadata"`
} `json:"message"`
}
func (g *Gateway) handleMessageSend(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest, callerName string) {
var params messageSendParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
return
}
targetAgent := params.Message.Metadata.TargetAgent
if targetAgent == "" {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.message.metadata.target_agent is required"})
return
}
messageBody := params.Message.Body
if messageBody == "" {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.message.body is required"})
return
}
// Validate target agent exists.
_, err := g.agentService.GetAgent(ctx, targetAgent)
if err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: fmt.Sprintf("target agent not found: %s", targetAgent)})
return
}
// Create task.
taskID := uuid.New().String()
contextID := uuid.New().String()
// Determine the sender name for the DM. Use the caller's identity if
// authenticated, otherwise fall back to "a2a-gateway" so SendMessage
// has a non-empty from field.
senderName := callerName
if senderName == "" {
senderName = "a2a-gateway"
}
// Build metadata containing the a2a_task_id.
metaJSON, _ := json.Marshal(map[string]string{"a2a_task_id": taskID})
// Send DM to target agent.
msg, err := g.msgService.SendMessage(ctx, senderName, targetAgent, messageBody, messaging.SendOptions{
Subject: fmt.Sprintf("A2A Task %s", taskID),
Metadata: string(metaJSON),
})
if err != nil {
g.logger.Error("failed to send DM for A2A task", "task_id", taskID, "error", err)
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to deliver message: " + err.Error()})
return
}
convID := msg.ConversationID
task := &A2ATask{
ID: taskID,
ContextID: contextID,
TargetAgent: targetAgent,
SourceAgent: callerName,
ConversationID: &convID,
State: StateSubmitted,
}
if err := g.taskStore.CreateTask(ctx, task); err != nil {
g.logger.Error("failed to create A2A task", "task_id", taskID, "error", err)
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to create task"})
return
}
g.logger.Info("A2A task created",
"task_id", taskID,
"target_agent", targetAgent,
"source_agent", callerName,
"message_id", msg.ID,
)
writeJSONRPC(w, req.ID, task, nil)
}
// tasks.get params
type tasksGetParams struct {
ID string `json:"id"`
}
func (g *Gateway) handleTasksGet(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest) {
var params tasksGetParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
return
}
if params.ID == "" {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.id is required"})
return
}
task, err := g.taskStore.GetTask(ctx, params.ID)
if err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: err.Error()})
return
}
// If the task has a conversation and is still SUBMITTED, check whether the
// target agent has replied, which indicates completion.
if task.State == StateSubmitted && task.ConversationID != nil {
_, msgs, err := g.msgService.GetConversation(ctx, *task.ConversationID)
if err == nil && len(msgs) > 1 {
// Check if the target agent sent a reply (any message from target after the first).
for _, m := range msgs[1:] {
if m.FromAgent == task.TargetAgent {
task.State = StateCompleted
_ = g.taskStore.UpdateTaskState(ctx, task.ID, StateCompleted)
break
}
}
}
}
writeJSONRPC(w, req.ID, task, nil)
}
// tasks.cancel params
type tasksCancelParams struct {
ID string `json:"id"`
}
func (g *Gateway) handleTasksCancel(w http.ResponseWriter, ctx context.Context, req jsonRPCRequest) {
var params tasksCancelParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "invalid params: " + err.Error()})
return
}
if params.ID == "" {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: "params.id is required"})
return
}
task, err := g.taskStore.GetTask(ctx, params.ID)
if err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: err.Error()})
return
}
// Cannot cancel a terminal task.
if task.State == StateCompleted || task.State == StateCanceled {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInvalidParams, Message: fmt.Sprintf("task is already in terminal state: %s", task.State)})
return
}
if err := g.taskStore.UpdateTaskState(ctx, task.ID, StateCanceled); err != nil {
writeJSONRPC(w, req.ID, nil, &jsonRPCError{Code: errCodeInternal, Message: "failed to cancel task"})
return
}
task.State = StateCanceled
g.logger.Info("A2A task canceled", "task_id", task.ID)
writeJSONRPC(w, req.ID, task, nil)
}
// writeJSONRPC writes a JSON-RPC 2.0 response.
func writeJSONRPC(w http.ResponseWriter, id json.RawMessage, result any, rpcErr *jsonRPCError) {
resp := jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
Error: rpcErr,
}
w.Header().Set("Content-Type", "application/json")
if rpcErr != nil {
// Use 200 for JSON-RPC errors (per spec), but set result to nil.
resp.Result = nil
}
json.NewEncoder(w).Encode(resp)
}
+483
View File
@@ -0,0 +1,483 @@
package a2a
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
)
// --- test helpers ---
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
// Seed a test user for owner_id FK
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
return db
}
func seedAgent(t *testing.T, db *sql.DB, name string) {
t.Helper()
_, err := db.Exec(
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', 1, 'testhash', 'active')`,
name, name,
)
if err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
// mockMsgService implements MessagingService for testing.
type mockMsgService struct {
lastFrom string
lastTo string
lastBody string
lastOpts messaging.SendOptions
sendErr error
returnMsg *messaging.Message
convMsgs []*messaging.Message
getConvErr error
}
func (m *mockMsgService) SendMessage(_ context.Context, from, to, body string, opts messaging.SendOptions) (*messaging.Message, error) {
m.lastFrom = from
m.lastTo = to
m.lastBody = body
m.lastOpts = opts
if m.sendErr != nil {
return nil, m.sendErr
}
if m.returnMsg != nil {
return m.returnMsg, nil
}
return &messaging.Message{
ID: 1,
ConversationID: 100,
FromAgent: from,
ToAgent: to,
Body: body,
}, nil
}
func (m *mockMsgService) GetConversation(_ context.Context, id int64) (*messaging.Conversation, []*messaging.Message, error) {
if m.getConvErr != nil {
return nil, nil, m.getConvErr
}
conv := &messaging.Conversation{ID: id}
return conv, m.convMsgs, nil
}
// mockAgentService implements AgentService for testing.
type mockAgentService struct {
agents map[string]*agents.Agent
}
func (m *mockAgentService) GetAgent(_ context.Context, name string) (*agents.Agent, error) {
if a, ok := m.agents[name]; ok {
return a, nil
}
return nil, fmt.Errorf("agent not found: %s", name)
}
func newTestGateway(t *testing.T) (*Gateway, *mockMsgService, *mockAgentService, *A2ATaskStore) {
t.Helper()
db := newTestDB(t)
seedAgent(t, db, "target-bot")
seedAgent(t, db, "sender-bot")
taskStore := NewA2ATaskStore(db)
msgSvc := &mockMsgService{}
agentSvc := &mockAgentService{
agents: map[string]*agents.Agent{
"target-bot": {ID: 1, Name: "target-bot", Status: "active"},
"sender-bot": {ID: 2, Name: "sender-bot", Status: "active"},
},
}
gw := NewGateway(taskStore, msgSvc, agentSvc)
return gw, msgSvc, agentSvc, taskStore
}
func jsonRPCCall(method string, params any) []byte {
p, _ := json.Marshal(params)
req := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": json.RawMessage(p),
}
b, _ := json.Marshal(req)
return b
}
func doRequest(t *testing.T, gw *Gateway, body []byte, agentCtx *agents.Agent) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if agentCtx != nil {
req = req.WithContext(agents.ContextWithAgent(req.Context(), agentCtx))
}
w := httptest.NewRecorder()
gw.HandleJSONRPC(w, req)
return w
}
func parseResponse(t *testing.T, w *httptest.ResponseRecorder) jsonRPCResponse {
t.Helper()
var resp jsonRPCResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v\nbody: %s", err, w.Body.String())
}
return resp
}
// --- tests ---
func TestMessageSend_CreatesTaskAndDM(t *testing.T) {
gw, msgSvc, _, taskStore := newTestGateway(t)
body := jsonRPCCall("message.send", map[string]any{
"message": map[string]any{
"body": "Hello target bot",
"metadata": map[string]string{
"target_agent": "target-bot",
},
},
})
callerAgent := &agents.Agent{Name: "sender-bot"}
w := doRequest(t, gw, body, callerAgent)
resp := parseResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
// Verify a task was returned.
resultBytes, _ := json.Marshal(resp.Result)
var task A2ATask
if err := json.Unmarshal(resultBytes, &task); err != nil {
t.Fatalf("unmarshal task result: %v", err)
}
if task.ID == "" {
t.Error("task ID should not be empty")
}
if task.State != StateSubmitted {
t.Errorf("task state = %q, want %q", task.State, StateSubmitted)
}
if task.TargetAgent != "target-bot" {
t.Errorf("target_agent = %q, want %q", task.TargetAgent, "target-bot")
}
if task.SourceAgent != "sender-bot" {
t.Errorf("source_agent = %q, want %q", task.SourceAgent, "sender-bot")
}
// Verify the DM was sent.
if msgSvc.lastTo != "target-bot" {
t.Errorf("DM to = %q, want %q", msgSvc.lastTo, "target-bot")
}
if msgSvc.lastFrom != "sender-bot" {
t.Errorf("DM from = %q, want %q", msgSvc.lastFrom, "sender-bot")
}
if msgSvc.lastBody != "Hello target bot" {
t.Errorf("DM body = %q, want %q", msgSvc.lastBody, "Hello target bot")
}
// Verify metadata contains a2a_task_id.
var meta map[string]string
if err := json.Unmarshal([]byte(msgSvc.lastOpts.Metadata), &meta); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
if meta["a2a_task_id"] != task.ID {
t.Errorf("metadata a2a_task_id = %q, want %q", meta["a2a_task_id"], task.ID)
}
// Verify task is persisted.
stored, err := taskStore.GetTask(context.Background(), task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if stored.State != StateSubmitted {
t.Errorf("stored task state = %q, want %q", stored.State, StateSubmitted)
}
}
func TestTasksGet_ReturnsTask(t *testing.T) {
gw, _, _, taskStore := newTestGateway(t)
// Create a task directly.
convID := int64(100)
task := &A2ATask{
ID: "test-task-123",
ContextID: "ctx-123",
TargetAgent: "target-bot",
SourceAgent: "sender-bot",
ConversationID: &convID,
State: StateSubmitted,
}
if err := taskStore.CreateTask(context.Background(), task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
body := jsonRPCCall("tasks.get", map[string]string{"id": "test-task-123"})
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
resultBytes, _ := json.Marshal(resp.Result)
var got A2ATask
if err := json.Unmarshal(resultBytes, &got); err != nil {
t.Fatalf("unmarshal task result: %v", err)
}
if got.ID != "test-task-123" {
t.Errorf("task ID = %q, want %q", got.ID, "test-task-123")
}
if got.State != StateSubmitted {
t.Errorf("task state = %q, want %q", got.State, StateSubmitted)
}
}
func TestTasksGet_CompletesOnReply(t *testing.T) {
gw, msgSvc, _, taskStore := newTestGateway(t)
// Create a task.
convID := int64(100)
task := &A2ATask{
ID: "task-reply-test",
ContextID: "ctx-456",
TargetAgent: "target-bot",
SourceAgent: "sender-bot",
ConversationID: &convID,
State: StateSubmitted,
}
if err := taskStore.CreateTask(context.Background(), task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Simulate the target agent having replied.
msgSvc.convMsgs = []*messaging.Message{
{ID: 1, FromAgent: "sender-bot", Body: "Hello"},
{ID: 2, FromAgent: "target-bot", Body: "Reply from target"},
}
body := jsonRPCCall("tasks.get", map[string]string{"id": "task-reply-test"})
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
resultBytes, _ := json.Marshal(resp.Result)
var got A2ATask
if err := json.Unmarshal(resultBytes, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.State != StateCompleted {
t.Errorf("task state = %q, want %q (target agent replied)", got.State, StateCompleted)
}
}
func TestTasksCancel_TransitionsToCanceled(t *testing.T) {
gw, _, _, taskStore := newTestGateway(t)
task := &A2ATask{
ID: "task-cancel-test",
ContextID: "ctx-789",
TargetAgent: "target-bot",
SourceAgent: "sender-bot",
State: StateSubmitted,
}
if err := taskStore.CreateTask(context.Background(), task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
body := jsonRPCCall("tasks.cancel", map[string]string{"id": "task-cancel-test"})
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
resultBytes, _ := json.Marshal(resp.Result)
var got A2ATask
if err := json.Unmarshal(resultBytes, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.State != StateCanceled {
t.Errorf("task state = %q, want %q", got.State, StateCanceled)
}
// Verify persisted state.
stored, err := taskStore.GetTask(context.Background(), "task-cancel-test")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if stored.State != StateCanceled {
t.Errorf("stored state = %q, want %q", stored.State, StateCanceled)
}
}
func TestTasksCancel_TerminalStateError(t *testing.T) {
gw, _, _, taskStore := newTestGateway(t)
task := &A2ATask{
ID: "task-already-done",
ContextID: "ctx-done",
TargetAgent: "target-bot",
State: StateCompleted,
}
if err := taskStore.CreateTask(context.Background(), task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
body := jsonRPCCall("tasks.cancel", map[string]string{"id": "task-already-done"})
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error == nil {
t.Fatal("expected error for canceling terminal task")
}
if resp.Error.Code != errCodeInvalidParams {
t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInvalidParams)
}
}
func TestMessageSend_NonExistentAgent(t *testing.T) {
gw, _, _, _ := newTestGateway(t)
body := jsonRPCCall("message.send", map[string]any{
"message": map[string]any{
"body": "Hello ghost",
"metadata": map[string]string{
"target_agent": "does-not-exist",
},
},
})
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error == nil {
t.Fatal("expected error for non-existent target agent")
}
if resp.Error.Code != errCodeInvalidParams {
t.Errorf("error code = %d, want %d", resp.Error.Code, errCodeInvalidParams)
}
}
func TestInvalidJSONRPC(t *testing.T) {
gw, _, _, _ := newTestGateway(t)
tests := []struct {
name string
body string
}{
{
name: "not JSON",
body: "this is not json",
},
{
name: "wrong jsonrpc version",
body: `{"jsonrpc":"1.0","id":1,"method":"message.send","params":{}}`,
},
{
name: "unknown method",
body: `{"jsonrpc":"2.0","id":1,"method":"unknown.method","params":{}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
gw.HandleJSONRPC(w, req)
var resp jsonRPCResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v\nbody: %s", err, w.Body.String())
}
if resp.Error == nil {
t.Error("expected error response")
}
})
}
}
func TestUnauthenticatedRequest_NoAgentContext(t *testing.T) {
// This tests that message.send works even without an authenticated agent
// in context (caller is anonymous), using "a2a-gateway" as the sender.
gw, msgSvc, _, _ := newTestGateway(t)
body := jsonRPCCall("message.send", map[string]any{
"message": map[string]any{
"body": "Hello from anonymous",
"metadata": map[string]string{
"target_agent": "target-bot",
},
},
})
// No agent context — simulates unauthenticated-at-gateway-level
// (in practice, the auth middleware would block this; this tests the
// gateway's fallback behavior).
w := doRequest(t, gw, body, nil)
resp := parseResponse(t, w)
if resp.Error != nil {
t.Fatalf("unexpected error: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
// Should use "a2a-gateway" as sender when no caller agent.
if msgSvc.lastFrom != "a2a-gateway" {
t.Errorf("DM from = %q, want %q", msgSvc.lastFrom, "a2a-gateway")
}
}
func TestHTTPMethodNotAllowed(t *testing.T) {
gw, _, _, _ := newTestGateway(t)
req := httptest.NewRequest(http.MethodGet, "/a2a", nil)
w := httptest.NewRecorder()
gw.HandleJSONRPC(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
+56
View File
@@ -0,0 +1,56 @@
package a2a
import (
"context"
"encoding/json"
"net/http"
)
// AgentLister abstracts the operation of listing active non-human agents.
type AgentLister interface {
ListAllActiveAgents(ctx context.Context) ([]AgentInfo, error)
}
// NewAgentCardHandler returns an http.HandlerFunc that serves the A2A Agent
// Card JSON document at /.well-known/agent-card.json.
//
// The handler is public (no auth required) because Agent Cards are meant for
// discovery. If configuredBaseURL is empty the base URL is derived from the
// incoming request (respecting X-Forwarded-* headers).
func NewAgentCardHandler(agentLister AgentLister, configuredBaseURL string, version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Derive base URL from request if not configured.
baseURL := configuredBaseURL
if baseURL == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
host := r.Host
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
host = fwdHost
}
baseURL = scheme + "://" + host
}
agents, err := agentLister.ListAllActiveAgents(r.Context())
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
card := GenerateAgentCard(baseURL, version, agents)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=60")
json.NewEncoder(w).Encode(card)
}
}
+132
View File
@@ -0,0 +1,132 @@
// Package a2a provides the A2A (Agent-to-Agent) inbound gateway for SynapBus.
// External A2A-compliant agents can send tasks to SynapBus agents via JSON-RPC.
package a2a
import (
"context"
"database/sql"
"fmt"
"time"
)
// Task states following the A2A protocol.
const (
StateSubmitted = "SUBMITTED"
StateCompleted = "COMPLETED"
StateCanceled = "CANCELED"
)
// A2ATask represents an inbound A2A task tracked by the gateway.
type A2ATask struct {
ID string `json:"id"`
ContextID string `json:"context_id"`
TargetAgent string `json:"target_agent"`
SourceAgent string `json:"source_agent"`
ConversationID *int64 `json:"conversation_id,omitempty"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// A2ATaskStore provides CRUD operations for A2A tasks backed by SQLite.
type A2ATaskStore struct {
db *sql.DB
}
// NewA2ATaskStore creates a new task store.
func NewA2ATaskStore(db *sql.DB) *A2ATaskStore {
return &A2ATaskStore{db: db}
}
// CreateTask inserts a new A2A task into the database.
func (s *A2ATaskStore) CreateTask(ctx context.Context, task *A2ATask) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO a2a_tasks (id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
task.ID, task.ContextID, task.TargetAgent, task.SourceAgent, task.ConversationID, task.State,
)
if err != nil {
return fmt.Errorf("insert a2a task: %w", err)
}
return nil
}
// GetTask returns an A2A task by its ID.
func (s *A2ATaskStore) GetTask(ctx context.Context, id string) (*A2ATask, error) {
var task A2ATask
var conversationID sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE id = ?`, id,
).Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("a2a task not found: %s", id)
}
return nil, fmt.Errorf("get a2a task: %w", err)
}
if conversationID.Valid {
task.ConversationID = &conversationID.Int64
}
return &task, nil
}
// UpdateTaskState transitions a task to a new state.
func (s *A2ATaskStore) UpdateTaskState(ctx context.Context, id, state string) error {
result, err := s.db.ExecContext(ctx,
`UPDATE a2a_tasks SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
state, id,
)
if err != nil {
return fmt.Errorf("update a2a task state: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("a2a task not found: %s", id)
}
return nil
}
// ListTasks returns tasks for a target agent, optionally filtered by state.
func (s *A2ATaskStore) ListTasks(ctx context.Context, targetAgent, state string) ([]*A2ATask, error) {
var query string
var args []any
if state != "" {
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE target_agent = ? AND state = ? ORDER BY created_at DESC`
args = []any{targetAgent, state}
} else {
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE target_agent = ? ORDER BY created_at DESC`
args = []any{targetAgent}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list a2a tasks: %w", err)
}
defer rows.Close()
var tasks []*A2ATask
for rows.Next() {
var task A2ATask
var conversationID sql.NullInt64
if err := rows.Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan a2a task: %w", err)
}
if conversationID.Valid {
task.ConversationID = &conversationID.Int64
}
tasks = append(tasks, &task)
}
if tasks == nil {
tasks = []*A2ATask{}
}
return tasks, rows.Err()
}
+169
View File
@@ -0,0 +1,169 @@
package actions
import (
"math"
"sort"
"strings"
)
// SearchResult pairs an action with a relevance score.
type SearchResult struct {
Action Action `json:"action"`
Score float64 `json:"score"`
}
// Index provides BM25 search over the action catalog.
type Index struct {
actions []Action
// Pre-computed document tokens (name + category + description + param names).
docs [][]string
// IDF values per term across all documents.
idf map[string]float64
// Average document length.
avgDL float64
}
// NewIndex builds a BM25 index from the provided actions.
func NewIndex(actions []Action) *Index {
idx := &Index{
actions: actions,
docs: make([][]string, len(actions)),
idf: make(map[string]float64),
}
// Tokenize each action into a bag of words.
df := make(map[string]int) // document frequency per term
totalLen := 0
for i, a := range actions {
tokens := tokenize(a)
idx.docs[i] = tokens
totalLen += len(tokens)
// Count unique terms in this document.
seen := make(map[string]bool)
for _, t := range tokens {
if !seen[t] {
df[t]++
seen[t] = true
}
}
}
n := float64(len(actions))
if n > 0 {
idx.avgDL = float64(totalLen) / n
}
// Compute IDF for each term.
for term, freq := range df {
idx.idf[term] = math.Log(1 + (n-float64(freq)+0.5)/(float64(freq)+0.5))
}
return idx
}
// Search returns actions matching the query, sorted by relevance score.
// If query is empty, returns all actions with score 0 (browse mode).
func (idx *Index) Search(query string, limit int) []SearchResult {
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
// Browse mode: return all actions.
if strings.TrimSpace(query) == "" {
results := make([]SearchResult, len(idx.actions))
for i, a := range idx.actions {
results[i] = SearchResult{Action: a, Score: 0}
}
if len(results) > limit {
results = results[:limit]
}
return results
}
queryTerms := strings.Fields(strings.ToLower(query))
// BM25 parameters.
const k1 = 1.2
const b = 0.75
type scored struct {
idx int
score float64
}
var scored_docs []scored
for i, docTokens := range idx.docs {
score := 0.0
dl := float64(len(docTokens))
tf := termFrequency(docTokens)
for _, qt := range queryTerms {
idfVal := idx.idf[qt]
freq := float64(tf[qt])
if freq == 0 {
continue
}
numerator := freq * (k1 + 1)
denominator := freq + k1*(1-b+b*dl/idx.avgDL)
score += idfVal * numerator / denominator
}
if score > 0 {
scored_docs = append(scored_docs, scored{idx: i, score: score})
}
}
sort.Slice(scored_docs, func(i, j int) bool {
return scored_docs[i].score > scored_docs[j].score
})
if len(scored_docs) > limit {
scored_docs = scored_docs[:limit]
}
results := make([]SearchResult, len(scored_docs))
for i, sd := range scored_docs {
results[i] = SearchResult{
Action: idx.actions[sd.idx],
Score: sd.score,
}
}
return results
}
// tokenize extracts searchable tokens from an action.
func tokenize(a Action) []string {
var parts []string
parts = append(parts, strings.Fields(strings.ToLower(a.Name))...)
parts = append(parts, strings.Fields(strings.ToLower(a.Category))...)
parts = append(parts, strings.Fields(strings.ToLower(a.Description))...)
for _, p := range a.Params {
parts = append(parts, strings.Fields(strings.ToLower(p.Name))...)
parts = append(parts, strings.Fields(strings.ToLower(p.Description))...)
}
// Split compound names (e.g. "read_inbox" -> "read", "inbox").
var expanded []string
for _, p := range parts {
expanded = append(expanded, p)
if strings.Contains(p, "_") {
expanded = append(expanded, strings.Split(p, "_")...)
}
if strings.Contains(p, "-") {
expanded = append(expanded, strings.Split(p, "-")...)
}
}
return expanded
}
// termFrequency counts occurrences of each term in a token list.
func termFrequency(tokens []string) map[string]int {
tf := make(map[string]int)
for _, t := range tokens {
tf[t]++
}
return tf
}
+122
View File
@@ -0,0 +1,122 @@
package actions
import (
"testing"
)
func TestRegistry_List(t *testing.T) {
reg := NewRegistry()
actions := reg.List()
if len(actions) == 0 {
t.Fatal("expected actions to be registered")
}
// Check that core actions exist
expectedNames := []string{
"read_inbox", "claim_messages", "mark_done", "search_messages",
"discover_agents", "create_channel", "join_channel", "list_channels",
"send_channel_message", "post_task", "upload_attachment",
}
nameSet := make(map[string]bool)
for _, a := range actions {
nameSet[a.Name] = true
}
for _, name := range expectedNames {
if !nameSet[name] {
t.Errorf("expected action %q in registry", name)
}
}
}
func TestRegistry_Get(t *testing.T) {
reg := NewRegistry()
t.Run("existing action", func(t *testing.T) {
a, ok := reg.Get("read_inbox")
if !ok {
t.Fatal("expected to find read_inbox")
}
if a.Category != "messaging" {
t.Errorf("category = %q, want messaging", a.Category)
}
})
t.Run("missing action", func(t *testing.T) {
_, ok := reg.Get("nonexistent")
if ok {
t.Error("expected not found for nonexistent action")
}
})
}
func TestIndex_Search(t *testing.T) {
reg := NewRegistry()
idx := NewIndex(reg.List())
t.Run("messaging query", func(t *testing.T) {
results := idx.Search("read inbox messages", 5)
if len(results) == 0 {
t.Fatal("expected results for 'read inbox messages'")
}
// read_inbox should be the top result
if results[0].Action.Name != "read_inbox" {
t.Errorf("top result = %q, want read_inbox", results[0].Action.Name)
}
if results[0].Score <= 0 {
t.Error("expected positive relevance score")
}
})
t.Run("channel query", func(t *testing.T) {
results := idx.Search("create channel", 5)
if len(results) == 0 {
t.Fatal("expected results for 'create channel'")
}
foundCreateChannel := false
for _, r := range results {
if r.Action.Name == "create_channel" {
foundCreateChannel = true
break
}
}
if !foundCreateChannel {
t.Error("expected create_channel in results")
}
})
t.Run("swarm query", func(t *testing.T) {
results := idx.Search("task auction bid", 5)
if len(results) == 0 {
t.Fatal("expected results for 'task auction bid'")
}
})
t.Run("empty query returns all", func(t *testing.T) {
results := idx.Search("", 20)
if len(results) == 0 {
t.Fatal("expected results for empty query")
}
// Should return all registered actions (up to limit)
totalActions := len(reg.List())
if len(results) > 20 {
t.Errorf("returned %d results but limit is 20", len(results))
}
if totalActions <= 20 && len(results) != totalActions {
t.Errorf("expected %d results in browse mode, got %d", totalActions, len(results))
}
})
t.Run("limit enforced", func(t *testing.T) {
results := idx.Search("message", 2)
if len(results) > 2 {
t.Errorf("expected at most 2 results, got %d", len(results))
}
})
t.Run("max limit capped at 20", func(t *testing.T) {
results := idx.Search("", 100)
if len(results) > 20 {
t.Errorf("expected at most 20 results, got %d", len(results))
}
})
}
+603
View File
@@ -0,0 +1,603 @@
package actions
// Registry holds all action definitions and supports lookup.
type Registry struct {
actions map[string]Action
ordered []Action // maintains insertion order
}
// NewRegistry creates a registry pre-populated with all 28 agent-callable actions.
func NewRegistry() *Registry {
r := &Registry{
actions: make(map[string]Action, 28),
}
for _, a := range allActions() {
r.actions[a.Name] = a
r.ordered = append(r.ordered, a)
}
return r
}
// Get returns an action by name.
func (r *Registry) Get(name string) (Action, bool) {
a, ok := r.actions[name]
return a, ok
}
// List returns all registered actions.
func (r *Registry) List() []Action {
out := make([]Action, len(r.ordered))
copy(out, r.ordered)
return out
}
// ListByCategory returns actions in the given category.
func (r *Registry) ListByCategory(category string) []Action {
var out []Action
for _, a := range r.ordered {
if a.Category == category {
out = append(out, a)
}
}
return out
}
// allActions returns the canonical list of all 28 agent-callable actions.
func allActions() []Action {
return []Action{
// ── Messaging (7 actions) ──────────────────────────────────────
{
Name: "my_status",
Category: "messaging",
Description: "Get your complete status overview — identity, pending messages, channel mentions, system notifications, and statistics. Call this first when connecting to SynapBus.",
Params: []Param{},
Returns: "JSON with agent identity, direct_messages, mentions, system_notifications, channels, and stats",
Examples: []Example{
{
Description: "Check your full status on connect",
Code: `call("my_status", {})`,
},
},
},
{
Name: "send_message",
Category: "messaging",
Description: "Send a direct message to another agent. Use discover_agents first to find available agents you can communicate with. For channel messages, use send_channel_message instead.",
Params: []Param{
{Name: "to", Type: "string", Description: "Name of the recipient agent (required for DMs, omit for channel messages)"},
{Name: "body", Type: "string", Description: "Message body text", Required: true},
{Name: "subject", Type: "string", Description: "Conversation subject (optional)"},
{Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"},
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
{Name: "channel_id", Type: "number", Description: "Channel ID for channel messages (optional)"},
{Name: "reply_to", Type: "number", Description: "ID of the message to reply to (optional, for threading)"},
},
Returns: "JSON with message_id, conversation_id, and status",
Examples: []Example{
{
Description: "Send a direct message to another agent",
Code: `call("send_message", {"to": "data-processor", "body": "Please analyze the Q4 sales data", "subject": "Q4 Analysis", "priority": 7})`,
},
},
},
{
Name: "read_inbox",
Category: "messaging",
Description: "Check your message inbox for pending messages. Call this first when connecting to see if other agents have sent you messages. Returns unread/pending direct messages addressed to you.",
Params: []Param{
{Name: "limit", Type: "number", Description: "Maximum number of messages to return (default 50)", Default: "50"},
{Name: "status_filter", Type: "string", Description: "Filter by message status: pending, processing, done, failed"},
{Name: "include_read", Type: "boolean", Description: "Include previously read messages (default false)", Default: "false"},
{Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"},
{Name: "from_agent", Type: "string", Description: "Filter by sender agent name"},
},
Returns: "JSON with messages array and count",
Examples: []Example{
{
Description: "Check for new messages",
Code: `call("read_inbox", {})`,
},
{
Description: "Read high-priority messages from a specific agent",
Code: `call("read_inbox", {"min_priority": 8, "from_agent": "coordinator"})`,
},
},
},
{
Name: "claim_messages",
Category: "messaging",
Description: "Atomically claim pending messages for processing",
Params: []Param{
{Name: "limit", Type: "number", Description: "Maximum number of messages to claim (default 10)", Default: "10"},
},
Returns: "JSON with claimed messages array and count",
Examples: []Example{
{
Description: "Claim up to 5 messages for processing",
Code: `call("claim_messages", {"limit": 5})`,
},
},
},
{
Name: "mark_done",
Category: "messaging",
Description: "Mark a claimed message as done or failed",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to mark", Required: true},
{Name: "status", Type: "string", Description: "New status: 'done' or 'failed' (default 'done')", Default: "done"},
{Name: "reason", Type: "string", Description: "Failure reason (only for status='failed')"},
},
Returns: "JSON with message_id and status",
Examples: []Example{
{
Description: "Mark a message as successfully processed",
Code: `call("mark_done", {"message_id": 42})`,
},
{
Description: "Mark a message as failed with reason",
Code: `call("mark_done", {"message_id": 42, "status": "failed", "reason": "invalid data format"})`,
},
},
},
{
Name: "search_messages",
Category: "messaging",
Description: "Search for messages across your inbox and channels you are a member of. Supports full-text and semantic search (if configured). Use with an empty query to browse recent messages, or provide a natural-language query to find relevant conversations.",
Params: []Param{
{Name: "query", Type: "string", Description: "Search query string — supports natural language for semantic search"},
{Name: "limit", Type: "number", Description: "Maximum results to return (default 10, max 100)", Default: "10"},
{Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"},
{Name: "from_agent", Type: "string", Description: "Filter by sender agent name"},
{Name: "status", Type: "string", Description: "Filter by message status"},
{Name: "search_mode", Type: "string", Description: "Search mode: 'auto' (default), 'semantic', or 'fulltext'", Default: "auto"},
{Name: "semantic", Type: "boolean", Description: "Force semantic search (shorthand for search_mode='semantic')"},
},
Returns: "JSON with results array, count, and search_mode used",
Examples: []Example{
{
Description: "Search for messages about deployment",
Code: `call("search_messages", {"query": "deployment status update", "limit": 5})`,
},
},
},
{
Name: "discover_agents",
Category: "messaging",
Description: "Discover other agents on the bus. Call this to find agents you can communicate with. Optionally filter by capability keywords, or omit the query to list all registered agents.",
Params: []Param{
{Name: "query", Type: "string", Description: "Capability keyword to search for"},
},
Returns: "JSON with agents array (name, display_name, type, capabilities, status) and count",
Examples: []Example{
{
Description: "List all available agents",
Code: `call("discover_agents", {})`,
},
{
Description: "Find agents with data analysis capabilities",
Code: `call("discover_agents", {"query": "data analysis"})`,
},
},
},
// ── Channels (9 actions) ──────────────────────────────────────
{
Name: "create_channel",
Category: "channels",
Description: "Create a new channel for group communication",
Params: []Param{
{Name: "name", Type: "string", Description: "Unique channel name (alphanumeric, hyphens, underscores, max 64 chars)", Required: true},
{Name: "description", Type: "string", Description: "Channel description"},
{Name: "topic", Type: "string", Description: "Current channel topic"},
{Name: "type", Type: "string", Description: "Channel type: 'standard', 'blackboard', or 'auction' (default 'standard')", Default: "standard"},
{Name: "is_private", Type: "boolean", Description: "Whether the channel is private (invite-only). Default false", Default: "false"},
},
Returns: "JSON with channel_id, name, description, topic, type, is_private, created_by",
Examples: []Example{
{
Description: "Create a public channel for project discussion",
Code: `call("create_channel", {"name": "project-alpha", "description": "Discussion for Project Alpha", "topic": "Sprint planning"})`,
},
},
},
{
Name: "join_channel",
Category: "channels",
Description: "Join a channel to participate in group conversations. You will receive messages sent to the channel after joining. Use list_channels first to see available channels.",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel to join"},
{Name: "channel_name", Type: "string", Description: "Name of the channel to join (alternative to channel_id)"},
},
Returns: "JSON with channel_id and status 'joined'",
Examples: []Example{
{
Description: "Join a channel by name",
Code: `call("join_channel", {"channel_name": "project-alpha"})`,
},
},
},
{
Name: "leave_channel",
Category: "channels",
Description: "Leave a channel you are a member of",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel to leave"},
{Name: "channel_name", Type: "string", Description: "Name of the channel to leave (alternative to channel_id)"},
},
Returns: "JSON with channel_id and status 'left'",
Examples: []Example{
{
Description: "Leave a channel by name",
Code: `call("leave_channel", {"channel_name": "project-alpha"})`,
},
},
},
{
Name: "list_channels",
Category: "channels",
Description: "List all channels visible to you. Call this when connecting to see available channels and join conversations. Shows all public channels plus private channels you are a member of or have been invited to.",
Params: []Param{},
Returns: "JSON with channels array (id, name, description, topic, type, is_private, created_by, member_count) and count",
Examples: []Example{
{
Description: "List all available channels",
Code: `call("list_channels", {})`,
},
},
},
{
Name: "invite_to_channel",
Category: "channels",
Description: "Invite an agent to a channel (only the channel owner can invite to private channels)",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
{Name: "agent_name", Type: "string", Description: "Name of the agent to invite", Required: true},
},
Returns: "JSON with channel_id, agent_name, and status 'invited'",
Examples: []Example{
{
Description: "Invite an agent to a private channel",
Code: `call("invite_to_channel", {"channel_name": "secret-ops", "agent_name": "data-processor"})`,
},
},
},
{
Name: "kick_from_channel",
Category: "channels",
Description: "Remove an agent from a channel (only the channel owner can kick)",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
{Name: "agent_name", Type: "string", Description: "Name of the agent to kick", Required: true},
},
Returns: "JSON with channel_id, agent_name, and status 'kicked'",
Examples: []Example{
{
Description: "Remove an agent from a channel",
Code: `call("kick_from_channel", {"channel_name": "project-alpha", "agent_name": "spambot"})`,
},
},
},
{
Name: "get_channel_messages",
Category: "channels",
Description: "Get recent messages from a channel you are a member of",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
{Name: "limit", Type: "number", Description: "Max number of messages to return (default 50, max 200)", Default: "50"},
},
Returns: "JSON with channel_id, messages array, and count",
Examples: []Example{
{
Description: "Get recent messages from a channel",
Code: `call("get_channel_messages", {"channel_name": "project-alpha", "limit": 20})`,
},
},
},
{
Name: "send_channel_message",
Category: "channels",
Description: "Send a message to all members of a channel. Use @agentname in the body to mention specific agents. You must be a member of the channel to send messages.",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
{Name: "body", Type: "string", Description: "Message body text", Required: true},
{Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"},
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
{Name: "reply_to", Type: "number", Description: "Message ID to reply to (creates a thread)", Required: false},
},
Returns: "JSON with channel_id, message_id, and status 'sent'",
Examples: []Example{
{
Description: "Send a message to a channel with a mention",
Code: `call("send_channel_message", {"channel_name": "project-alpha", "body": "Hey @coordinator, the build is ready for review"})`,
},
},
},
{
Name: "update_channel",
Category: "channels",
Description: "Update channel topic or description (only the channel owner can update)",
Params: []Param{
{Name: "channel_id", Type: "number", Description: "ID of the channel"},
{Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"},
{Name: "topic", Type: "string", Description: "New channel topic"},
{Name: "description", Type: "string", Description: "New channel description"},
},
Returns: "JSON with channel_id, name, description, and topic",
Examples: []Example{
{
Description: "Update a channel's topic",
Code: `call("update_channel", {"channel_name": "project-alpha", "topic": "v2.0 release planning"})`,
},
},
},
// ── Swarm (5 actions) ─────────────────────────────────────────
{
Name: "post_task",
Category: "swarm",
Description: "Post a task to an auction channel for agents to bid on. Use when you need work done by another agent with specific capabilities. FLOW: post_task → agents call bid_task → you call accept_bid to assign → agent calls complete_task when done.",
Params: []Param{
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
{Name: "title", Type: "string", Description: "Task title", Required: true},
{Name: "description", Type: "string", Description: "Task description"},
{Name: "requirements", Type: "string", Description: "JSON object of task requirements"},
{Name: "deadline", Type: "string", Description: "Task deadline in ISO 8601 format (e.g. 2026-03-13T15:00:00Z)"},
},
Returns: "JSON with task_id, channel_id, title, status, posted_by, deadline, created_at",
Examples: []Example{
{
Description: "Post a data analysis task to an auction channel",
Code: `call("post_task", {"channel_name": "task-marketplace", "title": "Analyze Q4 revenue", "description": "Run trend analysis on Q4 revenue data", "deadline": "2026-03-20T17:00:00Z"})`,
},
},
},
{
Name: "bid_task",
Category: "swarm",
Description: "Submit a bid on an open task. Include your relevant capabilities and time estimate. The task poster will review bids and accept one. Check list_tasks with status='open' to find tasks you can bid on.",
Params: []Param{
{Name: "task_id", Type: "number", Description: "ID of the task to bid on", Required: true},
{Name: "capabilities", Type: "string", Description: "JSON object describing your relevant capabilities"},
{Name: "time_estimate", Type: "string", Description: "Estimated time to complete the task"},
{Name: "message", Type: "string", Description: "Message to the task poster explaining your bid"},
},
Returns: "JSON with bid_id, task_id, agent_name, time_estimate, status",
Examples: []Example{
{
Description: "Bid on a task with capabilities and time estimate",
Code: `call("bid_task", {"task_id": 7, "capabilities": "{\"skills\": [\"data-analysis\", \"python\"]}", "time_estimate": "2 hours", "message": "I have experience with revenue trend analysis"})`,
},
},
},
{
Name: "accept_bid",
Category: "swarm",
Description: "Accept a bid on a task you posted, assigning the task to the bidding agent",
Params: []Param{
{Name: "task_id", Type: "number", Description: "ID of the task", Required: true},
{Name: "bid_id", Type: "number", Description: "ID of the bid to accept", Required: true},
},
Returns: "JSON with task_id, bid_id, and status 'accepted'",
Examples: []Example{
{
Description: "Accept a bid on your task",
Code: `call("accept_bid", {"task_id": 7, "bid_id": 3})`,
},
},
},
{
Name: "complete_task",
Category: "swarm",
Description: "Mark a task as completed (only the assigned agent can do this)",
Params: []Param{
{Name: "task_id", Type: "number", Description: "ID of the task to complete", Required: true},
},
Returns: "JSON with task_id and status 'completed'",
Examples: []Example{
{
Description: "Mark an assigned task as completed",
Code: `call("complete_task", {"task_id": 7})`,
},
},
},
{
Name: "list_tasks",
Category: "swarm",
Description: "List tasks in an auction channel, optionally filtered by status",
Params: []Param{
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
{Name: "status", Type: "string", Description: "Filter by task status: open, assigned, completed, cancelled"},
},
Returns: "JSON with tasks array (id, title, description, status, posted_by, assigned_to, deadline, created_at) and count",
Examples: []Example{
{
Description: "List open tasks in an auction channel",
Code: `call("list_tasks", {"channel_name": "task-marketplace", "status": "open"})`,
},
},
},
// ── Attachments (2 actions) ───────────────────────────────────
{
Name: "upload_attachment",
Category: "attachments",
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Upload first, then use the returned hash in send_message's attachments parameter to link it to a message. Max file size: 50MB.",
Params: []Param{
{Name: "content", Type: "string", Description: "Base64-encoded file content", Required: true},
{Name: "filename", Type: "string", Description: "Original filename (optional, used for MIME detection and display)"},
{Name: "mime_type", Type: "string", Description: "MIME type override (optional, auto-detected from content if not provided)"},
{Name: "message_id", Type: "number", Description: "Message ID to attach the file to (optional, can be linked later)"},
},
Returns: "JSON with hash, size, mime_type, original_filename",
Examples: []Example{
{
Description: "Upload a text file attachment",
Code: `call("upload_attachment", {"content": "SGVsbG8gV29ybGQ=", "filename": "hello.txt", "mime_type": "text/plain"})`,
},
},
},
{
Name: "download_attachment",
Category: "attachments",
Description: "Download an attachment by its SHA-256 hash. Returns base64-encoded content along with filename and MIME type metadata.",
Params: []Param{
{Name: "hash", Type: "string", Description: "SHA-256 hash of the attachment", Required: true},
},
Returns: "JSON with hash, content (base64), original_filename, mime_type, size",
Examples: []Example{
{
Description: "Download an attachment by hash",
Code: `call("download_attachment", {"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})`,
},
},
},
// ── Reactions (4 actions) ────────────────────────────────────
{
Name: "react",
Category: "reactions",
Description: "Add or toggle a reaction on a message to signal workflow state. Reactions: approve (human approves work), reject (decline), in_progress (claim work — only one agent can claim per message), done (work complete), published (shipped, include URL in metadata). WORKFLOW: Use list_by_state to find work → react in_progress to claim → do the work → react done/published. Toggle: calling same reaction again removes it.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to react to", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type: approve, reject, in_progress, done, published", Required: true},
{Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"},
},
Returns: "JSON with action ('added' or 'removed') and reaction details",
Examples: []Example{
{
Description: "Approve a message",
Code: `call("react", {"message_id": 42, "reaction": "approve"})`,
},
{
Description: "Toggle a reaction off (call same reaction again)",
Code: `call("react", {"message_id": 42, "reaction": "approve"})`,
},
},
},
{
Name: "unreact",
Category: "reactions",
Description: "Remove a specific reaction. Use to release a claim (unreact in_progress) so another agent can pick up the work.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to remove reaction from", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type to remove: approve, reject, in_progress, done, published", Required: true},
},
Returns: "JSON with message_id, reaction, and status 'removed'",
Examples: []Example{
{
Description: "Remove an approval reaction",
Code: `call("unreact", {"message_id": 42, "reaction": "approve"})`,
},
},
},
{
Name: "get_reactions",
Category: "reactions",
Description: "Get all reactions and derived workflow state for a message. Returns: reactions array + workflow_state (proposed/approved/in_progress/rejected/done/published). Use to check if work is claimed before attempting to claim it.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to get reactions for", Required: true},
},
Returns: "JSON with reactions array and workflow_state",
Examples: []Example{
{
Description: "Get reactions and workflow state for a message",
Code: `call("get_reactions", {"message_id": 42})`,
},
},
},
{
Name: "list_by_state",
Category: "reactions",
Description: "List messages in a channel filtered by workflow state. Paginated — use limit and offset for large channels. States: proposed (new), approved (ready for work), in_progress (claimed), rejected, done, published.",
Params: []Param{
{Name: "channel", Type: "string", Description: "Channel name", Required: true},
{Name: "state", Type: "string", Description: "Workflow state to filter by: proposed, approved, in_progress, rejected, done, published", Required: true},
{Name: "limit", Type: "number", Description: "Max messages to return (default 20, max 100)"},
{Name: "offset", Type: "number", Description: "Skip first N messages for pagination (default 0)"},
{Name: "include_messages", Type: "boolean", Description: "Include message bodies (default false). Bodies truncated to max_body_length chars."},
{Name: "max_body_length", Type: "number", Description: "Max chars per message body when include_messages=true (default 500). Use lower values for channels with long messages."},
},
Returns: "JSON with message_ids, count (this page), total (all matching), limit, offset, and optionally messages array",
Examples: []Example{
{
Description: "List first 10 approved messages with content",
Code: `call("list_by_state", {"channel": "approvals", "state": "approved", "limit": 10, "include_messages": true})`,
},
{
Description: "Paginate — get next page",
Code: `call("list_by_state", {"channel": "approvals", "state": "proposed", "limit": 10, "offset": 10})`,
},
},
},
// ── Threads (1 action) ──────────────────────────────────────
{
Name: "get_replies",
Category: "threads",
Description: "Get all replies (thread messages) for a given message. Use to read thread conversations, check for edits, or follow-up comments. Also available as a direct MCP tool.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the parent message to get replies for", Required: true},
},
Returns: "JSON with message_id, replies array, and count",
Examples: []Example{
{
Description: "Get all replies to a message",
Code: `call("get_replies", {"message_id": 42})`,
},
},
},
// ── Trust (1 action) ────────────────────────────────────────
{
Name: "get_trust",
Category: "trust",
Description: "Get your trust scores by action type. Trust determines autonomy: higher trust = less human approval needed. Scores increase on human approve (+0.05) and decrease on reject (-0.1). Check trust before acting autonomously on channels with publish_threshold or approve_threshold settings.",
Params: []Param{
{Name: "agent_name", Type: "string", Description: "Agent name to query (defaults to calling agent)"},
},
Returns: "JSON with agent_name and scores map (action_type -> score)",
Examples: []Example{
{
Description: "Get your own trust scores",
Code: `call("get_trust", {})`,
},
{
Description: "Get another agent's trust scores",
Code: `call("get_trust", {"agent_name": "research-mcpproxy"})`,
},
},
},
// ── SQL Query (1 action) ────────────────────────────────────
{
Name: "query",
Category: "data",
Description: "Execute a read-only SQL query against your accessible messages, channels, and reactions. Use tables: my_messages (your DMs + joined channels), my_channels (channels you are in), channel_messages (messages in your channels). Results are limited to 100 rows. Only SELECT statements are allowed.",
Params: []Param{
{Name: "sql", Type: "string", Description: "SQL SELECT query. Available tables: my_messages (id, body, from_agent, to_agent, priority, status, metadata, created_at, channel_name), my_channels (id, name, description, type), channel_messages (id, body, from_agent, priority, channel_name, created_at). CTEs (WITH) are supported.", Required: true},
},
Returns: "JSON with columns (array of column names), rows (array of row arrays), row_count, and truncated (boolean if > 100 rows)",
Examples: []Example{
{
Description: "Find high-priority messages in a channel",
Code: `call("query", {"sql": "SELECT id, body, from_agent, priority FROM channel_messages WHERE channel_name = 'news-mcpproxy' AND priority >= 7 ORDER BY created_at DESC LIMIT 10"})`,
},
{
Description: "List your channels",
Code: `call("query", {"sql": "SELECT name, description FROM my_channels ORDER BY name"})`,
},
{
Description: "Count messages per channel",
Code: `call("query", {"sql": "SELECT channel_name, COUNT(*) as msg_count FROM channel_messages GROUP BY channel_name ORDER BY msg_count DESC"})`,
},
{
Description: "Search messages with keyword",
Code: `call("query", {"sql": "SELECT id, body, from_agent, created_at FROM my_messages WHERE body LIKE '%MCP%' ORDER BY created_at DESC LIMIT 20"})`,
},
},
},
}
}
+138
View File
@@ -0,0 +1,138 @@
package actions
import (
"testing"
)
func TestRegistryHas30Actions(t *testing.T) {
r := NewRegistry()
got := len(r.List())
if got != 30 {
t.Errorf("expected 30 actions, got %d", got)
}
}
func TestRegistryCategories(t *testing.T) {
r := NewRegistry()
tests := []struct {
category string
want int
}{
{"messaging", 7},
{"channels", 9},
{"swarm", 5},
{"attachments", 2},
{"reactions", 4},
{"threads", 1},
{"trust", 1},
}
for _, tt := range tests {
t.Run(tt.category, func(t *testing.T) {
got := len(r.ListByCategory(tt.category))
if got != tt.want {
t.Errorf("category %q: expected %d actions, got %d", tt.category, tt.want, got)
}
})
}
}
func TestRegistryGetByName(t *testing.T) {
r := NewRegistry()
allNames := []string{
// messaging
"my_status", "send_message", "read_inbox", "claim_messages", "mark_done", "search_messages", "discover_agents",
// channels
"create_channel", "join_channel", "leave_channel", "list_channels",
"invite_to_channel", "kick_from_channel", "get_channel_messages",
"send_channel_message", "update_channel",
// swarm
"post_task", "bid_task", "accept_bid", "complete_task", "list_tasks",
// attachments
"upload_attachment", "download_attachment",
// reactions
"react", "unreact", "get_reactions", "list_by_state",
// threads
"get_replies",
// trust
"get_trust",
// data
"query",
}
for _, name := range allNames {
t.Run(name, func(t *testing.T) {
a, ok := r.Get(name)
if !ok {
t.Fatalf("action %q not found in registry", name)
}
if a.Name != name {
t.Errorf("expected name %q, got %q", name, a.Name)
}
})
}
}
func TestRegistryGetNotFound(t *testing.T) {
r := NewRegistry()
_, ok := r.Get("nonexistent_action")
if ok {
t.Error("expected Get to return false for nonexistent action")
}
}
func TestRegistryActionsHaveExamples(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if len(a.Examples) == 0 {
t.Errorf("action %q has no examples", a.Name)
}
})
}
}
func TestRegistryActionsHaveDescriptions(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if a.Description == "" {
t.Errorf("action %q has empty description", a.Name)
}
})
}
}
func TestRegistryActionsHaveReturns(t *testing.T) {
r := NewRegistry()
for _, a := range r.List() {
t.Run(a.Name, func(t *testing.T) {
if a.Returns == "" {
t.Errorf("action %q has empty Returns field", a.Name)
}
})
}
}
func TestRegistryListByUnknownCategory(t *testing.T) {
r := NewRegistry()
got := r.ListByCategory("nonexistent")
if len(got) != 0 {
t.Errorf("expected 0 actions for unknown category, got %d", len(got))
}
}
func TestRegistryListReturnsCopy(t *testing.T) {
r := NewRegistry()
list1 := r.List()
list2 := r.List()
// Mutating the first list should not affect the second.
if len(list1) > 0 {
list1[0].Name = "mutated"
if list2[0].Name == "mutated" {
t.Error("List() should return a copy, not a reference to internal slice")
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package actions
// Action represents a callable operation in the system.
type Action struct {
Name string `json:"name"`
Category string `json:"category"` // messaging, channels, swarm, attachments
Description string `json:"description"`
Params []Param `json:"params"`
Returns string `json:"returns"` // Human-readable return description
Examples []Example `json:"examples"`
}
// Param describes an action parameter.
type Param struct {
Name string `json:"name"`
Type string `json:"type"` // string, number, boolean
Description string `json:"description"`
Required bool `json:"required"`
Default string `json:"default,omitempty"`
}
// Example shows a usage example for the action.
type Example struct {
Description string `json:"description"`
Code string `json:"code"` // JS code example using call()
}
+38 -7
View File
@@ -2,26 +2,57 @@
package admin
import (
"context"
"database/sql"
"log/slog"
"net"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/trace"
"github.com/synapbus/synapbus/internal/webhooks"
)
// WebhookServiceProvider defines the webhook operations needed by the admin socket.
type WebhookServiceProvider interface {
RegisterWebhook(ctx context.Context, agentName, url string, events []string, secret string) (*webhooks.Webhook, error)
ListWebhooks(ctx context.Context, agentName string) ([]*webhooks.Webhook, error)
DeleteWebhook(ctx context.Context, agentName string, webhookID int64) error
}
// K8sServiceProvider defines the K8s handler operations needed by the admin socket.
type K8sServiceProvider interface {
RegisterHandler(ctx context.Context, agentName string, req k8s.RegisterHandlerRequest) (*k8s.K8sHandler, error)
ListHandlers(ctx context.Context, agentName string) ([]*k8s.K8sHandler, error)
DeleteHandler(ctx context.Context, agentName string, handlerID int64) error
}
// Services holds references to all services the admin socket can control.
type Services struct {
Users *auth.SQLiteUserStore
Sessions auth.SessionStore
Agents *agents.AgentService
Messages *messaging.MessagingService
Channels *channels.Service
Traces trace.TraceStore
DataDir string
Users *auth.SQLiteUserStore
Sessions auth.SessionStore
Agents *agents.AgentService
Messages *messaging.MessagingService
Channels *channels.Service
Traces trace.TraceStore
EmbeddingStore *search.EmbeddingStore
VectorIndex *search.VectorIndex
SearchService *search.Service
AttachmentService *attachments.Service
WebhookService WebhookServiceProvider
K8sService K8sServiceProvider
DataDir string
RetentionWorker RetentionStatusProvider
}
// RetentionStatusProvider provides retention status information.
type RetentionStatusProvider interface {
Status() map[string]interface{}
}
// AdminServer is a Unix domain socket server for local administration.
+750
View File
@@ -13,6 +13,8 @@ import (
"strings"
"time"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
)
@@ -137,6 +139,8 @@ func (s *AdminServer) dispatch(req Request) Response {
return s.handleAgentDelete(ctx, req.Args)
case "agent.revoke_key":
return s.handleAgentRevokeKey(ctx, req.Args)
case "agent.update_capabilities":
return s.handleAgentUpdateCapabilities(ctx, req.Args)
// --- audit commands ---
case "audit.list":
@@ -161,6 +165,12 @@ func (s *AdminServer) dispatch(req Request) Response {
return s.handleChannelsList(ctx)
case "channels.show":
return s.handleChannelsShow(ctx, req.Args)
case "channels.create":
return s.handleChannelsCreate(ctx, req.Args)
case "channels.join":
return s.handleChannelsJoin(ctx, req.Args)
case "channels.update_settings":
return s.handleChannelsUpdateSettings(ctx, req.Args)
// --- conversations ---
case "conversations.list":
@@ -168,6 +178,46 @@ func (s *AdminServer) dispatch(req Request) Response {
case "conversations.show":
return s.handleConversationsShow(ctx, req.Args)
// --- embeddings ---
case "embeddings.status":
return s.handleEmbeddingsStatus(ctx)
case "embeddings.reindex":
return s.handleEmbeddingsReindex(ctx)
case "embeddings.clear":
return s.handleEmbeddingsClear(ctx)
// --- db maintenance ---
case "db.vacuum":
return s.handleDBVacuum(ctx)
// --- messages purge ---
case "messages.purge":
return s.handleMessagesPurge(ctx, req.Args)
// --- retention ---
case "retention.status":
return s.handleRetentionStatus(ctx)
// --- webhooks ---
case "webhook.register":
return s.handleWebhookRegister(ctx, req.Args)
case "webhook.list":
return s.handleWebhookList(ctx, req.Args)
case "webhook.delete":
return s.handleWebhookDelete(ctx, req.Args)
// --- k8s ---
case "k8s.register":
return s.handleK8sRegister(ctx, req.Args)
case "k8s.list":
return s.handleK8sList(ctx, req.Args)
case "k8s.delete":
return s.handleK8sDelete(ctx, req.Args)
// --- attachments ---
case "attachments.gc":
return s.handleAttachmentsGC(ctx)
default:
return Response{OK: false, Error: fmt.Sprintf("unknown command: %s", req.Command)}
}
@@ -400,6 +450,35 @@ func (s *AdminServer) handleAgentRevokeKey(ctx context.Context, args json.RawMes
}}
}
func (s *AdminServer) handleAgentUpdateCapabilities(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
Capabilities json.RawMessage `json:"capabilities"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
if len(p.Capabilities) == 0 {
return Response{OK: false, Error: "capabilities is required"}
}
if !json.Valid(p.Capabilities) {
return Response{OK: false, Error: "capabilities must be valid JSON"}
}
agent, err := s.services.Agents.UpdateAgent(ctx, p.Name, "", p.Capabilities)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"name": agent.Name,
"capabilities": json.RawMessage(agent.Capabilities),
}}
}
// ---------- audit handlers ----------
func (s *AdminServer) handleAuditList(ctx context.Context, args json.RawMessage) Response {
@@ -846,6 +925,136 @@ func (s *AdminServer) handleChannelsShow(ctx context.Context, args json.RawMessa
}}
}
func (s *AdminServer) handleChannelsCreate(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
ch, err := s.services.Channels.CreateChannel(ctx, channels.CreateChannelRequest{
Name: p.Name,
Description: p.Description,
Type: "standard",
CreatedBy: "system",
})
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": ch.ID,
"name": ch.Name,
"description": ch.Description,
"type": ch.Type,
"is_private": ch.IsPrivate,
"created_by": ch.CreatedBy,
"created_at": ch.CreatedAt.Format(time.RFC3339),
}}
}
func (s *AdminServer) handleChannelsJoin(ctx context.Context, args json.RawMessage) Response {
var p struct {
Channel string `json:"channel"`
Agent string `json:"agent"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Channel == "" {
return Response{OK: false, Error: "channel is required"}
}
if p.Agent == "" {
return Response{OK: false, Error: "agent is required"}
}
ch, err := s.services.Channels.GetChannelByName(ctx, p.Channel)
if err != nil {
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Channel)}
}
// Check if already a member for status reporting
isMember, _ := s.services.Channels.IsMember(ctx, ch.ID, p.Agent)
if err := s.services.Channels.JoinChannel(ctx, ch.ID, p.Agent); err != nil {
return Response{OK: false, Error: err.Error()}
}
status := "joined"
if isMember {
status = "already_member"
}
return Response{OK: true, Data: map[string]interface{}{
"channel": p.Channel,
"agent": p.Agent,
"status": status,
}}
}
func (s *AdminServer) handleChannelsUpdateSettings(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
AutoApprove *bool `json:"auto_approve,omitempty"`
StalemateRemindAfter string `json:"stalemate_remind_after,omitempty"`
StalemateEscalateAfter string `json:"stalemate_escalate_after,omitempty"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
// Build the SET clause dynamically based on provided fields
var setClauses []string
var setArgs []interface{}
if p.AutoApprove != nil {
autoApproveVal := 0
if *p.AutoApprove {
autoApproveVal = 1
}
setClauses = append(setClauses, "auto_approve = ?")
setArgs = append(setArgs, autoApproveVal)
}
if p.StalemateRemindAfter != "" {
setClauses = append(setClauses, "stalemate_remind_after = ?")
setArgs = append(setArgs, p.StalemateRemindAfter)
}
if p.StalemateEscalateAfter != "" {
setClauses = append(setClauses, "stalemate_escalate_after = ?")
setArgs = append(setArgs, p.StalemateEscalateAfter)
}
if len(setClauses) == 0 {
return Response{OK: false, Error: "at least one setting must be provided (auto_approve, stalemate_remind_after, stalemate_escalate_after)"}
}
query := fmt.Sprintf("UPDATE channels SET %s WHERE LOWER(name) = LOWER(?)", strings.Join(setClauses, ", "))
setArgs = append(setArgs, p.Name)
result, err := s.db.ExecContext(ctx, query, setArgs...)
if err != nil {
return Response{OK: false, Error: "update channel settings: " + err.Error()}
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Name)}
}
return Response{OK: true, Data: map[string]interface{}{
"channel": p.Name,
"updated": true,
}}
}
// ---------- conversations handlers ----------
func (s *AdminServer) handleConversationsList(ctx context.Context, args json.RawMessage) Response {
@@ -941,5 +1150,546 @@ func (s *AdminServer) handleConversationsShow(ctx context.Context, args json.Raw
}}
}
// ---------- embeddings handlers ----------
func (s *AdminServer) handleEmbeddingsStatus(ctx context.Context) Response {
result := map[string]interface{}{
"provider": "",
"total_embedded": int64(0),
"pending_count": int64(0),
"failed_count": int64(0),
"index_size": 0,
"dimensions": 0,
}
if s.services.EmbeddingStore == nil {
return Response{OK: true, Data: result}
}
stats, err := s.services.EmbeddingStore.Stats(ctx)
if err != nil {
return Response{OK: false, Error: "get stats: " + err.Error()}
}
result["provider"] = stats.Provider
result["total_embedded"] = stats.TotalEmbedded
result["pending_count"] = stats.PendingCount
result["failed_count"] = stats.FailedCount
result["dimensions"] = stats.Dimensions
if s.services.VectorIndex != nil {
result["index_size"] = s.services.VectorIndex.Len()
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleEmbeddingsReindex(ctx context.Context) Response {
if s.services.EmbeddingStore == nil {
return Response{OK: false, Error: "embedding subsystem not configured"}
}
if err := s.services.EmbeddingStore.DeleteAllEmbeddings(ctx); err != nil {
return Response{OK: false, Error: "delete embeddings: " + err.Error()}
}
if err := s.services.EmbeddingStore.ClearQueue(ctx); err != nil {
return Response{OK: false, Error: "clear queue: " + err.Error()}
}
clearedIndex := false
if s.services.VectorIndex != nil {
if err := s.services.VectorIndex.Rebuild(nil); err != nil {
return Response{OK: false, Error: "clear index: " + err.Error()}
}
clearedIndex = true
}
enqueued, err := s.services.EmbeddingStore.EnqueueAllMessages(ctx)
if err != nil {
return Response{OK: false, Error: "enqueue messages: " + err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"deleted_embeddings": true,
"cleared_index": clearedIndex,
"enqueued_messages": enqueued,
}}
}
func (s *AdminServer) handleEmbeddingsClear(ctx context.Context) Response {
if s.services.EmbeddingStore == nil {
return Response{OK: false, Error: "embedding subsystem not configured"}
}
if err := s.services.EmbeddingStore.DeleteAllEmbeddings(ctx); err != nil {
return Response{OK: false, Error: "delete embeddings: " + err.Error()}
}
if err := s.services.EmbeddingStore.ClearQueue(ctx); err != nil {
return Response{OK: false, Error: "clear queue: " + err.Error()}
}
clearedIndex := false
if s.services.VectorIndex != nil {
if err := s.services.VectorIndex.Rebuild(nil); err != nil {
return Response{OK: false, Error: "clear index: " + err.Error()}
}
clearedIndex = true
}
return Response{OK: true, Data: map[string]interface{}{
"deleted_embeddings": true,
"cleared_index": clearedIndex,
"cleared_queue": true,
}}
}
// ---------- db maintenance handlers ----------
func (s *AdminServer) handleDBVacuum(ctx context.Context) Response {
dbPath := filepath.Join(s.services.DataDir, "synapbus.db")
beforeInfo, err := os.Stat(dbPath)
if err != nil {
return Response{OK: false, Error: "stat db: " + err.Error()}
}
beforeSize := beforeInfo.Size()
start := time.Now()
if _, err := s.db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return Response{OK: false, Error: "wal checkpoint: " + err.Error()}
}
if _, err := s.db.ExecContext(ctx, "VACUUM"); err != nil {
return Response{OK: false, Error: "vacuum: " + err.Error()}
}
durationMs := time.Since(start).Milliseconds()
afterInfo, err := os.Stat(dbPath)
if err != nil {
return Response{OK: false, Error: "stat db after vacuum: " + err.Error()}
}
afterSize := afterInfo.Size()
return Response{OK: true, Data: map[string]interface{}{
"before_size_bytes": beforeSize,
"after_size_bytes": afterSize,
"reclaimed_bytes": beforeSize - afterSize,
"duration_ms": durationMs,
}}
}
// ---------- messages purge handler ----------
func (s *AdminServer) handleMessagesPurge(ctx context.Context, args json.RawMessage) Response {
var p struct {
OlderThan string `json:"older_than"`
Agent string `json:"agent"`
Channel string `json:"channel"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if p.OlderThan == "" && p.Agent == "" && p.Channel == "" {
return Response{OK: false, Error: "at least one filter is required (older_than, agent, or channel)"}
}
var olderThan time.Duration
if p.OlderThan != "" {
cfg := messaging.ParseRetentionPeriod(p.OlderThan)
if cfg.RetentionPeriod <= 0 {
return Response{OK: false, Error: fmt.Sprintf("invalid duration: %q", p.OlderThan)}
}
olderThan = cfg.RetentionPeriod
}
counts, err := messaging.PurgeMessages(ctx, s.db, s.services.DataDir, olderThan, p.Agent, p.Channel)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: counts}
}
// ---------- retention handler ----------
func (s *AdminServer) handleRetentionStatus(ctx context.Context) Response {
if s.services.RetentionWorker != nil {
return Response{OK: true, Data: s.services.RetentionWorker.Status()}
}
return Response{OK: true, Data: map[string]interface{}{
"enabled": false,
"message": "retention worker not configured",
}}
}
// ---------- webhook handlers ----------
func (s *AdminServer) handleWebhookRegister(ctx context.Context, args json.RawMessage) Response {
var p struct {
URL string `json:"url"`
Events string `json:"events"`
Secret string `json:"secret"`
AgentName string `json:"agent_name"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.URL == "" || p.Events == "" || p.Secret == "" || p.AgentName == "" {
return Response{OK: false, Error: "url, events, secret, and agent_name are required"}
}
if s.services.WebhookService == nil {
return Response{OK: false, Error: "webhook service not configured"}
}
events := strings.Split(p.Events, ",")
for i := range events {
events[i] = strings.TrimSpace(events[i])
}
wh, err := s.services.WebhookService.RegisterWebhook(ctx, p.AgentName, p.URL, events, p.Secret)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": wh.ID,
"url": wh.URL,
"events": wh.Events,
"status": wh.Status,
}}
}
func (s *AdminServer) handleWebhookList(ctx context.Context, args json.RawMessage) Response {
var p struct {
AgentName string `json:"agent_name"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if s.services.WebhookService == nil {
return Response{OK: false, Error: "webhook service not configured"}
}
if p.AgentName == "" {
// Admin: list all webhooks by querying DB directly.
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_name, url, events, status, consecutive_failures, created_at
FROM webhooks ORDER BY created_at DESC`)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type whRow struct {
ID int64 `json:"id"`
AgentName string `json:"agent_name"`
URL string `json:"url"`
Events []string `json:"events"`
Status string `json:"status"`
ConsecutiveFailures int `json:"consecutive_failures"`
CreatedAt string `json:"created_at"`
}
var result []whRow
for rows.Next() {
var r whRow
var eventsJSON string
var createdAt time.Time
if err := rows.Scan(&r.ID, &r.AgentName, &r.URL, &eventsJSON, &r.Status, &r.ConsecutiveFailures, &createdAt); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
json.Unmarshal([]byte(eventsJSON), &r.Events)
if r.Events == nil {
r.Events = []string{}
}
r.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, r)
}
if result == nil {
result = []whRow{}
}
return Response{OK: true, Data: result}
}
webhookList, err := s.services.WebhookService.ListWebhooks(ctx, p.AgentName)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
type whRow struct {
ID int64 `json:"id"`
AgentName string `json:"agent_name"`
URL string `json:"url"`
Events []string `json:"events"`
Status string `json:"status"`
ConsecutiveFailures int `json:"consecutive_failures"`
CreatedAt string `json:"created_at"`
}
result := make([]whRow, len(webhookList))
for i, wh := range webhookList {
result[i] = whRow{
ID: wh.ID,
AgentName: wh.AgentName,
URL: wh.URL,
Events: wh.Events,
Status: wh.Status,
ConsecutiveFailures: wh.ConsecutiveFailures,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleWebhookDelete(ctx context.Context, args json.RawMessage) Response {
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.ID <= 0 {
return Response{OK: false, Error: "id is required"}
}
if s.services.WebhookService == nil {
return Response{OK: false, Error: "webhook service not configured"}
}
// Admin bypass: look up the webhook's agent_name first, then delete.
var agentName string
err := s.db.QueryRowContext(ctx, "SELECT agent_name FROM webhooks WHERE id = ?", p.ID).Scan(&agentName)
if err != nil {
return Response{OK: false, Error: "webhook not found"}
}
if err := s.services.WebhookService.DeleteWebhook(ctx, agentName, p.ID); err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"deleted": p.ID,
}}
}
// ---------- k8s handlers ----------
func (s *AdminServer) handleK8sRegister(ctx context.Context, args json.RawMessage) Response {
var p struct {
Image string `json:"image"`
Events string `json:"events"`
AgentName string `json:"agent_name"`
Namespace string `json:"namespace"`
ResourcesMemory string `json:"resources_memory"`
ResourcesCPU string `json:"resources_cpu"`
Env string `json:"env"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Image == "" || p.Events == "" || p.AgentName == "" {
return Response{OK: false, Error: "image, events, and agent_name are required"}
}
if s.services.K8sService == nil {
return Response{OK: false, Error: "k8s service not configured"}
}
events := strings.Split(p.Events, ",")
for i := range events {
events[i] = strings.TrimSpace(events[i])
}
// Parse env from comma-separated KEY=VALUE pairs.
envMap := map[string]string{}
if p.Env != "" {
for _, pair := range strings.Split(p.Env, ",") {
pair = strings.TrimSpace(pair)
parts := strings.SplitN(pair, "=", 2)
if len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
}
timeout := p.TimeoutSeconds
if timeout <= 0 {
timeout = 300
}
req := k8s.RegisterHandlerRequest{
Image: p.Image,
Events: events,
Namespace: p.Namespace,
ResourcesMemory: p.ResourcesMemory,
ResourcesCPU: p.ResourcesCPU,
Env: envMap,
TimeoutSeconds: timeout,
}
handler, err := s.services.K8sService.RegisterHandler(ctx, p.AgentName, req)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": handler.ID,
"image": handler.Image,
"events": handler.Events,
"status": handler.Status,
}}
}
func (s *AdminServer) handleK8sList(ctx context.Context, args json.RawMessage) Response {
var p struct {
AgentName string `json:"agent_name"`
}
if args != nil {
json.Unmarshal(args, &p)
}
if s.services.K8sService == nil {
return Response{OK: false, Error: "k8s service not configured"}
}
if p.AgentName == "" {
// Admin: list all K8s handlers by querying DB directly.
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_name, image, events, namespace, resources_memory, resources_cpu, timeout_seconds, status, created_at
FROM k8s_handlers ORDER BY created_at DESC`)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
defer rows.Close()
type handlerRow struct {
ID int64 `json:"id"`
AgentName string `json:"agent_name"`
Image string `json:"image"`
Events []string `json:"events"`
Namespace string `json:"namespace"`
ResourcesMemory string `json:"resources_memory"`
ResourcesCPU string `json:"resources_cpu"`
TimeoutSeconds int `json:"timeout_seconds"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
var result []handlerRow
for rows.Next() {
var r handlerRow
var eventsJSON string
var createdAt time.Time
if err := rows.Scan(&r.ID, &r.AgentName, &r.Image, &eventsJSON, &r.Namespace,
&r.ResourcesMemory, &r.ResourcesCPU, &r.TimeoutSeconds, &r.Status, &createdAt); err != nil {
return Response{OK: false, Error: "scan: " + err.Error()}
}
json.Unmarshal([]byte(eventsJSON), &r.Events)
if r.Events == nil {
r.Events = []string{}
}
r.CreatedAt = createdAt.Format(time.RFC3339)
result = append(result, r)
}
if result == nil {
result = []handlerRow{}
}
return Response{OK: true, Data: result}
}
handlers, err := s.services.K8sService.ListHandlers(ctx, p.AgentName)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
type handlerRow struct {
ID int64 `json:"id"`
AgentName string `json:"agent_name"`
Image string `json:"image"`
Events []string `json:"events"`
Namespace string `json:"namespace"`
ResourcesMemory string `json:"resources_memory"`
ResourcesCPU string `json:"resources_cpu"`
TimeoutSeconds int `json:"timeout_seconds"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
result := make([]handlerRow, len(handlers))
for i, h := range handlers {
result[i] = handlerRow{
ID: h.ID,
AgentName: h.AgentName,
Image: h.Image,
Events: h.Events,
Namespace: h.Namespace,
ResourcesMemory: h.ResourcesMemory,
ResourcesCPU: h.ResourcesCPU,
TimeoutSeconds: h.TimeoutSeconds,
Status: h.Status,
CreatedAt: h.CreatedAt.Format(time.RFC3339),
}
}
return Response{OK: true, Data: result}
}
func (s *AdminServer) handleK8sDelete(ctx context.Context, args json.RawMessage) Response {
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.ID <= 0 {
return Response{OK: false, Error: "id is required"}
}
if s.services.K8sService == nil {
return Response{OK: false, Error: "k8s service not configured"}
}
// Admin bypass: look up the handler's agent_name first, then delete.
var agentName string
err := s.db.QueryRowContext(ctx, "SELECT agent_name FROM k8s_handlers WHERE id = ?", p.ID).Scan(&agentName)
if err != nil {
return Response{OK: false, Error: "handler not found"}
}
if err := s.services.K8sService.DeleteHandler(ctx, agentName, p.ID); err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"deleted": p.ID,
}}
}
// ---------- attachments handlers ----------
func (s *AdminServer) handleAttachmentsGC(ctx context.Context) Response {
if s.services.AttachmentService == nil {
return Response{OK: false, Error: "attachment service not configured"}
}
result, err := s.services.AttachmentService.GarbageCollect(ctx)
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"files_removed": result.FilesRemoved,
"bytes_reclaimed": result.BytesReclaimed,
}}
}
// Ensure the messaging import is used.
var _ = messaging.StatusPending
+234
View File
@@ -0,0 +1,234 @@
// Package agentquery provides a sandboxed SQL query executor for agents.
// Agents can run read-only SELECT queries against curated views with
// per-agent access control, automatic LIMIT enforcement, and timeouts.
package agentquery
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
)
const (
// MaxRows is the maximum number of rows returned by a query.
MaxRows = 100
// QueryTimeout is the maximum duration for a query.
QueryTimeout = 5 * time.Second
)
// Allowed view names that agents can query.
var allowedTables = map[string]bool{
"my_messages": true,
"my_channels": true,
"channel_messages": true,
}
// Executor runs sandboxed SQL queries on behalf of agents.
type Executor struct {
db *sql.DB // read-only pool (query_only=ON)
logger *slog.Logger
}
// New creates a new query executor using the provided read-only database connection.
func New(readDB *sql.DB, logger *slog.Logger) *Executor {
return &Executor{
db: readDB,
logger: logger.With("component", "agentquery"),
}
}
// QueryResult holds the results of a SQL query.
type QueryResult struct {
Columns []string `json:"columns"`
Rows [][]interface{} `json:"rows"`
RowCount int `json:"row_count"`
Truncated bool `json:"truncated"`
}
// Execute runs a SQL query on behalf of an agent with access control.
func (e *Executor) Execute(ctx context.Context, agentName, sqlQuery string) (*QueryResult, error) {
// 1. Validate the SQL statement
if err := validateSQL(sqlQuery); err != nil {
return nil, fmt.Errorf("query validation failed: %w", err)
}
// 2. Rewrite the query to inject access control and enforce LIMIT
rewritten := rewriteQuery(agentName, sqlQuery)
// 3. Execute with timeout
queryCtx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
rows, err := e.db.QueryContext(queryCtx, rewritten)
if err != nil {
if queryCtx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("query timed out after %s", QueryTimeout)
}
return nil, fmt.Errorf("query execution failed: %w", err)
}
defer rows.Close()
// 4. Collect results
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("get columns: %w", err)
}
var resultRows [][]interface{}
truncated := false
for rows.Next() {
if len(resultRows) >= MaxRows {
truncated = true
break
}
values := make([]interface{}, len(columns))
scanArgs := make([]interface{}, len(columns))
for i := range values {
scanArgs[i] = &values[i]
}
if err := rows.Scan(scanArgs...); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
// Convert []byte to string for JSON serialization
row := make([]interface{}, len(columns))
for i, v := range values {
if b, ok := v.([]byte); ok {
row[i] = string(b)
} else {
row[i] = v
}
}
resultRows = append(resultRows, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate rows: %w", err)
}
if resultRows == nil {
resultRows = [][]interface{}{}
}
e.logger.Info("agent query executed",
"agent", agentName,
"rows", len(resultRows),
"truncated", truncated,
)
return &QueryResult{
Columns: columns,
Rows: resultRows,
RowCount: len(resultRows),
Truncated: truncated,
}, nil
}
// validateSQL checks that the query is a read-only SELECT statement.
func validateSQL(query string) error {
trimmed := strings.TrimSpace(query)
if trimmed == "" {
return fmt.Errorf("empty query")
}
// Remove comments
upper := strings.ToUpper(trimmed)
// Must start with SELECT or WITH (CTEs)
if !strings.HasPrefix(upper, "SELECT") && !strings.HasPrefix(upper, "WITH") {
return fmt.Errorf("only SELECT statements are allowed (got %q)", firstWord(upper))
}
// Block dangerous keywords (check as whole words or with common delimiters)
blocked := []string{
"INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE ",
"ATTACH ", "DETACH ", "PRAGMA", "REINDEX ", "VACUUM ",
"REPLACE ", "GRANT ", "REVOKE ",
}
for _, kw := range blocked {
if strings.Contains(upper, kw) {
return fmt.Errorf("statement contains blocked keyword: %s", strings.TrimSpace(kw))
}
}
// Block multiple statements (semicolon followed by non-whitespace)
parts := strings.Split(trimmed, ";")
nonEmpty := 0
for _, p := range parts {
if strings.TrimSpace(p) != "" {
nonEmpty++
}
}
if nonEmpty > 1 {
return fmt.Errorf("multiple statements not allowed")
}
return nil
}
// rewriteQuery wraps the agent's query with access control CTEs.
// It replaces references to my_messages, my_channels, channel_messages
// with CTEs that filter by the agent's access.
func rewriteQuery(agentName, query string) string {
// Build access-control CTEs that the agent's query can reference
cte := fmt.Sprintf(`
WITH my_messages AS (
SELECT v.* FROM v_agent_messages v
LEFT JOIN channel_members cm ON cm.channel_id = v.channel_id AND cm.agent_name = %[1]s
WHERE v.to_agent = %[1]s
OR v.from_agent = %[1]s
OR (v.channel_id IS NOT NULL AND cm.agent_name IS NOT NULL)
),
my_channels AS (
SELECT c.id, c.name, c.description, c.type, c.topic, c.is_private, c.created_at,
cm.joined_at AS member_since
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id AND cm.agent_name = %[1]s
),
channel_messages AS (
SELECT v.* FROM v_channel_messages v
WHERE v.channel_id IN (
SELECT channel_id FROM channel_members WHERE agent_name = %[1]s
)
)
`, quoteSQLString(agentName))
trimmed := strings.TrimSpace(query)
upper := strings.ToUpper(trimmed)
// Remove trailing semicolon if present
trimmed = strings.TrimRight(trimmed, "; \t\n")
if strings.HasPrefix(upper, "WITH") {
// User has their own CTEs. Merge: our CTEs first, then theirs.
userCTEs := strings.TrimSpace(trimmed[4:]) // skip "WITH"
return cte + ", " + userCTEs
}
// Simple SELECT — prepend our CTEs
return cte + trimmed
}
// quoteSQLString safely quotes a string for use in SQL.
func quoteSQLString(s string) string {
escaped := strings.ReplaceAll(s, "'", "''")
return "'" + escaped + "'"
}
func firstWord(s string) string {
for i, c := range s {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '(' {
return s[:i]
}
}
if len(s) > 20 {
return s[:20]
}
return s
}
+341
View File
@@ -0,0 +1,341 @@
package agentquery
import (
"context"
"database/sql"
"log/slog"
"testing"
_ "modernc.org/sqlite"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
// Create the schema needed for views
schema := `
CREATE TABLE channels (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
type TEXT DEFAULT 'standard',
topic TEXT DEFAULT '',
is_private INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE channel_members (
channel_id INTEGER,
agent_name TEXT,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, agent_name)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
conversation_id INTEGER DEFAULT 0,
from_agent TEXT,
to_agent TEXT,
channel_id INTEGER,
reply_to INTEGER,
body TEXT,
priority INTEGER DEFAULT 5,
status TEXT DEFAULT 'pending',
metadata TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Views matching the migration
CREATE VIEW v_agent_messages AS
SELECT m.id, m.body, m.from_agent, m.to_agent, m.priority, m.status, m.metadata,
m.created_at, m.updated_at, c.name AS channel_name, m.channel_id, m.reply_to, m.conversation_id
FROM messages m LEFT JOIN channels c ON c.id = m.channel_id;
CREATE VIEW v_agent_channels AS
SELECT c.id, c.name, c.description, c.type, c.topic, c.is_private, c.created_at,
cm.joined_at AS member_since
FROM channels c JOIN channel_members cm ON cm.channel_id = c.id;
CREATE VIEW v_channel_messages AS
SELECT m.id, m.body, m.from_agent, m.priority, m.status, m.metadata, m.created_at,
c.name AS channel_name, m.channel_id, m.reply_to
FROM messages m JOIN channels c ON c.id = m.channel_id;
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("create schema: %v", err)
}
// Seed test data
seed := `
INSERT INTO channels (id, name) VALUES (1, 'general'), (2, 'news-mcpproxy'), (3, 'private-channel');
INSERT INTO channel_members (channel_id, agent_name) VALUES
(1, 'agent-a'), (1, 'agent-b'),
(2, 'agent-a'),
(3, 'agent-b');
-- DMs
INSERT INTO messages (id, from_agent, to_agent, body, priority) VALUES
(1, 'algis', 'agent-a', 'Hello agent A', 7),
(2, 'agent-a', 'algis', 'Hi there', 5),
(3, 'algis', 'agent-b', 'Hello agent B', 5);
-- Channel messages
INSERT INTO messages (id, from_agent, channel_id, body, priority) VALUES
(4, 'agent-a', 1, 'General post from A', 5),
(5, 'agent-b', 1, 'General post from B', 5),
(6, 'agent-a', 2, 'News post high prio', 8),
(7, 'agent-b', 3, 'Private channel msg', 5);
`
if _, err := db.Exec(seed); err != nil {
t.Fatalf("seed data: %v", err)
}
return db
}
func TestExecuteBasicQuery(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"SELECT id, body, priority FROM my_messages ORDER BY id")
if err != nil {
t.Fatalf("query failed: %v", err)
}
if len(result.Columns) != 3 {
t.Errorf("expected 3 columns, got %d", len(result.Columns))
}
if result.Columns[0] != "id" || result.Columns[1] != "body" || result.Columns[2] != "priority" {
t.Errorf("unexpected columns: %v", result.Columns)
}
// agent-a should see: DM to it (1), DM from it (2), general posts (4,5), news post (6)
// Should NOT see: DM to agent-b (3), private channel msg (7)
if result.RowCount < 4 {
t.Errorf("expected at least 4 rows for agent-a, got %d", result.RowCount)
}
// Verify agent-b's DM and private channel msg are NOT visible
for _, row := range result.Rows {
id := row[0]
if id == int64(3) {
t.Error("agent-a should NOT see message 3 (DM to agent-b)")
}
if id == int64(7) {
t.Error("agent-a should NOT see message 7 (private channel, not joined)")
}
}
}
func TestAccessControlAgentB(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-b",
"SELECT id, body FROM my_messages ORDER BY id")
if err != nil {
t.Fatalf("query failed: %v", err)
}
// agent-b should see: DM to it (3), general posts (4,5), private channel (7)
// Should NOT see: DM to agent-a (1), DM from agent-a (2), news post (6)
hasMsg3 := false
hasMsg7 := false
for _, row := range result.Rows {
id := row[0]
if id == int64(3) {
hasMsg3 = true
}
if id == int64(7) {
hasMsg7 = true
}
if id == int64(1) {
t.Error("agent-b should NOT see message 1 (DM to agent-a)")
}
if id == int64(6) {
t.Error("agent-b should NOT see message 6 (news channel, not joined)")
}
}
if !hasMsg3 {
t.Error("agent-b should see message 3 (DM to it)")
}
if !hasMsg7 {
t.Error("agent-b should see message 7 (private channel, joined)")
}
}
func TestQueryChannelMessages(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"SELECT id, body, channel_name FROM channel_messages WHERE channel_name = 'news-mcpproxy'")
if err != nil {
t.Fatalf("query failed: %v", err)
}
if result.RowCount != 1 {
t.Errorf("expected 1 news message, got %d", result.RowCount)
}
}
func TestQueryMyChannels(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"SELECT name FROM my_channels ORDER BY name")
if err != nil {
t.Fatalf("query failed: %v", err)
}
// agent-a is in: general, news-mcpproxy (not private-channel)
if result.RowCount != 2 {
t.Errorf("expected 2 channels for agent-a, got %d", result.RowCount)
}
}
func TestValidationRejectsInsert(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
_, err := exec.Execute(context.Background(), "agent-a",
"INSERT INTO messages (body) VALUES ('evil')")
if err == nil {
t.Fatal("expected INSERT to be rejected")
}
if !contains(err.Error(), "only SELECT") {
t.Errorf("expected 'only SELECT' error, got: %v", err)
}
}
func TestValidationRejectsDrop(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
_, err := exec.Execute(context.Background(), "agent-a",
"SELECT 1; DROP TABLE messages")
if err == nil {
t.Fatal("expected multi-statement to be rejected")
}
}
func TestValidationRejectsUpdate(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
_, err := exec.Execute(context.Background(), "agent-a",
"UPDATE messages SET body = 'hacked'")
if err == nil {
t.Fatal("expected UPDATE to be rejected")
}
}
func TestValidationRejectsPragma(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
_, err := exec.Execute(context.Background(), "agent-a",
"SELECT * FROM pragma_table_info('messages')")
if err == nil {
t.Fatal("expected PRAGMA in SELECT to be rejected")
}
}
func TestEmptyQuery(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
_, err := exec.Execute(context.Background(), "agent-a", "")
if err == nil {
t.Fatal("expected empty query to be rejected")
}
}
func TestCTEQuery(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"WITH high_prio AS (SELECT * FROM my_messages WHERE priority >= 7) SELECT id, priority FROM high_prio")
if err != nil {
t.Fatalf("CTE query failed: %v", err)
}
// agent-a should see high-priority messages it has access to
if result.RowCount == 0 {
t.Error("expected at least 1 high-priority message")
}
}
func TestEmptyResultSet(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"SELECT * FROM my_messages WHERE body = 'nonexistent'")
if err != nil {
t.Fatalf("query failed: %v", err)
}
if result.RowCount != 0 {
t.Errorf("expected 0 rows, got %d", result.RowCount)
}
if result.Rows == nil {
t.Error("rows should be empty array, not nil")
}
if result.Truncated {
t.Error("should not be truncated")
}
}
func TestLimitEnforcement(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert 150 messages to test limit
for i := 100; i < 250; i++ {
_, _ = db.Exec("INSERT INTO messages (id, from_agent, to_agent, body) VALUES (?, 'algis', 'agent-a', 'msg')", i)
}
exec := New(db, slog.Default())
result, err := exec.Execute(context.Background(), "agent-a",
"SELECT id FROM my_messages")
if err != nil {
t.Fatalf("query failed: %v", err)
}
if result.RowCount > MaxRows {
t.Errorf("expected max %d rows, got %d", MaxRows, result.RowCount)
}
if !result.Truncated {
t.Error("expected truncated=true for large result set")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+7 -3
View File
@@ -229,9 +229,13 @@ func RequiredAuthMiddlewareWithOAuth(service *AgentService, keyService *apikeys.
// resolveOAuthToken introspects an OAuth bearer token and extracts agent identity.
func resolveOAuthToken(ctx context.Context, provider fosite.OAuth2Provider, token string, service *AgentService) (agentName string, ownerID string, ok bool) {
// Use a fositeSession-compatible struct for introspection.
// We import the type indirectly through the fosite interface.
_, ar, err := provider.IntrospectToken(ctx, token, fosite.AccessToken, &oauthIntrospectSession{})
// Decouple from the HTTP request context so token introspection completes
// even if the client disconnects (fixes "context canceled" errors during
// concurrent MCP connections from claude.ai).
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
_, ar, err := provider.IntrospectToken(dbCtx, token, fosite.AccessToken, &oauthIntrospectSession{})
if err != nil {
return "", "", false
}
+37
View File
@@ -245,6 +245,12 @@ func (s *AgentService) ListAgents(ctx context.Context, ownerID int64) ([]*Agent,
return s.store.ListAgentsByOwner(ctx, ownerID)
}
// ListAllActiveAgents returns all active non-human agents across all owners.
// Used for the A2A Agent Card discovery endpoint.
func (s *AgentService) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) {
return s.store.ListAllActiveAgents(ctx)
}
// RevokeKey generates a new API key for an agent. Only the owner can do this.
// Returns the agent and the new raw API key (shown once).
func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64) (*Agent, string, error) {
@@ -345,6 +351,37 @@ func (s *AgentService) EnsureHumanAgent(ctx context.Context, username, displayNa
return agent, nil
}
// EnsureSystemAgent creates the "system" agent if it doesn't already exist.
// The system agent is used for retention warnings and other system notifications.
func (s *AgentService) EnsureSystemAgent(ctx context.Context, ownerID int64) (*Agent, error) {
agent, err := s.store.GetAgentByName(ctx, "system")
if err == nil && agent != nil && agent.Status == "active" {
return agent, nil
}
agent, _, err = s.Register(ctx, "system", "System", "ai", json.RawMessage(`{"role":"system-notifications"}`), ownerID)
if err != nil {
// May already exist from a concurrent call
agent, err2 := s.store.GetAgentByName(ctx, "system")
if err2 == nil && agent != nil {
return agent, nil
}
return nil, fmt.Errorf("create system agent: %w", err)
}
s.logger.Info("created system agent", "owner_id", ownerID)
return agent, nil
}
// GetAgentWithOwner returns agent details along with the owner's display name.
func (s *AgentService) GetAgentWithOwner(ctx context.Context, name string) (*Agent, string, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
return nil, "", fmt.Errorf("get agent: %w", err)
}
return agent, "", nil // Owner name resolved by caller with access to user store
}
// generateAPIKey creates a cryptographically random API key (32 bytes, hex encoded).
func generateAPIKey() (string, error) {
b := make([]byte, 32)
+119 -12
View File
@@ -15,9 +15,16 @@ type AgentStore interface {
UpdateAgent(ctx context.Context, agent *Agent) error
DeactivateAgent(ctx context.Context, name string) error
ListActiveAgents(ctx context.Context) ([]*Agent, error)
ListAllActiveAgents(ctx context.Context) ([]*Agent, error)
ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error)
SearchAgentsByCapability(ctx context.Context, query string) ([]*Agent, error)
GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error)
// Reactive trigger methods
UpdateTriggerConfig(ctx context.Context, name string, mode string, cooldown, budget, maxDepth int) error
UpdateK8sImage(ctx context.Context, name, image, envJSON, preset string) error
SetPendingWork(ctx context.Context, name string, pending bool) error
ListReactiveAgents(ctx context.Context) ([]*Agent, error)
}
// SQLiteAgentStore implements AgentStore using SQLite.
@@ -36,6 +43,28 @@ func (s *SQLiteAgentStore) CreateAgent(ctx context.Context, agent *Agent) error
caps = "{}"
}
// Default trigger values
triggerMode := agent.TriggerMode
if triggerMode == "" {
triggerMode = TriggerModePassive
}
cooldown := agent.CooldownSeconds
if cooldown == 0 {
cooldown = 600
}
budget := agent.DailyTriggerBudget
if budget == 0 {
budget = 8
}
maxDepth := agent.MaxTriggerDepth
if maxDepth == 0 {
maxDepth = 5
}
preset := agent.K8sResourcePreset
if preset == "" {
preset = "default"
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
@@ -50,20 +79,75 @@ func (s *SQLiteAgentStore) CreateAgent(ctx context.Context, agent *Agent) error
}
agent.ID = id
agent.Status = AgentStatusActive
agent.TriggerMode = triggerMode
agent.CooldownSeconds = cooldown
agent.DailyTriggerBudget = budget
agent.MaxTriggerDepth = maxDepth
agent.K8sResourcePreset = preset
return nil
}
// UpdateTriggerConfig updates the reactive trigger configuration for an agent.
func (s *SQLiteAgentStore) UpdateTriggerConfig(ctx context.Context, name string, mode string, cooldown, budget, maxDepth int) error {
_, err := s.db.ExecContext(ctx,
`UPDATE agents SET trigger_mode = ?, cooldown_seconds = ?, daily_trigger_budget = ?, max_trigger_depth = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ? AND status = 'active'`,
mode, cooldown, budget, maxDepth, name,
)
return err
}
// UpdateK8sImage updates the K8s container image and env config for an agent.
func (s *SQLiteAgentStore) UpdateK8sImage(ctx context.Context, name, image, envJSON, preset string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE agents SET k8s_image = ?, k8s_env_json = ?, k8s_resource_preset = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ? AND status = 'active'`,
image, envJSON, preset, name,
)
return err
}
// SetPendingWork sets the pending_work flag for an agent.
func (s *SQLiteAgentStore) SetPendingWork(ctx context.Context, name string, pending bool) error {
val := 0
if pending {
val = 1
}
_, err := s.db.ExecContext(ctx,
`UPDATE agents SET pending_work = ? WHERE name = ? AND status = 'active'`,
val, name,
)
return err
}
// ListReactiveAgents returns all active agents with trigger_mode='reactive'.
func (s *SQLiteAgentStore) ListReactiveAgents(ctx context.Context) ([]*Agent, error) {
rows, err := s.db.QueryContext(ctx,
agentSelectSQL()+` WHERE status = 'active' AND trigger_mode = 'reactive' ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanAgents(rows)
}
// agentSelectSQL returns the base SELECT clause for agent queries.
func agentSelectSQL() string {
return `SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at,
trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_env_json, k8s_resource_preset, pending_work
FROM agents`
}
func (s *SQLiteAgentStore) GetAgentByName(ctx context.Context, name string) (*Agent, error) {
return s.scanAgent(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE name = ? AND status = 'active'`, name,
agentSelectSQL()+` WHERE name = ? AND status = 'active'`, name,
))
}
func (s *SQLiteAgentStore) GetAgentByID(ctx context.Context, id int64) (*Agent, error) {
return s.scanAgent(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE id = ? AND status = 'active'`, id,
agentSelectSQL()+` WHERE id = ? AND status = 'active'`, id,
))
}
@@ -102,8 +186,18 @@ func (s *SQLiteAgentStore) DeactivateAgent(ctx context.Context, name string) err
func (s *SQLiteAgentStore) ListActiveAgents(ctx context.Context) ([]*Agent, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE status = 'active' ORDER BY name`,
agentSelectSQL()+` WHERE status = 'active' ORDER BY name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanAgents(rows)
}
func (s *SQLiteAgentStore) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) {
rows, err := s.db.QueryContext(ctx,
agentSelectSQL()+` WHERE status = 'active' AND type != 'human' ORDER BY name`,
)
if err != nil {
return nil, err
@@ -114,8 +208,7 @@ func (s *SQLiteAgentStore) ListActiveAgents(ctx context.Context) ([]*Agent, erro
func (s *SQLiteAgentStore) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE owner_id = ? AND status = 'active' ORDER BY name`,
agentSelectSQL()+` WHERE owner_id = ? AND status = 'active' ORDER BY name`,
ownerID,
)
if err != nil {
@@ -128,8 +221,7 @@ func (s *SQLiteAgentStore) ListAgentsByOwner(ctx context.Context, ownerID int64)
func (s *SQLiteAgentStore) SearchAgentsByCapability(ctx context.Context, query string) ([]*Agent, error) {
// Simple LIKE search on the capabilities JSON field
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE status = 'active' AND capabilities LIKE ? ORDER BY name`,
agentSelectSQL()+` WHERE status = 'active' AND capabilities LIKE ? ORDER BY name`,
"%"+query+"%",
)
if err != nil {
@@ -141,23 +233,30 @@ func (s *SQLiteAgentStore) SearchAgentsByCapability(ctx context.Context, query s
func (s *SQLiteAgentStore) GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error) {
return s.scanAgent(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at
FROM agents WHERE owner_id = ? AND type = 'human' AND status = 'active' LIMIT 1`, ownerID,
agentSelectSQL()+` WHERE owner_id = ? AND type = 'human' AND status = 'active' LIMIT 1`, ownerID,
))
}
func (s *SQLiteAgentStore) scanAgent(row *sql.Row) (*Agent, error) {
var agent Agent
var caps string
var k8sImage, k8sEnvJSON sql.NullString
var pendingWork int
err := row.Scan(
&agent.ID, &agent.Name, &agent.DisplayName, &agent.Type,
&caps, &agent.OwnerID, &agent.APIKeyHash, &agent.Status,
&agent.CreatedAt, &agent.UpdatedAt,
&agent.TriggerMode, &agent.CooldownSeconds, &agent.DailyTriggerBudget,
&agent.MaxTriggerDepth, &k8sImage, &k8sEnvJSON,
&agent.K8sResourcePreset, &pendingWork,
)
if err != nil {
return nil, err
}
agent.Capabilities = json.RawMessage(caps)
agent.K8sImage = k8sImage.String
agent.K8sEnvJSON = k8sEnvJSON.String
agent.PendingWork = pendingWork != 0
return &agent, nil
}
@@ -166,15 +265,23 @@ func (s *SQLiteAgentStore) scanAgents(rows *sql.Rows) ([]*Agent, error) {
for rows.Next() {
var agent Agent
var caps string
var k8sImage, k8sEnvJSON sql.NullString
var pendingWork int
err := rows.Scan(
&agent.ID, &agent.Name, &agent.DisplayName, &agent.Type,
&caps, &agent.OwnerID, &agent.APIKeyHash, &agent.Status,
&agent.CreatedAt, &agent.UpdatedAt,
&agent.TriggerMode, &agent.CooldownSeconds, &agent.DailyTriggerBudget,
&agent.MaxTriggerDepth, &k8sImage, &k8sEnvJSON,
&agent.K8sResourcePreset, &pendingWork,
)
if err != nil {
return nil, err
}
agent.Capabilities = json.RawMessage(caps)
agent.K8sImage = k8sImage.String
agent.K8sEnvJSON = k8sEnvJSON.String
agent.PendingWork = pendingWork != 0
agents = append(agents, &agent)
}
if agents == nil {
+17
View File
@@ -12,6 +12,13 @@ const (
AgentStatusInactive = "inactive"
)
// Trigger mode constants.
const (
TriggerModePassive = "passive"
TriggerModeReactive = "reactive"
TriggerModeDisabled = "disabled"
)
// Agent represents a registered entity that can send/receive messages.
type Agent struct {
ID int64 `json:"id"`
@@ -24,4 +31,14 @@ type Agent struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Reactive trigger fields
TriggerMode string `json:"trigger_mode"`
CooldownSeconds int `json:"cooldown_seconds"`
DailyTriggerBudget int `json:"daily_trigger_budget"`
MaxTriggerDepth int `json:"max_trigger_depth"`
K8sImage string `json:"k8s_image,omitempty"`
K8sEnvJSON string `json:"k8s_env_json,omitempty"`
K8sResourcePreset string `json:"k8s_resource_preset"`
PendingWork bool `json:"pending_work"`
}
+282
View File
@@ -0,0 +1,282 @@
package api
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
)
// AnalyticsHandler serves analytics endpoints for the Web UI dashboard.
type AnalyticsHandler struct {
db *sql.DB
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewAnalyticsHandler creates a new analytics handler.
func NewAnalyticsHandler(db *sql.DB, agentService *agents.AgentService, channelService *channels.Service) *AnalyticsHandler {
return &AnalyticsHandler{
db: db,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.analytics"),
}
}
// parseSpan parses a span parameter and returns the cutoff time and strftime format string.
func parseSpan(span string) (time.Time, string, error) {
now := time.Now().UTC()
switch span {
case "1h":
return now.Add(-1 * time.Hour), "%Y-%m-%d %H:%M", nil
case "4h":
return now.Add(-4 * time.Hour), "%Y-%m-%d %H:%M", nil
case "24h":
return now.Add(-24 * time.Hour), "%Y-%m-%d %H:00", nil
case "7d":
return now.Add(-7 * 24 * time.Hour), "%Y-%m-%d", nil
case "30d":
return now.Add(-30 * 24 * time.Hour), "%Y-%m-%d", nil
default:
return time.Time{}, "", fmt.Errorf("invalid span: %s (valid: 1h, 4h, 24h, 7d, 30d)", span)
}
}
// Timeline handles GET /api/analytics/timeline?span=24h.
func (h *AnalyticsHandler) Timeline(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, strftimeFmt, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
// For 1h and 4h spans, bucket by 5-minute intervals using SQL expression
var query string
if span == "1h" || span == "4h" {
// Round minutes down to nearest 5 using printf
query = `SELECT strftime('%Y-%m-%d %H:', created_at) || printf('%02d', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
} else {
query = `SELECT strftime(?, created_at) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
}
type bucket struct {
Time string `json:"time"`
Count int `json:"count"`
}
var buckets []bucket
var total int
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
var rows *sql.Rows
if span == "1h" || span == "4h" {
rows, err = h.db.QueryContext(r.Context(), query, cutoffStr)
} else {
rows, err = h.db.QueryContext(r.Context(), query, strftimeFmt, cutoffStr)
}
if err != nil {
h.logger.Error("timeline query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query timeline"))
return
}
defer rows.Close()
for rows.Next() {
var b bucket
if err := rows.Scan(&b.Time, &b.Count); err != nil {
h.logger.Error("timeline scan failed", "error", err)
continue
}
buckets = append(buckets, b)
total += b.Count
}
if err := rows.Err(); err != nil {
h.logger.Error("timeline rows error", "error", err)
}
if buckets == nil {
buckets = []bucket{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"buckets": buckets,
"total": total,
})
}
// TopAgents handles GET /api/analytics/top-agents?span=24h&limit=5.
func (h *AnalyticsHandler) TopAgents(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, _, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
limit := 5
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
if limit > 100 {
limit = 100
}
}
}
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
rows, err := h.db.QueryContext(r.Context(),
`SELECT from_agent, COUNT(*) as count FROM messages WHERE created_at >= ? AND from_agent != '' GROUP BY from_agent ORDER BY count DESC LIMIT ?`,
cutoffStr, limit,
)
if err != nil {
h.logger.Error("top-agents query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top agents"))
return
}
defer rows.Close()
type agentStat struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Count int `json:"count"`
}
var agentStats []agentStat
for rows.Next() {
var s agentStat
if err := rows.Scan(&s.Name, &s.Count); err != nil {
h.logger.Error("top-agents scan failed", "error", err)
continue
}
// Look up display name from agent service
if h.agentService != nil {
if agent, err := h.agentService.GetAgent(r.Context(), s.Name); err == nil {
s.DisplayName = agent.DisplayName
}
}
if s.DisplayName == "" {
s.DisplayName = s.Name
}
agentStats = append(agentStats, s)
}
if err := rows.Err(); err != nil {
h.logger.Error("top-agents rows error", "error", err)
}
if agentStats == nil {
agentStats = []agentStat{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"agents": agentStats,
})
}
// TopChannels handles GET /api/analytics/top-channels?span=24h&limit=5.
func (h *AnalyticsHandler) TopChannels(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, _, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
limit := 5
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
if limit > 100 {
limit = 100
}
}
}
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
rows, err := h.db.QueryContext(r.Context(),
`SELECT c.name, COUNT(*) as count FROM messages m JOIN channels c ON m.channel_id = c.id WHERE m.created_at >= ? AND m.channel_id IS NOT NULL GROUP BY c.name ORDER BY count DESC LIMIT ?`,
cutoffStr, limit,
)
if err != nil {
h.logger.Error("top-channels query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top channels"))
return
}
defer rows.Close()
type channelStat struct {
Name string `json:"name"`
Count int `json:"count"`
}
var channelStats []channelStat
for rows.Next() {
var s channelStat
if err := rows.Scan(&s.Name, &s.Count); err != nil {
h.logger.Error("top-channels scan failed", "error", err)
continue
}
channelStats = append(channelStats, s)
}
if err := rows.Err(); err != nil {
h.logger.Error("top-channels rows error", "error", err)
}
if channelStats == nil {
channelStats = []channelStat{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"channels": channelStats,
})
}
// Summary handles GET /api/analytics/summary.
func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) {
var totalAgents, totalChannels, totalMessages int
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&totalAgents); err != nil {
h.logger.Error("summary agents count failed", "error", err)
}
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM channels`).Scan(&totalChannels); err != nil {
h.logger.Error("summary channels count failed", "error", err)
}
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM messages`).Scan(&totalMessages); err != nil {
h.logger.Error("summary messages count failed", "error", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"total_agents": totalAgents,
"total_channels": totalChannels,
"total_messages": totalMessages,
})
}
+556
View File
@@ -0,0 +1,556 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
)
func newAnalyticsTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:analytics_%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
// Seed test users
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test User')`)
return db
}
func seedAnalyticsAgent(t *testing.T, db *sql.DB, name, displayName string, ownerID int64) {
t.Helper()
_, err := db.Exec(
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'testhash', 'active')`,
name, displayName, ownerID,
)
if err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
func seedAnalyticsChannel(t *testing.T, db *sql.DB, name, createdBy string) int64 {
t.Helper()
result, err := db.Exec(
`INSERT INTO channels (name, description, type, is_private, created_by, created_at, updated_at) VALUES (?, '', 'standard', 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
name, createdBy,
)
if err != nil {
t.Fatalf("seed channel %s: %v", name, err)
}
id, _ := result.LastInsertId()
return id
}
func seedAnalyticsMessage(t *testing.T, db *sql.DB, from, to, body string, channelID *int64, createdAt time.Time) {
t.Helper()
// Ensure a conversation exists
result, err := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, ?, ?)`,
from, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
)
if err != nil {
t.Fatalf("seed conversation: %v", err)
}
convID, _ := result.LastInsertId()
_, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 5, 'pending', '{}', ?, ?)`,
convID, from, to, channelID, body, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
)
if err != nil {
t.Fatalf("seed message: %v", err)
}
}
func setupAnalyticsRouter(t *testing.T, db *sql.DB) chi.Router {
t.Helper()
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, nil)
channelStore := channels.NewSQLiteChannelStore(db)
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, nil)
channelService := channels.NewService(channelStore, msgService, nil)
analyticsHandler := NewAnalyticsHandler(db, agentService, channelService)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
r.Get("/api/analytics/summary", analyticsHandler.Summary)
})
return router
}
func analyticsRequest(t *testing.T, router chi.Router, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestAnalyticsTimeline(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
now := time.Now().UTC()
// Seed messages at various times within the last 24h
for i := 0; i < 5; i++ {
seedAnalyticsMessage(t, db, "agent-a", "agent-b", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Hour))
}
router := setupAnalyticsRouter(t, db)
tests := []struct {
name string
span string
wantStatus int
wantNonEmpty bool
}{
{
name: "default span (24h)",
span: "",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "1h span",
span: "1h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "4h span",
span: "4h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "24h span",
span: "24h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "7d span",
span: "7d",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "30d span",
span: "30d",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "invalid span",
span: "99x",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/api/analytics/timeline"
if tt.span != "" {
path += "?span=" + tt.span
}
rr := analyticsRequest(t, router, "GET", path)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantStatus != http.StatusOK {
return
}
var resp struct {
Span string `json:"span"`
Buckets []struct {
Time string `json:"time"`
Count int `json:"count"`
} `json:"buckets"`
Total int `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
expectedSpan := tt.span
if expectedSpan == "" {
expectedSpan = "24h"
}
if resp.Span != expectedSpan {
t.Errorf("span = %q, want %q", resp.Span, expectedSpan)
}
if tt.wantNonEmpty && resp.Total == 0 {
t.Error("expected non-zero total")
}
// Verify total matches sum of bucket counts
bucketSum := 0
for _, b := range resp.Buckets {
bucketSum += b.Count
}
if bucketSum != resp.Total {
t.Errorf("bucket sum %d != total %d", bucketSum, resp.Total)
}
})
}
}
func TestAnalyticsTimeline_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/timeline?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Span string `json:"span"`
Buckets []any `json:"buckets"`
Total int `json:"total"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.Total != 0 {
t.Errorf("total = %d, want 0", resp.Total)
}
if len(resp.Buckets) != 0 {
t.Errorf("buckets = %d, want 0", len(resp.Buckets))
}
}
func TestAnalyticsTopAgents(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-alpha", "Alpha Agent", 1)
seedAnalyticsAgent(t, db, "agent-beta", "Beta Agent", 1)
now := time.Now().UTC()
// agent-alpha sends 5 messages, agent-beta sends 2
for i := 0; i < 5; i++ {
seedAnalyticsMessage(t, db, "agent-alpha", "agent-beta", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
}
for i := 0; i < 2; i++ {
seedAnalyticsMessage(t, db, "agent-beta", "agent-alpha", fmt.Sprintf("reply-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
}
router := setupAnalyticsRouter(t, db)
t.Run("returns agents sorted by count", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Span string `json:"span"`
Agents []struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Count int `json:"count"`
} `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) < 2 {
t.Fatalf("agents = %d, want >= 2", len(resp.Agents))
}
// First agent should be alpha (5 messages)
if resp.Agents[0].Name != "agent-alpha" {
t.Errorf("top agent = %q, want agent-alpha", resp.Agents[0].Name)
}
if resp.Agents[0].Count != 5 {
t.Errorf("top agent count = %d, want 5", resp.Agents[0].Count)
}
if resp.Agents[0].DisplayName != "Alpha Agent" {
t.Errorf("display_name = %q, want 'Alpha Agent'", resp.Agents[0].DisplayName)
}
// Second agent should be beta (2 messages)
if resp.Agents[1].Name != "agent-beta" {
t.Errorf("second agent = %q, want agent-beta", resp.Agents[1].Name)
}
if resp.Agents[1].Count != 2 {
t.Errorf("second agent count = %d, want 2", resp.Agents[1].Count)
}
})
t.Run("respects limit parameter", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h&limit=1")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Agents []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) != 1 {
t.Errorf("agents = %d, want 1", len(resp.Agents))
}
})
t.Run("invalid span returns error", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=invalid")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
})
}
func TestAnalyticsTopAgents_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Agents []any `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) != 0 {
t.Errorf("agents = %d, want 0", len(resp.Agents))
}
}
func TestAnalyticsTopChannels(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
chID1 := seedAnalyticsChannel(t, db, "news-mcp", "agent-a")
chID2 := seedAnalyticsChannel(t, db, "general", "agent-a")
now := time.Now().UTC()
// news-mcp gets 4 messages, general gets 2
for i := 0; i < 4; i++ {
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("ch-msg-%d", i), &chID1, now.Add(-time.Duration(i)*time.Minute))
}
for i := 0; i < 2; i++ {
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("gen-msg-%d", i), &chID2, now.Add(-time.Duration(i)*time.Minute))
}
router := setupAnalyticsRouter(t, db)
t.Run("returns channels sorted by count", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Span string `json:"span"`
Channels []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) < 2 {
t.Fatalf("channels = %d, want >= 2", len(resp.Channels))
}
if resp.Channels[0].Name != "news-mcp" {
t.Errorf("top channel = %q, want news-mcp", resp.Channels[0].Name)
}
if resp.Channels[0].Count != 4 {
t.Errorf("top channel count = %d, want 4", resp.Channels[0].Count)
}
if resp.Channels[1].Name != "general" {
t.Errorf("second channel = %q, want general", resp.Channels[1].Name)
}
if resp.Channels[1].Count != 2 {
t.Errorf("second channel count = %d, want 2", resp.Channels[1].Count)
}
})
t.Run("respects limit parameter", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h&limit=1")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Channels []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 1 {
t.Errorf("channels = %d, want 1", len(resp.Channels))
}
})
t.Run("invalid span returns error", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=invalid")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
})
}
func TestAnalyticsTopChannels_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Channels []any `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 0 {
t.Errorf("channels = %d, want 0", len(resp.Channels))
}
}
func TestAnalyticsSummary(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
chID := seedAnalyticsChannel(t, db, "test-channel", "agent-a")
now := time.Now().UTC()
seedAnalyticsMessage(t, db, "agent-a", "agent-b", "hello", nil, now)
seedAnalyticsMessage(t, db, "agent-b", "agent-a", "hi back", nil, now)
seedAnalyticsMessage(t, db, "agent-a", "", "channel msg", &chID, now)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
TotalAgents int `json:"total_agents"`
TotalChannels int `json:"total_channels"`
TotalMessages int `json:"total_messages"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.TotalAgents != 2 {
t.Errorf("total_agents = %d, want 2", resp.TotalAgents)
}
if resp.TotalChannels != 1 {
t.Errorf("total_channels = %d, want 1", resp.TotalChannels)
}
if resp.TotalMessages != 3 {
t.Errorf("total_messages = %d, want 3", resp.TotalMessages)
}
}
func TestAnalyticsSummary_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
TotalAgents int `json:"total_agents"`
TotalChannels int `json:"total_channels"`
TotalMessages int `json:"total_messages"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.TotalAgents != 0 {
t.Errorf("total_agents = %d, want 0", resp.TotalAgents)
}
if resp.TotalChannels != 0 {
t.Errorf("total_channels = %d, want 0", resp.TotalChannels)
}
if resp.TotalMessages != 0 {
t.Errorf("total_messages = %d, want 0", resp.TotalMessages)
}
}
func TestAnalytics_Unauthenticated(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
endpoints := []string{
"/api/analytics/timeline",
"/api/analytics/top-agents",
"/api/analytics/top-channels",
"/api/analytics/summary",
}
for _, endpoint := range endpoints {
t.Run(endpoint, func(t *testing.T) {
req := httptest.NewRequest("GET", endpoint, nil)
// No X-Owner-ID header
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
})
}
}
+2
View File
@@ -137,6 +137,8 @@ func (h *AttachmentsHandler) Upload(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"empty file not allowed"}`, http.StatusBadRequest)
case attachments.ErrFileTooLarge:
http.Error(w, `{"error":"file exceeds maximum size of 50MB"}`, http.StatusRequestEntityTooLarge)
case attachments.ErrUnsupportedType:
http.Error(w, `{"error":"unsupported file type: only images, PDFs, and text files are allowed"}`, http.StatusBadRequest)
default:
h.logger.Error("upload attachment failed", "error", err)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"context"
"log/slog"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
// NewMessageEvent is broadcast when a new message is sent.
type NewMessageEvent struct {
Channel string `json:"channel,omitempty"` // set for channel messages
FromAgent string `json:"from_agent,omitempty"` // set for DMs
ToAgent string `json:"to_agent,omitempty"` // set for DMs
MessageID int64 `json:"message_id"`
}
// UnreadUpdateEvent is broadcast when unread counts change (e.g. mark-read).
type UnreadUpdateEvent struct {
Channel string `json:"channel,omitempty"`
Agent string `json:"agent,omitempty"`
UnreadCount int `json:"unread_count"`
}
// EventBroadcaster broadcasts real-time events to connected SSE clients.
type EventBroadcaster interface {
BroadcastNewMessage(ctx context.Context, ownerID int64, event NewMessageEvent)
BroadcastUnreadUpdate(ctx context.Context, ownerID int64, event UnreadUpdateEvent)
}
// SSEBroadcaster implements EventBroadcaster using the SSEHub.
type SSEBroadcaster struct {
hub *SSEHub
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewSSEBroadcaster creates a broadcaster that sends events via SSE.
func NewSSEBroadcaster(hub *SSEHub, agentService *agents.AgentService, channelService *channels.Service) *SSEBroadcaster {
return &SSEBroadcaster{
hub: hub,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.broadcaster"),
}
}
// BroadcastNewMessage sends a new_message event to the given owner.
func (b *SSEBroadcaster) BroadcastNewMessage(_ context.Context, ownerID int64, event NewMessageEvent) {
b.hub.Broadcast(ownerID, SSEEvent{
Type: "new_message",
Data: event,
})
}
// BroadcastUnreadUpdate sends an unread_update event to the given owner.
func (b *SSEBroadcaster) BroadcastUnreadUpdate(_ context.Context, ownerID int64, event UnreadUpdateEvent) {
b.hub.Broadcast(ownerID, SSEEvent{
Type: "unread_update",
Data: event,
})
}
// BroadcastDM broadcasts a new_message event for a direct message.
// It resolves the recipient agent's owner and sends the event to them.
func (b *SSEBroadcaster) BroadcastDM(ctx context.Context, msg NewMessageEvent) {
if msg.ToAgent == "" {
return
}
agent, err := b.agentService.GetAgent(ctx, msg.ToAgent)
if err != nil {
b.logger.Debug("could not resolve recipient owner for SSE broadcast",
"to_agent", msg.ToAgent, "error", err)
return
}
b.BroadcastNewMessage(ctx, agent.OwnerID, msg)
}
// BroadcastChannelMessage broadcasts a new_message event for a channel message.
// It resolves all channel members' owners and sends the event to each unique owner.
func (b *SSEBroadcaster) BroadcastChannelMessage(ctx context.Context, channelID int64, msg NewMessageEvent) {
if b.channelService == nil {
return
}
members, err := b.channelService.GetMembers(ctx, channelID)
if err != nil {
b.logger.Debug("could not get channel members for SSE broadcast",
"channel_id", channelID, "error", err)
return
}
// Collect unique owner IDs to avoid duplicate broadcasts.
seen := make(map[int64]bool)
for _, m := range members {
agent, err := b.agentService.GetAgent(ctx, m.AgentName)
if err != nil {
continue
}
if !seen[agent.OwnerID] {
seen[agent.OwnerID] = true
b.BroadcastNewMessage(ctx, agent.OwnerID, msg)
}
}
}
// OnMessageSent implements messaging.MessageListener so the SSEBroadcaster
// can be wired directly into the messaging service. This ensures SSE events
// fire for messages sent via MCP (agents) as well as the REST API.
func (b *SSEBroadcaster) OnMessageSent(ctx context.Context, msg *messaging.Message) {
event := NewMessageEvent{
MessageID: msg.ID,
FromAgent: msg.FromAgent,
ToAgent: msg.ToAgent,
}
if msg.ChannelID != nil {
ch, err := b.channelService.GetChannel(ctx, *msg.ChannelID)
if err == nil {
event.Channel = ch.Name
}
b.BroadcastChannelMessage(ctx, *msg.ChannelID, event)
} else {
b.BroadcastDM(ctx, event)
}
}
+160 -8
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@@ -15,10 +16,16 @@ import (
// ChannelsHandler handles REST API requests for channels.
type ChannelsHandler struct {
channelService *channels.Service
agentService *agents.AgentService
msgService *messaging.MessagingService
logger *slog.Logger
channelService *channels.Service
agentService *agents.AgentService
msgService *messaging.MessagingService
reactionService ChannelReactionService
logger *slog.Logger
}
// ChannelReactionService is the subset of reactions.Service needed by ChannelsHandler.
type ChannelReactionService interface {
ListByState(ctx context.Context, channelID int64, state string) ([]int64, error)
}
// NewChannelsHandler creates a new channels handler.
@@ -31,6 +38,11 @@ func NewChannelsHandler(channelService *channels.Service, agentService *agents.A
}
}
// SetReactionService sets the reaction service for workflow state queries.
func (h *ChannelsHandler) SetReactionService(svc ChannelReactionService) {
h.reactionService = svc
}
// ListChannels handles GET /api/channels.
func (h *ChannelsHandler) ListChannels(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
@@ -201,7 +213,7 @@ func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) {
// ChannelMessages handles GET /api/channels/{name}/messages.
func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
@@ -221,16 +233,32 @@ func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request
}
}
msgs, err := h.msgService.GetChannelMessages(r.Context(), ch.ID, limit)
paginated, err := h.msgService.GetChannelMessages(r.Context(), ch.ID, limit, 0)
if err != nil {
h.logger.Error("get channel messages failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get messages"))
return
}
// Enrich messages with reply counts and attachments.
h.msgService.EnrichMessages(r.Context(), paginated.Messages)
// Compute last_read_message_id across owned agents
var lastReadMessageID int64
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err == nil {
for _, agent := range ownedAgents {
lr, err := h.msgService.GetLastReadForChannel(r.Context(), agent.Name, ch.ID)
if err == nil && lr > lastReadMessageID {
lastReadMessageID = lr
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
"messages": paginated.Messages,
"total": paginated.Total,
"last_read_message_id": lastReadMessageID,
})
}
@@ -282,3 +310,127 @@ func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "left"})
}
// UpdateSettings handles PUT /api/channels/{name}/settings.
func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
var req struct {
WorkflowEnabled *bool `json:"workflow_enabled"`
AutoApprove *bool `json:"auto_approve"`
StalemateRemindAfter *string `json:"stalemate_remind_after"`
StalemateEscalateAfter *string `json:"stalemate_escalate_after"`
PublishThreshold *float64 `json:"publish_threshold"`
ApproveThreshold *float64 `json:"approve_threshold"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
settings := channels.ChannelSettings{
WorkflowEnabled: ch.WorkflowEnabled,
AutoApprove: ch.AutoApprove,
StalemateRemindAfter: ch.StalemateRemindAfter,
StalemateEscalateAfter: ch.StalemateEscalateAfter,
PublishThreshold: ch.PublishThreshold,
ApproveThreshold: ch.ApproveThreshold,
}
if req.WorkflowEnabled != nil {
settings.WorkflowEnabled = *req.WorkflowEnabled
}
if req.AutoApprove != nil {
settings.AutoApprove = *req.AutoApprove
}
if req.StalemateRemindAfter != nil {
settings.StalemateRemindAfter = *req.StalemateRemindAfter
}
if req.StalemateEscalateAfter != nil {
settings.StalemateEscalateAfter = *req.StalemateEscalateAfter
}
if req.PublishThreshold != nil {
settings.PublishThreshold = *req.PublishThreshold
}
if req.ApproveThreshold != nil {
settings.ApproveThreshold = *req.ApproveThreshold
}
updated, err := h.channelService.UpdateChannelSettings(r.Context(), ch.ID, settings)
if err != nil {
h.logger.Error("update channel settings failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to update channel settings"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"channel": updated})
}
// ListByState handles GET /api/channels/{name}/messages/by-state?state=X.
func (h *ChannelsHandler) ListByState(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
if h.reactionService == nil {
writeJSON(w, http.StatusServiceUnavailable, errorBody("unavailable", "Reactions service not configured"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
state := r.URL.Query().Get("state")
if state == "" {
writeJSON(w, http.StatusBadRequest, errorBody("missing_state", "Query parameter 'state' is required"))
return
}
ids, err := h.reactionService.ListByState(r.Context(), ch.ID, state)
if err != nil {
h.logger.Error("list by state failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("invalid_state", err.Error()))
return
}
// Load messages by IDs
var messages []*messaging.Message
for _, id := range ids {
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
continue
}
messages = append(messages, msg)
}
if messages == nil {
messages = []*messaging.Message{}
}
// Enrich messages with reactions, reply counts, attachments
h.msgService.EnrichMessages(r.Context(), messages)
writeJSON(w, http.StatusOK, map[string]any{
"messages": messages,
"state": state,
"total": len(messages),
})
}
+103 -17
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -18,9 +19,15 @@ import (
type MessagesHandler struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
broadcaster *SSEBroadcaster
logger *slog.Logger
}
// SetBroadcaster sets the event broadcaster for real-time SSE notifications.
func (h *MessagesHandler) SetBroadcaster(b *SSEBroadcaster) {
h.broadcaster = b
}
// NewMessagesHandler creates a new messages handler.
func NewMessagesHandler(msgService *messaging.MessagingService, agentService *agents.AgentService) *MessagesHandler {
return &MessagesHandler{
@@ -67,18 +74,20 @@ func (h *MessagesHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
IncludeRead: true,
Status: status,
}
msgs, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
result, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
if err != nil {
h.logger.Error("read inbox failed", "agent", agent.Name, "error", err)
continue
}
allMessages = append(allMessages, msgs...)
allMessages = append(allMessages, result.Messages...)
}
if allMessages == nil {
allMessages = []*messaging.Message{}
}
h.msgService.EnrichMessages(r.Context(), allMessages)
sortMessagesByTime(allMessages)
if len(allMessages) > limit {
allMessages = allMessages[:limit]
@@ -115,6 +124,8 @@ func (h *MessagesHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
writeJSON(w, http.StatusOK, msg)
}
@@ -153,11 +164,11 @@ func (h *MessagesHandler) ListConversations(w http.ResponseWriter, r *http.Reque
Limit: 100,
IncludeRead: true,
}
msgs, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
result, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
if err != nil {
continue
}
for _, msg := range msgs {
for _, msg := range result.Messages {
existing, exists := convMap[msg.ConversationID]
if !exists {
convMap[msg.ConversationID] = &convSummary{
@@ -223,6 +234,8 @@ func (h *MessagesHandler) GetConversation(w http.ResponseWriter, r *http.Request
return
}
h.msgService.EnrichMessages(r.Context(), messages)
writeJSON(w, http.StatusOK, map[string]any{
"conversation": conv,
"messages": messages,
@@ -238,14 +251,15 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
}
var req struct {
From string `json:"from"`
To string `json:"to"`
Body string `json:"body"`
Priority int `json:"priority"`
ChannelID *int64 `json:"channel_id,omitempty"`
ConversationID *int64 `json:"conversation_id,omitempty"`
Subject string `json:"subject,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
From string `json:"from"`
To string `json:"to"`
Body string `json:"body"`
Priority int `json:"priority"`
ChannelID *int64 `json:"channel_id,omitempty"`
ConversationID *int64 `json:"conversation_id,omitempty"`
Subject string `json:"subject,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
Attachments []string `json:"attachments,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -300,6 +314,7 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
ConversationID: req.ConversationID,
Subject: req.Subject,
ReplyTo: req.ReplyTo,
Attachments: req.Attachments,
}
msg, err := h.msgService.SendMessage(r.Context(), req.From, req.To, req.Body, opts)
@@ -309,6 +324,11 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
return
}
// SSE broadcast is handled by the MessageListener on the messaging
// service, so it fires for both REST and MCP message paths.
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
writeJSON(w, http.StatusCreated, msg)
}
@@ -390,20 +410,73 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
limit = 20
}
// Parse advanced filter parameters
channelFilter := r.URL.Query().Get("channel")
agentFilter := r.URL.Query().Get("agent")
afterFilter := r.URL.Query().Get("after")
beforeFilter := r.URL.Query().Get("before")
var allMessages []*messaging.Message
for _, agent := range ownedAgents {
opts := messaging.SearchOptions{Limit: limit}
msgs, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
opts := messaging.SearchOptions{
Limit: limit,
After: afterFilter,
Before: beforeFilter,
}
// Apply channel filters (comma-separated, prefix with - to exclude)
if channelFilter != "" {
channels := strings.Split(channelFilter, ",")
var includeChannels []string
var excludeChannels []string
for _, ch := range channels {
ch = strings.TrimSpace(ch)
if ch == "" {
continue
}
if strings.HasPrefix(ch, "-") {
excludeChannels = append(excludeChannels, strings.TrimPrefix(ch, "-"))
} else {
includeChannels = append(includeChannels, ch)
}
}
opts.Channels = includeChannels
opts.ExcludeChannels = excludeChannels
}
// Apply agent filters (comma-separated, prefix with - to exclude)
if agentFilter != "" {
agentNames := strings.Split(agentFilter, ",")
var includeAgents []string
var excludeAgents []string
for _, a := range agentNames {
a = strings.TrimSpace(a)
if a == "" {
continue
}
if strings.HasPrefix(a, "-") {
excludeAgents = append(excludeAgents, strings.TrimPrefix(a, "-"))
} else {
includeAgents = append(includeAgents, a)
}
}
opts.Agents = includeAgents
opts.ExcludeAgents = excludeAgents
}
result, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
if err != nil {
continue
}
allMessages = append(allMessages, msgs...)
allMessages = append(allMessages, result.Messages...)
}
if allMessages == nil {
allMessages = []*messaging.Message{}
}
h.msgService.EnrichMessages(r.Context(), allMessages)
writeJSON(w, http.StatusOK, map[string]any{
"messages": allMessages,
"query": query,
@@ -444,6 +517,8 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
return
}
h.msgService.EnrichMessages(r.Context(), replies)
writeJSON(w, http.StatusOK, map[string]any{
"replies": replies,
"total": len(replies),
@@ -489,9 +564,20 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
return
}
// Reverse to chronological order (query returns newest first for correct LIMIT behavior)
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
h.msgService.EnrichMessages(r.Context(), msgs)
// Include last_read_message_id for the human agent's DM with the peer
lastRead, _ := h.msgService.GetLastReadForDM(r.Context(), agentNames, peerAgent)
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
"messages": msgs,
"total": len(msgs),
"last_read_message_id": lastRead,
})
}
+203
View File
@@ -0,0 +1,203 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
// NotificationsHandler handles REST API requests for notification badges.
type NotificationsHandler struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewNotificationsHandler creates a new notifications handler.
func NewNotificationsHandler(msgService *messaging.MessagingService, agentService *agents.AgentService, channelService *channels.Service) *NotificationsHandler {
return &NotificationsHandler{
msgService: msgService,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.notifications"),
}
}
// channelUnread is the JSON shape for a channel's unread info.
type channelUnread struct {
Name string `json:"name"`
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// dmUnread is the JSON shape for a DM peer's unread info.
type dmUnread struct {
Agent string `json:"agent"`
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// UnreadCounts handles GET /api/notifications/unread.
func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
// Use only the human agent's perspective for notification counts.
// This avoids system/AI agents inflating unread counts.
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil || humanAgent == nil {
writeJSON(w, http.StatusOK, map[string]any{
"channels": []channelUnread{},
"dms": []dmUnread{},
"total_unread": 0,
})
return
}
// Channel summaries for the human agent
channelsList := []channelUnread{}
if h.channelService != nil {
summaries, err := h.channelService.GetChannelSummaries(r.Context(), humanAgent.Name)
if err != nil {
h.logger.Error("get channel summaries failed", "agent", humanAgent.Name, "error", err)
} else {
for _, cs := range summaries {
channelsList = append(channelsList, channelUnread{
Name: cs.Name,
UnreadCount: cs.UnreadCount,
LastMessageID: cs.LastMessageID,
})
}
}
}
// DM unread counts for the human agent
dmMap := make(map[string]*dmUnread)
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), humanAgent.Name)
if err != nil {
h.logger.Error("get dm unread counts failed", "agent", humanAgent.Name, "error", err)
} else {
for _, dc := range counts {
dmMap[dc.Agent] = &dmUnread{
Agent: dc.Agent,
UnreadCount: dc.UnreadCount,
LastMessageID: dc.LastMessageID,
}
}
}
dmsList := make([]dmUnread, 0, len(dmMap))
for _, du := range dmMap {
dmsList = append(dmsList, *du)
}
totalUnread := 0
for _, cu := range channelsList {
totalUnread += cu.UnreadCount
}
for _, du := range dmsList {
totalUnread += du.UnreadCount
}
writeJSON(w, http.StatusOK, map[string]any{
"channels": channelsList,
"dms": dmsList,
"total_unread": totalUnread,
})
}
// MarkRead handles POST /api/notifications/mark-read.
func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
Type string `json:"type"`
Target string `json:"target"`
LastMessageID int64 `json:"last_message_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Type == "" || req.Target == "" || req.LastMessageID <= 0 {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type, target, and last_message_id are required"))
return
}
if req.Type != "channel" && req.Type != "dm" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "type must be 'channel' or 'dm'"))
return
}
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil || humanAgent == nil {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No human agent found"))
return
}
agentNames := []string{humanAgent.Name}
if req.Type == "channel" {
if h.channelService == nil {
writeJSON(w, http.StatusBadRequest, errorBody("not_available", "Channel service not available"))
return
}
ch, err := h.channelService.GetChannelByName(r.Context(), req.Target)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
convIDs, err := h.msgService.GetConversationIDsForChannel(r.Context(), ch.ID, req.LastMessageID)
if err != nil {
h.logger.Error("get conversation ids failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
return
}
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", humanAgent.Name,
"conversation_id", convID,
"error", err,
)
}
}
} else {
// DM mark-read
convIDs, err := h.msgService.GetConversationIDsForDM(r.Context(), agentNames, req.Target, req.LastMessageID)
if err != nil {
h.logger.Error("get dm conversation ids failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get conversations"))
return
}
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", humanAgent.Name,
"conversation_id", convID,
"error", err,
)
}
}
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+373
View File
@@ -0,0 +1,373 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
func setupNotificationsRouter(t *testing.T) (chi.Router, *messaging.MessagingService, *agents.AgentService, *channels.Service) {
t.Helper()
db := newTestDBFull(t)
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, nil)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, nil)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, nil)
// Seed agents — human-agent must be type 'human' for GetHumanAgentForUser
seedTestAgentWithType(t, db, "human-agent", "human", 1)
seedTestAgent(t, db, "bot-alice", 2)
seedTestAgent(t, db, "bot-bob", 2)
handler := NewNotificationsHandler(msgService, agentService, channelService)
messagesHandler := NewMessagesHandler(msgService, agentService)
channelsHandler := NewChannelsHandler(channelService, agentService, msgService)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Get("/api/notifications/unread", handler.UnreadCounts)
r.Post("/api/notifications/mark-read", handler.MarkRead)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
})
return router, msgService, agentService, channelService
}
func TestUnreadCounts_Unauthenticated(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
}
func TestUnreadCounts_NoAgents(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
// Owner 99 has no agents
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "99")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Channels []channelUnread `json:"channels"`
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 0 {
t.Errorf("channels = %d, want 0", len(resp.Channels))
}
if len(resp.DMs) != 0 {
t.Errorf("dms = %d, want 0", len(resp.DMs))
}
if resp.TotalUnread != 0 {
t.Errorf("total_unread = %d, want 0", resp.TotalUnread)
}
}
func TestUnreadCounts_WithDMs(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// bot-alice sends DMs to human-agent (owner 1)
_, err := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Hello from Alice", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
_, err = msgService.SendMessage(ctx, "bot-alice", "human-agent", "Second message from Alice", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
// bot-bob sends a DM to human-agent
_, err = msgService.SendMessage(ctx, "bot-bob", "human-agent", "Hello from Bob", messaging.SendOptions{Subject: "dm"})
if err != nil {
t.Fatalf("send message: %v", err)
}
req := httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Channels []channelUnread `json:"channels"`
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.DMs) != 2 {
t.Fatalf("dms = %d, want 2; body: %s", len(resp.DMs), rr.Body.String())
}
// Find alice and bob counts
dmsByAgent := make(map[string]dmUnread)
for _, dm := range resp.DMs {
dmsByAgent[dm.Agent] = dm
}
if alice, ok := dmsByAgent["bot-alice"]; !ok {
t.Error("expected DM from bot-alice")
} else if alice.UnreadCount != 2 {
t.Errorf("bot-alice unread = %d, want 2", alice.UnreadCount)
}
if bob, ok := dmsByAgent["bot-bob"]; !ok {
t.Error("expected DM from bot-bob")
} else if bob.UnreadCount != 1 {
t.Errorf("bot-bob unread = %d, want 1", bob.UnreadCount)
}
if resp.TotalUnread != 3 {
t.Errorf("total_unread = %d, want 3", resp.TotalUnread)
}
}
func TestMarkRead_DM(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// bot-alice sends 3 DMs to human-agent
msg1, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 1", messaging.SendOptions{Subject: "dm"})
_, _ = msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 2", messaging.SendOptions{Subject: "dm"})
msg3, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Message 3", messaging.SendOptions{Subject: "dm"})
// Mark read up to msg1
body, _ := json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg1.ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
// Check unread — should have 2 unread still
req = httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
var resp struct {
DMs []dmUnread `json:"dms"`
TotalUnread int `json:"total_unread"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.DMs) != 1 {
t.Fatalf("dms = %d, want 1; body: %s", len(resp.DMs), rr.Body.String())
}
if resp.DMs[0].UnreadCount != 2 {
t.Errorf("unread after mark-read = %d, want 2", resp.DMs[0].UnreadCount)
}
// Mark all read up to msg3
body, _ = json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg3.ID,
})
req = httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, want %d", rr.Code, http.StatusOK)
}
// Check unread — should be 0
req = httptest.NewRequest("GET", "/api/notifications/unread", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.TotalUnread != 0 {
t.Errorf("total_unread after marking all read = %d, want 0; body: %s", resp.TotalUnread, rr.Body.String())
}
}
func TestMarkRead_Validation(t *testing.T) {
router, _, _, _ := setupNotificationsRouter(t)
tests := []struct {
name string
body map[string]any
want int
}{
{
name: "missing type",
body: map[string]any{"target": "foo", "last_message_id": 1},
want: http.StatusBadRequest,
},
{
name: "missing target",
body: map[string]any{"type": "dm", "last_message_id": 1},
want: http.StatusBadRequest,
},
{
name: "missing last_message_id",
body: map[string]any{"type": "dm", "target": "foo"},
want: http.StatusBadRequest,
},
{
name: "invalid type",
body: map[string]any{"type": "invalid", "target": "foo", "last_message_id": 1},
want: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tt.want {
t.Errorf("status = %d, want %d, body: %s", rr.Code, tt.want, rr.Body.String())
}
})
}
}
func TestDMMessages_IncludesLastRead(t *testing.T) {
router, msgService, _, _ := setupNotificationsRouter(t)
ctx := t.Context()
// Send DMs from bot-alice to human-agent
msg1, _ := msgService.SendMessage(ctx, "bot-alice", "human-agent", "Hello", messaging.SendOptions{Subject: "dm"})
_, _ = msgService.SendMessage(ctx, "bot-alice", "human-agent", "World", messaging.SendOptions{Subject: "dm"})
// Mark read up to msg1
body, _ := json.Marshal(map[string]any{
"type": "dm",
"target": "bot-alice",
"last_message_id": msg1.ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d", rr.Code)
}
// GET DM messages should include last_read_message_id
req = httptest.NewRequest("GET", "/api/agents/bot-alice/messages", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("dm messages status = %d, body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
lastRead, ok := resp["last_read_message_id"]
if !ok {
t.Fatal("response missing last_read_message_id")
}
if int64(lastRead.(float64)) != msg1.ID {
t.Errorf("last_read_message_id = %v, want %d", lastRead, msg1.ID)
}
}
func TestChannelMessages_IncludesLastRead(t *testing.T) {
router, _, _, channelService := setupNotificationsRouter(t)
ctx := t.Context()
// Create a channel and have human-agent join
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "test-channel",
CreatedBy: "human-agent",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
// Broadcast messages
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "", nil, nil)
if err != nil {
t.Fatalf("broadcast: %v", err)
}
// Mark read up to the first channel message
body, _ := json.Marshal(map[string]any{
"type": "channel",
"target": "test-channel",
"last_message_id": msgs[0].ID,
})
req := httptest.NewRequest("POST", "/api/notifications/mark-read", bytes.NewReader(body))
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark-read status = %d, body: %s", rr.Code, rr.Body.String())
}
// GET channel messages should include last_read_message_id
req = httptest.NewRequest("GET", "/api/channels/test-channel/messages", nil)
req.Header.Set("X-Owner-ID", "1")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("channel messages status = %d, body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
_, ok := resp["last_read_message_id"]
if !ok {
t.Fatal("response missing last_read_message_id")
}
}
+134
View File
@@ -0,0 +1,134 @@
package api
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/onboarding"
)
// OnboardingHandler handles REST API requests for agent onboarding.
type OnboardingHandler struct {
agentService *agents.AgentService
channelService *channels.Service
baseURL string
logger *slog.Logger
}
// NewOnboardingHandler creates a new onboarding handler.
func NewOnboardingHandler(agentService *agents.AgentService, channelService *channels.Service, baseURL string) *OnboardingHandler {
return &OnboardingHandler{
agentService: agentService,
channelService: channelService,
baseURL: baseURL,
logger: slog.Default().With("component", "api.onboarding"),
}
}
// GetCLAUDEMD handles GET /api/agents/{name}/claude-md?archetype=researcher
// Returns a rendered CLAUDE.md for the given agent and archetype.
func (h *OnboardingHandler) GetCLAUDEMD(w http.ResponseWriter, r *http.Request) {
agentName := chi.URLParam(r, "name")
archetype := r.URL.Query().Get("archetype")
if archetype == "" {
archetype = "custom"
}
// Look up the agent to get owner info
ownerName := "owner"
displayName := agentName
agent, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
h.logger.Debug("agent not found, using defaults", "name", agentName, "error", err)
} else {
if agent.DisplayName != "" {
displayName = agent.DisplayName
}
}
config := onboarding.GeneratorConfig{
AgentName: displayName,
Archetype: archetype,
OwnerName: ownerName,
SynapBusURL: h.baseURL,
}
md, err := onboarding.GenerateCLAUDEMD(config)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_archetype", err.Error()))
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(md))
}
// GetMCPConfig handles GET /api/agents/{name}/mcp-config?api_key=xxx
// Returns a JSON MCP config snippet for Claude Code settings.
// If api_key query param is provided, uses it. Otherwise uses a placeholder.
func (h *OnboardingHandler) GetMCPConfig(w http.ResponseWriter, r *http.Request) {
agentName := chi.URLParam(r, "name")
// Verify the agent exists
_, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found: "+agentName))
return
}
apiKey := r.URL.Query().Get("api_key")
if apiKey == "" {
apiKey = "<YOUR_API_KEY>"
}
config := onboarding.GenerateMCPConfig(h.baseURL, apiKey)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(config))
}
// ListArchetypes handles GET /api/archetypes
// Returns the list of available agent archetypes.
func (h *OnboardingHandler) ListArchetypes(w http.ResponseWriter, r *http.Request) {
archetypes := onboarding.ListArchetypes()
writeJSON(w, http.StatusOK, map[string]any{
"archetypes": archetypes,
})
}
// ListSkills handles GET /api/skills
// Returns the list of available agent skills.
func (h *OnboardingHandler) ListSkills(w http.ResponseWriter, r *http.Request) {
skills, err := onboarding.ListSkills()
if err != nil {
h.logger.Error("failed to list skills", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list skills"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"skills": skills,
})
}
// GetSkill handles GET /api/skills/{name}
// Returns the markdown content of a skill.
func (h *OnboardingHandler) GetSkill(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
content, err := onboarding.GetSkill(name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", err.Error()))
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(content))
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/synapbus/synapbus/internal/push"
)
// PushHandler manages Web Push notification subscription endpoints.
type PushHandler struct {
pushService *push.Service
logger *slog.Logger
}
// NewPushHandler creates a new push notification handler.
func NewPushHandler(pushService *push.Service) *PushHandler {
return &PushHandler{
pushService: pushService,
logger: slog.Default().With("component", "api.push"),
}
}
// subscribeRequest is the JSON body for POST /api/push/subscribe.
type subscribeRequest struct {
Endpoint string `json:"endpoint"`
KeyP256dh string `json:"key_p256dh"`
KeyAuth string `json:"key_auth"`
}
// unsubscribeRequest is the JSON body for DELETE /api/push/subscribe.
type unsubscribeRequest struct {
Endpoint string `json:"endpoint"`
}
// Subscribe handles POST /api/push/subscribe.
// Registers a Web Push subscription for the authenticated user.
func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req subscribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Endpoint == "" || req.KeyP256dh == "" || req.KeyAuth == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "endpoint, key_p256dh, and key_auth are required"))
return
}
userAgent := r.Header.Get("User-Agent")
if err := h.pushService.Subscribe(r.Context(), ownerID, req.Endpoint, req.KeyP256dh, req.KeyAuth, userAgent); err != nil {
h.logger.Error("push subscribe failed",
"user_id", ownerID,
"error", err,
)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to register subscription"))
return
}
h.logger.Info("push subscription registered",
"user_id", ownerID,
)
writeJSON(w, http.StatusOK, map[string]string{"status": "subscribed"})
}
// Unsubscribe handles DELETE /api/push/subscribe.
// Removes a Web Push subscription for the authenticated user.
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req unsubscribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Endpoint == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "endpoint is required"))
return
}
if err := h.pushService.Unsubscribe(r.Context(), ownerID, req.Endpoint); err != nil {
h.logger.Error("push unsubscribe failed",
"error", err,
)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to remove subscription"))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unsubscribed"})
}
// VAPIDKey handles GET /api/push/vapid-key.
// Returns the VAPID public key needed by clients to subscribe to push notifications.
func (h *PushHandler) VAPIDKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"vapid_public_key": h.pushService.GetVAPIDPublicKey(),
})
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
)
// ReactionsHandler handles REST API requests for message reactions.
type ReactionsHandler struct {
reactionService *reactions.Service
msgService *messaging.MessagingService
agentService *agents.AgentService
logger *slog.Logger
}
// NewReactionsHandler creates a new reactions handler.
func NewReactionsHandler(reactionService *reactions.Service, msgService *messaging.MessagingService, agentService *agents.AgentService) *ReactionsHandler {
return &ReactionsHandler{
reactionService: reactionService,
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "api.reactions"),
}
}
// Toggle handles POST /api/messages/{id}/reactions.
func (h *ReactionsHandler) Toggle(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
var req struct {
Reaction string `json:"reaction"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Reaction == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Reaction type is required"))
return
}
// Verify the message exists
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
// Determine the acting agent name from the session
agentName, err := h.resolveAgentName(r, ownerID, msg)
if err != nil {
h.logger.Error("resolve agent name failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("no_agent", err.Error()))
return
}
result, err := h.reactionService.Toggle(r.Context(), id, agentName, req.Reaction, req.Metadata)
if err != nil {
if err == reactions.ErrInvalidReaction {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_reaction", err.Error()))
return
}
if err == reactions.ErrReactionLimit {
writeJSON(w, http.StatusBadRequest, errorBody("reaction_limit", err.Error()))
return
}
h.logger.Error("toggle reaction failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to toggle reaction"))
return
}
// Reload reactions and workflow state for the response
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"action": result.Action,
"reaction": result.Reaction,
"reactions": rxs,
"workflow_state": state,
})
}
// GetReactions handles GET /api/messages/{id}/reactions.
func (h *ReactionsHandler) GetReactions(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
// Verify the message exists
_, err = h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"reactions": rxs,
"workflow_state": state,
})
}
// Remove handles DELETE /api/messages/{id}/reactions/{reaction}.
func (h *ReactionsHandler) Remove(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
reactionType := chi.URLParam(r, "reaction")
if reactionType == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Reaction type is required"))
return
}
// Verify the message exists
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
// Determine the acting agent name
agentName, err := h.resolveAgentName(r, ownerID, msg)
if err != nil {
h.logger.Error("resolve agent name failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("no_agent", err.Error()))
return
}
if err := h.reactionService.Remove(r.Context(), id, agentName, reactionType); err != nil {
if err == reactions.ErrInvalidReaction {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_reaction", err.Error()))
return
}
h.logger.Error("remove reaction failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to remove reaction"))
return
}
// Reload reactions and workflow state for the response
rxs, state, err := h.reactionService.GetReactions(r.Context(), id)
if err != nil {
h.logger.Error("get reactions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get reactions"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "removed",
"reactions": rxs,
"workflow_state": state,
})
}
// resolveAgentName determines the agent name for the current session user.
// For session-authenticated users (Web UI), it returns the human agent.
// For API key / OAuth, it falls back to the first owned agent.
func (h *ReactionsHandler) resolveAgentName(r *http.Request, ownerID int64, msg *messaging.Message) (string, error) {
if _, isSession := auth.SessionIDFromContext(r.Context()); isSession {
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil {
return "", err
}
if humanAgent == nil {
return "", fmt.Errorf("no human agent found for this user")
}
return humanAgent.Name, nil
}
// Non-session: find an owned agent
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
return "", fmt.Errorf("no agents registered")
}
// Prefer human-type agent
for _, a := range ownedAgents {
if a.Type == "human" {
return a.Name, nil
}
}
return ownedAgents[0].Name, nil
}
+115
View File
@@ -1,6 +1,7 @@
package api
import (
"database/sql"
"net/http"
"github.com/go-chi/chi/v5"
@@ -11,7 +12,11 @@ import (
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactor"
"github.com/synapbus/synapbus/internal/push"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/trace"
"github.com/synapbus/synapbus/internal/trust"
"github.com/synapbus/synapbus/internal/webhooks"
)
@@ -30,8 +35,17 @@ type RouterConfig struct {
WebhookStore webhooks.WebhookStore
K8sService *k8s.K8sService
K8sStore k8s.K8sStore
ReactionService *reactions.Service
PushService *push.Service
TrustService *trust.Service
ReactorStore *reactor.Store
ReactorEngine *reactor.Reactor
SSEHub *SSEHub
Broadcaster *SSEBroadcaster
SessionMiddleware func(http.Handler) http.Handler
DB *sql.DB
Version string
BaseURL string
}
// NewRouter creates a chi router with all API routes configured.
@@ -85,6 +99,14 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
if cfg.MsgService != nil && cfg.AgentService != nil {
messagesHandler := NewMessagesHandler(cfg.MsgService, cfg.AgentService)
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore, cfg.ChannelService)
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
// Wire up SSE broadcaster for real-time events
if cfg.Broadcaster != nil {
messagesHandler.SetBroadcaster(cfg.Broadcaster)
} else if cfg.SSEHub != nil {
messagesHandler.SetBroadcaster(NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService))
}
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
@@ -109,6 +131,10 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
// Notifications
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
r.Post("/api/notifications/mark-read", notificationsHandler.MarkRead)
})
// API Keys
@@ -124,9 +150,24 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
})
}
// Reactions
if cfg.ReactionService != nil {
reactionsHandler := NewReactionsHandler(cfg.ReactionService, cfg.MsgService, cfg.AgentService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/api/messages/{id}/reactions", reactionsHandler.Toggle)
r.Get("/api/messages/{id}/reactions", reactionsHandler.GetReactions)
r.Delete("/api/messages/{id}/reactions/{reaction}", reactionsHandler.Remove)
})
}
// Channels
if cfg.ChannelService != nil {
channelsHandler := NewChannelsHandler(cfg.ChannelService, cfg.AgentService, cfg.MsgService)
if cfg.ReactionService != nil {
channelsHandler.SetReactionService(cfg.ReactionService)
}
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
@@ -134,6 +175,8 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
r.Post("/api/channels", channelsHandler.CreateChannel)
r.Get("/api/channels/{name}/messages", channelsHandler.ChannelMessages)
r.Get("/api/channels/{name}/messages/by-state", channelsHandler.ListByState)
r.Put("/api/channels/{name}/settings", channelsHandler.UpdateSettings)
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
})
@@ -183,6 +226,78 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Get("/api/k8s/job-runs/{id}/logs", k8sHandler.JobRunLogs)
})
}
// Push Notifications
if cfg.PushService != nil {
pushHandler := NewPushHandler(cfg.PushService)
// VAPID key endpoint is unauthenticated (needed before subscription)
r.Get("/api/push/vapid-key", pushHandler.VAPIDKey)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/api/push/subscribe", pushHandler.Subscribe)
r.Delete("/api/push/subscribe", pushHandler.Unsubscribe)
})
}
}
// Reactive Runs
if cfg.ReactorStore != nil && cfg.ReactorEngine != nil && cfg.AgentService != nil {
runsHandler := NewRunsHandler(cfg.ReactorStore, cfg.ReactorEngine, agents.NewSQLiteAgentStore(cfg.DB))
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/runs", runsHandler.ListRuns)
r.Get("/api/runs/{id}", runsHandler.GetRun)
r.Post("/api/runs/{id}/retry", runsHandler.RetryRun)
r.Get("/api/agents/reactive", runsHandler.ReactiveAgents)
})
}
// Trust Scores
if cfg.TrustService != nil {
trustHandler := NewTrustHandler(cfg.TrustService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/trust/{name}", trustHandler.GetScores)
})
}
// Onboarding (CLAUDE.md generator, MCP config, archetypes, skills)
if cfg.AgentService != nil {
onboardingHandler := NewOnboardingHandler(cfg.AgentService, cfg.ChannelService, cfg.BaseURL)
// Unauthenticated: archetypes list, skills list, skill content
r.Get("/api/archetypes", onboardingHandler.ListArchetypes)
r.Get("/api/skills", onboardingHandler.ListSkills)
r.Get("/api/skills/{name}", onboardingHandler.GetSkill)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/agents/{name}/claude-md", onboardingHandler.GetCLAUDEMD)
r.Get("/api/agents/{name}/mcp-config", onboardingHandler.GetMCPConfig)
})
}
// Analytics (authenticated, requires DB)
if cfg.DB != nil {
analyticsHandler := NewAnalyticsHandler(cfg.DB, cfg.AgentService, cfg.ChannelService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
r.Get("/api/analytics/summary", analyticsHandler.Summary)
})
}
// Version (unauthenticated)
if cfg.Version != "" {
versionHandler := NewVersionHandler(cfg.Version)
r.Get("/api/version", versionHandler.GetVersion)
}
// Metrics endpoint (unauthenticated, only registered when enabled)
+165
View File
@@ -0,0 +1,165 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/reactor"
)
// RunsHandler handles REST API requests for reactive runs.
type RunsHandler struct {
store *reactor.Store
reactor *reactor.Reactor
agentStore agents.AgentStore
}
// NewRunsHandler creates a new runs handler.
func NewRunsHandler(store *reactor.Store, r *reactor.Reactor, agentStore agents.AgentStore) *RunsHandler {
return &RunsHandler{
store: store,
reactor: r,
agentStore: agentStore,
}
}
// ListRuns returns reactive runs with optional filters.
func (h *RunsHandler) ListRuns(w http.ResponseWriter, r *http.Request) {
agentName := r.URL.Query().Get("agent")
status := r.URL.Query().Get("status")
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 200 {
limit = v
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
runs, total, err := h.store.ListRuns(r.Context(), agentName, status, limit, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"runs": runs,
"total": total,
})
}
// GetRun returns a single run by ID.
func (h *RunsHandler) GetRun(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "invalid run ID"))
return
}
run, err := h.store.GetRunByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "run not found"))
return
}
writeJSON(w, http.StatusOK, run)
}
// RetryRun retries a failed run.
func (h *RunsHandler) RetryRun(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "invalid run ID"))
return
}
newRun, err := h.reactor.RetryRun(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("retry_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"new_run_id": newRun.ID,
"status": newRun.Status,
})
}
// ReactiveAgents returns agents with reactive trigger config and current status.
func (h *RunsHandler) ReactiveAgents(w http.ResponseWriter, r *http.Request) {
agentsList, err := h.agentStore.ListReactiveAgents(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", err.Error()))
return
}
type agentStatus struct {
Name string `json:"name"`
TriggerMode string `json:"trigger_mode"`
CooldownSeconds int `json:"cooldown_seconds"`
DailyTriggerBudget int `json:"daily_trigger_budget"`
MaxTriggerDepth int `json:"max_trigger_depth"`
K8sImage string `json:"k8s_image"`
PendingWork bool `json:"pending_work"`
State string `json:"state"`
TodayRuns int `json:"today_runs"`
CooldownUntil *string `json:"cooldown_until"`
}
result := make([]agentStatus, 0, len(agentsList))
for _, a := range agentsList {
as := agentStatus{
Name: a.Name,
TriggerMode: a.TriggerMode,
CooldownSeconds: a.CooldownSeconds,
DailyTriggerBudget: a.DailyTriggerBudget,
MaxTriggerDepth: a.MaxTriggerDepth,
K8sImage: a.K8sImage,
PendingWork: a.PendingWork,
}
// Compute state
todayCount, _ := h.store.CountTodayRuns(r.Context(), a.Name)
as.TodayRuns = todayCount
running, _ := h.store.IsAgentRunning(r.Context(), a.Name)
if running {
as.State = "running"
} else if a.PendingWork {
as.State = "queued"
} else if todayCount >= a.DailyTriggerBudget {
as.State = "budget_exhausted"
} else {
lastRun, _ := h.store.GetLastRunTime(r.Context(), a.Name)
if lastRun != nil {
cooldownEnd := lastRun.Add(time.Duration(a.CooldownSeconds) * time.Second)
if time.Now().Before(cooldownEnd) {
as.State = "cooldown"
t := cooldownEnd.UTC().Format(time.RFC3339)
as.CooldownUntil = &t
} else {
as.State = "idle"
}
} else {
as.State = "idle"
}
}
result = append(result, as)
}
writeJSON(w, http.StatusOK, map[string]any{
"agents": result,
})
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/trust"
)
// TrustHandler handles REST API requests for agent trust scores.
type TrustHandler struct {
trustService *trust.Service
logger *slog.Logger
}
// NewTrustHandler creates a new trust handler.
func NewTrustHandler(trustService *trust.Service) *TrustHandler {
return &TrustHandler{
trustService: trustService,
logger: slog.Default().With("component", "api.trust"),
}
}
// GetScores handles GET /api/trust/{name}.
func (h *TrustHandler) GetScores(w http.ResponseWriter, r *http.Request) {
agentName := chi.URLParam(r, "name")
if agentName == "" {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_name", "Agent name is required"))
return
}
scores, err := h.trustService.GetScores(r.Context(), agentName)
if err != nil {
h.logger.Error("failed to get trust scores", "agent", agentName, "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("internal", "Failed to get trust scores"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"scores": scores,
})
}
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"net/http"
)
// VersionHandler serves version information.
// The version is set at build time via -ldflags.
type VersionHandler struct {
version string
}
// NewVersionHandler creates a new version handler.
func NewVersionHandler(version string) *VersionHandler {
return &VersionHandler{version: version}
}
// GetVersion handles GET /api/version.
func (h *VersionHandler) GetVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"version": h.version,
"repo": "https://github.com/synapbus/synapbus",
})
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGetVersion(t *testing.T) {
handler := NewVersionHandler("v0.7.0")
req := httptest.NewRequest("GET", "/api/version", nil)
rr := httptest.NewRecorder()
handler.GetVersion(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
ct := rr.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var resp struct {
Version string `json:"version"`
Repo string `json:"repo"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Version != "v0.7.0" {
t.Errorf("version = %q, want v0.7.0", resp.Version)
}
if resp.Repo != "https://github.com/synapbus/synapbus" {
t.Errorf("repo = %q, want https://github.com/synapbus/synapbus", resp.Repo)
}
}
func TestGetVersion_DevBuild(t *testing.T) {
handler := NewVersionHandler("dev")
req := httptest.NewRequest("GET", "/api/version", nil)
rr := httptest.NewRecorder()
handler.GetVersion(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Version string `json:"version"`
Repo string `json:"repo"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.Version != "dev" {
t.Errorf("version = %q, want dev", resp.Version)
}
}
func TestGetVersion_ResponseFormat(t *testing.T) {
handler := NewVersionHandler("v1.2.3")
req := httptest.NewRequest("GET", "/api/version", nil)
rr := httptest.NewRecorder()
handler.GetVersion(rr, req)
// Verify the response is valid JSON with exactly the expected keys
var raw map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(raw) != 2 {
t.Errorf("response has %d keys, want 2", len(raw))
}
if _, ok := raw["version"]; !ok {
t.Error("response missing 'version' key")
}
if _, ok := raw["repo"]; !ok {
t.Error("response missing 'repo' key")
}
}
+6
View File
@@ -118,3 +118,9 @@ func DefaultFilename(mimeType string) string {
func IsImageType(mimeType string) bool {
return imageTypes[mimeType]
}
// IsAllowedType returns true for all MIME types. Any file type is allowed;
// only size is restricted (50 MB max).
func IsAllowedType(mimeType string) bool {
return true
}
+29
View File
@@ -154,6 +154,35 @@ func TestIsImageType(t *testing.T) {
}
}
func TestIsAllowedType(t *testing.T) {
tests := []struct {
mimeType string
want bool
}{
{"image/png", true},
{"image/jpeg", true},
{"image/gif", true},
{"application/pdf", true},
{"text/plain", true},
{"text/csv", true},
{"text/plain; charset=utf-8", true},
{"application/json", true},
{"application/octet-stream", true},
{"application/zip", true},
{"application/x-executable", true},
{"video/mp4", true},
}
for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) {
got := IsAllowedType(tt.mimeType)
if got != tt.want {
t.Errorf("IsAllowedType(%q) = %v, want %v", tt.mimeType, got, tt.want)
}
})
}
}
func min(a, b int) int {
if a < b {
return a
+5 -4
View File
@@ -11,10 +11,11 @@ const MaxFileSize = 50 * 1024 * 1024 // 50 MB
// Sentinel errors.
var (
ErrNotFound = errors.New("attachment not found")
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
ErrEmptyFile = errors.New("empty file not allowed")
ErrFileMissing = errors.New("attachment file missing from disk")
ErrNotFound = errors.New("attachment not found")
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
ErrEmptyFile = errors.New("empty file not allowed")
ErrFileMissing = errors.New("attachment file missing from disk")
ErrUnsupportedType = errors.New("unsupported file type: only images (jpg, png, gif, webp, svg), PDFs, and text files are allowed")
)
// Attachment represents the metadata for a stored file.
+74
View File
@@ -202,6 +202,80 @@ func TestService_Dedup(t *testing.T) {
}
}
func TestService_Upload_FileTypeValidation(t *testing.T) {
// PNG magic bytes.
pngContent := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00}
// PDF magic bytes.
pdfContent := []byte("%PDF-1.4 some pdf content here")
// Plain text content.
textContent := []byte("just some plain text content")
tests := []struct {
name string
content []byte
filename string
mimeType string
wantErr error
}{
{
name: "valid image upload",
content: pngContent,
filename: "photo.png",
wantErr: nil,
},
{
name: "valid PDF upload",
content: pdfContent,
filename: "report.pdf",
wantErr: nil,
},
{
name: "valid text file upload",
content: textContent,
filename: "notes.txt",
wantErr: nil,
},
{
name: "zip upload allowed",
content: []byte("not real zip content"),
filename: "archive.zip",
mimeType: "application/zip",
wantErr: nil,
},
{
name: "executable upload allowed",
content: []byte{0x7f, 0x45, 0x4c, 0x46},
filename: "program.exe",
mimeType: "application/x-executable",
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
_, err := svc.Upload(ctx, UploadRequest{
Content: bytes.NewReader(tt.content),
Filename: tt.filename,
MIMEType: tt.mimeType,
UploadedBy: "agent-a",
})
if tt.wantErr != nil {
if err != tt.wantErr {
t.Errorf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("Upload: %v", err)
}
})
}
}
func TestService_GarbageCollect(t *testing.T) {
svc, db := newTestService(t)
ctx := context.Background()
+85 -9
View File
@@ -227,6 +227,59 @@ func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(user)
}
// HandleUpdateProfile handles PUT /api/auth/profile.
func (h *Handlers) HandleUpdateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "PUT required")
return
}
user, ok := UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
return
}
var req struct {
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
return
}
if strings.TrimSpace(req.DisplayName) == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Display name cannot be empty")
return
}
if err := h.userStore.UpdateDisplayName(r.Context(), user.ID, req.DisplayName); err != nil {
h.logger.Error("update display name failed", "error", err)
writeError(w, http.StatusInternalServerError, "server_error", "Failed to update display name")
return
}
// Fetch updated user
updated, err := h.userStore.GetUserByID(r.Context(), user.ID)
if err != nil {
h.logger.Error("fetch updated user failed", "error", err)
writeError(w, http.StatusInternalServerError, "server_error", "Profile updated but failed to fetch result")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "Profile updated",
"user": map[string]any{
"id": updated.ID,
"username": updated.Username,
"display_name": updated.DisplayName,
"role": updated.Role,
},
})
}
// HandleChangePassword handles PUT /auth/password.
func (h *Handlers) HandleChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
@@ -295,13 +348,21 @@ func (h *Handlers) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
}
baseURL := h.config.IssuerURL
if baseURL == "" {
// Fall back to request Host header
if baseURL == "" || baseURL == "auto" {
// Derive from request headers (supports reverse proxies and tunnels)
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
baseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
// Cloudflare, nginx, and other proxies set these headers
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
host := r.Host
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
host = fwdHost
}
baseURL = fmt.Sprintf("%s://%s", scheme, host)
}
metadata := map[string]any{
@@ -816,13 +877,28 @@ const authorizeTemplateHTML = `<!DOCTYPE html>
<body>
<div class="card">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.8">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#36c5f0"/>
<path d="M2 17l10 5 10-5" stroke="#7c3aed"/>
<path d="M2 12l10 5 10-5" stroke="url(#g)"/>
<defs><linearGradient id="g" x1="2" y1="12" x2="22" y2="17" gradientUnits="userSpaceOnUse">
<stop stop-color="#36c5f0"/><stop offset="1" stop-color="#7c3aed"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs><linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#7c3aed"/><stop offset="1" stop-color="#06b6d4"/>
</linearGradient></defs>
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<g transform="translate(4,4)">
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
</g>
</svg>
<h1>SynapBus</h1>
<p>Agent Authorization</p>
+93
View File
@@ -0,0 +1,93 @@
package idp
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"time"
)
// LoadConfig reads environment variables and returns a slice of enabled identity providers.
// Providers are only included if both client ID and client secret are set.
func LoadConfig(baseURL string) []Provider {
var providers []Provider
// GitHub
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_GITHUB_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_GITHUB_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
redirectURL := baseURL + "/auth/callback/github"
providers = append(providers, NewGitHubProvider(clientID, clientSecret, redirectURL))
slog.Info("IdP enabled", "provider", "github")
}
// Google (OIDC)
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_GOOGLE_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_GOOGLE_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
var allowedDomains []string
if domains := os.Getenv("SYNAPBUS_IDP_GOOGLE_ALLOWED_DOMAINS"); domains != "" {
for _, d := range strings.Split(domains, ",") {
d = strings.TrimSpace(d)
if d != "" {
allowedDomains = append(allowedDomains, d)
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := NewOIDCProvider(ctx, OIDCConfig{
ID: "google",
DisplayName: "Google",
IssuerURL: "https://accounts.google.com",
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: baseURL + "/auth/callback/google",
AllowedDomains: allowedDomains,
})
if err != nil {
slog.Error("failed to initialize Google OIDC provider", "error", err)
} else {
providers = append(providers, p)
slog.Info("IdP enabled", "provider", "google", "allowed_domains", allowedDomains)
}
}
// Azure AD (OIDC)
if clientID, clientSecret := os.Getenv("SYNAPBUS_IDP_AZUREAD_CLIENT_ID"), os.Getenv("SYNAPBUS_IDP_AZUREAD_CLIENT_SECRET"); clientID != "" && clientSecret != "" {
tenantID := os.Getenv("SYNAPBUS_IDP_AZUREAD_TENANT_ID")
if tenantID == "" {
tenantID = "common" // multi-tenant by default
}
var groupMapping map[string]string
if gm := os.Getenv("SYNAPBUS_IDP_AZUREAD_GROUP_MAPPING"); gm != "" {
if err := json.Unmarshal([]byte(gm), &groupMapping); err != nil {
slog.Error("failed to parse Azure AD group mapping", "error", err)
}
}
issuerURL := "https://login.microsoftonline.com/" + tenantID + "/v2.0"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := NewOIDCProvider(ctx, OIDCConfig{
ID: "azuread",
DisplayName: "Microsoft",
IssuerURL: issuerURL,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: baseURL + "/auth/callback/azuread",
GroupMapping: groupMapping,
})
if err != nil {
slog.Error("failed to initialize Azure AD OIDC provider", "error", err)
} else {
providers = append(providers, p)
slog.Info("IdP enabled", "provider", "azuread", "tenant_id", tenantID)
}
}
return providers
}
+140
View File
@@ -0,0 +1,140 @@
package idp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
// GitHubProvider implements GitHub OAuth authentication.
// GitHub does not support OIDC discovery, so this uses plain OAuth 2.0
// with GitHub's user API endpoints.
type GitHubProvider struct {
config *oauth2.Config
}
// NewGitHubProvider creates a new GitHub OAuth provider.
func NewGitHubProvider(clientID, clientSecret, redirectURL string) *GitHubProvider {
return &GitHubProvider{
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"read:user", "user:email"},
Endpoint: github.Endpoint,
},
}
}
func (p *GitHubProvider) ID() string { return "github" }
func (p *GitHubProvider) Type() string { return "oauth" }
func (p *GitHubProvider) DisplayName() string { return "GitHub" }
func (p *GitHubProvider) AuthCodeURL(state string) string {
return p.config.AuthCodeURL(state)
}
func (p *GitHubProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
token, err := p.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("github: exchange code: %w", err)
}
client := p.config.Client(ctx, token)
// Fetch user profile
userInfo, err := fetchGitHubUser(client)
if err != nil {
return nil, err
}
// Fetch primary verified email
email, err := fetchGitHubPrimaryEmail(client)
if err != nil {
// Non-fatal: email might not be available
email = ""
}
// If user profile has an email and we didn't get one from the emails endpoint, use it
if email == "" {
if e, ok := userInfo["email"].(string); ok && e != "" {
email = e
}
}
idNum, _ := userInfo["id"].(float64)
login, _ := userInfo["login"].(string)
name, _ := userInfo["name"].(string)
return &ExternalUser{
ProviderID: "github",
ExternalID: strconv.FormatInt(int64(idNum), 10),
Email: email,
Name: name,
Username: login,
RawClaims: userInfo,
}, nil
}
func fetchGitHubUser(client *http.Client) (map[string]any, error) {
resp, err := client.Get("https://api.github.com/user")
if err != nil {
return nil, fmt.Errorf("github: fetch user: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("github: user API returned %d: %s", resp.StatusCode, body)
}
var user map[string]any
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("github: decode user: %w", err)
}
return user, nil
}
// githubEmail represents an email entry from the GitHub emails API.
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
func fetchGitHubPrimaryEmail(client *http.Client) (string, error) {
resp, err := client.Get("https://api.github.com/user/emails")
if err != nil {
return "", fmt.Errorf("github: fetch emails: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github: emails API returned %d", resp.StatusCode)
}
var emails []githubEmail
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
return "", fmt.Errorf("github: decode emails: %w", err)
}
// Find primary verified email
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
// Fallback: first verified email
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", fmt.Errorf("github: no verified email found")
}
+349
View File
@@ -0,0 +1,349 @@
package idp
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/auth"
)
// AgentProvisioner creates a human agent and personal channel after IdP login.
// This is a narrow interface to avoid importing the agents/channels packages.
type AgentProvisioner interface {
ProvisionHumanAgent(ctx context.Context, username, displayName string, ownerID int64) error
}
// Handlers holds HTTP handlers for external identity provider authentication.
type Handlers struct {
providers map[string]Provider
providerList []Provider // preserve order for listing
idStore *UserIdentityStore
userStore auth.UserStore
sessionStore auth.SessionStore
agentProvisioner AgentProvisioner
logger *slog.Logger
}
// NewHandlers creates a new set of IdP HTTP handlers.
func NewHandlers(
providers []Provider,
idStore *UserIdentityStore,
userStore auth.UserStore,
sessionStore auth.SessionStore,
agentProvisioner AgentProvisioner,
) *Handlers {
providerMap := make(map[string]Provider, len(providers))
for _, p := range providers {
providerMap[p.ID()] = p
}
return &Handlers{
providers: providerMap,
providerList: providers,
idStore: idStore,
userStore: userStore,
sessionStore: sessionStore,
agentProvisioner: agentProvisioner,
logger: slog.Default().With("component", "idp"),
}
}
// HandleListProviders returns the list of enabled identity providers.
// GET /auth/providers
func (h *Handlers) HandleListProviders(w http.ResponseWriter, r *http.Request) {
providers := make([]ProviderInfo, 0, len(h.providerList))
for _, p := range h.providerList {
providers = append(providers, ProviderInfo{
ID: p.ID(),
Type: p.Type(),
DisplayName: p.DisplayName(),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"providers": providers,
})
}
// HandleLogin initiates the OAuth/OIDC flow by redirecting to the IdP.
// GET /auth/login/{provider}
func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
providerID := chi.URLParam(r, "provider")
provider, ok := h.providers[providerID]
if !ok {
http.Error(w, fmt.Sprintf(`{"error":"unknown_provider","message":"Provider %q not found"}`, providerID), http.StatusNotFound)
return
}
state, err := generateState()
if err != nil {
h.logger.Error("failed to generate state", "error", err)
http.Error(w, `{"error":"server_error","message":"Failed to generate state"}`, http.StatusInternalServerError)
return
}
// Store state in a short-lived cookie for CSRF protection
http.SetCookie(w, &http.Cookie{
Name: "idp_state",
Value: state,
Path: "/auth/callback/" + providerID,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 600, // 10 minutes
})
authURL := provider.AuthCodeURL(state)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
// HandleCallback processes the OAuth/OIDC callback from the IdP.
// GET /auth/callback/{provider}
func (h *Handlers) HandleCallback(w http.ResponseWriter, r *http.Request) {
providerID := chi.URLParam(r, "provider")
provider, ok := h.providers[providerID]
if !ok {
http.Error(w, "Unknown provider", http.StatusNotFound)
return
}
// Verify state parameter
stateCookie, err := r.Cookie("idp_state")
if err != nil || stateCookie.Value == "" {
http.Error(w, "Missing state cookie", http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != stateCookie.Value {
http.Error(w, "State mismatch — possible CSRF attack", http.StatusBadRequest)
return
}
// Clear state cookie
http.SetCookie(w, &http.Cookie{
Name: "idp_state",
Value: "",
Path: "/auth/callback/" + providerID,
HttpOnly: true,
MaxAge: -1,
})
// Check for error from IdP
if errParam := r.URL.Query().Get("error"); errParam != "" {
desc := r.URL.Query().Get("error_description")
h.logger.Warn("IdP returned error", "provider", providerID, "error", errParam, "description", desc)
http.Redirect(w, r, "/login?error="+errParam, http.StatusTemporaryRedirect)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "Missing authorization code", http.StatusBadRequest)
return
}
// Exchange code for user info
extUser, err := provider.Exchange(r.Context(), code)
if err != nil {
h.logger.Error("IdP exchange failed", "provider", providerID, "error", err)
http.Redirect(w, r, "/login?error=exchange_failed", http.StatusTemporaryRedirect)
return
}
// Find or create local user
user, err := h.findOrCreateUser(r.Context(), extUser)
if err != nil {
h.logger.Error("failed to provision user from IdP", "provider", providerID, "error", err)
http.Redirect(w, r, "/login?error=provisioning_failed", http.StatusTemporaryRedirect)
return
}
// Create session
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, 24*time.Hour)
if err != nil {
h.logger.Error("failed to create session after IdP login", "error", err)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: session.SessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24 hours
})
auth.LogAuthEvent(r.Context(), h.logger, auth.AuthEvent{
Type: auth.EventLoginSuccess,
UserID: user.ID,
Username: user.Username,
RemoteIP: r.RemoteAddr,
Details: map[string]any{
"provider": providerID,
"external_id": extUser.ExternalID,
},
})
// Ensure human agent and channel in background
if h.agentProvisioner != nil {
go func() {
ctx := context.Background()
if err := h.agentProvisioner.ProvisionHumanAgent(ctx, user.Username, user.DisplayName, user.ID); err != nil {
h.logger.Warn("failed to ensure human agent after IdP login", "username", user.Username, "error", err)
}
}()
}
// Redirect to home
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// findOrCreateUser looks up or provisions a local user from external identity.
// 1. Check user_identities for (provider, external_id) -> if found, load user
// 2. If not found, check users by email -> if found, link identity
// 3. If no user at all, create new user with random password
func (h *Handlers) findOrCreateUser(ctx context.Context, ext *ExternalUser) (*auth.User, error) {
// Step 1: Check existing identity link
userID, err := h.idStore.FindByProvider(ctx, ext.ProviderID, ext.ExternalID)
if err == nil {
// Found existing link
user, err := h.userStore.GetUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load linked user %d: %w", userID, err)
}
return user, nil
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("find identity: %w", err)
}
// Step 2: Try to find user by email
if ext.Email != "" {
user, err := h.userStore.GetUserByEmail(ctx, ext.Email)
if err == nil {
// Found user with matching email — link this identity
if linkErr := h.idStore.Create(ctx, user.ID, ext.ProviderID, ext.ExternalID, ext.Email, ext.Name, ext.RawClaims); linkErr != nil {
h.logger.Warn("failed to link identity to existing user", "user_id", user.ID, "error", linkErr)
}
return user, nil
}
// If error is not "not found", it's a real error
if err != auth.ErrUserNotFound {
return nil, fmt.Errorf("find user by email: %w", err)
}
}
// Step 3: Create new user
username := sanitizeUsername(ext.Username, ext.ProviderID, ext.ExternalID)
displayName := ext.Name
if displayName == "" {
displayName = username
}
// Generate random password (user will login via IdP)
randomPW, err := generateRandomPassword()
if err != nil {
return nil, fmt.Errorf("generate password: %w", err)
}
user, err := h.userStore.CreateUser(ctx, username, randomPW, displayName)
if err != nil {
// If username conflicts, try with a suffix
if strings.Contains(err.Error(), "already exists") {
suffix, _ := generateShortID()
username = username + "_" + suffix
user, err = h.userStore.CreateUser(ctx, username, randomPW, displayName)
}
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
}
// Set email on the user if available
if ext.Email != "" {
if emailStore, ok := h.userStore.(*auth.SQLiteUserStore); ok {
emailStore.SetEmail(ctx, user.ID, ext.Email)
}
}
// Link identity
if linkErr := h.idStore.Create(ctx, user.ID, ext.ProviderID, ext.ExternalID, ext.Email, ext.Name, ext.RawClaims); linkErr != nil {
h.logger.Warn("failed to link identity to new user", "user_id", user.ID, "error", linkErr)
}
h.logger.Info("provisioned new user from IdP",
"username", username,
"provider", ext.ProviderID,
"external_id", ext.ExternalID,
"email", ext.Email,
)
return user, nil
}
// sanitizeUsername normalizes a username from an IdP to match SynapBus requirements.
// Usernames must be 3-64 chars, alphanumeric and underscore only.
func sanitizeUsername(username, provider, externalID string) string {
if username == "" {
username = provider + "_" + externalID
}
// Replace invalid characters with underscore
var result strings.Builder
for _, r := range username {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
result.WriteRune(r)
} else {
result.WriteRune('_')
}
}
username = result.String()
// Trim underscores from edges and ensure minimum length
username = strings.Trim(username, "_")
if len(username) < 3 {
username = username + "_user"
}
if len(username) > 64 {
username = username[:64]
}
return username
}
// generateState creates a cryptographically random state parameter for OAuth.
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// generateRandomPassword creates a random password for IdP-provisioned users.
func generateRandomPassword() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// generateShortID creates a short random string for username disambiguation.
func generateShortID() (string, error) {
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+392
View File
@@ -0,0 +1,392 @@
package idp
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/auth"
"github.com/synapbus/synapbus/internal/storage"
)
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
return db
}
func newTestUserStore(db *sql.DB, t *testing.T) auth.UserStore {
t.Helper()
return auth.NewSQLiteUserStore(db, 10) // low cost for fast tests
}
// --- GitHub provider tests ---
func TestGitHubProvider_AuthCodeURL(t *testing.T) {
p := NewGitHubProvider("test-client-id", "test-secret", "http://localhost:8080/auth/callback/github")
url := p.AuthCodeURL("test-state-123")
if url == "" {
t.Fatal("AuthCodeURL returned empty string")
}
if p.ID() != "github" {
t.Errorf("ID() = %q, want %q", p.ID(), "github")
}
if p.Type() != "oauth" {
t.Errorf("Type() = %q, want %q", p.Type(), "oauth")
}
if p.DisplayName() != "GitHub" {
t.Errorf("DisplayName() = %q, want %q", p.DisplayName(), "GitHub")
}
// URL should contain the client ID and state
if got := url; got == "" {
t.Error("expected non-empty URL")
}
}
// --- OIDC domain validation tests ---
func TestOIDCProvider_DomainRestriction(t *testing.T) {
p := &OIDCProvider{
id: "test",
displayName: "Test",
allowedDomains: []string{"gcore.com", "example.com"},
}
tests := []struct {
name string
email string
hd string
wantError bool
}{
{"allowed domain via hd", "user@gcore.com", "gcore.com", false},
{"allowed domain via email", "user@example.com", "", false},
{"disallowed domain", "user@evil.com", "evil.com", true},
{"no domain info", "", "", true},
{"case insensitive", "User@Gcore.COM", "Gcore.COM", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := p.validateDomain(tt.email, tt.hd)
if (err != nil) != tt.wantError {
t.Errorf("validateDomain(%q, %q) error = %v, wantError %v", tt.email, tt.hd, err, tt.wantError)
}
})
}
}
func TestOIDCProvider_NoDomainRestriction(t *testing.T) {
p := &OIDCProvider{
id: "test",
displayName: "Test",
allowedDomains: nil,
}
if len(p.allowedDomains) != 0 {
t.Error("expected empty allowed domains")
}
}
// --- Store tests ---
func TestUserIdentityStore_CreateAndFind(t *testing.T) {
db := newTestDB(t)
store := NewUserIdentityStore(db)
ctx := context.Background()
// Create a user first
_, err := db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)`,
"testuser", "hash", "Test User", "user",
)
if err != nil {
t.Fatalf("create user: %v", err)
}
var userID int64
db.QueryRowContext(ctx, "SELECT id FROM users WHERE username = ?", "testuser").Scan(&userID)
// Create identity
claims := map[string]any{"sub": "12345", "login": "ghuser"}
err = store.Create(ctx, userID, "github", "12345", "test@example.com", "Test User", claims)
if err != nil {
t.Fatalf("Create: %v", err)
}
// Find by provider
foundID, err := store.FindByProvider(ctx, "github", "12345")
if err != nil {
t.Fatalf("FindByProvider: %v", err)
}
if foundID != userID {
t.Errorf("FindByProvider = %d, want %d", foundID, userID)
}
// Find non-existent
_, err = store.FindByProvider(ctx, "github", "99999")
if err != sql.ErrNoRows {
t.Errorf("expected sql.ErrNoRows, got %v", err)
}
}
func TestUserIdentityStore_ListByUser(t *testing.T) {
db := newTestDB(t)
store := NewUserIdentityStore(db)
ctx := context.Background()
_, err := db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)`,
"multiuser", "hash", "Multi User", "user",
)
if err != nil {
t.Fatalf("create user: %v", err)
}
var userID int64
db.QueryRowContext(ctx, "SELECT id FROM users WHERE username = ?", "multiuser").Scan(&userID)
store.Create(ctx, userID, "github", "gh-123", "user@gh.com", "GH User", map[string]any{})
store.Create(ctx, userID, "google", "goog-456", "user@google.com", "Google User", map[string]any{})
identities, err := store.ListByUser(ctx, userID)
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(identities) != 2 {
t.Errorf("got %d identities, want 2", len(identities))
}
if identities[0].Provider != "github" {
t.Errorf("first identity provider = %q, want %q", identities[0].Provider, "github")
}
if identities[1].Provider != "google" {
t.Errorf("second identity provider = %q, want %q", identities[1].Provider, "google")
}
empty, err := store.ListByUser(ctx, 99999)
if err != nil {
t.Fatalf("ListByUser (empty): %v", err)
}
if len(empty) != 0 {
t.Errorf("expected empty list, got %d", len(empty))
}
}
// --- Handler tests ---
func TestHandleListProviders(t *testing.T) {
providers := []Provider{
NewGitHubProvider("id", "secret", "http://localhost/callback"),
&mockProvider{id: "google", providerType: "oidc", name: "Google"},
}
handlers := NewHandlers(providers, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/providers", nil)
w := httptest.NewRecorder()
handlers.HandleListProviders(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
var resp struct {
Providers []ProviderInfo `json:"providers"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Providers) != 2 {
t.Fatalf("got %d providers, want 2", len(resp.Providers))
}
if resp.Providers[0].ID != "github" {
t.Errorf("first provider ID = %q, want %q", resp.Providers[0].ID, "github")
}
if resp.Providers[0].DisplayName != "GitHub" {
t.Errorf("first provider DisplayName = %q, want %q", resp.Providers[0].DisplayName, "GitHub")
}
if resp.Providers[1].ID != "google" {
t.Errorf("second provider ID = %q, want %q", resp.Providers[1].ID, "google")
}
}
// --- User provisioning tests ---
func TestSanitizeUsername(t *testing.T) {
tests := []struct {
name string
username string
provider string
externalID string
wantMin string // exact match, or empty for length-only check
maxLen int
}{
{"normal", "testuser", "github", "123", "testuser", 0},
{"with dash", "test-user", "github", "123", "test_user", 0},
{"with dots", "test.user", "github", "123", "test_user", 0},
{"with at sign", "user@email.com", "github", "123", "user_email_com", 0},
{"too short", "ab", "github", "123", "ab_user", 0},
{"empty", "", "github", "123", "github_123", 0},
{"too long", "", "", "", "", 64},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := tt.username
if tt.name == "too long" {
b := make([]byte, 100)
for i := range b {
b[i] = 'a'
}
input = string(b)
}
got := sanitizeUsername(input, tt.provider, tt.externalID)
if tt.maxLen > 0 {
if len(got) > tt.maxLen {
t.Errorf("sanitizeUsername too long: len=%d, want <= %d", len(got), tt.maxLen)
}
return
}
if got != tt.wantMin {
t.Errorf("sanitizeUsername(%q, %q, %q) = %q, want %q", tt.username, tt.provider, tt.externalID, got, tt.wantMin)
}
})
}
}
func TestFindOrCreateUser_NewUser(t *testing.T) {
db := newTestDB(t)
idStore := NewUserIdentityStore(db)
userStore := newTestUserStore(db, t)
handlers := &Handlers{
idStore: idStore,
userStore: userStore,
logger: slog.Default(),
}
ctx := context.Background()
ext := &ExternalUser{
ProviderID: "github",
ExternalID: "gh-newuser-001",
Email: "newuser@example.com",
Name: "New User",
Username: "newghuser",
RawClaims: map[string]any{"login": "newghuser"},
}
user, err := handlers.findOrCreateUser(ctx, ext)
if err != nil {
t.Fatalf("findOrCreateUser: %v", err)
}
if user.Username != "newghuser" {
t.Errorf("Username = %q, want %q", user.Username, "newghuser")
}
if user.DisplayName != "New User" {
t.Errorf("DisplayName = %q, want %q", user.DisplayName, "New User")
}
// Identity should be linked
foundID, err := idStore.FindByProvider(ctx, "github", "gh-newuser-001")
if err != nil {
t.Fatalf("identity not linked: %v", err)
}
if foundID != user.ID {
t.Errorf("linked user ID = %d, want %d", foundID, user.ID)
}
}
func TestFindOrCreateUser_ExistingIdentity(t *testing.T) {
db := newTestDB(t)
idStore := NewUserIdentityStore(db)
userStore := newTestUserStore(db, t)
ctx := context.Background()
// Create a user first
user, err := userStore.CreateUser(ctx, "existinguser", "password1234", "Existing User")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Link identity manually
err = idStore.Create(ctx, user.ID, "github", "gh-existing-001", "existing@example.com", "Existing", map[string]any{})
if err != nil {
t.Fatalf("Create identity: %v", err)
}
handlers := &Handlers{
idStore: idStore,
userStore: userStore,
logger: slog.Default(),
}
ext := &ExternalUser{
ProviderID: "github",
ExternalID: "gh-existing-001",
Email: "existing@example.com",
Name: "Existing User",
Username: "existinguser",
}
found, err := handlers.findOrCreateUser(ctx, ext)
if err != nil {
t.Fatalf("findOrCreateUser: %v", err)
}
if found.ID != user.ID {
t.Errorf("found user ID = %d, want %d", found.ID, user.ID)
}
}
// --- Mock helpers ---
type mockProvider struct {
id string
providerType string
name string
}
func (m *mockProvider) ID() string { return m.id }
func (m *mockProvider) Type() string { return m.providerType }
func (m *mockProvider) DisplayName() string { return m.name }
func (m *mockProvider) AuthCodeURL(state string) string {
return "https://mock.idp.example.com/authorize?state=" + state
}
func (m *mockProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
return &ExternalUser{
ProviderID: m.id,
ExternalID: "mock-ext-id",
Email: "mock@example.com",
Name: "Mock User",
Username: "mockuser",
}, nil
}
+174
View File
@@ -0,0 +1,174 @@
package idp
import (
"context"
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// OIDCProvider implements OpenID Connect authentication.
// Works with any OIDC-compliant provider (Google, Azure AD, etc.).
type OIDCProvider struct {
id string
displayName string
issuerURL string
config *oauth2.Config
verifier *oidc.IDTokenVerifier
allowedDomains []string // Empty means allow all domains
groupMapping map[string]string
}
// OIDCConfig holds configuration for an OIDC provider.
type OIDCConfig struct {
ID string
DisplayName string
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
AllowedDomains []string
GroupMapping map[string]string
}
// NewOIDCProvider creates a new OIDC provider using discovery.
func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (*OIDCProvider, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc: discover %s: %w", cfg.IssuerURL, err)
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "email", "profile"}
}
oauthConfig := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: scopes,
}
verifier := provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
return &OIDCProvider{
id: cfg.ID,
displayName: cfg.DisplayName,
issuerURL: cfg.IssuerURL,
config: oauthConfig,
verifier: verifier,
allowedDomains: cfg.AllowedDomains,
groupMapping: cfg.GroupMapping,
}, nil
}
func (p *OIDCProvider) ID() string { return p.id }
func (p *OIDCProvider) Type() string { return "oidc" }
func (p *OIDCProvider) DisplayName() string { return p.displayName }
func (p *OIDCProvider) AuthCodeURL(state string) string {
return p.config.AuthCodeURL(state)
}
func (p *OIDCProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
token, err := p.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc: exchange code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("oidc: no id_token in response")
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
}
// Extract claims
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"preferred_username"`
HD string `json:"hd"` // Google hosted domain
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: parse claims: %w", err)
}
// Extract raw claims for storage
var rawClaims map[string]any
if err := idToken.Claims(&rawClaims); err != nil {
rawClaims = map[string]any{"sub": claims.Sub}
}
// Validate domain restriction
if len(p.allowedDomains) > 0 {
if err := p.validateDomain(claims.Email, claims.HD); err != nil {
return nil, err
}
}
// Map groups through group mapping
var mappedGroups []string
if len(p.groupMapping) > 0 {
for _, group := range claims.Groups {
if mapped, ok := p.groupMapping[group]; ok {
mappedGroups = append(mappedGroups, mapped)
}
}
} else {
mappedGroups = claims.Groups
}
// Derive username from email if preferred_username is empty
username := claims.Username
if username == "" && claims.Email != "" {
parts := strings.SplitN(claims.Email, "@", 2)
username = parts[0]
}
return &ExternalUser{
ProviderID: p.id,
ExternalID: claims.Sub,
Email: claims.Email,
Name: claims.Name,
Username: username,
Groups: mappedGroups,
RawClaims: rawClaims,
}, nil
}
// validateDomain checks that the user's email domain is in the allowed list.
func (p *OIDCProvider) validateDomain(email, hostedDomain string) error {
// Prefer hd (hosted domain) claim when available (Google Workspace)
domain := hostedDomain
if domain == "" && email != "" {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
domain = parts[1]
}
}
if domain == "" {
return fmt.Errorf("oidc: no domain found in claims, required domains: %v", p.allowedDomains)
}
for _, allowed := range p.allowedDomains {
if strings.EqualFold(domain, allowed) {
return nil
}
}
return fmt.Errorf("oidc: domain %q not in allowed list %v", domain, p.allowedDomains)
}
+37
View File
@@ -0,0 +1,37 @@
// Package idp provides enterprise identity provider integrations for SynapBus.
// Supported providers: GitHub (OAuth), Google (OIDC), Azure AD (OIDC).
package idp
import "context"
// Provider is the interface that all identity providers must implement.
type Provider interface {
// ID returns the unique identifier for this provider (e.g., "github", "google", "azuread").
ID() string
// Type returns the provider type ("oauth" or "oidc").
Type() string
// DisplayName returns the human-readable name (e.g., "GitHub", "Google").
DisplayName() string
// AuthCodeURL generates the authorization URL with the given state parameter.
AuthCodeURL(state string) string
// Exchange trades an authorization code for user information.
Exchange(ctx context.Context, code string) (*ExternalUser, error)
}
// ExternalUser represents user information obtained from an external identity provider.
type ExternalUser struct {
ProviderID string
ExternalID string
Email string
Name string
Username string
Groups []string
RawClaims map[string]any
}
// ProviderInfo is a minimal representation of a provider for API responses.
type ProviderInfo struct {
ID string `json:"id"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
}
+97
View File
@@ -0,0 +1,97 @@
package idp
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// UserIdentity represents an external identity linked to a local user.
type UserIdentity struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Provider string `json:"provider"`
ExternalID string `json:"external_id"`
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
RawClaims string `json:"raw_claims"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserIdentityStore manages the user_identities table.
type UserIdentityStore struct {
db *sql.DB
}
// NewUserIdentityStore creates a new identity store backed by SQLite.
func NewUserIdentityStore(db *sql.DB) *UserIdentityStore {
return &UserIdentityStore{db: db}
}
// FindByProvider looks up a user ID by provider name and external ID.
// Returns sql.ErrNoRows if not found.
func (s *UserIdentityStore) FindByProvider(ctx context.Context, provider, externalID string) (int64, error) {
var userID int64
err := s.db.QueryRowContext(ctx,
`SELECT user_id FROM user_identities WHERE provider = ? AND external_id = ?`,
provider, externalID,
).Scan(&userID)
if err != nil {
return 0, err
}
return userID, nil
}
// Create links an external identity to a local user.
func (s *UserIdentityStore) Create(ctx context.Context, userID int64, provider, externalID, email, displayName string, rawClaims map[string]any) error {
claimsJSON, err := json.Marshal(rawClaims)
if err != nil {
claimsJSON = []byte("{}")
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO user_identities (user_id, provider, external_id, email, display_name, raw_claims, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
userID, provider, externalID, email, displayName, string(claimsJSON),
)
if err != nil {
return fmt.Errorf("create identity: %w", err)
}
return nil
}
// ListByUser returns all external identities linked to a user.
func (s *UserIdentityStore) ListByUser(ctx context.Context, userID int64) ([]UserIdentity, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, provider, external_id, email, display_name, raw_claims, created_at, updated_at
FROM user_identities WHERE user_id = ? ORDER BY created_at`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list identities: %w", err)
}
defer rows.Close()
var identities []UserIdentity
for rows.Next() {
var identity UserIdentity
var email, displayName sql.NullString
if err := rows.Scan(
&identity.ID, &identity.UserID, &identity.Provider,
&identity.ExternalID, &email, &displayName,
&identity.RawClaims, &identity.CreatedAt, &identity.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan identity: %w", err)
}
identity.Email = email.String
identity.DisplayName = displayName.String
identities = append(identities, identity)
}
if identities == nil {
identities = []UserIdentity{}
}
return identities, rows.Err()
}
+6 -2
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/ory/fosite"
)
@@ -107,8 +108,11 @@ func RequireBearer(provider fosite.OAuth2Provider, userStore UserStore) func(htt
token := parts[1]
_ = token
// Use fosite introspection
_, ar, err := provider.IntrospectToken(r.Context(), parts[1], fosite.AccessToken, new(fositeSession))
// Use fosite introspection — decouple from HTTP request context so
// token validation completes even if the client disconnects.
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
_, ar, err := provider.IntrospectToken(dbCtx, parts[1], fosite.AccessToken, new(fositeSession))
cancel()
if err != nil {
slog.Debug("bearer token validation failed", "error", err)
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
+55
View File
@@ -18,10 +18,12 @@ type UserStore interface {
CreateUser(ctx context.Context, username, password, displayName string) (*User, error)
GetUserByID(ctx context.Context, id int64) (*User, error)
GetUserByUsername(ctx context.Context, username string) (*User, error)
GetUserByEmail(ctx context.Context, email string) (*User, error)
UpdatePassword(ctx context.Context, userID int64, newPassword string) error
ListUsers(ctx context.Context) ([]*User, error)
CountUsers(ctx context.Context) (int, error)
VerifyPassword(ctx context.Context, username, password string) (*User, error)
UpdateDisplayName(ctx context.Context, userID int64, displayName string) error
}
// SQLiteUserStore implements UserStore using SQLite.
@@ -115,6 +117,36 @@ func (s *SQLiteUserStore) GetUserByID(ctx context.Context, id int64) (*User, err
return user, nil
}
// GetUserByEmail retrieves a user by their email address.
// Returns ErrUserNotFound if no user has the given email or if email is empty.
func (s *SQLiteUserStore) GetUserByEmail(ctx context.Context, email string) (*User, error) {
if email == "" {
return nil, ErrUserNotFound
}
user := &User{}
err := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, display_name, role, created_at, updated_at
FROM users WHERE email = ?`, email,
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.DisplayName,
&user.Role, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by email: %w", err)
}
return user, nil
}
// SetEmail updates a user's email address.
func (s *SQLiteUserStore) SetEmail(ctx context.Context, userID int64, email string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
email, userID,
)
return err
}
// GetUserByUsername retrieves a user by their username.
func (s *SQLiteUserStore) GetUserByUsername(ctx context.Context, username string) (*User, error) {
user := &User{}
@@ -215,3 +247,26 @@ func (s *SQLiteUserStore) VerifyPassword(ctx context.Context, username, password
return user, nil
}
// UpdateDisplayName changes a user's display name.
func (s *SQLiteUserStore) UpdateDisplayName(ctx context.Context, userID int64, displayName string) error {
displayName = strings.TrimSpace(displayName)
if displayName == "" {
return fmt.Errorf("display name cannot be empty")
}
result, err := s.db.ExecContext(ctx,
`UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
displayName, userID,
)
if err != nil {
return fmt.Errorf("update display name: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}
+118 -19
View File
@@ -5,11 +5,21 @@ import (
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
)
// ChannelSummary holds a joined channel with its unread message count.
type ChannelSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
UnreadCount int `json:"unread"`
LastMessageID int64 `json:"last_message_id"`
LastMessageAt *time.Time `json:"last_message_at"`
}
// Service provides business logic for channel operations.
type Service struct {
store ChannelStore
@@ -302,13 +312,13 @@ func (s *Service) KickFromChannel(ctx context.Context, channelID int64, agentNam
// ListChannels returns channels visible to the agent.
func (s *Service) ListChannels(ctx context.Context, agentName string) ([]*ChannelWithCount, error) {
channels, err := s.store.ListChannels(ctx, agentName)
chList, err := s.store.ListChannels(ctx, agentName)
if err != nil {
return nil, err
}
result := make([]*ChannelWithCount, len(channels))
for i, ch := range channels {
result := make([]*ChannelWithCount, len(chList))
for i, ch := range chList {
count, err := s.store.CountMembers(ctx, ch.ID)
if err != nil {
return nil, fmt.Errorf("count members for channel %d: %w", ch.ID, err)
@@ -328,6 +338,63 @@ func (s *Service) ListChannels(ctx context.Context, agentName string) ([]*Channe
return result, nil
}
// ListChannelsPaginated returns channels visible to the agent with pagination.
func (s *Service) ListChannelsPaginated(ctx context.Context, agentName string, opts ListChannelsOptions) (*PaginatedChannels, error) {
allChannels, err := s.store.ListChannels(ctx, agentName)
if err != nil {
return nil, err
}
total := len(allChannels)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
// Apply pagination
start := offset
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
page := allChannels[start:end]
result := make([]*ChannelWithCount, len(page))
for i, ch := range page {
count, err := s.store.CountMembers(ctx, ch.ID)
if err != nil {
return nil, fmt.Errorf("count members for channel %d: %w", ch.ID, err)
}
result[i] = &ChannelWithCount{
Channel: *ch,
MemberCount: count,
}
}
if s.tracer != nil {
s.tracer.Record(ctx, agentName, "channel.list", map[string]any{
"count": len(result),
"total": total,
})
}
return &PaginatedChannels{
Channels: result,
Total: total,
Offset: offset,
Limit: limit,
}, nil
}
// GetChannel returns a channel by ID.
func (s *Service) GetChannel(ctx context.Context, id int64) (*Channel, error) {
return s.store.GetChannel(ctx, id)
@@ -392,25 +459,55 @@ func (s *Service) UpdateChannel(ctx context.Context, channelID int64, req Update
return ch, nil
}
// UpdateChannelSettings updates the workflow-related settings for a channel.
func (s *Service) UpdateChannelSettings(ctx context.Context, channelID int64, settings ChannelSettings) (*Channel, error) {
store, ok := s.store.(*SQLiteChannelStore)
if !ok {
return nil, fmt.Errorf("channel store does not support settings update")
}
if err := store.UpdateChannelSettings(ctx, channelID, settings); err != nil {
return nil, err
}
// Reload channel to return updated state
ch, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, err
}
s.logger.Info("channel settings updated",
"channel_id", channelID,
"auto_approve", settings.AutoApprove,
)
return ch, nil
}
// BroadcastMessage sends a message to a channel. It creates a single channel
// message (visible in the channel timeline via GetChannelMessages) and also
// delivers individual DM notifications to each member's inbox.
// If the message body contains @mentions, mentioned members receive a
// "mention":true flag in their inbox notification metadata, and the channel
// message metadata includes "mentioned_agents".
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string) ([]*messaging.Message, error) {
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string, replyTo *int64, attachments []string) ([]*messaging.Message, error) {
ch, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, err
}
// Verify sender is a member
// Verify sender is a member; auto-join public channels on first send.
isMember, err := s.store.IsMember(ctx, channelID, fromAgent)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if !isMember {
return nil, ErrNotChannelMember
if ch.IsPrivate {
return nil, ErrNotChannelMember
}
if err := s.JoinChannel(ctx, channelID, fromAgent); err != nil {
return nil, fmt.Errorf("auto-join public channel: %w", err)
}
}
// Get members for mentions and inbox notifications
@@ -450,19 +547,21 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
channelMetaBytes, _ := json.Marshal(channelMetaObj)
channelMsg, err := s.msgService.SendMessage(ctx, fromAgent, "", body, messaging.SendOptions{
Subject: fmt.Sprintf("channel:%s", ch.Name),
Priority: priority,
Metadata: string(channelMetaBytes),
ChannelID: &channelID,
Subject: fmt.Sprintf("channel:%s", ch.Name),
Priority: priority,
Metadata: string(channelMetaBytes),
ChannelID: &channelID,
ReplyTo: replyTo,
Attachments: attachments,
})
if err != nil {
return nil, fmt.Errorf("create channel message: %w", err)
}
// 2. Deliver inbox notifications to other members.
// 2. Deliver inbox notifications only to @mentioned members.
recipientCount := 0
for _, m := range members {
if m.AgentName == fromAgent {
if m.AgentName == fromAgent || !mentionedMembers[m.AgentName] {
continue
}
@@ -470,12 +569,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
"channel_id": channelID,
"channel_name": ch.Name,
"channel_message_id": channelMsg.ID,
}
if len(mentionedAgentsList) > 0 {
inboxMetaObj["mentioned_agents"] = mentionedAgentsList
}
if mentionedMembers[m.AgentName] {
inboxMetaObj["mention"] = true
"mention": true,
}
inboxMetaBytes, _ := json.Marshal(inboxMetaObj)
@@ -485,7 +579,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
Metadata: string(inboxMetaBytes),
})
if err != nil {
s.logger.Error("failed to send channel notification",
s.logger.Error("failed to send mention notification",
"channel_id", channelID,
"from", fromAgent,
"to", m.AgentName,
@@ -520,6 +614,11 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
return []*messaging.Message{channelMsg}, nil
}
// GetChannelSummaries returns channels the agent has joined with unread message counts.
func (s *Service) GetChannelSummaries(ctx context.Context, agentName string) ([]ChannelSummary, error) {
return s.store.GetChannelSummaries(ctx, agentName)
}
// GetMembers returns all members of a channel.
func (s *Service) GetMembers(ctx context.Context, channelID int64) ([]*Membership, error) {
return s.store.GetMembers(ctx, channelID)
+66 -50
View File
@@ -533,7 +533,7 @@ func TestService_BroadcastMessage(t *testing.T) {
ch, _ := svc.CreateChannel(ctx, CreateChannelRequest{Name: "alerts", Type: TypeStandard, CreatedBy: "agent-a"})
t.Run("broadcast creates channel message", func(t *testing.T) {
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "")
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
@@ -549,12 +549,12 @@ func TestService_BroadcastMessage(t *testing.T) {
})
t.Run("broadcast visible via GetChannelMessages", func(t *testing.T) {
channelMsgs, err := svc.msgService.GetChannelMessages(ctx, ch.ID, 100)
channelResult, err := svc.msgService.GetChannelMessages(ctx, ch.ID, 100, 0)
if err != nil {
t.Fatalf("GetChannelMessages: %v", err)
}
found := false
for _, m := range channelMsgs {
for _, m := range channelResult.Messages {
if m.Body == "hello" && m.FromAgent == "agent-a" {
found = true
break
@@ -565,44 +565,69 @@ func TestService_BroadcastMessage(t *testing.T) {
}
})
t.Run("broadcast delivers inbox notifications", func(t *testing.T) {
t.Run("broadcast without mentions sends no DMs", func(t *testing.T) {
svc.JoinChannel(ctx, ch.ID, "agent-b")
svc.JoinChannel(ctx, ch.ID, "agent-c")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "multi-member test", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
// agent-b should have an inbox notification
inbox, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
found := false
for _, m := range inbox {
if m.Body == "multi-member test" {
found = true
break
// agent-b should NOT have an inbox notification (no @mention)
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
for _, m := range inboxResult.Messages {
if m.Body == "no-dm-test" {
t.Error("agent-b should not receive inbox DM for non-mention broadcast")
}
}
if !found {
t.Error("agent-b did not receive inbox notification")
}
})
t.Run("sender does not receive own message", func(t *testing.T) {
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "")
inbox, _ := svc.msgService.ReadInbox(ctx, "agent-a", messaging.ReadOptions{IncludeRead: true})
for _, m := range inbox {
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "", nil, nil)
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-a", messaging.ReadOptions{IncludeRead: true})
for _, m := range inboxResult.Messages {
if m.Body == "no self-message" {
t.Error("sender should not receive their own broadcast in inbox")
}
}
})
t.Run("non-member cannot broadcast", func(t *testing.T) {
// agent-c is a member but let's test someone who isn't
t.Run("non-member auto-joins public channel on broadcast", func(t *testing.T) {
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "unauthorized", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "", nil, nil)
if err != nil {
t.Fatalf("expected auto-join for public channel, got %v", err)
}
isMember, _ := svc.IsMember(ctx, ch.ID, "outsider")
if !isMember {
t.Error("outsider should be a member after auto-join")
}
})
t.Run("broadcast with reply_to", func(t *testing.T) {
msgs, _ := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "original", 5, "", nil, nil)
original := msgs[0]
replies, err := svc.BroadcastMessage(ctx, ch.ID, "agent-b", "reply to original", 5, "", &original.ID, nil)
if err != nil {
t.Fatalf("BroadcastMessage with reply_to: %v", err)
}
if replies[0].ReplyTo == nil || *replies[0].ReplyTo != original.ID {
t.Errorf("reply_to = %v, want %d", replies[0].ReplyTo, original.ID)
}
})
t.Run("non-member cannot broadcast to private channel", func(t *testing.T) {
privCh, err := svc.CreateChannel(ctx, CreateChannelRequest{
Name: "private-test", Type: TypeStandard, IsPrivate: true, CreatedBy: "agent-a",
})
if err != nil {
t.Fatalf("create private channel: %v", err)
}
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "", nil, nil)
if !errors.Is(err, ErrNotChannelMember) {
t.Errorf("expected ErrNotChannelMember, got %v", err)
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
}
})
@@ -619,15 +644,15 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
svc.JoinChannel(ctx, ch.ID, "agent-c")
t.Run("mentioned member gets mention flag in inbox", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
// agent-b was mentioned — inbox notification should have mention:true
inbox, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
found := false
for _, m := range inbox {
for _, m := range inboxResult.Messages {
if m.Body == "hey @agent-b check this" {
found = true
var meta map[string]any
@@ -642,33 +667,24 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
t.Error("agent-b did not receive inbox notification")
}
// agent-c was NOT mentioned — inbox notification should NOT have mention:true
inbox, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
for _, m := range inbox {
// agent-c was NOT mentioned — should NOT receive an inbox DM at all
inboxResult, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
for _, m := range inboxResult.Messages {
if m.Body == "hey @agent-b check this" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
if meta["mention"] == true {
t.Error("agent-c should NOT have mention flag")
}
// But should still have mentioned_agents list
if _, ok := meta["mentioned_agents"]; !ok {
t.Error("agent-c metadata should have mentioned_agents list")
}
break
t.Error("agent-c should not receive inbox DM when not @mentioned")
}
}
})
t.Run("channel message metadata includes mentioned_agents", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
channelResult2, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10, 0)
found := false
for _, m := range channelMsgs {
for _, m := range channelResult2.Messages {
if m.Body == "cc @agent-b and @agent-c" {
found = true
var meta map[string]any
@@ -691,13 +707,13 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
})
t.Run("self-mention is excluded", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
channelResult3, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10, 0)
for _, m := range channelResult3.Messages {
if m.Body == "I am @agent-a and cc @agent-b" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
@@ -716,13 +732,13 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
})
t.Run("no mentions produces no mention metadata", func(t *testing.T) {
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
channelResult4, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10, 0)
for _, m := range channelResult4.Messages {
if m.Body == "just a normal message" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
@@ -736,13 +752,13 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
t.Run("non-member mention is ignored", func(t *testing.T) {
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "", nil, nil)
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
channelMsgs, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10)
for _, m := range channelMsgs {
channelResult5, _ := svc.msgService.GetChannelMessages(ctx, ch.ID, 10, 0)
for _, m := range channelResult5.Messages {
if m.Body == "hey @outsider and @agent-b" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
+88 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"strings"
"time"
)
// ChannelStore defines the storage interface for channel operations.
@@ -14,6 +15,7 @@ type ChannelStore interface {
GetChannel(ctx context.Context, id int64) (*Channel, error)
GetChannelByName(ctx context.Context, name string) (*Channel, error)
ListChannels(ctx context.Context, agentName string) ([]*Channel, error)
CountChannels(ctx context.Context, agentName string) (int, error)
UpdateChannel(ctx context.Context, ch *Channel) error
DeleteChannel(ctx context.Context, id int64) error
AddMember(ctx context.Context, m *Membership) error
@@ -26,6 +28,7 @@ type ChannelStore interface {
GetInvite(ctx context.Context, channelID int64, agentName string) (*ChannelInvite, error)
HasPendingInvite(ctx context.Context, channelID int64, agentName string) (bool, error)
AcceptInvite(ctx context.Context, channelID int64, agentName string) error
GetChannelSummaries(ctx context.Context, agentName string) ([]ChannelSummary, error)
}
// SQLiteChannelStore implements ChannelStore using SQLite.
@@ -80,9 +83,9 @@ func (s *SQLiteChannelStore) GetChannel(ctx context.Context, id int64) (*Channel
var ch Channel
var isPrivate, isSystem int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
`SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, publish_threshold, approve_threshold, created_at, updated_at
FROM channels WHERE id = ?`, id,
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrChannelNotFound
@@ -99,9 +102,9 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
var ch Channel
var isPrivate, isSystem int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at
`SELECT id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, publish_threshold, approve_threshold, created_at, updated_at
FROM channels WHERE LOWER(name) = LOWER(?)`, name,
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt)
).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrChannelNotFound
@@ -117,7 +120,7 @@ func (s *SQLiteChannelStore) GetChannelByName(ctx context.Context, name string)
// is a member or has a pending invite.
func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string) ([]*Channel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.created_at, c.updated_at
`SELECT DISTINCT c.id, c.name, c.description, c.topic, c.type, c.is_private, c.is_system, c.created_by, c.workflow_enabled, c.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, c.publish_threshold, c.approve_threshold, c.created_at, c.updated_at
FROM channels c
WHERE c.is_private = 0
OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.id AND cm.agent_name = ?)
@@ -134,7 +137,7 @@ func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string)
for rows.Next() {
var ch Channel
var isPrivate, isSystem int
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Topic, &ch.Type, &isPrivate, &isSystem, &ch.CreatedBy, &ch.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &ch.PublishThreshold, &ch.ApproveThreshold, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan channel: %w", err)
}
ch.IsPrivate = isPrivate != 0
@@ -147,6 +150,23 @@ func (s *SQLiteChannelStore) ListChannels(ctx context.Context, agentName string)
return channels, rows.Err()
}
// CountChannels returns the total number of channels visible to the agent.
func (s *SQLiteChannelStore) CountChannels(ctx context.Context, agentName string) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(DISTINCT c.id)
FROM channels c
WHERE c.is_private = 0
OR EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = c.id AND cm.agent_name = ?)
OR EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND ci.agent_name = ? AND ci.status = 'pending')`,
agentName, agentName,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count channels: %w", err)
}
return count, nil
}
// UpdateChannel updates a channel's mutable fields.
func (s *SQLiteChannelStore) UpdateChannel(ctx context.Context, ch *Channel) error {
result, err := s.db.ExecContext(ctx,
@@ -350,6 +370,68 @@ func (s *SQLiteChannelStore) AcceptInvite(ctx context.Context, channelID int64,
return nil
}
// GetChannelSummaries returns channels the agent has joined with unread message counts.
func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName string) ([]ChannelSummary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name,
(SELECT COUNT(*) FROM messages m
WHERE m.channel_id = c.id
AND m.id > COALESCE(
(SELECT MAX(ist.last_read_message_id) FROM inbox_state ist
WHERE ist.agent_name = ? AND ist.conversation_id = m.conversation_id), 0)
AND m.from_agent != ?
) AS unread_count,
COALESCE((SELECT MAX(m3.id) FROM messages m3 WHERE m3.channel_id = c.id), 0) AS last_message_id,
(SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id) AS last_message_at
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id AND cm.agent_name = ?
ORDER BY c.name`,
agentName, agentName, agentName,
)
if err != nil {
return nil, fmt.Errorf("get channel summaries: %w", err)
}
defer rows.Close()
var summaries []ChannelSummary
for rows.Next() {
var cs ChannelSummary
var lastMsg sql.NullString
if err := rows.Scan(&cs.ID, &cs.Name, &cs.UnreadCount, &cs.LastMessageID, &lastMsg); err != nil {
return nil, fmt.Errorf("scan channel summary: %w", err)
}
if lastMsg.Valid {
if t, err := time.Parse("2006-01-02T15:04:05Z", lastMsg.String); err == nil {
cs.LastMessageAt = &t
} else if t, err := time.Parse("2006-01-02 15:04:05", lastMsg.String); err == nil {
cs.LastMessageAt = &t
}
}
summaries = append(summaries, cs)
}
if summaries == nil {
summaries = []ChannelSummary{}
}
return summaries, rows.Err()
}
// UpdateChannelSettings updates the workflow-related settings for a channel.
func (s *SQLiteChannelStore) UpdateChannelSettings(ctx context.Context, id int64, settings ChannelSettings) error {
result, err := s.db.ExecContext(ctx,
`UPDATE channels SET workflow_enabled = ?, auto_approve = ?, stalemate_remind_after = ?, stalemate_escalate_after = ?, publish_threshold = ?, approve_threshold = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
settings.WorkflowEnabled, settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, settings.PublishThreshold, settings.ApproveThreshold, id,
)
if err != nil {
return fmt.Errorf("update channel settings: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return ErrChannelNotFound
}
s.logger.Info("channel settings updated", "id", id)
return nil
}
// isUniqueConstraintError checks if an error is a SQLite unique constraint violation.
func isUniqueConstraintError(err error) bool {
return strings.Contains(err.Error(), "UNIQUE constraint failed")
+35
View File
@@ -266,6 +266,41 @@ func (s *SwarmService) ListTasks(ctx context.Context, channelID int64, status st
return s.taskStore.ListTasks(ctx, channelID, status)
}
// ListTasksPaginated returns tasks for a channel with pagination.
func (s *SwarmService) ListTasksPaginated(ctx context.Context, channelID int64, status string, limit, offset int) (*PaginatedTasks, error) {
tasks, err := s.taskStore.ListTasks(ctx, channelID, status)
if err != nil {
return nil, err
}
total := len(tasks)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
// Apply pagination in memory (task lists are typically small)
start := offset
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
page := tasks[start:end]
return &PaginatedTasks{
Tasks: page,
Total: total,
Offset: offset,
Limit: limit,
}, nil
}
// GetTaskWithBids returns a task and all its bids.
func (s *SwarmService) GetTaskWithBids(ctx context.Context, taskID int64) (*Task, []*Bid, error) {
task, err := s.taskStore.GetTask(ctx, taskID)
+23
View File
@@ -14,6 +14,7 @@ type TaskStore interface {
CreateTask(ctx context.Context, task *Task) error
GetTask(ctx context.Context, id int64) (*Task, error)
ListTasks(ctx context.Context, channelID int64, status string) ([]*Task, error)
CountTasks(ctx context.Context, channelID int64, status string) (int, error)
UpdateTaskStatus(ctx context.Context, id int64, status, assignedTo string) error
CreateBid(ctx context.Context, bid *Bid) error
GetBids(ctx context.Context, taskID int64) ([]*Bid, error)
@@ -130,6 +131,28 @@ func (s *SQLiteTaskStore) ListTasks(ctx context.Context, channelID int64, status
return scanTasks(rows)
}
// CountTasks returns the total number of tasks for a channel, optionally filtered by status.
func (s *SQLiteTaskStore) CountTasks(ctx context.Context, channelID int64, status string) (int, error) {
var count int
var err error
if status != "" {
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tasks WHERE channel_id = ? AND status = ?`,
channelID, status,
).Scan(&count)
} else {
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM tasks WHERE channel_id = ?`,
channelID,
).Scan(&count)
}
if err != nil {
return 0, fmt.Errorf("count tasks: %w", err)
}
return count, nil
}
// UpdateTaskStatus updates a task's status and optionally assigned_to.
func (s *SQLiteTaskStore) UpdateTaskStatus(ctx context.Context, id int64, status, assignedTo string) error {
var result sql.Result
+48 -10
View File
@@ -25,16 +25,22 @@ const (
// Channel represents a named group communication space.
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
IsSystem bool `json:"is_system"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
IsSystem bool `json:"is_system"`
CreatedBy string `json:"created_by"`
WorkflowEnabled bool `json:"workflow_enabled"`
AutoApprove bool `json:"auto_approve"`
StalemateRemindAfter string `json:"stalemate_remind_after"`
StalemateEscalateAfter string `json:"stalemate_escalate_after"`
PublishThreshold float64 `json:"publish_threshold"`
ApproveThreshold float64 `json:"approve_threshold"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChannelWithCount embeds Channel and adds a member count.
@@ -43,6 +49,28 @@ type ChannelWithCount struct {
MemberCount int `json:"member_count"`
}
// PaginatedChannels holds a page of channels with total count.
type PaginatedChannels struct {
Channels []*ChannelWithCount `json:"channels"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// ListChannelsOptions configures channel listing behavior.
type ListChannelsOptions struct {
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// PaginatedTasks holds a page of tasks with total count.
type PaginatedTasks struct {
Tasks []*Task `json:"tasks"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// Membership represents the relationship between an agent and a channel.
type Membership struct {
ID int64 `json:"id"`
@@ -85,6 +113,16 @@ type JoinChannelRequest struct {
AgentName string `json:"agent_name"`
}
// ChannelSettings holds workflow-related settings for a channel.
type ChannelSettings struct {
WorkflowEnabled bool `json:"workflow_enabled"`
AutoApprove bool `json:"auto_approve"`
StalemateRemindAfter string `json:"stalemate_remind_after"`
StalemateEscalateAfter string `json:"stalemate_escalate_after"`
PublishThreshold float64 `json:"publish_threshold"`
ApproveThreshold float64 `json:"approve_threshold"`
}
// InviteRequest is the input for inviting an agent to a channel.
type InviteRequest struct {
ChannelID int64 `json:"channel_id"`
+83
View File
@@ -0,0 +1,83 @@
package jsruntime
import (
"context"
"fmt"
"sync"
)
// Pool manages a pool of reusable goja VMs for concurrent JavaScript execution.
// Each Execute call acquires a VM from the pool, runs the code, then releases
// the VM back. If all VMs are in use, callers block until one becomes available
// or the context is cancelled.
type Pool struct {
size int
available chan struct{} // semaphore — each token represents a "slot"
mu sync.Mutex
closed bool
}
// NewPool creates a new runtime pool with the given concurrency limit.
// The size determines how many concurrent Execute calls can run simultaneously.
func NewPool(size int) *Pool {
if size < 1 {
size = 1
}
p := &Pool{
size: size,
available: make(chan struct{}, size),
}
// Fill the semaphore
for i := 0; i < size; i++ {
p.available <- struct{}{}
}
return p
}
// Execute acquires a slot from the pool, runs the code, and releases the slot.
// A fresh goja VM is created for each execution to ensure clean state isolation.
// Blocks if all slots are in use; respects context cancellation.
func (p *Pool) Execute(ctx context.Context, code string, caller ToolCaller, opts ExecuteOptions) (*ExecuteResult, error) {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return nil, fmt.Errorf("pool is closed")
}
p.mu.Unlock()
// Acquire a slot (blocks if pool is exhausted)
select {
case <-p.available:
// Got a slot
case <-ctx.Done():
return nil, ctx.Err()
}
// Always release the slot when done
defer func() {
p.available <- struct{}{}
}()
// Execute with a fresh VM (created inside Execute)
return Execute(ctx, code, caller, opts)
}
// Size returns the configured pool size.
func (p *Pool) Size() int {
return p.size
}
// Available returns the number of available slots.
func (p *Pool) Available() int {
return len(p.available)
}
// Close marks the pool as closed. Subsequent Execute calls will return an error.
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
}
+189
View File
@@ -0,0 +1,189 @@
package jsruntime
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestNewPool(t *testing.T) {
p := NewPool(5)
if p.Size() != 5 {
t.Errorf("expected size 5, got %d", p.Size())
}
if p.Available() != 5 {
t.Errorf("expected 5 available, got %d", p.Available())
}
}
func TestNewPool_MinSize(t *testing.T) {
p := NewPool(0)
if p.Size() != 1 {
t.Errorf("expected size clamped to 1, got %d", p.Size())
}
p2 := NewPool(-5)
if p2.Size() != 1 {
t.Errorf("expected size clamped to 1, got %d", p2.Size())
}
}
func TestPool_Execute(t *testing.T) {
p := NewPool(3)
defer p.Close()
caller := newMockCaller()
result, err := p.Execute(context.Background(), `42`, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var num int64
switch v := result.Value.(type) {
case int64:
num = v
case float64:
num = int64(v)
}
if num != 42 {
t.Errorf("expected 42, got %v", result.Value)
}
}
func TestPool_ConcurrentExecution(t *testing.T) {
poolSize := 3
p := NewPool(poolSize)
defer p.Close()
numGoroutines := 20
var wg sync.WaitGroup
var successCount int32
var errCount int32
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
caller := newMockCaller()
result, err := p.Execute(context.Background(), `({ value: 1 })`, caller, ExecuteOptions{})
if err != nil {
atomic.AddInt32(&errCount, 1)
return
}
if result != nil {
atomic.AddInt32(&successCount, 1)
}
}(i)
}
wg.Wait()
if int(errCount) > 0 {
t.Errorf("expected 0 errors, got %d", errCount)
}
if int(successCount) != numGoroutines {
t.Errorf("expected %d successes, got %d", numGoroutines, successCount)
}
// All slots should be available again
if p.Available() != poolSize {
t.Errorf("expected %d available after all complete, got %d", poolSize, p.Available())
}
}
func TestPool_BlocksWhenExhausted(t *testing.T) {
p := NewPool(1)
defer p.Close()
// Occupy the only slot with a long-running script
started := make(chan struct{})
done := make(chan struct{})
go func() {
caller := newMockCaller()
close(started)
p.Execute(context.Background(), `
var i = 0;
while(i < 1000000) { i++; }
i
`, caller, ExecuteOptions{})
close(done)
}()
<-started
// Give the goroutine a moment to acquire the slot
time.Sleep(10 * time.Millisecond)
// Try to execute with a short timeout — should fail because the slot is occupied
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
caller := newMockCaller()
_, err := p.Execute(ctx, `42`, caller, ExecuteOptions{})
if err == nil {
t.Error("expected context deadline error when pool is exhausted, got nil")
}
// Wait for first execution to finish
<-done
}
func TestPool_ClosedPoolRejectsExecute(t *testing.T) {
p := NewPool(3)
p.Close()
caller := newMockCaller()
_, err := p.Execute(context.Background(), `42`, caller, ExecuteOptions{})
if err == nil {
t.Error("expected error from closed pool, got nil")
}
}
func TestPool_WithCallBridge(t *testing.T) {
p := NewPool(2)
defer p.Close()
caller := newMockCaller()
caller.results["greet"] = map[string]any{"greeting": "hello"}
code := `
var res = call("greet", { name: "world" });
res.ok ? res.result.greeting : "error"
`
result, err := p.Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Value != "hello" {
t.Errorf("expected 'hello', got %v", result.Value)
}
if result.CallCount != 1 {
t.Errorf("expected CallCount=1, got %d", result.CallCount)
}
}
func TestPool_IsolationBetweenExecutions(t *testing.T) {
p := NewPool(1)
defer p.Close()
caller := newMockCaller()
// First execution sets a variable
_, err := p.Execute(context.Background(), `var shared = 42; shared`, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("first execution failed: %v", err)
}
// Second execution should not see the variable from the first
_, err = p.Execute(context.Background(), `
typeof shared === "undefined" ? "isolated" : "leaked"
`, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("second execution failed: %v", err)
}
// Note: since Execute creates a fresh VM each time, isolation is guaranteed
}
+273
View File
@@ -0,0 +1,273 @@
package jsruntime
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync/atomic"
"time"
"github.com/dop251/goja"
)
// Execute runs JavaScript or TypeScript code in a sandboxed goja VM.
//
// If the code looks like TypeScript (contains type annotations, interfaces,
// generics, etc.), it is automatically transpiled to JavaScript before execution.
//
// A global `call(actionName, args)` function is provided that bridges to the
// supplied ToolCaller. It returns {ok: true, result: ...} or {ok: false, error: ...}.
//
// The value of the last expression in the code is returned as ExecuteResult.Value.
func Execute(ctx context.Context, code string, caller ToolCaller, opts ExecuteOptions) (*ExecuteResult, error) {
opts = opts.defaults()
start := time.Now()
// Auto-detect and transpile TypeScript
if looksLikeTypeScript(code) {
transpiled, err := TranspileTypeScript(code)
if err != nil {
return nil, err
}
code = transpiled
}
// Handle empty code
if len(code) == 0 {
return &ExecuteResult{
Value: nil,
CallCount: 0,
Duration: time.Since(start),
}, nil
}
// Create VM and set up sandbox
vm := goja.New()
setupSandbox(vm)
// Track call count
var callCount int32
// Register call() global function
callFn := makeCallFunction(ctx, vm, caller, opts.MaxCalls, &callCount)
if err := vm.Set("call", callFn); err != nil {
return nil, &ExecError{
Code: CodeRuntimeError,
Message: fmt.Sprintf("failed to register call() function: %v", err),
}
}
// Set up timeout via context
timeoutCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
// Run in a goroutine so we can enforce timeout
type execResult struct {
value goja.Value
err error
}
resultCh := make(chan execResult, 1)
go func() {
// Compile first to get better syntax error reporting
prog, compileErr := goja.Compile("", code, false)
if compileErr != nil {
resultCh <- execResult{err: compileErr}
return
}
val, runErr := vm.RunProgram(prog)
resultCh <- execResult{value: val, err: runErr}
}()
// Monitor for timeout — interrupt the VM
go func() {
<-timeoutCtx.Done()
if timeoutCtx.Err() == context.DeadlineExceeded {
vm.Interrupt("execution timeout")
}
}()
select {
case res := <-resultCh:
duration := time.Since(start)
if res.err != nil {
return nil, classifyError(res.err)
}
// Export result
exported := res.value.Export()
// Validate JSON serializability
if err := validateSerializable(exported); err != nil {
return nil, &ExecError{
Code: CodeRuntimeError,
Message: fmt.Sprintf("result is not JSON-serializable: %v", err),
}
}
return &ExecuteResult{
Value: exported,
CallCount: int(atomic.LoadInt32(&callCount)),
Duration: duration,
}, nil
case <-timeoutCtx.Done():
vm.Interrupt("execution timeout")
return nil, &ExecError{
Code: CodeTimeout,
Message: fmt.Sprintf("execution exceeded timeout of %s", opts.Timeout),
}
}
}
// setupSandbox disables dangerous global APIs in the VM.
func setupSandbox(vm *goja.Runtime) {
// Disable module loading
vm.Set("require", goja.Undefined())
vm.Set("import", goja.Undefined())
// Disable async operations
vm.Set("setTimeout", goja.Undefined())
vm.Set("setInterval", goja.Undefined())
vm.Set("clearTimeout", goja.Undefined())
vm.Set("clearInterval", goja.Undefined())
// Disable network access
vm.Set("fetch", goja.Undefined())
vm.Set("XMLHttpRequest", goja.Undefined())
// Disable process/system access
vm.Set("process", goja.Undefined())
// Note: goja does not provide filesystem or network access by default,
// so we only need to block APIs that could be expected by JS code.
}
// makeCallFunction creates the call(actionName, args) bridge function.
func makeCallFunction(
ctx context.Context,
vm *goja.Runtime,
caller ToolCaller,
maxCalls int,
callCount *int32,
) func(goja.FunctionCall) goja.Value {
return func(fc goja.FunctionCall) goja.Value {
// Validate arguments
if len(fc.Arguments) < 2 {
return vm.ToValue(map[string]any{
"ok": false,
"error": map[string]any{
"code": "INVALID_ARGS",
"message": "call() requires 2 arguments: actionName (string), args (object)",
},
})
}
// Extract and validate actionName
actionName := fc.Arguments[0].String()
if actionName == "" || actionName == "undefined" {
return vm.ToValue(map[string]any{
"ok": false,
"error": map[string]any{
"code": "INVALID_ARGS",
"message": "actionName must be a non-empty string",
},
})
}
// Extract and validate args
argsExported := fc.Arguments[1].Export()
args, ok := argsExported.(map[string]any)
if !ok {
return vm.ToValue(map[string]any{
"ok": false,
"error": map[string]any{
"code": "INVALID_ARGS",
"message": "args must be an object",
},
})
}
// Enforce MaxCalls limit
count := atomic.AddInt32(callCount, 1)
if int(count) > maxCalls {
return vm.ToValue(map[string]any{
"ok": false,
"error": map[string]any{
"code": CodeMaxCallsExceeded,
"message": fmt.Sprintf("exceeded maximum of %d call() invocations", maxCalls),
},
})
}
// Bridge to ToolCaller with context propagation
result, err := caller.Call(ctx, actionName, args)
if err != nil {
slog.Warn("call() bridge error",
"action", actionName,
"error", err,
)
return vm.ToValue(map[string]any{
"ok": false,
"error": map[string]any{
"code": "CALL_ERROR",
"message": err.Error(),
},
})
}
return vm.ToValue(map[string]any{
"ok": true,
"result": result,
})
}
}
// classifyError converts a goja error into a structured ExecError.
func classifyError(err error) *ExecError {
if err == nil {
return nil
}
// Check for interrupt (timeout)
if interrupted, ok := err.(*goja.InterruptedError); ok {
return &ExecError{
Code: CodeTimeout,
Message: interrupted.Error(),
}
}
// Check for syntax error from compilation
if syntaxErr, ok := err.(*goja.CompilerSyntaxError); ok {
return &ExecError{
Code: CodeSyntaxError,
Message: syntaxErr.Error(),
}
}
// Check for JS exception (runtime error)
if exception, ok := err.(*goja.Exception); ok {
return &ExecError{
Code: CodeRuntimeError,
Message: exception.Error(),
Stack: exception.String(),
}
}
// Generic error
return &ExecError{
Code: CodeRuntimeError,
Message: err.Error(),
}
}
// validateSerializable checks whether the value can be marshaled to JSON.
func validateSerializable(value any) error {
if value == nil {
return nil
}
_, err := json.Marshal(value)
return err
}
+524
View File
@@ -0,0 +1,524 @@
package jsruntime
import (
"context"
"fmt"
"testing"
"time"
)
// mockCaller implements ToolCaller for testing.
type mockCaller struct {
calls []mockCall
results map[string]any
errors map[string]error
}
type mockCall struct {
Action string
Args map[string]any
}
func newMockCaller() *mockCaller {
return &mockCaller{
results: make(map[string]any),
errors: make(map[string]error),
}
}
func (m *mockCaller) Call(_ context.Context, actionName string, args map[string]any) (any, error) {
m.calls = append(m.calls, mockCall{Action: actionName, Args: args})
if err, ok := m.errors[actionName]; ok {
return nil, err
}
if result, ok := m.results[actionName]; ok {
return result, nil
}
return map[string]any{"ok": true}, nil
}
func TestExecute(t *testing.T) {
tests := []struct {
name string
code string
opts ExecuteOptions
wantValue any
wantErr string // substring match on error code
}{
{
name: "simple integer",
code: `42`,
wantValue: int64(42),
},
{
name: "simple string",
code: `"hello"`,
wantValue: "hello",
},
{
name: "object literal",
code: `({ a: 1, b: "two" })`,
},
{
name: "arithmetic expression",
code: `2 + 3 * 4`,
},
{
name: "null value",
code: `null`,
wantValue: nil,
},
{
name: "array",
code: `[1, 2, 3]`,
},
{
name: "boolean true",
code: `true`,
wantValue: true,
},
{
name: "boolean false",
code: `false`,
wantValue: false,
},
{
name: "empty code",
code: "",
wantValue: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := newMockCaller()
result, err := Execute(context.Background(), tt.code, caller, tt.opts)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
execErr, ok := err.(*ExecError)
if !ok {
t.Fatalf("expected *ExecError, got %T: %v", err, err)
}
if execErr.Code != tt.wantErr {
t.Errorf("expected error code %q, got %q", tt.wantErr, execErr.Code)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantValue != nil && result.Value != tt.wantValue {
t.Errorf("expected value %v (%T), got %v (%T)", tt.wantValue, tt.wantValue, result.Value, result.Value)
}
})
}
}
func TestExecute_SyntaxError(t *testing.T) {
caller := newMockCaller()
_, err := Execute(context.Background(), `{ invalid syntax`, caller, ExecuteOptions{})
if err == nil {
t.Fatal("expected syntax error, got nil")
}
execErr, ok := err.(*ExecError)
if !ok {
t.Fatalf("expected *ExecError, got %T", err)
}
if execErr.Code != CodeSyntaxError {
t.Errorf("expected code %q, got %q", CodeSyntaxError, execErr.Code)
}
}
func TestExecute_RuntimeError(t *testing.T) {
caller := newMockCaller()
_, err := Execute(context.Background(), `throw new Error("boom")`, caller, ExecuteOptions{})
if err == nil {
t.Fatal("expected runtime error, got nil")
}
execErr, ok := err.(*ExecError)
if !ok {
t.Fatalf("expected *ExecError, got %T", err)
}
if execErr.Code != CodeRuntimeError {
t.Errorf("expected code %q, got %q", CodeRuntimeError, execErr.Code)
}
if execErr.Stack == "" {
t.Error("expected non-empty stack trace for runtime error")
}
}
func TestExecute_Timeout(t *testing.T) {
caller := newMockCaller()
start := time.Now()
_, err := Execute(context.Background(), `while(true) {}`, caller, ExecuteOptions{
Timeout: 100 * time.Millisecond,
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
execErr, ok := err.(*ExecError)
if !ok {
t.Fatalf("expected *ExecError, got %T", err)
}
if execErr.Code != CodeTimeout {
t.Errorf("expected code %q, got %q", CodeTimeout, execErr.Code)
}
// Should complete within a reasonable margin of the timeout
if elapsed > 2*time.Second {
t.Errorf("timeout took too long: %v", elapsed)
}
}
func TestExecute_CallBridge(t *testing.T) {
caller := newMockCaller()
caller.results["get_user"] = map[string]any{
"name": "alice",
"id": 42,
}
code := `
var res = call("get_user", { id: 1 });
if (!res.ok) throw new Error("failed");
({ name: res.result.name })
`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
if caller.calls[0].Action != "get_user" {
t.Errorf("expected action 'get_user', got %q", caller.calls[0].Action)
}
if result.CallCount != 1 {
t.Errorf("expected CallCount=1, got %d", result.CallCount)
}
resultMap, ok := result.Value.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result.Value)
}
if resultMap["name"] != "alice" {
t.Errorf("expected name='alice', got %v", resultMap["name"])
}
}
func TestExecute_CallBridgeError(t *testing.T) {
caller := newMockCaller()
caller.errors["fail_action"] = fmt.Errorf("upstream error")
code := `
var res = call("fail_action", {});
({ ok: res.ok, code: res.error.code })
`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap := result.Value.(map[string]any)
if resultMap["ok"] != false {
t.Errorf("expected ok=false, got %v", resultMap["ok"])
}
if resultMap["code"] != "CALL_ERROR" {
t.Errorf("expected code='CALL_ERROR', got %v", resultMap["code"])
}
}
func TestExecute_CallInvalidArgs(t *testing.T) {
tests := []struct {
name string
code string
}{
{"no arguments", `call()`},
{"one argument", `call("action")`},
{"args not object", `call("action", "not_an_object")`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := newMockCaller()
code := fmt.Sprintf(`
var res = %s;
({ ok: res.ok, code: res.error.code })
`, tt.code)
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap := result.Value.(map[string]any)
if resultMap["ok"] != false {
t.Errorf("expected ok=false, got %v", resultMap["ok"])
}
if resultMap["code"] != "INVALID_ARGS" {
t.Errorf("expected code='INVALID_ARGS', got %v", resultMap["code"])
}
})
}
}
func TestExecute_MaxCalls(t *testing.T) {
caller := newMockCaller()
caller.results["action"] = "ok"
code := `
var results = [];
for (var i = 0; i < 10; i++) {
var res = call("action", {});
results.push({ ok: res.ok, code: res.error ? res.error.code : null });
}
({ results: results, total: results.length })
`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{
MaxCalls: 3,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only 3 calls should have succeeded upstream
if len(caller.calls) != 3 {
t.Errorf("expected 3 upstream calls, got %d", len(caller.calls))
}
resultMap := result.Value.(map[string]any)
results := resultMap["results"].([]any)
// First 3 should be ok, rest should be MAX_CALLS_EXCEEDED
for i, r := range results {
rm := r.(map[string]any)
if i < 3 {
if rm["ok"] != true {
t.Errorf("call %d: expected ok=true, got %v", i, rm["ok"])
}
} else {
if rm["ok"] != false {
t.Errorf("call %d: expected ok=false, got %v", i, rm["ok"])
}
if rm["code"] != CodeMaxCallsExceeded {
t.Errorf("call %d: expected code=%q, got %v", i, CodeMaxCallsExceeded, rm["code"])
}
}
}
}
func TestExecute_MultipleCallsInLoop(t *testing.T) {
caller := newMockCaller()
caller.results["add"] = map[string]any{"sum": 10}
code := `
var total = 0;
for (var i = 0; i < 5; i++) {
var res = call("add", { a: i, b: 1 });
if (res.ok) total++;
}
({ total: total })
`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caller.calls) != 5 {
t.Fatalf("expected 5 calls, got %d", len(caller.calls))
}
if result.CallCount != 5 {
t.Errorf("expected CallCount=5, got %d", result.CallCount)
}
}
func TestExecute_SandboxBlockedAPIs(t *testing.T) {
blockedAPIs := []struct {
name string
code string
}{
{"require", `require("fs")`},
{"fetch", `fetch("http://example.com")`},
{"setTimeout", `setTimeout(function(){}, 100)`},
{"setInterval", `setInterval(function(){}, 100)`},
{"clearTimeout", `clearTimeout(1)`},
{"clearInterval", `clearInterval(1)`},
{"XMLHttpRequest", `new XMLHttpRequest()`},
{"process.env", `process.env.HOME`},
}
for _, tt := range blockedAPIs {
t.Run(tt.name, func(t *testing.T) {
caller := newMockCaller()
result, err := Execute(context.Background(), tt.code, caller, ExecuteOptions{})
// Should either error or return undefined (not execute the blocked API)
if err == nil && result.Ok() {
// If it succeeds, the value should be undefined/nil (the API was replaced with undefined)
// This is acceptable — the key thing is the API doesn't actually work
t.Logf("%s returned: %v (blocked by sandbox)", tt.name, result.Value)
}
})
}
}
// Ok is a helper for test assertions.
func (r *ExecuteResult) Ok() bool {
return r != nil
}
func TestExecute_TypeScriptAutoDetect(t *testing.T) {
caller := newMockCaller()
code := `const x: number = 42; const msg: string = "hello"; ({ result: x, message: msg })`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap, ok := result.Value.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", result.Value)
}
// goja exports numbers as int64 or float64
var num int64
switch v := resultMap["result"].(type) {
case int64:
num = v
case float64:
num = int64(v)
default:
t.Fatalf("expected numeric result, got %T", resultMap["result"])
}
if num != 42 {
t.Errorf("expected 42, got %d", num)
}
if resultMap["message"] != "hello" {
t.Errorf("expected 'hello', got %v", resultMap["message"])
}
}
func TestExecute_TypeScriptWithInterface(t *testing.T) {
caller := newMockCaller()
caller.results["get_data"] = map[string]any{"value": 99}
code := `
interface Result {
ok: boolean;
result?: any;
error?: any;
}
const res: Result = call("get_data", { key: "test" });
if (!res.ok) throw new Error("failed");
({ data: res.result })
`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
resultMap := result.Value.(map[string]any)
data := resultMap["data"].(map[string]any)
// Values passed through the call() bridge preserve their Go types
var val int64
switch v := data["value"].(type) {
case int:
val = int64(v)
case int64:
val = v
case float64:
val = int64(v)
}
if val != 99 {
t.Errorf("expected value=99, got %v", data["value"])
}
}
func TestExecute_PlainJSNotTranspiled(t *testing.T) {
// Plain JS should work without transpilation
caller := newMockCaller()
code := `var x = 42; x`
result, err := Execute(context.Background(), code, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var num int64
switch v := result.Value.(type) {
case int64:
num = v
case float64:
num = int64(v)
}
if num != 42 {
t.Errorf("expected 42, got %v", result.Value)
}
}
func TestExecute_Duration(t *testing.T) {
caller := newMockCaller()
result, err := Execute(context.Background(), `42`, caller, ExecuteOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Duration <= 0 {
t.Error("expected positive duration")
}
}
func TestExecute_ContextCancellation(t *testing.T) {
caller := newMockCaller()
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, err := Execute(ctx, `while(true) {}`, caller, ExecuteOptions{
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("expected error from cancelled context")
}
}
func TestExecError_Error(t *testing.T) {
tests := []struct {
name string
err ExecError
contains string
}{
{
name: "without stack",
err: ExecError{Code: CodeSyntaxError, Message: "unexpected token"},
contains: "SYNTAX_ERROR: unexpected token",
},
{
name: "with stack",
err: ExecError{Code: CodeRuntimeError, Message: "boom", Stack: "at line 1"},
contains: "at line 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := tt.err.Error()
if msg == "" {
t.Error("expected non-empty error message")
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
package jsruntime
import (
"context"
"fmt"
"time"
)
// ToolCaller is implemented by the action registry to bridge call() invocations
// from JavaScript code to the host application.
type ToolCaller interface {
Call(ctx context.Context, actionName string, args map[string]any) (any, error)
}
// ExecuteOptions configures a single code execution.
type ExecuteOptions struct {
Timeout time.Duration // Maximum execution time. Default 120s.
MaxCalls int // Maximum number of call() invocations. Default 50.
MaxMemoryMB int // Memory limit hint in MB. Default 128.
}
// defaults fills in zero-value fields with sensible defaults.
func (o ExecuteOptions) defaults() ExecuteOptions {
if o.Timeout <= 0 {
o.Timeout = 120 * time.Second
}
if o.MaxCalls <= 0 {
o.MaxCalls = 50
}
if o.MaxMemoryMB <= 0 {
o.MaxMemoryMB = 128
}
return o
}
// ExecuteResult holds the output of a successful execution.
type ExecuteResult struct {
Value any // Final expression value (JSON-serializable)
CallCount int // Number of call() invocations made
Duration time.Duration // Wall-clock execution time
}
// ExecError wraps execution errors with structured context.
type ExecError struct {
Code string // SYNTAX_ERROR, RUNTIME_ERROR, TIMEOUT, MAX_CALLS_EXCEEDED, TRANSPILE_ERROR
Message string // Human-readable error description
Stack string // JS stack trace if available
Line int // Source line if available
Column int // Source column if available
}
// Error implements the error interface.
func (e *ExecError) Error() string {
if e.Stack != "" {
return fmt.Sprintf("%s: %s\n%s", e.Code, e.Message, e.Stack)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Error code constants.
const (
CodeSyntaxError = "SYNTAX_ERROR"
CodeRuntimeError = "RUNTIME_ERROR"
CodeTimeout = "TIMEOUT"
CodeMaxCallsExceeded = "MAX_CALLS_EXCEEDED"
CodeTranspileError = "TRANSPILE_ERROR"
)
+78
View File
@@ -0,0 +1,78 @@
package jsruntime
import (
"fmt"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// TranspileTypeScript transpiles TypeScript code to JavaScript using esbuild.
// It performs type-stripping only (no bundling, no type checking).
// Target is ES2020 for compatibility with goja.
func TranspileTypeScript(code string) (string, error) {
result := api.Transform(code, api.TransformOptions{
Loader: api.LoaderTS,
Target: api.ES2020,
})
if len(result.Errors) > 0 {
msg := result.Errors[0]
e := &ExecError{
Code: CodeTranspileError,
Message: fmt.Sprintf("TypeScript transpilation failed: %s", msg.Text),
}
if msg.Location != nil {
e.Line = msg.Location.Line
e.Column = msg.Location.Column
e.Message = fmt.Sprintf("TypeScript transpilation failed at line %d, column %d: %s",
msg.Location.Line, msg.Location.Column, msg.Text)
}
return "", e
}
return string(result.Code), nil
}
// looksLikeTypeScript uses simple heuristics to detect TypeScript code.
// It checks for common TypeScript-only syntax patterns.
func looksLikeTypeScript(code string) bool {
// Check for type annotations like `: string`, `: number`, `: boolean`, `: any`
typeAnnotationPatterns := []string{
": string",
": number",
": boolean",
": any",
": void",
": never",
": unknown",
}
for _, pattern := range typeAnnotationPatterns {
if strings.Contains(code, pattern) {
return true
}
}
// Check for interface declarations
if strings.Contains(code, "interface ") && strings.Contains(code, "{") {
return true
}
// Check for type aliases
if strings.Contains(code, "type ") && strings.Contains(code, "=") {
return true
}
// Check for generic type parameters like <T> or <T,U>
// Simple heuristic: look for <identifier> patterns not preceded by comparison operators
if strings.Contains(code, "<T>") || strings.Contains(code, "<T,") || strings.Contains(code, "<T ") {
return true
}
// Check for 'as' type assertions
if strings.Contains(code, " as ") {
return true
}
return false
}
+145
View File
@@ -0,0 +1,145 @@
package jsruntime
import (
"strings"
"testing"
)
func TestTranspileTypeScript(t *testing.T) {
tests := []struct {
name string
code string
wantContain string // substring that must be in the output
wantAbsent string // substring that must NOT be in the output
wantErr bool
}{
{
name: "basic type annotation",
code: `const x: number = 42; x;`,
wantContain: "42",
wantAbsent: ": number",
},
{
name: "interface removed",
code: "interface User { name: string; age: number; }\nconst u: User = { name: \"Alice\", age: 30 }; u;",
wantContain: "Alice",
wantAbsent: "interface",
},
{
name: "generics stripped",
code: "function identity<T>(arg: T): T { return arg; }\nconst r = identity<number>(42); r;",
wantContain: "42",
wantAbsent: "<T>",
},
{
name: "enum produces JS",
code: "enum Dir { Up = \"UP\", Down = \"DOWN\" }\nconst d: Dir = Dir.Up; d;",
wantContain: "UP",
wantAbsent: ": Dir",
},
{
name: "type alias removed",
code: "type ID = string | number;\nconst id: ID = \"abc\"; id;",
wantContain: "abc",
wantAbsent: "type ID",
},
{
name: "as expression stripped",
code: `const x = (42 as number); x;`,
wantContain: "42",
},
{
name: "plain JS passthrough",
code: `var x = 42; x;`,
wantContain: "42",
},
{
name: "empty code",
code: "",
wantContain: "",
},
{
name: "invalid code",
code: `const x: number = ;`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := TranspileTypeScript(tt.code)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
execErr, ok := err.(*ExecError)
if !ok {
t.Fatalf("expected *ExecError, got %T", err)
}
if execErr.Code != CodeTranspileError {
t.Errorf("expected code %q, got %q", CodeTranspileError, execErr.Code)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantContain != "" && !strings.Contains(result, tt.wantContain) {
t.Errorf("output should contain %q, got: %s", tt.wantContain, result)
}
if tt.wantAbsent != "" && strings.Contains(result, tt.wantAbsent) {
t.Errorf("output should NOT contain %q, got: %s", tt.wantAbsent, result)
}
})
}
}
func TestTranspileTypeScript_ErrorDetails(t *testing.T) {
_, err := TranspileTypeScript(`const x: number = ;`)
if err == nil {
t.Fatal("expected error")
}
execErr := err.(*ExecError)
// Should include line info
if execErr.Line == 0 && execErr.Column == 0 {
// esbuild may or may not provide location for all errors;
// at minimum the message should be informative
if execErr.Message == "" {
t.Error("expected non-empty error message")
}
}
}
func TestLooksLikeTypeScript(t *testing.T) {
tests := []struct {
name string
code string
want bool
}{
{"plain JS", `var x = 42;`, false},
{"type annotation string", `const x: string = "hi";`, true},
{"type annotation number", `const x: number = 1;`, true},
{"type annotation boolean", `const x: boolean = true;`, true},
{"type annotation any", `const x: any = null;`, true},
{"interface", `interface Foo { bar: string; }`, true},
{"type alias", `type ID = string`, true},
{"generic T", `function id<T>(x: T): T { return x; }`, true},
{"as expression", `const x = 42 as number;`, true},
{"no false positive on colon in object", `var x = { a: 1, b: 2 };`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := looksLikeTypeScript(tt.code)
if got != tt.want {
t.Errorf("looksLikeTypeScript(%q) = %v, want %v", tt.code, got, tt.want)
}
})
}
}
+54 -3
View File
@@ -84,6 +84,11 @@ func (r *K8sJobRunner) IsAvailable() bool {
return true
}
// GetClientset returns the kubernetes clientset for direct API access (used by reactor poller).
func (r *K8sJobRunner) GetClientset() kubernetes.Interface {
return r.clientset
}
func (r *K8sJobRunner) GetNamespace() string {
return r.namespace
}
@@ -145,14 +150,18 @@ func (r *K8sJobRunner) CreateJob(ctx context.Context, handler *K8sHandler, msg *
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "handler",
Image: handler.Image,
Env: envVars,
Name: "handler",
Image: handler.Image,
ImagePullPolicy: corev1.PullIfNotPresent,
Args: handler.Args,
Env: envVars,
VolumeMounts: buildVolumeMounts(handler.VolumeMounts),
Resources: corev1.ResourceRequirements{
Limits: resourceLimits,
},
},
},
Volumes: buildVolumes(handler.Volumes),
},
},
},
@@ -228,6 +237,48 @@ func sanitizeJobName(name string) string {
return name
}
// buildVolumeMounts converts our VolumeMount type to K8s VolumeMounts.
func buildVolumeMounts(mounts []VolumeMount) []corev1.VolumeMount {
if len(mounts) == 0 {
return nil
}
var result []corev1.VolumeMount
for _, m := range mounts {
result = append(result, corev1.VolumeMount{
Name: m.Name,
MountPath: m.MountPath,
ReadOnly: m.ReadOnly,
})
}
return result
}
// buildVolumes converts our Volume type to K8s Volumes.
func buildVolumes(volumes []Volume) []corev1.Volume {
if len(volumes) == 0 {
return nil
}
var result []corev1.Volume
for _, v := range volumes {
vol := corev1.Volume{Name: v.Name}
if v.HostPath != "" {
hostPathType := corev1.HostPathDirectory
vol.VolumeSource = corev1.VolumeSource{
HostPath: &corev1.HostPathVolumeSource{
Path: v.HostPath,
Type: &hostPathType,
},
}
} else if v.EmptyDir {
vol.VolumeSource = corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
}
}
result = append(result, vol)
}
return result
}
// truncateBody truncates the message body to maxLen bytes.
func truncateBody(body string, maxLen int) string {
if len(body) <= maxLen {
+19
View File
@@ -22,6 +22,25 @@ type K8sHandler struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Extended fields for reactive triggers (not persisted in k8s_handlers table)
Args []string `json:"-"`
VolumeMounts []VolumeMount `json:"-"`
Volumes []Volume `json:"-"`
}
// VolumeMount defines a mount point in the container.
type VolumeMount struct {
Name string
MountPath string
ReadOnly bool
}
// Volume defines a volume source for the pod.
type Volume struct {
Name string
HostPath string // If set, uses hostPath volume
EmptyDir bool // If true, uses emptyDir volume
}
// K8sJobRun represents a single Kubernetes job execution.
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
package mcp
import (
"context"
"log/slog"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/trace"
)
func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, *agents.AgentService, *channels.Service) {
t.Helper()
db := newTestDB(t)
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, tracer)
taskStore := channels.NewSQLiteTaskStore(db)
swarmService := channels.NewSwarmService(taskStore, channelStore, tracer)
// Seed test agents
agentService.Register(context.Background(), "agent-a", "Agent A", "ai", nil, 1)
agentService.Register(context.Background(), "agent-b", "Agent B", "ai", nil, 1)
bridge := NewServiceBridge(
msgService,
agentService,
channelService,
swarmService,
nil, // attachmentService
nil, // searchService
nil, // reactionService
nil, // trustService
"agent-a",
)
return bridge, msgService, agentService, channelService
}
func TestBridge_SendMessage(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
result, err := bridge.Call(ctx, "send_message", map[string]any{
"to": "agent-b",
"body": "hello from bridge",
})
if err != nil {
t.Fatalf("Call send_message: %v", err)
}
r := result.(map[string]any)
if r["message_id"] == nil {
t.Error("expected message_id")
}
}
func TestBridge_ReadInbox(t *testing.T) {
bridge, msgService, _, _ := newTestBridge(t)
ctx := context.Background()
// Send a message to agent-a
msgService.SendMessage(ctx, "agent-b", "agent-a", "test inbox msg", messaging.SendOptions{})
result, err := bridge.Call(ctx, "read_inbox", map[string]any{
"limit": 10,
})
if err != nil {
t.Fatalf("Call read_inbox: %v", err)
}
r := result.(map[string]any)
if r["count"].(int) != 1 {
t.Errorf("count = %v, want 1", r["count"])
}
}
func TestBridge_ClaimMessages(t *testing.T) {
bridge, msgService, _, _ := newTestBridge(t)
ctx := context.Background()
msgService.SendMessage(ctx, "agent-b", "agent-a", "claim me", messaging.SendOptions{})
result, err := bridge.Call(ctx, "claim_messages", map[string]any{
"limit": 1,
})
if err != nil {
t.Fatalf("Call claim_messages: %v", err)
}
r := result.(map[string]any)
count := r["count"].(int)
if count != 1 {
t.Errorf("count = %v, want 1", count)
}
}
func TestBridge_MarkDone(t *testing.T) {
bridge, msgService, _, _ := newTestBridge(t)
ctx := context.Background()
msg, _ := msgService.SendMessage(ctx, "agent-b", "agent-a", "done me", messaging.SendOptions{})
msgService.ClaimMessages(ctx, "agent-a", 1)
result, err := bridge.Call(ctx, "mark_done", map[string]any{
"message_id": int(msg.ID),
"status": "done",
})
if err != nil {
t.Fatalf("Call mark_done: %v", err)
}
r := result.(map[string]any)
if r["status"] != "done" {
t.Errorf("status = %v, want done", r["status"])
}
}
func TestBridge_MarkDone_Missing(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
_, err := bridge.Call(ctx, "mark_done", map[string]any{})
if err == nil {
t.Error("expected error for missing message_id")
}
}
func TestBridge_DiscoverAgents(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
result, err := bridge.Call(ctx, "discover_agents", map[string]any{})
if err != nil {
t.Fatalf("Call discover_agents: %v", err)
}
r := result.(map[string]any)
count := r["count"].(int)
if count < 2 {
t.Errorf("expected at least 2 agents, got %v", count)
}
}
func TestBridge_CreateChannel(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
result, err := bridge.Call(ctx, "create_channel", map[string]any{
"name": "bridge-test-ch",
"type": "standard",
})
if err != nil {
t.Fatalf("Call create_channel: %v", err)
}
r := result.(map[string]any)
if r["name"] != "bridge-test-ch" {
t.Errorf("name = %v, want bridge-test-ch", r["name"])
}
}
func TestBridge_JoinChannel(t *testing.T) {
bridge, _, _, channelService := newTestBridge(t)
ctx := context.Background()
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "join-bridge", Type: "standard", CreatedBy: "agent-a",
})
// Create a bridge for agent-b to join
bridgeB := NewServiceBridge(
bridge.msgService,
bridge.agentService,
bridge.channelService,
bridge.swarmService,
nil, nil, nil, nil,
"agent-b",
)
result, err := bridgeB.Call(ctx, "join_channel", map[string]any{
"channel_name": "join-bridge",
})
if err != nil {
t.Fatalf("Call join_channel: %v", err)
}
r := result.(map[string]any)
if r["status"] != "joined" {
t.Errorf("status = %v, want joined", r["status"])
}
}
func TestBridge_ListChannels(t *testing.T) {
bridge, _, _, channelService := newTestBridge(t)
ctx := context.Background()
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "list-ch-1", Type: "standard", CreatedBy: "agent-a",
})
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "list-ch-2", Type: "standard", CreatedBy: "agent-a",
})
result, err := bridge.Call(ctx, "list_channels", map[string]any{})
if err != nil {
t.Fatalf("Call list_channels: %v", err)
}
r := result.(map[string]any)
count := r["count"].(int)
if count < 2 {
t.Errorf("expected at least 2 channels, got %v", count)
}
}
func TestBridge_SendChannelMessage(t *testing.T) {
bridge, _, _, channelService := newTestBridge(t)
ctx := context.Background()
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "msg-bridge", Type: "standard", CreatedBy: "agent-a",
})
result, err := bridge.Call(ctx, "send_channel_message", map[string]any{
"channel_name": "msg-bridge",
"body": "hello from bridge",
})
if err != nil {
t.Fatalf("Call send_channel_message: %v", err)
}
r := result.(map[string]any)
if r["status"] != "sent" {
t.Errorf("status = %v, want sent", r["status"])
}
}
func TestBridge_UnknownAction(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
_, err := bridge.Call(ctx, "totally_unknown", map[string]any{})
if err == nil {
t.Error("expected error for unknown action")
}
}
func TestBridge_ParamHelpers(t *testing.T) {
args := map[string]any{
"str_val": "hello",
"int_val": float64(42),
"bool_val": true,
"nil_val": nil,
}
t.Run("getString", func(t *testing.T) {
if v := getString(args, "str_val", ""); v != "hello" {
t.Errorf("got %q, want hello", v)
}
if v := getString(args, "missing", "default"); v != "default" {
t.Errorf("got %q, want default", v)
}
})
t.Run("getInt", func(t *testing.T) {
if v := getInt(args, "int_val", 0); v != 42 {
t.Errorf("got %d, want 42", v)
}
if v := getInt(args, "missing", 99); v != 99 {
t.Errorf("got %d, want 99", v)
}
})
t.Run("getBool", func(t *testing.T) {
if v := getBool(args, "bool_val", false); v != true {
t.Errorf("got %v, want true", v)
}
if v := getBool(args, "missing", true); v != true {
t.Errorf("got %v, want true", v)
}
})
}
func newTestBridgeWithReactions(t *testing.T) (*ServiceBridge, *channels.Service) {
t.Helper()
db := newTestDB(t)
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, tracer)
taskStore := channels.NewSQLiteTaskStore(db)
swarmService := channels.NewSwarmService(taskStore, channelStore, tracer)
reactionStore := reactions.NewSQLiteStore(db)
reactionService := reactions.NewService(reactionStore, slog.Default())
agentService.Register(context.Background(), "agent-a", "Agent A", "ai", nil, 1)
agentService.Register(context.Background(), "agent-b", "Agent B", "ai", nil, 1)
bridge := NewServiceBridge(
msgService,
agentService,
channelService,
swarmService,
nil, // attachmentService
nil, // searchService
reactionService,
nil, // trustService
"agent-a",
)
return bridge, channelService
}
func TestBridge_React_WorkflowState(t *testing.T) {
tests := []struct {
name string
reaction string
wantAction string
wantWorkflowState string
}{
{
name: "approve sets approved state",
reaction: "approve",
wantAction: "added",
wantWorkflowState: "approved",
},
{
name: "in_progress sets in_progress state",
reaction: "in_progress",
wantAction: "added",
wantWorkflowState: "in_progress",
},
{
name: "done sets done state",
reaction: "done",
wantAction: "added",
wantWorkflowState: "done",
},
{
name: "published sets published state",
reaction: "published",
wantAction: "added",
wantWorkflowState: "published",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bridge, channelService := newTestBridgeWithReactions(t)
ctx := context.Background()
// Create a channel and send a message to react to
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "react-test", Type: "standard", CreatedBy: "agent-a",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelService.JoinChannel(ctx, ch.ID, "agent-a")
msg, err := bridge.Call(ctx, "send_channel_message", map[string]any{
"channel_name": "react-test",
"body": "test message",
})
if err != nil {
t.Fatalf("send_channel_message: %v", err)
}
msgMap := msg.(map[string]any)
msgID := msgMap["message_id"]
// React to the message
result, err := bridge.Call(ctx, "react", map[string]any{
"message_id": msgID,
"reaction": tt.reaction,
})
if err != nil {
t.Fatalf("react: %v", err)
}
resp := result.(map[string]any)
if resp["action"] != tt.wantAction {
t.Errorf("action = %v, want %v", resp["action"], tt.wantAction)
}
state, ok := resp["workflow_state"]
if !ok {
t.Fatal("response missing workflow_state field")
}
if state != tt.wantWorkflowState {
t.Errorf("workflow_state = %v, want %v", state, tt.wantWorkflowState)
}
rxns, ok := resp["reactions"]
if !ok {
t.Fatal("response missing reactions field")
}
rxnSlice, ok := rxns.([]*reactions.Reaction)
if !ok {
t.Fatalf("reactions has unexpected type %T", rxns)
}
if len(rxnSlice) == 0 {
t.Error("expected at least one reaction")
}
})
}
}
func TestBridge_React_Toggle_Removes_WorkflowState(t *testing.T) {
bridge, channelService := newTestBridgeWithReactions(t)
ctx := context.Background()
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "toggle-test", Type: "standard", CreatedBy: "agent-a",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelService.JoinChannel(ctx, ch.ID, "agent-a")
msg, err := bridge.Call(ctx, "send_channel_message", map[string]any{
"channel_name": "toggle-test",
"body": "toggle message",
})
if err != nil {
t.Fatalf("send_channel_message: %v", err)
}
msgMap := msg.(map[string]any)
msgID := msgMap["message_id"]
// Add reaction
bridge.Call(ctx, "react", map[string]any{
"message_id": msgID,
"reaction": "approve",
})
// Toggle off (remove)
result, err := bridge.Call(ctx, "react", map[string]any{
"message_id": msgID,
"reaction": "approve",
})
if err != nil {
t.Fatalf("react toggle off: %v", err)
}
resp := result.(map[string]any)
if resp["action"] != "removed" {
t.Errorf("action = %v, want removed", resp["action"])
}
// After removing the only reaction, workflow_state should be "proposed"
state, ok := resp["workflow_state"]
if !ok {
t.Fatal("response missing workflow_state after removal")
}
if state != "proposed" {
t.Errorf("workflow_state = %v, want proposed", state)
}
}
var _ = storage.RunMigrations
-438
View File
@@ -1,438 +0,0 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
// ChannelToolRegistrar registers channel MCP tools on the server.
type ChannelToolRegistrar struct {
channelService *channels.Service
msgService *messaging.MessagingService
}
// NewChannelToolRegistrar creates a new channel tool registrar.
func NewChannelToolRegistrar(channelService *channels.Service, msgService *messaging.MessagingService) *ChannelToolRegistrar {
return &ChannelToolRegistrar{
channelService: channelService,
msgService: msgService,
}
}
// RegisterAll registers all channel tools on the MCP server.
func (ctr *ChannelToolRegistrar) RegisterAll(s *server.MCPServer) {
s.AddTool(ctr.createChannelTool(), ctr.handleCreateChannel)
s.AddTool(ctr.joinChannelTool(), ctr.handleJoinChannel)
s.AddTool(ctr.leaveChannelTool(), ctr.handleLeaveChannel)
s.AddTool(ctr.listChannelsTool(), ctr.handleListChannels)
s.AddTool(ctr.inviteToChannelTool(), ctr.handleInviteToChannel)
s.AddTool(ctr.kickFromChannelTool(), ctr.handleKickFromChannel)
s.AddTool(ctr.getChannelMessagesTool(), ctr.handleGetChannelMessages)
s.AddTool(ctr.sendChannelMessageTool(), ctr.handleSendChannelMessage)
s.AddTool(ctr.updateChannelTool(), ctr.handleUpdateChannel)
}
// --- Tool Definitions ---
func (ctr *ChannelToolRegistrar) createChannelTool() mcp.Tool {
return mcp.NewTool("create_channel",
mcp.WithDescription("Create a new channel for group communication"),
mcp.WithString("name", mcp.Description("Unique channel name (alphanumeric, hyphens, underscores, max 64 chars)"), mcp.Required()),
mcp.WithString("description", mcp.Description("Channel description")),
mcp.WithString("topic", mcp.Description("Current channel topic")),
mcp.WithString("type", mcp.Description("Channel type: 'standard', 'blackboard', or 'auction' (default 'standard')")),
mcp.WithBoolean("is_private", mcp.Description("Whether the channel is private (invite-only). Default false")),
)
}
func (ctr *ChannelToolRegistrar) joinChannelTool() mcp.Tool {
return mcp.NewTool("join_channel",
mcp.WithDescription("Join a channel to participate in group conversations. You will receive messages sent to the channel after joining. Use list_channels first to see available channels."),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel to join")),
mcp.WithString("channel_name", mcp.Description("Name of the channel to join (alternative to channel_id)")),
)
}
func (ctr *ChannelToolRegistrar) leaveChannelTool() mcp.Tool {
return mcp.NewTool("leave_channel",
mcp.WithDescription("Leave a channel you are a member of"),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel to leave")),
mcp.WithString("channel_name", mcp.Description("Name of the channel to leave (alternative to channel_id)")),
)
}
func (ctr *ChannelToolRegistrar) listChannelsTool() mcp.Tool {
return mcp.NewTool("list_channels",
mcp.WithDescription("List all channels visible to you. Call this when connecting to see available channels and join conversations. Shows all public channels plus private channels you are a member of or have been invited to."),
)
}
func (ctr *ChannelToolRegistrar) inviteToChannelTool() mcp.Tool {
return mcp.NewTool("invite_to_channel",
mcp.WithDescription("Invite an agent to a channel (only the channel owner can invite to private channels)"),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel")),
mcp.WithString("channel_name", mcp.Description("Name of the channel (alternative to channel_id)")),
mcp.WithString("agent_name", mcp.Description("Name of the agent to invite"), mcp.Required()),
)
}
func (ctr *ChannelToolRegistrar) kickFromChannelTool() mcp.Tool {
return mcp.NewTool("kick_from_channel",
mcp.WithDescription("Remove an agent from a channel (only the channel owner can kick)"),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel")),
mcp.WithString("channel_name", mcp.Description("Name of the channel (alternative to channel_id)")),
mcp.WithString("agent_name", mcp.Description("Name of the agent to kick"), mcp.Required()),
)
}
func (ctr *ChannelToolRegistrar) getChannelMessagesTool() mcp.Tool {
return mcp.NewTool("get_channel_messages",
mcp.WithDescription("Get recent messages from a channel you are a member of"),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel")),
mcp.WithString("channel_name", mcp.Description("Name of the channel (alternative to channel_id)")),
mcp.WithNumber("limit", mcp.Description("Max number of messages to return (default 50, max 200)")),
)
}
func (ctr *ChannelToolRegistrar) sendChannelMessageTool() mcp.Tool {
return mcp.NewTool("send_channel_message",
mcp.WithDescription("Send a message to all members of a channel. Use @agentname in the body to mention specific agents. You must be a member of the channel to send messages."),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel")),
mcp.WithString("channel_name", mcp.Description("Name of the channel (alternative to channel_id)")),
mcp.WithString("body", mcp.Description("Message body text"), mcp.Required()),
mcp.WithNumber("priority", mcp.Description("Message priority (1-10, default 5)"), mcp.Min(1), mcp.Max(10)),
mcp.WithString("metadata", mcp.Description("JSON metadata object (optional)")),
)
}
func (ctr *ChannelToolRegistrar) updateChannelTool() mcp.Tool {
return mcp.NewTool("update_channel",
mcp.WithDescription("Update channel topic or description (only the channel owner can update)"),
mcp.WithNumber("channel_id", mcp.Description("ID of the channel")),
mcp.WithString("channel_name", mcp.Description("Name of the channel (alternative to channel_id)")),
mcp.WithString("topic", mcp.Description("New channel topic")),
mcp.WithString("description", mcp.Description("New channel description")),
)
}
// --- Tool Handlers ---
func (ctr *ChannelToolRegistrar) handleCreateChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
name := req.GetString("name", "")
if name == "" {
return mcp.NewToolResultError("'name' parameter is required"), nil
}
isPrivate := false
args := req.GetArguments()
if v, ok := args["is_private"]; ok {
if b, ok := v.(bool); ok {
isPrivate = b
}
}
createReq := channels.CreateChannelRequest{
Name: name,
Description: req.GetString("description", ""),
Topic: req.GetString("topic", ""),
Type: req.GetString("type", "standard"),
IsPrivate: isPrivate,
CreatedBy: agentName,
}
ch, err := ctr.channelService.CreateChannel(ctx, createReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("create_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": ch.ID,
"name": ch.Name,
"description": ch.Description,
"topic": ch.Topic,
"type": ch.Type,
"is_private": ch.IsPrivate,
"created_by": ch.CreatedBy,
})
}
func (ctr *ChannelToolRegistrar) handleJoinChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("join_channel failed: %s", err)), nil
}
if err := ctr.channelService.JoinChannel(ctx, channelID, agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("join_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": channelID,
"status": "joined",
})
}
func (ctr *ChannelToolRegistrar) handleLeaveChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("leave_channel failed: %s", err)), nil
}
if err := ctr.channelService.LeaveChannel(ctx, channelID, agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("leave_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": channelID,
"status": "left",
})
}
func (ctr *ChannelToolRegistrar) handleListChannels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
chList, err := ctr.channelService.ListChannels(ctx, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list_channels failed: %s", err)), nil
}
result := make([]map[string]any, len(chList))
for i, ch := range chList {
result[i] = map[string]any{
"id": ch.ID,
"name": ch.Name,
"description": ch.Description,
"topic": ch.Topic,
"type": ch.Type,
"is_private": ch.IsPrivate,
"created_by": ch.CreatedBy,
"member_count": ch.MemberCount,
}
}
return resultJSON(map[string]any{
"channels": result,
"count": len(result),
})
}
func (ctr *ChannelToolRegistrar) handleInviteToChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invite_to_channel failed: %s", err)), nil
}
targetAgent := req.GetString("agent_name", "")
if targetAgent == "" {
return mcp.NewToolResultError("'agent_name' parameter is required"), nil
}
if err := ctr.channelService.InviteToChannel(ctx, channelID, targetAgent, agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invite_to_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": channelID,
"agent_name": targetAgent,
"status": "invited",
})
}
func (ctr *ChannelToolRegistrar) handleKickFromChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("kick_from_channel failed: %s", err)), nil
}
targetAgent := req.GetString("agent_name", "")
if targetAgent == "" {
return mcp.NewToolResultError("'agent_name' parameter is required"), nil
}
if err := ctr.channelService.KickFromChannel(ctx, channelID, targetAgent, agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("kick_from_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": channelID,
"agent_name": targetAgent,
"status": "kicked",
})
}
func (ctr *ChannelToolRegistrar) handleGetChannelMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get_channel_messages failed: %s", err)), nil
}
// Verify the agent is a member of the channel
isMember, err := ctr.channelService.IsMember(ctx, channelID, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get_channel_messages failed: %s", err)), nil
}
if !isMember {
return mcp.NewToolResultError("you are not a member of this channel"), nil
}
limit := req.GetInt("limit", 50)
if limit > 200 {
limit = 200
}
messages, err := ctr.msgService.GetChannelMessages(ctx, channelID, limit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get_channel_messages failed: %s", err)), nil
}
result := make([]map[string]any, len(messages))
for i, msg := range messages {
result[i] = map[string]any{
"id": msg.ID,
"from": msg.FromAgent,
"body": msg.Body,
"priority": msg.Priority,
"status": msg.Status,
"created_at": msg.CreatedAt,
}
if len(msg.Metadata) > 0 {
result[i]["metadata"] = msg.Metadata
}
}
return resultJSON(map[string]any{
"channel_id": channelID,
"messages": result,
"count": len(result),
})
}
func (ctr *ChannelToolRegistrar) handleSendChannelMessage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("send_channel_message failed: %s", err)), nil
}
body := req.GetString("body", "")
if body == "" {
return mcp.NewToolResultError("'body' parameter is required"), nil
}
priority := req.GetInt("priority", 5)
metadata := req.GetString("metadata", "")
messages, err := ctr.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadata)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("send_channel_message failed: %s", err)), nil
}
var messageID int64
if len(messages) > 0 {
messageID = messages[0].ID
}
return resultJSON(map[string]any{
"channel_id": channelID,
"message_id": messageID,
"status": "sent",
})
}
func (ctr *ChannelToolRegistrar) handleUpdateChannel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelID, err := ctr.resolveChannelID(ctx, req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("update_channel failed: %s", err)), nil
}
updateReq := channels.UpdateChannelRequest{}
args := req.GetArguments()
if v, ok := args["topic"]; ok {
if s, ok := v.(string); ok {
updateReq.Topic = &s
}
}
if v, ok := args["description"]; ok {
if s, ok := v.(string); ok {
updateReq.Description = &s
}
}
ch, err := ctr.channelService.UpdateChannel(ctx, channelID, updateReq, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("update_channel failed: %s", err)), nil
}
return resultJSON(map[string]any{
"channel_id": ch.ID,
"name": ch.Name,
"description": ch.Description,
"topic": ch.Topic,
})
}
// resolveChannelID resolves a channel ID from either channel_id or channel_name parameter.
func (ctr *ChannelToolRegistrar) resolveChannelID(ctx context.Context, req mcp.CallToolRequest) (int64, error) {
if cid := req.GetInt("channel_id", 0); cid > 0 {
return int64(cid), nil
}
name := req.GetString("channel_name", "")
if name != "" {
ch, err := ctr.channelService.GetChannelByName(ctx, name)
if err != nil {
return 0, err
}
return ch.ID, nil
}
return 0, fmt.Errorf("either 'channel_id' or 'channel_name' is required")
}
+121 -298
View File
@@ -8,13 +8,16 @@ import (
mcplib "github.com/mark3labs/mcp-go/mcp"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/trace"
)
func newTestChannelRegistrar(t *testing.T) (*ChannelToolRegistrar, *channels.Service) {
func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Service) {
t.Helper()
db := newTestDB(t)
@@ -26,12 +29,34 @@ func newTestChannelRegistrar(t *testing.T) (*ChannelToolRegistrar, *channels.Ser
msgService := messaging.NewMessagingService(msgStore, tracer)
channelService := channels.NewService(channelStore, msgService, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
jsPool := jsruntime.NewPool(2)
t.Cleanup(func() { jsPool.Close() })
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
// Seed test agents
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-a', 'Agent A', 'ai', '{}', 1, 'hash', 'active')`)
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-b', 'Agent B', 'ai', '{}', 1, 'hash', 'active')`)
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-c', 'Agent C', 'ai', '{}', 1, 'hash', 'active')`)
registrar := NewChannelToolRegistrar(channelService, msgService)
registrar := NewHybridToolRegistrar(
msgService,
agentService,
channelService,
nil, // swarmService
nil, // attachmentService
nil, // searchService
nil, // reactionService
nil, // trustService
jsPool,
actionRegistry,
actionIndex,
db,
)
return registrar, channelService
}
@@ -45,274 +70,24 @@ func parseResponse(t *testing.T, result *mcplib.CallToolResult) map[string]any {
return resp
}
func TestChannelToolHandler_CreateChannel(t *testing.T) {
ctr, _ := newTestChannelRegistrar(t)
authCtx := ContextWithAgentName(context.Background(), "agent-a")
t.Run("successful creation", func(t *testing.T) {
req := makeRequest(map[string]any{
"name": "test-channel",
"description": "A test channel",
"type": "standard",
})
result, err := ctr.handleCreateChannel(authCtx, req)
if err != nil {
t.Fatalf("handleCreateChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resp := parseResponse(t, result)
if resp["name"] != "test-channel" {
t.Errorf("name = %v, want test-channel", resp["name"])
}
if resp["channel_id"] == nil || resp["channel_id"].(float64) == 0 {
t.Error("expected non-zero channel_id")
}
})
t.Run("create private channel", func(t *testing.T) {
req := makeRequest(map[string]any{
"name": "private-test",
"is_private": true,
})
result, err := ctr.handleCreateChannel(authCtx, req)
if err != nil {
t.Fatalf("handleCreateChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resp := parseResponse(t, result)
if resp["is_private"] != true {
t.Errorf("is_private = %v, want true", resp["is_private"])
}
})
t.Run("missing name", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, _ := ctr.handleCreateChannel(authCtx, req)
if !result.IsError {
t.Error("expected error for missing name")
}
})
t.Run("unauthenticated", func(t *testing.T) {
req := makeRequest(map[string]any{"name": "fail"})
result, _ := ctr.handleCreateChannel(context.Background(), req)
if !result.IsError {
t.Error("expected error for unauthenticated request")
}
})
t.Run("duplicate name", func(t *testing.T) {
req := makeRequest(map[string]any{"name": "test-channel"})
result, _ := ctr.handleCreateChannel(authCtx, req)
if !result.IsError {
t.Error("expected error for duplicate name")
}
})
}
func TestChannelToolHandler_JoinChannel(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "join-test", Type: "standard", CreatedBy: "agent-a",
})
authCtx := ContextWithAgentName(ctx, "agent-b")
t.Run("join by channel_id", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
})
result, err := ctr.handleJoinChannel(authCtx, req)
if err != nil {
t.Fatalf("handleJoinChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resp := parseResponse(t, result)
if resp["status"] != "joined" {
t.Errorf("status = %v, want joined", resp["status"])
}
})
t.Run("join by channel_name", func(t *testing.T) {
authCtxC := ContextWithAgentName(ctx, "agent-c")
req := makeRequest(map[string]any{
"channel_name": "join-test",
})
result, err := ctr.handleJoinChannel(authCtxC, req)
if err != nil {
t.Fatalf("handleJoinChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
})
t.Run("no channel identifier", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, _ := ctr.handleJoinChannel(authCtx, req)
if !result.IsError {
t.Error("expected error when no channel identifier provided")
}
})
}
func TestChannelToolHandler_LeaveChannel(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "leave-test", Type: "standard", CreatedBy: "agent-a",
})
svc.JoinChannel(ctx, ch.ID, "agent-b")
authCtx := ContextWithAgentName(ctx, "agent-b")
t.Run("successful leave", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
})
result, err := ctr.handleLeaveChannel(authCtx, req)
if err != nil {
t.Fatalf("handleLeaveChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
})
t.Run("owner cannot leave", func(t *testing.T) {
ownerCtx := ContextWithAgentName(ctx, "agent-a")
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
})
result, _ := ctr.handleLeaveChannel(ownerCtx, req)
if !result.IsError {
t.Error("expected error for owner leaving")
}
})
}
func TestChannelToolHandler_ListChannels(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
ctx := context.Background()
svc.CreateChannel(ctx, channels.CreateChannelRequest{Name: "pub-1", Type: "standard", CreatedBy: "agent-a"})
svc.CreateChannel(ctx, channels.CreateChannelRequest{Name: "pub-2", Type: "standard", CreatedBy: "agent-a"})
authCtx := ContextWithAgentName(ctx, "agent-b")
req := makeRequest(map[string]any{})
result, err := ctr.handleListChannels(authCtx, req)
if err != nil {
t.Fatalf("handleListChannels: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
// parseCallResult unwraps the execute response envelope and the call() wrapper
// to return the inner bridge result: { result: { ok, result: <bridge_data> }, calls, duration } → <bridge_data>
func parseCallResult(t *testing.T, result *mcplib.CallToolResult) map[string]any {
t.Helper()
resp := parseResponse(t, result)
count := resp["count"].(float64)
if count != 2 {
t.Errorf("count = %v, want 2", count)
callEnvelope, ok := resp["result"].(map[string]any)
if !ok {
t.Fatalf("expected result to be map, got %T", resp["result"])
}
chList := resp["channels"].([]any)
ch0 := chList[0].(map[string]any)
if ch0["name"] == nil {
t.Error("expected name field in channel")
}
if ch0["member_count"] == nil {
t.Error("expected member_count field in channel")
inner, ok := callEnvelope["result"].(map[string]any)
if !ok {
t.Fatalf("expected call result to be map, got %T (ok=%v)", callEnvelope["result"], callEnvelope["ok"])
}
return inner
}
func TestChannelToolHandler_InviteToChannel(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "invite-test", Type: "standard", IsPrivate: true, CreatedBy: "agent-a",
})
ownerCtx := ContextWithAgentName(ctx, "agent-a")
t.Run("owner can invite", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
"agent_name": "agent-b",
})
result, err := ctr.handleInviteToChannel(ownerCtx, req)
if err != nil {
t.Fatalf("handleInviteToChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
})
t.Run("missing agent_name", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
})
result, _ := ctr.handleInviteToChannel(ownerCtx, req)
if !result.IsError {
t.Error("expected error for missing agent_name")
}
})
}
func TestChannelToolHandler_KickFromChannel(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "kick-test", Type: "standard", CreatedBy: "agent-a",
})
svc.JoinChannel(ctx, ch.ID, "agent-b")
ownerCtx := ContextWithAgentName(ctx, "agent-a")
t.Run("owner can kick", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
"agent_name": "agent-b",
})
result, err := ctr.handleKickFromChannel(ownerCtx, req)
if err != nil {
t.Fatalf("handleKickFromChannel: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resp := parseResponse(t, result)
if resp["status"] != "kicked" {
t.Errorf("status = %v, want kicked", resp["status"])
}
})
}
func TestChannelToolHandler_SendChannelMessage(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
func TestHybridTool_SendMessage_Channel(t *testing.T) {
h, svc := newTestHybridWithChannels(t)
ctx := context.Background()
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
@@ -322,15 +97,15 @@ func TestChannelToolHandler_SendChannelMessage(t *testing.T) {
authCtx := ContextWithAgentName(ctx, "agent-a")
t.Run("send channel message", func(t *testing.T) {
t.Run("send to channel by name", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_name": "msg-test",
"body": "Hello channel!",
"channel": "msg-test",
"body": "Hello channel!",
})
result, err := ctr.handleSendChannelMessage(authCtx, req)
result, err := h.handleSendMessage(authCtx, req)
if err != nil {
t.Fatalf("handleSendChannelMessage: %v", err)
t.Fatalf("handleSendMessage: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
@@ -345,57 +120,105 @@ func TestChannelToolHandler_SendChannelMessage(t *testing.T) {
}
})
t.Run("missing body", func(t *testing.T) {
t.Run("missing body for channel", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_name": "msg-test",
"channel": "msg-test",
})
result, _ := ctr.handleSendChannelMessage(authCtx, req)
result, _ := h.handleSendMessage(authCtx, req)
if !result.IsError {
t.Error("expected error for missing body")
}
})
}
func TestChannelToolHandler_UpdateChannel(t *testing.T) {
ctr, svc := newTestChannelRegistrar(t)
func TestBridge_ChannelOperations(t *testing.T) {
h, svc := newTestHybridWithChannels(t)
ctx := context.Background()
authCtx := ContextWithAgentName(ctx, "agent-a")
ch, _ := svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "update-test", Type: "standard", Topic: "Original", CreatedBy: "agent-a",
})
ownerCtx := ContextWithAgentName(ctx, "agent-a")
t.Run("update topic", func(t *testing.T) {
t.Run("create_channel via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
"topic": "Updated topic",
"code": `call("create_channel", { name: "test-channel", description: "A test channel", type: "standard" })`,
})
result, err := ctr.handleUpdateChannel(ownerCtx, req)
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleUpdateChannel: %v", err)
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resp := parseResponse(t, result)
if resp["topic"] != "Updated topic" {
t.Errorf("topic = %v, want 'Updated topic'", resp["topic"])
resultData := parseCallResult(t, result)
if resultData["name"] != "test-channel" {
t.Errorf("name = %v, want test-channel", resultData["name"])
}
})
t.Run("non-owner cannot update", func(t *testing.T) {
svc.JoinChannel(ctx, ch.ID, "agent-b")
nonOwnerCtx := ContextWithAgentName(ctx, "agent-b")
req := makeRequest(map[string]any{
"channel_id": float64(ch.ID),
"topic": "Unauthorized",
t.Run("join_channel via execute", func(t *testing.T) {
svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "join-test", Type: "standard", CreatedBy: "agent-a",
})
result, _ := ctr.handleUpdateChannel(nonOwnerCtx, req)
if !result.IsError {
t.Error("expected error for non-owner update")
bCtx := ContextWithAgentName(ctx, "agent-b")
req := makeRequest(map[string]any{
"code": `call("join_channel", { channel_name: "join-test" })`,
})
result, err := h.handleExecute(bCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
if resultData["status"] != "joined" {
t.Errorf("status = %v, want joined", resultData["status"])
}
})
t.Run("list_channels via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": `call("list_channels", {})`,
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
count := resultData["count"].(float64)
if count < 1 {
t.Errorf("expected at least 1 channel, got %v", count)
}
})
t.Run("update_channel via execute", func(t *testing.T) {
svc.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "update-test", Type: "standard", Topic: "Original", CreatedBy: "agent-a",
})
req := makeRequest(map[string]any{
"code": `call("update_channel", { channel_name: "update-test", topic: "Updated topic" })`,
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
if resultData["topic"] != "Updated topic" {
t.Errorf("topic = %v, want 'Updated topic'", resultData["topic"])
}
})
}
+503
View File
@@ -0,0 +1,503 @@
package mcp
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/trace"
)
// PromptRegistrar registers MCP prompts on the server.
type PromptRegistrar struct {
db *sql.DB
agentService *agents.AgentService
channelService *channels.Service
traceStore trace.TraceStore
logger *slog.Logger
}
// NewPromptRegistrar creates a new prompt registrar.
func NewPromptRegistrar(
db *sql.DB,
agentService *agents.AgentService,
channelService *channels.Service,
traceStore trace.TraceStore,
) *PromptRegistrar {
return &PromptRegistrar{
db: db,
agentService: agentService,
channelService: channelService,
traceStore: traceStore,
logger: slog.Default().With("component", "mcp-prompts"),
}
}
// RegisterAllOnServer registers all 4 MCP prompts on an mcp-go MCPServer.
func (p *PromptRegistrar) RegisterAllOnServer(s *server.MCPServer) {
s.AddPrompt(p.dailyDigestPrompt(), p.handleDailyDigest)
s.AddPrompt(p.agentHealthCheckPrompt(), p.handleAgentHealthCheck)
s.AddPrompt(p.channelOverviewPrompt(), p.handleChannelOverview)
s.AddPrompt(p.debugAgentPrompt(), p.handleDebugAgent)
p.logger.Info("MCP prompts registered", "count", 4)
}
// --- Prompt Definitions ---
func (p *PromptRegistrar) dailyDigestPrompt() mcplib.Prompt {
return mcplib.NewPrompt("daily-digest",
mcplib.WithPromptDescription("Summary of SynapBus activity in the last 24 hours: message counts, active agents, busiest channels, and high-priority messages."),
)
}
func (p *PromptRegistrar) agentHealthCheckPrompt() mcplib.Prompt {
return mcplib.NewPrompt("agent-health-check",
mcplib.WithPromptDescription("Status overview of all registered agents: name, type, status, last activity, and pending DM count. Flags agents needing attention."),
)
}
func (p *PromptRegistrar) channelOverviewPrompt() mcplib.Prompt {
return mcplib.NewPrompt("channel-overview",
mcplib.WithPromptDescription("Overview of all channels: name, type, member count, and recent message activity."),
)
}
func (p *PromptRegistrar) debugAgentPrompt() mcplib.Prompt {
return mcplib.NewPrompt("debug-agent",
mcplib.WithPromptDescription("Detailed debug information for a specific agent: identity, recent traces, pending messages, and errors."),
mcplib.WithArgument("agent_name",
mcplib.ArgumentDescription("Name of the agent to debug"),
mcplib.RequiredArgument(),
),
)
}
// --- Prompt Handlers ---
func (p *PromptRegistrar) handleDailyDigest(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
since := time.Now().Add(-24 * time.Hour)
var md strings.Builder
md.WriteString("# SynapBus Daily Digest\n\n")
md.WriteString(fmt.Sprintf("*Period: %s to %s*\n\n", since.Format(time.RFC3339), time.Now().Format(time.RFC3339)))
// Total messages sent in last 24h
var totalMessages int
err := p.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE created_at >= ?`, since,
).Scan(&totalMessages)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying messages: %s\n\n", err))
} else {
md.WriteString(fmt.Sprintf("## Messages\n\n**Total messages sent**: %d\n\n", totalMessages))
}
// Active agents (those who sent messages in last 24h)
rows, err := p.db.QueryContext(ctx,
`SELECT DISTINCT from_agent FROM messages WHERE created_at >= ? AND from_agent != '' ORDER BY from_agent`,
since,
)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying active agents: %s\n\n", err))
} else {
var activeAgents []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err == nil {
activeAgents = append(activeAgents, name)
}
}
rows.Close()
md.WriteString("## Active Agents\n\n")
if len(activeAgents) == 0 {
md.WriteString("No agents were active in the last 24 hours.\n\n")
} else {
md.WriteString(fmt.Sprintf("**%d agents** sent messages: %s\n\n", len(activeAgents), strings.Join(activeAgents, ", ")))
}
}
// Top 3 busiest channels
chanRows, err := p.db.QueryContext(ctx,
`SELECT c.name, COUNT(m.id) as msg_count
FROM messages m
JOIN channels c ON m.channel_id = c.id
WHERE m.created_at >= ? AND m.channel_id IS NOT NULL
GROUP BY c.name
ORDER BY msg_count DESC
LIMIT 3`,
since,
)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying channels: %s\n\n", err))
} else {
md.WriteString("## Top Channels\n\n")
md.WriteString("| Channel | Messages |\n")
md.WriteString("|---------|----------|\n")
found := false
for chanRows.Next() {
var chName string
var msgCount int
if err := chanRows.Scan(&chName, &msgCount); err == nil {
md.WriteString(fmt.Sprintf("| #%s | %d |\n", chName, msgCount))
found = true
}
}
chanRows.Close()
if !found {
md.WriteString("| (no channel activity) | - |\n")
}
md.WriteString("\n")
}
// High-priority messages (priority >= 7)
highRows, err := p.db.QueryContext(ctx,
`SELECT id, from_agent, COALESCE(to_agent, ''), body, priority, created_at
FROM messages
WHERE created_at >= ? AND priority >= 7
ORDER BY priority DESC, created_at DESC
LIMIT 20`,
since,
)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying high-priority messages: %s\n\n", err))
} else {
md.WriteString("## High-Priority Messages (>= 7)\n\n")
var highPriorityFound bool
for highRows.Next() {
var id int64
var from, to, body string
var priority int
var createdAt time.Time
if err := highRows.Scan(&id, &from, &to, &body, &priority, &createdAt); err == nil {
if !highPriorityFound {
md.WriteString("| ID | From | To | Priority | Time | Body (truncated) |\n")
md.WriteString("|----|------|----|----------|------|------------------|\n")
highPriorityFound = true
}
if len(body) > 80 {
body = body[:80] + "..."
}
// Escape pipe characters in body for markdown table
body = strings.ReplaceAll(body, "|", "\\|")
body = strings.ReplaceAll(body, "\n", " ")
target := to
if target == "" {
target = "(channel)"
}
md.WriteString(fmt.Sprintf("| %d | %s | %s | %d | %s | %s |\n",
id, from, target, priority, createdAt.Format("15:04"), body))
}
}
highRows.Close()
if !highPriorityFound {
md.WriteString("No high-priority messages in the last 24 hours.\n")
}
md.WriteString("\n")
}
return &mcplib.GetPromptResult{
Description: "SynapBus activity summary for the last 24 hours",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(md.String()),
},
},
}, nil
}
func (p *PromptRegistrar) handleAgentHealthCheck(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
var md strings.Builder
md.WriteString("# Agent Health Check\n\n")
// Get all active agents
agentList, err := p.agentService.DiscoverAgents(ctx, "")
if err != nil {
return &mcplib.GetPromptResult{
Description: "Agent health check",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(fmt.Sprintf("Error listing agents: %s", err)),
},
},
}, nil
}
md.WriteString("| Agent | Type | Status | Last Activity | Pending DMs | Notes |\n")
md.WriteString("|-------|------|--------|---------------|-------------|-------|\n")
for _, agent := range agentList {
if agent.Name == "system" {
continue
}
// Get last activity from traces
lastActivity := "unknown"
if p.traceStore != nil {
traces, err := p.traceStore.GetTraces(ctx, agent.Name, 1)
if err == nil && len(traces) > 0 {
lastActivity = traces[0].CreatedAt.Format("2006-01-02 15:04")
}
}
// Get pending DM count
var pendingDMs int
err := p.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE to_agent = ? AND status = 'pending'`,
agent.Name,
).Scan(&pendingDMs)
if err != nil {
pendingDMs = -1
}
notes := ""
if pendingDMs > 10 {
notes = "**NEEDS ATTENTION**"
}
pendingStr := fmt.Sprintf("%d", pendingDMs)
if pendingDMs < 0 {
pendingStr = "error"
}
md.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s |\n",
agent.Name, agent.Type, agent.Status, lastActivity, pendingStr, notes))
}
md.WriteString(fmt.Sprintf("\n*Total agents: %d*\n", len(agentList)-1)) // exclude system
return &mcplib.GetPromptResult{
Description: "Status of all registered agents",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(md.String()),
},
},
}, nil
}
func (p *PromptRegistrar) handleChannelOverview(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
since := time.Now().Add(-24 * time.Hour)
var md strings.Builder
md.WriteString("# Channel Overview\n\n")
// Query all channels with member counts and recent message counts
rows, err := p.db.QueryContext(ctx,
`SELECT c.id, c.name, c.type, c.is_private,
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) as member_count,
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id AND m.created_at >= ?) as msg_count_24h
FROM channels c
ORDER BY c.name`,
since,
)
if err != nil {
return &mcplib.GetPromptResult{
Description: "Channel overview",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(fmt.Sprintf("Error querying channels: %s", err)),
},
},
}, nil
}
defer rows.Close()
md.WriteString("| Channel | Type | Private | Members | Messages (24h) |\n")
md.WriteString("|---------|------|---------|---------|----------------|\n")
count := 0
for rows.Next() {
var id int64
var name, chType string
var isPrivate int
var memberCount, msgCount int
if err := rows.Scan(&id, &name, &chType, &isPrivate, &memberCount, &msgCount); err != nil {
continue
}
privateStr := "no"
if isPrivate != 0 {
privateStr = "yes"
}
md.WriteString(fmt.Sprintf("| #%s | %s | %s | %d | %d |\n",
name, chType, privateStr, memberCount, msgCount))
count++
}
if count == 0 {
md.WriteString("| (no channels) | - | - | - | - |\n")
}
md.WriteString(fmt.Sprintf("\n*Total channels: %d*\n", count))
return &mcplib.GetPromptResult{
Description: "Overview of all channels",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(md.String()),
},
},
}, nil
}
func (p *PromptRegistrar) handleDebugAgent(ctx context.Context, req mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) {
agentName := req.Params.Arguments["agent_name"]
if agentName == "" {
return &mcplib.GetPromptResult{
Description: "Debug agent",
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent("Error: agent_name argument is required"),
},
},
}, nil
}
var md strings.Builder
md.WriteString(fmt.Sprintf("# Debug: Agent `%s`\n\n", agentName))
// Agent details
agent, err := p.agentService.GetAgent(ctx, agentName)
if err != nil {
return &mcplib.GetPromptResult{
Description: fmt.Sprintf("Debug info for agent %s", agentName),
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(fmt.Sprintf("Error: agent not found: %s", err)),
},
},
}, nil
}
// Resolve owner name
ownerName := "unknown"
if p.db != nil {
var username sql.NullString
_ = p.db.QueryRowContext(ctx,
`SELECT username FROM users WHERE id = ?`, agent.OwnerID,
).Scan(&username)
if username.Valid {
ownerName = username.String
}
}
md.WriteString("## Identity\n\n")
md.WriteString(fmt.Sprintf("| Field | Value |\n"))
md.WriteString(fmt.Sprintf("|-------|-------|\n"))
md.WriteString(fmt.Sprintf("| Name | %s |\n", agent.Name))
md.WriteString(fmt.Sprintf("| Display Name | %s |\n", agent.DisplayName))
md.WriteString(fmt.Sprintf("| Type | %s |\n", agent.Type))
md.WriteString(fmt.Sprintf("| Status | %s |\n", agent.Status))
md.WriteString(fmt.Sprintf("| Owner | %s (ID: %d) |\n", ownerName, agent.OwnerID))
md.WriteString(fmt.Sprintf("| Capabilities | `%s` |\n", string(agent.Capabilities)))
md.WriteString(fmt.Sprintf("| Created | %s |\n", agent.CreatedAt.Format(time.RFC3339)))
md.WriteString("\n")
// Pending messages count
var pendingCount int
err = p.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE to_agent = ? AND status = 'pending'`,
agentName,
).Scan(&pendingCount)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying pending messages: %s\n\n", err))
} else {
md.WriteString(fmt.Sprintf("## Pending Messages\n\n**Count**: %d\n\n", pendingCount))
}
// Recent traces (last 10)
if p.traceStore != nil {
traces, err := p.traceStore.GetTraces(ctx, agentName, 10)
if err != nil {
md.WriteString(fmt.Sprintf("Error querying traces: %s\n\n", err))
} else {
md.WriteString("## Recent Traces (last 10)\n\n")
if len(traces) == 0 {
md.WriteString("No traces found.\n\n")
} else {
md.WriteString("| Time | Action | Details | Error |\n")
md.WriteString("|------|--------|---------|-------|\n")
for _, t := range traces {
details := t.Details
if len(details) > 80 {
details = details[:80] + "..."
}
details = strings.ReplaceAll(details, "|", "\\|")
details = strings.ReplaceAll(details, "\n", " ")
errStr := ""
if t.Error.Valid {
errStr = t.Error.String
if len(errStr) > 60 {
errStr = errStr[:60] + "..."
}
errStr = strings.ReplaceAll(errStr, "|", "\\|")
}
md.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n",
t.CreatedAt.Format("15:04:05"), t.Action, details, errStr))
}
md.WriteString("\n")
}
}
// Recent errors from traces
errorTraces, err := p.traceStore.GetTraces(ctx, agentName, 50)
if err == nil {
md.WriteString("## Recent Errors\n\n")
errorFound := false
for _, t := range errorTraces {
if t.Error.Valid && t.Error.String != "" {
if !errorFound {
md.WriteString("| Time | Action | Error |\n")
md.WriteString("|------|--------|-------|\n")
errorFound = true
}
errStr := t.Error.String
if len(errStr) > 100 {
errStr = errStr[:100] + "..."
}
errStr = strings.ReplaceAll(errStr, "|", "\\|")
errStr = strings.ReplaceAll(errStr, "\n", " ")
md.WriteString(fmt.Sprintf("| %s | %s | %s |\n",
t.CreatedAt.Format("2006-01-02 15:04:05"), t.Action, errStr))
}
}
if !errorFound {
md.WriteString("No recent errors found.\n")
}
md.WriteString("\n")
}
} else {
md.WriteString("## Traces\n\n*Trace store not available*\n\n")
}
return &mcplib.GetPromptResult{
Description: fmt.Sprintf("Debug information for agent %s", agentName),
Messages: []mcplib.PromptMessage{
{
Role: mcplib.RoleUser,
Content: mcplib.NewTextContent(md.String()),
},
},
}, nil
}
+339
View File
@@ -0,0 +1,339 @@
package mcp
import (
"context"
"strings"
"testing"
"time"
mcplib "github.com/mark3labs/mcp-go/mcp"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
)
func newTestPromptRegistrar(t *testing.T) (*PromptRegistrar, *messaging.MessagingService, *agents.AgentService, *channels.Service) {
t.Helper()
db := newTestDB(t)
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, tracer)
traceStore := trace.NewSQLiteTraceStore(db)
registrar := NewPromptRegistrar(db, agentService, channelService, traceStore)
return registrar, msgService, agentService, channelService
}
// extractPromptText extracts the text content from a GetPromptResult.
func extractPromptText(t *testing.T, result *mcplib.GetPromptResult) string {
t.Helper()
if len(result.Messages) == 0 {
t.Fatal("expected at least one prompt message")
}
tc, ok := result.Messages[0].Content.(mcplib.TextContent)
if !ok {
t.Fatalf("expected TextContent, got %T", result.Messages[0].Content)
}
return tc.Text
}
func TestPrompt_DailyDigest(t *testing.T) {
p, msgService, agentService, _ := newTestPromptRegistrar(t)
ctx := context.Background()
// Register agents
agentService.Register(ctx, "agent-a", "Agent A", "ai", nil, 1)
agentService.Register(ctx, "agent-b", "Agent B", "ai", nil, 1)
t.Run("empty system", func(t *testing.T) {
req := mcplib.GetPromptRequest{}
result, err := p.handleDailyDigest(ctx, req)
if err != nil {
t.Fatalf("handleDailyDigest: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "Daily Digest") {
t.Error("expected 'Daily Digest' in output")
}
if !strings.Contains(text, "Total messages sent") {
t.Error("expected 'Total messages sent' in output")
}
if result.Messages[0].Role != mcplib.RoleUser {
t.Errorf("expected role=user, got %s", result.Messages[0].Role)
}
})
t.Run("with messages", func(t *testing.T) {
// Send some messages
msgService.SendMessage(ctx, "agent-a", "agent-b", "hello", messaging.SendOptions{})
msgService.SendMessage(ctx, "agent-b", "agent-a", "hi back", messaging.SendOptions{})
msgService.SendMessage(ctx, "agent-a", "agent-b", "urgent!", messaging.SendOptions{Priority: 8})
req := mcplib.GetPromptRequest{}
result, err := p.handleDailyDigest(ctx, req)
if err != nil {
t.Fatalf("handleDailyDigest: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "agent-a") {
t.Error("expected 'agent-a' in active agents")
}
if !strings.Contains(text, "agent-b") {
t.Error("expected 'agent-b' in active agents")
}
// Should have the high priority message
if !strings.Contains(text, "High-Priority") {
t.Error("expected 'High-Priority' section in output")
}
})
}
func TestPrompt_AgentHealthCheck(t *testing.T) {
p, msgService, agentService, _ := newTestPromptRegistrar(t)
ctx := context.Background()
t.Run("no agents", func(t *testing.T) {
req := mcplib.GetPromptRequest{}
result, err := p.handleAgentHealthCheck(ctx, req)
if err != nil {
t.Fatalf("handleAgentHealthCheck: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "Agent Health Check") {
t.Error("expected 'Agent Health Check' header")
}
})
t.Run("with agents and pending DMs", func(t *testing.T) {
agentService.Register(ctx, "healthy-agent", "Healthy", "ai", nil, 1)
agentService.Register(ctx, "overloaded-agent", "Overloaded", "ai", nil, 1)
// Send 12 messages to overloaded-agent to trigger "NEEDS ATTENTION"
for i := 0; i < 12; i++ {
msgService.SendMessage(ctx, "healthy-agent", "overloaded-agent", "msg", messaging.SendOptions{})
}
req := mcplib.GetPromptRequest{}
result, err := p.handleAgentHealthCheck(ctx, req)
if err != nil {
t.Fatalf("handleAgentHealthCheck: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "healthy-agent") {
t.Error("expected 'healthy-agent' in output")
}
if !strings.Contains(text, "overloaded-agent") {
t.Error("expected 'overloaded-agent' in output")
}
if !strings.Contains(text, "NEEDS ATTENTION") {
t.Error("expected 'NEEDS ATTENTION' flag for overloaded agent")
}
})
}
func TestPrompt_ChannelOverview(t *testing.T) {
p, _, agentService, channelService := newTestPromptRegistrar(t)
ctx := context.Background()
t.Run("no channels", func(t *testing.T) {
req := mcplib.GetPromptRequest{}
result, err := p.handleChannelOverview(ctx, req)
if err != nil {
t.Fatalf("handleChannelOverview: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "Channel Overview") {
t.Error("expected 'Channel Overview' header")
}
if !strings.Contains(text, "Total channels: 0") {
t.Error("expected 'Total channels: 0' with no channels")
}
})
t.Run("with channels", func(t *testing.T) {
agentService.Register(ctx, "chan-creator", "Chan Creator", "ai", nil, 1)
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "general",
Type: "standard",
CreatedBy: "chan-creator",
})
channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: "private-ops",
Type: "standard",
IsPrivate: true,
CreatedBy: "chan-creator",
})
req := mcplib.GetPromptRequest{}
result, err := p.handleChannelOverview(ctx, req)
if err != nil {
t.Fatalf("handleChannelOverview: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "general") {
t.Error("expected 'general' channel in output")
}
if !strings.Contains(text, "private-ops") {
t.Error("expected 'private-ops' channel in output")
}
})
}
func TestPrompt_DebugAgent(t *testing.T) {
p, msgService, agentService, _ := newTestPromptRegistrar(t)
ctx := context.Background()
t.Run("missing agent_name", func(t *testing.T) {
req := mcplib.GetPromptRequest{
Params: mcplib.GetPromptParams{
Name: "debug-agent",
Arguments: map[string]string{},
},
}
result, err := p.handleDebugAgent(ctx, req)
if err != nil {
t.Fatalf("handleDebugAgent: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "agent_name argument is required") {
t.Error("expected error message about missing agent_name")
}
})
t.Run("agent not found", func(t *testing.T) {
req := mcplib.GetPromptRequest{
Params: mcplib.GetPromptParams{
Name: "debug-agent",
Arguments: map[string]string{
"agent_name": "nonexistent",
},
},
}
result, err := p.handleDebugAgent(ctx, req)
if err != nil {
t.Fatalf("handleDebugAgent: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "agent not found") {
t.Error("expected 'agent not found' error message")
}
})
t.Run("agent exists", func(t *testing.T) {
agentService.Register(ctx, "debug-target", "Debug Target", "ai", nil, 1)
// Send some messages to create pending DMs
agentService.Register(ctx, "sender-for-debug", "Sender", "ai", nil, 1)
msgService.SendMessage(ctx, "sender-for-debug", "debug-target", "test message", messaging.SendOptions{})
// Wait briefly for traces to flush
time.Sleep(200 * time.Millisecond)
req := mcplib.GetPromptRequest{
Params: mcplib.GetPromptParams{
Name: "debug-agent",
Arguments: map[string]string{
"agent_name": "debug-target",
},
},
}
result, err := p.handleDebugAgent(ctx, req)
if err != nil {
t.Fatalf("handleDebugAgent: %v", err)
}
text := extractPromptText(t, result)
if !strings.Contains(text, "debug-target") {
t.Error("expected 'debug-target' in output")
}
if !strings.Contains(text, "Identity") {
t.Error("expected 'Identity' section in output")
}
if !strings.Contains(text, "Pending Messages") {
t.Error("expected 'Pending Messages' section in output")
}
if !strings.Contains(text, "Recent Traces") {
t.Error("expected 'Recent Traces' section in output")
}
if !strings.Contains(text, "Recent Errors") {
t.Error("expected 'Recent Errors' section in output")
}
if !strings.Contains(text, "testowner") {
t.Error("expected owner name 'testowner' in output")
}
})
}
func TestPrompt_Registration(t *testing.T) {
p, _, _, _ := newTestPromptRegistrar(t)
// Verify prompt definitions
t.Run("daily-digest prompt definition", func(t *testing.T) {
prompt := p.dailyDigestPrompt()
if prompt.Name != "daily-digest" {
t.Errorf("name = %q, want daily-digest", prompt.Name)
}
if prompt.Description == "" {
t.Error("expected non-empty description")
}
if len(prompt.Arguments) != 0 {
t.Errorf("expected 0 arguments, got %d", len(prompt.Arguments))
}
})
t.Run("agent-health-check prompt definition", func(t *testing.T) {
prompt := p.agentHealthCheckPrompt()
if prompt.Name != "agent-health-check" {
t.Errorf("name = %q, want agent-health-check", prompt.Name)
}
if len(prompt.Arguments) != 0 {
t.Errorf("expected 0 arguments, got %d", len(prompt.Arguments))
}
})
t.Run("channel-overview prompt definition", func(t *testing.T) {
prompt := p.channelOverviewPrompt()
if prompt.Name != "channel-overview" {
t.Errorf("name = %q, want channel-overview", prompt.Name)
}
})
t.Run("debug-agent prompt definition", func(t *testing.T) {
prompt := p.debugAgentPrompt()
if prompt.Name != "debug-agent" {
t.Errorf("name = %q, want debug-agent", prompt.Name)
}
if len(prompt.Arguments) != 1 {
t.Fatalf("expected 1 argument, got %d", len(prompt.Arguments))
}
arg := prompt.Arguments[0]
if arg.Name != "agent_name" {
t.Errorf("arg name = %q, want agent_name", arg.Name)
}
if !arg.Required {
t.Error("expected agent_name to be required")
}
})
}
+56 -47
View File
@@ -2,6 +2,7 @@ package mcp
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
@@ -10,28 +11,32 @@ import (
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agentquery"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/trace"
"github.com/synapbus/synapbus/internal/webhooks"
"github.com/synapbus/synapbus/internal/trust"
)
// MCPServer wraps the mcp-go server with SynapBus services.
type MCPServer struct {
mcpServer *server.MCPServer
httpServer *server.StreamableHTTPServer
connMgr *ConnectionManager
agentService *agents.AgentService
logger *slog.Logger
console *console.Printer
mcpServer *server.MCPServer
httpServer *server.StreamableHTTPServer
connMgr *ConnectionManager
agentService *agents.AgentService
hybridRegistrar *HybridToolRegistrar
logger *slog.Logger
console *console.Printer
}
// NewMCPServer creates and configures a new MCP server with all tools registered.
// NewMCPServer creates and configures a new MCP server with 4 hybrid tools registered.
func NewMCPServer(
msgService *messaging.MessagingService,
agentService *agents.AgentService,
@@ -39,9 +44,13 @@ func NewMCPServer(
swarmService *channels.SwarmService,
attachmentService *attachments.Service,
searchService *search.Service,
reactionService *reactions.Service,
trustService *trust.Service,
consolePrinter *console.Printer,
webhookService *webhooks.WebhookService,
k8sService *k8s.K8sService,
jsPool *jsruntime.Pool,
actionRegistry *actions.Registry,
actionIndex *actions.Index,
db *sql.DB,
) *MCPServer {
logger := slog.Default().With("component", "mcp-server")
connMgr := NewConnectionManager()
@@ -138,39 +147,31 @@ func NewMCPServer(
"SynapBus",
"0.1.0",
server.WithToolCapabilities(true),
server.WithPromptCapabilities(true),
server.WithHooks(hooks),
)
// Register all tools
registrar := NewToolRegistrar(msgService, agentService)
if searchService != nil {
registrar.SetSearchService(searchService)
}
registrar.RegisterAll(mcpSrv)
// Register the 4 hybrid tools
hybridRegistrar := NewHybridToolRegistrar(
msgService,
agentService,
channelService,
swarmService,
attachmentService,
searchService,
reactionService,
trustService,
jsPool,
actionRegistry,
actionIndex,
db,
)
hybridRegistrar.RegisterAllOnServer(mcpSrv)
// Register channel tools
if channelService != nil {
channelRegistrar := NewChannelToolRegistrar(channelService, msgService)
channelRegistrar.RegisterAll(mcpSrv)
}
// Register swarm tools
if swarmService != nil && channelService != nil {
swarmRegistrar := NewSwarmToolRegistrar(swarmService, channelService)
swarmRegistrar.RegisterAll(mcpSrv)
}
// Register attachment tools
if attachmentService != nil {
attachmentRegistrar := NewAttachmentToolRegistrar(attachmentService)
attachmentRegistrar.RegisterAll(mcpSrv)
}
// Register webhook and K8s handler tools
if webhookService != nil || k8sService != nil {
webhookRegistrar := NewWebhookToolRegistrar(webhookService, k8sService)
webhookRegistrar.RegisterAll(mcpSrv)
}
// Register the 4 MCP prompts
traceStore := trace.NewSQLiteTraceStore(db)
promptRegistrar := NewPromptRegistrar(db, agentService, channelService, traceStore)
promptRegistrar.RegisterAllOnServer(mcpSrv)
// Create Streamable HTTP transport with context func for auth propagation
httpServer := server.NewStreamableHTTPServer(mcpSrv,
@@ -188,18 +189,26 @@ func NewMCPServer(
)
s := &MCPServer{
mcpServer: mcpSrv,
httpServer: httpServer,
connMgr: connMgr,
agentService: agentService,
logger: logger,
console: consolePrinter,
mcpServer: mcpSrv,
httpServer: httpServer,
connMgr: connMgr,
agentService: agentService,
hybridRegistrar: hybridRegistrar,
logger: logger,
console: consolePrinter,
}
logger.Info("MCP server initialized (streamable HTTP transport)")
logger.Info("MCP server initialized (4 hybrid tools, 4 prompts, streamable HTTP transport)")
return s
}
// SetQueryExecutor sets the SQL query executor for agent queries via the execute tool.
func (s *MCPServer) SetQueryExecutor(exec *agentquery.Executor) {
if s.hybridRegistrar != nil {
s.hybridRegistrar.SetQueryExecutor(exec)
}
}
// Handler returns the HTTP handler for mounting on a router.
func (s *MCPServer) Handler() http.Handler {
return s.httpServer
+46 -44
View File
@@ -2,7 +2,6 @@ package mcp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -10,14 +9,18 @@ import (
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/apikeys"
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
)
func TestNewMCPServerWithConsole(t *testing.T) {
// newTestMCPServer creates a full MCPServer for testing.
func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messaging.MessagingService, *agents.AgentService) {
t.Helper()
db := newTestDB(t)
tracer := trace.NewTracer(db)
@@ -29,9 +32,19 @@ func TestNewMCPServerWithConsole(t *testing.T) {
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
con := console.New()
jsPool := jsruntime.NewPool(2)
t.Cleanup(func() { jsPool.Close() })
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, con, nil, nil)
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
return srv, msgService, agentService
}
func TestNewMCPServerWithConsole(t *testing.T) {
con := console.New()
srv, _, _ := newTestMCPServer(t, con)
if srv == nil {
t.Fatal("expected non-nil MCPServer")
}
@@ -44,19 +57,7 @@ func TestNewMCPServerWithConsole(t *testing.T) {
}
func TestNewMCPServerNilConsole(t *testing.T) {
db := newTestDB(t)
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
// nil console should not panic
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
srv, _, _ := newTestMCPServer(t, nil)
if srv == nil {
t.Fatal("expected non-nil MCPServer")
}
@@ -99,7 +100,7 @@ func TestConnectionManagerClientInfo(t *testing.T) {
}
}
// T007: Test MCP tool calls with valid API key — agent identity is correctly resolved.
// T007: Test MCP tool calls with valid API key -- agent identity is correctly resolved.
func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
@@ -125,8 +126,14 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
// Also register a receiver
agentService.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
jsPool := jsruntime.NewPool(2)
defer jsPool.Close()
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
// Mount with auth middleware, just like main.go does
mux := http.NewServeMux()
@@ -156,15 +163,10 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
// Verify the agent was authenticated by checking the connection manager
// (the AfterInitialize hook would have captured the agent name)
// The init should have succeeded — verify by checking no 401 was returned
t.Log("MCP connection with valid API key succeeded")
}
// T008: Test MCP tool calls without auth return 401 when auth is required.
// Note: With the current OptionalAuthMiddleware, unauthenticated requests pass through
// (returning tool-level errors). This test verifies that an invalid API key is rejected.
func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
db := newTestDB(t)
@@ -180,7 +182,13 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
apiKeyStore := apikeys.NewSQLiteStore(db)
apiKeyService := apikeys.NewService(apiKeyStore)
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil)
jsPool := jsruntime.NewPool(2)
defer jsPool.Close()
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
mux := http.NewServeMux()
handler := agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService)(srv.Handler())
@@ -212,7 +220,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
// T008 (continued): Test that unauthenticated MCP tool calls (no auth header at all)
// are rejected at the tool handler level.
func TestMCPToolCall_NoAuthReturnsToolError(t *testing.T) {
tr, _, agentSvc, _ := newTestRegistrar(t)
h, _, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
// Register a receiver so the send would work if auth was present
@@ -224,7 +232,7 @@ func TestMCPToolCall_NoAuthReturnsToolError(t *testing.T) {
"body": "should fail",
})
result, err := tr.handleSendMessage(ctx, req)
result, err := h.handleSendMessage(ctx, req)
if err != nil {
t.Fatalf("handleSendMessage returned error: %v", err)
}
@@ -239,10 +247,8 @@ func TestMCPToolCall_NoAuthReturnsToolError(t *testing.T) {
}
// T009: Verify send_message enforces from_agent from the authenticated context.
// The send_message tool does NOT expose a "from" parameter — the sender is always
// derived from the authenticated agent identity in the context.
func TestSendMessage_EnforcesAuthenticatedAgent(t *testing.T) {
tr, _, agentSvc, _ := newTestRegistrar(t)
h, _, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "real-sender", "Real Sender", "ai", nil, 1)
@@ -252,16 +258,13 @@ func TestSendMessage_EnforcesAuthenticatedAgent(t *testing.T) {
// Authenticate as "real-sender"
authCtx := ContextWithAgentName(ctx, "real-sender")
// Try to send a message — even if someone could supply a "from" field,
// the handler should use the authenticated agent name, not a user-supplied value.
// Send a message
req := makeRequest(map[string]any{
"to": "receiver",
"body": "message from real sender",
// Note: there is no "from" parameter in the send_message tool definition,
// but even if extra args are passed, the handler ignores them.
})
result, err := tr.handleSendMessage(authCtx, req)
result, err := h.handleSendMessage(authCtx, req)
if err != nil {
t.Fatalf("handleSendMessage: %v", err)
}
@@ -269,16 +272,15 @@ func TestSendMessage_EnforcesAuthenticatedAgent(t *testing.T) {
t.Fatalf("unexpected error: %v", result.Content)
}
// Verify the message was sent from "real-sender" by reading receiver's inbox
// Verify the message was sent from "real-sender" by reading receiver's inbox via execute
inboxCtx := ContextWithAgentName(ctx, "receiver")
inboxReq := makeRequest(map[string]any{})
inboxResult, _ := tr.handleReadInbox(inboxCtx, inboxReq)
inboxReq := makeRequest(map[string]any{
"code": `call("read_inbox", {})`,
})
inboxResult, _ := h.handleExecute(inboxCtx, inboxReq)
text := inboxResult.Content[0].(mcplib.TextContent).Text
var resp map[string]any
json.Unmarshal([]byte(text), &resp)
messages := resp["messages"].([]any)
resultData := parseCallResult(t, inboxResult)
messages := resultData["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(messages))
}
@@ -286,6 +288,6 @@ func TestSendMessage_EnforcesAuthenticatedAgent(t *testing.T) {
msg := messages[0].(map[string]any)
fromAgent := msg["from_agent"].(string)
if fromAgent != "real-sender" {
t.Errorf("message from_agent = %q, want %q — send_message must enforce authenticated agent", fromAgent, "real-sender")
t.Errorf("message from_agent = %q, want %q -- send_message must enforce authenticated agent", fromAgent, "real-sender")
}
}
-285
View File
@@ -1,285 +0,0 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/channels"
)
// SwarmToolRegistrar registers swarm-pattern MCP tools on the server.
type SwarmToolRegistrar struct {
swarmService *channels.SwarmService
channelService *channels.Service
logger *slog.Logger
}
// NewSwarmToolRegistrar creates a new swarm tool registrar.
func NewSwarmToolRegistrar(swarmService *channels.SwarmService, channelService *channels.Service) *SwarmToolRegistrar {
return &SwarmToolRegistrar{
swarmService: swarmService,
channelService: channelService,
logger: slog.Default().With("component", "mcp-swarm-tools"),
}
}
// RegisterAll registers all swarm tools on the MCP server.
func (str *SwarmToolRegistrar) RegisterAll(s *server.MCPServer) {
s.AddTool(str.postTaskTool(), str.handlePostTask)
s.AddTool(str.bidTaskTool(), str.handleBidTask)
s.AddTool(str.acceptBidTool(), str.handleAcceptBid)
s.AddTool(str.completeTaskTool(), str.handleCompleteTask)
s.AddTool(str.listTasksTool(), str.handleListTasks)
str.logger.Info("swarm MCP tools registered", "count", 5)
}
// --- Tool Definitions ---
func (str *SwarmToolRegistrar) postTaskTool() mcp.Tool {
return mcp.NewTool("post_task",
mcp.WithDescription("Post a task to an auction channel for agents to bid on"),
mcp.WithString("channel_name", mcp.Description("Name of the auction channel"), mcp.Required()),
mcp.WithString("title", mcp.Description("Task title"), mcp.Required()),
mcp.WithString("description", mcp.Description("Task description")),
mcp.WithString("requirements", mcp.Description("JSON object of task requirements")),
mcp.WithString("deadline", mcp.Description("Task deadline in ISO 8601 format (e.g. 2026-03-13T15:00:00Z)")),
)
}
func (str *SwarmToolRegistrar) bidTaskTool() mcp.Tool {
return mcp.NewTool("bid_task",
mcp.WithDescription("Submit a bid on an open task in an auction channel"),
mcp.WithNumber("task_id", mcp.Description("ID of the task to bid on"), mcp.Required()),
mcp.WithString("capabilities", mcp.Description("JSON object describing your relevant capabilities")),
mcp.WithString("time_estimate", mcp.Description("Estimated time to complete the task")),
mcp.WithString("message", mcp.Description("Message to the task poster explaining your bid")),
)
}
func (str *SwarmToolRegistrar) acceptBidTool() mcp.Tool {
return mcp.NewTool("accept_bid",
mcp.WithDescription("Accept a bid on a task you posted, assigning the task to the bidding agent"),
mcp.WithNumber("task_id", mcp.Description("ID of the task"), mcp.Required()),
mcp.WithNumber("bid_id", mcp.Description("ID of the bid to accept"), mcp.Required()),
)
}
func (str *SwarmToolRegistrar) completeTaskTool() mcp.Tool {
return mcp.NewTool("complete_task",
mcp.WithDescription("Mark a task as completed (only the assigned agent can do this)"),
mcp.WithNumber("task_id", mcp.Description("ID of the task to complete"), mcp.Required()),
)
}
func (str *SwarmToolRegistrar) listTasksTool() mcp.Tool {
return mcp.NewTool("list_tasks",
mcp.WithDescription("List tasks in an auction channel, optionally filtered by status"),
mcp.WithString("channel_name", mcp.Description("Name of the auction channel"), mcp.Required()),
mcp.WithString("status", mcp.Description("Filter by task status: open, assigned, completed, cancelled")),
)
}
// --- Tool Handlers ---
func (str *SwarmToolRegistrar) handlePostTask(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
channelName := req.GetString("channel_name", "")
if channelName == "" {
return mcp.NewToolResultError("'channel_name' parameter is required"), nil
}
title := req.GetString("title", "")
if title == "" {
return mcp.NewToolResultError("'title' parameter is required"), nil
}
description := req.GetString("description", "")
requirementsStr := req.GetString("requirements", "{}")
deadlineStr := req.GetString("deadline", "")
// Parse requirements JSON
var requirements json.RawMessage
if requirementsStr != "" {
if !json.Valid([]byte(requirementsStr)) {
return mcp.NewToolResultError("requirements must be valid JSON"), nil
}
requirements = json.RawMessage(requirementsStr)
}
// Parse deadline
var deadline *time.Time
if deadlineStr != "" {
t, err := time.Parse(time.RFC3339, deadlineStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("deadline must be ISO 8601 format: %s", err)), nil
}
deadline = &t
}
// Resolve channel
ch, err := str.channelService.GetChannelByName(ctx, channelName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("post_task failed: %s", err)), nil
}
task, err := str.swarmService.PostTask(ctx, ch.ID, agentName, title, description, requirements, deadline)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("post_task failed: %s", err)), nil
}
return resultJSON(map[string]any{
"task_id": task.ID,
"channel_id": task.ChannelID,
"title": task.Title,
"status": task.Status,
"posted_by": task.PostedBy,
"deadline": task.Deadline,
"created_at": task.CreatedAt,
})
}
func (str *SwarmToolRegistrar) handleBidTask(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
taskID, err := req.RequireInt("task_id")
if err != nil {
return mcp.NewToolResultError("'task_id' parameter is required"), nil
}
capabilitiesStr := req.GetString("capabilities", "{}")
timeEstimate := req.GetString("time_estimate", "")
message := req.GetString("message", "")
var capabilities json.RawMessage
if capabilitiesStr != "" {
if !json.Valid([]byte(capabilitiesStr)) {
return mcp.NewToolResultError("capabilities must be valid JSON"), nil
}
capabilities = json.RawMessage(capabilitiesStr)
}
bid, err := str.swarmService.BidOnTask(ctx, int64(taskID), agentName, capabilities, timeEstimate, message)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("bid_task failed: %s", err)), nil
}
return resultJSON(map[string]any{
"bid_id": bid.ID,
"task_id": bid.TaskID,
"agent_name": bid.AgentName,
"time_estimate": bid.TimeEstimate,
"status": bid.Status,
})
}
func (str *SwarmToolRegistrar) handleAcceptBid(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
taskID, err := req.RequireInt("task_id")
if err != nil {
return mcp.NewToolResultError("'task_id' parameter is required"), nil
}
bidID, err := req.RequireInt("bid_id")
if err != nil {
return mcp.NewToolResultError("'bid_id' parameter is required"), nil
}
if err := str.swarmService.AcceptBid(ctx, int64(taskID), int64(bidID), agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("accept_bid failed: %s", err)), nil
}
return resultJSON(map[string]any{
"task_id": taskID,
"bid_id": bidID,
"status": "accepted",
})
}
func (str *SwarmToolRegistrar) handleCompleteTask(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
taskID, err := req.RequireInt("task_id")
if err != nil {
return mcp.NewToolResultError("'task_id' parameter is required"), nil
}
if err := str.swarmService.CompleteTask(ctx, int64(taskID), agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("complete_task failed: %s", err)), nil
}
return resultJSON(map[string]any{
"task_id": taskID,
"status": "completed",
})
}
func (str *SwarmToolRegistrar) handleListTasks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
_ = agentName // just verifying auth
channelName := req.GetString("channel_name", "")
if channelName == "" {
return mcp.NewToolResultError("'channel_name' parameter is required"), nil
}
statusFilter := req.GetString("status", "")
// Resolve channel
ch, err := str.channelService.GetChannelByName(ctx, channelName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list_tasks failed: %s", err)), nil
}
// Verify channel is auction type
if ch.Type != channels.TypeAuction {
return mcp.NewToolResultError(fmt.Sprintf("list_tasks requires a channel of type 'auction', got '%s'", ch.Type)), nil
}
tasks, err := str.swarmService.ListTasks(ctx, ch.ID, statusFilter)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list_tasks failed: %s", err)), nil
}
result := make([]map[string]any, len(tasks))
for i, task := range tasks {
result[i] = map[string]any{
"id": task.ID,
"title": task.Title,
"description": task.Description,
"status": task.Status,
"posted_by": task.PostedBy,
"assigned_to": task.AssignedTo,
"deadline": task.Deadline,
"created_at": task.CreatedAt,
}
}
return resultJSON(map[string]any{
"tasks": result,
"count": len(result),
})
}
-353
View File
@@ -1,365 +1,12 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/search"
)
// ToolRegistrar registers all SynapBus MCP tools on the given server.
type ToolRegistrar struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
searchService *search.Service
logger *slog.Logger
}
// NewToolRegistrar creates a new tool registrar.
func NewToolRegistrar(msgService *messaging.MessagingService, agentService *agents.AgentService) *ToolRegistrar {
return &ToolRegistrar{
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "mcp-tools"),
}
}
// SetSearchService sets the search service for semantic search support.
func (tr *ToolRegistrar) SetSearchService(svc *search.Service) {
tr.searchService = svc
}
// RegisterAll registers all tools on the MCP server.
// Note: Agent management tools (register, update, deregister) are NOT exposed via MCP.
// Agents are managed exclusively through the Web UI. MCP is for messaging only.
func (tr *ToolRegistrar) RegisterAll(s *server.MCPServer) {
s.AddTool(tr.sendMessageTool(), tr.handleSendMessage)
s.AddTool(tr.readInboxTool(), tr.handleReadInbox)
s.AddTool(tr.claimMessagesTool(), tr.handleClaimMessages)
s.AddTool(tr.markDoneTool(), tr.handleMarkDone)
s.AddTool(tr.searchMessagesTool(), tr.handleSearchMessages)
s.AddTool(tr.discoverAgentsTool(), tr.handleDiscoverAgents)
tr.logger.Info("all MCP tools registered", "count", 6)
}
// --- Tool Definitions ---
func (tr *ToolRegistrar) sendMessageTool() mcp.Tool {
return mcp.NewTool("send_message",
mcp.WithDescription("Send a direct message to another agent. Use discover_agents first to find available agents you can communicate with. For channel messages, use send_channel_message instead."),
mcp.WithString("to", mcp.Description("Name of the recipient agent (required for DMs, omit for channel messages)")),
mcp.WithString("body", mcp.Description("Message body text"), mcp.Required()),
mcp.WithString("subject", mcp.Description("Conversation subject (optional)")),
mcp.WithNumber("priority", mcp.Description("Message priority (1-10, default 5)"), mcp.Min(1), mcp.Max(10)),
mcp.WithString("metadata", mcp.Description("JSON metadata object (optional)")),
mcp.WithNumber("channel_id", mcp.Description("Channel ID for channel messages (optional)")),
mcp.WithNumber("reply_to", mcp.Description("ID of the message to reply to (optional, for threading)")),
)
}
func (tr *ToolRegistrar) readInboxTool() mcp.Tool {
return mcp.NewTool("read_inbox",
mcp.WithDescription("Check your message inbox for pending messages. Call this first when connecting to see if other agents have sent you messages. Returns unread/pending direct messages addressed to you."),
mcp.WithNumber("limit", mcp.Description("Maximum number of messages to return (default 50)")),
mcp.WithString("status_filter", mcp.Description("Filter by message status: pending, processing, done, failed")),
mcp.WithBoolean("include_read", mcp.Description("Include previously read messages (default false)")),
mcp.WithNumber("min_priority", mcp.Description("Minimum priority filter (1-10)")),
mcp.WithString("from_agent", mcp.Description("Filter by sender agent name")),
)
}
func (tr *ToolRegistrar) claimMessagesTool() mcp.Tool {
return mcp.NewTool("claim_messages",
mcp.WithDescription("Atomically claim pending messages for processing"),
mcp.WithNumber("limit", mcp.Description("Maximum number of messages to claim (default 10)")),
)
}
func (tr *ToolRegistrar) markDoneTool() mcp.Tool {
return mcp.NewTool("mark_done",
mcp.WithDescription("Mark a claimed message as done or failed"),
mcp.WithNumber("message_id", mcp.Description("ID of the message to mark"), mcp.Required()),
mcp.WithString("status", mcp.Description("New status: 'done' or 'failed' (default 'done')")),
mcp.WithString("reason", mcp.Description("Failure reason (only for status='failed')")),
)
}
func (tr *ToolRegistrar) searchMessagesTool() mcp.Tool {
return mcp.NewTool("search_messages",
mcp.WithDescription("Search for messages across your inbox and channels you are a member of. Supports full-text and semantic search (if configured). Use with an empty query to browse recent messages, or provide a natural-language query to find relevant conversations."),
mcp.WithString("query", mcp.Description("Search query string — supports natural language for semantic search")),
mcp.WithNumber("limit", mcp.Description("Maximum results to return (default 10, max 100)")),
mcp.WithNumber("min_priority", mcp.Description("Minimum priority filter (1-10)")),
mcp.WithString("from_agent", mcp.Description("Filter by sender agent name")),
mcp.WithString("status", mcp.Description("Filter by message status")),
mcp.WithString("search_mode", mcp.Description("Search mode: 'auto' (default), 'semantic', or 'fulltext'")),
mcp.WithBoolean("semantic", mcp.Description("Force semantic search (shorthand for search_mode='semantic')")),
)
}
func (tr *ToolRegistrar) discoverAgentsTool() mcp.Tool {
return mcp.NewTool("discover_agents",
mcp.WithDescription("Discover other agents on the bus. Call this to find agents you can communicate with. Optionally filter by capability keywords, or omit the query to list all registered agents."),
mcp.WithString("query", mcp.Description("Capability keyword to search for")),
)
}
// --- Tool Handlers ---
func (tr *ToolRegistrar) handleSendMessage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
to := req.GetString("to", "")
body := req.GetString("body", "")
subject := req.GetString("subject", "")
priority := req.GetInt("priority", 5)
metadataStr := req.GetString("metadata", "")
if body == "" {
return mcp.NewToolResultError("'body' parameter is required"), nil
}
var channelID *int64
if cid := req.GetInt("channel_id", 0); cid > 0 {
v := int64(cid)
channelID = &v
}
var replyTo *int64
if rtID := req.GetInt("reply_to", 0); rtID > 0 {
v := int64(rtID)
replyTo = &v
}
opts := messaging.SendOptions{
Subject: subject,
Priority: priority,
Metadata: metadataStr,
ChannelID: channelID,
ReplyTo: replyTo,
}
msg, err := tr.msgService.SendMessage(ctx, agentName, to, body, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("send_message failed: %s", err)), nil
}
return resultJSON(map[string]any{
"message_id": msg.ID,
"conversation_id": msg.ConversationID,
"status": msg.Status,
})
}
func (tr *ToolRegistrar) handleReadInbox(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
opts := messaging.ReadOptions{
Limit: req.GetInt("limit", 50),
Status: req.GetString("status_filter", ""),
MinPriority: req.GetInt("min_priority", 0),
FromAgent: req.GetString("from_agent", ""),
}
// Handle include_read boolean
args := req.GetArguments()
if v, ok := args["include_read"]; ok {
if b, ok := v.(bool); ok {
opts.IncludeRead = b
}
}
messages, err := tr.msgService.ReadInbox(ctx, agentName, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("read_inbox failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
})
}
func (tr *ToolRegistrar) handleClaimMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
limit := req.GetInt("limit", 10)
messages, err := tr.msgService.ClaimMessages(ctx, agentName, limit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("claim_messages failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
})
}
func (tr *ToolRegistrar) handleMarkDone(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
messageID, err := req.RequireInt("message_id")
if err != nil {
return mcp.NewToolResultError("'message_id' parameter is required"), nil
}
status := req.GetString("status", "done")
reason := req.GetString("reason", "")
switch status {
case "done":
if err := tr.msgService.MarkDone(ctx, int64(messageID), agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("mark_done failed: %s", err)), nil
}
case "failed":
if err := tr.msgService.MarkFailed(ctx, int64(messageID), agentName, reason); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("mark_failed failed: %s", err)), nil
}
default:
return mcp.NewToolResultError("status must be 'done' or 'failed'"), nil
}
return resultJSON(map[string]any{
"message_id": messageID,
"status": status,
})
}
func (tr *ToolRegistrar) handleSearchMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
query := req.GetString("query", "")
// If search service is available, use it for unified search
if tr.searchService != nil {
searchMode := req.GetString("search_mode", "auto")
// Handle boolean "semantic" shorthand
args := req.GetArguments()
if v, ok := args["semantic"]; ok {
if b, ok := v.(bool); ok && b {
searchMode = "semantic"
}
}
opts := search.SearchOptions{
Query: query,
Mode: searchMode,
Limit: req.GetInt("limit", 10),
FromAgent: req.GetString("from_agent", ""),
MinPriority: req.GetInt("min_priority", 0),
}
resp, err := tr.searchService.Search(ctx, agentName, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search_messages failed: %s", err)), nil
}
// Format results
resultMsgs := make([]map[string]any, len(resp.Results))
for i, r := range resp.Results {
entry := map[string]any{
"message": r.Message,
"match_type": r.MatchType,
}
if r.SimilarityScore > 0 {
entry["similarity_score"] = r.SimilarityScore
}
if r.RelevanceScore > 0 {
entry["relevance_score"] = r.RelevanceScore
}
resultMsgs[i] = entry
}
result := map[string]any{
"results": resultMsgs,
"count": resp.TotalResults,
"search_mode": resp.SearchMode,
}
if resp.Warning != "" {
result["warning"] = resp.Warning
}
return resultJSON(result)
}
// Fallback: use messaging service directly (no search service configured)
msgOpts := messaging.SearchOptions{
Limit: req.GetInt("limit", 20),
MinPriority: req.GetInt("min_priority", 0),
FromAgent: req.GetString("from_agent", ""),
Status: req.GetString("status", ""),
}
messages, err := tr.msgService.SearchMessages(ctx, agentName, query, msgOpts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search_messages failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
"search_mode": "fulltext",
})
}
func (tr *ToolRegistrar) handleDiscoverAgents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
query := req.GetString("query", "")
_ = agentName // just verifying auth
agentsList, err := tr.agentService.DiscoverAgents(ctx, query)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("discover_agents failed: %s", err)), nil
}
// Strip sensitive fields
result := make([]map[string]any, len(agentsList))
for i, a := range agentsList {
result[i] = map[string]any{
"name": a.Name,
"display_name": a.DisplayName,
"type": a.Type,
"capabilities": a.Capabilities,
"status": a.Status,
}
}
return resultJSON(map[string]any{
"agents": result,
"count": len(result),
})
}
// resultJSON marshals data to a JSON text MCP result.
func resultJSON(data any) (*mcp.CallToolResult, error) {
b, err := json.Marshal(data)
-161
View File
@@ -1,161 +0,0 @@
package mcp
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/attachments"
)
// AttachmentToolRegistrar registers attachment MCP tools on the server.
type AttachmentToolRegistrar struct {
attachmentService *attachments.Service
}
// NewAttachmentToolRegistrar creates a new attachment tool registrar.
func NewAttachmentToolRegistrar(attachmentService *attachments.Service) *AttachmentToolRegistrar {
return &AttachmentToolRegistrar{
attachmentService: attachmentService,
}
}
// RegisterAll registers all attachment tools on the MCP server.
func (atr *AttachmentToolRegistrar) RegisterAll(s *server.MCPServer) {
s.AddTool(atr.uploadAttachmentTool(), atr.handleUploadAttachment)
s.AddTool(atr.downloadAttachmentTool(), atr.handleDownloadAttachment)
s.AddTool(atr.gcAttachmentsTool(), atr.handleGCAttachments)
}
// --- Tool Definitions ---
func (atr *AttachmentToolRegistrar) uploadAttachmentTool() mcp.Tool {
return mcp.NewTool("upload_attachment",
mcp.WithDescription("Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Max file size: 50MB."),
mcp.WithString("content", mcp.Description("Base64-encoded file content"), mcp.Required()),
mcp.WithString("filename", mcp.Description("Original filename (optional, used for MIME detection and display)")),
mcp.WithString("mime_type", mcp.Description("MIME type override (optional, auto-detected from content if not provided)")),
mcp.WithNumber("message_id", mcp.Description("Message ID to attach the file to (optional, can be linked later)")),
)
}
func (atr *AttachmentToolRegistrar) downloadAttachmentTool() mcp.Tool {
return mcp.NewTool("download_attachment",
mcp.WithDescription("Download an attachment by its SHA-256 hash. Returns base64-encoded content along with filename and MIME type metadata."),
mcp.WithString("hash", mcp.Description("SHA-256 hash of the attachment"), mcp.Required()),
)
}
func (atr *AttachmentToolRegistrar) gcAttachmentsTool() mcp.Tool {
return mcp.NewTool("gc_attachments",
mcp.WithDescription("Run garbage collection to remove orphaned attachments not referenced by any message. Returns a summary of files removed and bytes reclaimed."),
)
}
// --- Tool Handlers ---
func (atr *AttachmentToolRegistrar) handleUploadAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
contentB64 := req.GetString("content", "")
if contentB64 == "" {
return mcp.NewToolResultError("'content' parameter is required"), nil
}
// Check base64 size before decoding to avoid buffering oversized content.
// Base64 expands data by ~4/3, so decoded size is roughly 3/4 of encoded.
if int64(len(contentB64))*3/4 > attachments.MaxFileSize {
return mcp.NewToolResultError("file exceeds maximum size of 50MB"), nil
}
decoded, err := base64.StdEncoding.DecodeString(contentB64)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid base64 content: %s", err)), nil
}
if int64(len(decoded)) > attachments.MaxFileSize {
return mcp.NewToolResultError("file exceeds maximum size of 50MB"), nil
}
uploadReq := attachments.UploadRequest{
Content: bytes.NewReader(decoded),
Filename: req.GetString("filename", ""),
MIMEType: req.GetString("mime_type", ""),
UploadedBy: agentName,
}
// Optional message_id.
if mid := req.GetInt("message_id", 0); mid > 0 {
v := int64(mid)
uploadReq.MessageID = &v
}
result, err := atr.attachmentService.Upload(ctx, uploadReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("upload_attachment failed: %s", err)), nil
}
return resultJSON(map[string]any{
"hash": result.Hash,
"size": result.Size,
"mime_type": result.MIMEType,
"original_filename": result.Filename,
})
}
func (atr *AttachmentToolRegistrar) handleDownloadAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
hash := req.GetString("hash", "")
if hash == "" {
return mcp.NewToolResultError("'hash' parameter is required"), nil
}
result, err := atr.attachmentService.Download(ctx, hash)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("download_attachment failed: %s", err)), nil
}
defer result.Content.Close()
// Read content and base64-encode it.
content, err := io.ReadAll(result.Content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("read attachment content failed: %s", err)), nil
}
return resultJSON(map[string]any{
"hash": result.Hash,
"content": base64.StdEncoding.EncodeToString(content),
"original_filename": result.Filename,
"mime_type": result.MIMEType,
"size": result.Size,
})
}
func (atr *AttachmentToolRegistrar) handleGCAttachments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
_, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
result, err := atr.attachmentService.GarbageCollect(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("gc_attachments failed: %s", err)), nil
}
return resultJSON(map[string]any{
"files_removed": result.FilesRemoved,
"bytes_reclaimed": result.BytesReclaimed,
})
}
+563
View File
@@ -0,0 +1,563 @@
package mcp
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/agentquery"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/reactions"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/trust"
)
// HybridToolRegistrar registers the 4 hybrid MCP tools.
type HybridToolRegistrar struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
channelService *channels.Service
swarmService *channels.SwarmService
attachmentService *attachments.Service
searchService *search.Service
reactionService *reactions.Service
trustService *trust.Service
jsPool *jsruntime.Pool
actionRegistry *actions.Registry
actionIndex *actions.Index
db *sql.DB
queryExecutor *agentquery.Executor
logger *slog.Logger
}
// SetQueryExecutor sets the SQL query executor for all agent bridges.
func (h *HybridToolRegistrar) SetQueryExecutor(exec *agentquery.Executor) {
h.queryExecutor = exec
}
// NewHybridToolRegistrar creates a new hybrid tool registrar.
func NewHybridToolRegistrar(
msgService *messaging.MessagingService,
agentService *agents.AgentService,
channelService *channels.Service,
swarmService *channels.SwarmService,
attachmentService *attachments.Service,
searchService *search.Service,
reactionService *reactions.Service,
trustService *trust.Service,
jsPool *jsruntime.Pool,
actionRegistry *actions.Registry,
actionIndex *actions.Index,
db *sql.DB,
) *HybridToolRegistrar {
return &HybridToolRegistrar{
msgService: msgService,
agentService: agentService,
channelService: channelService,
swarmService: swarmService,
attachmentService: attachmentService,
searchService: searchService,
reactionService: reactionService,
trustService: trustService,
jsPool: jsPool,
actionRegistry: actionRegistry,
actionIndex: actionIndex,
db: db,
logger: slog.Default().With("component", "mcp-hybrid-tools"),
}
}
// RegisterAllOnServer registers all hybrid tools on an mcp-go MCPServer.
func (h *HybridToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
s.AddTool(h.myStatusTool(), h.handleMyStatus)
s.AddTool(h.sendMessageTool(), h.handleSendMessage)
s.AddTool(h.searchTool(), h.handleSearch)
s.AddTool(h.executeTool(), h.handleExecute)
s.AddTool(h.getRepliesTool(), h.handleGetReplies)
h.logger.Info("hybrid MCP tools registered", "count", 5)
}
// --- Tool Definitions ---
func (h *HybridToolRegistrar) myStatusTool() mcplib.Tool {
return mcplib.NewTool("my_status",
mcplib.WithDescription("Get your complete status overview — identity, pending messages, channel mentions, system notifications, and statistics. Call this first when connecting to SynapBus."),
)
}
func (h *HybridToolRegistrar) sendMessageTool() mcplib.Tool {
return mcplib.NewTool("send_message",
mcplib.WithDescription("Send a message to another agent (DM) or to a channel. Supports attachments — upload files first via the execute tool, then pass the returned hashes here. Specify exactly one of 'to' (agent name for DM) or 'channel' (channel name or numeric ID)."),
mcplib.WithString("to", mcplib.Description("Recipient agent name for direct messages")),
mcplib.WithString("channel", mcplib.Description("Channel name or numeric ID for channel messages")),
mcplib.WithString("body", mcplib.Description("Message body text"), mcplib.Required()),
mcplib.WithString("subject", mcplib.Description("Conversation subject (optional)")),
mcplib.WithNumber("priority", mcplib.Description("Message priority (1-10, default 5)"), mcplib.Min(1), mcplib.Max(10)),
mcplib.WithString("metadata", mcplib.Description("JSON metadata object (optional)")),
mcplib.WithNumber("reply_to", mcplib.Description("ID of the parent message to reply to. Creates a threaded reply. Always use reply_to when responding to a message that is itself a thread reply, to keep conversations organized.")),
mcplib.WithString("attachments", mcplib.Description("Comma-separated list of attachment hashes to link to this message. Upload attachments first using the upload_attachment action via the execute tool.")),
)
}
func (h *HybridToolRegistrar) searchTool() mcplib.Tool {
return mcplib.NewTool("search",
mcplib.WithDescription("Search for available actions you can perform via the 'execute' tool. Returns action names, descriptions, parameters, and examples. Use an empty query to browse all actions, or describe what you want to do."),
mcplib.WithString("query", mcplib.Description("What you want to do — e.g. 'read messages', 'create channel', 'upload file'")),
mcplib.WithNumber("limit", mcplib.Description("Maximum results to return (default 5, max 20)")),
)
}
func (h *HybridToolRegistrar) executeTool() mcplib.Tool {
return mcplib.NewTool("execute",
mcplib.WithDescription("Execute code that calls SynapBus actions. Use call(actionName, args) to invoke actions discovered via the 'search' tool. Multiple sequential calls are supported."),
mcplib.WithString("code", mcplib.Description("Code containing call() expressions. Example: call('read_inbox', { limit: 5 })"), mcplib.Required()),
mcplib.WithNumber("timeout", mcplib.Description("Execution timeout in milliseconds (default 120000, max 300000)")),
)
}
func (h *HybridToolRegistrar) getRepliesTool() mcplib.Tool {
return mcplib.NewTool("get_replies",
mcplib.WithDescription("Get all replies (thread messages) for a given message. Use this to read thread conversations, check for edits or follow-up comments on a message."),
mcplib.WithNumber("message_id", mcplib.Description("ID of the parent message to get replies for"), mcplib.Required()),
)
}
// --- Tool Handlers ---
func (h *HybridToolRegistrar) handleMyStatus(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
// 1. Get agent identity.
agent, err := h.agentService.GetAgent(ctx, agentName)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
// Resolve owner name.
ownerName := ""
if h.db != nil {
var username sql.NullString
_ = h.db.QueryRowContext(ctx,
`SELECT username FROM users WHERE id = ?`, agent.OwnerID,
).Scan(&username)
if username.Valid {
ownerName = username.String
}
}
agentInfo := map[string]any{
"name": agent.Name,
"display_name": agent.DisplayName,
"type": agent.Type,
"owner": ownerName,
}
// 2. Get pending DMs.
pendingDMs, err := h.msgService.GetPendingDMs(ctx, agentName, 10)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
pendingDMCount, err := h.msgService.GetPendingDMCount(ctx, agentName)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
dmList := make([]map[string]any, len(pendingDMs))
for i, msg := range pendingDMs {
body := msg.Body
if len(body) > 200 {
body = body[:200] + "..."
}
entry := map[string]any{
"id": msg.ID,
"from": msg.FromAgent,
"body": body,
"priority": msg.Priority,
"status": msg.Status,
"created_at": msg.CreatedAt,
}
if msg.ConversationID > 0 {
conv, _, _ := h.msgService.GetConversation(ctx, msg.ConversationID)
if conv != nil && conv.Subject != "" {
entry["subject"] = conv.Subject
}
}
dmList[i] = entry
}
// 3. Get channel mentions.
mentions, err := h.msgService.GetRecentMentions(ctx, agentName, 10)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
mentionList := make([]map[string]any, len(mentions))
for i, msg := range mentions {
body := msg.Body
if len(body) > 200 {
body = body[:200] + "..."
}
entry := map[string]any{
"id": msg.ID,
"from": msg.FromAgent,
"body": body,
"created_at": msg.CreatedAt,
}
if len(msg.Metadata) > 0 {
var meta map[string]any
if json.Unmarshal(msg.Metadata, &meta) == nil {
if chName, ok := meta["channel_name"].(string); ok {
entry["channel"] = chName
}
}
}
mentionList[i] = entry
}
// 4. Get system notifications.
sysNotifs, err := h.msgService.GetSystemNotifications(ctx, agentName, 5)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
sysNotifList := make([]map[string]any, len(sysNotifs))
for i, msg := range sysNotifs {
body := msg.Body
if len(body) > 200 {
body = body[:200] + "..."
}
sysNotifList[i] = map[string]any{
"id": msg.ID,
"body": body,
"created_at": msg.CreatedAt,
}
}
// 5. Get channel summaries.
var channelSummaries []channels.ChannelSummary
if h.channelService != nil {
channelSummaries, err = h.channelService.GetChannelSummaries(ctx, agentName)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("my_status failed: %s", err)), nil
}
}
if channelSummaries == nil {
channelSummaries = []channels.ChannelSummary{}
}
// 6. Build stats.
totalUnreadChannel := 0
for _, cs := range channelSummaries {
totalUnreadChannel += cs.UnreadCount
}
stats := map[string]any{
"pending_dms": pendingDMCount,
"channels_joined": len(channelSummaries),
"unread_channel_messages": totalUnreadChannel,
"system_notifications": len(sysNotifs),
}
// 7. Build truncation instructions.
var instructionParts []string
truncated := false
if int64(len(pendingDMs)) < pendingDMCount {
truncated = true
instructionParts = append(instructionParts, fmt.Sprintf("Showing %d of %d pending messages. Use execute tool with call('read_inbox', {}) to see all.", len(pendingDMs), pendingDMCount))
}
if len(mentions) >= 10 {
truncated = true
instructionParts = append(instructionParts, fmt.Sprintf("Showing %d mentions (may be more). Use execute tool with call('search_messages', {}) to find all.", len(mentions)))
}
if len(sysNotifs) >= 5 {
truncated = true
instructionParts = append(instructionParts, fmt.Sprintf("Showing %d system notifications (may be more). Use execute tool with call('read_inbox', { from_agent: 'system' }) to see all.", len(sysNotifs)))
}
// 8. Add usage instructions for the hybrid tools.
usageInstructions := "Use 'search' tool with a query to discover available actions. " +
"Use 'execute' tool with call(action, args) to perform any action. " +
"Use 'send_message' tool directly for sending messages (DMs or channel)."
result := map[string]any{
"agent": agentInfo,
"direct_messages": dmList,
"direct_messages_total": pendingDMCount,
"mentions": mentionList,
"mentions_total": len(mentions),
"system_notifications": sysNotifList,
"system_notifications_total": len(sysNotifs),
"channels": channelSummaries,
"stats": stats,
"truncated": truncated,
"usage": usageInstructions,
}
if len(instructionParts) > 0 {
result["instructions"] = strings.Join(instructionParts, " ")
}
return resultJSON(result)
}
func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
to := req.GetString("to", "")
channel := req.GetString("channel", "")
body := req.GetString("body", "")
subject := req.GetString("subject", "")
priority := req.GetInt("priority", 5)
metadataStr := req.GetString("metadata", "")
if body == "" {
return mcplib.NewToolResultError("'body' parameter is required"), nil
}
// Validate mutually exclusive: exactly one of to/channel.
if to == "" && channel == "" {
return mcplib.NewToolResultError("either 'to' (agent name) or 'channel' (channel name/ID) is required"), nil
}
if to != "" && channel != "" {
return mcplib.NewToolResultError("specify exactly one of 'to' (for DM) or 'channel' (for channel message), not both"), nil
}
var replyTo *int64
if rtID := req.GetInt("reply_to", 0); rtID > 0 {
v := int64(rtID)
replyTo = &v
}
var attachmentHashes []string
if attStr := req.GetString("attachments", ""); attStr != "" {
for _, h := range strings.Split(attStr, ",") {
h = strings.TrimSpace(h)
if h != "" {
attachmentHashes = append(attachmentHashes, h)
}
}
}
// Channel message path.
if channel != "" {
if h.channelService == nil {
return mcplib.NewToolResultError("channel service not available"), nil
}
// Resolve channel by name or numeric ID.
channelID, err := h.resolveChannel(ctx, channel)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
}
messages, err := h.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadataStr, replyTo, attachmentHashes)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
}
var messageID int64
if len(messages) > 0 {
messageID = messages[0].ID
}
result := map[string]any{
"channel_id": channelID,
"message_id": messageID,
"status": "sent",
}
// Enrich channel messages with attachment info.
if len(messages) > 0 && len(attachmentHashes) > 0 {
h.msgService.EnrichMessages(ctx, messages)
if len(messages[0].Attachments) > 0 {
result["attachments"] = messages[0].Attachments
}
}
return resultJSON(result)
}
// DM path.
opts := messaging.SendOptions{
Subject: subject,
Priority: priority,
Metadata: metadataStr,
ReplyTo: replyTo,
Attachments: attachmentHashes,
}
msg, err := h.msgService.SendMessage(ctx, agentName, to, body, opts)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("send_message failed: %s", err)), nil
}
result := map[string]any{
"message_id": msg.ID,
"conversation_id": msg.ConversationID,
"status": msg.Status,
}
// Enrich message with attachment info.
if len(attachmentHashes) > 0 {
h.msgService.EnrichMessages(ctx, []*messaging.Message{msg})
if len(msg.Attachments) > 0 {
result["attachments"] = msg.Attachments
}
}
return resultJSON(result)
}
func (h *HybridToolRegistrar) handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
_, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
query := req.GetString("query", "")
limit := req.GetInt("limit", 5)
results := h.actionIndex.Search(query, limit)
// Format results for the agent.
formatted := make([]map[string]any, len(results))
for i, r := range results {
params := make([]map[string]any, len(r.Action.Params))
for j, p := range r.Action.Params {
params[j] = map[string]any{
"name": p.Name,
"type": p.Type,
"description": p.Description,
"required": p.Required,
}
}
entry := map[string]any{
"name": r.Action.Name,
"category": r.Action.Category,
"description": r.Action.Description,
"params": params,
"examples": r.Action.Examples,
}
if r.Score > 0 {
entry["relevance_score"] = r.Score
}
formatted[i] = entry
}
result := map[string]any{
"actions": formatted,
"count": len(formatted),
"note": "Use the 'execute' tool with call(actionName, { param: value }) to run any action.",
}
return resultJSON(result)
}
func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
code := req.GetString("code", "")
if code == "" {
return mcplib.NewToolResultError("'code' parameter is required"), nil
}
timeoutMs := req.GetInt("timeout", 120000)
if timeoutMs > 300000 {
timeoutMs = 300000
}
timeout := time.Duration(timeoutMs) * time.Millisecond
// Create a bridge for this agent.
bridge := NewServiceBridge(
h.msgService,
h.agentService,
h.channelService,
h.swarmService,
h.attachmentService,
h.searchService,
h.reactionService,
h.trustService,
agentName,
)
if h.queryExecutor != nil {
bridge.SetQueryExecutor(h.queryExecutor)
}
result, err := h.jsPool.Execute(ctx, code, bridge, jsruntime.ExecuteOptions{
Timeout: timeout,
})
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("execute failed: %s", err)), nil
}
return resultJSON(map[string]any{
"result": result.Value,
"calls": result.CallCount,
"duration": result.Duration.String(),
})
}
func (h *HybridToolRegistrar) handleGetReplies(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
_, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
messageID := req.GetInt("message_id", 0)
if messageID == 0 {
return mcplib.NewToolResultError("'message_id' parameter is required"), nil
}
replies, err := h.msgService.GetReplies(ctx, int64(messageID))
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("get_replies failed: %s", err)), nil
}
// Enrich replies with attachment info.
h.msgService.EnrichMessages(ctx, replies)
return resultJSON(map[string]any{
"message_id": messageID,
"replies": replies,
"count": len(replies),
})
}
// resolveChannel resolves a channel name or numeric ID string to an int64 channel ID.
func (h *HybridToolRegistrar) resolveChannel(ctx context.Context, channel string) (int64, error) {
// Try parsing as numeric ID first.
var channelID int64
if _, err := fmt.Sscanf(channel, "%d", &channelID); err == nil && channelID > 0 {
return channelID, nil
}
// Resolve by name.
ch, err := h.channelService.GetChannelByName(ctx, channel)
if err != nil {
return 0, err
}
return ch.ID, nil
}
+372 -142
View File
@@ -10,7 +10,9 @@ import (
mcplib "github.com/mark3labs/mcp-go/mcp"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/jsruntime"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/trace"
@@ -40,7 +42,7 @@ func newTestDB(t *testing.T) *sql.DB {
return db
}
func newTestRegistrar(t *testing.T) (*ToolRegistrar, *messaging.MessagingService, *agents.AgentService, *sql.DB) {
func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.MessagingService, *agents.AgentService, *sql.DB) {
t.Helper()
db := newTestDB(t)
@@ -53,7 +55,26 @@ func newTestRegistrar(t *testing.T) (*ToolRegistrar, *messaging.MessagingService
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
registrar := NewToolRegistrar(msgService, agentService)
jsPool := jsruntime.NewPool(2)
t.Cleanup(func() { jsPool.Close() })
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
registrar := NewHybridToolRegistrar(
msgService,
agentService,
nil, // channelService
nil, // swarmService
nil, // attachmentService
nil, // searchService
nil, // reactionService
nil, // trustService
jsPool,
actionRegistry,
actionIndex,
db,
)
return registrar, msgService, agentService, db
}
@@ -65,24 +86,67 @@ func makeRequest(args map[string]any) mcplib.CallToolRequest {
}
}
func TestToolHandler_SendMessage(t *testing.T) {
tr, _, agentSvc, _ := newTestRegistrar(t)
func TestHybridTool_MyStatus(t *testing.T) {
h, _, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "test-agent", "Test Agent", "ai", nil, 1)
authCtx := ContextWithAgentName(ctx, "test-agent")
t.Run("returns status with usage instructions", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, err := h.handleMyStatus(authCtx, req)
if err != nil {
t.Fatalf("handleMyStatus: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
// Check agent info
agentInfo := resp["agent"].(map[string]any)
if agentInfo["name"] != "test-agent" {
t.Errorf("agent name = %v, want test-agent", agentInfo["name"])
}
// Check usage instructions
usage := resp["usage"].(string)
if usage == "" {
t.Error("expected usage instructions in response")
}
})
t.Run("unauthenticated", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, _ := h.handleMyStatus(ctx, req)
if !result.IsError {
t.Error("expected error for unauthenticated request")
}
})
}
func TestHybridTool_SendMessage_DM(t *testing.T) {
h, _, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
// Register agents
agentSvc.Register(ctx, "sender", "Sender", "ai", nil, 1)
agentSvc.Register(ctx, "receiver", "Receiver", "ai", nil, 1)
// Set up authenticated context
authCtx := ContextWithAgentName(ctx, "sender")
t.Run("successful send", func(t *testing.T) {
t.Run("successful DM", func(t *testing.T) {
req := makeRequest(map[string]any{
"to": "receiver",
"body": "Hello from test",
})
result, err := tr.handleSendMessage(authCtx, req)
result, err := h.handleSendMessage(authCtx, req)
if err != nil {
t.Fatalf("handleSendMessage: %v", err)
}
@@ -98,58 +162,65 @@ func TestToolHandler_SendMessage(t *testing.T) {
}
})
t.Run("missing to", func(t *testing.T) {
req := makeRequest(map[string]any{
"body": "no recipient",
})
result, _ := tr.handleSendMessage(authCtx, req)
if !result.IsError {
t.Error("expected error for missing 'to'")
}
})
t.Run("missing body", func(t *testing.T) {
req := makeRequest(map[string]any{
"to": "receiver",
})
result, _ := tr.handleSendMessage(authCtx, req)
result, _ := h.handleSendMessage(authCtx, req)
if !result.IsError {
t.Error("expected error for missing body")
}
})
t.Run("both to and channel rejected", func(t *testing.T) {
req := makeRequest(map[string]any{
"to": "receiver",
"channel": "general",
"body": "test",
})
result, _ := h.handleSendMessage(authCtx, req)
if !result.IsError {
t.Error("expected error when both to and channel specified")
}
})
t.Run("neither to nor channel rejected", func(t *testing.T) {
req := makeRequest(map[string]any{
"body": "test",
})
result, _ := h.handleSendMessage(authCtx, req)
if !result.IsError {
t.Error("expected error when neither to nor channel specified")
}
})
t.Run("unauthenticated", func(t *testing.T) {
req := makeRequest(map[string]any{
"to": "receiver",
"body": "should fail",
})
result, _ := tr.handleSendMessage(ctx, req)
result, _ := h.handleSendMessage(ctx, req)
if !result.IsError {
t.Error("expected error for unauthenticated request")
}
})
}
func TestToolHandler_ReadInbox(t *testing.T) {
tr, msgSvc, agentSvc, _ := newTestRegistrar(t)
func TestHybridTool_Search(t *testing.T) {
h, _, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "sender", "Sender", "ai", nil, 1)
agentSvc.Register(ctx, "reader", "Reader", "ai", nil, 1)
agentSvc.Register(ctx, "test-agent", "Test Agent", "ai", nil, 1)
authCtx := ContextWithAgentName(ctx, "test-agent")
msgSvc.SendMessage(ctx, "sender", "reader", "test message", messaging.SendOptions{})
t.Run("search for messaging actions", func(t *testing.T) {
req := makeRequest(map[string]any{
"query": "read inbox messages",
})
authCtx := ContextWithAgentName(ctx, "reader")
t.Run("read messages", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, err := tr.handleReadInbox(authCtx, req)
result, err := h.handleSearch(authCtx, req)
if err != nil {
t.Fatalf("handleReadInbox: %v", err)
t.Fatalf("handleSearch: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
@@ -158,96 +229,202 @@ func TestToolHandler_ReadInbox(t *testing.T) {
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count == 0 {
t.Error("expected at least one result")
}
actionsList := resp["actions"].([]any)
firstAction := actionsList[0].(map[string]any)
if firstAction["name"] == nil {
t.Error("expected name in action result")
}
})
t.Run("empty query returns all actions", func(t *testing.T) {
req := makeRequest(map[string]any{
"limit": float64(20),
})
result, err := h.handleSearch(authCtx, req)
if err != nil {
t.Fatalf("handleSearch: %v", err)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count < 5 {
t.Errorf("expected at least 5 actions in browse mode, got %v", count)
}
})
}
func TestHybridTool_Execute(t *testing.T) {
h, msgSvc, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "executor", "Executor", "ai", nil, 1)
agentSvc.Register(ctx, "target", "Target", "ai", nil, 1)
// Send a message so the executor has something to read
msgSvc.SendMessage(ctx, "target", "executor", "hello executor", messaging.SendOptions{})
authCtx := ContextWithAgentName(ctx, "executor")
t.Run("read_inbox via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": `call("read_inbox", { limit: 10 })`,
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
count := resultData["count"].(float64)
if count != 1 {
t.Errorf("count = %v, want 1", count)
t.Errorf("expected 1 message, got %v", count)
}
})
t.Run("send_message via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": `call("send_message", { to: "target", body: "hello from execute" })`,
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
if resultData["message_id"] == nil {
t.Error("expected message_id in execute result")
}
})
t.Run("discover_agents via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": `call("discover_agents", {})`,
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
resultData := parseCallResult(t, result)
count := resultData["count"].(float64)
if count < 2 {
t.Errorf("expected at least 2 agents, got %v", count)
}
})
t.Run("unknown action returns error", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": `call("nonexistent_action", {})`,
})
result, _ := h.handleExecute(authCtx, req)
// call() returns { ok: false, error: {...} } inside a successful execution result.
resp := parseResponse(t, result)
callResult := resp["result"].(map[string]any)
if callResult["ok"] != false {
t.Error("expected ok=false for unknown action")
}
})
t.Run("empty code rejected", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": "",
})
result, _ := h.handleExecute(authCtx, req)
if !result.IsError {
t.Error("expected error for empty code")
}
})
t.Run("unauthenticated", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, _ := tr.handleReadInbox(ctx, req)
req := makeRequest(map[string]any{
"code": `call("read_inbox", {})`,
})
result, _ := h.handleExecute(ctx, req)
if !result.IsError {
t.Error("expected error for unauthenticated request")
}
})
}
func TestToolHandler_ClaimMessages(t *testing.T) {
tr, msgSvc, agentSvc, _ := newTestRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "sender", "Sender", "ai", nil, 1)
agentSvc.Register(ctx, "claimer", "Claimer", "ai", nil, 1)
msgSvc.SendMessage(ctx, "sender", "claimer", "task 1", messaging.SendOptions{})
msgSvc.SendMessage(ctx, "sender", "claimer", "task 2", messaging.SendOptions{})
authCtx := ContextWithAgentName(ctx, "claimer")
req := makeRequest(map[string]any{"limit": float64(1)})
result, err := tr.handleClaimMessages(authCtx, req)
if err != nil {
t.Fatalf("handleClaimMessages: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("count = %v, want 1", count)
}
}
func TestToolHandler_MarkDone(t *testing.T) {
tr, msgSvc, agentSvc, _ := newTestRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "sender", "Sender", "ai", nil, 1)
agentSvc.Register(ctx, "worker", "Worker", "ai", nil, 1)
msg, _ := msgSvc.SendMessage(ctx, "sender", "worker", "do this", messaging.SendOptions{})
msgSvc.ClaimMessages(ctx, "worker", 1)
authCtx := ContextWithAgentName(ctx, "worker")
req := makeRequest(map[string]any{
"message_id": float64(msg.ID),
})
result, err := tr.handleMarkDone(authCtx, req)
if err != nil {
t.Fatalf("handleMarkDone: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
}
func TestToolHandler_SearchMessages(t *testing.T) {
tr, msgSvc, agentSvc, _ := newTestRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "sender", "Sender", "ai", nil, 1)
agentSvc.Register(ctx, "searcher", "Searcher", "ai", nil, 1)
msgSvc.SendMessage(ctx, "sender", "searcher", "deployment failed", messaging.SendOptions{})
msgSvc.SendMessage(ctx, "sender", "searcher", "all clear", messaging.SendOptions{})
authCtx := ContextWithAgentName(ctx, "searcher")
t.Run("keyword search", func(t *testing.T) {
t.Run("auth propagation to bridge", func(t *testing.T) {
req := makeRequest(map[string]any{
"query": "deployment",
"code": `call("read_inbox", {})`,
})
result, err := tr.handleSearchMessages(authCtx, req)
// Execute as "executor" - should see executor's inbox
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleSearchMessages: %v", err)
t.Fatalf("handleExecute: %v", err)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
// The bridge should use "executor" as the agent name
if resp["calls"].(float64) != 1 {
t.Errorf("expected 1 call, got %v", resp["calls"])
}
})
}
func TestHybridTool_GetReplies(t *testing.T) {
h, msgSvc, agentSvc, _ := newTestHybridRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "alice", "Alice", "ai", nil, 1)
agentSvc.Register(ctx, "bob", "Bob", "ai", nil, 1)
authCtx := ContextWithAgentName(ctx, "alice")
// Send a parent message from bob to alice.
parentMsg, err := msgSvc.SendMessage(ctx, "bob", "alice", "parent message", messaging.SendOptions{})
if err != nil {
t.Fatalf("send parent message: %v", err)
}
// Send two replies to the parent message.
replyTo := parentMsg.ID
_, err = msgSvc.SendMessage(ctx, "alice", "bob", "reply one", messaging.SendOptions{ReplyTo: &replyTo})
if err != nil {
t.Fatalf("send reply 1: %v", err)
}
_, err = msgSvc.SendMessage(ctx, "bob", "alice", "reply two", messaging.SendOptions{ReplyTo: &replyTo})
if err != nil {
t.Fatalf("send reply 2: %v", err)
}
t.Run("returns replies for message", func(t *testing.T) {
req := makeRequest(map[string]any{
"message_id": float64(parentMsg.ID),
})
result, err := h.handleGetReplies(authCtx, req)
if err != nil {
t.Fatalf("handleGetReplies: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
@@ -256,41 +433,94 @@ func TestToolHandler_SearchMessages(t *testing.T) {
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("count = %v, want 1", count)
if count != 2 {
t.Errorf("expected 2 replies, got %v", count)
}
replies := resp["replies"].([]any)
if len(replies) != 2 {
t.Errorf("expected 2 replies in array, got %d", len(replies))
}
if resp["message_id"].(float64) != float64(parentMsg.ID) {
t.Errorf("expected message_id %d, got %v", parentMsg.ID, resp["message_id"])
}
})
t.Run("returns empty for message with no replies", func(t *testing.T) {
// Send a message with no replies.
noReplyMsg, err := msgSvc.SendMessage(ctx, "bob", "alice", "no replies here", messaging.SendOptions{})
if err != nil {
t.Fatalf("send message: %v", err)
}
req := makeRequest(map[string]any{
"message_id": float64(noReplyMsg.ID),
})
result, err := h.handleGetReplies(authCtx, req)
if err != nil {
t.Fatalf("handleGetReplies: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count != 0 {
t.Errorf("expected 0 replies, got %v", count)
}
})
t.Run("missing message_id", func(t *testing.T) {
req := makeRequest(map[string]any{})
result, _ := h.handleGetReplies(authCtx, req)
if !result.IsError {
t.Error("expected error for missing message_id")
}
})
t.Run("unauthenticated", func(t *testing.T) {
req := makeRequest(map[string]any{
"message_id": float64(1),
})
result, _ := h.handleGetReplies(ctx, req)
if !result.IsError {
t.Error("expected error for unauthenticated request")
}
})
t.Run("get_replies via execute", func(t *testing.T) {
req := makeRequest(map[string]any{
"code": fmt.Sprintf(`call("get_replies", {"message_id": %d})`, parentMsg.ID),
})
result, err := h.handleExecute(authCtx, req)
if err != nil {
t.Fatalf("handleExecute: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
// Parse the execute envelope to get the bridge result.
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
callEnvelope := resp["result"].(map[string]any)
inner := callEnvelope["result"].(map[string]any)
count := inner["count"].(float64)
if count != 2 {
t.Errorf("expected 2 replies via execute, got %v", count)
}
})
}
func TestToolHandler_DiscoverAgents(t *testing.T) {
tr, _, agentSvc, _ := newTestRegistrar(t)
ctx := context.Background()
agentSvc.Register(ctx, "bot-a", "Bot A", "ai", json.RawMessage(`{"skills":["search"]}`), 1)
agentSvc.Register(ctx, "bot-b", "Bot B", "ai", json.RawMessage(`{"skills":["analyze"]}`), 1)
authCtx := ContextWithAgentName(ctx, "bot-a")
req := makeRequest(map[string]any{
"query": "search",
})
result, err := tr.handleDiscoverAgents(authCtx, req)
if err != nil {
t.Fatalf("handleDiscoverAgents: %v", err)
}
if result.IsError {
t.Fatalf("unexpected error: %v", result.Content)
}
var resp map[string]any
text := result.Content[0].(mcplib.TextContent).Text
json.Unmarshal([]byte(text), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("count = %v, want 1", count)
}
}
var _ = storage.RunMigrations
-316
View File
@@ -1,316 +0,0 @@
package mcp
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/webhooks"
)
// WebhookToolRegistrar registers webhook and K8s handler MCP tools.
type WebhookToolRegistrar struct {
webhookService *webhooks.WebhookService
k8sService *k8s.K8sService
logger *slog.Logger
}
// NewWebhookToolRegistrar creates a new webhook tool registrar.
func NewWebhookToolRegistrar(webhookService *webhooks.WebhookService, k8sService *k8s.K8sService) *WebhookToolRegistrar {
return &WebhookToolRegistrar{
webhookService: webhookService,
k8sService: k8sService,
logger: slog.Default().With("component", "mcp-webhook-tools"),
}
}
// RegisterAll registers all webhook and K8s handler tools on the MCP server.
func (r *WebhookToolRegistrar) RegisterAll(s *server.MCPServer) {
count := 0
// Webhook tools
if r.webhookService != nil {
s.AddTool(r.registerWebhookTool(), r.handleRegisterWebhook)
s.AddTool(r.listWebhooksTool(), r.handleListWebhooks)
s.AddTool(r.deleteWebhookTool(), r.handleDeleteWebhook)
count += 3
}
// K8s handler tools
if r.k8sService != nil {
s.AddTool(r.registerK8sHandlerTool(), r.handleRegisterK8sHandler)
s.AddTool(r.listK8sHandlersTool(), r.handleListK8sHandlers)
s.AddTool(r.deleteK8sHandlerTool(), r.handleDeleteK8sHandler)
count += 3
}
r.logger.Info("webhook/K8s MCP tools registered", "count", count)
}
// --- Webhook Tool Definitions ---
func (r *WebhookToolRegistrar) registerWebhookTool() mcp.Tool {
return mcp.NewTool("register_webhook",
mcp.WithDescription("Register a webhook URL to receive event notifications. When matching events occur (messages, mentions), SynapBus will POST a signed JSON payload to your URL. Max 3 webhooks per agent. HTTPS required in production."),
mcp.WithString("url", mcp.Description("HTTPS URL to receive webhook POST requests"), mcp.Required()),
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
mcp.WithString("secret", mcp.Description("Shared secret for HMAC-SHA256 payload signing (X-SynapBus-Signature header)"), mcp.Required()),
)
}
func (r *WebhookToolRegistrar) listWebhooksTool() mcp.Tool {
return mcp.NewTool("list_webhooks",
mcp.WithDescription("List your registered webhooks and their status (active/disabled, failure counts)."),
)
}
func (r *WebhookToolRegistrar) deleteWebhookTool() mcp.Tool {
return mcp.NewTool("delete_webhook",
mcp.WithDescription("Delete one of your registered webhooks by ID."),
mcp.WithNumber("webhook_id", mcp.Description("ID of the webhook to delete"), mcp.Required()),
)
}
// --- Webhook Tool Handlers ---
func (r *WebhookToolRegistrar) handleRegisterWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
url := req.GetString("url", "")
eventsStr := req.GetString("events", "")
secret := req.GetString("secret", "")
if url == "" {
return mcp.NewToolResultError("'url' parameter is required"), nil
}
if eventsStr == "" {
return mcp.NewToolResultError("'events' parameter is required"), nil
}
if secret == "" {
return mcp.NewToolResultError("'secret' parameter is required"), nil
}
// Parse comma-separated events
events := parseEvents(eventsStr)
wh, err := r.webhookService.RegisterWebhook(ctx, agentName, url, events, secret)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("register_webhook failed: %s", err)), nil
}
return resultJSON(map[string]any{
"webhook_id": wh.ID,
"url": wh.URL,
"events": wh.Events,
"status": wh.Status,
})
}
func (r *WebhookToolRegistrar) handleListWebhooks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
hooks, err := r.webhookService.ListWebhooks(ctx, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list_webhooks failed: %s", err)), nil
}
result := make([]map[string]any, len(hooks))
for i, wh := range hooks {
result[i] = map[string]any{
"id": wh.ID,
"url": wh.URL,
"events": wh.Events,
"status": wh.Status,
"consecutive_failures": wh.ConsecutiveFailures,
"created_at": wh.CreatedAt,
}
}
return resultJSON(map[string]any{
"webhooks": result,
"count": len(result),
})
}
func (r *WebhookToolRegistrar) handleDeleteWebhook(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
webhookID, err := req.RequireInt("webhook_id")
if err != nil {
return mcp.NewToolResultError("'webhook_id' parameter is required"), nil
}
if err := r.webhookService.DeleteWebhook(ctx, agentName, int64(webhookID)); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("delete_webhook failed: %s", err)), nil
}
return resultJSON(map[string]any{
"deleted": true,
"webhook_id": webhookID,
})
}
// --- K8s Handler Tool Definitions ---
func (r *WebhookToolRegistrar) registerK8sHandlerTool() mcp.Tool {
return mcp.NewTool("register_k8s_handler",
mcp.WithDescription("Register a Kubernetes Job handler. When matching events occur, SynapBus launches a K8s Job with message data injected via environment variables. Only available when SynapBus runs in-cluster."),
mcp.WithString("image", mcp.Description("Container image to run (e.g. myregistry/handler:v1)"), mcp.Required()),
mcp.WithString("events", mcp.Description("Comma-separated event types: message.received, message.mentioned, channel.message"), mcp.Required()),
mcp.WithString("namespace", mcp.Description("Kubernetes namespace (default: SynapBus's namespace)")),
mcp.WithString("resources_memory", mcp.Description("Memory limit (e.g. 256Mi, 1Gi)")),
mcp.WithString("resources_cpu", mcp.Description("CPU limit (e.g. 100m, 1)")),
mcp.WithString("env", mcp.Description("Comma-separated KEY=VALUE environment variables")),
mcp.WithNumber("timeout_seconds", mcp.Description("Job timeout in seconds (default 300)")),
)
}
func (r *WebhookToolRegistrar) listK8sHandlersTool() mcp.Tool {
return mcp.NewTool("list_k8s_handlers",
mcp.WithDescription("List your registered Kubernetes Job handlers and their status."),
)
}
func (r *WebhookToolRegistrar) deleteK8sHandlerTool() mcp.Tool {
return mcp.NewTool("delete_k8s_handler",
mcp.WithDescription("Delete one of your registered Kubernetes Job handlers by ID."),
mcp.WithNumber("handler_id", mcp.Description("ID of the K8s handler to delete"), mcp.Required()),
)
}
// --- K8s Handler Tool Handlers ---
func (r *WebhookToolRegistrar) handleRegisterK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
image := req.GetString("image", "")
eventsStr := req.GetString("events", "")
if image == "" {
return mcp.NewToolResultError("'image' parameter is required"), nil
}
if eventsStr == "" {
return mcp.NewToolResultError("'events' parameter is required"), nil
}
events := parseEvents(eventsStr)
// Parse env vars
envMap := make(map[string]string)
if envStr := req.GetString("env", ""); envStr != "" {
for _, pair := range strings.Split(envStr, ",") {
pair = strings.TrimSpace(pair)
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
envMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
handlerReq := k8s.RegisterHandlerRequest{
Image: image,
Events: events,
Namespace: req.GetString("namespace", ""),
ResourcesMemory: req.GetString("resources_memory", ""),
ResourcesCPU: req.GetString("resources_cpu", ""),
Env: envMap,
TimeoutSeconds: req.GetInt("timeout_seconds", 300),
}
handler, err := r.k8sService.RegisterHandler(ctx, agentName, handlerReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("register_k8s_handler failed: %s", err)), nil
}
return resultJSON(map[string]any{
"handler_id": handler.ID,
"image": handler.Image,
"events": handler.Events,
"namespace": handler.Namespace,
"timeout_seconds": handler.TimeoutSeconds,
"status": handler.Status,
})
}
func (r *WebhookToolRegistrar) handleListK8sHandlers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
handlers, err := r.k8sService.ListHandlers(ctx, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list_k8s_handlers failed: %s", err)), nil
}
result := make([]map[string]any, len(handlers))
for i, h := range handlers {
result[i] = map[string]any{
"id": h.ID,
"image": h.Image,
"events": h.Events,
"namespace": h.Namespace,
"resources_memory": h.ResourcesMemory,
"resources_cpu": h.ResourcesCPU,
"timeout_seconds": h.TimeoutSeconds,
"status": h.Status,
"created_at": h.CreatedAt,
}
}
return resultJSON(map[string]any{
"handlers": result,
"count": len(result),
"k8s_available": r.k8sService.IsAvailable(),
})
}
func (r *WebhookToolRegistrar) handleDeleteK8sHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
handlerID, err := req.RequireInt("handler_id")
if err != nil {
return mcp.NewToolResultError("'handler_id' parameter is required"), nil
}
if err := r.k8sService.DeleteHandler(ctx, agentName, int64(handlerID)); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("delete_k8s_handler failed: %s", err)), nil
}
return resultJSON(map[string]any{
"deleted": true,
"handler_id": handlerID,
})
}
// parseEvents splits a comma-separated event string into a trimmed slice.
func parseEvents(s string) []string {
parts := strings.Split(s, ",")
events := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
events = append(events, p)
}
}
return events
}
+24 -12
View File
@@ -2,12 +2,13 @@ package messaging
// SendOptions configures message sending behavior.
type SendOptions struct {
Subject string `json:"subject,omitempty"`
Priority int `json:"priority,omitempty"`
Metadata string `json:"metadata,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
ConversationID *int64 `json:"conversation_id,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
Subject string `json:"subject,omitempty"`
Priority int `json:"priority,omitempty"`
Metadata string `json:"metadata,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
ConversationID *int64 `json:"conversation_id,omitempty"`
ReplyTo *int64 `json:"reply_to,omitempty"`
Attachments []string `json:"attachments,omitempty"` // attachment hashes to link
}
// ReadOptions configures inbox reading behavior.
@@ -17,15 +18,26 @@ type ReadOptions struct {
ConversationID *int64 `json:"conversation_id,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
IncludeRead bool `json:"include_read,omitempty"`
}
// SearchOptions configures message search behavior.
type SearchOptions struct {
FromAgent string `json:"from_agent,omitempty"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
FromAgent string `json:"from_agent,omitempty"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
Channel string `json:"channel,omitempty"`
Channels []string `json:"channels,omitempty"` // include messages in these channels
ExcludeChannels []string `json:"exclude_channels,omitempty"` // exclude messages in these channels
Agents []string `json:"agents,omitempty"` // include messages from/to these agents
ExcludeAgents []string `json:"exclude_agents,omitempty"` // exclude messages from/to these agents
}

Some files were not shown because too many files have changed in this diff Show More