Compare commits
@@ -44,3 +44,4 @@ __pycache__/
|
||||
# Debug
|
||||
__debug_bin*
|
||||
.claude/worktrees/
|
||||
synapbus-linux-amd64
|
||||
|
||||
@@ -106,6 +106,10 @@ make lint # Run linters
|
||||
- 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)
|
||||
|
||||
+74
-3
@@ -39,6 +39,8 @@ import (
|
||||
"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"
|
||||
@@ -47,6 +49,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -291,6 +294,11 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
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 {
|
||||
@@ -461,10 +469,21 @@ 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)
|
||||
|
||||
// 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()
|
||||
@@ -473,7 +492,14 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
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, con, jsPool, actionRegistry, actionIndex, db.DB)
|
||||
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
|
||||
@@ -634,6 +660,10 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
DB: db.DB,
|
||||
Version: version,
|
||||
PushService: pushService,
|
||||
TrustService: trustService,
|
||||
ReactorStore: reactorStore,
|
||||
ReactorEngine: reactorEngine,
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
r.Mount("/", apiRouter)
|
||||
|
||||
@@ -997,3 +1027,44 @@ func (a *channelLookupAdapter) GetChannelIDByName(ctx context.Context, name stri
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -6,10 +6,10 @@ type Registry struct {
|
||||
ordered []Action // maintains insertion order
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry pre-populated with all 27 agent-callable actions.
|
||||
// NewRegistry creates a registry pre-populated with all 28 agent-callable actions.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
actions: make(map[string]Action, 27),
|
||||
actions: make(map[string]Action, 28),
|
||||
}
|
||||
for _, a := range allActions() {
|
||||
r.actions[a.Name] = a
|
||||
@@ -42,7 +42,7 @@ func (r *Registry) ListByCategory(category string) []Action {
|
||||
return out
|
||||
}
|
||||
|
||||
// allActions returns the canonical list of all 27 agent-callable actions.
|
||||
// allActions returns the canonical list of all 28 agent-callable actions.
|
||||
func allActions() []Action {
|
||||
return []Action{
|
||||
// ── Messaging (7 actions) ──────────────────────────────────────
|
||||
@@ -339,7 +339,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "post_task",
|
||||
Category: "swarm",
|
||||
Description: "Post a task to an auction channel for agents to bid on",
|
||||
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},
|
||||
@@ -358,7 +358,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "bid_task",
|
||||
Category: "swarm",
|
||||
Description: "Submit a bid on an open task in an auction channel",
|
||||
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"},
|
||||
@@ -460,7 +460,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "react",
|
||||
Category: "reactions",
|
||||
Description: "Add or toggle a reaction on a message. Valid reactions: approve, reject, in_progress, done, published. Adding the same reaction again removes it (toggle).",
|
||||
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},
|
||||
@@ -481,7 +481,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "unreact",
|
||||
Category: "reactions",
|
||||
Description: "Remove a specific reaction from a message.",
|
||||
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},
|
||||
@@ -497,7 +497,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "get_reactions",
|
||||
Category: "reactions",
|
||||
Description: "Get all reactions on a message and its derived workflow state.",
|
||||
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},
|
||||
},
|
||||
@@ -512,16 +512,90 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "list_by_state",
|
||||
Category: "reactions",
|
||||
Description: "List messages in a channel filtered by workflow state. Valid states: proposed, approved, in_progress, rejected, done, published.",
|
||||
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 array and count",
|
||||
Returns: "JSON with message_ids, count (this page), total (all matching), limit, offset, and optionally messages array",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "List approved messages in a channel",
|
||||
Code: `call("list_by_state", {"channel": "approvals", "state": "approved"})`,
|
||||
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"})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryHas27Actions(t *testing.T) {
|
||||
func TestRegistryHas30Actions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
got := len(r.List())
|
||||
if got != 27 {
|
||||
t.Errorf("expected 27 actions, got %d", got)
|
||||
if got != 30 {
|
||||
t.Errorf("expected 30 actions, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ func TestRegistryCategories(t *testing.T) {
|
||||
{"swarm", 5},
|
||||
{"attachments", 2},
|
||||
{"reactions", 4},
|
||||
{"threads", 1},
|
||||
{"trust", 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -52,6 +54,12 @@ func TestRegistryGetByName(t *testing.T) {
|
||||
"upload_attachment", "download_attachment",
|
||||
// reactions
|
||||
"react", "unreact", "get_reactions", "list_by_state",
|
||||
// threads
|
||||
"get_replies",
|
||||
// trust
|
||||
"get_trust",
|
||||
// data
|
||||
"query",
|
||||
}
|
||||
|
||||
for _, name := range allNames {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+108
-14
@@ -19,6 +19,12 @@ type AgentStore interface {
|
||||
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.
|
||||
@@ -37,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)`,
|
||||
@@ -51,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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -103,8 +186,7 @@ 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
|
||||
@@ -115,8 +197,7 @@ func (s *SQLiteAgentStore) ListActiveAgents(ctx context.Context) ([]*Agent, erro
|
||||
|
||||
func (s *SQLiteAgentStore) ListAllActiveAgents(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' AND type != 'human' ORDER BY name`,
|
||||
agentSelectSQL()+` WHERE status = 'active' AND type != 'human' ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -127,8 +208,7 @@ func (s *SQLiteAgentStore) ListAllActiveAgents(ctx context.Context) ([]*Agent, e
|
||||
|
||||
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 {
|
||||
@@ -141,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 {
|
||||
@@ -154,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
|
||||
}
|
||||
|
||||
@@ -179,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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -327,9 +327,12 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AutoApprove *bool `json:"auto_approve"`
|
||||
StalemateRemindAfter *string `json:"stalemate_remind_after"`
|
||||
StalemateEscalateAfter *string `json:"stalemate_escalate_after"`
|
||||
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 {
|
||||
@@ -338,11 +341,17 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -352,6 +361,12 @@ func (h *ChannelsHandler) UpdateSettings(w http.ResponseWriter, r *http.Request)
|
||||
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 {
|
||||
|
||||
@@ -564,6 +564,11 @@ 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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -12,9 +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"
|
||||
)
|
||||
|
||||
@@ -35,11 +37,15 @@ type RouterConfig struct {
|
||||
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.
|
||||
@@ -235,6 +241,46 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -119,26 +119,8 @@ func IsImageType(mimeType string) bool {
|
||||
return imageTypes[mimeType]
|
||||
}
|
||||
|
||||
// IsAllowedType returns true if the MIME type is allowed for upload.
|
||||
// Allowed: image/*, application/pdf, text/*.
|
||||
// IsAllowedType returns true for all MIME types. Any file type is allowed;
|
||||
// only size is restricted (50 MB max).
|
||||
func IsAllowedType(mimeType string) bool {
|
||||
// Normalize: strip parameters like "; charset=utf-8".
|
||||
base := mimeType
|
||||
if idx := strings.Index(mimeType, ";"); idx >= 0 {
|
||||
base = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
if strings.HasPrefix(base, "image/") {
|
||||
return true
|
||||
}
|
||||
if base == "application/pdf" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(base, "text/") {
|
||||
return true
|
||||
}
|
||||
// Also allow JSON and XML which may be detected as application/*
|
||||
if base == "application/json" || base == "application/xml" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -167,10 +167,10 @@ func TestIsAllowedType(t *testing.T) {
|
||||
{"text/csv", true},
|
||||
{"text/plain; charset=utf-8", true},
|
||||
{"application/json", true},
|
||||
{"application/octet-stream", false},
|
||||
{"application/zip", false},
|
||||
{"application/x-executable", false},
|
||||
{"video/mp4", false},
|
||||
{"application/octet-stream", true},
|
||||
{"application/zip", true},
|
||||
{"application/x-executable", true},
|
||||
{"video/mp4", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -60,11 +60,6 @@ func (s *Service) Upload(ctx context.Context, req UploadRequest) (*UploadResult,
|
||||
mimeType = DetectMIMEType(sniffBuf, req.Filename)
|
||||
}
|
||||
|
||||
// Validate file type against allowlist.
|
||||
if !IsAllowedType(mimeType) {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
|
||||
// Assign default filename if missing.
|
||||
filename := req.Filename
|
||||
if filename == "" {
|
||||
|
||||
@@ -236,18 +236,18 @@ func TestService_Upload_FileTypeValidation(t *testing.T) {
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid type zip rejected",
|
||||
name: "zip upload allowed",
|
||||
content: []byte("not real zip content"),
|
||||
filename: "archive.zip",
|
||||
mimeType: "application/zip",
|
||||
wantErr: ErrUnsupportedType,
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid type executable rejected",
|
||||
name: "executable upload allowed",
|
||||
content: []byte{0x7f, 0x45, 0x4c, 0x46},
|
||||
filename: "program.exe",
|
||||
mimeType: "application/x-executable",
|
||||
wantErr: ErrUnsupportedType,
|
||||
wantErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -83,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, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, 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.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &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
|
||||
@@ -102,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, workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after, 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.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &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
|
||||
@@ -120,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.workflow_enabled, c.auto_approve, c.stalemate_remind_after, c.stalemate_escalate_after, 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 = ?)
|
||||
@@ -137,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.WorkflowEnabled, &ch.AutoApprove, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter, &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
|
||||
@@ -418,8 +418,8 @@ func (s *SQLiteChannelStore) GetChannelSummaries(ctx context.Context, agentName
|
||||
// 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 = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
settings.WorkflowEnabled, settings.AutoApprove, settings.StalemateRemindAfter, settings.StalemateEscalateAfter, id,
|
||||
`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)
|
||||
|
||||
@@ -37,6 +37,8 @@ type Channel struct {
|
||||
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"`
|
||||
}
|
||||
@@ -113,10 +115,12 @@ type JoinChannelRequest struct {
|
||||
|
||||
// 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"`
|
||||
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.
|
||||
|
||||
+54
-3
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+189
-9
@@ -7,15 +7,18 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/agentquery"
|
||||
"github.com/synapbus/synapbus/internal/attachments"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/reactions"
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
)
|
||||
|
||||
// ServiceBridge implements jsruntime.ToolCaller, mapping action names to
|
||||
@@ -28,6 +31,8 @@ type ServiceBridge struct {
|
||||
attachmentService *attachments.Service
|
||||
searchService *search.Service
|
||||
reactionService *reactions.Service
|
||||
trustService *trust.Service
|
||||
queryExecutor *agentquery.Executor
|
||||
agentName string
|
||||
}
|
||||
|
||||
@@ -40,6 +45,7 @@ func NewServiceBridge(
|
||||
attachmentService *attachments.Service,
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
agentName string,
|
||||
) *ServiceBridge {
|
||||
return &ServiceBridge{
|
||||
@@ -50,6 +56,7 @@ func NewServiceBridge(
|
||||
attachmentService: attachmentService,
|
||||
searchService: searchService,
|
||||
reactionService: reactionService,
|
||||
trustService: trustService,
|
||||
agentName: agentName,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +124,18 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st
|
||||
case "list_by_state":
|
||||
return b.callListByState(ctx, args)
|
||||
|
||||
// --- Threads ---
|
||||
case "get_replies":
|
||||
return b.callGetReplies(ctx, args)
|
||||
|
||||
// --- Trust ---
|
||||
case "get_trust":
|
||||
return b.callGetTrust(ctx, args)
|
||||
|
||||
// --- SQL Query ---
|
||||
case "query":
|
||||
return b.callQuery(ctx, args)
|
||||
|
||||
// --- DM send (also accessible via bridge for execute tool) ---
|
||||
case "send_message":
|
||||
return b.callSendMessage(ctx, args)
|
||||
@@ -203,6 +222,8 @@ func (b *ServiceBridge) callReadInbox(ctx context.Context, args map[string]any)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.msgService.EnrichMessages(ctx, page.Messages)
|
||||
|
||||
return map[string]any{
|
||||
"messages": page.Messages,
|
||||
"count": len(page.Messages),
|
||||
@@ -220,6 +241,8 @@ func (b *ServiceBridge) callClaimMessages(ctx context.Context, args map[string]a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.msgService.EnrichMessages(ctx, messages)
|
||||
|
||||
return map[string]any{
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
@@ -274,6 +297,15 @@ func (b *ServiceBridge) callSearchMessages(ctx context.Context, args map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich messages with attachments
|
||||
searchMsgs := make([]*messaging.Message, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
if r.Message != nil {
|
||||
searchMsgs = append(searchMsgs, r.Message)
|
||||
}
|
||||
}
|
||||
b.msgService.EnrichMessages(ctx, searchMsgs)
|
||||
|
||||
resultMsgs := make([]map[string]any, len(resp.Results))
|
||||
for i, r := range resp.Results {
|
||||
entry := map[string]any{
|
||||
@@ -313,6 +345,8 @@ func (b *ServiceBridge) callSearchMessages(ctx context.Context, args map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.msgService.EnrichMessages(ctx, page.Messages)
|
||||
|
||||
return map[string]any{
|
||||
"messages": page.Messages,
|
||||
"count": len(page.Messages),
|
||||
@@ -540,15 +574,18 @@ func (b *ServiceBridge) callGetChannelMessages(ctx context.Context, args map[str
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.msgService.EnrichMessages(ctx, page.Messages)
|
||||
|
||||
result := make([]map[string]any, len(page.Messages))
|
||||
for i, msg := range page.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,
|
||||
"id": msg.ID,
|
||||
"from": msg.FromAgent,
|
||||
"body": msg.Body,
|
||||
"priority": msg.Priority,
|
||||
"status": msg.Status,
|
||||
"created_at": msg.CreatedAt,
|
||||
"attachments": msg.Attachments,
|
||||
}
|
||||
if len(msg.Metadata) > 0 {
|
||||
result[i]["metadata"] = msg.Metadata
|
||||
@@ -973,6 +1010,17 @@ func (b *ServiceBridge) callReact(ctx context.Context, args map[string]any) (any
|
||||
resp["id"] = result.Reaction.ID
|
||||
resp["created_at"] = result.Reaction.CreatedAt
|
||||
}
|
||||
|
||||
// After the toggle, get current reactions and workflow state
|
||||
rxns, state, err := b.reactionService.GetReactions(ctx, int64(messageID))
|
||||
if err != nil {
|
||||
// Non-fatal: still return the toggle result
|
||||
slog.Warn("failed to get reactions after toggle", "error", err)
|
||||
} else {
|
||||
resp["workflow_state"] = state
|
||||
resp["reactions"] = rxns
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -1056,14 +1104,146 @@ func (b *ServiceBridge) callListByState(ctx context.Context, args map[string]any
|
||||
messageIDs = []int64{}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"message_ids": messageIDs,
|
||||
"count": len(messageIDs),
|
||||
totalCount := len(messageIDs)
|
||||
|
||||
// Apply limit and offset for pagination
|
||||
limit := getInt(args, "limit", 20)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := getInt(args, "offset", 0)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > len(messageIDs) {
|
||||
offset = len(messageIDs)
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(messageIDs) {
|
||||
end = len(messageIDs)
|
||||
}
|
||||
pageIDs := messageIDs[offset:end]
|
||||
|
||||
resp := map[string]any{
|
||||
"message_ids": pageIDs,
|
||||
"count": len(pageIDs),
|
||||
"total": totalCount,
|
||||
"channel": channelName,
|
||||
"state": state,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
includeMessages := getBool(args, "include_messages", false)
|
||||
if includeMessages && len(pageIDs) > 0 && b.msgService != nil {
|
||||
maxBodyLen := getInt(args, "max_body_length", 500)
|
||||
if maxBodyLen <= 0 {
|
||||
maxBodyLen = 500
|
||||
}
|
||||
var msgSlice []*messaging.Message
|
||||
for _, id := range pageIDs {
|
||||
msg, err := b.msgService.GetMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgSlice = append(msgSlice, msg)
|
||||
}
|
||||
b.msgService.EnrichMessages(ctx, msgSlice)
|
||||
|
||||
var messages []map[string]any
|
||||
for _, msg := range msgSlice {
|
||||
body := msg.Body
|
||||
if len(body) > maxBodyLen {
|
||||
body = body[:maxBodyLen] + "..."
|
||||
}
|
||||
messages = append(messages, map[string]any{
|
||||
"id": msg.ID,
|
||||
"from_agent": msg.FromAgent,
|
||||
"body": body,
|
||||
"priority": msg.Priority,
|
||||
"created_at": msg.CreatedAt,
|
||||
"reply_to": msg.ReplyTo,
|
||||
"attachments": msg.Attachments,
|
||||
})
|
||||
}
|
||||
resp["messages"] = messages
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// --- Threads ---
|
||||
|
||||
func (b *ServiceBridge) callGetReplies(ctx context.Context, args map[string]any) (any, error) {
|
||||
messageID := getInt(args, "message_id", 0)
|
||||
if messageID == 0 {
|
||||
return nil, fmt.Errorf("'message_id' parameter is required")
|
||||
}
|
||||
|
||||
replies, err := b.msgService.GetReplies(ctx, int64(messageID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich with attachments
|
||||
b.msgService.EnrichMessages(ctx, replies)
|
||||
|
||||
return map[string]any{
|
||||
"message_id": messageID,
|
||||
"replies": replies,
|
||||
"count": len(replies),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Trust implementations ---
|
||||
|
||||
func (b *ServiceBridge) callGetTrust(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.trustService == nil {
|
||||
return nil, fmt.Errorf("trust service not available")
|
||||
}
|
||||
|
||||
agentName := getString(args, "agent_name", "")
|
||||
if agentName == "" {
|
||||
agentName = b.agentName
|
||||
}
|
||||
|
||||
scores, err := b.trustService.GetScores(ctx, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"agent_name": agentName,
|
||||
"scores": scores,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetQueryExecutor sets the SQL query executor for the bridge.
|
||||
func (b *ServiceBridge) SetQueryExecutor(exec *agentquery.Executor) {
|
||||
b.queryExecutor = exec
|
||||
}
|
||||
|
||||
func (b *ServiceBridge) callQuery(ctx context.Context, args map[string]any) (any, error) {
|
||||
if b.queryExecutor == nil {
|
||||
return nil, fmt.Errorf("SQL query not available")
|
||||
}
|
||||
|
||||
sqlStr := getString(args, "sql", "")
|
||||
if sqlStr == "" {
|
||||
return nil, fmt.Errorf("sql parameter is required")
|
||||
}
|
||||
|
||||
result, err := b.queryExecutor.Execute(ctx, b.agentName, sqlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// resolveChannelID resolves a channel ID from either channel_id or channel_name in args.
|
||||
|
||||
+190
-1
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -44,6 +46,7 @@ func newTestBridge(t *testing.T) (*ServiceBridge, *messaging.MessagingService, *
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
"agent-a",
|
||||
)
|
||||
return bridge, msgService, agentService, channelService
|
||||
@@ -186,7 +189,7 @@ func TestBridge_JoinChannel(t *testing.T) {
|
||||
bridge.agentService,
|
||||
bridge.channelService,
|
||||
bridge.swarmService,
|
||||
nil, nil, nil,
|
||||
nil, nil, nil, nil,
|
||||
"agent-b",
|
||||
)
|
||||
|
||||
@@ -294,4 +297,190 @@ func TestBridge_ParamHelpers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -51,6 +51,7 @@ func newTestHybridWithChannels(t *testing.T) (*HybridToolRegistrar, *channels.Se
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
|
||||
+25
-12
@@ -12,6 +12,7 @@ import (
|
||||
"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"
|
||||
@@ -21,16 +22,18 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/reactions"
|
||||
"github.com/synapbus/synapbus/internal/search"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
"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 4 hybrid tools registered.
|
||||
@@ -42,6 +45,7 @@ func NewMCPServer(
|
||||
attachmentService *attachments.Service,
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
consolePrinter *console.Printer,
|
||||
jsPool *jsruntime.Pool,
|
||||
actionRegistry *actions.Registry,
|
||||
@@ -156,6 +160,7 @@ func NewMCPServer(
|
||||
attachmentService,
|
||||
searchService,
|
||||
reactionService,
|
||||
trustService,
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
@@ -184,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 (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
|
||||
|
||||
@@ -38,7 +38,7 @@ func newTestMCPServer(t *testing.T, con *console.Printer) (*MCPServer, *messagin
|
||||
actionRegistry := actions.NewRegistry()
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
return srv, msgService, agentService
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestMCPToolCall_WithValidAPIKey(t *testing.T) {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
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()
|
||||
@@ -188,7 +188,7 @@ func TestMCPToolCall_InvalidAPIKeyReturns401(t *testing.T) {
|
||||
actionRegistry := actions.NewRegistry()
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
srv := NewMCPServer(msgService, agentService, nil, nil, nil, nil, nil, nil, jsPool, actionRegistry, actionIndex, db)
|
||||
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())
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"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.
|
||||
@@ -31,13 +33,20 @@ type HybridToolRegistrar struct {
|
||||
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,
|
||||
@@ -47,6 +56,7 @@ func NewHybridToolRegistrar(
|
||||
attachmentService *attachments.Service,
|
||||
searchService *search.Service,
|
||||
reactionService *reactions.Service,
|
||||
trustService *trust.Service,
|
||||
jsPool *jsruntime.Pool,
|
||||
actionRegistry *actions.Registry,
|
||||
actionIndex *actions.Index,
|
||||
@@ -60,6 +70,7 @@ func NewHybridToolRegistrar(
|
||||
attachmentService: attachmentService,
|
||||
searchService: searchService,
|
||||
reactionService: reactionService,
|
||||
trustService: trustService,
|
||||
jsPool: jsPool,
|
||||
actionRegistry: actionRegistry,
|
||||
actionIndex: actionIndex,
|
||||
@@ -68,14 +79,15 @@ func NewHybridToolRegistrar(
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAllOnServer registers all 4 hybrid tools on an mcp-go MCPServer.
|
||||
// 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", 4)
|
||||
h.logger.Info("hybrid MCP tools registered", "count", 5)
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
@@ -116,6 +128,13 @@ func (h *HybridToolRegistrar) executeTool() mcplib.Tool {
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -480,8 +499,12 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call
|
||||
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,
|
||||
@@ -497,6 +520,32 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -69,6 +69,7 @@ func newTestHybridRegistrar(t *testing.T) (*HybridToolRegistrar, *messaging.Mess
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
nil, // reactionService
|
||||
nil, // trustService
|
||||
jsPool,
|
||||
actionRegistry,
|
||||
actionIndex,
|
||||
@@ -390,4 +391,136 @@ func TestHybridTool_Execute(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
|
||||
count := resp["count"].(float64)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -153,11 +153,16 @@ func (w *StalemateWorker) checkStaleMessages(ctx context.Context) {
|
||||
reminded := w.sendPendingReminders(ctx)
|
||||
escalated := w.escalatePendingMessages(ctx)
|
||||
|
||||
if failed > 0 || reminded > 0 || escalated > 0 {
|
||||
// Phase 2: Workflow stalemate checks for channel messages
|
||||
wfReminded, wfEscalated := w.checkWorkflowStalemates(ctx)
|
||||
|
||||
if failed > 0 || reminded > 0 || escalated > 0 || wfReminded > 0 || wfEscalated > 0 {
|
||||
w.logger.Info("stalemate check complete",
|
||||
"auto_failed", failed,
|
||||
"reminders_sent", reminded,
|
||||
"escalations_sent", escalated,
|
||||
"workflow_reminders", wfReminded,
|
||||
"workflow_escalations", wfEscalated,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +443,419 @@ func (w *StalemateWorker) escalationExists(ctx context.Context, messageID int64)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// workflowChannel holds channel info relevant to workflow stalemate checking.
|
||||
type workflowChannel struct {
|
||||
ID int64
|
||||
Name string
|
||||
StalemateRemindAfter string
|
||||
StalemateEscalateAfter string
|
||||
}
|
||||
|
||||
// staleWorkflowMsg holds info about a channel message in a stale workflow state.
|
||||
type staleWorkflowMsg struct {
|
||||
ID int64
|
||||
Body string
|
||||
FromAgent string
|
||||
ChannelID int64
|
||||
Channel string
|
||||
State string
|
||||
StateAge time.Duration
|
||||
}
|
||||
|
||||
// checkWorkflowStalemates scans workflow-enabled channels for messages stuck in
|
||||
// non-terminal workflow states (proposed, approved, in_progress) and sends
|
||||
// reminders to channel members or escalates to #approvals.
|
||||
func (w *StalemateWorker) checkWorkflowStalemates(ctx context.Context) (reminded int64, escalated int64) {
|
||||
// Step 1: Find all workflow-enabled channels
|
||||
channels, err := w.listWorkflowChannels(ctx)
|
||||
if err != nil {
|
||||
w.logger.Error("list workflow channels failed", "error", err)
|
||||
return 0, 0
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
remindTimeout, err := parseDurationWithDays(ch.StalemateRemindAfter)
|
||||
if err != nil || remindTimeout <= 0 {
|
||||
remindTimeout = 24 * time.Hour // default
|
||||
}
|
||||
escalateTimeout, err := parseDurationWithDays(ch.StalemateEscalateAfter)
|
||||
if err != nil || escalateTimeout <= 0 {
|
||||
escalateTimeout = 72 * time.Hour // default
|
||||
}
|
||||
|
||||
// Step 2: Find messages in non-terminal workflow states
|
||||
staleMessages, err := w.findStaleWorkflowMessages(ctx, ch)
|
||||
if err != nil {
|
||||
w.logger.Error("find stale workflow messages failed",
|
||||
"channel", ch.Name,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, msg := range staleMessages {
|
||||
// Step 3: Check escalation first (longer timeout)
|
||||
if msg.StateAge >= escalateTimeout {
|
||||
if w.workflowEscalationExists(ctx, msg.ID) {
|
||||
continue
|
||||
}
|
||||
if w.sendWorkflowEscalation(ctx, msg) {
|
||||
escalated++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 4: Check reminder (shorter timeout)
|
||||
if msg.StateAge >= remindTimeout {
|
||||
if w.workflowReminderExists(ctx, msg.ID) {
|
||||
continue
|
||||
}
|
||||
r := w.sendWorkflowReminders(ctx, msg, ch.ID)
|
||||
reminded += r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reminded, escalated
|
||||
}
|
||||
|
||||
// listWorkflowChannels returns all channels that have workflow_enabled = true.
|
||||
func (w *StalemateWorker) listWorkflowChannels(ctx context.Context) ([]workflowChannel, error) {
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT id, name, stalemate_remind_after, stalemate_escalate_after
|
||||
FROM channels
|
||||
WHERE workflow_enabled = 1`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query workflow channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var channels []workflowChannel
|
||||
for rows.Next() {
|
||||
var ch workflowChannel
|
||||
if err := rows.Scan(&ch.ID, &ch.Name, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter); err != nil {
|
||||
return nil, fmt.Errorf("scan workflow channel: %w", err)
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
return channels, rows.Err()
|
||||
}
|
||||
|
||||
// findStaleWorkflowMessages finds channel messages in non-terminal workflow states
|
||||
// and computes how long they have been in their current state.
|
||||
func (w *StalemateWorker) findStaleWorkflowMessages(ctx context.Context, ch workflowChannel) ([]staleWorkflowMsg, error) {
|
||||
// Get all messages in this channel that could be in a workflow state.
|
||||
// We fetch messages and their reactions, then compute state in Go.
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT m.id, m.body, m.from_agent, m.created_at
|
||||
FROM messages m
|
||||
WHERE m.channel_id = ?
|
||||
AND m.from_agent != 'system'
|
||||
ORDER BY m.created_at ASC`,
|
||||
ch.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query channel messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type chanMsg struct {
|
||||
ID int64
|
||||
Body string
|
||||
FromAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var msgs []chanMsg
|
||||
for rows.Next() {
|
||||
var m chanMsg
|
||||
if err := rows.Scan(&m.ID, &m.Body, &m.FromAgent, &m.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan channel message: %w", err)
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Batch-fetch reactions for all messages
|
||||
msgIDs := make([]int64, len(msgs))
|
||||
for i, m := range msgs {
|
||||
msgIDs[i] = m.ID
|
||||
}
|
||||
|
||||
reactionsMap, err := w.getReactionsByMessageIDs(ctx, msgIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get reactions: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var stale []staleWorkflowMsg
|
||||
for _, m := range msgs {
|
||||
reactions := reactionsMap[m.ID]
|
||||
state := computeWorkflowStateFromReactions(reactions)
|
||||
|
||||
// Skip terminal states
|
||||
if isTerminalWorkflowState(state) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the "state age": how long since the state was entered.
|
||||
// If reactions exist, use the most recent reaction's created_at.
|
||||
// If no reactions (proposed state), use the message's created_at.
|
||||
stateEnteredAt := m.CreatedAt
|
||||
if len(reactions) > 0 {
|
||||
// Find the most recent reaction
|
||||
for _, r := range reactions {
|
||||
if r.CreatedAt.After(stateEnteredAt) {
|
||||
stateEnteredAt = r.CreatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stale = append(stale, staleWorkflowMsg{
|
||||
ID: m.ID,
|
||||
Body: m.Body,
|
||||
FromAgent: m.FromAgent,
|
||||
ChannelID: ch.ID,
|
||||
Channel: ch.Name,
|
||||
State: state,
|
||||
StateAge: now.Sub(stateEnteredAt),
|
||||
})
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
// reactionRow holds a raw reaction row for workflow state computation.
|
||||
type reactionRow struct {
|
||||
Reaction string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// getReactionsByMessageIDs fetches reactions for a batch of message IDs.
|
||||
func (w *StalemateWorker) getReactionsByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]reactionRow, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return map[int64][]reactionRow{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(messageIDs))
|
||||
args := make([]any, len(messageIDs))
|
||||
for i, id := range messageIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT message_id, reaction, created_at
|
||||
FROM message_reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC`,
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
|
||||
rows, err := w.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query reactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64][]reactionRow)
|
||||
for rows.Next() {
|
||||
var msgID int64
|
||||
var r reactionRow
|
||||
if err := rows.Scan(&msgID, &r.Reaction, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan reaction: %w", err)
|
||||
}
|
||||
result[msgID] = append(result[msgID], r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// computeWorkflowStateFromReactions derives workflow state from raw reaction rows.
|
||||
// Mirrors the logic in reactions.ComputeWorkflowState without importing that package.
|
||||
func computeWorkflowStateFromReactions(reactions []reactionRow) string {
|
||||
if len(reactions) == 0 {
|
||||
return "proposed"
|
||||
}
|
||||
|
||||
// Reaction priority (same as reactions.reactionPriority)
|
||||
priority := map[string]int{
|
||||
"approve": 2,
|
||||
"in_progress": 3,
|
||||
"reject": 4,
|
||||
"done": 5,
|
||||
"published": 6,
|
||||
}
|
||||
|
||||
// Reaction-to-state mapping (same as reactions.reactionToState)
|
||||
toState := map[string]string{
|
||||
"approve": "approved",
|
||||
"reject": "rejected",
|
||||
"in_progress": "in_progress",
|
||||
"done": "done",
|
||||
"published": "published",
|
||||
}
|
||||
|
||||
highestPriority := 0
|
||||
highestState := "proposed"
|
||||
|
||||
for _, r := range reactions {
|
||||
if p, ok := priority[r.Reaction]; ok && p > highestPriority {
|
||||
highestPriority = p
|
||||
highestState = toState[r.Reaction]
|
||||
}
|
||||
}
|
||||
|
||||
return highestState
|
||||
}
|
||||
|
||||
// isTerminalWorkflowState returns true if the state should not trigger stalemate checks.
|
||||
func isTerminalWorkflowState(state string) bool {
|
||||
switch state {
|
||||
case "rejected", "done", "published":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sendWorkflowReminders sends DMs to channel members about a stale workflow message.
|
||||
func (w *StalemateWorker) sendWorkflowReminders(ctx context.Context, msg staleWorkflowMsg, channelID int64) int64 {
|
||||
// Get channel members
|
||||
rows, err := w.db.QueryContext(ctx,
|
||||
`SELECT agent_name FROM channel_members WHERE channel_id = ?`,
|
||||
channelID,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Error("query channel members for workflow reminder failed",
|
||||
"channel_id", channelID,
|
||||
"error", err,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, name)
|
||||
}
|
||||
|
||||
age := formatAge(msg.StateAge)
|
||||
truncBody := truncate(msg.Body, 100)
|
||||
count := int64(0)
|
||||
|
||||
for _, member := range members {
|
||||
body := fmt.Sprintf(
|
||||
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
|
||||
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
|
||||
)
|
||||
|
||||
_, err := w.msgService.SendMessage(ctx, "system", member, body, SendOptions{
|
||||
Subject: fmt.Sprintf("workflow-stalemate-reminder:%d", msg.ID),
|
||||
Priority: 7,
|
||||
Metadata: fmt.Sprintf(`{"workflow_stalemate_reminder_for":%d}`, msg.ID),
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send workflow stalemate reminder failed",
|
||||
"message_id", msg.ID,
|
||||
"to_agent", member,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
w.logger.Info("sent workflow stalemate reminder",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"state", msg.State,
|
||||
"to_agent", member,
|
||||
"age", age,
|
||||
)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// sendWorkflowEscalation posts an escalation to #approvals for a stale workflow message.
|
||||
func (w *StalemateWorker) sendWorkflowEscalation(ctx context.Context, msg staleWorkflowMsg) bool {
|
||||
approvalsChanID, err := w.channelLookup.GetChannelIDByName(ctx, "approvals")
|
||||
if err != nil {
|
||||
w.logger.Warn("cannot escalate workflow stalemate: #approvals channel not found", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
age := formatAge(msg.StateAge)
|
||||
truncBody := truncate(msg.Body, 100)
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
|
||||
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
|
||||
)
|
||||
|
||||
_, err = w.msgService.SendMessage(ctx, "system", "", body, SendOptions{
|
||||
Subject: fmt.Sprintf("workflow-stalemate-escalation:%d", msg.ID),
|
||||
Priority: 9,
|
||||
Metadata: fmt.Sprintf(`{"workflow_stalemate_escalation_for":%d}`, msg.ID),
|
||||
ChannelID: &approvalsChanID,
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("send workflow escalation to #approvals failed",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"error", err,
|
||||
)
|
||||
return false
|
||||
}
|
||||
w.logger.Info("escalated stale workflow message to #approvals",
|
||||
"message_id", msg.ID,
|
||||
"channel", msg.Channel,
|
||||
"state", msg.State,
|
||||
"age", age,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// workflowReminderExists checks if a workflow stalemate reminder already exists for a message.
|
||||
func (w *StalemateWorker) workflowReminderExists(ctx context.Context, messageID int64) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND metadata LIKE ?`,
|
||||
fmt.Sprintf(`%%"workflow_stalemate_reminder_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// workflowEscalationExists checks if a workflow stalemate escalation already exists for a message.
|
||||
func (w *StalemateWorker) workflowEscalationExists(ctx context.Context, messageID int64) bool {
|
||||
var count int
|
||||
err := w.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages
|
||||
WHERE from_agent = 'system'
|
||||
AND metadata LIKE ?`,
|
||||
fmt.Sprintf(`%%"workflow_stalemate_escalation_for":%d%%`, messageID),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// truncate truncates a string to maxLen characters, appending "..." if truncated.
|
||||
func truncate(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
|
||||
@@ -478,3 +478,345 @@ func TestFormatAge(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeWorkflowStateFromReactions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reactions []reactionRow
|
||||
want string
|
||||
}{
|
||||
{"no reactions = proposed", nil, "proposed"},
|
||||
{"approve only", []reactionRow{{Reaction: "approve"}}, "approved"},
|
||||
{"in_progress only", []reactionRow{{Reaction: "in_progress"}}, "in_progress"},
|
||||
{"reject only", []reactionRow{{Reaction: "reject"}}, "rejected"},
|
||||
{"done only", []reactionRow{{Reaction: "done"}}, "done"},
|
||||
{"published only", []reactionRow{{Reaction: "published"}}, "published"},
|
||||
{"approve + in_progress = in_progress (higher priority)", []reactionRow{
|
||||
{Reaction: "approve"},
|
||||
{Reaction: "in_progress"},
|
||||
}, "in_progress"},
|
||||
{"approve + done = done", []reactionRow{
|
||||
{Reaction: "approve"},
|
||||
{Reaction: "done"},
|
||||
}, "done"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeWorkflowStateFromReactions(tt.reactions)
|
||||
if got != tt.want {
|
||||
t.Errorf("computeWorkflowStateFromReactions() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalWorkflowState(t *testing.T) {
|
||||
tests := []struct {
|
||||
state string
|
||||
terminal bool
|
||||
}{
|
||||
{"proposed", false},
|
||||
{"approved", false},
|
||||
{"in_progress", false},
|
||||
{"rejected", true},
|
||||
{"done", true},
|
||||
{"published", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.state, func(t *testing.T) {
|
||||
got := isTerminalWorkflowState(tt.state)
|
||||
if got != tt.terminal {
|
||||
t.Errorf("isTerminalWorkflowState(%q) = %v, want %v", tt.state, got, tt.terminal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowReminder(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeouts
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
|
||||
// Add system and sender as members
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message with old created_at (will be in "proposed" state since no reactions)
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-test', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert conversation: %v", err)
|
||||
}
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Draft blog post about MCP', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the timeout to elapse
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals channel")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify workflow stalemate reminders were sent to channel members
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query workflow reminders: %v", err)
|
||||
}
|
||||
// Should have sent reminders to all 3 members (system, sender, receiver)
|
||||
if count < 1 {
|
||||
t.Errorf("expected at least 1 workflow reminder, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowEscalation(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short escalation timeout
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
|
||||
// Create #approvals channel
|
||||
db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
|
||||
VALUES (20, 'approvals', 'Approval queue', '', 'standard', 0, 0, 'system', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (20, 'system', 'owner', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Add members to workflow channel
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message old enough to trigger escalation
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-esc', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Stale proposal needing attention', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 20}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify escalation was sent to #approvals
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND channel_id = 20 AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query workflow escalation: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 workflow escalation, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowTerminalStateSkip(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeouts
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-done', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
msgResult, err := db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Completed task', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
msgID, _ := msgResult.LastInsertId()
|
||||
|
||||
// Add a "done" reaction — puts it in terminal state
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO message_reactions (message_id, agent_name, reaction, metadata, created_at)
|
||||
VALUES (?, 'sender', 'done', '{}', ?)`,
|
||||
msgID, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert reaction: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify NO reminders were sent (message is in terminal "done" state)
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 reminders for terminal state message, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowDuplicateReminderPrevention(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a workflow-enabled channel with short timeout
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'news-test', 'Test', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create workflow channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-dup', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'Needs review', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert channel message: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
// Run twice
|
||||
worker.checkStaleMessages(ctx)
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify only one set of reminders was sent (no duplicates)
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND to_agent = 'receiver' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 reminder (no duplicates), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalemateWorker_WorkflowNonWorkflowChannelSkip(t *testing.T) {
|
||||
svc, db := newStalemateTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a channel with workflow DISABLED
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
|
||||
VALUES (10, 'general', 'General', '', 'standard', 0, 0, 'system', 0, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
|
||||
|
||||
// Insert a channel message
|
||||
oldTime := time.Now().Add(-2 * time.Second)
|
||||
convResult, _ := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('no-wf', 'sender', ?, ?)`,
|
||||
oldTime, oldTime,
|
||||
)
|
||||
convID, _ := convResult.LastInsertId()
|
||||
|
||||
channelID := int64(10)
|
||||
db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
|
||||
VALUES (?, 'sender', '', 'No workflow here', 5, 'pending', '{}', ?, ?, ?)`,
|
||||
convID, channelID, oldTime, oldTime,
|
||||
)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
config := DefaultStalemateConfig()
|
||||
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
|
||||
worker := NewStalemateWorker(db, svc, lookup, config)
|
||||
|
||||
worker.checkStaleMessages(ctx)
|
||||
|
||||
// Verify NO reminders — channel is not workflow-enabled
|
||||
var count int
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query reminders: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 reminders for non-workflow channel, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
|
||||
FROM messages
|
||||
WHERE channel_id IS NULL
|
||||
AND ((from_agent IN (%s) AND to_agent = ?) OR (from_agent = ? AND to_agent IN (%s)))
|
||||
ORDER BY created_at ASC
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
inClause, inClause,
|
||||
)
|
||||
|
||||
@@ -42,4 +42,46 @@ var (
|
||||
Name: "active_connections",
|
||||
Help: "Number of active connections",
|
||||
})
|
||||
|
||||
// Reactive agent triggering metrics
|
||||
ReactiveTriggersTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "synapbus",
|
||||
Subsystem: "reactor",
|
||||
Name: "triggers_total",
|
||||
Help: "Total reactive trigger evaluations by agent and outcome",
|
||||
},
|
||||
[]string{"agent", "status"},
|
||||
)
|
||||
|
||||
ReactiveRunDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "synapbus",
|
||||
Subsystem: "reactor",
|
||||
Name: "run_duration_seconds",
|
||||
Help: "Duration of reactive agent runs in seconds",
|
||||
Buckets: []float64{10, 30, 60, 120, 300, 600, 1200, 1800, 3600},
|
||||
},
|
||||
[]string{"agent"},
|
||||
)
|
||||
|
||||
ReactiveAgentState = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "synapbus",
|
||||
Subsystem: "reactor",
|
||||
Name: "agent_running",
|
||||
Help: "Whether a reactive agent is currently running (1) or idle (0)",
|
||||
},
|
||||
[]string{"agent"},
|
||||
)
|
||||
|
||||
ReactiveBudgetUsed = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "synapbus",
|
||||
Subsystem: "reactor",
|
||||
Name: "budget_used_today",
|
||||
Help: "Number of reactive runs used today per agent",
|
||||
},
|
||||
[]string{"agent"},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// GeneratorConfig holds the parameters for generating a CLAUDE.md file.
|
||||
type GeneratorConfig struct {
|
||||
AgentName string
|
||||
Archetype string
|
||||
OwnerName string
|
||||
SynapBusURL string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// ArchetypeInfo describes an available archetype.
|
||||
type ArchetypeInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// archetypeDescriptions maps archetype names to human-readable descriptions.
|
||||
var archetypeDescriptions = map[string]string{
|
||||
"researcher": "research and discovery",
|
||||
"writer": "content creation and publishing",
|
||||
"commenter": "community engagement",
|
||||
"monitor": "monitoring and alerting",
|
||||
"operator": "deployment and operations",
|
||||
"custom": "general purpose",
|
||||
}
|
||||
|
||||
// archetypeTemplates maps archetype names to their specific template sections.
|
||||
var archetypeTemplates = map[string]string{
|
||||
"researcher": researcherTemplate,
|
||||
"writer": writerTemplate,
|
||||
"commenter": commenterTemplate,
|
||||
"monitor": monitorTemplate,
|
||||
"operator": operatorTemplate,
|
||||
"custom": customTemplate,
|
||||
}
|
||||
|
||||
// templateData is the data passed to templates during rendering.
|
||||
type templateData struct {
|
||||
AgentName string
|
||||
Archetype string
|
||||
ArchetypeDescription string
|
||||
OwnerName string
|
||||
SynapBusURL string
|
||||
}
|
||||
|
||||
// GenerateCLAUDEMD renders the CLAUDE.md template for the given archetype.
|
||||
func GenerateCLAUDEMD(config GeneratorConfig) (string, error) {
|
||||
archetype := strings.ToLower(config.Archetype)
|
||||
if archetype == "" {
|
||||
archetype = "custom"
|
||||
}
|
||||
|
||||
description, ok := archetypeDescriptions[archetype]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown archetype: %s", config.Archetype)
|
||||
}
|
||||
|
||||
archetypeSection, ok := archetypeTemplates[archetype]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no template for archetype: %s", config.Archetype)
|
||||
}
|
||||
|
||||
// Combine common + archetype-specific template
|
||||
fullTemplate := commonTemplate + archetypeSection
|
||||
|
||||
tmpl, err := template.New("claude-md").Parse(fullTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
AgentName: config.AgentName,
|
||||
Archetype: archetype,
|
||||
ArchetypeDescription: description,
|
||||
OwnerName: config.OwnerName,
|
||||
SynapBusURL: config.SynapBusURL,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// GenerateMCPConfig returns a JSON snippet for Claude Code MCP settings.
|
||||
func GenerateMCPConfig(synapbusURL, apiKey string) string {
|
||||
config := map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"synapbus": map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": strings.TrimRight(synapbusURL, "/") + "/mcp",
|
||||
"headers": map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(config, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ListArchetypes returns available archetype examples with descriptions.
|
||||
// These are starting templates, not rigid categories.
|
||||
func ListArchetypes() []ArchetypeInfo {
|
||||
return []ArchetypeInfo{
|
||||
{Name: "custom", Description: "Clean start — core SynapBus protocol only, you define the workflow"},
|
||||
{Name: "researcher", Description: "Example: web search, platform discovery, finding deduplication"},
|
||||
{Name: "writer", Description: "Example: content creation, blog publishing, draft-review-publish pipeline"},
|
||||
{Name: "commenter", Description: "Example: community engagement, comment drafting, approval workflow"},
|
||||
{Name: "monitor", Description: "Example: diff checking, change detection, alerts"},
|
||||
{Name: "operator", Description: "Example: deployment, incident response, system automation"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCLAUDEMD_Researcher(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-bot",
|
||||
Archetype: "researcher",
|
||||
OwnerName: "alice",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check common sections
|
||||
checks := []string{
|
||||
"# test-bot",
|
||||
"Startup Loop",
|
||||
"Reactions",
|
||||
"Trust",
|
||||
"Research & Discovery",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(md, check) {
|
||||
t.Errorf("expected CLAUDE.md to contain %q", check)
|
||||
}
|
||||
}
|
||||
|
||||
// Check researcher-specific sections
|
||||
researcherChecks := []string{
|
||||
"Research & Discovery",
|
||||
"Web Search",
|
||||
"Finding Deduplication",
|
||||
}
|
||||
for _, check := range researcherChecks {
|
||||
if !strings.Contains(md, check) {
|
||||
t.Errorf("expected CLAUDE.md to contain researcher section %q", check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_AllArchetypes(t *testing.T) {
|
||||
archetypes := ListArchetypes()
|
||||
for _, archetype := range archetypes {
|
||||
t.Run(archetype.Name, func(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: archetype.Name,
|
||||
OwnerName: "owner",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for archetype %s: %v", archetype.Name, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(md, "# test-agent") {
|
||||
t.Error("expected agent name in output")
|
||||
}
|
||||
if !strings.Contains(md, "Startup Loop") {
|
||||
t.Error("expected common sections in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_UnknownArchetype(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: "nonexistent",
|
||||
}
|
||||
|
||||
_, err := GenerateCLAUDEMD(config)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown archetype")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown archetype") {
|
||||
t.Errorf("expected 'unknown archetype' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCLAUDEMD_EmptyArchetypeDefaultsToCustom(t *testing.T) {
|
||||
config := GeneratorConfig{
|
||||
AgentName: "test-agent",
|
||||
Archetype: "",
|
||||
OwnerName: "owner",
|
||||
SynapBusURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
md, err := GenerateCLAUDEMD(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Custom template has only common sections — no "Example Workflow" section
|
||||
if !strings.Contains(md, "Startup Loop") {
|
||||
t.Error("expected common protocol sections for empty archetype")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMCPConfig(t *testing.T) {
|
||||
result := GenerateMCPConfig("http://localhost:8080", "sk-test-key-123")
|
||||
|
||||
// Should be valid JSON
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "/mcp") {
|
||||
t.Error("expected MCP endpoint URL")
|
||||
}
|
||||
if !strings.Contains(result, "sk-test-key-123") {
|
||||
t.Error("expected API key in config")
|
||||
}
|
||||
if !strings.Contains(result, "streamable-http") {
|
||||
t.Error("expected streamable-http type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArchetypes(t *testing.T) {
|
||||
archetypes := ListArchetypes()
|
||||
if len(archetypes) != 6 {
|
||||
t.Errorf("expected 6 archetypes, got %d", len(archetypes))
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, a := range archetypes {
|
||||
names[a.Name] = true
|
||||
if a.Description == "" {
|
||||
t.Errorf("archetype %s has empty description", a.Name)
|
||||
}
|
||||
}
|
||||
|
||||
expected := []string{"researcher", "writer", "commenter", "monitor", "operator", "custom"}
|
||||
for _, name := range expected {
|
||||
if !names[name] {
|
||||
t.Errorf("expected archetype %s in list", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkills(t *testing.T) {
|
||||
skills, err := ListSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(skills) < 2 {
|
||||
t.Errorf("expected at least 2 skills, got %d", len(skills))
|
||||
}
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, s := range skills {
|
||||
names[s.Name] = true
|
||||
}
|
||||
|
||||
if !names["stigmergy-workflow"] {
|
||||
t.Error("expected stigmergy-workflow skill")
|
||||
}
|
||||
if !names["task-auction"] {
|
||||
t.Error("expected task-auction skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSkill(t *testing.T) {
|
||||
content, err := GetSkill("stigmergy-workflow")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "Stigmergy Workflow") {
|
||||
t.Error("expected skill content to contain title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSkill_NotFound(t *testing.T) {
|
||||
_, err := GetSkill("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent skill")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed skills/*.md
|
||||
var skillsFS embed.FS
|
||||
|
||||
// SkillInfo describes an available skill.
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListSkills returns all embedded skill files.
|
||||
func ListSkills() ([]SkillInfo, error) {
|
||||
var skills []SkillInfo
|
||||
|
||||
err := fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
description := skillDescription(name)
|
||||
|
||||
skills = append(skills, SkillInfo{
|
||||
Name: name,
|
||||
Filename: filepath.Base(path),
|
||||
Description: description,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list skills: %w", err)
|
||||
}
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
// GetSkill returns the markdown content of a skill by name.
|
||||
func GetSkill(name string) (string, error) {
|
||||
filename := name + ".md"
|
||||
data, err := skillsFS.ReadFile(filepath.Join("skills", filename))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// skillDescription returns a short description for a skill by name.
|
||||
func skillDescription(name string) string {
|
||||
descriptions := map[string]string{
|
||||
"stigmergy-workflow": "Stigmergy-based workflow for claiming, processing, and completing work items on channels",
|
||||
"task-auction": "Task auction workflow for bidding on and executing tasks in auction channels",
|
||||
}
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
return desc
|
||||
}
|
||||
return "Agent skill"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,176 @@
|
||||
package onboarding
|
||||
|
||||
// Archetype CLAUDE.md templates using text/template syntax.
|
||||
|
||||
// commonTemplate is the base template included in all archetypes.
|
||||
// This is the core protocol every agent needs — no channel list, no fluff.
|
||||
const commonTemplate = `# {{.AgentName}}
|
||||
|
||||
You are **{{.AgentName}}**, an autonomous agent connected to SynapBus.
|
||||
|
||||
## SynapBus Protocol
|
||||
|
||||
### Startup Loop (run this every cycle)
|
||||
1. ` + "`call(\"my_status\")`" + ` — check inbox, owner messages = top priority
|
||||
2. Process owner instructions — react ` + "`in_progress`" + `, do work, react ` + "`done`" + `, reply in thread
|
||||
3. ` + "`call(\"list_by_state\", {\"channel\": \"...\", \"state\": \"approved\"})`" + ` — find claimable work
|
||||
4. For each item: claim (` + "`in_progress`" + `) → work → complete (` + "`done`" + `) → reply
|
||||
5. Run your specific workflow (see below)
|
||||
6. Post findings to channels
|
||||
7. Update CLAUDE.md if you learned something, commit changes
|
||||
|
||||
### Reactions (Workflow State Machine)
|
||||
- ` + "`approve`" + ` — owner approves a proposal
|
||||
- ` + "`reject`" + ` — owner declines
|
||||
- ` + "`in_progress`" + ` — you're working on it (claims the item, first-agent-wins)
|
||||
- ` + "`done`" + ` — work complete
|
||||
- ` + "`published`" + ` — shipped (include URL in metadata)
|
||||
|
||||
Use ` + "`call(\"search\", {\"query\": \"workflow\"})`" + ` to discover all available tools.
|
||||
|
||||
### SQL Queries
|
||||
You can run read-only SQL against your messages and channels:
|
||||
` + "```" + `
|
||||
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"})
|
||||
` + "```" + `
|
||||
Available tables: ` + "`my_messages`" + ` (your DMs + joined channels), ` + "`my_channels`" + ` (channels you joined), ` + "`channel_messages`" + ` (messages in your channels).
|
||||
Results capped at 100 rows. CTEs (WITH) supported. Only SELECT allowed.
|
||||
|
||||
### Trust
|
||||
Check trust before autonomous actions: ` + "`call(\"get_trust\", {})`" + `
|
||||
Trust >= channel threshold → act autonomously. Otherwise post as "proposed" and wait for approval.
|
||||
Trust increases when owner approves your work (+0.05), decreases on rejection (-0.1).
|
||||
`
|
||||
|
||||
// researcherTemplate adds web search and discovery sections.
|
||||
const researcherTemplate = `
|
||||
## Example Workflow: Research & Discovery
|
||||
|
||||
This is a starting template — customize it for your specific research domain.
|
||||
|
||||
### Web Search & Discovery
|
||||
1. Identify topics relevant to your assigned channels
|
||||
2. Use web search tools to find new content, articles, discussions
|
||||
3. Evaluate relevance and quality before posting
|
||||
|
||||
### Finding Deduplication
|
||||
Before posting a finding:
|
||||
` + "```" + `
|
||||
call("search", {"query": "<your finding summary>", "limit": 5})
|
||||
` + "```" + `
|
||||
If a similar finding already exists, skip it or add new context as a reply.
|
||||
|
||||
### Posting Findings
|
||||
Post to the appropriate news channel:
|
||||
` + "```" + `
|
||||
call("send_message", {"channel": "<news-channel>", "body": "<finding with source URL>"})
|
||||
` + "```" + `
|
||||
|
||||
### Research Cadence
|
||||
- Check for new content each cycle
|
||||
- Prioritize recent and trending topics
|
||||
- Balance breadth (new sources) with depth (following up on leads)
|
||||
`
|
||||
|
||||
// writerTemplate adds content creation sections.
|
||||
const writerTemplate = `
|
||||
## Example Workflow: Content Creation
|
||||
|
||||
This is a starting template — customize it for your content domain.
|
||||
|
||||
### Content Pipeline
|
||||
1. **Discover** — find topics from research channels and owner requests
|
||||
2. **Draft** — write content and post as "proposed" for review
|
||||
3. **Review** — wait for owner approval via ` + "`approve`" + ` reaction
|
||||
4. **Publish** — on approval, publish and react with ` + "`published`" + `
|
||||
|
||||
### Blog Publishing
|
||||
After approval:
|
||||
1. Format content for the target platform
|
||||
2. Publish using available tools
|
||||
3. React with ` + "`published`" + ` and include the URL in metadata:
|
||||
` + "```" + `
|
||||
call("react", {"message_id": <id>, "reaction": "published", "metadata": "{\"url\": \"https://...\"}"})
|
||||
` + "```" + `
|
||||
|
||||
### Editing Guidelines
|
||||
- Keep tone consistent with the brand voice
|
||||
- Include sources and citations where appropriate
|
||||
- Use clear headings, short paragraphs, and bullet points
|
||||
`
|
||||
|
||||
// commenterTemplate adds community engagement sections.
|
||||
const commenterTemplate = `
|
||||
## Example Workflow: Community Engagement
|
||||
|
||||
This is a starting template — customize it for your engagement domain.
|
||||
|
||||
### Community Engagement
|
||||
1. Monitor approved content items for comment opportunities
|
||||
2. Draft comments tailored to the platform and audience
|
||||
3. Submit for owner approval before posting
|
||||
|
||||
### Comment Drafting
|
||||
Post proposed comments to the approvals channel:
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "approvals",
|
||||
"body": "PROPOSED COMMENT for <platform>:\n\n<comment text>\n\nSource: <URL>"
|
||||
})
|
||||
` + "```" + `
|
||||
|
||||
### Tone Guidelines
|
||||
- Be helpful and add genuine value to the conversation
|
||||
- Match the community's communication style
|
||||
- Avoid promotional or spammy language
|
||||
- Never post without approval unless trust score permits it
|
||||
`
|
||||
|
||||
// monitorTemplate adds diff checking and alert sections.
|
||||
const monitorTemplate = `
|
||||
## Example Workflow: Monitoring & Alerting
|
||||
|
||||
This is a starting template — customize it for your monitoring domain.
|
||||
|
||||
### Change Detection
|
||||
1. Track target resources (websites, APIs, repos, docs) for changes
|
||||
2. Compare current state against last known state
|
||||
3. Alert on meaningful differences
|
||||
|
||||
### Alert Levels
|
||||
- **Info**: minor changes, log but do not alert
|
||||
- **Warning**: notable changes, post to monitoring channel
|
||||
- **Critical**: breaking changes or outages, post with priority 8+
|
||||
|
||||
### Posting Alerts
|
||||
` + "```" + `
|
||||
call("send_message", {
|
||||
"channel": "<monitoring-channel>",
|
||||
"body": "ALERT [<severity>]: <description>\n\nDetails: <diff summary>",
|
||||
"priority": <5-9 based on severity>
|
||||
})
|
||||
` + "```" + `
|
||||
`
|
||||
|
||||
// operatorTemplate adds deployment and incident response sections.
|
||||
const operatorTemplate = `
|
||||
## Example Workflow: Operations & Automation
|
||||
|
||||
This is a starting template — customize it for your operations domain.
|
||||
|
||||
### Task Execution
|
||||
1. Check for approved tasks in work channels
|
||||
2. Validate prerequisites (tests passing, approvals in place)
|
||||
3. Execute steps
|
||||
4. Verify success and report status
|
||||
|
||||
### Safety Rules
|
||||
- Never run destructive operations without explicit approval
|
||||
- Always have a rollback plan
|
||||
- Prefer idempotent operations
|
||||
- Log all actions for audit trail
|
||||
- Report any unexpected state immediately
|
||||
`
|
||||
|
||||
// customTemplate provides only the common sections — no example workflow.
|
||||
const customTemplate = ``
|
||||
@@ -5,12 +5,39 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/trust"
|
||||
)
|
||||
|
||||
// StateChangeNotifier is called when a message's workflow state changes.
|
||||
type StateChangeNotifier interface {
|
||||
OnWorkflowStateChanged(ctx context.Context, event trust.WorkflowStateChangeEvent)
|
||||
}
|
||||
|
||||
// AgentTypeChecker resolves an agent's type (e.g. "human", "ai").
|
||||
type AgentTypeChecker interface {
|
||||
GetAgentType(ctx context.Context, agentName string) (string, error)
|
||||
}
|
||||
|
||||
// TrustAdjuster adjusts trust scores for agents.
|
||||
type TrustAdjuster interface {
|
||||
RecordApproval(ctx context.Context, agentName, actionType string) error
|
||||
RecordRejection(ctx context.Context, agentName, actionType string) error
|
||||
}
|
||||
|
||||
// MessageAuthorResolver looks up the author of a message.
|
||||
type MessageAuthorResolver interface {
|
||||
GetMessageAuthor(ctx context.Context, messageID int64) (string, error)
|
||||
}
|
||||
|
||||
// Service provides business logic for message reactions.
|
||||
type Service struct {
|
||||
store Store
|
||||
logger *slog.Logger
|
||||
store Store
|
||||
logger *slog.Logger
|
||||
stateChangeNotifier StateChangeNotifier
|
||||
agentTypeChecker AgentTypeChecker
|
||||
trustAdjuster TrustAdjuster
|
||||
authorResolver MessageAuthorResolver
|
||||
}
|
||||
|
||||
// NewService creates a new reaction service.
|
||||
@@ -21,6 +48,26 @@ func NewService(store Store, logger *slog.Logger) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// SetStateChangeNotifier sets the notifier called on workflow state transitions.
|
||||
func (s *Service) SetStateChangeNotifier(n StateChangeNotifier) {
|
||||
s.stateChangeNotifier = n
|
||||
}
|
||||
|
||||
// SetAgentTypeChecker sets the checker used to resolve agent types for trust adjustments.
|
||||
func (s *Service) SetAgentTypeChecker(c AgentTypeChecker) {
|
||||
s.agentTypeChecker = c
|
||||
}
|
||||
|
||||
// SetTrustAdjuster sets the trust adjuster for recording approvals/rejections.
|
||||
func (s *Service) SetTrustAdjuster(a TrustAdjuster) {
|
||||
s.trustAdjuster = a
|
||||
}
|
||||
|
||||
// SetMessageAuthorResolver sets the resolver for looking up message authors.
|
||||
func (s *Service) SetMessageAuthorResolver(r MessageAuthorResolver) {
|
||||
s.authorResolver = r
|
||||
}
|
||||
|
||||
// ToggleResult describes what happened after a toggle operation.
|
||||
type ToggleResult struct {
|
||||
Action string `json:"action"` // "added" or "removed"
|
||||
@@ -34,6 +81,13 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti
|
||||
return nil, ErrInvalidReaction
|
||||
}
|
||||
|
||||
// Capture old workflow state before any mutation
|
||||
var oldState string
|
||||
if s.stateChangeNotifier != nil {
|
||||
oldReactions, _ := s.store.GetByMessageID(ctx, messageID)
|
||||
oldState = ComputeWorkflowState(oldReactions)
|
||||
}
|
||||
|
||||
// Check if reaction already exists
|
||||
exists, err := s.store.Exists(ctx, messageID, agentName, reactionType)
|
||||
if err != nil {
|
||||
@@ -50,9 +104,26 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti
|
||||
"agent", agentName,
|
||||
"reaction", reactionType,
|
||||
)
|
||||
|
||||
// Check for workflow state change after removal
|
||||
s.notifyStateChangeIfNeeded(ctx, messageID, oldState, agentName, reactionType)
|
||||
|
||||
return &ToggleResult{Action: "removed"}, nil
|
||||
}
|
||||
|
||||
// Claim semantics: only one agent can have in_progress at a time
|
||||
if reactionType == ReactionInProgress {
|
||||
existing, err := s.store.GetByMessageID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing claims: %w", err)
|
||||
}
|
||||
for _, r := range existing {
|
||||
if r.Reaction == ReactionInProgress && r.AgentName != agentName {
|
||||
return nil, fmt.Errorf("already claimed by %s", r.AgentName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check reaction count limit
|
||||
count, err := s.store.CountByMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
@@ -84,9 +155,86 @@ func (s *Service) Toggle(ctx context.Context, messageID int64, agentName, reacti
|
||||
"reaction", reactionType,
|
||||
)
|
||||
|
||||
// Check for workflow state change after addition
|
||||
s.notifyStateChangeIfNeeded(ctx, messageID, oldState, agentName, reactionType)
|
||||
|
||||
// Adjust trust when a human approves/rejects an AI agent's message
|
||||
s.adjustTrustIfNeeded(ctx, messageID, agentName, reactionType)
|
||||
|
||||
return &ToggleResult{Action: "added", Reaction: r}, nil
|
||||
}
|
||||
|
||||
// notifyStateChangeIfNeeded fires the state change notifier if the workflow state changed.
|
||||
func (s *Service) notifyStateChangeIfNeeded(ctx context.Context, messageID int64, oldState, agentName, reactionType string) {
|
||||
if s.stateChangeNotifier == nil {
|
||||
return
|
||||
}
|
||||
newReactions, err := s.store.GetByMessageID(ctx, messageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
newState := ComputeWorkflowState(newReactions)
|
||||
if newState != oldState {
|
||||
s.stateChangeNotifier.OnWorkflowStateChanged(ctx, trust.WorkflowStateChangeEvent{
|
||||
MessageID: messageID,
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
TriggeredBy: agentName,
|
||||
Reaction: reactionType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adjustTrustIfNeeded adjusts trust when a human reacts approve/reject to an AI agent's message.
|
||||
func (s *Service) adjustTrustIfNeeded(ctx context.Context, messageID int64, reactorName, reactionType string) {
|
||||
if s.trustAdjuster == nil || s.agentTypeChecker == nil || s.authorResolver == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only approve and reject adjust trust
|
||||
if reactionType != ReactionApprove && reactionType != ReactionReject {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the reactor is a human
|
||||
reactorType, err := s.agentTypeChecker.GetAgentType(ctx, reactorName)
|
||||
if err != nil || reactorType != "human" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the message author
|
||||
authorName, err := s.authorResolver.GetMessageAuthor(ctx, messageID)
|
||||
if err != nil || authorName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the author is an AI agent
|
||||
authorType, err := s.agentTypeChecker.GetAgentType(ctx, authorName)
|
||||
if err != nil || authorType != "ai" {
|
||||
return
|
||||
}
|
||||
|
||||
// Adjust trust for the AI agent
|
||||
actionType := trust.ActionPublish
|
||||
if reactionType == ReactionApprove {
|
||||
if err := s.trustAdjuster.RecordApproval(ctx, authorName, actionType); err != nil {
|
||||
s.logger.Warn("trust approval failed",
|
||||
"agent", authorName,
|
||||
"reactor", reactorName,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if err := s.trustAdjuster.RecordRejection(ctx, authorName, actionType); err != nil {
|
||||
s.logger.Warn("trust rejection failed",
|
||||
"agent", authorName,
|
||||
"reactor", reactorName,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove explicitly removes a reaction.
|
||||
func (s *Service) Remove(ctx context.Context, messageID int64, agentName, reactionType string) error {
|
||||
if !IsValidReaction(reactionType) {
|
||||
@@ -122,6 +270,33 @@ func (s *Service) GetReactionsByMessageIDs(ctx context.Context, messageIDs []int
|
||||
}
|
||||
|
||||
// ListByState returns message IDs in a channel that have the given workflow state.
|
||||
// For non-proposed states, it verifies each candidate by computing the actual
|
||||
// workflow state from all reactions, so a message with both "approve" and "reject"
|
||||
// only appears in the state matching its highest-priority reaction.
|
||||
func (s *Service) ListByState(ctx context.Context, channelID int64, state string) ([]int64, error) {
|
||||
return s.store.GetMessageIDsByState(ctx, channelID, state)
|
||||
candidates, err := s.store.GetMessageIDsByState(ctx, channelID, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// "proposed" means no reactions at all — the SQL query already handles this correctly.
|
||||
if state == StateProposed {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Batch-fetch reactions for all candidate messages.
|
||||
reactionsMap, err := s.store.GetByMessageIDs(ctx, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch fetch reactions for state filtering: %w", err)
|
||||
}
|
||||
|
||||
// Only keep messages whose computed workflow state matches the requested state.
|
||||
var filtered []int64
|
||||
for _, id := range candidates {
|
||||
rxns := reactionsMap[id]
|
||||
if ComputeWorkflowState(rxns) == state {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package reactions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_ListByState_FiltersCorrectly(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
svc := NewService(store, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
// Ensure user and agents exist
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
||||
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, 'testhash', '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, 'testhash2', 'active')`)
|
||||
|
||||
// Create a channel
|
||||
result, err := db.Exec(`INSERT INTO channels (name, description, created_by, workflow_enabled) VALUES ('test-channel', 'test', 'agent-a', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID, _ := result.LastInsertId()
|
||||
|
||||
// Helper to create a message in the channel
|
||||
createMsg := func(agent, body string) int64 {
|
||||
t.Helper()
|
||||
r, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
agent,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
convID, _ := r.LastInsertId()
|
||||
|
||||
r, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, channel_id, body, priority, status, created_at) VALUES (?, ?, ?, ?, 5, 'pending', CURRENT_TIMESTAMP)`,
|
||||
convID, agent, channelID, body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
id, _ := r.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// Scenario: msg1 has approve + reject (should be "rejected" since reject has higher priority)
|
||||
msg1 := createMsg("agent-a", "msg with approve and reject")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg1, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg1, AgentName: "agent-b", Reaction: ReactionReject})
|
||||
|
||||
// Scenario: msg2 has only approve (should be "approved")
|
||||
msg2 := createMsg("agent-a", "msg with only approve")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg2, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
|
||||
// Scenario: msg3 has no reactions (should be "proposed")
|
||||
msg3 := createMsg("agent-a", "msg with no reactions")
|
||||
|
||||
// Scenario: msg4 has approve + in_progress + done (should be "done")
|
||||
msg4 := createMsg("agent-a", "msg with approve, in_progress, done")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-b", Reaction: ReactionInProgress})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-b", Reaction: ReactionDone})
|
||||
|
||||
// Test: list "approved" should only return msg2 (NOT msg1 which also has approve but its state is rejected)
|
||||
t.Run("approved returns only truly approved", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateApproved)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(approved): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 approved message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg2 {
|
||||
t.Errorf("expected msg2 (id=%d), got id=%d", msg2, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "rejected" should only return msg1
|
||||
t.Run("rejected returns only truly rejected", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateRejected)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(rejected): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 rejected message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg1 {
|
||||
t.Errorf("expected msg1 (id=%d), got id=%d", msg1, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "proposed" should only return msg3
|
||||
t.Run("proposed returns only messages with no reactions", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(proposed): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 proposed message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg3 {
|
||||
t.Errorf("expected msg3 (id=%d), got id=%d", msg3, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "done" should only return msg4
|
||||
t.Run("done returns only truly done", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateDone)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(done): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 done message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg4 {
|
||||
t.Errorf("expected msg4 (id=%d), got id=%d", msg4, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "in_progress" should return nothing (msg4 has in_progress but done overrides it)
|
||||
t.Run("in_progress excludes messages that have progressed to done", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateInProgress)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(in_progress): %v", err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("expected 0 in_progress messages, got %d: %v", len(ids), ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListByState_EmptyChannel(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
svc := NewService(store, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
||||
|
||||
result, err := db.Exec(`INSERT INTO channels (name, description, created_by, workflow_enabled) VALUES ('empty-channel', 'empty', 'testowner', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID, _ := result.LastInsertId()
|
||||
|
||||
ids, err := svc.ListByState(ctx, channelID, StateProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState: %v", err)
|
||||
}
|
||||
if ids != nil && len(ids) != 0 {
|
||||
t.Errorf("expected nil or empty slice, got %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package reactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
// DMFailureNotifier sends system DMs to agent owners on job failure.
|
||||
type DMFailureNotifier struct {
|
||||
msgService *messaging.MessagingService
|
||||
}
|
||||
|
||||
// NewDMFailureNotifier creates a new failure notifier.
|
||||
func NewDMFailureNotifier(msgService *messaging.MessagingService) *DMFailureNotifier {
|
||||
return &DMFailureNotifier{msgService: msgService}
|
||||
}
|
||||
|
||||
// NotifyFailure sends a system DM to the agent's owner with error details.
|
||||
func (n *DMFailureNotifier) NotifyFailure(ctx context.Context, ownerAgentName, agentName, triggerFrom, triggerEvent string, durationMs int64, errorSummary string) error {
|
||||
durationStr := "< 1s"
|
||||
if durationMs > 0 {
|
||||
secs := durationMs / 1000
|
||||
if secs >= 60 {
|
||||
durationStr = fmt.Sprintf("%dm%ds", secs/60, secs%60)
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%ds", secs)
|
||||
}
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"⚠️ **Reactive run failed** for **%s**\n\n"+
|
||||
"**Trigger**: %s from %s\n"+
|
||||
"**Duration**: %s\n"+
|
||||
"**Error**: %s\n\n"+
|
||||
"View details in Agent Runs page.",
|
||||
agentName, triggerEvent, triggerFrom, durationStr, truncateError(errorSummary, 500),
|
||||
)
|
||||
|
||||
_, err := n.msgService.SendMessage(ctx, "system", ownerAgentName, body, messaging.SendOptions{
|
||||
Priority: 7,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func truncateError(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package reactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
k8spkg "github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/metrics"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// Poller watches active reactive runs and updates their status from K8s.
|
||||
type Poller struct {
|
||||
store *Store
|
||||
agentStore agents.AgentStore
|
||||
clientset kubernetes.Interface
|
||||
runner k8spkg.JobRunner
|
||||
reactor *Reactor
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewPoller creates a new job status poller.
|
||||
func NewPoller(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, reactor *Reactor, logger *slog.Logger) *Poller {
|
||||
// Extract clientset from runner if it's the real K8s runner
|
||||
var clientset kubernetes.Interface
|
||||
if kr, ok := runner.(*k8spkg.K8sJobRunner); ok {
|
||||
clientset = kr.GetClientset()
|
||||
}
|
||||
|
||||
return &Poller{
|
||||
store: store,
|
||||
agentStore: agentStore,
|
||||
clientset: clientset,
|
||||
runner: runner,
|
||||
reactor: reactor,
|
||||
interval: 15 * time.Second,
|
||||
logger: logger.With("component", "reactor-poller"),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the polling loop in a background goroutine.
|
||||
func (p *Poller) Start() {
|
||||
if !p.runner.IsAvailable() || p.clientset == nil {
|
||||
p.logger.Info("K8s not available, reactor poller disabled")
|
||||
return
|
||||
}
|
||||
go p.pollLoop()
|
||||
p.logger.Info("reactor poller started", "interval", p.interval)
|
||||
}
|
||||
|
||||
// Stop signals the poller to stop.
|
||||
func (p *Poller) Stop() {
|
||||
close(p.stopCh)
|
||||
}
|
||||
|
||||
func (p *Poller) pollLoop() {
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.pollActiveRuns()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) pollActiveRuns() {
|
||||
ctx := context.Background()
|
||||
|
||||
runs, err := p.store.GetActiveRuns(ctx)
|
||||
if err != nil {
|
||||
p.logger.Error("failed to get active runs", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, run := range runs {
|
||||
if run.K8sJobName == "" || run.K8sNamespace == "" {
|
||||
continue
|
||||
}
|
||||
p.checkJob(ctx, run)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) checkJob(ctx context.Context, run *ReactiveRun) {
|
||||
ns := run.K8sNamespace
|
||||
jobName := run.K8sJobName
|
||||
|
||||
job, err := p.clientset.BatchV1().Jobs(ns).Get(ctx, jobName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
p.logger.Warn("failed to get K8s Job status", "job", jobName, "namespace", ns, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check job conditions
|
||||
for _, cond := range job.Status.Conditions {
|
||||
switch cond.Type {
|
||||
case batchv1.JobComplete:
|
||||
if cond.Status == "True" {
|
||||
p.handleJobComplete(ctx, run, true, "")
|
||||
return
|
||||
}
|
||||
case batchv1.JobFailed:
|
||||
if cond.Status == "True" {
|
||||
reason := cond.Reason
|
||||
if cond.Message != "" {
|
||||
reason = reason + ": " + cond.Message
|
||||
}
|
||||
p.handleJobComplete(ctx, run, false, reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if active deadline exceeded
|
||||
if job.Status.Failed > 0 {
|
||||
p.handleJobComplete(ctx, run, false, "job failed (pod failure)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) handleJobComplete(ctx context.Context, run *ReactiveRun, success bool, failureReason string) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Update metrics
|
||||
metrics.ReactiveAgentState.WithLabelValues(run.AgentName).Set(0)
|
||||
if run.StartedAt != nil {
|
||||
duration := now.Sub(*run.StartedAt).Seconds()
|
||||
metrics.ReactiveRunDuration.WithLabelValues(run.AgentName).Observe(duration)
|
||||
}
|
||||
todayCount, _ := p.store.CountTodayRuns(ctx, run.AgentName)
|
||||
metrics.ReactiveBudgetUsed.WithLabelValues(run.AgentName).Set(float64(todayCount))
|
||||
|
||||
if success {
|
||||
metrics.ReactiveTriggersTotal.WithLabelValues(run.AgentName, StatusSucceeded).Inc()
|
||||
_ = p.store.CompleteRun(ctx, run.ID, StatusSucceeded, "", now)
|
||||
p.logger.Info("reactive run succeeded",
|
||||
"agent", run.AgentName,
|
||||
"job", run.K8sJobName,
|
||||
"run_id", run.ID,
|
||||
)
|
||||
} else {
|
||||
// Retrieve logs
|
||||
errorLog := failureReason
|
||||
logs, err := p.runner.GetJobLogs(ctx, run.K8sNamespace, run.K8sJobName)
|
||||
if err == nil && logs != "" {
|
||||
// Keep last 100 lines
|
||||
lines := strings.Split(logs, "\n")
|
||||
if len(lines) > 100 {
|
||||
lines = lines[len(lines)-100:]
|
||||
}
|
||||
errorLog = strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
metrics.ReactiveTriggersTotal.WithLabelValues(run.AgentName, StatusFailed).Inc()
|
||||
_ = p.store.CompleteRun(ctx, run.ID, StatusFailed, errorLog, now)
|
||||
|
||||
p.logger.Warn("reactive run failed",
|
||||
"agent", run.AgentName,
|
||||
"job", run.K8sJobName,
|
||||
"run_id", run.ID,
|
||||
"reason", failureReason,
|
||||
)
|
||||
|
||||
// Send failure notification
|
||||
var durationMs int64
|
||||
if run.StartedAt != nil {
|
||||
durationMs = now.Sub(*run.StartedAt).Milliseconds()
|
||||
}
|
||||
agent, err := p.agentStore.GetAgentByName(ctx, run.AgentName)
|
||||
if err == nil && agent != nil {
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: run.TriggerEvent,
|
||||
FromAgent: run.TriggerFrom,
|
||||
}
|
||||
p.reactor.notifyFailure(ctx, agent, event, durationMs, fmt.Sprintf("Job %s failed: %s", run.K8sJobName, failureReason))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pending_work — launch coalesced run if needed
|
||||
p.checkPendingWork(ctx, run.AgentName)
|
||||
}
|
||||
|
||||
func (p *Poller) checkPendingWork(ctx context.Context, agentName string) {
|
||||
agent, err := p.agentStore.GetAgentByName(ctx, agentName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !agent.PendingWork {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear pending_work first
|
||||
_ = p.agentStore.SetPendingWork(ctx, agentName, false)
|
||||
|
||||
p.logger.Info("pending_work found, launching coalesced run", "agent", agentName)
|
||||
|
||||
// Create a synthetic event (coalesced — agent will pick up all pending messages via claim_messages)
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
FromAgent: "system",
|
||||
ToAgent: agentName,
|
||||
Body: "Coalesced trigger: process all pending messages.",
|
||||
MentionedAgents: nil,
|
||||
Depth: 0,
|
||||
}
|
||||
|
||||
// Evaluate the trigger (it will check cooldown/budget again)
|
||||
_ = p.reactor.evaluateTrigger(ctx, agentName, event)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package reactor provides the reactive agent triggering engine.
|
||||
// When a DM or @mention targets an agent with trigger_mode='reactive',
|
||||
// the reactor evaluates rate limits and creates a K8s Job to run the agent.
|
||||
package reactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
k8spkg "github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/metrics"
|
||||
)
|
||||
|
||||
// Reactor is the reactive agent triggering engine.
|
||||
type Reactor struct {
|
||||
store *Store
|
||||
agentStore agents.AgentStore
|
||||
runner k8spkg.JobRunner
|
||||
notifier FailureNotifier
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// FailureNotifier sends system DMs on job failure.
|
||||
type FailureNotifier interface {
|
||||
NotifyFailure(ctx context.Context, ownerAgentName, agentName, triggerFrom, triggerEvent string, durationMs int64, errorSummary string) error
|
||||
}
|
||||
|
||||
// New creates a new Reactor.
|
||||
func New(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, logger *slog.Logger) *Reactor {
|
||||
return &Reactor{
|
||||
store: store,
|
||||
agentStore: agentStore,
|
||||
runner: runner,
|
||||
logger: logger.With("component", "reactor"),
|
||||
}
|
||||
}
|
||||
|
||||
// SetFailureNotifier sets the notifier for sending failure DMs.
|
||||
func (r *Reactor) SetFailureNotifier(n FailureNotifier) {
|
||||
r.notifier = n
|
||||
}
|
||||
|
||||
// Dispatch implements dispatcher.EventDispatcher. Called by MultiDispatcher
|
||||
// when a message event occurs.
|
||||
func (r *Reactor) Dispatch(ctx context.Context, event dispatcher.MessageEvent) error {
|
||||
switch event.EventType {
|
||||
case "message.received":
|
||||
// DM to an agent
|
||||
return r.evaluateTrigger(ctx, event.ToAgent, event)
|
||||
case "message.mentioned":
|
||||
// @mentions in channel messages
|
||||
for _, mentioned := range event.MentionedAgents {
|
||||
// Self-mention filter: agent can't trigger itself
|
||||
if mentioned == event.FromAgent {
|
||||
continue
|
||||
}
|
||||
if err := r.evaluateTrigger(ctx, mentioned, event); err != nil {
|
||||
r.logger.ErrorContext(ctx, "reactor trigger eval failed",
|
||||
"agent", mentioned,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil // Ignore other event types
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateTrigger runs the decision chain for a single agent.
|
||||
func (r *Reactor) evaluateTrigger(ctx context.Context, agentName string, event dispatcher.MessageEvent) error {
|
||||
// 0. Ignore system messages — stalemate worker notifications, retention warnings,
|
||||
// and other automated DMs should NOT trigger reactive runs. They're notifications
|
||||
// meant for the human owner, not actionable work for agents.
|
||||
if event.FromAgent == "system" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Get agent config
|
||||
agent, err := r.agentStore.GetAgentByName(ctx, agentName)
|
||||
if err != nil {
|
||||
return nil // Agent doesn't exist, skip silently
|
||||
}
|
||||
|
||||
// 2. Check trigger mode
|
||||
if agent.TriggerMode != agents.TriggerModeReactive {
|
||||
return nil // Not reactive, skip
|
||||
}
|
||||
|
||||
// 3. Check K8s image configured
|
||||
if agent.K8sImage == "" {
|
||||
r.logger.Warn("reactive agent has no k8s_image configured", "agent", agentName)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusFailed, "no k8s_image configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Check K8s runner available
|
||||
if !r.runner.IsAvailable() {
|
||||
r.logger.Warn("K8s runner not available for reactive trigger", "agent", agentName)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusFailed, "K8s runner not available")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 5. Extract depth from event metadata
|
||||
depth := event.Depth
|
||||
|
||||
// 6. Check trigger depth
|
||||
if depth >= agent.MaxTriggerDepth {
|
||||
r.logger.Info("trigger depth exceeded", "agent", agentName, "depth", depth, "max", agent.MaxTriggerDepth)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusDepthExceeded, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7. Check daily budget
|
||||
todayCount, err := r.store.CountTodayRuns(ctx, agentName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count today runs: %w", err)
|
||||
}
|
||||
if todayCount >= agent.DailyTriggerBudget {
|
||||
r.logger.Info("daily trigger budget exhausted", "agent", agentName, "count", todayCount, "budget", agent.DailyTriggerBudget)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusBudgetExhausted, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. Check cooldown
|
||||
lastRun, err := r.store.GetLastRunTime(ctx, agentName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last run time: %w", err)
|
||||
}
|
||||
if lastRun != nil {
|
||||
elapsed := time.Since(*lastRun)
|
||||
if elapsed < time.Duration(agent.CooldownSeconds)*time.Second {
|
||||
r.logger.Info("agent on cooldown", "agent", agentName, "elapsed", elapsed, "cooldown", agent.CooldownSeconds)
|
||||
// Set pending_work so we retry after cooldown
|
||||
_ = r.agentStore.SetPendingWork(ctx, agentName, true)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusCooldownSkipped, "")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Check if agent is currently running
|
||||
running, err := r.store.IsAgentRunning(ctx, agentName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check agent running: %w", err)
|
||||
}
|
||||
if running {
|
||||
r.logger.Info("agent already running, setting pending_work", "agent", agentName)
|
||||
_ = r.agentStore.SetPendingWork(ctx, agentName, true)
|
||||
r.recordSkippedRun(ctx, agentName, event, StatusQueued, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 10. All checks pass — create K8s Job
|
||||
return r.createJob(ctx, agent, event, depth)
|
||||
}
|
||||
|
||||
// createJob creates a K8s Job for the reactive trigger.
|
||||
func (r *Reactor) createJob(ctx context.Context, agent *agents.Agent, event dispatcher.MessageEvent, depth int) error {
|
||||
// Build handler from agent config
|
||||
handler := r.buildHandler(agent)
|
||||
|
||||
body := event.Body
|
||||
if len(body) > 4096 {
|
||||
body = body[:4096] + " [truncated]"
|
||||
}
|
||||
|
||||
msg := &k8spkg.JobMessage{
|
||||
MessageID: event.MessageID,
|
||||
FromAgent: event.FromAgent,
|
||||
Body: body,
|
||||
Event: event.EventType,
|
||||
Channel: event.Channel,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add trigger depth env var to handler
|
||||
handler.Env["SYNAPBUS_TRIGGER_DEPTH"] = fmt.Sprintf("%d", depth)
|
||||
|
||||
// Create K8s Job FIRST (before DB insert to avoid stuck runs on SQLITE_BUSY)
|
||||
jobName, err := r.runner.CreateJob(ctx, handler, msg)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("K8s Job creation failed: %s", err.Error())
|
||||
r.recordSkippedRun(ctx, agent.Name, event, StatusFailed, errMsg)
|
||||
r.notifyFailure(ctx, agent, event, 0, errMsg)
|
||||
return fmt.Errorf("create K8s job: %w", err)
|
||||
}
|
||||
|
||||
ns := handler.Namespace
|
||||
if ns == "" {
|
||||
ns = r.runner.GetNamespace()
|
||||
}
|
||||
|
||||
// Insert run record with job name already set (single atomic write)
|
||||
now := time.Now().UTC()
|
||||
run := &ReactiveRun{
|
||||
AgentName: agent.Name,
|
||||
TriggerMessageID: &event.MessageID,
|
||||
TriggerEvent: event.EventType,
|
||||
TriggerDepth: depth,
|
||||
TriggerFrom: event.FromAgent,
|
||||
Status: StatusRunning,
|
||||
K8sJobName: jobName,
|
||||
K8sNamespace: ns,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
runID, err := r.store.InsertRun(ctx, run)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to record reactive run (job already created)",
|
||||
"agent", agent.Name, "job", jobName, "error", err)
|
||||
runID = 0
|
||||
}
|
||||
|
||||
// Clear pending_work since we're launching
|
||||
_ = r.agentStore.SetPendingWork(ctx, agent.Name, false)
|
||||
|
||||
metrics.ReactiveTriggersTotal.WithLabelValues(agent.Name, StatusRunning).Inc()
|
||||
metrics.ReactiveAgentState.WithLabelValues(agent.Name).Set(1)
|
||||
|
||||
r.logger.Info("reactive K8s Job created",
|
||||
"agent", agent.Name,
|
||||
"job", jobName,
|
||||
"trigger_from", event.FromAgent,
|
||||
"trigger_event", event.EventType,
|
||||
"depth", depth,
|
||||
"run_id", runID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildHandler constructs a K8sHandler from agent config.
|
||||
func (r *Reactor) buildHandler(agent *agents.Agent) *k8spkg.K8sHandler {
|
||||
env := map[string]string{}
|
||||
|
||||
// Parse k8s_env_json
|
||||
if agent.K8sEnvJSON != "" {
|
||||
var envMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(agent.K8sEnvJSON), &envMap); err == nil {
|
||||
for k, v := range envMap {
|
||||
// Plain string values
|
||||
var str string
|
||||
if err := json.Unmarshal(v, &str); err == nil {
|
||||
env[k] = str
|
||||
continue
|
||||
}
|
||||
// Secret refs are handled at K8s level; for now pass as-is
|
||||
// (the K8s runner would need extension for secretKeyRef)
|
||||
env[k] = strings.Trim(string(v), "\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resource presets — default matches CronJob config (agent SDK needs ~1-2Gi)
|
||||
memory := "2Gi"
|
||||
cpu := "500m"
|
||||
if agent.K8sResourcePreset == "small" {
|
||||
memory = "512Mi"
|
||||
cpu = "100m"
|
||||
}
|
||||
|
||||
timeout := 3600 // 1 hour (matches CronJob config)
|
||||
|
||||
handler := &k8spkg.K8sHandler{
|
||||
AgentName: agent.Name,
|
||||
Image: agent.K8sImage,
|
||||
Events: []string{"message.received", "message.mentioned"},
|
||||
Namespace: "", // Use runner's namespace
|
||||
ResourcesMemory: memory,
|
||||
ResourcesCPU: cpu,
|
||||
Env: env,
|
||||
TimeoutSeconds: timeout,
|
||||
Status: "active",
|
||||
Args: []string{"--max-turns", "50", "--model", "claude-sonnet-4-6"},
|
||||
VolumeMounts: []k8spkg.VolumeMount{
|
||||
{Name: "claude-config", MountPath: "/app/.claude", ReadOnly: false},
|
||||
{Name: "workspace", MountPath: "/app/workspace", ReadOnly: false},
|
||||
},
|
||||
Volumes: []k8spkg.Volume{
|
||||
{Name: "claude-config", HostPath: "/home/user/.claude"},
|
||||
{Name: "workspace", EmptyDir: true},
|
||||
},
|
||||
}
|
||||
|
||||
// Override args for social-commenter (uses opus, more turns)
|
||||
if agent.Name == "social-commenter" {
|
||||
handler.Args = []string{"--max-turns", "80", "--model", "claude-opus-4-6"}
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// RetryRun retries a failed run.
|
||||
func (r *Reactor) RetryRun(ctx context.Context, runID int64) (*ReactiveRun, error) {
|
||||
run, err := r.store.GetRunByID(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get run: %w", err)
|
||||
}
|
||||
if run.Status != StatusFailed {
|
||||
return nil, fmt.Errorf("can only retry failed runs, current status: %s", run.Status)
|
||||
}
|
||||
|
||||
agent, err := r.agentStore.GetAgentByName(ctx, run.AgentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get agent: %w", err)
|
||||
}
|
||||
|
||||
// Create a synthetic event for the retry
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: run.TriggerEvent,
|
||||
MessageID: 0,
|
||||
FromAgent: run.TriggerFrom,
|
||||
ToAgent: run.AgentName,
|
||||
Body: "",
|
||||
Depth: run.TriggerDepth,
|
||||
}
|
||||
if run.TriggerMessageID != nil {
|
||||
event.MessageID = *run.TriggerMessageID
|
||||
}
|
||||
|
||||
if err := r.createJob(ctx, agent, event, run.TriggerDepth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the newly created run
|
||||
runs, _, err := r.store.ListRuns(ctx, run.AgentName, StatusRunning, 1, 0)
|
||||
if err != nil || len(runs) == 0 {
|
||||
return nil, fmt.Errorf("retry succeeded but couldn't find new run")
|
||||
}
|
||||
return runs[0], nil
|
||||
}
|
||||
|
||||
func (r *Reactor) recordSkippedRun(ctx context.Context, agentName string, event dispatcher.MessageEvent, status, errorLog string) {
|
||||
metrics.ReactiveTriggersTotal.WithLabelValues(agentName, status).Inc()
|
||||
run := &ReactiveRun{
|
||||
AgentName: agentName,
|
||||
TriggerEvent: event.EventType,
|
||||
TriggerDepth: event.Depth,
|
||||
TriggerFrom: event.FromAgent,
|
||||
Status: status,
|
||||
ErrorLog: errorLog,
|
||||
}
|
||||
if event.MessageID > 0 {
|
||||
run.TriggerMessageID = &event.MessageID
|
||||
}
|
||||
_, _ = r.store.InsertRun(ctx, run)
|
||||
}
|
||||
|
||||
func (r *Reactor) notifyFailure(ctx context.Context, agent *agents.Agent, event dispatcher.MessageEvent, durationMs int64, errorSummary string) {
|
||||
if r.notifier == nil {
|
||||
return
|
||||
}
|
||||
// Find the owner's human agent name
|
||||
ownerAgent, err := r.agentStore.GetHumanAgentByOwner(ctx, agent.OwnerID)
|
||||
if err != nil || ownerAgent == nil {
|
||||
r.logger.Warn("could not find owner agent for failure notification", "agent", agent.Name)
|
||||
return
|
||||
}
|
||||
_ = r.notifier.NotifyFailure(ctx, ownerAgent.Name, agent.Name, event.FromAgent, event.EventType, durationMs, errorSummary)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package reactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/dispatcher"
|
||||
k8spkg "github.com/synapbus/synapbus/internal/k8s"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database with schema for testing.
|
||||
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 minimal schema
|
||||
schema := `
|
||||
CREATE TABLE agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'ai',
|
||||
capabilities TEXT NOT NULL DEFAULT '{}',
|
||||
owner_id INTEGER NOT NULL DEFAULT 1,
|
||||
api_key_hash TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
trigger_mode TEXT NOT NULL DEFAULT 'passive',
|
||||
cooldown_seconds INTEGER NOT NULL DEFAULT 600,
|
||||
daily_trigger_budget INTEGER NOT NULL DEFAULT 8,
|
||||
max_trigger_depth INTEGER NOT NULL DEFAULT 5,
|
||||
k8s_image TEXT,
|
||||
k8s_env_json TEXT,
|
||||
k8s_resource_preset TEXT NOT NULL DEFAULT 'default',
|
||||
pending_work INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE reactive_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL,
|
||||
trigger_message_id INTEGER,
|
||||
trigger_event TEXT NOT NULL,
|
||||
trigger_depth INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_from TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
k8s_job_name TEXT,
|
||||
k8s_namespace TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
duration_ms INTEGER,
|
||||
error_log TEXT,
|
||||
token_cost_json TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func insertTestAgent(t *testing.T, db *sql.DB, name, triggerMode, image string, cooldown, budget, maxDepth int) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_resource_preset)
|
||||
VALUES (?, ?, 'ai', 1, ?, ?, ?, ?, ?, 'default')`,
|
||||
name, name, triggerMode, cooldown, budget, maxDepth, image,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorPassiveAgentSkipped(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "passive-agent", "passive", "image:latest", 600, 8, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := k8spkg.NewNoopRunner()
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "passive-agent",
|
||||
Body: "hello",
|
||||
}
|
||||
|
||||
err := reactor.Dispatch(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// No runs should be created for passive agents
|
||||
runs, total, err := store.ListRuns(context.Background(), "passive-agent", "", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list runs: %v", err)
|
||||
}
|
||||
if total != 0 || len(runs) != 0 {
|
||||
t.Errorf("expected 0 runs for passive agent, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorNoK8sImage(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "no-image-agent", "reactive", "", 600, 8, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := k8spkg.NewNoopRunner()
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "no-image-agent",
|
||||
Body: "hello",
|
||||
}
|
||||
|
||||
_ = reactor.Dispatch(context.Background(), event)
|
||||
|
||||
runs, _, _ := store.ListRuns(context.Background(), "no-image-agent", StatusFailed, 10, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 failed run for agent with no image, got %d", len(runs))
|
||||
}
|
||||
if runs[0].ErrorLog != "no k8s_image configured" {
|
||||
t.Errorf("expected 'no k8s_image configured' error, got: %s", runs[0].ErrorLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorDepthExceeded(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "deep-agent", "reactive", "image:latest", 600, 8, 3)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "other-agent",
|
||||
ToAgent: "deep-agent",
|
||||
Body: "hello from depth 3",
|
||||
Depth: 3, // equals max depth
|
||||
}
|
||||
|
||||
_ = reactor.Dispatch(context.Background(), event)
|
||||
|
||||
runs, _, _ := store.ListRuns(context.Background(), "deep-agent", StatusDepthExceeded, 10, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 depth_exceeded run, got %d", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorBudgetExhausted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "budget-agent", "reactive", "image:latest", 0, 2, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
// Record 2 existing runs today
|
||||
for i := 0; i < 2; i++ {
|
||||
_, _ = store.InsertRun(context.Background(), &ReactiveRun{
|
||||
AgentName: "budget-agent",
|
||||
TriggerEvent: "message.received",
|
||||
Status: StatusSucceeded,
|
||||
})
|
||||
}
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 10,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "budget-agent",
|
||||
Body: "one more",
|
||||
}
|
||||
|
||||
_ = reactor.Dispatch(context.Background(), event)
|
||||
|
||||
runs, _, _ := store.ListRuns(context.Background(), "budget-agent", StatusBudgetExhausted, 10, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 budget_exhausted run, got %d", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorCooldownSkipped(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "cool-agent", "reactive", "image:latest", 600, 8, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
// Record a recent run
|
||||
now := time.Now().UTC()
|
||||
_, _ = store.InsertRun(context.Background(), &ReactiveRun{
|
||||
AgentName: "cool-agent",
|
||||
TriggerEvent: "message.received",
|
||||
Status: StatusSucceeded,
|
||||
})
|
||||
// Hack: the above uses CURRENT_TIMESTAMP which is "now", so cooldown should be active
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 10,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "cool-agent",
|
||||
Body: "too soon",
|
||||
}
|
||||
|
||||
_ = reactor.Dispatch(context.Background(), event)
|
||||
_ = now // avoid unused
|
||||
|
||||
runs, _, _ := store.ListRuns(context.Background(), "cool-agent", StatusCooldownSkipped, 10, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 cooldown_skipped run, got %d", len(runs))
|
||||
}
|
||||
|
||||
// Check pending_work was set
|
||||
agent, _ := agentStore.GetAgentByName(context.Background(), "cool-agent")
|
||||
if !agent.PendingWork {
|
||||
t.Error("expected pending_work to be set after cooldown skip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorSequentialExecution(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "busy-agent", "reactive", "image:latest", 0, 8, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
// First trigger — should succeed
|
||||
event1 := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 1,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "busy-agent",
|
||||
Body: "first",
|
||||
}
|
||||
_ = reactor.Dispatch(context.Background(), event1)
|
||||
|
||||
// Second trigger — agent is running, should queue
|
||||
event2 := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 2,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "busy-agent",
|
||||
Body: "second",
|
||||
}
|
||||
_ = reactor.Dispatch(context.Background(), event2)
|
||||
|
||||
// Check: one running, one queued
|
||||
running, _, _ := store.ListRuns(context.Background(), "busy-agent", StatusRunning, 10, 0)
|
||||
queued, _, _ := store.ListRuns(context.Background(), "busy-agent", StatusQueued, 10, 0)
|
||||
|
||||
if len(running) != 1 {
|
||||
t.Errorf("expected 1 running, got %d", len(running))
|
||||
}
|
||||
if len(queued) != 1 {
|
||||
t.Errorf("expected 1 queued, got %d", len(queued))
|
||||
}
|
||||
|
||||
// Check pending_work is set
|
||||
agent, _ := agentStore.GetAgentByName(context.Background(), "busy-agent")
|
||||
if !agent.PendingWork {
|
||||
t.Error("expected pending_work to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorSelfMentionIgnored(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
insertTestAgent(t, db, "self-agent", "reactive", "image:latest", 0, 8, 5)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
// Agent mentions itself
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.mentioned",
|
||||
MessageID: 1,
|
||||
FromAgent: "self-agent",
|
||||
Body: "hey @self-agent",
|
||||
MentionedAgents: []string{"self-agent"},
|
||||
}
|
||||
|
||||
_ = reactor.Dispatch(context.Background(), event)
|
||||
|
||||
runs, total, _ := store.ListRuns(context.Background(), "self-agent", "", 10, 0)
|
||||
if total != 0 || len(runs) != 0 {
|
||||
t.Errorf("expected 0 runs for self-mention, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactorSuccessfulTrigger(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
envJSON, _ := json.Marshal(map[string]string{
|
||||
"AGENT_GIT_REPO": "Dumbris/test-agent",
|
||||
})
|
||||
_, _ = db.Exec(
|
||||
`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_env_json, k8s_resource_preset)
|
||||
VALUES (?, ?, 'ai', 1, 'reactive', 0, 8, 5, 'image:latest', ?, 'default')`,
|
||||
"test-agent", "Test Agent", string(envJSON),
|
||||
)
|
||||
|
||||
store := NewStore(db)
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
runner := &fakeRunner{available: true}
|
||||
logger := slog.Default()
|
||||
|
||||
reactor := New(store, agentStore, runner, logger)
|
||||
|
||||
event := dispatcher.MessageEvent{
|
||||
EventType: "message.received",
|
||||
MessageID: 42,
|
||||
FromAgent: "algis",
|
||||
ToAgent: "test-agent",
|
||||
Body: "research this topic",
|
||||
}
|
||||
|
||||
err := reactor.Dispatch(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify job was created
|
||||
if runner.lastJobName == "" {
|
||||
t.Fatal("expected K8s Job to be created")
|
||||
}
|
||||
|
||||
// Verify run record
|
||||
runs, _, _ := store.ListRuns(context.Background(), "test-agent", StatusRunning, 10, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 running run, got %d", len(runs))
|
||||
}
|
||||
run := runs[0]
|
||||
if run.TriggerFrom != "algis" {
|
||||
t.Errorf("expected trigger_from=algis, got %s", run.TriggerFrom)
|
||||
}
|
||||
if run.TriggerEvent != "message.received" {
|
||||
t.Errorf("expected trigger_event=message.received, got %s", run.TriggerEvent)
|
||||
}
|
||||
|
||||
// Verify env vars passed to job
|
||||
if runner.lastEnv["SYNAPBUS_TRIGGER_DEPTH"] != "0" {
|
||||
t.Errorf("expected SYNAPBUS_TRIGGER_DEPTH=0, got %s", runner.lastEnv["SYNAPBUS_TRIGGER_DEPTH"])
|
||||
}
|
||||
if runner.lastEnv["AGENT_GIT_REPO"] != "Dumbris/test-agent" {
|
||||
t.Errorf("expected AGENT_GIT_REPO from k8s_env_json, got %s", runner.lastEnv["AGENT_GIT_REPO"])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunner is a test double for k8spkg.JobRunner.
|
||||
type fakeRunner struct {
|
||||
available bool
|
||||
lastJobName string
|
||||
lastEnv map[string]string
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (f *fakeRunner) IsAvailable() bool { return f.available }
|
||||
func (f *fakeRunner) GetNamespace() string { return "test-ns" }
|
||||
func (f *fakeRunner) GetJobLogs(_ context.Context, _, _ string) (string, error) {
|
||||
return "test logs", nil
|
||||
}
|
||||
func (f *fakeRunner) CreateJob(_ context.Context, handler *k8spkg.K8sHandler, msg *k8spkg.JobMessage) (string, error) {
|
||||
f.callCount++
|
||||
f.lastJobName = fmt.Sprintf("synapbus-%s-%d", handler.AgentName, msg.MessageID)
|
||||
f.lastEnv = make(map[string]string)
|
||||
for k, v := range handler.Env {
|
||||
f.lastEnv[k] = v
|
||||
}
|
||||
return f.lastJobName, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package reactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunStatus constants for reactive_runs.
|
||||
const (
|
||||
StatusQueued = "queued"
|
||||
StatusRunning = "running"
|
||||
StatusSucceeded = "succeeded"
|
||||
StatusFailed = "failed"
|
||||
StatusCooldownSkipped = "cooldown_skipped"
|
||||
StatusBudgetExhausted = "budget_exhausted"
|
||||
StatusDepthExceeded = "depth_exceeded"
|
||||
)
|
||||
|
||||
// ReactiveRun represents a single trigger evaluation and its outcome.
|
||||
type ReactiveRun struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
TriggerMessageID *int64 `json:"trigger_message_id,omitempty"`
|
||||
TriggerEvent string `json:"trigger_event"`
|
||||
TriggerDepth int `json:"trigger_depth"`
|
||||
TriggerFrom string `json:"trigger_from,omitempty"`
|
||||
Status string `json:"status"`
|
||||
K8sJobName string `json:"k8s_job_name,omitempty"`
|
||||
K8sNamespace string `json:"k8s_namespace,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
DurationMs *int64 `json:"duration_ms,omitempty"`
|
||||
ErrorLog string `json:"error_log,omitempty"`
|
||||
TokenCostJSON string `json:"token_cost_json,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store handles SQLite persistence for reactive runs.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore creates a new reactor store.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// InsertRun creates a new reactive_runs record.
|
||||
func (s *Store) InsertRun(ctx context.Context, run *ReactiveRun) (int64, error) {
|
||||
now := time.Now().UTC()
|
||||
run.CreatedAt = now
|
||||
nowStr := now.Format(time.RFC3339)
|
||||
var startedAtStr *string
|
||||
if run.StartedAt != nil {
|
||||
s := run.StartedAt.UTC().Format(time.RFC3339)
|
||||
startedAtStr = &s
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO reactive_runs (agent_name, trigger_message_id, trigger_event, trigger_depth, trigger_from, status, k8s_job_name, k8s_namespace, started_at, error_log, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
run.AgentName, run.TriggerMessageID, run.TriggerEvent, run.TriggerDepth,
|
||||
run.TriggerFrom, run.Status, run.K8sJobName, run.K8sNamespace, startedAtStr, run.ErrorLog, nowStr,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert reactive run: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
run.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateRunStatus updates a run's status and optional fields.
|
||||
func (s *Store) UpdateRunStatus(ctx context.Context, id int64, status string, jobName, namespace string, startedAt *time.Time) error {
|
||||
var startedAtStr *string
|
||||
if startedAt != nil {
|
||||
str := startedAt.UTC().Format(time.RFC3339)
|
||||
startedAtStr = &str
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE reactive_runs SET status = ?, k8s_job_name = ?, k8s_namespace = ?, started_at = ? WHERE id = ?`,
|
||||
status, jobName, namespace, startedAtStr, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteRun marks a run as completed (succeeded or failed).
|
||||
func (s *Store) CompleteRun(ctx context.Context, id int64, status, errorLog string, completedAt time.Time) error {
|
||||
completedStr := completedAt.UTC().Format(time.RFC3339)
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE reactive_runs SET status = ?, error_log = ?, completed_at = ?,
|
||||
duration_ms = CAST((julianday(?) - julianday(started_at)) * 86400000 AS INTEGER)
|
||||
WHERE id = ?`,
|
||||
status, errorLog, completedStr, completedStr, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRunByID returns a single run.
|
||||
func (s *Store) GetRunByID(ctx context.Context, id int64) (*ReactiveRun, error) {
|
||||
return s.scanRun(s.db.QueryRowContext(ctx, runSelectSQL()+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// ListRuns returns recent runs with optional filters.
|
||||
func (s *Store) ListRuns(ctx context.Context, agentName, status string, limit, offset int) ([]*ReactiveRun, int, error) {
|
||||
where := "WHERE 1=1"
|
||||
args := []any{}
|
||||
|
||||
if agentName != "" {
|
||||
where += " AND agent_name = ?"
|
||||
args = append(args, agentName)
|
||||
}
|
||||
if status != "" {
|
||||
where += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int
|
||||
countArgs := make([]any, len(args))
|
||||
copy(countArgs, args)
|
||||
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM reactive_runs "+where, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Query with pagination
|
||||
query := runSelectSQL() + " " + where + " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
runs, err := s.scanRuns(rows)
|
||||
return runs, total, err
|
||||
}
|
||||
|
||||
// GetActiveRuns returns runs with status 'running' (for polling).
|
||||
func (s *Store) GetActiveRuns(ctx context.Context) ([]*ReactiveRun, error) {
|
||||
rows, err := s.db.QueryContext(ctx, runSelectSQL()+` WHERE status = 'running'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return s.scanRuns(rows)
|
||||
}
|
||||
|
||||
// CountTodayRuns counts runs that count against the daily budget for an agent.
|
||||
func (s *Store) CountTodayRuns(ctx context.Context, agentName string) (int, error) {
|
||||
// Compute start of today in UTC as RFC3339
|
||||
now := time.Now().UTC()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
startStr := startOfDay.Format(time.RFC3339)
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM reactive_runs
|
||||
WHERE agent_name = ? AND status IN ('running', 'succeeded', 'failed')
|
||||
AND created_at >= ?`,
|
||||
agentName, startStr,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetLastRunTime returns the created_at of the most recent countable run.
|
||||
func (s *Store) GetLastRunTime(ctx context.Context, agentName string) (*time.Time, error) {
|
||||
var t sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(created_at) FROM reactive_runs
|
||||
WHERE agent_name = ? AND status IN ('running', 'succeeded', 'failed')`,
|
||||
agentName,
|
||||
).Scan(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !t.Valid || t.String == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := parseTime(t.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
// parseTime tries multiple time formats used by SQLite / Go driver.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05+00:00",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.999999999Z07:00",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse time %q", s)
|
||||
}
|
||||
|
||||
// IsAgentRunning checks if the agent has an active (running) reactive run.
|
||||
func (s *Store) IsAgentRunning(ctx context.Context, agentName string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM reactive_runs WHERE agent_name = ? AND status = 'running'`,
|
||||
agentName,
|
||||
).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func runSelectSQL() string {
|
||||
return `SELECT id, agent_name, trigger_message_id, trigger_event, trigger_depth, trigger_from,
|
||||
status, k8s_job_name, k8s_namespace, started_at, completed_at, duration_ms, error_log, token_cost_json, created_at
|
||||
FROM reactive_runs`
|
||||
}
|
||||
|
||||
func scanRunFields(r *ReactiveRun, msgID *sql.NullInt64, triggerFrom, jobName, namespace, errorLog, tokenCost *sql.NullString, startedAt, completedAt *sql.NullString, durationMs *sql.NullInt64, createdAt *string) {
|
||||
if msgID.Valid {
|
||||
r.TriggerMessageID = &msgID.Int64
|
||||
}
|
||||
r.TriggerFrom = triggerFrom.String
|
||||
r.K8sJobName = jobName.String
|
||||
r.K8sNamespace = namespace.String
|
||||
if startedAt.Valid && startedAt.String != "" {
|
||||
if t, err := parseTime(startedAt.String); err == nil {
|
||||
r.StartedAt = &t
|
||||
}
|
||||
}
|
||||
if completedAt.Valid && completedAt.String != "" {
|
||||
if t, err := parseTime(completedAt.String); err == nil {
|
||||
r.CompletedAt = &t
|
||||
}
|
||||
}
|
||||
if durationMs.Valid {
|
||||
r.DurationMs = &durationMs.Int64
|
||||
}
|
||||
r.ErrorLog = errorLog.String
|
||||
r.TokenCostJSON = tokenCost.String
|
||||
if *createdAt != "" {
|
||||
if t, err := parseTime(*createdAt); err == nil {
|
||||
r.CreatedAt = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) scanRun(row *sql.Row) (*ReactiveRun, error) {
|
||||
var r ReactiveRun
|
||||
var msgID sql.NullInt64
|
||||
var triggerFrom, jobName, namespace, errorLog, tokenCost sql.NullString
|
||||
var startedAt, completedAt sql.NullString
|
||||
var durationMs sql.NullInt64
|
||||
var createdAt string
|
||||
|
||||
err := row.Scan(
|
||||
&r.ID, &r.AgentName, &msgID, &r.TriggerEvent, &r.TriggerDepth, &triggerFrom,
|
||||
&r.Status, &jobName, &namespace, &startedAt, &completedAt, &durationMs, &errorLog, &tokenCost, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanRunFields(&r, &msgID, &triggerFrom, &jobName, &namespace, &errorLog, &tokenCost, &startedAt, &completedAt, &durationMs, &createdAt)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *Store) scanRuns(rows *sql.Rows) ([]*ReactiveRun, error) {
|
||||
var runs []*ReactiveRun
|
||||
for rows.Next() {
|
||||
var r ReactiveRun
|
||||
var msgID sql.NullInt64
|
||||
var triggerFrom, jobName, namespace, errorLog, tokenCost sql.NullString
|
||||
var startedAt, completedAt sql.NullString
|
||||
var durationMs sql.NullInt64
|
||||
var createdAt string
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.AgentName, &msgID, &r.TriggerEvent, &r.TriggerDepth, &triggerFrom,
|
||||
&r.Status, &jobName, &namespace, &startedAt, &completedAt, &durationMs, &errorLog, &tokenCost, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanRunFields(&r, &msgID, &triggerFrom, &jobName, &namespace, &errorLog, &tokenCost, &startedAt, &completedAt, &durationMs, &createdAt)
|
||||
runs = append(runs, &r)
|
||||
}
|
||||
if runs == nil {
|
||||
runs = []*ReactiveRun{}
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Trust scores per (agent, action_type) for graduated autonomy
|
||||
CREATE TABLE IF NOT EXISTS agent_trust (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0.0,
|
||||
adjustments_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_adjusted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(agent_name, action_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trust_agent ON agent_trust(agent_name);
|
||||
|
||||
-- Channel autonomy thresholds
|
||||
ALTER TABLE channels ADD COLUMN publish_threshold REAL NOT NULL DEFAULT 0.8;
|
||||
ALTER TABLE channels ADD COLUMN approve_threshold REAL NOT NULL DEFAULT 0.6;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 013: Reactive agent triggering
|
||||
-- Extends agents with trigger configuration, adds reactive_runs tracking table.
|
||||
|
||||
-- Extend agents table with reactive trigger configuration
|
||||
ALTER TABLE agents ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'passive';
|
||||
ALTER TABLE agents ADD COLUMN cooldown_seconds INTEGER NOT NULL DEFAULT 600;
|
||||
ALTER TABLE agents ADD COLUMN daily_trigger_budget INTEGER NOT NULL DEFAULT 8;
|
||||
ALTER TABLE agents ADD COLUMN max_trigger_depth INTEGER NOT NULL DEFAULT 5;
|
||||
ALTER TABLE agents ADD COLUMN k8s_image TEXT;
|
||||
ALTER TABLE agents ADD COLUMN k8s_env_json TEXT;
|
||||
ALTER TABLE agents ADD COLUMN k8s_resource_preset TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE agents ADD COLUMN pending_work INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Reactive trigger runs: tracks every trigger evaluation and K8s job lifecycle
|
||||
CREATE TABLE reactive_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name),
|
||||
trigger_message_id INTEGER,
|
||||
trigger_event TEXT NOT NULL,
|
||||
trigger_depth INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_from TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
k8s_job_name TEXT,
|
||||
k8s_namespace TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
duration_ms INTEGER,
|
||||
error_log TEXT,
|
||||
token_cost_json TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reactive_runs_agent_created ON reactive_runs(agent_name, created_at);
|
||||
CREATE INDEX idx_reactive_runs_status ON reactive_runs(status);
|
||||
CREATE INDEX idx_reactive_runs_agent_status ON reactive_runs(agent_name, status);
|
||||
@@ -0,0 +1,58 @@
|
||||
-- 016: Agent SQL query views
|
||||
-- These views are used by the 'query' action to give agents read access
|
||||
-- to messages they can see. The views expose a stable schema that agents
|
||||
-- can query via SQL. Access control is enforced at the Go layer by
|
||||
-- rewriting queries to filter by agent name.
|
||||
|
||||
-- Note: SQLite views cannot be parameterized. The Go query executor
|
||||
-- wraps agent queries in a CTE that filters by the authenticated agent's
|
||||
-- access (own DMs + joined channels). These views provide the base schema.
|
||||
|
||||
-- my_messages: All messages accessible to the calling agent
|
||||
CREATE VIEW IF NOT EXISTS 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;
|
||||
|
||||
-- my_channels: Channels the calling agent has joined
|
||||
CREATE VIEW IF NOT EXISTS 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;
|
||||
|
||||
-- channel_messages: Messages in channels (filtered by membership at Go layer)
|
||||
CREATE VIEW IF NOT EXISTS 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;
|
||||
+97
-23
@@ -12,17 +12,22 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB wraps a *sql.DB with SynapBus-specific configuration.
|
||||
// DB wraps a write-only *sql.DB and an optional read-only *sql.DB
|
||||
// for split connection pool architecture. The write pool has MaxOpenConns=1
|
||||
// to serialize writes and eliminate SQLITE_BUSY errors. The read pool has
|
||||
// MaxOpenConns=8 and query_only=ON for safe concurrent reads.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
*sql.DB // Write pool (MaxOpenConns=1)
|
||||
ReadDB *sql.DB // Read pool (MaxOpenConns=8, query_only=ON) — nil for :memory: DBs
|
||||
}
|
||||
|
||||
// New opens a SQLite database with WAL mode, busy_timeout, and foreign keys enabled.
|
||||
// If dataDir is empty or ":memory:", an in-memory database is used.
|
||||
// New opens a SQLite database with WAL mode, split read/write pools, and foreign keys.
|
||||
// If dataDir is empty or ":memory:", an in-memory database is used (single pool, no split).
|
||||
func New(ctx context.Context, dataDir string) (*DB, error) {
|
||||
var dsn string
|
||||
isMemory := dataDir == "" || dataDir == ":memory:"
|
||||
|
||||
if dataDir == "" || dataDir == ":memory:" {
|
||||
if isMemory {
|
||||
dsn = ":memory:"
|
||||
} else {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
@@ -31,16 +36,76 @@ func New(ctx context.Context, dataDir string) (*DB, error) {
|
||||
dsn = filepath.Join(dataDir, "synapbus.db")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
// Open WRITE pool (single connection, serializes all writes)
|
||||
writeDB, err := openPool(ctx, dsn, poolConfig{
|
||||
maxOpen: 1,
|
||||
maxIdle: 1,
|
||||
queryOnly: false,
|
||||
label: "write",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
return nil, fmt.Errorf("open write pool: %w", err)
|
||||
}
|
||||
|
||||
// Configure SQLite pragmas
|
||||
result := &DB{DB: writeDB}
|
||||
|
||||
// For file-based databases, open a separate READ pool
|
||||
if !isMemory {
|
||||
readDB, err := openPool(ctx, dsn, poolConfig{
|
||||
maxOpen: 8,
|
||||
maxIdle: 4,
|
||||
queryOnly: true,
|
||||
label: "read",
|
||||
})
|
||||
if err != nil {
|
||||
writeDB.Close()
|
||||
return nil, fmt.Errorf("open read pool: %w", err)
|
||||
}
|
||||
result.ReadDB = readDB
|
||||
}
|
||||
|
||||
// Verify settings on write pool
|
||||
var journalMode string
|
||||
if err := writeDB.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
result.Close()
|
||||
return nil, fmt.Errorf("verify journal_mode: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database opened",
|
||||
"dsn", dsn,
|
||||
"journal_mode", journalMode,
|
||||
"write_pool", "MaxOpenConns=1",
|
||||
"read_pool_enabled", result.ReadDB != nil,
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type poolConfig struct {
|
||||
maxOpen int
|
||||
maxIdle int
|
||||
queryOnly bool
|
||||
label string
|
||||
}
|
||||
|
||||
func openPool(ctx context.Context, dsn string, cfg poolConfig) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s pool: %w", cfg.label, err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(cfg.maxOpen)
|
||||
db.SetMaxIdleConns(cfg.maxIdle)
|
||||
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA busy_timeout=15000",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA wal_autocheckpoint=1000",
|
||||
}
|
||||
if cfg.queryOnly {
|
||||
pragmas = append(pragmas, "PRAGMA query_only=ON")
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
@@ -50,22 +115,31 @@ func New(ctx context.Context, dataDir string) (*DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify settings
|
||||
var journalMode string
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("verify journal_mode: %w", err)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// QueryDB returns the read pool if available, otherwise falls back to the write pool.
|
||||
// Use this for all SELECT queries to avoid blocking writers.
|
||||
func (db *DB) QueryDB() *sql.DB {
|
||||
if db.ReadDB != nil {
|
||||
return db.ReadDB
|
||||
}
|
||||
|
||||
slog.Info("database opened",
|
||||
"dsn", dsn,
|
||||
"journal_mode", journalMode,
|
||||
)
|
||||
|
||||
return &DB{DB: db}, nil
|
||||
return db.DB
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
// Close closes both the write and read database connections.
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
var errs []error
|
||||
if db.ReadDB != nil {
|
||||
if err := db.ReadDB.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close read pool: %w", err))
|
||||
}
|
||||
}
|
||||
if err := db.DB.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close write pool: %w", err))
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ func TestNew(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query busy_timeout: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("busy_timeout = %d, want 5000", timeout)
|
||||
if timeout != 15000 {
|
||||
t.Errorf("busy_timeout = %d, want 15000", timeout)
|
||||
}
|
||||
|
||||
// Verify database is usable
|
||||
// Verify database is usable via write pool
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test table: %v", err)
|
||||
@@ -80,3 +80,77 @@ func TestNew(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
db, err := New(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run migrations to create tables
|
||||
if err := RunMigrations(ctx, db.DB); err != nil {
|
||||
t.Fatalf("migrations: %v", err)
|
||||
}
|
||||
|
||||
// Verify read pool exists for file-based DB
|
||||
if db.ReadDB == nil {
|
||||
t.Fatal("expected ReadDB to be non-nil for file-based database")
|
||||
}
|
||||
|
||||
// Verify QueryDB returns read pool
|
||||
if db.QueryDB() != db.ReadDB {
|
||||
t.Error("QueryDB() should return ReadDB when available")
|
||||
}
|
||||
|
||||
// Create user first (FK requirement)
|
||||
_, err = db.Exec("INSERT INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test')")
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// Verify write pool can write
|
||||
_, err = db.Exec("INSERT INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('test-agent', 'Test', 'ai', '{}', 1, 'hash', 'active')")
|
||||
if err != nil {
|
||||
t.Fatalf("write pool should allow writes: %v", err)
|
||||
}
|
||||
|
||||
// Verify read pool can read
|
||||
var name string
|
||||
err = db.ReadDB.QueryRow("SELECT name FROM agents WHERE name = 'test-agent'").Scan(&name)
|
||||
if err != nil {
|
||||
t.Fatalf("read pool should allow reads: %v", err)
|
||||
}
|
||||
if name != "test-agent" {
|
||||
t.Errorf("expected 'test-agent', got %q", name)
|
||||
}
|
||||
|
||||
// Verify read pool rejects writes
|
||||
_, err = db.ReadDB.Exec("INSERT INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('bad', 'Bad', 'ai', '{}', 1, 'hash', 'active')")
|
||||
if err == nil {
|
||||
t.Fatal("read pool should reject writes (query_only=ON)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInMemoryNoSplitPool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := New(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// In-memory DB should NOT have a separate read pool
|
||||
if db.ReadDB != nil {
|
||||
t.Error("in-memory DB should not have a separate ReadDB")
|
||||
}
|
||||
|
||||
// QueryDB should fall back to write pool
|
||||
if db.QueryDB() != db.DB {
|
||||
t.Error("QueryDB() should return write pool for in-memory DB")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
)
|
||||
|
||||
// Metrics provides Prometheus-compatible metrics for SynapBus.
|
||||
@@ -93,6 +96,17 @@ func (m *Metrics) WritePrometheus(w io.Writer) {
|
||||
fmt.Fprintf(w, "# HELP synapbus_active_agents Number of currently active agents.\n")
|
||||
fmt.Fprintf(w, "# TYPE synapbus_active_agents gauge\n")
|
||||
fmt.Fprintf(w, "synapbus_active_agents %d\n", m.activeAgents.Load())
|
||||
fmt.Fprintf(w, "\n")
|
||||
|
||||
// Append metrics from the standard Prometheus registry (reactor metrics, etc.)
|
||||
mfs, _ := prometheus.DefaultGatherer.Gather()
|
||||
enc := expfmt.NewEncoder(w, expfmt.NewFormat(expfmt.TypeTextPlain))
|
||||
for _, mf := range mfs {
|
||||
// Only include our custom metrics, skip Go runtime metrics
|
||||
if name := mf.GetName(); len(name) > 8 && name[:8] == "synapbus" {
|
||||
_ = enc.Encode(mf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NullMetrics is a no-op metrics implementation for when metrics are disabled.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package trust provides agent trust score tracking for graduated autonomy.
|
||||
package trust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Trust adjustment constants.
|
||||
const (
|
||||
ApprovalIncrement = 0.05
|
||||
RejectionDecrement = 0.10
|
||||
MinScore = 0.0
|
||||
MaxScore = 1.0
|
||||
)
|
||||
|
||||
// Common action types (extensible — any string is valid).
|
||||
const (
|
||||
ActionResearch = "research"
|
||||
ActionPublish = "publish"
|
||||
ActionComment = "comment"
|
||||
ActionApprove = "approve"
|
||||
ActionOperate = "operate"
|
||||
)
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
ErrAlreadyClaimed = errors.New("work item already claimed by another agent")
|
||||
ErrSelfReaction = errors.New("cannot adjust trust for self-reactions")
|
||||
)
|
||||
|
||||
// TrustScore represents an agent's trust level for a specific action type.
|
||||
type TrustScore struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
ActionType string `json:"action_type"`
|
||||
Score float64 `json:"score"`
|
||||
AdjustmentsCount int `json:"adjustments_count"`
|
||||
LastAdjustedAt *time.Time `json:"last_adjusted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AgentTrustSummary is a map of action_type -> score for an agent.
|
||||
type AgentTrustSummary map[string]float64
|
||||
|
||||
// ClampScore ensures a score stays within [0.0, 1.0].
|
||||
func ClampScore(score float64) float64 {
|
||||
if score < MinScore {
|
||||
return MinScore
|
||||
}
|
||||
if score > MaxScore {
|
||||
return MaxScore
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// WorkflowStateChangeEvent is the webhook payload for state transitions.
|
||||
type WorkflowStateChangeEvent struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
ChannelID int64 `json:"channel_id,omitempty"`
|
||||
OldState string `json:"old_state"`
|
||||
NewState string `json:"new_state"`
|
||||
TriggeredBy string `json:"triggered_by"`
|
||||
Reaction string `json:"reaction"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package trust
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClampScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
want float64
|
||||
}{
|
||||
{"zero", 0.0, 0.0},
|
||||
{"one", 1.0, 1.0},
|
||||
{"mid", 0.5, 0.5},
|
||||
{"below zero", -0.1, MinScore},
|
||||
{"far below zero", -10.0, MinScore},
|
||||
{"above one", 1.1, MaxScore},
|
||||
{"far above one", 100.0, MaxScore},
|
||||
{"small positive", 0.001, 0.001},
|
||||
{"near max", 0.999, 0.999},
|
||||
{"exactly min", MinScore, MinScore},
|
||||
{"exactly max", MaxScore, MaxScore},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ClampScore(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ClampScore(%f) = %f, want %f", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Service provides business logic for trust score management.
|
||||
type Service struct {
|
||||
store Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new trust service.
|
||||
func NewService(store Store, logger *slog.Logger) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
logger: logger.With("component", "trust"),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordApproval increases an agent's trust for an action type.
|
||||
func (s *Service) RecordApproval(ctx context.Context, agentName, actionType string) (*TrustScore, error) {
|
||||
ts, err := s.store.UpsertScore(ctx, agentName, actionType, ApprovalIncrement)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("record approval: %w", err)
|
||||
}
|
||||
s.logger.Info("trust increased",
|
||||
"agent", agentName,
|
||||
"action", actionType,
|
||||
"delta", ApprovalIncrement,
|
||||
"new_score", ts.Score,
|
||||
)
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// RecordRejection decreases an agent's trust for an action type.
|
||||
func (s *Service) RecordRejection(ctx context.Context, agentName, actionType string) (*TrustScore, error) {
|
||||
ts, err := s.store.UpsertScore(ctx, agentName, actionType, -RejectionDecrement)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("record rejection: %w", err)
|
||||
}
|
||||
s.logger.Info("trust decreased",
|
||||
"agent", agentName,
|
||||
"action", actionType,
|
||||
"delta", -RejectionDecrement,
|
||||
"new_score", ts.Score,
|
||||
)
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// GetScores returns all trust scores for an agent as a summary map.
|
||||
func (s *Service) GetScores(ctx context.Context, agentName string) (AgentTrustSummary, error) {
|
||||
scores, err := s.store.GetAllScores(ctx, agentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get scores: %w", err)
|
||||
}
|
||||
summary := make(AgentTrustSummary)
|
||||
for _, ts := range scores {
|
||||
summary[ts.ActionType] = ts.Score
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetScore returns the trust score for a specific (agent, action) pair.
|
||||
func (s *Service) GetScore(ctx context.Context, agentName, actionType string) (float64, error) {
|
||||
ts, err := s.store.GetScore(ctx, agentName, actionType)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get score: %w", err)
|
||||
}
|
||||
return ts.Score, nil
|
||||
}
|
||||
|
||||
// CheckAutonomy returns whether an agent has sufficient trust for an action
|
||||
// given a channel's threshold.
|
||||
func (s *Service) CheckAutonomy(ctx context.Context, agentName, actionType string, threshold float64) (bool, float64, error) {
|
||||
score, err := s.GetScore(ctx, agentName, actionType)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
return score >= threshold, score, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store defines the storage interface for trust scores.
|
||||
type Store interface {
|
||||
GetScore(ctx context.Context, agentName, actionType string) (*TrustScore, error)
|
||||
GetAllScores(ctx context.Context, agentName string) ([]*TrustScore, error)
|
||||
UpsertScore(ctx context.Context, agentName, actionType string, delta float64) (*TrustScore, error)
|
||||
}
|
||||
|
||||
// SQLiteStore implements Store using SQLite.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteStore creates a new SQLite-backed trust store.
|
||||
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
|
||||
return &SQLiteStore{db: db}
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetScore(ctx context.Context, agentName, actionType string) (*TrustScore, error) {
|
||||
var ts TrustScore
|
||||
var lastAdj sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at
|
||||
FROM agent_trust WHERE agent_name = ? AND action_type = ?`,
|
||||
agentName, actionType,
|
||||
).Scan(&ts.ID, &ts.AgentName, &ts.ActionType, &ts.Score, &ts.AdjustmentsCount, &lastAdj, &ts.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return &TrustScore{AgentName: agentName, ActionType: actionType, Score: 0.0}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get trust score: %w", err)
|
||||
}
|
||||
if lastAdj.Valid {
|
||||
ts.LastAdjustedAt = &lastAdj.Time
|
||||
}
|
||||
return &ts, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetAllScores(ctx context.Context, agentName string) ([]*TrustScore, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at
|
||||
FROM agent_trust WHERE agent_name = ?
|
||||
ORDER BY action_type`, agentName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get all trust scores: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scores []*TrustScore
|
||||
for rows.Next() {
|
||||
var ts TrustScore
|
||||
var lastAdj sql.NullTime
|
||||
if err := rows.Scan(&ts.ID, &ts.AgentName, &ts.ActionType, &ts.Score, &ts.AdjustmentsCount, &lastAdj, &ts.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan trust score: %w", err)
|
||||
}
|
||||
if lastAdj.Valid {
|
||||
ts.LastAdjustedAt = &lastAdj.Time
|
||||
}
|
||||
scores = append(scores, &ts)
|
||||
}
|
||||
if scores == nil {
|
||||
scores = []*TrustScore{}
|
||||
}
|
||||
return scores, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) UpsertScore(ctx context.Context, agentName, actionType string, delta float64) (*TrustScore, error) {
|
||||
// Upsert: insert if not exists, update if exists
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_trust (agent_name, action_type, score, adjustments_count, last_adjusted_at, created_at)
|
||||
VALUES (?, ?, MAX(0.0, MIN(1.0, ?)), 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(agent_name, action_type) DO UPDATE SET
|
||||
score = MAX(0.0, MIN(1.0, agent_trust.score + ?)),
|
||||
adjustments_count = agent_trust.adjustments_count + 1,
|
||||
last_adjusted_at = CURRENT_TIMESTAMP`,
|
||||
agentName, actionType, delta, delta,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert trust score: %w", err)
|
||||
}
|
||||
|
||||
return s.GetScore(ctx, agentName, actionType)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package trust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"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 TestSQLiteStore_UpsertAndGet(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
ts, err := store.UpsertScore(ctx, "agent-a", ActionResearch, 0.5)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore: %v", err)
|
||||
}
|
||||
|
||||
if ts.Score != 0.5 {
|
||||
t.Errorf("Score = %f, want 0.5", ts.Score)
|
||||
}
|
||||
if ts.AgentName != "agent-a" {
|
||||
t.Errorf("AgentName = %q, want %q", ts.AgentName, "agent-a")
|
||||
}
|
||||
if ts.ActionType != ActionResearch {
|
||||
t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch)
|
||||
}
|
||||
if ts.AdjustmentsCount != 1 {
|
||||
t.Errorf("AdjustmentsCount = %d, want 1", ts.AdjustmentsCount)
|
||||
}
|
||||
|
||||
// Verify it's retrievable via GetScore
|
||||
got, err := store.GetScore(ctx, "agent-a", ActionResearch)
|
||||
if err != nil {
|
||||
t.Fatalf("GetScore: %v", err)
|
||||
}
|
||||
if got.Score != 0.5 {
|
||||
t.Errorf("GetScore Score = %f, want 0.5", got.Score)
|
||||
}
|
||||
if got.AgentName != "agent-a" {
|
||||
t.Errorf("GetScore AgentName = %q, want %q", got.AgentName, "agent-a")
|
||||
}
|
||||
if got.ActionType != ActionResearch {
|
||||
t.Errorf("GetScore ActionType = %q, want %q", got.ActionType, ActionResearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_UpsertIncrement(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// First upsert: initial score
|
||||
_, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore first: %v", err)
|
||||
}
|
||||
|
||||
// Second upsert: should increment
|
||||
ts, err := store.UpsertScore(ctx, "agent-a", ActionPublish, 0.2)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore second: %v", err)
|
||||
}
|
||||
|
||||
want := 0.5
|
||||
if ts.Score != want {
|
||||
t.Errorf("Score = %f, want %f", ts.Score, want)
|
||||
}
|
||||
if ts.AdjustmentsCount != 2 {
|
||||
t.Errorf("AdjustmentsCount = %d, want 2", ts.AdjustmentsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_ClampMax(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a high score
|
||||
_, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.9)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore first: %v", err)
|
||||
}
|
||||
|
||||
// Push past 1.0
|
||||
ts, err := store.UpsertScore(ctx, "agent-a", ActionComment, 0.5)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore second: %v", err)
|
||||
}
|
||||
|
||||
if ts.Score != MaxScore {
|
||||
t.Errorf("Score = %f, want %f (clamped to max)", ts.Score, MaxScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_ClampMin(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a low score
|
||||
_, err := store.UpsertScore(ctx, "agent-a", ActionOperate, 0.1)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore first: %v", err)
|
||||
}
|
||||
|
||||
// Push past 0.0 with a large negative delta
|
||||
ts, err := store.UpsertScore(ctx, "agent-a", ActionOperate, -0.5)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertScore second: %v", err)
|
||||
}
|
||||
|
||||
if ts.Score != MinScore {
|
||||
t.Errorf("Score = %f, want %f (clamped to min)", ts.Score, MinScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_GetAllScores(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert multiple action types for the same agent
|
||||
actions := []struct {
|
||||
actionType string
|
||||
delta float64
|
||||
}{
|
||||
{ActionResearch, 0.3},
|
||||
{ActionPublish, 0.5},
|
||||
{ActionComment, 0.7},
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
if _, err := store.UpsertScore(ctx, "agent-a", a.actionType, a.delta); err != nil {
|
||||
t.Fatalf("UpsertScore %s: %v", a.actionType, err)
|
||||
}
|
||||
}
|
||||
|
||||
scores, err := store.GetAllScores(ctx, "agent-a")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllScores: %v", err)
|
||||
}
|
||||
|
||||
if len(scores) != 3 {
|
||||
t.Fatalf("got %d scores, want 3", len(scores))
|
||||
}
|
||||
|
||||
// Scores are ordered by action_type alphabetically
|
||||
scoreMap := make(map[string]float64)
|
||||
for _, s := range scores {
|
||||
scoreMap[s.ActionType] = s.Score
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
got, ok := scoreMap[a.actionType]
|
||||
if !ok {
|
||||
t.Errorf("missing score for action %q", a.actionType)
|
||||
continue
|
||||
}
|
||||
if got != a.delta {
|
||||
t.Errorf("score for %q = %f, want %f", a.actionType, got, a.delta)
|
||||
}
|
||||
}
|
||||
|
||||
// Different agent should return empty
|
||||
other, err := store.GetAllScores(ctx, "agent-nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllScores (other): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Errorf("got %d scores for nonexistent agent, want 0", len(other))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStore_GetScoreNotFound(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Get score for non-existent agent should return 0.0 (not an error)
|
||||
ts, err := store.GetScore(ctx, "nonexistent-agent", ActionResearch)
|
||||
if err != nil {
|
||||
t.Fatalf("GetScore: %v", err)
|
||||
}
|
||||
|
||||
if ts.Score != 0.0 {
|
||||
t.Errorf("Score = %f, want 0.0 for non-existent agent", ts.Score)
|
||||
}
|
||||
if ts.AgentName != "nonexistent-agent" {
|
||||
t.Errorf("AgentName = %q, want %q", ts.AgentName, "nonexistent-agent")
|
||||
}
|
||||
if ts.ActionType != ActionResearch {
|
||||
t.Errorf("ActionType = %q, want %q", ts.ActionType, ActionResearch)
|
||||
}
|
||||
if ts.AdjustmentsCount != 0 {
|
||||
t.Errorf("AdjustmentsCount = %d, want 0", ts.AdjustmentsCount)
|
||||
}
|
||||
}
|
||||
Vendored
+11
-11
@@ -11,30 +11,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.HBAljWpY.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/By4mEdwX.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.C53XvHet.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BYAQw6Ic.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DFRGYO_X.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/0x2jFCf0.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C3nS3byM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C1Y8Vas-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bs4ZECIt.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.mRWkfG8z.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.1r_Gkim3.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_ro0mhp = {
|
||||
__sveltekit_1vc73p1 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.HBAljWpY.js"),
|
||||
import("/_app/immutable/entry/app.mRWkfG8z.js")
|
||||
import("/_app/immutable/entry/start.C53XvHet.js"),
|
||||
import("/_app/immutable/entry/app.1r_Gkim3.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Feature Specification: Trust Scores, Claim Semantics & State-Change Webhooks
|
||||
|
||||
**Feature Branch**: `011-trust-claims-triggers`
|
||||
**Created**: 2026-03-18
|
||||
**Status**: Draft
|
||||
**Input**: Platform architecture design from `docs/superpowers/specs/2026-03-18-agent-platform-architecture-design.md`
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Trust scores are stored per (agent_name, action_type) pair in a new `agent_trust` table
|
||||
- Action types are a flexible string enum: "research", "publish", "comment", "approve", "operate" — not hardcoded, extensible
|
||||
- Default trust score for a new (agent, action) pair is 0.0
|
||||
- Trust increments: +0.05 on human approval (reaction approve/published on agent's work), -0.1 on rejection
|
||||
- Trust range: 0.0 to 1.0, clamped
|
||||
- Autonomy thresholds are per-channel settings (e.g., `publish_threshold: 0.8`)
|
||||
- Claim semantics: only one `in_progress` reaction per message enforced at DB level (first agent wins)
|
||||
- Webhook state-change triggers reuse existing webhook infrastructure (internal/webhooks/)
|
||||
- A new event type `workflow.state_changed` fires when a reaction changes the derived workflow state
|
||||
- Migration number: 014_trust_claims.sql
|
||||
- Trust score API is read-only for agents (they can query their scores but not set them)
|
||||
- Trust adjustments happen automatically when a human reacts to agent work (approve = +trust, reject = -trust)
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust Score Tracking (Priority: P1)
|
||||
|
||||
When a human approves an agent's blog post (reacts "approve" to a message from an AI agent), the agent's trust score for the "publish" action type increases. When rejected, it decreases. Over time, agents earn autonomy.
|
||||
|
||||
**Why this priority**: Trust is the foundation of graduated autonomy. Without tracking, all agents stay fully supervised forever.
|
||||
|
||||
**Independent Test**: Have agent post content, human reacts approve, verify trust score increased.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "research-mcpproxy" with no trust history, **When** querying trust, **Then** all action scores return 0.0.
|
||||
2. **Given** agent posted a message, **When** a human reacts with "approve", **Then** the agent's trust for "publish" increases by 0.05.
|
||||
3. **Given** agent with trust 0.95 for "publish", **When** approved again, **Then** trust is clamped to 1.0.
|
||||
4. **Given** agent with trust 0.3 for "comment", **When** human reacts "reject", **Then** trust decreases by 0.1 to 0.2.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Claim Semantics (Priority: P1)
|
||||
|
||||
When an agent reacts with "in_progress" to claim a work item, no other agent can claim the same item. First agent wins.
|
||||
|
||||
**Why this priority**: Without claim semantics, multiple agents could work on the same task simultaneously, wasting resources.
|
||||
|
||||
**Independent Test**: Two agents try to react in_progress on the same message, second one gets an error.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a proposed message, **When** agent A reacts "in_progress", **Then** the claim succeeds.
|
||||
2. **Given** a message already claimed by agent A, **When** agent B reacts "in_progress", **Then** agent B gets an error "already claimed by agent-a".
|
||||
3. **Given** a claimed message, **When** agent A removes their "in_progress" reaction, **Then** the message becomes claimable again.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Webhook on State Change (Priority: P2)
|
||||
|
||||
When a reaction changes a message's workflow state (e.g., proposed -> approved), SynapBus fires a webhook with the event details. This enables event-driven agent activation.
|
||||
|
||||
**Why this priority**: Webhooks replace polling. Agents can be triggered immediately when work is available.
|
||||
|
||||
**Independent Test**: Register a webhook for workflow.state_changed, add a reaction that changes state, verify webhook fires.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a registered webhook for "workflow.state_changed", **When** a message transitions from proposed to approved, **Then** a webhook is delivered with message_id, old_state, new_state, channel.
|
||||
2. **Given** no webhook registered, **When** a state change occurs, **Then** no error — the change proceeds normally.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Trust Query via MCP (Priority: P2)
|
||||
|
||||
Agents can query their own trust scores via MCP tools to understand their autonomy level.
|
||||
|
||||
**Why this priority**: Agents need to know if they can act autonomously or must request approval.
|
||||
|
||||
**Independent Test**: Agent calls get_trust MCP action, receives trust scores.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with trust scores, **When** it calls `get_trust`, **Then** it receives a map of action_type -> score.
|
||||
2. **Given** a channel with publish_threshold=0.8, **When** agent has publish trust 0.9, **Then** the response indicates autonomous publishing is allowed.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Agent reacts to its own message: trust adjustment skipped (can't self-approve)
|
||||
- Human reacts to human message: no trust adjustment (only applies to AI agent messages)
|
||||
- Multiple humans approve same message: trust increases once per unique approval
|
||||
- Trust score requested for unknown agent: returns empty map (all zeros)
|
||||
- Webhook delivery fails: standard retry logic from existing webhook system
|
||||
- Message with no channel (DM): claim semantics still apply, trust adjustments still apply
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST store trust scores per (agent_name, action_type) pair
|
||||
- **FR-002**: System MUST automatically adjust trust when a human reacts to an AI agent's message (approve: +0.05, reject: -0.1)
|
||||
- **FR-003**: System MUST clamp trust scores to range [0.0, 1.0]
|
||||
- **FR-004**: System MUST prevent duplicate "in_progress" claims on a message (first agent wins)
|
||||
- **FR-005**: System MUST return a clear error when a claim attempt is blocked
|
||||
- **FR-006**: System MUST fire a "workflow.state_changed" webhook event when reactions change a message's derived workflow state
|
||||
- **FR-007**: System MUST expose trust scores via MCP `get_trust` action
|
||||
- **FR-008**: System MUST expose trust scores via REST API for the web UI
|
||||
- **FR-009**: System MUST skip trust adjustments for self-reactions (agent reacts to own message)
|
||||
- **FR-010**: System MUST support per-channel autonomy thresholds (publish_threshold, approve_threshold)
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **TrustScore**: Per (agent_name, action_type) pair. Fields: score (float), adjustments_count, last_adjusted_at.
|
||||
- **Claim**: Implicit via in_progress reaction uniqueness constraint. No separate entity needed.
|
||||
- **WorkflowStateChange Event**: Webhook payload with message_id, channel_id, old_state, new_state, triggered_by agent.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Trust scores update within 1 second of a reaction
|
||||
- **SC-002**: 100% of duplicate claim attempts are rejected with clear error
|
||||
- **SC-003**: Webhook events fire within 2 seconds of a state change
|
||||
- **SC-004**: Agents can query their trust scores in under 1 second
|
||||
- **SC-005**: Trust adjustments are idempotent — same human approving twice doesn't double-increment
|
||||
@@ -0,0 +1,37 @@
|
||||
# Tasks: Trust Scores, Claim Semantics & State-Change Webhooks
|
||||
|
||||
## Phase 1: Setup
|
||||
- [ ] T001 Verify `make test` passes
|
||||
- [ ] T002 Create migration 014_trust_claims.sql
|
||||
|
||||
## Phase 2: Trust Score Backend
|
||||
- [ ] T003 Create internal/trust/model.go (TrustScore struct, constants, AdjustTrust logic)
|
||||
- [ ] T004 Create internal/trust/store.go (SQLite CRUD: Get, Upsert, GetAll, AdjustScore)
|
||||
- [ ] T005 Create internal/trust/service.go (business logic: RecordApproval, RecordRejection, GetScores)
|
||||
- [ ] T006 [P] Write tests for trust model + store
|
||||
- [ ] T007 Wire trust service into main.go
|
||||
|
||||
## Phase 3: Claim Semantics
|
||||
- [ ] T008 Add UNIQUE constraint for in_progress claims in reactions store
|
||||
- [ ] T009 Update reactions service Toggle to check for existing in_progress claims
|
||||
- [ ] T010 Write tests for claim prevention
|
||||
|
||||
## Phase 4: Trust Auto-Adjustment on Reactions
|
||||
- [ ] T011 Hook trust adjustment into reaction creation (when human reacts to AI message)
|
||||
- [ ] T012 Add logic to detect human-reacting-to-AI-message pattern
|
||||
- [ ] T013 Write tests for auto-adjustment
|
||||
|
||||
## Phase 5: Webhook State-Change Triggers
|
||||
- [ ] T014 Add workflow.state_changed event type to dispatcher
|
||||
- [ ] T015 Fire event from reactions service when state changes
|
||||
- [ ] T016 Write tests for webhook trigger
|
||||
|
||||
## Phase 6: MCP + REST API
|
||||
- [ ] T017 Register get_trust MCP action in bridge + registry
|
||||
- [ ] T018 Add GET /api/trust/{agent} REST endpoint
|
||||
- [ ] T019 Add channel threshold fields (publish_threshold, approve_threshold)
|
||||
|
||||
## Phase 7: Polish
|
||||
- [ ] T020 Run go test ./...
|
||||
- [ ] T021 Run make build
|
||||
- [ ] T022 Run make web
|
||||
@@ -0,0 +1,66 @@
|
||||
# Feature Specification: Agent Onboarding & Experimentation Environment
|
||||
|
||||
**Feature Branch**: `012-agent-onboarding`
|
||||
**Created**: 2026-03-20
|
||||
**Status**: Draft
|
||||
|
||||
## Assumptions
|
||||
|
||||
- CLAUDE.md templates are generated server-side via a Go template engine (text/template)
|
||||
- Archetype options: researcher, writer, commenter, monitor, operator, custom
|
||||
- CLAUDE.md download is a GET endpoint returning text/markdown
|
||||
- MCP config snippet is generated from the server's base URL + agent API key
|
||||
- Skills are served as static markdown files from an embedded directory
|
||||
- The agent registration page in web UI is at /agents (existing page enhanced)
|
||||
- No runtime dependency — downloaded files are standalone
|
||||
- Skills library is a simple list page, not a marketplace
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Register Agent with Archetype (Priority: P1)
|
||||
|
||||
User registers a new agent via web UI, selects an archetype, and gets a downloadable CLAUDE.md and MCP config snippet.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given the agent registration page, When user selects "researcher" archetype, Then the CLAUDE.md download contains researcher-specific instructions.
|
||||
2. Given a registered agent, When user clicks "Download CLAUDE.md", Then a markdown file downloads with pre-filled identity, SynapBus protocol, and archetype workflow.
|
||||
3. Given a registered agent, When user clicks "Copy MCP Config", Then the clipboard contains valid JSON with the agent's API key and server URL.
|
||||
|
||||
### User Story 2 - CLAUDE.md Generator API (Priority: P1)
|
||||
|
||||
GET /api/agents/{name}/claude-md returns a generated CLAUDE.md for the agent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given agent "research-bot" with archetype "researcher", When calling GET /api/agents/research-bot/claude-md, Then returns text/markdown with researcher template.
|
||||
2. Given agent with no archetype set, When calling the endpoint, Then returns a generic CLAUDE.md with protocol instructions.
|
||||
|
||||
### User Story 3 - Skills Library (Priority: P2)
|
||||
|
||||
Web UI page listing available skills with download buttons.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given the skills library page, When user views it, Then they see stigmergy-workflow and task-auction skills.
|
||||
2. Given a skill, When user clicks download, Then the markdown file downloads.
|
||||
|
||||
### User Story 4 - Quick Start Guide (Priority: P2)
|
||||
|
||||
After agent registration, show a 3-step quick start guide inline.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
1. Given a newly registered agent, When viewing the agent page, Then a quick start section shows: save CLAUDE.md, add MCP config, run /loop command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **FR-001**: System MUST allow selecting an archetype when registering an agent
|
||||
- **FR-002**: System MUST generate a CLAUDE.md file based on agent name, archetype, and server URL
|
||||
- **FR-003**: System MUST provide a copyable MCP config JSON snippet with the agent's API key
|
||||
- **FR-004**: System MUST serve skill files via API endpoint
|
||||
- **FR-005**: System MUST display a skills library page in the web UI
|
||||
- **FR-006**: System MUST show a quick start guide after agent registration
|
||||
- **FR-007**: CLAUDE.md templates MUST include: startup loop, reactions workflow, trust awareness, channel guide
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **SC-001**: User can go from zero to a working agent loop in under 5 minutes
|
||||
- **SC-002**: Downloaded CLAUDE.md is immediately usable without editing
|
||||
- **SC-003**: MCP config snippet is valid JSON that works with Claude Code settings
|
||||
@@ -0,0 +1,90 @@
|
||||
# Autonomous Implementation Summary: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Branch**: `013-linkedin-approval-workflow`
|
||||
**Date**: 2026-03-22
|
||||
**Status**: Implementation complete, end-to-end tested
|
||||
|
||||
## What Was Built
|
||||
|
||||
### SynapBus (this repo) — 4 MCP Tool Fixes
|
||||
|
||||
1. **`callReact` now returns `workflow_state`** — After toggle, response includes `workflow_state` and full `reactions` list. Previously only returned action/message_id/reaction.
|
||||
- Files: `internal/mcp/bridge.go`
|
||||
- Tests: `internal/mcp/bridge_test.go` (TestBridge_React_WorkflowState, TestBridge_React_Toggle_Removes_WorkflowState)
|
||||
|
||||
2. **`list_by_state` properly filters by computed state** — Fixed bug where a message with both `approve` and `reject` reactions appeared in both states. Now batch-fetches reactions and verifies with `ComputeWorkflowState()`.
|
||||
- Files: `internal/reactions/service.go`
|
||||
- Tests: `internal/reactions/service_test.go` (TestService_ListByState_FiltersCorrectly, TestService_ListByState_EmptyChannel)
|
||||
|
||||
3. **`list_by_state` supports `include_messages`** — Optional boolean parameter returns full message bodies alongside IDs, eliminating N+1 queries for agents.
|
||||
- Files: `internal/mcp/bridge.go`, `internal/actions/registry.go`
|
||||
|
||||
4. **New `get_replies` MCP tool** — Agents can now fetch thread replies via MCP. Registered as both a direct MCP tool and an execute bridge action.
|
||||
- Files: `internal/mcp/bridge.go`, `internal/mcp/tools_hybrid.go`, `internal/actions/registry.go`, `internal/actions/registry_test.go`
|
||||
- Tests: `internal/mcp/tools_test.go` (TestHybridTool_GetReplies — 5 subtests), `tests/integration/mcp_e2e_test.go`
|
||||
|
||||
### SynapBus Deployment
|
||||
|
||||
- Built and deployed `v0.12.0-013` to kubic (MicroK8s)
|
||||
- Created `#approve-linkedin-comment` channel with `workflow_enabled=true`
|
||||
- Verified reactions, state transitions, threading all work via live MCP calls
|
||||
|
||||
### Searcher Project (~/repos/searcher) — 3 Agent Changes
|
||||
|
||||
5. **Social commenter redirected to `#approve-linkedin-comment`** — Changed from `#approvals` channel, added structured metadata (target_url, comment_type, score, platform). Updated message format with emoji reaction hints.
|
||||
- Files: `agents/social-commenter/src/social_commenter/agent.py`, `agents/social-commenter/src/social_commenter/generator.py`
|
||||
|
||||
6. **Feedback reflection module** — New module reads approved/rejected/edited feedback from SynapBus, generates reflection summaries, updates CLAUDE.md, and commits to git.
|
||||
- Files: `agents/social-commenter/src/social_commenter/feedback.py` (new), `agents/social-commenter/src/social_commenter/main.py` (integrated), `agents/social-commenter/CLAUDE.md` (new)
|
||||
|
||||
7. **LinkedIn posting agent** — New agent that queries approved messages, checks threads for human edits, posts to LinkedIn via Chrome/Playwright MCP, and reacts with published/done.
|
||||
- Files: `agents/linkedin-poster/` (new directory with `agent.py`, `main.py`, `CLAUDE.md`, `pyproject.toml`)
|
||||
- K8s: Updated `k8s/synapbus/agent-cronjobs.yaml` with linkedin-poster CronJob
|
||||
|
||||
## End-to-End Test Results
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Post draft to #approve-linkedin-comment | PASS — Message 5573 created in "proposed" state |
|
||||
| list_by_state returns proposed messages with content | PASS |
|
||||
| Human adds thread edit (reply_to) | PASS — Message 5577 linked as reply |
|
||||
| get_replies returns thread edits | PASS — 1 reply returned |
|
||||
| Human approves via reaction | PASS — State → "approved", workflow_state in response |
|
||||
| list_by_state("approved") returns only approved | PASS — Only msg 5573 |
|
||||
| Human rejects second message | PASS — State → "rejected", trust decreased |
|
||||
| Rejection feedback in thread | PASS — Reply 5579 with rejection reason |
|
||||
| list_by_state("rejected") returns only rejected | PASS — Only msg 5578 |
|
||||
| State filtering correctness (no cross-contamination) | PASS |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The `reply_to` parameter doesn't work through the `execute` tool's JS evaluator for `send_channel_message` (the value gets parsed differently). Use the direct `send_message` MCP tool with `reply_to` instead.
|
||||
- LinkedIn posting agent requires Chrome/Playwright MCP running locally (not available on kubic K8s pods without VNC/browser setup).
|
||||
- Feedback reflection uses a simple state file (`.feedback_state.json`) to track last processed message ID — not persistent across container restarts without PVC.
|
||||
|
||||
## Files Changed (SynapBus)
|
||||
|
||||
```
|
||||
internal/mcp/bridge.go — callReact fix, list_by_state enhance, get_replies dispatch
|
||||
internal/mcp/bridge_test.go — React workflow_state tests
|
||||
internal/mcp/tools_hybrid.go — get_replies tool definition + handler
|
||||
internal/mcp/tools_test.go — get_replies tests
|
||||
internal/reactions/service.go — ListByState proper filtering
|
||||
internal/reactions/service_test.go — Filtering correctness tests (new)
|
||||
internal/actions/registry.go — get_replies action, list_by_state include_messages param
|
||||
internal/actions/registry_test.go — Updated action counts
|
||||
tests/integration/mcp_e2e_test.go — Updated tool count expectations
|
||||
specs/013-linkedin-approval-workflow/ — Spec, plan, research, data-model, checklists
|
||||
```
|
||||
|
||||
## Files Changed (Searcher)
|
||||
|
||||
```
|
||||
agents/social-commenter/src/social_commenter/agent.py — Channel redirect + metadata
|
||||
agents/social-commenter/src/social_commenter/generator.py — Message format update
|
||||
agents/social-commenter/src/social_commenter/feedback.py — New: feedback reflection
|
||||
agents/social-commenter/src/social_commenter/main.py — Integrated feedback call
|
||||
agents/social-commenter/CLAUDE.md — New: agent config
|
||||
agents/linkedin-poster/ — New: entire posting agent
|
||||
k8s/synapbus/agent-cronjobs.yaml — New: linkedin-poster CronJob
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.plan`.
|
||||
- Assumptions section documents all reasonable defaults chosen for ambiguous areas.
|
||||
- Chrome/Playwright and MCP are referenced as capability descriptions, not implementation prescriptions.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Data Model: LinkedIn Comment Approval Workflow
|
||||
|
||||
## Existing Entities (SynapBus - no changes needed)
|
||||
|
||||
### Message (messages table)
|
||||
Already supports: channel_id, from_agent, body, reply_to (threading), created_at, metadata.
|
||||
Workflow state is derived from reactions, not stored.
|
||||
|
||||
### Reaction (message_reactions table)
|
||||
Already supports: message_id, agent_name, reaction type, metadata, created_at.
|
||||
Types: approve, reject, in_progress, done, published.
|
||||
|
||||
### Channel (channels table)
|
||||
Already supports: workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after.
|
||||
|
||||
### Trust Score (agent_trust table)
|
||||
Already supports: agent_name, action_type, score, adjustments_count.
|
||||
|
||||
## New Entity: Comment Draft Message Format
|
||||
|
||||
Messages posted to `#approve-linkedin-comment` follow this structured format:
|
||||
|
||||
```
|
||||
**Comment Draft** — LinkedIn
|
||||
**Target**: [URL]
|
||||
**Opportunity**: [title] ([platform])
|
||||
**Score**: [0.0-1.0] | **Type**: [comment_type]
|
||||
|
||||
---
|
||||
[comment text]
|
||||
---
|
||||
|
||||
React: ✅ approve | ❌ reject | Reply with edits before approving.
|
||||
```
|
||||
|
||||
**Metadata** (JSON, stored in message metadata field):
|
||||
```json
|
||||
{
|
||||
"target_url": "https://linkedin.com/posts/...",
|
||||
"comment_type": "technical_insight",
|
||||
"score": 0.85,
|
||||
"opportunity_id": 123,
|
||||
"platform": "linkedin"
|
||||
}
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
proposed → approved → in_progress → published
|
||||
↓ ↓
|
||||
rejected rejected
|
||||
```
|
||||
|
||||
- **proposed**: No reactions (initial state when social commenter posts)
|
||||
- **approved**: Human clicked approve reaction
|
||||
- **rejected**: Human clicked reject reaction
|
||||
- **in_progress**: Posting agent claimed the message for posting
|
||||
- **published**: Posting agent successfully posted and reacted with published
|
||||
|
||||
## Thread Structure for Edits
|
||||
|
||||
```
|
||||
Message (comment draft) — proposed/approved state
|
||||
└── Reply (human edit) — "Use this text instead: ..."
|
||||
└── Reply (human feedback) — "Tone is too promotional"
|
||||
└── Reply (posting agent) — "Published: [URL]"
|
||||
```
|
||||
|
||||
The posting agent checks for thread replies from human agents before posting. If a human reply contains edited text, that text is used instead of the original.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Implementation Plan: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Branch**: `013-linkedin-approval-workflow` | **Date**: 2026-03-22 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/013-linkedin-approval-workflow/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
End-to-end approval pipeline: social commenter generates LinkedIn comments → posts to `#approve-linkedin-comment` SynapBus channel → human approves/rejects via Web UI reactions → posting agent publishes approved comments to LinkedIn via browser automation → social commenter reflects on feedback and updates its CLAUDE.md/skills. This feature requires fixes to SynapBus MCP tools (react response, list_by_state filtering, get_replies tool) and new agent code in the searcher project.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (SynapBus), Python 3.12 (Searcher agents)
|
||||
**Primary Dependencies**: go-chi/chi, mark3labs/mcp-go, ory/fosite (SynapBus); claude-agent-sdk, httpx, psycopg (Searcher)
|
||||
**Storage**: SQLite via modernc.org/sqlite (SynapBus); PostgreSQL (Searcher)
|
||||
**Testing**: `go test ./...` (SynapBus); `uv run pytest` (Searcher)
|
||||
**Target Platform**: linux/amd64 (K8s on kubic), darwin/arm64 (local dev)
|
||||
**Project Type**: Cross-project: web-service (SynapBus) + agent scripts (Searcher)
|
||||
**Performance Goals**: <30s for message submission, <10min for posting cycle
|
||||
**Constraints**: Zero CGO, single binary (SynapBus); browser session required for LinkedIn posting
|
||||
**Scale/Scope**: ~10 comments/day, 1 approval channel, 2 agents
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | No new external dependencies |
|
||||
| II. MCP-Native | PASS | All agent interactions via MCP tools |
|
||||
| III. Pure Go, Zero CGO | PASS | No new Go dependencies |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Agents have owners, reactions track agent identity |
|
||||
| V. Embedded OAuth 2.1 | PASS | N/A - no auth changes |
|
||||
| VI. Semantic-Ready Storage | PASS | N/A - no search changes |
|
||||
| VII. Swarm Intelligence Patterns | PASS | Using workflow channels (designed for this) |
|
||||
| VIII. Observable by Default | PASS | Reactions traced, trust adjusted, workflow states logged |
|
||||
| IX. Progressive Complexity | PASS | Workflow is opt-in per channel |
|
||||
| X. Web UI as First-Class Citizen | PASS | Reactions already work in Web UI |
|
||||
|
||||
**Result**: All gates pass. No violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/013-linkedin-approval-workflow/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output (MCP tool contracts)
|
||||
└── tasks.md # Phase 2 output
|
||||
```
|
||||
|
||||
### Source Code (SynapBus - this repo)
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── mcp/bridge.go # FIX: react response, list_by_state, add get_replies
|
||||
├── reactions/store.go # FIX: list_by_state filtering by computed state
|
||||
└── mcp/tools_hybrid.go # ADD: get_replies tool definition
|
||||
```
|
||||
|
||||
### Source Code (Searcher - ~/repos/searcher)
|
||||
|
||||
```text
|
||||
agents/social-commenter/
|
||||
├── src/social_commenter/
|
||||
│ ├── agent.py # MODIFY: post to #approve-linkedin-comment
|
||||
│ ├── feedback.py # NEW: feedback reflection logic
|
||||
│ └── generator.py # MINOR: format_approval_message update
|
||||
├── CLAUDE.md # NEW: agent-maintained config (learning target)
|
||||
└── .claude/skills/ # NEW: agent-learned skills
|
||||
|
||||
agents/linkedin-poster/
|
||||
├── src/linkedin_poster/
|
||||
│ ├── __init__.py # NEW
|
||||
│ ├── main.py # NEW: CLI entry point
|
||||
│ └── agent.py # NEW: read approved msgs, post via browser
|
||||
├── CLAUDE.md # NEW: posting agent config
|
||||
└── pyproject.toml # NEW
|
||||
|
||||
k8s/synapbus/
|
||||
└── agent-cronjobs.yaml # MODIFY: add linkedin-poster cronjob
|
||||
```
|
||||
|
||||
**Structure Decision**: Cross-project changes. SynapBus gets MCP tool fixes (3 files). Searcher gets a new `linkedin-poster` agent directory and social-commenter modifications. The posting agent is separated from the existing `engagement/` module to keep it SynapBus-native (reads from channel, not from PostgreSQL).
|
||||
|
||||
## Research Findings
|
||||
|
||||
### 1. SynapBus MCP Tool Issues (Confirmed via code review)
|
||||
|
||||
**Issue A: `react` MCP tool missing `workflow_state` in response**
|
||||
- File: `internal/mcp/bridge.go:975-984`
|
||||
- The REST API handler (`reactions_handler.go`) correctly returns workflow_state and full reactions
|
||||
- But the MCP bridge `callReact()` only returns action, message_id, reaction, id, created_at
|
||||
- Fix: After toggle, call `GetReactions()` and include `workflow_state` + `reactions` in response
|
||||
|
||||
**Issue B: `list_by_state` returns only message IDs**
|
||||
- File: `internal/mcp/bridge.go:1035-1073`
|
||||
- Agents must make N+1 calls to get message content
|
||||
- Fix: Add optional `include_messages=true` parameter that returns full message bodies
|
||||
|
||||
**Issue C: `list_by_state` doesn't properly filter by computed state**
|
||||
- File: `internal/reactions/store.go:143-174`
|
||||
- Comment on line 168: "filter in app layer" — but app layer filtering doesn't happen
|
||||
- A message with both `approve` and `reject` reactions appears in both states
|
||||
- Fix: Fetch candidates then verify with `ComputeWorkflowState()` in the service layer
|
||||
|
||||
**Issue D: No `get_replies` MCP tool**
|
||||
- Agents can't read message threads via MCP
|
||||
- REST API has it at `/api/messages/{id}/replies`
|
||||
- Fix: Add `get_replies` action to MCP bridge
|
||||
|
||||
### 2. Searcher Agent Architecture
|
||||
|
||||
**Social commenter current flow:**
|
||||
1. Reads opportunities from PostgreSQL
|
||||
2. Scores and evaluates via Claude
|
||||
3. Generates comments
|
||||
4. Posts to `#approvals` channel via `_mcp_send_channel()`
|
||||
5. Posts run summary to `#general`
|
||||
|
||||
**Changes needed:**
|
||||
- Redirect to `#approve-linkedin-comment` (channel name change)
|
||||
- Add feedback reflection at start of each run
|
||||
- Load CLAUDE.md from git, update it, commit
|
||||
|
||||
**Posting agent (new):**
|
||||
- Queries `list_by_state(channel="approve-linkedin-comment", state="approved")`
|
||||
- For each approved message: extract URL + comment, check thread for edits
|
||||
- Post via Chrome/Playwright MCP (existing pattern in `engagement/linkedin/poster.py`)
|
||||
- React with "published" on success or "rejected" on failure
|
||||
|
||||
### 3. Agent Configuration Management
|
||||
|
||||
**Decision**: Store CLAUDE.md and .claude/skills under each agent's directory in the searcher repo.
|
||||
**Rationale**: Git provides versioning, agents can commit changes, changes are auditable.
|
||||
**Alternative rejected**: Storing in SynapBus (adds complexity, not version-controlled).
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: SynapBus MCP Tool Fixes (this repo)
|
||||
|
||||
**1A. Fix `callReact` to return workflow_state**
|
||||
- Edit `internal/mcp/bridge.go:callReact()`
|
||||
- After toggle, call `GetReactions()` to get current state and reactions
|
||||
- Return `workflow_state` and `reactions` in response
|
||||
- Test: `go test ./internal/mcp/ -run TestReactReturnsWorkflowState`
|
||||
|
||||
**1B. Fix `list_by_state` filtering**
|
||||
- Edit `internal/reactions/store.go:GetMessageIDsByState()`
|
||||
- For non-proposed states: fetch candidate IDs, then verify each with `ComputeWorkflowState()`
|
||||
- Alternatively: do the filtering in `Service.ListByState()` after fetching candidates
|
||||
- Test: `go test ./internal/reactions/ -run TestListByStateFiltersCorrectly`
|
||||
|
||||
**1C. Enhance `list_by_state` to include message content**
|
||||
- Edit `internal/mcp/bridge.go:callListByState()`
|
||||
- Add optional `include_messages` boolean parameter
|
||||
- When true, fetch full messages for the returned IDs
|
||||
- Test: `go test ./internal/mcp/ -run TestListByStateIncludesMessages`
|
||||
|
||||
**1D. Add `get_replies` MCP tool**
|
||||
- Add `case "get_replies"` in bridge.go dispatch
|
||||
- Implement `callGetReplies()` using existing `store.GetReplies()`
|
||||
- Register tool in `tools_hybrid.go`
|
||||
- Test: `go test ./internal/mcp/ -run TestGetReplies`
|
||||
|
||||
### Phase 2: Create Approval Channel (operational)
|
||||
|
||||
- Create `#approve-linkedin-comment` channel via admin CLI
|
||||
- Enable workflow: `kubectl exec -n synapbus deploy/synapbus -- /synapbus channel update approve-linkedin-comment --workflow-enabled`
|
||||
- Verify via Web UI
|
||||
|
||||
### Phase 3: Social Commenter Changes (searcher repo)
|
||||
|
||||
**3A. Redirect to new channel**
|
||||
- Edit `agent.py:_submit_to_approvals()` to post to `approve-linkedin-comment`
|
||||
- Update `synapbus_client.py:build_approval_message()` format if needed
|
||||
|
||||
**3B. Add feedback reflection module**
|
||||
- Create `agents/social-commenter/src/social_commenter/feedback.py`
|
||||
- `read_feedback()`: Query list_by_state for approved/rejected messages since last run
|
||||
- `reflect_on_feedback()`: Use Claude to analyze patterns in approved vs rejected
|
||||
- `update_agent_config()`: Modify CLAUDE.md and .claude/skills based on reflection
|
||||
- `commit_and_push()`: Git commit + push changes
|
||||
- Integrate into main.py startup sequence (before generating new comments)
|
||||
|
||||
**3C. Create agent CLAUDE.md**
|
||||
- Create `agents/social-commenter/CLAUDE.md` with initial writing rules
|
||||
- Create `agents/social-commenter/.claude/skills/` with initial skills
|
||||
|
||||
### Phase 4: LinkedIn Posting Agent (searcher repo)
|
||||
|
||||
**4A. Create posting agent**
|
||||
- New `agents/linkedin-poster/` directory with `main.py`, `agent.py`
|
||||
- Connect to SynapBus, query approved messages
|
||||
- For each: extract URL/comment, check thread for edits
|
||||
- Post via Claude Agent SDK + Chrome/Playwright MCP
|
||||
- React with published/rejected on SynapBus
|
||||
|
||||
**4B. K8s deployment**
|
||||
- Add linkedin-poster CronJob to `k8s/synapbus/agent-cronjobs.yaml`
|
||||
- Register agent in SynapBus with API key
|
||||
- Docker image update to include new agent
|
||||
|
||||
### Phase 5: End-to-End Testing
|
||||
|
||||
- Run social commenter (local or K8s)
|
||||
- Verify message appears in Web UI
|
||||
- Approve/reject via Web UI reactions
|
||||
- Run posting agent
|
||||
- Verify LinkedIn comment posted
|
||||
- Run social commenter again
|
||||
- Verify CLAUDE.md updated and committed
|
||||
@@ -0,0 +1,31 @@
|
||||
# Research: LinkedIn Comment Approval Workflow
|
||||
|
||||
## Decision 1: SynapBus MCP Tool Fixes
|
||||
|
||||
**Decision**: Fix 4 issues in SynapBus MCP bridge before building agent workflow.
|
||||
**Rationale**: Agents need reliable MCP tools. Current bugs (missing workflow_state in react response, incorrect list_by_state filtering) would cause agent failures.
|
||||
**Alternatives considered**: Working around bugs in agent code (rejected: fragile, defeats MCP-native principle).
|
||||
|
||||
## Decision 2: Posting Agent Architecture
|
||||
|
||||
**Decision**: Create a new standalone `linkedin-poster` agent in the searcher repo that reads approved messages from SynapBus (not from PostgreSQL).
|
||||
**Rationale**: SynapBus is the source of truth for the approval workflow. Reading from SynapBus makes the agent independent of the DB and consistent with the MCP-native approach.
|
||||
**Alternatives considered**: Extending existing `engagement/` posting module to read from SynapBus (rejected: tightly coupled to PostgreSQL schema, would require dual-path logic).
|
||||
|
||||
## Decision 3: Agent Configuration Management
|
||||
|
||||
**Decision**: Store CLAUDE.md and .claude/skills in the searcher git repo under each agent's directory.
|
||||
**Rationale**: Git provides version history, auditable changes, and agents can commit via `gh` CLI.
|
||||
**Alternatives considered**: Storing config in SynapBus messages (rejected: not version-controlled). Storing in a separate repo (rejected: adds complexity).
|
||||
|
||||
## Decision 4: Feedback Reflection Approach
|
||||
|
||||
**Decision**: Use Claude Agent SDK to reflect on approved/rejected comments, generate writing rules, and update CLAUDE.md.
|
||||
**Rationale**: Claude can analyze patterns in human feedback (what was approved, what was rejected, what was edited) and derive actionable rules.
|
||||
**Alternatives considered**: Rule-based pattern extraction (rejected: too rigid, can't understand nuanced feedback).
|
||||
|
||||
## Decision 5: Channel Name
|
||||
|
||||
**Decision**: `approve-linkedin-comment` (not `approvals`, not `approve-comments`).
|
||||
**Rationale**: Platform-specific channels allow different workflow settings per platform. Future channels: `approve-hn-comment`, `approve-reddit-comment`.
|
||||
**Alternatives considered**: Reusing `#approvals` (rejected: mixes LinkedIn with other content types, can't set LinkedIn-specific workflow settings).
|
||||
@@ -0,0 +1,151 @@
|
||||
# Feature Specification: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Feature Branch**: `013-linkedin-approval-workflow`
|
||||
**Created**: 2026-03-22
|
||||
**Status**: Draft
|
||||
**Input**: End-to-end approval pipeline connecting social commenter agent → SynapBus approval channel with reactions workflow → posting agent. Agents learn from feedback and update their configuration.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Social Commenter Posts Draft for Approval (Priority: P1)
|
||||
|
||||
The social commenter agent generates a LinkedIn comment for a discovered opportunity and submits it to the `#approve-linkedin-comment` channel on SynapBus. The message includes the target post URL, generated comment text, relevance score, and comment type. The channel has workflow enabled so the message starts in "proposed" state.
|
||||
|
||||
**Why this priority**: Without draft submission, nothing downstream can work. This is the entry point of the entire pipeline.
|
||||
|
||||
**Independent Test**: Can be tested by running the social commenter agent and verifying a message appears in `#approve-linkedin-comment` with workflow_state="proposed".
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the social commenter agent has a scored opportunity with score >= 0.6, **When** it generates a comment and submits to SynapBus, **Then** a message appears in `#approve-linkedin-comment` with workflow_state="proposed" containing the target URL, comment text, score, and comment type.
|
||||
2. **Given** a comment was already submitted for a given URL, **When** the agent tries to submit another, **Then** it skips the duplicate.
|
||||
3. **Given** the SynapBus server is unreachable, **When** the agent attempts to submit, **Then** it retries up to 3 times with backoff and logs the failure.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Human Reviews and Approves/Rejects via Web UI (Priority: P1)
|
||||
|
||||
A human owner opens the SynapBus Web UI, navigates to `#approve-linkedin-comment`, sees proposed messages with workflow badges. They can click approve or reject reactions. They can also open a thread on a message and add text edits/feedback before approving.
|
||||
|
||||
**Why this priority**: Human-in-the-loop approval is the core safety mechanism. Without it, no comments get published and no feedback loop exists.
|
||||
|
||||
**Independent Test**: Can be tested by creating a test message in the channel, clicking approve/reject in the Web UI, and verifying the workflow_state transitions correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a message in "proposed" state in `#approve-linkedin-comment`, **When** a human clicks the approve reaction, **Then** workflow_state transitions to "approved" and the agent's trust score increases.
|
||||
2. **Given** a message in "proposed" state, **When** a human clicks the reject reaction, **Then** workflow_state transitions to "rejected" and the agent's trust score decreases.
|
||||
3. **Given** a proposed message, **When** a human opens the thread and adds a reply with edited comment text before approving, **Then** the thread contains the edited text and the message is approved.
|
||||
4. **Given** a message is already approved, **When** a human tries to reject it, **Then** the state transitions to "rejected" (latest reaction wins by priority).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Posting Agent Publishes Approved Comments (Priority: P1)
|
||||
|
||||
The posting agent queries SynapBus for messages in "approved" state on `#approve-linkedin-comment`. For each approved message, it extracts the target LinkedIn URL and comment text (checking thread for edited versions). It uses Chrome/Playwright browser automation to navigate to the LinkedIn post and submit the comment. On success, it reacts with "published" (including the posted URL in metadata).
|
||||
|
||||
**Why this priority**: Publishing is the end goal of the pipeline. Without it, approved comments sit idle.
|
||||
|
||||
**Independent Test**: Can be tested by manually approving a message, running the posting agent, and verifying the comment appears on LinkedIn and the message state transitions to "published".
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an approved message in `#approve-linkedin-comment`, **When** the posting agent runs, **Then** it extracts the LinkedIn URL and comment text, posts via browser automation, and reacts with "published" including the post URL.
|
||||
2. **Given** an approved message with a thread containing edited text from the human, **When** the posting agent runs, **Then** it uses the edited text from the thread instead of the original comment.
|
||||
3. **Given** the posting agent encounters a LinkedIn error (CAPTCHA, session expired, comments disabled), **When** posting fails, **Then** it marks the message as failed with a reason and reports the failure.
|
||||
4. **Given** no approved messages exist, **When** the posting agent runs, **Then** it exits cleanly with no actions taken.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Agent Learns from Feedback (Priority: P2)
|
||||
|
||||
After each run cycle, the social commenter agent reads the approval/rejection outcomes and any edited text from the `#approve-linkedin-comment` channel. For approved comments, it notes what worked. For rejected comments, it analyzes the rejection reason. For edited comments, it compares original vs edited text to understand the human's preferences. It then updates its own CLAUDE.md file and .claude/skills with learned patterns and commits these changes to git.
|
||||
|
||||
**Why this priority**: Learning from feedback makes the system improve over time. Without it, the same mistakes repeat.
|
||||
|
||||
**Independent Test**: Can be tested by approving one comment and rejecting another (with edits), running the social commenter agent, and checking that CLAUDE.md was updated with new rules and committed to git.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 3 approved and 2 rejected comments from the last cycle, **When** the social commenter runs its feedback reflection, **Then** it reads all outcomes, identifies patterns, and updates CLAUDE.md with new writing guidelines.
|
||||
2. **Given** a rejected comment where the human provided edited text in the thread, **When** the agent reflects, **Then** it compares original vs edited text, extracts the diff as a writing rule, and saves it to .claude/skills.
|
||||
3. **Given** the agent updates its CLAUDE.md, **When** the update is complete, **Then** it commits the change to git with a descriptive message and pushes to the repository.
|
||||
4. **Given** no new feedback since last reflection, **When** the agent checks, **Then** it skips the reflection step and proceeds with normal operation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - End-to-End Workflow Verification (Priority: P2)
|
||||
|
||||
The entire pipeline runs end-to-end: social commenter generates a comment, posts to SynapBus, human approves via Web UI, posting agent publishes to LinkedIn, and on next run the social commenter reflects on the feedback. This can be verified by checking agent logs, SynapBus message states, and git commit history.
|
||||
|
||||
**Why this priority**: Integration testing ensures all components work together correctly.
|
||||
|
||||
**Independent Test**: Can be tested by triggering the social commenter, approving in Web UI, running the posting agent, triggering the social commenter again, and verifying CLAUDE.md changes in git.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** all components are deployed, **When** running the full cycle (generate → approve → publish → reflect), **Then** the LinkedIn comment is posted, the message reaches "published" state, and CLAUDE.md contains updated rules.
|
||||
2. **Given** the rejection path, **When** running the full cycle (generate → reject with feedback → reflect), **Then** no comment is posted, the message is "rejected", and CLAUDE.md reflects the feedback.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the SynapBus server is unreachable during agent execution? Agent retries with exponential backoff (3 attempts), then logs failure and exits gracefully.
|
||||
- What happens when a LinkedIn session expires mid-posting? Agent detects session expiry, marks message as failed with reason, and alerts via SynapBus DM to the owner.
|
||||
- What happens when the human edits text but forgets to approve? The stalemate worker sends a reminder after the configured remind period (default 4h).
|
||||
- What happens when two posting agents try to publish the same approved message? The first agent to react with "in_progress" claims it; the second sees the state change and skips.
|
||||
- What happens when the agent's CLAUDE.md update creates a git conflict? Agent pulls latest, attempts auto-merge. If conflict persists, it creates the commit on a separate branch and notifies the owner.
|
||||
- What happens when a comment is approved but the LinkedIn post has been deleted? Agent detects "post not found" error, marks as failed, and notifies via SynapBus.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a `#approve-linkedin-comment` channel with workflow_enabled=true, supporting the state machine: proposed → approved/rejected → in_progress → done/published.
|
||||
- **FR-002**: Social commenter agent MUST post generated LinkedIn comments to `#approve-linkedin-comment` with structured metadata (target_url, comment_text, score, comment_type, opportunity_id).
|
||||
- **FR-003**: Human owners MUST be able to approve or reject proposed comments via reaction buttons in the SynapBus Web UI.
|
||||
- **FR-004**: Human owners MUST be able to add text edits as threaded replies before approving a comment.
|
||||
- **FR-005**: Posting agent MUST query `#approve-linkedin-comment` for messages in "approved" state using the `list_by_state` MCP tool.
|
||||
- **FR-006**: Posting agent MUST check for threaded replies containing edited comment text and use the edited version when present.
|
||||
- **FR-007**: Posting agent MUST publish approved comments to LinkedIn using Chrome/Playwright browser automation via MCP.
|
||||
- **FR-008**: Posting agent MUST react with "published" (including the LinkedIn URL in metadata) after successful posting.
|
||||
- **FR-009**: On approval, the system MUST increase the social commenter agent's trust score. On rejection, it MUST decrease the trust score.
|
||||
- **FR-010**: Social commenter agent MUST read approved/rejected/edited feedback from the channel on each run and reflect on patterns.
|
||||
- **FR-011**: Social commenter agent MUST update its CLAUDE.md and .claude/skills files based on feedback patterns and commit changes to git.
|
||||
- **FR-012**: Both agents MUST load their CLAUDE.md and .claude/skills configuration from the git repository at startup.
|
||||
- **FR-013**: Agents MUST run on Kubernetes (kubic) and be triggerable via CronJob or manual kubectl exec.
|
||||
- **FR-014**: The posting agent MUST handle posting failures gracefully (CAPTCHA, session expired, post deleted) by marking the message as failed with a reason.
|
||||
- **FR-015**: The social commenter MUST deduplicate submissions — no duplicate comments for the same target URL in the channel.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Comment Draft**: A proposed LinkedIn comment with target URL, comment text, score, type, and opportunity reference. Lifecycle: proposed → approved/rejected → published/failed.
|
||||
- **Feedback Record**: An approved/rejected decision with optional edited text, mapped to the original comment draft. Used for agent learning.
|
||||
- **Agent Configuration**: CLAUDE.md and .claude/skills files in the git repository that encode the agent's learned writing rules and preferences.
|
||||
- **Trust Score**: A per-agent metric that increases on approval and decreases on rejection, influencing future behavior thresholds.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A comment draft submitted by the social commenter appears in the approval channel within 30 seconds of generation.
|
||||
- **SC-002**: A human can approve or reject a comment draft in under 3 clicks from the channel view.
|
||||
- **SC-003**: An approved comment is published to LinkedIn within 10 minutes of the posting agent's next run.
|
||||
- **SC-004**: The agent's configuration file is updated with at least one new rule after processing 5+ feedback items.
|
||||
- **SC-005**: 100% of approved comments reach "published" state or have a documented failure reason.
|
||||
- **SC-006**: Trust scores reflect approval patterns — agents with >80% approval rate have increasing trust over time.
|
||||
- **SC-007**: The full cycle (generate → approve → publish → reflect) completes end-to-end without manual intervention beyond the approval step.
|
||||
- **SC-008**: Agent configuration changes are committed to git with descriptive messages traceable to specific feedback.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The `#approve-linkedin-comment` channel will be created by the system owner (algis) or via admin CLI, not auto-created by agents (agents don't have channel creation permissions).
|
||||
- LinkedIn authentication is handled via persistent browser sessions managed outside the agent (pre-logged-in Chrome profile).
|
||||
- The Chrome/Playwright MCP server runs locally on the machine where the posting agent executes, or is accessible via network MCP.
|
||||
- Agent CLAUDE.md and .claude/skills are stored in the searcher git repository under each agent's directory.
|
||||
- The posting agent uses the existing Playwriter MCP integration pattern already established in the searcher project.
|
||||
- Only LinkedIn platform is in scope for this feature; other platforms (Reddit, HN, etc.) are excluded.
|
||||
- The social commenter currently posts to `#approvals` — this feature redirects to `#approve-linkedin-comment` for LinkedIn-specific workflow.
|
||||
- Feedback reflection happens at the start of each social commenter run, before generating new comments.
|
||||
- Git operations (commit, push) are performed by the agent using `gh` CLI or git commands available in the container.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Reactive Agent Triggering System
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec references K8s Jobs and env vars as these are domain terms (the deployment target), not implementation choices.
|
||||
- Assumptions section documents all design decisions from brainstorming including rate limit defaults, trigger events scope, and coalescing behavior.
|
||||
- 10 user stories covering P1 (core trigger + rate limiting), P2 (visibility + admin), P3 (future-proofing).
|
||||
- 20 functional requirements, 9 success criteria, 7 edge cases.
|
||||
@@ -0,0 +1,108 @@
|
||||
# CLI Command Contracts: Reactive Agent Triggering
|
||||
|
||||
## Agent Trigger Configuration
|
||||
|
||||
### synapbus agent set-triggers
|
||||
|
||||
Configure reactive trigger settings for an agent.
|
||||
|
||||
```bash
|
||||
synapbus agent set-triggers <agent-name> [flags]
|
||||
```
|
||||
|
||||
**Flags**:
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--mode` | string | - | Trigger mode: `passive`, `reactive`, `disabled` |
|
||||
| `--cooldown` | int | 600 | Cooldown seconds between runs |
|
||||
| `--daily-budget` | int | 8 | Max runs per UTC day |
|
||||
| `--max-depth` | int | 5 | Max cascade depth |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
synapbus agent set-triggers research-mcpproxy \
|
||||
--mode reactive --cooldown 600 --daily-budget 8 --max-depth 5
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```
|
||||
Updated trigger config for research-mcpproxy:
|
||||
mode: reactive
|
||||
cooldown: 600s
|
||||
daily budget: 8
|
||||
max depth: 5
|
||||
```
|
||||
|
||||
### synapbus agent set-image
|
||||
|
||||
Set the K8s container image and env vars for reactive runs.
|
||||
|
||||
```bash
|
||||
synapbus agent set-image <agent-name> [flags]
|
||||
```
|
||||
|
||||
**Flags**:
|
||||
| Flag | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `--image` | string | Container image (required) |
|
||||
| `--env` | string[] | Plain env var: KEY=VALUE (repeatable) |
|
||||
| `--secret-env` | string[] | Secret ref: KEY=secret-name:key-name (repeatable) |
|
||||
| `--resource-preset` | string | `default` or `large` |
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
synapbus agent set-image research-mcpproxy \
|
||||
--image localhost:32000/universal-agent:latest \
|
||||
--env AGENT_GIT_REPO=Dumbris/agent-research-mcpproxy \
|
||||
--secret-env SYNAPBUS_API_KEY=synapbus-agent-keys:RESEARCH_MCPPROXY_API_KEY \
|
||||
--resource-preset default
|
||||
```
|
||||
|
||||
## Run Management
|
||||
|
||||
### synapbus runs list
|
||||
|
||||
List recent reactive runs.
|
||||
|
||||
```bash
|
||||
synapbus runs list [flags]
|
||||
```
|
||||
|
||||
**Flags**:
|
||||
| Flag | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `--agent` | string | - | Filter by agent name |
|
||||
| `--status` | string | - | Filter by status |
|
||||
| `--limit` | int | 20 | Max results |
|
||||
|
||||
**Output**:
|
||||
```
|
||||
ID AGENT STATUS TRIGGER DURATION CREATED
|
||||
1 research-mcpproxy succeeded DM from algis 3m42s 2026-03-25 10:00
|
||||
2 social-commenter failed @mention in #news 0m45s 2026-03-25 10:15
|
||||
3 research-synapbus running DM from algis - 2026-03-25 10:30
|
||||
```
|
||||
|
||||
### synapbus runs logs
|
||||
|
||||
View error logs for a specific run.
|
||||
|
||||
```bash
|
||||
synapbus runs logs <run-id>
|
||||
```
|
||||
|
||||
**Output**: Last 100 lines of pod logs for the run.
|
||||
|
||||
### synapbus runs retry
|
||||
|
||||
Retry a failed run.
|
||||
|
||||
```bash
|
||||
synapbus runs retry <run-id>
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```
|
||||
Retrying run 2 for social-commenter...
|
||||
New run ID: 4, status: running
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# MCP Tool Contracts: Reactive Agent Triggering
|
||||
|
||||
**Note**: These are owner-only admin tools, not agent-callable tools (Constitution Principle IV).
|
||||
|
||||
## configure_triggers
|
||||
|
||||
Configure reactive trigger settings for an agent.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"agent_name": "research-mcpproxy",
|
||||
"trigger_mode": "reactive",
|
||||
"cooldown_seconds": 600,
|
||||
"daily_trigger_budget": 8,
|
||||
"max_trigger_depth": 5
|
||||
}
|
||||
```
|
||||
|
||||
All fields except `agent_name` are optional — only provided fields are updated.
|
||||
|
||||
**Returns**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"agent_name": "research-mcpproxy",
|
||||
"trigger_mode": "reactive",
|
||||
"cooldown_seconds": 600,
|
||||
"daily_trigger_budget": 8,
|
||||
"max_trigger_depth": 5
|
||||
}
|
||||
```
|
||||
|
||||
## set_agent_image
|
||||
|
||||
Set the K8s container image and environment variables for reactive runs.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"agent_name": "research-mcpproxy",
|
||||
"k8s_image": "localhost:32000/universal-agent:latest",
|
||||
"k8s_env_json": {
|
||||
"AGENT_GIT_REPO": "Dumbris/agent-research-mcpproxy",
|
||||
"SYNAPBUS_API_KEY": {"secretRef": "synapbus-agent-keys", "key": "RESEARCH_MCPPROXY_API_KEY"}
|
||||
},
|
||||
"k8s_resource_preset": "default"
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"agent_name": "research-mcpproxy",
|
||||
"k8s_image": "localhost:32000/universal-agent:latest"
|
||||
}
|
||||
```
|
||||
|
||||
## list_runs
|
||||
|
||||
List recent reactive runs for an agent.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"agent_name": "research-mcpproxy",
|
||||
"status": "failed",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
All fields optional. Without `agent_name`, lists all agents' runs.
|
||||
|
||||
**Returns**:
|
||||
```json
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"id": 1,
|
||||
"agent_name": "research-mcpproxy",
|
||||
"trigger_event": "message.received",
|
||||
"trigger_from": "algis",
|
||||
"status": "failed",
|
||||
"duration_ms": 45000,
|
||||
"error_log": "Exit code 1 — OOMKilled...",
|
||||
"created_at": "2026-03-25T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## get_run_logs
|
||||
|
||||
Get full error log for a specific run.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"run_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```json
|
||||
{
|
||||
"run_id": 1,
|
||||
"agent_name": "research-mcpproxy",
|
||||
"status": "failed",
|
||||
"error_log": "... last 100 lines of pod logs ..."
|
||||
}
|
||||
```
|
||||
|
||||
## retry_run
|
||||
|
||||
Retry a failed run.
|
||||
|
||||
**Parameters**:
|
||||
```json
|
||||
{
|
||||
"run_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```json
|
||||
{
|
||||
"new_run_id": 43,
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# REST API Contracts: Reactive Agent Triggering
|
||||
|
||||
**Note**: REST API is for the embedded Web UI only (Constitution Principle II). Agents use MCP tools.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/runs
|
||||
|
||||
List reactive runs with optional filters.
|
||||
|
||||
**Query Parameters**:
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `agent` | string | No | Filter by agent name |
|
||||
| `status` | string | No | Filter by status (comma-separated) |
|
||||
| `limit` | int | No | Max results (default: 50, max: 200) |
|
||||
| `offset` | int | No | Pagination offset |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"id": 1,
|
||||
"agent_name": "research-mcpproxy",
|
||||
"trigger_message_id": 12345,
|
||||
"trigger_event": "message.received",
|
||||
"trigger_depth": 0,
|
||||
"trigger_from": "algis",
|
||||
"status": "succeeded",
|
||||
"k8s_job_name": "reactive-research-mcpproxy-1",
|
||||
"started_at": "2026-03-25T10:00:00Z",
|
||||
"completed_at": "2026-03-25T10:03:42Z",
|
||||
"duration_ms": 222000,
|
||||
"error_log": null,
|
||||
"created_at": "2026-03-25T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 42
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/runs/:id
|
||||
|
||||
Get a single run with full details including error log.
|
||||
|
||||
**Response** (200): Single run object (same as above).
|
||||
|
||||
### POST /api/runs/:id/retry
|
||||
|
||||
Retry a failed run. Creates a new trigger evaluation for the same agent.
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"new_run_id": 43,
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (429 — rate limited):
|
||||
```json
|
||||
{
|
||||
"error": "cooldown_active",
|
||||
"cooldown_remaining_seconds": 342
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/agents/reactive
|
||||
|
||||
List agents with reactive trigger configuration and current status.
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "research-mcpproxy",
|
||||
"trigger_mode": "reactive",
|
||||
"cooldown_seconds": 600,
|
||||
"daily_trigger_budget": 8,
|
||||
"max_trigger_depth": 5,
|
||||
"k8s_image": "localhost:32000/universal-agent:latest",
|
||||
"pending_work": false,
|
||||
"state": "idle",
|
||||
"today_runs": 3,
|
||||
"cooldown_until": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`state` values**: `idle`, `running`, `queued` (pending_work set), `cooldown`, `budget_exhausted`
|
||||
@@ -0,0 +1,127 @@
|
||||
# Data Model: Reactive Agent Triggering System
|
||||
|
||||
**Feature**: 014-reactive-agent-triggers
|
||||
**Date**: 2026-03-25
|
||||
|
||||
## Entity Changes
|
||||
|
||||
### Agent (extended)
|
||||
|
||||
Existing `agents` table gains new columns for reactive trigger configuration.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `trigger_mode` | TEXT | `'passive'` | `passive` (polls only), `reactive` (auto-triggered), `disabled` (no triggers) |
|
||||
| `cooldown_seconds` | INTEGER | `600` | Minimum seconds between reactive runs |
|
||||
| `daily_trigger_budget` | INTEGER | `8` | Max reactive runs per UTC calendar day |
|
||||
| `max_trigger_depth` | INTEGER | `5` | Max agent-to-agent cascade depth |
|
||||
| `k8s_image` | TEXT | `NULL` | Container image for reactive K8s Jobs |
|
||||
| `k8s_env_json` | TEXT | `NULL` | JSON object of env vars (plain + secret refs) |
|
||||
| `k8s_resource_preset` | TEXT | `'default'` | Resource limits: `default` (256Mi/100m) or `large` (2Gi/1CPU) |
|
||||
| `pending_work` | BOOLEAN | `0` | True if triggers arrived while agent was busy |
|
||||
|
||||
### Reactive Run (new)
|
||||
|
||||
New `reactive_runs` table tracking every trigger evaluation.
|
||||
|
||||
| Field | Type | Nullable | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | INTEGER PK | No | Auto-increment |
|
||||
| `agent_name` | TEXT FK | No | References `agents(name)` |
|
||||
| `trigger_message_id` | INTEGER FK | Yes | References `messages(id)` — the message that caused the trigger |
|
||||
| `trigger_event` | TEXT | No | `message.received` or `message.mentioned` |
|
||||
| `trigger_depth` | INTEGER | No | Depth in the cascade chain (0 = human-initiated) |
|
||||
| `trigger_from` | TEXT | Yes | Agent/user who sent the trigger message |
|
||||
| `status` | TEXT | No | `queued`, `running`, `succeeded`, `failed`, `cooldown_skipped`, `budget_exhausted`, `depth_exceeded` |
|
||||
| `k8s_job_name` | TEXT | Yes | K8s Job name (set when job is created) |
|
||||
| `k8s_namespace` | TEXT | Yes | K8s namespace |
|
||||
| `started_at` | DATETIME | Yes | When the K8s Job was created |
|
||||
| `completed_at` | DATETIME | Yes | When the K8s Job finished |
|
||||
| `duration_ms` | INTEGER | Yes | Computed: completed_at - started_at |
|
||||
| `error_log` | TEXT | Yes | Last 100 lines of pod logs on failure |
|
||||
| `token_cost_json` | TEXT | Yes | Optional: `{"input": N, "output": N}` |
|
||||
| `created_at` | DATETIME | No | When the trigger evaluation happened |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_reactive_runs_agent_created` on `(agent_name, created_at)` — for budget counting and cooldown checks
|
||||
- `idx_reactive_runs_status` on `(status)` — for poller to find active runs
|
||||
- `idx_reactive_runs_agent_status` on `(agent_name, status)` — for coalescing checks
|
||||
|
||||
### State Transitions
|
||||
|
||||
```
|
||||
Trigger evaluation:
|
||||
→ [all checks pass, agent idle] → status: 'running'
|
||||
→ [all checks pass, agent busy] → status: 'queued' (sets pending_work)
|
||||
→ [cooldown not elapsed] → status: 'cooldown_skipped'
|
||||
→ [daily budget exhausted] → status: 'budget_exhausted'
|
||||
→ [depth exceeded] → status: 'depth_exceeded'
|
||||
→ [no k8s_image configured] → status: 'failed'
|
||||
→ [K8s cluster unreachable] → status: 'failed'
|
||||
|
||||
Job completion (via poller):
|
||||
→ [exit code 0] → status: 'succeeded'
|
||||
→ [exit code != 0 / OOM / timeout] → status: 'failed'
|
||||
→ [pending_work set] → new run launched (back to evaluation)
|
||||
```
|
||||
|
||||
### Message Metadata (extended)
|
||||
|
||||
Messages sent by triggered agents carry `trigger_depth` in their metadata JSON field. When the dispatcher evaluates mentions from such a message, it reads the depth and increments it for the next trigger evaluation.
|
||||
|
||||
| Metadata Key | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `trigger_depth` | INTEGER | Current depth in cascade chain |
|
||||
|
||||
## Migration: 015_reactive_triggers.sql
|
||||
|
||||
```sql
|
||||
-- Extend agents table with reactive trigger configuration
|
||||
ALTER TABLE agents ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'passive';
|
||||
ALTER TABLE agents ADD COLUMN cooldown_seconds INTEGER NOT NULL DEFAULT 600;
|
||||
ALTER TABLE agents ADD COLUMN daily_trigger_budget INTEGER NOT NULL DEFAULT 8;
|
||||
ALTER TABLE agents ADD COLUMN max_trigger_depth INTEGER NOT NULL DEFAULT 5;
|
||||
ALTER TABLE agents ADD COLUMN k8s_image TEXT;
|
||||
ALTER TABLE agents ADD COLUMN k8s_env_json TEXT;
|
||||
ALTER TABLE agents ADD COLUMN k8s_resource_preset TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE agents ADD COLUMN pending_work INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- New table: reactive trigger runs
|
||||
CREATE TABLE reactive_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_name TEXT NOT NULL REFERENCES agents(name),
|
||||
trigger_message_id INTEGER,
|
||||
trigger_event TEXT NOT NULL,
|
||||
trigger_depth INTEGER NOT NULL DEFAULT 0,
|
||||
trigger_from TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
k8s_job_name TEXT,
|
||||
k8s_namespace TEXT,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
duration_ms INTEGER,
|
||||
error_log TEXT,
|
||||
token_cost_json TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reactive_runs_agent_created ON reactive_runs(agent_name, created_at);
|
||||
CREATE INDEX idx_reactive_runs_status ON reactive_runs(status);
|
||||
CREATE INDEX idx_reactive_runs_agent_status ON reactive_runs(agent_name, status);
|
||||
```
|
||||
|
||||
## k8s_env_json Format
|
||||
|
||||
```json
|
||||
{
|
||||
"AGENT_GIT_REPO": "Dumbris/agent-research-mcpproxy",
|
||||
"AGENT_PROMPT": "You are research-mcpproxy...",
|
||||
"MCPPROXY_URL": "http://kubic.home.arpa:30080",
|
||||
"SYNAPBUS_API_KEY": {
|
||||
"secretRef": "synapbus-agent-keys",
|
||||
"key": "RESEARCH_MCPPROXY_API_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain string values become `env[].value`. Objects with `secretRef` become `env[].valueFrom.secretKeyRef`.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Implementation Plan: Reactive Agent Triggering System
|
||||
|
||||
**Branch**: `014-reactive-agent-triggers` | **Date**: 2026-03-25 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/014-reactive-agent-triggers/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add a reactor engine to SynapBus that automatically triggers K8s Jobs when agents receive DMs or @mentions. The reactor enforces per-agent rate limits (cooldown, daily budget, trigger depth), ensures sequential execution with coalescing, and provides visibility through a Web UI Agent Runs panel, failure DM notifications, and admin CLI commands.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (per go.mod)
|
||||
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), spf13/cobra (CLI), modernc.org/sqlite (storage), k8s.io/client-go (K8s Jobs)
|
||||
**Storage**: SQLite via modernc.org/sqlite — new migration 015_reactive_triggers.sql
|
||||
**Testing**: `go test ./...` (table-driven tests, Go standard)
|
||||
**Target Platform**: linux/amd64 (kubic deployment), darwin/arm64 (development)
|
||||
**Project Type**: Web service (single binary) with embedded Svelte 5 SPA
|
||||
**Performance Goals**: Trigger evaluation < 100ms, K8s Job creation < 5s after evaluation, Job status polling every 15s
|
||||
**Constraints**: Zero CGO, single binary, single `--data` directory, all state in SQLite
|
||||
**Scale/Scope**: ~4 reactive agents, ~8 triggers/day each, single K8s cluster
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | Reactor lives inside SynapBus binary. K8s client is optional (no-op when not in-cluster). |
|
||||
| II. MCP-Native | PASS | Admin tools exposed via MCP. Agents interact through existing MCP tools only. |
|
||||
| III. Pure Go, Zero CGO | PASS | k8s.io/client-go is pure Go. No new CGO deps. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Trigger config scoped to agent's owner. Run visibility restricted to owner. |
|
||||
| V. Embedded OAuth 2.1 | N/A | No auth changes needed. |
|
||||
| VI. Semantic-Ready Storage | N/A | No vector search changes. |
|
||||
| VII. Swarm Intelligence | N/A | Not affected. |
|
||||
| VIII. Observable by Default | PASS | Every trigger evaluation recorded in reactive_runs. Failed runs send DM + show in Web UI. |
|
||||
| IX. Progressive Complexity | PASS | Reactive triggers are opt-in per agent (trigger_mode='reactive'). Default is 'passive' — no behavior change for existing agents. |
|
||||
| X. Web UI First-Class | PASS | New Agent Runs page with real-time status, filtering, expandable logs. |
|
||||
|
||||
**GATE RESULT: PASS** — No violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/014-reactive-agent-triggers/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0: research findings
|
||||
├── data-model.md # Phase 1: schema design
|
||||
├── quickstart.md # Phase 1: developer onboarding
|
||||
├── contracts/ # Phase 1: API contracts
|
||||
│ ├── rest-api.md # REST endpoints for Web UI
|
||||
│ ├── mcp-tools.md # MCP admin tools
|
||||
│ └── cli-commands.md # Admin CLI commands
|
||||
└── tasks.md # Phase 2: implementation tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── reactor/ # NEW: reactive trigger engine
|
||||
│ ├── reactor.go # Core decision logic
|
||||
│ ├── store.go # SQLite persistence for reactive_runs
|
||||
│ ├── poller.go # K8s Job status polling goroutine
|
||||
│ └── reactor_test.go # Unit tests
|
||||
├── agents/ # MODIFIED: add trigger fields to agent model
|
||||
│ ├── model.go # Add trigger_mode, cooldown, budget, etc.
|
||||
│ └── store.go # Add trigger config CRUD
|
||||
├── k8s/ # MODIFIED: extend job creation with trigger env vars
|
||||
│ └── runner.go # Add SYNAPBUS_MESSAGE_* env vars
|
||||
├── dispatcher/ # MODIFIED: add reactor as dispatch target
|
||||
│ └── dispatcher.go # Wire reactor into MultiDispatcher
|
||||
├── webhooks/ # MODIFIED: add trigger block to payloads
|
||||
│ └── delivery.go # Enrich payload with depth/run_id
|
||||
├── messaging/ # MODIFIED: propagate trigger depth on agent messages
|
||||
│ └── service.go # Track depth in message metadata
|
||||
├── mcp/ # MODIFIED: add admin MCP tools
|
||||
│ └── bridge.go # Register configure_triggers, list_runs, etc.
|
||||
├── api/ # MODIFIED: add REST endpoints for Web UI
|
||||
│ └── runs.go # NEW: /api/runs endpoints
|
||||
└── web/ # MODIFIED: embed updated SPA
|
||||
└── dist/ # Rebuilt after Svelte changes
|
||||
|
||||
web/ # Svelte source
|
||||
└── src/
|
||||
├── routes/
|
||||
│ └── runs/ # NEW: Agent Runs page
|
||||
│ └── +page.svelte
|
||||
└── lib/
|
||||
└── components/
|
||||
└── RunCard.svelte # NEW: run row component
|
||||
|
||||
schema/
|
||||
└── 015_reactive_triggers.sql # NEW: migration
|
||||
|
||||
cmd/synapbus/
|
||||
└── runs.go # NEW: CLI commands for runs
|
||||
└── agent_triggers.go # NEW: CLI commands for trigger config
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing SynapBus layout. New `internal/reactor/` package for core logic. All other changes extend existing packages.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No violations — section not needed.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Quickstart: Reactive Agent Triggering
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.25+ installed
|
||||
- Access to K8s cluster (MicroK8s on kubic) for integration tests
|
||||
- SynapBus built and running locally or on kubic
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Build SynapBus
|
||||
make build
|
||||
|
||||
# Run with hot reload
|
||||
make dev
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
```
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
### New Files
|
||||
- `internal/reactor/reactor.go` — Core reactor engine
|
||||
- `internal/reactor/store.go` — SQLite persistence
|
||||
- `internal/reactor/poller.go` — K8s Job status poller
|
||||
- `internal/reactor/reactor_test.go` — Unit tests
|
||||
- `internal/api/runs.go` — REST API for Web UI
|
||||
- `schema/015_reactive_triggers.sql` — Migration
|
||||
- `cmd/synapbus/runs.go` — CLI commands
|
||||
- `cmd/synapbus/agent_triggers.go` — CLI trigger config commands
|
||||
- `web/src/routes/runs/+page.svelte` — Web UI Agent Runs page
|
||||
|
||||
### Modified Files
|
||||
- `internal/agents/model.go` — Add trigger fields
|
||||
- `internal/agents/store.go` — Add trigger config CRUD
|
||||
- `internal/k8s/runner.go` — Add trigger env vars to Job creation
|
||||
- `internal/dispatcher/dispatcher.go` — Wire reactor into fan-out
|
||||
- `internal/webhooks/delivery.go` — Add trigger block to payloads
|
||||
- `internal/mcp/bridge.go` — Register admin MCP tools
|
||||
- `cmd/synapbus/main.go` — Register CLI commands
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### Unit Tests (no K8s required)
|
||||
```bash
|
||||
go test ./internal/reactor/... -v
|
||||
```
|
||||
|
||||
Test the reactor decision logic with mock K8s runner:
|
||||
- Cooldown enforcement
|
||||
- Budget counting
|
||||
- Depth checking
|
||||
- Sequential execution / coalescing
|
||||
- Self-mention filtering
|
||||
|
||||
### Integration Tests (requires K8s)
|
||||
```bash
|
||||
go test ./internal/reactor/... -tags=integration -v
|
||||
```
|
||||
|
||||
Test actual K8s Job creation and polling on kubic.
|
||||
|
||||
## Configuring an Agent
|
||||
|
||||
```bash
|
||||
# 1. Set trigger mode and rate limits
|
||||
./synapbus agent set-triggers research-mcpproxy \
|
||||
--mode reactive --cooldown 600 --daily-budget 8
|
||||
|
||||
# 2. Set K8s image and env vars
|
||||
./synapbus agent set-image research-mcpproxy \
|
||||
--image localhost:32000/universal-agent:latest \
|
||||
--env AGENT_GIT_REPO=Dumbris/agent-research-mcpproxy \
|
||||
--secret-env SYNAPBUS_API_KEY=synapbus-agent-keys:RESEARCH_MCPPROXY_API_KEY
|
||||
|
||||
# 3. Send a DM to test
|
||||
# (via Web UI or MCP client)
|
||||
|
||||
# 4. Check run status
|
||||
./synapbus runs list --agent research-mcpproxy
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Research: Reactive Agent Triggering System
|
||||
|
||||
**Feature**: 014-reactive-agent-triggers
|
||||
**Date**: 2026-03-25
|
||||
|
||||
## R1: K8s Job Status Polling vs Callbacks
|
||||
|
||||
**Decision**: Polling via background goroutine every 15 seconds.
|
||||
|
||||
**Rationale**: SynapBus's K8s runner already uses in-cluster client-go. Polling is simpler than setting up K8s watch streams or webhooks back to SynapBus. With ~4 agents and max 8 runs/day each, polling is trivially cheap. The poller queries active runs from SQLite, then checks each K8s Job status via client-go.
|
||||
|
||||
**Alternatives considered**:
|
||||
- K8s Watch API: More responsive but requires long-lived connections, reconnect logic, and is overkill for <10 concurrent jobs.
|
||||
- K8s Job completion callbacks (via init containers or sidecars): Complex, adds container dependencies, violates single-binary principle.
|
||||
- Argo Events sensor: External dependency, violates Principle I.
|
||||
|
||||
## R2: Pending Work Flag Storage
|
||||
|
||||
**Decision**: Boolean `pending_work` column on the `agents` table.
|
||||
|
||||
**Rationale**: Simplest approach. The flag is set to true when a trigger arrives while the agent is busy, and cleared when the coalesced run launches. No need for a separate queue table since the agent's `claim_messages` workflow handles message ordering.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate queue table tracking individual trigger messages: Unnecessary complexity — the agent processes all pending messages anyway via `claim_messages`.
|
||||
- In-memory flag: Lost on restart. SQLite is authoritative.
|
||||
- Field on the latest reactive_run record: Complicates queries; cleaner as agent field.
|
||||
|
||||
## R3: Trigger Depth Propagation
|
||||
|
||||
**Decision**: Depth is tracked at two levels: (1) K8s env var `SYNAPBUS_TRIGGER_DEPTH` for the agent to know its depth, (2) stored on each message sent by a triggered agent as metadata, so the reactor can read it when evaluating the next hop.
|
||||
|
||||
**Rationale**: When agent A is triggered at depth N and sends a message mentioning agent B, the message needs to carry depth N+1. The reactor reads this from message metadata when evaluating agent B's trigger. This aligns with the existing `X-SynapBus-Depth` header pattern used for webhooks.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Global depth counter per conversation chain: Complex, requires conversation tracking.
|
||||
- Only counting via webhook headers: Doesn't work for MCP-originated messages.
|
||||
|
||||
## R4: Cooldown Timer — From Start or From Completion
|
||||
|
||||
**Decision**: Cooldown starts from the most recent run's `created_at` timestamp (i.e., when the job was launched, not when it completed).
|
||||
|
||||
**Rationale**: Simpler and more predictable. If an agent runs for 30 minutes, the cooldown is already partially elapsed by completion time. Starting from launch prevents rapid re-triggering even if the previous run was fast.
|
||||
|
||||
**Alternatives considered**:
|
||||
- From completion time: Could lead to very long effective cooldowns for long-running jobs. A 10-minute cooldown + 30-minute run = 40 minutes between runs.
|
||||
- Configurable (start vs completion): Over-engineering for current needs.
|
||||
|
||||
## R5: CronJob vs Reactive Job Overlap Detection
|
||||
|
||||
**Decision**: The reactor checks for any running K8s Job with the agent's label (`synapbus-agent=<name>`), regardless of whether it's a CronJob-spawned or reactor-spawned job. If any is running, `pending_work` is set.
|
||||
|
||||
**Rationale**: The sequential execution constraint applies to all runs, not just reactive ones. Using K8s label selectors is clean and already supported by client-go.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Only tracking reactive runs in SQLite: Misses CronJob runs, could cause concurrent execution.
|
||||
- Requiring agents to report "busy" status via MCP: Adds agent-side complexity, unreliable if agent crashes.
|
||||
|
||||
## R6: Self-Mention Detection
|
||||
|
||||
**Decision**: When extracting mentions from a message, filter out the sender's own agent name. The reactor never triggers an agent based on its own message.
|
||||
|
||||
**Rationale**: Prevents trivial infinite loops where an agent mentions itself in its response.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Relying on depth limit to catch self-loops: Too permissive — wastes budget on preventable triggers.
|
||||
- No self-mention filtering: Dangerous with reactive agents.
|
||||
|
||||
## R7: Web UI Polling vs SSE for Agent Runs
|
||||
|
||||
**Decision**: The Agent Runs page uses polling (every 10 seconds) to refresh run statuses, same as other SynapBus Web UI pages.
|
||||
|
||||
**Rationale**: Consistent with existing Web UI patterns. SSE is already used for message notifications but adding a new SSE channel for run status adds complexity. Polling at 10s intervals is adequate for runs that take minutes.
|
||||
|
||||
**Alternatives considered**:
|
||||
- SSE push: More responsive but adds server-side event infrastructure for a page that's not time-critical.
|
||||
- WebSocket: Overkill, not used elsewhere in SynapBus.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Feature Specification: Reactive Agent Triggering System
|
||||
|
||||
**Feature Branch**: `014-reactive-agent-triggers`
|
||||
**Created**: 2026-03-25
|
||||
**Status**: Draft
|
||||
**Input**: Brainstormed and approved design from conversation — reactive agent triggering via DM/@mention with K8s Job orchestration.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Reactive triggers fire only on `message.received` (DM) and `message.mentioned` (@mention) events — not on `workflow.state_changed` or `channel.message` (deferred to a future feature)
|
||||
- All agents are hybrid: they have existing K8s CronJob schedules and can additionally be triggered reactively by SynapBus
|
||||
- The reactor engine lives inside SynapBus as `internal/reactor/` — no external coordinator service
|
||||
- K8s is the only trigger mechanism for v1; webhook-based triggers are deferred (infrastructure exists but is not wired to the reactor)
|
||||
- Agent K8s image and env config are stored on the agent registry record — the existing `k8s_handlers` table is for the legacy webhook-style K8s integration and remains unchanged
|
||||
- The universal agent template (`searcher/agents/universal/run_agent.py`) reads `SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, `SYNAPBUS_FROM_AGENT`, `SYNAPBUS_EVENT` env vars and prepends trigger context to the agent prompt
|
||||
- Coalescing: when an agent is busy and new triggers arrive, a `pending_work` flag is set. On job completion, if the flag is set, a new run launches. The agent's `my_status` / `claim_messages` workflow handles processing all pending messages — SynapBus does not queue individual messages
|
||||
- Per-agent configurable rate limits with defaults: cooldown = 600 seconds, daily budget = 8 runs, max trigger depth = 5
|
||||
- Trigger depth is propagated via `SYNAPBUS_TRIGGER_DEPTH` env var and incremented on each agent-to-agent hop; if an agent sends a message via MCP that triggers another agent, the depth increases
|
||||
- Failed reactive jobs send a system DM to the agent's owner when a reactive job fails, including agent name, trigger context, duration, and error summary
|
||||
- The Web UI Agent Runs panel is a new page showing recent reactive triggers with status, trigger context, duration, and expandable error logs
|
||||
- Token cost tracking is optional — agents may report it back but it is not required for v1
|
||||
- Migration number: 015_reactive_triggers.sql (next after existing migrations)
|
||||
- Admin CLI commands use the existing `synapbus` cobra command tree
|
||||
- `k8s_env_json` stores both plain env vars and secret references (format: `{"AGENT_GIT_REPO": "value", "SYNAPBUS_API_KEY": {"secretRef": "secret-name", "key": "key-name"}}`)
|
||||
- SYNAPBUS_MESSAGE_BODY is truncated to 4KB when passed as an env var
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Reactive Agent Trigger via DM (Priority: P1)
|
||||
|
||||
A human owner sends a DM to an agent (e.g., "research-mcpproxy") via SynapBus. SynapBus detects the agent has `trigger_mode='reactive'`, passes all rate-limit checks, and automatically launches a K8s Job running the agent's container image. The agent processes the DM as its first priority.
|
||||
|
||||
**Why this priority**: This is the core value proposition — agents respond to messages in near-real-time instead of waiting for the next cron cycle.
|
||||
|
||||
**Independent Test**: Send a DM to a reactive agent, verify a K8s Job is created with the correct env vars, and the agent responds to the message.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "research-mcpproxy" with `trigger_mode='reactive'` and no active runs, **When** a human sends it a DM, **Then** SynapBus creates a K8s Job within 5 seconds with `SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, `SYNAPBUS_FROM_AGENT`, `SYNAPBUS_EVENT=message.received` env vars.
|
||||
2. **Given** agent "research-mcpproxy" with `trigger_mode='passive'`, **When** a human sends it a DM, **Then** no reactive trigger fires; the agent picks up the message on its next scheduled run.
|
||||
3. **Given** agent "research-mcpproxy" with `trigger_mode='reactive'`, **When** a DM is sent, **Then** a `reactive_runs` record is created with `status='running'` and `trigger_event='message.received'`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Reactive Agent Trigger via @Mention (Priority: P1)
|
||||
|
||||
A human or agent @mentions another agent in a channel message (e.g., "@social-commenter check this thread"). SynapBus detects the mention, checks if the mentioned agent is reactive, and triggers it.
|
||||
|
||||
**Why this priority**: @mentions are the primary way to request agent attention in channel conversations — equally important as DMs.
|
||||
|
||||
**Independent Test**: Post a channel message mentioning a reactive agent, verify a K8s Job is created.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "social-commenter" with `trigger_mode='reactive'`, **When** a message containing "@social-commenter" is posted in a channel, **Then** SynapBus triggers the agent with `SYNAPBUS_EVENT=message.mentioned`.
|
||||
2. **Given** a message mentioning multiple reactive agents, **When** the message is sent, **Then** each mentioned agent is evaluated independently for triggering (subject to their own cooldown/budget).
|
||||
3. **Given** agent "social-commenter" already running, **When** a new @mention arrives, **Then** the `pending_work` flag is set and no additional job is created until the current one completes.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Rate Limiting: Cooldown (Priority: P1)
|
||||
|
||||
To control costs, each agent has a configurable cooldown period. After a reactive run starts, no new reactive run can be triggered for that agent until the cooldown elapses.
|
||||
|
||||
**Why this priority**: Without cooldown, a burst of messages could trigger many expensive runs in rapid succession.
|
||||
|
||||
**Independent Test**: Trigger an agent, then immediately send another DM. Verify the second trigger is recorded as `cooldown_skipped`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent with `cooldown_seconds=600` and a run that started 3 minutes ago, **When** a new DM arrives, **Then** the trigger is recorded as `cooldown_skipped` and no K8s Job is created.
|
||||
2. **Given** agent with `cooldown_seconds=600` and last run started 11 minutes ago, **When** a new DM arrives, **Then** the agent is triggered normally.
|
||||
3. **Given** a trigger that was `cooldown_skipped`, **When** the cooldown elapses, **Then** if `pending_work` is set, a new run launches automatically.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Rate Limiting: Daily Budget (Priority: P1)
|
||||
|
||||
Each agent has a configurable daily limit on the number of reactive runs. Once exhausted, no more reactive triggers fire until the next day.
|
||||
|
||||
**Why this priority**: Hard cap on daily spend per agent prevents runaway costs.
|
||||
|
||||
**Independent Test**: Configure an agent with daily budget of 2, trigger it twice successfully, then send a third DM. Verify the third is recorded as `budget_exhausted`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent with `daily_trigger_budget=8` and 7 runs today, **When** a new DM arrives, **Then** the agent is triggered (8th run).
|
||||
2. **Given** agent with `daily_trigger_budget=8` and 8 runs today, **When** a new DM arrives, **Then** the trigger is recorded as `budget_exhausted` and no job is created.
|
||||
3. **Given** budget-exhausted agent, **When** a new calendar day begins (UTC), **Then** the budget resets and new triggers can fire.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Rate Limiting: Trigger Depth (Priority: P1)
|
||||
|
||||
When agents trigger other agents (agent A's response mentions @agent-B), the depth counter increments. If depth exceeds the agent's `max_trigger_depth`, the cascade stops.
|
||||
|
||||
**Why this priority**: Prevents infinite agent-to-agent loops which could be extremely costly.
|
||||
|
||||
**Independent Test**: Set max_trigger_depth=2 on an agent, simulate a depth-3 trigger chain, verify the third hop is blocked.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent with `max_trigger_depth=5` and an incoming trigger at depth 4, **When** evaluated, **Then** the trigger fires (depth 4 < max 5).
|
||||
2. **Given** agent with `max_trigger_depth=5` and an incoming trigger at depth 5, **When** evaluated, **Then** the trigger is blocked and recorded as `depth_exceeded`.
|
||||
3. **Given** a human-initiated DM (depth 0), **When** the triggered agent sends a message mentioning another agent, **Then** the second agent receives the trigger with depth 1.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Sequential Execution with Coalescing (Priority: P1)
|
||||
|
||||
Only one reactive K8s Job runs per agent at a time. If new triggers arrive while the agent is busy, they are coalesced — a single follow-up run launches when the current one completes, and the agent processes all accumulated messages.
|
||||
|
||||
**Why this priority**: Prevents concurrent modification of agent workspaces and saves tokens by avoiding redundant startups.
|
||||
|
||||
**Independent Test**: Trigger an agent, send 3 more DMs while it's running. Verify only one follow-up run launches after the first completes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent currently running a reactive job, **When** a new DM arrives, **Then** `pending_work` flag is set to true, no new job is created, and the trigger is recorded as `queued`.
|
||||
2. **Given** agent finishes a run and `pending_work` is true, **When** the poller detects job completion, **Then** `pending_work` is cleared and a new coalesced run is launched (subject to cooldown/budget checks).
|
||||
3. **Given** agent finishes a run and `pending_work` is false, **When** the poller detects job completion, **Then** no follow-up run launches.
|
||||
4. **Given** 5 DMs arrive while agent is busy, **When** the follow-up run launches, **Then** only one K8s Job is created (not 5), and the agent uses `claim_messages` to process all pending messages.
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 - Job Failure Notification (Priority: P2)
|
||||
|
||||
When a reactive K8s Job fails (exit code != 0, OOMKilled, timeout), SynapBus detects the failure, retrieves pod logs, records the error, and sends a system DM to the agent's human owner.
|
||||
|
||||
**Why this priority**: Visibility into failures is essential for debugging but not strictly required for the trigger mechanism to function.
|
||||
|
||||
**Independent Test**: Configure an agent with an image that exits with error, trigger it, verify owner receives a system DM with error details.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a reactive job that fails with exit code 1, **When** the poller detects failure, **Then** the `reactive_runs` record is updated with `status='failed'`, `error_log` containing the last 100 lines of pod logs, and `completed_at` timestamp.
|
||||
2. **Given** a failed reactive run, **When** the failure is recorded, **Then** a system DM is sent to the agent's owner with agent name, trigger reason, duration, and error summary.
|
||||
3. **Given** a reactive job that exceeds its timeout, **When** the pod is killed, **Then** the run is recorded as `failed` with error indicating timeout.
|
||||
|
||||
---
|
||||
|
||||
### User Story 8 - Web UI Agent Runs Panel (Priority: P2)
|
||||
|
||||
The SynapBus Web UI includes an "Agent Runs" page showing recent reactive triggers, their status, and details. Owners can filter by agent and status, view error logs, click through to the original trigger message, and retry failed runs.
|
||||
|
||||
**Why this priority**: Complements DM notifications with a historical, browsable view — important for day-to-day management but not blocking core functionality.
|
||||
|
||||
**Independent Test**: Trigger several agents (some succeed, some fail), navigate to Agent Runs page, verify all runs are listed with correct status and details.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** several reactive runs have occurred, **When** the owner navigates to the Agent Runs page, **Then** runs are listed in reverse chronological order showing: status badge, agent name, trigger reason, duration, and timestamp.
|
||||
2. **Given** a failed run, **When** the owner clicks to expand it, **Then** the error log and a "Retry" button are shown.
|
||||
3. **Given** the owner clicks "Retry" on a failed run, **When** the retry fires, **Then** a new reactive run is created for the same agent (subject to cooldown/budget checks).
|
||||
4. **Given** multiple reactive agents, **When** the owner views the page, **Then** agent summary cards at the top show: name, today's budget usage (e.g., "3/8 runs"), cooldown status, and current state (idle/running/queued).
|
||||
5. **Given** a run with a trigger message, **When** the owner clicks the message link, **Then** they are navigated to the message in the Web UI.
|
||||
|
||||
---
|
||||
|
||||
### User Story 9 - Admin CLI for Trigger Configuration (Priority: P2)
|
||||
|
||||
System administrators can configure reactive triggers per agent via the CLI: set trigger mode, cooldown, daily budget, max depth, K8s image, and environment variables.
|
||||
|
||||
**Why this priority**: Required for initial setup and ongoing management, but can be done via direct DB manipulation as a workaround.
|
||||
|
||||
**Independent Test**: Use CLI to configure an agent as reactive, then verify the agent triggers on DM.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** agent "research-mcpproxy", **When** admin runs `synapbus agent set-triggers ... --mode reactive --cooldown 600 --daily-budget 8 --max-depth 5`, **Then** the agent's trigger configuration is updated in the registry.
|
||||
2. **Given** agent with no image configured, **When** admin runs `synapbus agent set-image ... --image <image> --env KEY=VALUE`, **Then** the image and env config are stored on the agent record.
|
||||
3. **Given** admin wants to view recent runs, **When** they run `synapbus runs list --agent <name>`, **Then** recent runs are displayed with status, duration, and trigger reason.
|
||||
4. **Given** a failed run, **When** admin runs `synapbus runs logs <run-id>`, **Then** the error log for that run is displayed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 10 - Webhook Payload Enrichment (Priority: P3)
|
||||
|
||||
Webhook payloads for `message.received` and `message.mentioned` events include a `trigger` block with depth and run context, enabling future webhook-based agent triggers.
|
||||
|
||||
**Why this priority**: Future-proofing for webhook-based triggers. No immediate user need but prepares the infrastructure.
|
||||
|
||||
**Independent Test**: Register a webhook, send a message that triggers it, verify the payload includes the `trigger` block.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent with a registered webhook for `message.received`, **When** a DM is sent, **Then** the webhook payload includes a `trigger` object with `depth` and `triggered_by_run_id` fields.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the K8s cluster is unreachable? The reactor records the run as `failed` with a connection error and sends a failure DM to the owner.
|
||||
- What happens when a reactive agent's K8s image is not configured? The reactor skips the trigger and logs a warning. The trigger is recorded as `failed` with reason "no k8s_image configured".
|
||||
- What happens when two DMs arrive simultaneously for the same agent? The reactor processes them sequentially (database-level locking on the agent). The first creates a job; the second sets `pending_work`.
|
||||
- What happens when a scheduled CronJob and a reactive trigger overlap? The reactor checks for any running K8s Job for that agent (both scheduled and reactive). If one is running, it sets `pending_work` and waits.
|
||||
- What happens when the daily budget resets while a coalesced run is pending? The pending run uses the new day's budget.
|
||||
- What happens when an agent is mentioned in its own message (self-mention)? Self-mentions are ignored — an agent cannot trigger itself.
|
||||
- What happens when the message body exceeds 4KB? It is truncated to 4KB in the `SYNAPBUS_MESSAGE_BODY` env var with a `[truncated]` suffix.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST detect DMs and @mentions to agents with `trigger_mode='reactive'` and initiate a reactive trigger evaluation.
|
||||
- **FR-002**: System MUST enforce per-agent cooldown periods between reactive runs, rejecting triggers during cooldown.
|
||||
- **FR-003**: System MUST enforce per-agent daily run budgets, rejecting triggers when the budget is exhausted.
|
||||
- **FR-004**: System MUST track and enforce trigger depth limits to prevent infinite agent-to-agent cascades.
|
||||
- **FR-005**: System MUST ensure only one reactive K8s Job runs per agent at any time (sequential execution).
|
||||
- **FR-006**: System MUST coalesce pending triggers — when a new trigger arrives while an agent is busy, a `pending_work` flag is set and a single follow-up run launches after the current job completes.
|
||||
- **FR-007**: System MUST pass trigger context to K8s Jobs via environment variables: `SYNAPBUS_MESSAGE_ID`, `SYNAPBUS_MESSAGE_BODY`, `SYNAPBUS_FROM_AGENT`, `SYNAPBUS_EVENT`, `SYNAPBUS_TRIGGER_DEPTH`.
|
||||
- **FR-008**: System MUST record every trigger evaluation (successful or not) in the `reactive_runs` table with appropriate status.
|
||||
- **FR-009**: System MUST poll K8s Job status and update `reactive_runs` records when jobs complete (succeed or fail).
|
||||
- **FR-010**: System MUST retrieve and store the last 100 lines of pod logs for failed reactive runs.
|
||||
- **FR-011**: System MUST send a system DM to the agent's owner when a reactive job fails, including agent name, trigger context, duration, and error summary.
|
||||
- **FR-012**: System MUST provide a Web UI page listing reactive runs with filtering by agent and status.
|
||||
- **FR-013**: System MUST display agent summary cards in the Web UI showing budget usage, cooldown status, and current state.
|
||||
- **FR-014**: System MUST allow retrying failed runs from the Web UI (subject to rate limits).
|
||||
- **FR-015**: System MUST provide CLI commands for configuring agent trigger settings (mode, cooldown, budget, depth, image, env vars).
|
||||
- **FR-016**: System MUST provide CLI commands for listing and inspecting reactive runs.
|
||||
- **FR-017**: System MUST ignore self-mentions (an agent cannot trigger itself).
|
||||
- **FR-018**: System MUST truncate `SYNAPBUS_MESSAGE_BODY` to 4KB when passed as an env var.
|
||||
- **FR-019**: System MUST include trigger context (`depth`, `triggered_by_run_id`) in webhook payloads for `message.received` and `message.mentioned` events.
|
||||
- **FR-020**: System MUST support configurable rate limits per agent (cooldown, daily budget, max depth) with system-wide defaults.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Agent (extended)**: Gains `trigger_mode` (passive/reactive/disabled), `cooldown_seconds`, `daily_trigger_budget`, `max_trigger_depth`, `k8s_image`, `k8s_env_json`, `k8s_resource_preset` fields.
|
||||
- **Reactive Run**: A record of a trigger evaluation and its outcome. Tracks agent, trigger message, event type, depth, status (queued/running/succeeded/failed/cooldown_skipped/budget_exhausted/depth_exceeded), K8s job metadata, timing, error logs, and optional token cost.
|
||||
- **Pending Work Flag**: A per-agent boolean indicating that new triggers arrived while the agent was busy. Stored on the agent record or in a dedicated field on the latest running reactive_run.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Reactive agents respond to DMs and @mentions within 30 seconds of message delivery (time from message sent to K8s Job created).
|
||||
- **SC-002**: No more than one reactive K8s Job runs per agent at any time — verified by checking job state and run records.
|
||||
- **SC-003**: Cooldown enforcement prevents back-to-back triggers — an agent triggered at time T cannot be triggered again before T + cooldown_seconds.
|
||||
- **SC-004**: Daily budget enforcement caps reactive runs — after N runs in a calendar day (UTC), all further triggers are recorded as `budget_exhausted`.
|
||||
- **SC-005**: Trigger depth enforcement prevents cascades beyond the configured limit — a trigger chain deeper than max_trigger_depth is blocked.
|
||||
- **SC-006**: Agent owners receive failure notifications within 60 seconds of job failure detection.
|
||||
- **SC-007**: The Web UI Agent Runs page accurately reflects all reactive runs with correct status, timing, and trigger context.
|
||||
- **SC-008**: Admin CLI commands successfully configure trigger settings and display run history.
|
||||
- **SC-009**: Coalesced runs process all pending messages in a single session — verified by checking that the agent handles all queued work.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Feature Specification: SQL Query Interface + Split Connection Pools
|
||||
|
||||
**Feature Branch**: `015-sql-query-split-pools`
|
||||
**Created**: 2026-03-26
|
||||
**Status**: Draft
|
||||
**Input**: Architecture research from reactive agent triggering session
|
||||
|
||||
## Assumptions
|
||||
|
||||
- SQL query interface is exposed as a `query` action via the existing `execute` MCP tool, not a new top-level MCP tool
|
||||
- Queries are read-only (enforced via `PRAGMA query_only=ON` on a dedicated connection)
|
||||
- Agents query curated SQL views (not raw tables) that bake in per-agent access control
|
||||
- Views: `my_messages`, `my_channels`, `channel_messages` — parameterized by the authenticated agent's name
|
||||
- Results are automatically limited to 100 rows; agent can specify lower limit
|
||||
- Query timeout: 5 seconds max
|
||||
- Only SELECT statements allowed (validated before execution); WITH (CTEs) permitted
|
||||
- Split connection pools: writeDB (MaxOpenConns=1) for all INSERT/UPDATE/DELETE, readDB (MaxOpenConns=8) for all SELECT
|
||||
- Both pools share the same SQLite file with WAL mode
|
||||
- The read pool uses `PRAGMA query_only=ON` for safety
|
||||
- No schema changes needed — this is a runtime architecture change
|
||||
- Agent SQL queries use the read pool
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Agent Queries Messages via SQL (Priority: P1)
|
||||
|
||||
An agent connected via MCP uses the `execute` tool to run a SQL query against its accessible messages. For example: "Show me all messages in #news-mcpproxy from the last 3 days with priority >= 7".
|
||||
|
||||
**Why this priority**: Removes the expressiveness ceiling — agents can compose arbitrary queries instead of being limited to fixed API endpoints.
|
||||
|
||||
**Independent Test**: Agent calls `execute` with `call('query', {sql: "SELECT * FROM my_messages WHERE channel_name = 'news-mcpproxy' AND priority >= 7 ORDER BY created_at DESC LIMIT 5"})` and gets results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated agent, **When** it calls `query` with a valid SELECT, **Then** it receives JSON results with column names and rows.
|
||||
2. **Given** an agent, **When** it runs a query referencing `my_messages`, **Then** it only sees messages it has access to (own DMs + joined channels).
|
||||
3. **Given** an agent, **When** it runs `INSERT INTO messages ...`, **Then** the query is rejected with "only SELECT statements allowed".
|
||||
4. **Given** an agent, **When** it runs a query without LIMIT, **Then** results are automatically capped at 100 rows.
|
||||
5. **Given** an agent, **When** it runs a slow query (> 5s), **Then** the query is cancelled and an error is returned.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Split Read/Write Connection Pools (Priority: P1)
|
||||
|
||||
SynapBus uses separate connection pools for reads and writes to eliminate SQLITE_BUSY errors under concurrent agent load.
|
||||
|
||||
**Why this priority**: Directly fixes the SQLITE_BUSY errors observed during reactive agent runs.
|
||||
|
||||
**Independent Test**: Run concurrent read and write operations; verify no SQLITE_BUSY errors and writes serialize correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** concurrent agents sending messages, **When** writes happen simultaneously, **Then** they serialize through the single-writer pool without SQLITE_BUSY.
|
||||
2. **Given** a write in progress, **When** a read query arrives, **Then** the read executes immediately on the read pool (WAL mode).
|
||||
3. **Given** the read pool, **When** any write operation is attempted, **Then** it fails (query_only=ON enforcement).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Agent Queries Channel Messages (Priority: P2)
|
||||
|
||||
An agent queries messages from a specific channel with rich filtering — date ranges, keywords, reactions, workflow states.
|
||||
|
||||
**Why this priority**: Enables the social-commenter to query #opportunities channel structured data via SQL.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent that has joined #opportunities, **When** it queries `SELECT * FROM channel_messages WHERE channel_name = 'opportunities' AND created_at > datetime('now', '-3 days')`, **Then** it sees messages from that channel.
|
||||
2. **Given** an agent that has NOT joined a private channel, **When** it queries that channel's messages, **Then** no results are returned.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Query with syntax error returns a clear error message, not a crash
|
||||
- Query referencing non-existent view returns "no such table" error
|
||||
- Empty result set returns empty array, not null
|
||||
- Very large result (>100 rows) is truncated with a warning
|
||||
- Concurrent SQL queries from multiple agents don't interfere
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a `query` action callable via the `execute` MCP tool that accepts a SQL string and returns results as JSON.
|
||||
- **FR-002**: System MUST enforce read-only execution — no INSERT, UPDATE, DELETE, DROP, ALTER, or PRAGMA statements allowed.
|
||||
- **FR-003**: System MUST expose curated views (`my_messages`, `my_channels`, `channel_messages`) that enforce per-agent access control.
|
||||
- **FR-004**: System MUST automatically limit query results to 100 rows (or fewer if agent specifies).
|
||||
- **FR-005**: System MUST cancel queries that exceed 5 seconds.
|
||||
- **FR-006**: System MUST use a separate read-only connection pool (MaxOpenConns=8) for all SELECT operations.
|
||||
- **FR-007**: System MUST use a single-writer connection pool (MaxOpenConns=1) for all write operations.
|
||||
- **FR-008**: System MUST configure `PRAGMA query_only=ON` on the read pool connections.
|
||||
- **FR-009**: System MUST validate SQL statements before execution — only SELECT and WITH (CTE) prefixes allowed.
|
||||
- **FR-010**: System MUST return query results as `{columns: [...], rows: [[...], ...], row_count: N, truncated: bool}`.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Read Pool**: SQLite connection pool with MaxOpenConns=8, query_only=ON, for all SELECT operations including agent SQL queries.
|
||||
- **Write Pool**: SQLite connection pool with MaxOpenConns=1, for all INSERT/UPDATE/DELETE operations.
|
||||
- **Agent Views**: SQL views parameterized by agent name that enforce access control.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Agents can execute arbitrary SELECT queries against curated views and receive structured JSON results within 5 seconds.
|
||||
- **SC-002**: No SQLITE_BUSY errors under concurrent 4-agent workload (verified by running all 4 reactive agents simultaneously).
|
||||
- **SC-003**: Write operations on the read pool are rejected at the SQLite engine level.
|
||||
- **SC-004**: Query results are limited to 100 rows maximum.
|
||||
- **SC-005**: All 28+ existing test packages continue to pass with the split pool architecture.
|
||||
Binary file not shown.
@@ -126,7 +126,7 @@ func setupEnv(t *testing.T) *testEnv {
|
||||
actionIndex := actions.NewIndex(actionRegistry.List())
|
||||
|
||||
// Create MCP server with 4 hybrid tools
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
|
||||
t.Cleanup(func() {
|
||||
mcpSrv.Shutdown(context.Background())
|
||||
})
|
||||
@@ -591,11 +591,11 @@ func TestE2E_ListTools(t *testing.T) {
|
||||
aliceClient.Initialize()
|
||||
|
||||
tools := aliceClient.ListTools()
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("expected exactly 4 tools, got %d: %v", len(tools), tools)
|
||||
if len(tools) != 5 {
|
||||
t.Fatalf("expected exactly 5 tools, got %d: %v", len(tools), tools)
|
||||
}
|
||||
|
||||
// Verify the 4 hybrid tools are present.
|
||||
// Verify the 5 hybrid tools are present.
|
||||
toolSet := make(map[string]bool)
|
||||
for _, name := range tools {
|
||||
toolSet[name] = true
|
||||
@@ -605,6 +605,7 @@ func TestE2E_ListTools(t *testing.T) {
|
||||
"send_message",
|
||||
"search",
|
||||
"execute",
|
||||
"get_replies",
|
||||
}
|
||||
for _, name := range expectedTools {
|
||||
if !toolSet[name] {
|
||||
|
||||
@@ -62,6 +62,7 @@ export const messages = {
|
||||
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
|
||||
},
|
||||
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
|
||||
getReplies: (id: number) => request<{ replies: any[]; total: number }>('GET', `/api/messages/${id}/replies`),
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number; attachments?: string[] }) =>
|
||||
request<any>('POST', '/api/messages', body),
|
||||
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
|
||||
@@ -112,7 +113,16 @@ export const channels = {
|
||||
messages: (name: string, limit?: number) => {
|
||||
const qs = limit ? `?limit=${limit}` : '';
|
||||
return request<{ messages: any[]; total: number }>('GET', `/api/channels/${encodeURIComponent(name)}/messages${qs}`);
|
||||
}
|
||||
},
|
||||
updateSettings: (name: string, settings: {
|
||||
workflow_enabled?: boolean;
|
||||
auto_approve?: boolean;
|
||||
publish_threshold?: number;
|
||||
approve_threshold?: number;
|
||||
stalemate_remind_after?: string;
|
||||
stalemate_escalate_after?: string;
|
||||
}) =>
|
||||
request<{ channel: any }>('PUT', `/api/channels/${encodeURIComponent(name)}/settings`, settings)
|
||||
};
|
||||
|
||||
// Dead Letters
|
||||
@@ -262,4 +272,47 @@ export const reactions = {
|
||||
)
|
||||
};
|
||||
|
||||
// Trust Scores
|
||||
export const trust = {
|
||||
get: (agentName: string) =>
|
||||
request<{ scores: Record<string, number> }>('GET', `/api/trust/${encodeURIComponent(agentName)}`)
|
||||
};
|
||||
|
||||
// Onboarding
|
||||
export const onboarding = {
|
||||
archetypes: () => request<{ archetypes: any[] }>('GET', '/api/archetypes'),
|
||||
claudeMd: async (agentName: string, archetype?: string) => {
|
||||
const qs = archetype ? `?archetype=${encodeURIComponent(archetype)}` : '';
|
||||
const res = await fetch(`/api/agents/${encodeURIComponent(agentName)}/claude-md${qs}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return '';
|
||||
return res.text();
|
||||
},
|
||||
mcpConfig: (agentName: string, apiKey?: string) => {
|
||||
const qs = apiKey ? `?api_key=${encodeURIComponent(apiKey)}` : '';
|
||||
return request<any>('GET', `/api/agents/${encodeURIComponent(agentName)}/mcp-config${qs}`);
|
||||
},
|
||||
skills: () => request<{ skills: any[] }>('GET', '/api/skills'),
|
||||
skill: async (name: string) => {
|
||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return '';
|
||||
return res.text();
|
||||
}
|
||||
};
|
||||
|
||||
// Reactive Runs
|
||||
export const runs = {
|
||||
list: (params?: { agent?: string; status?: string; limit?: number; offset?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.agent) qs.set('agent', params.agent);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
const q = qs.toString();
|
||||
return request<{ runs: any[]; total: number }>('GET', `/api/runs${q ? '?' + q : ''}`);
|
||||
},
|
||||
get: (id: number) => request<any>('GET', `/api/runs/${id}`),
|
||||
retry: (id: number) => request<any>('POST', `/api/runs/${id}/retry`),
|
||||
reactiveAgents: () => request<{ agents: any[] }>('GET', '/api/agents/reactive')
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
let uploadError = $state('');
|
||||
let fileInputEl: HTMLInputElement | undefined = $state(undefined);
|
||||
|
||||
const ACCEPTED_FILES = '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -261,7 +259,6 @@
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILES}
|
||||
class="hidden"
|
||||
onchange={handleFileSelected}
|
||||
/>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={800} /></div>
|
||||
|
||||
<!-- Attachments -->
|
||||
{#if msg.attachments && msg.attachments.length > 0}
|
||||
|
||||
@@ -55,8 +55,20 @@
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
// Filter DM list: only show human agents + AI agents with unread DMs.
|
||||
// This hides agent-to-agent internal conversations from the sidebar.
|
||||
let dmAgentList = $derived(
|
||||
agentList.filter(agent => {
|
||||
if (agent.type !== 'ai') return true;
|
||||
const unread = $notifications.dms.get(agent.name) ?? 0;
|
||||
return unread > 0;
|
||||
})
|
||||
);
|
||||
|
||||
const adminLinks = [
|
||||
{ href: '/agents', label: 'Agents' },
|
||||
{ href: '/runs', label: 'Agent Runs' },
|
||||
{ href: '/skills', label: 'Skills' },
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
</script>
|
||||
@@ -207,10 +219,10 @@
|
||||
</button>
|
||||
{#if dmsExpanded}
|
||||
<div class="mt-0.5">
|
||||
{#if agentList.length === 0}
|
||||
{#if dmAgentList.length === 0}
|
||||
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
|
||||
{:else}
|
||||
{#each agentList as agent}
|
||||
{#each dmAgentList as agent}
|
||||
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
|
||||
<a
|
||||
href="/dm/{agent.name}"
|
||||
@@ -264,6 +276,10 @@
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{:else if link.label === 'Skills'}
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
{:else if link.label === 'Settings'}
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { activeThread, closeThread } from '$lib/stores/thread';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let threadMessages = $state<any[]>([]);
|
||||
let parentMessage = $state<any>(null);
|
||||
let threadReplies = $state<any[]>([]);
|
||||
let loadingThread = $state(false);
|
||||
let replyBody = $state('');
|
||||
let sending = $state(false);
|
||||
@@ -20,20 +20,24 @@
|
||||
error = '';
|
||||
replyBody = '';
|
||||
if (val) {
|
||||
loadThread(val.conversationId);
|
||||
loadThread(val.messageId);
|
||||
} else {
|
||||
conversation = null;
|
||||
threadMessages = [];
|
||||
parentMessage = null;
|
||||
threadReplies = [];
|
||||
}
|
||||
});
|
||||
|
||||
async function loadThread(conversationId: number) {
|
||||
async function loadThread(messageId: number) {
|
||||
loadingThread = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await convsApi.get(conversationId);
|
||||
conversation = res.conversation;
|
||||
threadMessages = res.messages;
|
||||
// Load parent message and its replies separately
|
||||
const [msgRes, repliesRes] = await Promise.all([
|
||||
messagesApi.get(messageId),
|
||||
messagesApi.getReplies(messageId)
|
||||
]);
|
||||
parentMessage = msgRes;
|
||||
threadReplies = repliesRes.replies || [];
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Could not load thread';
|
||||
} finally {
|
||||
@@ -45,28 +49,27 @@
|
||||
e.preventDefault();
|
||||
if (!replyBody.trim() || !currentThread) return;
|
||||
|
||||
// Determine recipient: use last message's sender, or fallback to stored fromAgent
|
||||
const recipient = threadMessages.length > 0
|
||||
? threadMessages[threadMessages.length - 1].from_agent
|
||||
: currentThread.fromAgent;
|
||||
|
||||
if (!recipient) {
|
||||
error = 'Cannot determine recipient';
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
error = '';
|
||||
try {
|
||||
await messagesApi.send({
|
||||
to: recipient,
|
||||
body: replyBody.trim(),
|
||||
reply_to: currentThread.messageId,
|
||||
conversation_id: currentThread.conversationId,
|
||||
subject: conversation?.subject
|
||||
});
|
||||
// For channel messages, send as channel message with reply_to
|
||||
if (parentMessage?.channel_id) {
|
||||
await messagesApi.send({
|
||||
body: replyBody.trim(),
|
||||
channel_id: parentMessage.channel_id,
|
||||
reply_to: currentThread.messageId
|
||||
});
|
||||
} else {
|
||||
// For DMs, send to the other party
|
||||
const recipient = parentMessage?.from_agent || currentThread.fromAgent;
|
||||
await messagesApi.send({
|
||||
to: recipient,
|
||||
body: replyBody.trim(),
|
||||
reply_to: currentThread.messageId
|
||||
});
|
||||
}
|
||||
replyBody = '';
|
||||
await loadThread(currentThread.conversationId);
|
||||
await loadThread(currentThread.messageId);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to send reply';
|
||||
} finally {
|
||||
@@ -101,6 +104,11 @@
|
||||
if (form) form.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Combine parent + replies for display
|
||||
let allMessages = $derived(
|
||||
parentMessage ? [parentMessage, ...threadReplies] : threadReplies
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if currentThread}
|
||||
@@ -109,11 +117,13 @@
|
||||
<div class="flex items-center justify-between h-12 px-4 border-b border-border flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display font-bold text-sm text-text-primary truncate">Thread</h3>
|
||||
{#if conversation}
|
||||
<p class="text-[10px] text-text-secondary truncate">{conversation.subject || 'Conversation #' + conversation.id}</p>
|
||||
{:else if currentThread.fromAgent}
|
||||
<p class="text-[10px] text-text-secondary truncate">Reply to {currentThread.fromAgent}</p>
|
||||
{/if}
|
||||
<p class="text-[10px] text-text-secondary truncate">
|
||||
{#if parentMessage}
|
||||
{threadReplies.length} {threadReplies.length === 1 ? 'reply' : 'replies'}
|
||||
{:else}
|
||||
Loading...
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
|
||||
@@ -143,14 +153,14 @@
|
||||
{:else if loadError}
|
||||
<div class="p-4 text-center text-text-secondary text-xs">
|
||||
<p>Thread history unavailable</p>
|
||||
<p class="mt-1 text-[10px]">You can still send a reply below</p>
|
||||
<p class="mt-1 text-[10px]">{loadError}</p>
|
||||
</div>
|
||||
{:else if threadMessages.length === 0}
|
||||
{:else if allMessages.length === 0}
|
||||
<div class="p-4 text-center text-text-secondary text-xs">
|
||||
No messages in this thread yet
|
||||
</div>
|
||||
{:else}
|
||||
{#each threadMessages as msg, i (msg.id)}
|
||||
{#each allMessages as msg, i (msg.id)}
|
||||
<div class="px-4 py-3 hover:bg-bg-tertiary/50 transition-colors {i === 0 ? 'border-b border-border bg-bg-primary/30' : ''}">
|
||||
<div class="flex gap-2.5">
|
||||
<div class="w-7 h-7 rounded-full bg-bg-tertiary flex items-center justify-center text-[11px] font-bold text-text-secondary flex-shrink-0">
|
||||
@@ -160,7 +170,7 @@
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="font-semibold text-xs text-text-primary">{msg.from_agent}</span>
|
||||
<span class="text-[10px] text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
{#if msg.status !== 'done'}
|
||||
{#if msg.status && msg.status !== 'done' && msg.status !== 'pending'}
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { agents as agentsApi } from '$lib/api/client';
|
||||
import { agents as agentsApi, onboarding } from '$lib/api/client';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import AgentCard from '$lib/components/AgentCard.svelte';
|
||||
|
||||
@@ -9,10 +9,25 @@
|
||||
|
||||
let newName = $state('');
|
||||
let newDisplayName = $state('');
|
||||
let newArchetype = $state('');
|
||||
let registering = $state(false);
|
||||
let registerError = $state('');
|
||||
let newApiKey = $state('');
|
||||
let copiedField = $state('');
|
||||
let showQuickStart = $state(false);
|
||||
let claudeMdContent = $state('');
|
||||
let mcpConfigContent = $state<any>(null);
|
||||
let loadingOnboarding = $state(false);
|
||||
|
||||
const archetypes = [
|
||||
{ value: '', label: 'Select archetype...', icon: '' },
|
||||
{ value: 'researcher', label: 'Researcher', icon: '🔍' },
|
||||
{ value: 'writer', label: 'Writer', icon: '✍️' },
|
||||
{ value: 'commenter', label: 'Commenter', icon: '💬' },
|
||||
{ value: 'monitor', label: 'Monitor', icon: '📡' },
|
||||
{ value: 'operator', label: 'Operator', icon: '⚙️' },
|
||||
{ value: 'custom', label: 'Custom', icon: '🧩' }
|
||||
];
|
||||
|
||||
type ClientId = 'claude-code' | 'gemini' | 'cursor' | 'windsurf' | 'vscode' | 'claude-desktop';
|
||||
type AuthMode = 'apikey' | 'oauth';
|
||||
@@ -134,13 +149,17 @@
|
||||
registerError = '';
|
||||
newApiKey = '';
|
||||
try {
|
||||
const capabilities = newArchetype ? { archetype: newArchetype } : undefined;
|
||||
const res = await agentsApi.register({
|
||||
name: newName.trim(),
|
||||
display_name: newDisplayName.trim() || undefined,
|
||||
type: 'ai'
|
||||
type: 'ai',
|
||||
capabilities
|
||||
});
|
||||
newApiKey = res.api_key;
|
||||
await loadAgents();
|
||||
// Load onboarding data for quick start
|
||||
loadOnboardingData(newName.trim());
|
||||
} catch (err: any) {
|
||||
registerError = err.message || 'Failed to register agent';
|
||||
} finally {
|
||||
@@ -148,14 +167,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOnboardingData(agentName: string) {
|
||||
loadingOnboarding = true;
|
||||
try {
|
||||
const [claudeMd, mcpConfig] = await Promise.allSettled([
|
||||
onboarding.claudeMd(agentName, newArchetype || undefined),
|
||||
onboarding.mcpConfig(agentName)
|
||||
]);
|
||||
claudeMdContent = claudeMd.status === 'fulfilled' ? claudeMd.value : '';
|
||||
mcpConfigContent = mcpConfig.status === 'fulfilled' ? (mcpConfig.value as any).config : null;
|
||||
} catch {
|
||||
// Endpoints may not exist yet
|
||||
} finally {
|
||||
loadingOnboarding = false;
|
||||
showQuickStart = true;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadClaudeMd() {
|
||||
const content = claudeMdContent || `# Agent: ${newName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`;
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyMcpConfig() {
|
||||
const config = mcpConfigContent ? JSON.stringify(mcpConfigContent, null, 2) : currentConfig;
|
||||
await copyText(config, 'mcp-config');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
showRegister = false;
|
||||
newApiKey = '';
|
||||
newName = '';
|
||||
newDisplayName = '';
|
||||
newArchetype = '';
|
||||
registerError = '';
|
||||
selectedClient = 'claude-code';
|
||||
authMode = 'apikey';
|
||||
showQuickStart = false;
|
||||
claudeMdContent = '';
|
||||
mcpConfigContent = null;
|
||||
loadingOnboarding = false;
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
@@ -277,6 +336,56 @@
|
||||
<p class="text-[10px] text-text-secondary mt-1.5">Add to <code class="font-mono">{getConfigFilePath(selectedClient)}</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start Panel -->
|
||||
{#if showQuickStart}
|
||||
<div class="mb-4 p-4 bg-accent-purple/5 border border-accent-purple/20 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-text-primary font-display mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Quick Start
|
||||
</h4>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={downloadClaudeMd}
|
||||
disabled={loadingOnboarding}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download CLAUDE.md
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={copyMcpConfig}
|
||||
disabled={loadingOnboarding}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="space-y-2 text-xs text-text-secondary">
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn-primary w-full"
|
||||
onclick={resetForm}
|
||||
@@ -300,6 +409,15 @@
|
||||
<label for="agent-display" class="block text-xs font-medium text-text-secondary mb-1">Display name <span class="text-text-secondary">(optional)</span></label>
|
||||
<input id="agent-display" type="text" class="input" placeholder="e.g. Research Agent" bind:value={newDisplayName} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="agent-archetype" class="block text-xs font-medium text-text-secondary mb-1">Archetype</label>
|
||||
<select id="agent-archetype" class="input" bind:value={newArchetype}>
|
||||
{#each archetypes as arch}
|
||||
<option value={arch.value}>{arch.icon}{arch.icon ? ' ' : ''}{arch.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-[10px] text-text-secondary mt-1">Determines the agent's CLAUDE.md template and default skills.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full" disabled={registering || !newName.trim()}>
|
||||
{registering ? 'Registering...' : 'Register Agent'}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { agents as agentsApi } from '$lib/api/client';
|
||||
import { agents as agentsApi, trust as trustApi, onboarding } from '$lib/api/client';
|
||||
import TraceViewer from '$lib/components/TraceViewer.svelte';
|
||||
|
||||
let agent = $state<any>(null);
|
||||
@@ -18,6 +18,26 @@
|
||||
let savingName = $state(false);
|
||||
let nameError = $state('');
|
||||
|
||||
// Getting Started
|
||||
let gettingStartedOpen = $state(false);
|
||||
let mcpConfigData = $state<any>(null);
|
||||
let mcpConfigLoading = $state(false);
|
||||
let copiedField = $state('');
|
||||
|
||||
const archetypeLabels: Record<string, { label: string; color: string }> = {
|
||||
researcher: { label: 'Researcher', color: 'bg-accent-blue/20 text-accent-blue' },
|
||||
writer: { label: 'Writer', color: 'bg-accent-green/20 text-accent-green' },
|
||||
commenter: { label: 'Commenter', color: 'bg-accent-yellow/20 text-accent-yellow' },
|
||||
monitor: { label: 'Monitor', color: 'bg-accent-purple/20 text-accent-purple' },
|
||||
operator: { label: 'Operator', color: 'bg-accent-red/20 text-accent-red' },
|
||||
custom: { label: 'Custom', color: 'bg-bg-tertiary text-text-secondary' }
|
||||
};
|
||||
|
||||
// Trust Scores state
|
||||
let trustScores = $state<Record<string, number>>({});
|
||||
let trustLoading = $state(false);
|
||||
let trustEntries = $derived(Object.entries(trustScores).sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
// Access Rights state
|
||||
let allowedChannels = $state('');
|
||||
let readOnly = $state(false);
|
||||
@@ -44,6 +64,8 @@
|
||||
allowedChannels = (caps.allowed_channels || []).join(', ');
|
||||
readOnly = caps.read_only ?? false;
|
||||
maxRate = caps.max_rate ?? 60;
|
||||
// Load trust scores
|
||||
loadTrustScores();
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
@@ -51,6 +73,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrustScores() {
|
||||
trustLoading = true;
|
||||
try {
|
||||
const res = await trustApi.get(agentName);
|
||||
trustScores = res.scores || {};
|
||||
} catch {
|
||||
trustScores = {};
|
||||
} finally {
|
||||
trustLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
@@ -144,6 +178,63 @@
|
||||
savingAccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGettingStarted() {
|
||||
gettingStartedOpen = !gettingStartedOpen;
|
||||
if (gettingStartedOpen && !mcpConfigData) {
|
||||
mcpConfigLoading = true;
|
||||
try {
|
||||
const res = await onboarding.mcpConfig(agentName);
|
||||
mcpConfigData = res;
|
||||
} catch {
|
||||
mcpConfigData = null;
|
||||
} finally {
|
||||
mcpConfigLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAgentClaudeMd() {
|
||||
const archetype = agent?.capabilities?.archetype;
|
||||
try {
|
||||
const content = await onboarding.claudeMd(agentName, archetype);
|
||||
const blob = new Blob([content || `# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Endpoint not available yet
|
||||
const blob = new Blob([`# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CLAUDE.md';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedField = label;
|
||||
setTimeout(() => (copiedField = ''), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMcpConfig() {
|
||||
const config = mcpConfigData ? JSON.stringify(mcpConfigData, null, 2) : '{}';
|
||||
await copyText(config, 'mcp-config');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
@@ -265,6 +356,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started -->
|
||||
<div class="card mb-5">
|
||||
<button
|
||||
class="w-full px-5 py-3 border-b border-border flex items-center justify-between hover:bg-bg-tertiary/30 transition-colors"
|
||||
onclick={toggleGettingStarted}
|
||||
>
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Getting Started
|
||||
{#if agent?.capabilities?.archetype}
|
||||
{@const arch = archetypeLabels[agent.capabilities.archetype]}
|
||||
{#if arch}
|
||||
<span class="badge text-[10px] {arch.color}">{arch.label}</span>
|
||||
{:else}
|
||||
<span class="badge text-[10px] bg-bg-tertiary text-text-secondary">{agent.capabilities.archetype}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h2>
|
||||
<svg class="w-4 h-4 text-text-secondary transition-transform {gettingStartedOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if gettingStartedOpen}
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={downloadAgentClaudeMd}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download CLAUDE.md
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={copyMcpConfig}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- MCP Config preview -->
|
||||
{#if mcpConfigLoading}
|
||||
<div class="skeleton h-20 rounded"></div>
|
||||
{:else if mcpConfigData}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs font-medium text-text-secondary">MCP Configuration</label>
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||
onclick={copyMcpConfig}
|
||||
>
|
||||
{copiedField === 'mcp-config' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border overflow-x-auto">{JSON.stringify(mcpConfigData, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Start steps -->
|
||||
<div>
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Quick Start</h4>
|
||||
<ol class="space-y-2 text-xs text-text-secondary">
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Webhook & K8s Management Links -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
@@ -286,6 +466,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trust Scores -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Trust Scores</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{#if trustLoading}
|
||||
<div class="space-y-3">
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
</div>
|
||||
{:else if trustEntries.length === 0}
|
||||
<p class="text-xs text-text-secondary">No trust scores recorded yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each trustEntries as [actionType, score]}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-text-primary">{actionType}</span>
|
||||
<span class="text-xs text-text-secondary">{Math.round(score * 100)}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {score >= 0.7 ? 'bg-accent-green' : score >= 0.4 ? 'bg-accent-yellow' : 'bg-accent-red'}"
|
||||
style="width: {Math.round(score * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Rights -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newIsPrivate = $state(false);
|
||||
let newType = $state('standard');
|
||||
let newWorkflowEnabled = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state('');
|
||||
|
||||
@@ -43,11 +45,22 @@
|
||||
await channelsApi.create({
|
||||
name: newName.trim(),
|
||||
description: newDescription.trim(),
|
||||
is_private: newIsPrivate
|
||||
is_private: newIsPrivate,
|
||||
type: newType
|
||||
});
|
||||
// Enable workflow if requested (separate settings call)
|
||||
if (newWorkflowEnabled) {
|
||||
try {
|
||||
await channelsApi.updateSettings(newName.trim(), { workflow_enabled: true });
|
||||
} catch {
|
||||
// Channel created but workflow toggle failed -- non-fatal
|
||||
}
|
||||
}
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newIsPrivate = false;
|
||||
newType = 'standard';
|
||||
newWorkflowEnabled = false;
|
||||
showCreate = false;
|
||||
await loadChannels();
|
||||
} catch (err: any) {
|
||||
@@ -81,10 +94,24 @@
|
||||
<div class="space-y-3">
|
||||
<input type="text" class="input" placeholder="Channel name (e.g. research-findings)" bind:value={newName} />
|
||||
<input type="text" class="input" placeholder="Description (optional)" bind:value={newDescription} />
|
||||
<div>
|
||||
<label class="block text-xs text-text-secondary mb-1" for="channel-type">Channel Type</label>
|
||||
<select id="channel-type" bind:value={newType} class="input">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="blackboard">Blackboard</option>
|
||||
<option value="auction">Auction</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newIsPrivate} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Private channel (invite-only)
|
||||
</label>
|
||||
{#if newType !== 'auction'}
|
||||
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newWorkflowEnabled} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Enable workflow reactions
|
||||
</label>
|
||||
{/if}
|
||||
<button type="submit" class="btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
@@ -121,9 +148,14 @@
|
||||
<p class="text-xs text-text-secondary truncate mt-0.5">{ch.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
|
||||
{ch.member_count} members
|
||||
</span>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{#if ch.type && ch.type !== 'standard'}
|
||||
<span class="badge bg-accent-purple/20 text-accent-purple text-[10px]">{ch.type}</span>
|
||||
{/if}
|
||||
<span class="badge bg-bg-tertiary text-text-secondary">
|
||||
{ch.member_count} members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
let uploading = $state(false);
|
||||
let fileInputEl: HTMLInputElement;
|
||||
|
||||
// Workflow settings state
|
||||
let settingsSaving = $state(false);
|
||||
let settingsError = $state('');
|
||||
|
||||
function triggerFileInput() { fileInputEl?.click(); }
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
@@ -218,6 +222,19 @@
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function updateSetting(settings: Record<string, any>) {
|
||||
settingsSaving = true;
|
||||
settingsError = '';
|
||||
try {
|
||||
const res = await channelsApi.updateSettings(channelName, settings);
|
||||
channel = res.channel;
|
||||
} catch (err: any) {
|
||||
settingsError = err.message || 'Failed to update settings';
|
||||
} finally {
|
||||
settingsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
let isMember = $derived(
|
||||
members.some(m => agentList.some(a => a.name === m.agent_name))
|
||||
);
|
||||
@@ -365,7 +382,6 @@
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log"
|
||||
bind:this={fileInputEl}
|
||||
onchange={handleFileSelected}
|
||||
/>
|
||||
@@ -459,6 +475,98 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Workflow Settings -->
|
||||
{#if channel && channel.type !== 'auction'}
|
||||
<div class="border-t border-border pt-3 mt-3">
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Workflow Settings</h4>
|
||||
{#if settingsError}
|
||||
<div class="mb-2 px-2 py-1.5 bg-accent-red/10 rounded text-[11px] text-accent-red">{settingsError}</div>
|
||||
{/if}
|
||||
<div class="space-y-2.5">
|
||||
<label class="flex items-center justify-between text-xs cursor-pointer">
|
||||
<span class="text-text-primary">Workflow enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.workflow_enabled}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ workflow_enabled: (e.target as HTMLInputElement).checked })}
|
||||
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
|
||||
/>
|
||||
</label>
|
||||
{#if channel.workflow_enabled}
|
||||
<label class="flex items-center justify-between text-xs cursor-pointer">
|
||||
<span class="text-text-primary">Auto-approve</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channel.auto_approve}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ auto_approve: (e.target as HTMLInputElement).checked })}
|
||||
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="publish-threshold">
|
||||
Publish threshold
|
||||
<span class="text-text-secondary ml-1">{channel.publish_threshold ?? 0}</span>
|
||||
</label>
|
||||
<input
|
||||
id="publish-threshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={channel.publish_threshold ?? 0}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ publish_threshold: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="approve-threshold">
|
||||
Approve threshold
|
||||
<span class="text-text-secondary ml-1">{channel.approve_threshold ?? 0}</span>
|
||||
</label>
|
||||
<input
|
||||
id="approve-threshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={channel.approve_threshold ?? 0}
|
||||
disabled={settingsSaving}
|
||||
onchange={(e) => updateSetting({ approve_threshold: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="stalemate-remind">Stalemate remind after</label>
|
||||
<input
|
||||
id="stalemate-remind"
|
||||
type="text"
|
||||
value={channel.stalemate_remind_after || ''}
|
||||
disabled={settingsSaving}
|
||||
placeholder="e.g. 24h, 7d"
|
||||
onchange={(e) => updateSetting({ stalemate_remind_after: (e.target as HTMLInputElement).value })}
|
||||
class="input text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-text-primary mb-1" for="stalemate-escalate">Stalemate escalate after</label>
|
||||
<input
|
||||
id="stalemate-escalate"
|
||||
type="text"
|
||||
value={channel.stalemate_escalate_after || ''}
|
||||
disabled={settingsSaving}
|
||||
placeholder="e.g. 72h, 14d"
|
||||
onchange={(e) => updateSetting({ stalemate_escalate_after: (e.target as HTMLInputElement).value })}
|
||||
class="input text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-border pt-3 mt-3">
|
||||
<h4 class="text-xs font-medium text-text-secondary mb-2">Members ({members.length})</h4>
|
||||
{#if members.length === 0}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { agents as agentsApi, messages as messagesApi, attachments as attachmentsApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
import WorkflowBadge from '$lib/components/WorkflowBadge.svelte';
|
||||
import ReactionPills from '$lib/components/ReactionPills.svelte';
|
||||
|
||||
let peerAgent = $derived($page.params.name);
|
||||
let peer = $state<any>(null);
|
||||
@@ -17,6 +20,13 @@
|
||||
let sending = $state(false);
|
||||
let sendError = $state('');
|
||||
|
||||
// Attachment state
|
||||
type UploadedAttachment = { hash: string; original_filename: string; size: number; mime_type: string };
|
||||
let uploadedAttachments = $state<UploadedAttachment[]>([]);
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state('');
|
||||
let fileInputEl: HTMLInputElement | undefined = $state(undefined);
|
||||
|
||||
// Mark-as-read timer
|
||||
let markReadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -94,15 +104,18 @@
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
if (!body.trim()) return;
|
||||
if (!body.trim() && uploadedAttachments.length === 0) return;
|
||||
sending = true;
|
||||
sendError = '';
|
||||
try {
|
||||
await messagesApi.send({
|
||||
to: peerAgent,
|
||||
body: body.trim()
|
||||
body: body.trim(),
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments.map(a => a.hash) : undefined
|
||||
});
|
||||
body = '';
|
||||
uploadedAttachments = [];
|
||||
uploadError = '';
|
||||
await loadMessages();
|
||||
} catch (err: any) {
|
||||
sendError = err.message || 'Failed to send message';
|
||||
@@ -146,6 +159,32 @@
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
const result = await attachmentsApi.upload(file);
|
||||
uploadedAttachments = [...uploadedAttachments, result];
|
||||
} catch (err: any) {
|
||||
uploadError = err.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(hash: string) {
|
||||
uploadedAttachments = uploadedAttachments.filter(a => a.hash !== hash);
|
||||
}
|
||||
|
||||
let isOwnAgent = $derived(ownAgents.some(a => a.name === peerAgent));
|
||||
</script>
|
||||
|
||||
@@ -246,6 +285,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.attachments?.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mt-1.5">
|
||||
{#each msg.attachments as att (att.hash)}
|
||||
<AttachmentPreview attachment={att} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.workflow_state}
|
||||
<WorkflowBadge state={msg.workflow_state} />
|
||||
{/if}
|
||||
<ReactionPills reactions={msg.reactions ?? []} messageId={msg.id} />
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
@@ -280,11 +330,34 @@
|
||||
{#if sendError}
|
||||
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{sendError}</div>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<div class="mb-2 px-3 py-1.5 bg-accent-red/10 rounded text-xs text-accent-red">{uploadError}</div>
|
||||
{/if}
|
||||
{#if ownAgents.length === 0}
|
||||
<div class="px-3 py-2 bg-bg-tertiary rounded text-xs text-text-secondary text-center">
|
||||
Register an agent to send messages
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hidden file input -->
|
||||
<input bind:this={fileInputEl} type="file" class="hidden" onchange={handleFileSelected} />
|
||||
{#if uploadedAttachments.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{#each uploadedAttachments as att (att.hash)}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-bg-secondary border border-border rounded text-xs text-text-primary">
|
||||
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" /></svg>
|
||||
{att.original_filename}
|
||||
<span class="text-text-secondary">({formatFileSize(att.size)})</span>
|
||||
<button class="ml-0.5 text-text-secondary hover:text-accent-red" onclick={() => removeAttachment(att.hash)} title="Remove">×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if uploading}
|
||||
<div class="flex items-center gap-2 px-3 pt-1 text-xs text-text-secondary">
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
Uploading...
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-end gap-2 bg-bg-tertiary rounded-lg border border-border focus-within:border-border-active transition-colors">
|
||||
<textarea
|
||||
placeholder="Message {peer?.display_name || peerAgent}..."
|
||||
@@ -293,9 +366,19 @@
|
||||
rows="1"
|
||||
onkeydown={handleKeydown}
|
||||
></textarea>
|
||||
<button
|
||||
class="p-2 mr-0.5 mb-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-bg-secondary transition-colors disabled:opacity-40"
|
||||
disabled={uploading}
|
||||
onclick={() => fileInputEl?.click()}
|
||||
title="Attach file"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 mr-1 mb-1 rounded-md bg-accent-green text-white hover:brightness-110 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={sending || !body.trim()}
|
||||
disabled={sending || (!body.trim() && uploadedAttachments.length === 0)}
|
||||
onclick={handleSend}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
<script lang="ts">
|
||||
import { runs } from '$lib/api/client';
|
||||
import { user } from '$lib/stores/auth';
|
||||
|
||||
let runsList = $state<any[]>([]);
|
||||
let total = $state(0);
|
||||
let reactiveAgents = $state<any[]>([]);
|
||||
let filterAgent = $state('');
|
||||
let filterStatus = $state('');
|
||||
let loading = $state(true);
|
||||
let expandedRun = $state<number | null>(null);
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let _initialized = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!_initialized && $user) {
|
||||
_initialized = true;
|
||||
loadData();
|
||||
_intervalId = setInterval(loadData, 10000);
|
||||
}
|
||||
return () => {
|
||||
if (_intervalId) {
|
||||
clearInterval(_intervalId);
|
||||
_intervalId = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [runsRes, agentsRes] = await Promise.all([
|
||||
runs.list({ agent: filterAgent || undefined, status: filterStatus || undefined, limit: 50 }),
|
||||
runs.reactiveAgents()
|
||||
]);
|
||||
runsList = runsRes.runs ?? [];
|
||||
total = runsRes.total;
|
||||
reactiveAgents = agentsRes.agents ?? [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load runs data:', e);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'succeeded': return 'var(--color-success, #22c55e)';
|
||||
case 'running': return 'var(--color-warning, #eab308)';
|
||||
case 'failed': return 'var(--color-error, #ef4444)';
|
||||
case 'queued': return 'var(--color-info, #3b82f6)';
|
||||
case 'cooldown_skipped': return '#94a3b8';
|
||||
case 'budget_exhausted': return '#f97316';
|
||||
case 'depth_exceeded': return '#a855f7';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (!ms) return '-';
|
||||
const secs = Math.floor(ms / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
return `${Math.floor(secs / 60)}m${secs % 60}s`;
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return '-';
|
||||
const d = new Date(t);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function triggerLabel(run: any): string {
|
||||
if (run.trigger_event === 'message.received') return `DM from ${run.trigger_from || 'unknown'}`;
|
||||
if (run.trigger_event === 'message.mentioned') return `@mention by ${run.trigger_from || 'unknown'}`;
|
||||
return run.trigger_event;
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'idle': return '#22c55e';
|
||||
case 'running': return '#eab308';
|
||||
case 'queued': return '#3b82f6';
|
||||
case 'cooldown': return '#94a3b8';
|
||||
case 'budget_exhausted': return '#f97316';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRun(id: number) {
|
||||
try {
|
||||
await runs.retry(id);
|
||||
await loadData();
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Retry failed');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedRun = expandedRun === id ? null : id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Agent Runs - SynapBus</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<h1>Agent Runs</h1>
|
||||
|
||||
<!-- Agent summary cards -->
|
||||
{#if reactiveAgents.length > 0}
|
||||
<div class="agent-cards">
|
||||
{#each reactiveAgents as agent}
|
||||
<div class="agent-card">
|
||||
<div class="agent-card-header">
|
||||
<span class="agent-name">{agent.name}</span>
|
||||
<span class="state-badge" style="background:{stateColor(agent.state)}">{agent.state}</span>
|
||||
</div>
|
||||
<div class="agent-card-stats">
|
||||
<span class="stat">
|
||||
<span class="stat-value">{agent.today_runs}/{agent.daily_trigger_budget}</span>
|
||||
<span class="stat-label">runs today</span>
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-value">{agent.cooldown_seconds}s</span>
|
||||
<span class="stat-label">cooldown</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select bind:value={filterAgent} onchange={() => loadData()}>
|
||||
<option value="">All agents</option>
|
||||
{#each reactiveAgents as agent}
|
||||
<option value={agent.name}>{agent.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={filterStatus} onchange={() => loadData()}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="succeeded">Succeeded</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="cooldown_skipped">Cooldown Skipped</option>
|
||||
<option value="budget_exhausted">Budget Exhausted</option>
|
||||
<option value="depth_exceeded">Depth Exceeded</option>
|
||||
</select>
|
||||
<span class="total-count">{total} runs</span>
|
||||
</div>
|
||||
|
||||
<!-- Runs list -->
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if runsList.length === 0}
|
||||
<div class="empty">No reactive runs found.</div>
|
||||
{:else}
|
||||
<div class="runs-list">
|
||||
{#each runsList as run}
|
||||
<div class="run-row" class:expanded={expandedRun === run.id}>
|
||||
<button class="run-row-main" onclick={() => toggleExpand(run.id)}>
|
||||
<span class="status-dot" style="background:{statusColor(run.status)}"></span>
|
||||
<span class="run-agent">{run.agent_name}</span>
|
||||
<span class="run-trigger">{triggerLabel(run)}</span>
|
||||
<span class="run-status">{run.status}</span>
|
||||
<span class="run-duration">{formatDuration(run.duration_ms)}</span>
|
||||
<span class="run-time">{formatTime(run.created_at)}</span>
|
||||
<span class="expand-arrow">{expandedRun === run.id ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
|
||||
{#if expandedRun === run.id}
|
||||
<div class="run-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Run ID</span>
|
||||
<span class="detail-value">{run.id}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">K8s Job</span>
|
||||
<span class="detail-value">{run.k8s_job_name || '-'}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Depth</span>
|
||||
<span class="detail-value">{run.trigger_depth}</span>
|
||||
</div>
|
||||
{#if run.trigger_message_id}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Trigger Message</span>
|
||||
<a href="/dm/{run.trigger_from}?msg={run.trigger_message_id}" class="detail-link">
|
||||
Message #{run.trigger_message_id}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if run.error_log}
|
||||
<div class="error-log">
|
||||
<div class="error-log-header">Error Log</div>
|
||||
<pre>{run.error_log}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if run.status === 'failed'}
|
||||
<button class="retry-btn" onclick={() => retryRun(run.id)}>
|
||||
Retry
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.agent-cards {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: var(--color-surface, #1e293b);
|
||||
border: 1px solid var(--color-border, #334155);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
background: var(--color-surface, #1e293b);
|
||||
border: 1px solid var(--color-border, #334155);
|
||||
color: var(--color-text, #e2e8f0);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.runs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.run-row {
|
||||
background: var(--color-surface, #1e293b);
|
||||
border: 1px solid var(--color-border, #334155);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.run-row.expanded {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.run-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.run-row-main:hover {
|
||||
background: var(--color-surface-hover, #334155);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.run-agent {
|
||||
font-weight: 600;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.run-trigger {
|
||||
flex: 1;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-status {
|
||||
min-width: 100px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.run-duration {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.run-time {
|
||||
min-width: 100px;
|
||||
text-align: right;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.run-details {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #334155);
|
||||
background: var(--color-surface-alt, #0f172a);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-muted, #94a3b8);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
color: var(--color-primary, #3b82f6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.error-log {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.error-log-header {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.error-log pre {
|
||||
background: #0a0a0a;
|
||||
color: #e2e8f0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.375rem 1rem;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onboarding } from '$lib/api/client';
|
||||
|
||||
let skills = $state<any[]>([]);
|
||||
let loadingData = $state(true);
|
||||
let loadError = $state('');
|
||||
let expandedSkill = $state<string | null>(null);
|
||||
let skillContent = $state<Record<string, string>>({});
|
||||
let loadingContent = $state<Record<string, boolean>>({});
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadSkills();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSkills() {
|
||||
loadingData = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await onboarding.skills();
|
||||
skills = res.skills || [];
|
||||
} catch {
|
||||
loadError = 'Skills library is not available yet. The backend endpoint may not be deployed.';
|
||||
skills = [];
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSkillName(name: string): string {
|
||||
return name
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getDescription(skill: any): string {
|
||||
return skill.description || 'No description available.';
|
||||
}
|
||||
|
||||
async function toggleView(skillName: string) {
|
||||
if (expandedSkill === skillName) {
|
||||
expandedSkill = null;
|
||||
return;
|
||||
}
|
||||
expandedSkill = skillName;
|
||||
if (!skillContent[skillName]) {
|
||||
loadingContent = { ...loadingContent, [skillName]: true };
|
||||
try {
|
||||
const content = await onboarding.skill(skillName);
|
||||
skillContent = { ...skillContent, [skillName]: content };
|
||||
} catch {
|
||||
skillContent = { ...skillContent, [skillName]: 'Failed to load skill content.' };
|
||||
} finally {
|
||||
loadingContent = { ...loadingContent, [skillName]: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSkill(skill: any) {
|
||||
const content = skillContent[skill.name] || `# ${formatSkillName(skill.name)}\n\n${getDescription(skill)}`;
|
||||
const filename = skill.filename || `${skill.name}.md`;
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadWithFetch(skill: any) {
|
||||
// Try to fetch content first if not cached
|
||||
if (!skillContent[skill.name]) {
|
||||
try {
|
||||
const content = await onboarding.skill(skill.name);
|
||||
skillContent = { ...skillContent, [skill.name]: content };
|
||||
} catch {
|
||||
// Use fallback
|
||||
}
|
||||
}
|
||||
downloadSkill(skill);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="mb-5">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Skills Library</h1>
|
||||
<p class="text-sm text-text-secondary mt-1">Downloadable workflow skills for your agents</p>
|
||||
</div>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="card p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-5 w-1/3"></div>
|
||||
<div class="skeleton h-3 w-2/3"></div>
|
||||
<div class="skeleton h-3 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="card p-8 text-center">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<p class="text-text-secondary text-sm">{loadError}</p>
|
||||
</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="card p-8 text-center">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<p class="text-text-secondary text-sm">No skills available yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each skills as skill (skill.name)}
|
||||
<div class="card">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display">{formatSkillName(skill.name)}</h3>
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary mb-3 line-clamp-2">{getDescription(skill)}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn-primary text-xs flex items-center gap-1.5"
|
||||
onclick={() => downloadWithFetch(skill)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary text-xs flex items-center gap-1.5"
|
||||
onclick={() => toggleView(skill.name)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{expandedSkill === skill.name ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedSkill === skill.name}
|
||||
<div class="border-t border-border p-4">
|
||||
{#if loadingContent[skill.name]}
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-3 w-full"></div>
|
||||
<div class="skeleton h-3 w-4/5"></div>
|
||||
<div class="skeleton h-3 w-3/5"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="text-xs font-mono text-text-primary/80 whitespace-pre-wrap break-words max-h-80 overflow-y-auto">{skillContent[skill.name] || 'No content available.'}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user