Compare commits
17 Commits
b6634e4c1b
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| c30c6dc480 | |||
|
|
26e4ef7d8b | ||
|
|
eb2e05ff84 | ||
| ef4bf1efe0 | |||
|
|
2578876eeb | ||
|
|
95784822ce | ||
|
|
1bb736c09a | ||
|
|
57751f277a | ||
| 966225c3e2 | |||
|
|
e820770409 | ||
|
|
aea4d45fd8 | ||
|
|
0dbb6bb588 | ||
|
|
e67d669df9 | ||
|
|
a784398a10 | ||
|
|
1806a4fe09 | ||
|
|
1ccb84093e | ||
|
|
f866d11bd7 |
44
Dockerfile.simple
Normal file
44
Dockerfile.simple
Normal file
@@ -0,0 +1,44 @@
|
||||
# CHORUS - Simple Docker image using pre-built binary
|
||||
FROM alpine:3.18
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
curl
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1000 chorus && \
|
||||
adduser -u 1000 -G chorus -s /bin/sh -D chorus
|
||||
|
||||
# Create application directories
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R chorus:chorus /app
|
||||
|
||||
# Copy pre-built binary from build directory (ensure it exists and is the correct one)
|
||||
COPY build/chorus-agent /app/chorus-agent
|
||||
RUN chmod +x /app/chorus-agent && chown chorus:chorus /app/chorus-agent
|
||||
|
||||
# Switch to non-root user
|
||||
USER chorus
|
||||
WORKDIR /app
|
||||
|
||||
# Note: Using correct chorus-agent binary built with 'make build-agent'
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8080 8081 9000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8081/health || exit 1
|
||||
|
||||
# Set default environment variables
|
||||
ENV LOG_LEVEL=info \
|
||||
LOG_FORMAT=structured \
|
||||
CHORUS_BIND_ADDRESS=0.0.0.0 \
|
||||
CHORUS_API_PORT=8080 \
|
||||
CHORUS_HEALTH_PORT=8081 \
|
||||
CHORUS_P2P_PORT=9000
|
||||
|
||||
# Start CHORUS
|
||||
ENTRYPOINT ["/app/chorus-agent"]
|
||||
130
Makefile
Normal file
130
Makefile
Normal file
@@ -0,0 +1,130 @@
|
||||
# CHORUS Multi-Binary Makefile
|
||||
# Builds both chorus-agent and chorus-hap binaries
|
||||
|
||||
# Build configuration
|
||||
BINARY_NAME_AGENT = chorus-agent
|
||||
BINARY_NAME_HAP = chorus-hap
|
||||
BINARY_NAME_COMPAT = chorus
|
||||
VERSION ?= 0.1.0-dev
|
||||
COMMIT_HASH ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_DATE ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
|
||||
# Go build flags
|
||||
LDFLAGS = -ldflags "-X main.version=$(VERSION) -X main.commitHash=$(COMMIT_HASH) -X main.buildDate=$(BUILD_DATE)"
|
||||
BUILD_FLAGS = -v $(LDFLAGS)
|
||||
|
||||
# Directories
|
||||
BUILD_DIR = build
|
||||
CMD_DIR = cmd
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: clean build
|
||||
|
||||
# Build all binaries (including compatibility wrapper)
|
||||
.PHONY: build
|
||||
build: build-agent build-hap build-compat
|
||||
|
||||
# Build autonomous agent binary
|
||||
.PHONY: build-agent
|
||||
build-agent:
|
||||
@echo "🤖 Building CHORUS autonomous agent..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_AGENT) ./$(CMD_DIR)/agent
|
||||
@echo "✅ Agent binary built: $(BUILD_DIR)/$(BINARY_NAME_AGENT)"
|
||||
|
||||
# Build human agent portal binary
|
||||
.PHONY: build-hap
|
||||
build-hap:
|
||||
@echo "👤 Building CHORUS human agent portal..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_HAP) ./$(CMD_DIR)/hap
|
||||
@echo "✅ HAP binary built: $(BUILD_DIR)/$(BINARY_NAME_HAP)"
|
||||
|
||||
# Build compatibility wrapper (deprecated)
|
||||
.PHONY: build-compat
|
||||
build-compat:
|
||||
@echo "⚠️ Building CHORUS compatibility wrapper (deprecated)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build $(BUILD_FLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_COMPAT) ./$(CMD_DIR)/chorus
|
||||
@echo "✅ Compatibility wrapper built: $(BUILD_DIR)/$(BINARY_NAME_COMPAT)"
|
||||
|
||||
# Test compilation without building
|
||||
.PHONY: test-compile
|
||||
test-compile:
|
||||
@echo "🔍 Testing compilation of both binaries..."
|
||||
go build -o /dev/null ./$(CMD_DIR)/agent
|
||||
go build -o /dev/null ./$(CMD_DIR)/hap
|
||||
@echo "✅ Both binaries compile successfully"
|
||||
|
||||
# Run tests
|
||||
.PHONY: test
|
||||
test:
|
||||
@echo "🧪 Running tests..."
|
||||
go test -v ./...
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "🧹 Cleaning build artifacts..."
|
||||
rm -rf $(BUILD_DIR)
|
||||
@echo "✅ Clean complete"
|
||||
|
||||
# Install both binaries to GOPATH/bin
|
||||
.PHONY: install
|
||||
install: build
|
||||
@echo "📦 Installing binaries to GOPATH/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME_AGENT) $(shell go env GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME_HAP) $(shell go env GOPATH)/bin/
|
||||
@echo "✅ Binaries installed"
|
||||
|
||||
# Development helpers
|
||||
.PHONY: run-agent
|
||||
run-agent: build-agent
|
||||
@echo "🚀 Running CHORUS agent..."
|
||||
./$(BUILD_DIR)/$(BINARY_NAME_AGENT)
|
||||
|
||||
.PHONY: run-hap
|
||||
run-hap: build-hap
|
||||
@echo "🚀 Running CHORUS HAP..."
|
||||
./$(BUILD_DIR)/$(BINARY_NAME_HAP)
|
||||
|
||||
# Docker builds
|
||||
.PHONY: docker-agent
|
||||
docker-agent:
|
||||
@echo "🐳 Building Docker image for CHORUS agent..."
|
||||
docker build -f docker/Dockerfile.agent -t chorus-agent:$(VERSION) .
|
||||
|
||||
.PHONY: docker-hap
|
||||
docker-hap:
|
||||
@echo "🐳 Building Docker image for CHORUS HAP..."
|
||||
docker build -f docker/Dockerfile.hap -t chorus-hap:$(VERSION) .
|
||||
|
||||
.PHONY: docker
|
||||
docker: docker-agent docker-hap
|
||||
|
||||
# Help
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "CHORUS Multi-Binary Build System"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " all - Clean and build both binaries (default)"
|
||||
@echo " build - Build both binaries"
|
||||
@echo " build-agent - Build autonomous agent binary only"
|
||||
@echo " build-hap - Build human agent portal binary only"
|
||||
@echo " test-compile - Test that both binaries compile"
|
||||
@echo " test - Run tests"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " run-agent - Build and run agent"
|
||||
@echo " run-hap - Build and run HAP"
|
||||
@echo " docker - Build Docker images for both binaries"
|
||||
@echo " docker-agent - Build Docker image for agent only"
|
||||
@echo " docker-hap - Build Docker image for HAP only"
|
||||
@echo " help - Show this help"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " VERSION - Version string (default: 0.1.0-dev)"
|
||||
@echo " COMMIT_HASH - Git commit hash (auto-detected)"
|
||||
@echo " BUILD_DATE - Build timestamp (auto-generated)"
|
||||
111
README.md
111
README.md
@@ -1,99 +1,54 @@
|
||||
# CHORUS - Container-First P2P Task Coordination System
|
||||
# CHORUS – Container-First Context Platform (Alpha)
|
||||
|
||||
CHORUS is a next-generation P2P task coordination and collaborative AI system designed from the ground up for containerized deployments. It takes the best lessons learned from CHORUS and reimagines them for Docker Swarm, Kubernetes, and modern container orchestration platforms.
|
||||
CHORUS is the runtime that ties the CHORUS ecosystem together: libp2p mesh, DHT-backed storage, council/task coordination, and (eventually) SLURP contextual intelligence. The repository you are looking at is the in-progress container-first refactor. Several core systems boot today, but higher-level services (SLURP, SHHH, full HMMM routing) are still landing.
|
||||
|
||||
## Vision
|
||||
## Current Status
|
||||
|
||||
CHORUS enables distributed AI agents to coordinate, collaborate, and execute tasks across container clusters, supporting deployments from single containers to hundreds of instances in enterprise environments.
|
||||
| Area | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| libp2p node + PubSub | ✅ Running | `internal/runtime/shared.go` spins up the mesh, hypercore logging, availability broadcasts. |
|
||||
| DHT + DecisionPublisher | ✅ Running | Encrypted storage wired through `pkg/dht`; decisions written via `ucxl.DecisionPublisher`. |
|
||||
| Election manager | ✅ Running | Admin election integrated with Backbeat; metrics exposed under `pkg/metrics`. |
|
||||
| SLURP (context intelligence) | 🚧 Stubbed | `pkg/slurp/slurp.go` contains TODOs for resolver, temporal graphs, intelligence. Leader integration scaffolding exists but uses placeholder IDs/request forwarding. |
|
||||
| SHHH (secrets sentinel) | 🚧 Sentinel live | `pkg/shhh` redacts hypercore + PubSub payloads with audit + metrics hooks (policy replay TBD). |
|
||||
| HMMM routing | 🚧 Partial | PubSub topics join, but capability/role announcements and HMMM router wiring are placeholders (`internal/runtime/agent_support.go`). |
|
||||
|
||||
## Key Design Principles
|
||||
See `docs/progress/CHORUS-WHOOSH-development-plan.md` for the detailed build plan and `docs/progress/CHORUS-WHOOSH-roadmap.md` for sequencing.
|
||||
|
||||
- **Container-First**: Designed specifically for Docker/Kubernetes deployments
|
||||
- **License-Controlled**: Simple environment variable-based licensing
|
||||
- **Cloud-Native Logging**: Structured logging to stdout/stderr for container runtime collection
|
||||
- **Swarm-Ready P2P**: P2P protocols optimized for container networking
|
||||
- **Scalable Agent IDs**: Agent identification system that works across distributed deployments
|
||||
- **Zero-Config**: Minimal configuration requirements via environment variables
|
||||
## Quick Start (Alpha)
|
||||
|
||||
## Architecture
|
||||
The container-first workflows are still evolving; expect frequent changes.
|
||||
|
||||
CHORUS follows a microservices architecture where each container runs a single agent instance:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ CHORUS Agent │ │ CHORUS Agent │ │ CHORUS Agent │
|
||||
│ Container 1 │◄─┤ Container 2 │─►│ Container N │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└──────────────────────┼──────────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Container │
|
||||
│ Network │
|
||||
│ (P2P Mesh) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Valid CHORUS license key
|
||||
- Access to Ollama endpoints for AI functionality
|
||||
|
||||
### Basic Deployment
|
||||
|
||||
1. Clone and configure:
|
||||
```bash
|
||||
git clone https://gitea.chorus.services/tony/CHORUS.git
|
||||
cd CHORUS
|
||||
cp docker/chorus.env.example docker/chorus.env
|
||||
# Edit docker/chorus.env with your license key and configuration
|
||||
# adjust env vars (KACHING license, bootstrap peers, etc.)
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
2. Deploy:
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
You’ll get a single agent container with:
|
||||
- libp2p networking (mDNS + configured bootstrap peers)
|
||||
- election heartbeat
|
||||
- DHT storage (AGE-encrypted)
|
||||
- HTTP API + health endpoints
|
||||
|
||||
3. Scale (Docker Swarm):
|
||||
```bash
|
||||
docker service scale chorus_agent=10
|
||||
```
|
||||
**Missing today:** SLURP context resolution, advanced SHHH policy replay, HMMM per-issue routing. Expect log warnings/TODOs for those paths.
|
||||
|
||||
## Licensing
|
||||
## Roadmap Highlights
|
||||
|
||||
CHORUS requires a valid license key to operate. Set your license key in the environment:
|
||||
1. **Security substrate** – land SHHH sentinel, finish SLURP leader-only operations, validate COOEE enrolment (see roadmap Phase 1).
|
||||
2. **Autonomous teams** – coordinate with WHOOSH for deployment telemetry + SLURP context export.
|
||||
3. **UCXL + KACHING** – hook runtime telemetry into KACHING and enforce UCXL validator.
|
||||
|
||||
```env
|
||||
CHORUS_LICENSE_KEY=your-license-key-here
|
||||
CHORUS_LICENSE_EMAIL=your-email@example.com
|
||||
```
|
||||
Track progress via the shared roadmap and weekly burndown dashboards.
|
||||
|
||||
**No license = No operation.** CHORUS will not start without valid licensing.
|
||||
|
||||
## Differences from CHORUS
|
||||
|
||||
| Aspect | CHORUS | CHORUS |
|
||||
|--------|------|--------|
|
||||
| Deployment | systemd service (1 per host) | Container (N per cluster) |
|
||||
| Configuration | Web UI setup | Environment variables |
|
||||
| Logging | Journal/files | stdout/stderr (structured) |
|
||||
| Licensing | Setup-time validation | Runtime environment variable |
|
||||
| Agent IDs | Host-based | Container/cluster-based |
|
||||
| P2P Discovery | mDNS local network | Container network + service discovery |
|
||||
|
||||
## Development Status
|
||||
|
||||
🚧 **Early Development** - CHORUS is being designed and built. Not yet ready for production use.
|
||||
|
||||
Current Phase: Architecture design and core foundation development.
|
||||
|
||||
## License
|
||||
|
||||
CHORUS is a commercial product. Contact chorus.services for licensing information.
|
||||
## Related Projects
|
||||
- [WHOOSH](https://gitea.chorus.services/tony/WHOOSH) – council/team orchestration
|
||||
- [KACHING](https://gitea.chorus.services/tony/KACHING) – telemetry/licensing
|
||||
- [SLURP](https://gitea.chorus.services/tony/SLURP) – contextual intelligence prototypes
|
||||
- [HMMM](https://gitea.chorus.services/tony/hmmm) – meta-discussion layer
|
||||
|
||||
## Contributing
|
||||
|
||||
CHORUS is developed by the chorus.services team. For contributions or feedback, please use the issue tracker on our GITEA instance.
|
||||
This repo is still alpha. Please coordinate via the roadmap tickets before landing changes. Major security/runtime decisions should include a Decision Record with a UCXL address so SLURP/BUBBLE can ingest it later.
|
||||
|
||||
BIN
chorus-agent
Executable file
BIN
chorus-agent
Executable file
Binary file not shown.
67
cmd/agent/main.go
Normal file
67
cmd/agent/main.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"chorus/internal/runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Early CLI handling: print help/version without requiring env/config
|
||||
for _, a := range os.Args[1:] {
|
||||
switch a {
|
||||
case "--help", "-h", "help":
|
||||
fmt.Printf("%s-agent %s\n\n", runtime.AppName, runtime.AppVersion)
|
||||
fmt.Println("Usage:")
|
||||
fmt.Printf(" %s [--help] [--version]\n\n", filepath.Base(os.Args[0]))
|
||||
fmt.Println("CHORUS Autonomous Agent - P2P Task Coordination")
|
||||
fmt.Println()
|
||||
fmt.Println("This binary runs autonomous AI agents that participate in P2P task coordination,")
|
||||
fmt.Println("collaborative reasoning via HMMM, and distributed decision making.")
|
||||
fmt.Println()
|
||||
fmt.Println("Environment (common):")
|
||||
fmt.Println(" CHORUS_LICENSE_ID (required)")
|
||||
fmt.Println(" CHORUS_AGENT_ID (optional; auto-generated if empty)")
|
||||
fmt.Println(" CHORUS_P2P_PORT (default 9000)")
|
||||
fmt.Println(" CHORUS_API_PORT (default 8080)")
|
||||
fmt.Println(" CHORUS_HEALTH_PORT (default 8081)")
|
||||
fmt.Println(" CHORUS_DHT_ENABLED (default true)")
|
||||
fmt.Println(" CHORUS_BOOTSTRAP_PEERS (comma-separated multiaddrs)")
|
||||
fmt.Println(" OLLAMA_ENDPOINT (default http://localhost:11434)")
|
||||
fmt.Println()
|
||||
fmt.Println("Example:")
|
||||
fmt.Println(" CHORUS_LICENSE_ID=dev-123 \\")
|
||||
fmt.Println(" CHORUS_AGENT_ID=chorus-agent-1 \\")
|
||||
fmt.Println(" CHORUS_P2P_PORT=9000 CHORUS_API_PORT=8080 ./chorus-agent")
|
||||
fmt.Println()
|
||||
fmt.Println("Agent Features:")
|
||||
fmt.Println(" - Autonomous task execution")
|
||||
fmt.Println(" - P2P mesh networking")
|
||||
fmt.Println(" - HMMM collaborative reasoning")
|
||||
fmt.Println(" - DHT encrypted storage")
|
||||
fmt.Println(" - UCXL context addressing")
|
||||
fmt.Println(" - Democratic leader election")
|
||||
fmt.Println(" - Health monitoring")
|
||||
return
|
||||
case "--version", "-v":
|
||||
fmt.Printf("%s-agent %s\n", runtime.AppName, runtime.AppVersion)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize shared P2P runtime
|
||||
sharedRuntime, err := runtime.Initialize("agent")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to initialize CHORUS agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sharedRuntime.Cleanup()
|
||||
|
||||
// Start agent mode with autonomous behaviors
|
||||
if err := sharedRuntime.StartAgentMode(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Agent mode failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,688 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"chorus/api"
|
||||
"chorus/coordinator"
|
||||
"chorus/discovery"
|
||||
"chorus/internal/backbeat"
|
||||
"chorus/internal/licensing"
|
||||
"chorus/internal/logging"
|
||||
"chorus/p2p"
|
||||
"chorus/pkg/config"
|
||||
"chorus/pkg/dht"
|
||||
"chorus/pkg/election"
|
||||
"chorus/pkg/health"
|
||||
"chorus/pkg/shutdown"
|
||||
"chorus/pkg/ucxi"
|
||||
"chorus/pkg/ucxl"
|
||||
"chorus/pubsub"
|
||||
"chorus/reasoning"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
"chorus/internal/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "CHORUS"
|
||||
AppVersion = "0.1.0-dev"
|
||||
)
|
||||
|
||||
// SimpleLogger provides basic logging implementation
|
||||
type SimpleLogger struct{}
|
||||
|
||||
func (l *SimpleLogger) Info(msg string, args ...interface{}) {
|
||||
log.Printf("[INFO] "+msg, args...)
|
||||
}
|
||||
|
||||
func (l *SimpleLogger) Warn(msg string, args ...interface{}) {
|
||||
log.Printf("[WARN] "+msg, args...)
|
||||
}
|
||||
|
||||
func (l *SimpleLogger) Error(msg string, args ...interface{}) {
|
||||
log.Printf("[ERROR] "+msg, args...)
|
||||
}
|
||||
|
||||
// SimpleTaskTracker tracks active tasks for availability reporting
|
||||
type SimpleTaskTracker struct {
|
||||
maxTasks int
|
||||
activeTasks map[string]bool
|
||||
decisionPublisher *ucxl.DecisionPublisher
|
||||
}
|
||||
|
||||
// GetActiveTasks returns list of active task IDs
|
||||
func (t *SimpleTaskTracker) GetActiveTasks() []string {
|
||||
tasks := make([]string, 0, len(t.activeTasks))
|
||||
for taskID := range t.activeTasks {
|
||||
tasks = append(tasks, taskID)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
// GetMaxTasks returns maximum number of concurrent tasks
|
||||
func (t *SimpleTaskTracker) GetMaxTasks() int {
|
||||
return t.maxTasks
|
||||
}
|
||||
|
||||
// AddTask marks a task as active
|
||||
func (t *SimpleTaskTracker) AddTask(taskID string) {
|
||||
t.activeTasks[taskID] = true
|
||||
}
|
||||
|
||||
// RemoveTask marks a task as completed and publishes decision if publisher available
|
||||
func (t *SimpleTaskTracker) RemoveTask(taskID string) {
|
||||
delete(t.activeTasks, taskID)
|
||||
|
||||
// Publish task completion decision if publisher is available
|
||||
if t.decisionPublisher != nil {
|
||||
t.publishTaskCompletion(taskID, true, "Task completed successfully", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTaskCompletion publishes a task completion decision to DHT
|
||||
func (t *SimpleTaskTracker) publishTaskCompletion(taskID string, success bool, summary string, filesModified []string) {
|
||||
if t.decisionPublisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.decisionPublisher.PublishTaskCompletion(taskID, success, summary, filesModified); err != nil {
|
||||
fmt.Printf("⚠️ Failed to publish task completion for %s: %v\n", taskID, err)
|
||||
} else {
|
||||
fmt.Printf("📤 Published task completion decision for: %s\n", taskID)
|
||||
}
|
||||
}
|
||||
// DEPRECATED: This binary is deprecated in favor of chorus-agent and chorus-hap
|
||||
// This compatibility wrapper redirects users to the appropriate new binary
|
||||
|
||||
func main() {
|
||||
// Early CLI handling: print help/version without requiring env/config
|
||||
// Early CLI handling: print help/version/deprecation notice
|
||||
for _, a := range os.Args[1:] {
|
||||
switch a {
|
||||
case "--help", "-h", "help":
|
||||
fmt.Printf("%s %s\n\n", AppName, AppVersion)
|
||||
fmt.Println("Usage:")
|
||||
fmt.Printf(" %s [--help] [--version]\n\n", filepath.Base(os.Args[0]))
|
||||
fmt.Println("Environment (common):")
|
||||
fmt.Println(" CHORUS_LICENSE_ID (required)")
|
||||
fmt.Println(" CHORUS_AGENT_ID (optional; auto-generated if empty)")
|
||||
fmt.Println(" CHORUS_P2P_PORT (default 9000)")
|
||||
fmt.Println(" CHORUS_API_PORT (default 8080)")
|
||||
fmt.Println(" CHORUS_HEALTH_PORT (default 8081)")
|
||||
fmt.Println(" CHORUS_DHT_ENABLED (default true)")
|
||||
fmt.Println(" CHORUS_BOOTSTRAP_PEERS (comma-separated multiaddrs)")
|
||||
fmt.Println(" OLLAMA_ENDPOINT (default http://localhost:11434)")
|
||||
fmt.Println()
|
||||
fmt.Println("Example:")
|
||||
fmt.Println(" CHORUS_LICENSE_ID=dev-123 \\")
|
||||
fmt.Println(" CHORUS_AGENT_ID=chorus-dev \\")
|
||||
fmt.Println(" CHORUS_P2P_PORT=9000 CHORUS_API_PORT=8080 ./chorus")
|
||||
printDeprecationHelp()
|
||||
return
|
||||
case "--version", "-v":
|
||||
fmt.Printf("%s %s\n", AppName, AppVersion)
|
||||
fmt.Printf("%s %s (DEPRECATED)\n", runtime.AppName, runtime.AppVersion)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize container-optimized logger
|
||||
logger := &SimpleLogger{}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
logger.Info("🎭 Starting CHORUS v%s - Container-First P2P Task Coordination", AppVersion)
|
||||
logger.Info("📦 Container deployment of proven CHORUS functionality")
|
||||
|
||||
// Load configuration from environment (no config files in containers)
|
||||
logger.Info("📋 Loading configuration from environment variables...")
|
||||
cfg, err := config.LoadFromEnvironment()
|
||||
if err != nil {
|
||||
logger.Error("❌ Configuration error: %v", err)
|
||||
// Print deprecation warning for direct execution
|
||||
printDeprecationWarning()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("✅ Configuration loaded successfully")
|
||||
logger.Info("🤖 Agent ID: %s", cfg.Agent.ID)
|
||||
logger.Info("🎯 Specialization: %s", cfg.Agent.Specialization)
|
||||
|
||||
// CRITICAL: Validate license before any P2P operations
|
||||
logger.Info("🔐 Validating CHORUS license with KACHING...")
|
||||
licenseValidator := licensing.NewValidator(licensing.LicenseConfig{
|
||||
LicenseID: cfg.License.LicenseID,
|
||||
ClusterID: cfg.License.ClusterID,
|
||||
KachingURL: cfg.License.KachingURL,
|
||||
})
|
||||
if err := licenseValidator.Validate(); err != nil {
|
||||
logger.Error("❌ License validation failed: %v", err)
|
||||
logger.Error("💰 CHORUS requires a valid license to operate")
|
||||
logger.Error("📞 Contact chorus.services for licensing information")
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("✅ License validation successful - CHORUS authorized to run")
|
||||
|
||||
// Initialize AI provider configuration
|
||||
logger.Info("🧠 Configuring AI provider: %s", cfg.AI.Provider)
|
||||
if err := initializeAIProvider(cfg, logger); err != nil {
|
||||
logger.Error("❌ AI provider initialization failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("✅ AI provider configured successfully")
|
||||
|
||||
// Initialize BACKBEAT integration
|
||||
var backbeatIntegration *backbeat.Integration
|
||||
backbeatIntegration, err = backbeat.NewIntegration(cfg, cfg.Agent.ID, logger)
|
||||
if err != nil {
|
||||
logger.Warn("⚠️ BACKBEAT integration initialization failed: %v", err)
|
||||
logger.Info("📍 P2P operations will run without beat synchronization")
|
||||
} else {
|
||||
if err := backbeatIntegration.Start(ctx); err != nil {
|
||||
logger.Warn("⚠️ Failed to start BACKBEAT integration: %v", err)
|
||||
backbeatIntegration = nil
|
||||
} else {
|
||||
logger.Info("🎵 BACKBEAT integration started successfully")
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if backbeatIntegration != nil {
|
||||
backbeatIntegration.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize P2P node
|
||||
node, err := p2p.NewNode(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create P2P node: %v", err)
|
||||
}
|
||||
defer node.Close()
|
||||
|
||||
logger.Info("🐝 CHORUS node started successfully")
|
||||
logger.Info("📍 Node ID: %s", node.ID().ShortString())
|
||||
logger.Info("🔗 Listening addresses:")
|
||||
for _, addr := range node.Addresses() {
|
||||
logger.Info(" %s/p2p/%s", addr, node.ID())
|
||||
}
|
||||
|
||||
// Initialize Hypercore-style logger for P2P coordination
|
||||
hlog := logging.NewHypercoreLog(node.ID())
|
||||
hlog.Append(logging.PeerJoined, map[string]interface{}{"status": "started"})
|
||||
logger.Info("📝 Hypercore logger initialized")
|
||||
|
||||
// Initialize mDNS discovery
|
||||
mdnsDiscovery, err := discovery.NewMDNSDiscovery(ctx, node.Host(), "chorus-peer-discovery")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create mDNS discovery: %v", err)
|
||||
}
|
||||
defer mdnsDiscovery.Close()
|
||||
|
||||
// Initialize PubSub with hypercore logging
|
||||
ps, err := pubsub.NewPubSubWithLogger(ctx, node.Host(), "chorus/coordination/v1", "hmmm/meta-discussion/v1", hlog)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create PubSub: %v", err)
|
||||
}
|
||||
defer ps.Close()
|
||||
|
||||
logger.Info("📡 PubSub system initialized")
|
||||
|
||||
// Join role-based topics if role is configured
|
||||
if cfg.Agent.Role != "" {
|
||||
reportsTo := []string{}
|
||||
if cfg.Agent.ReportsTo != "" {
|
||||
reportsTo = []string{cfg.Agent.ReportsTo}
|
||||
}
|
||||
if err := ps.JoinRoleBasedTopics(cfg.Agent.Role, cfg.Agent.Expertise, reportsTo); err != nil {
|
||||
logger.Warn("⚠️ Failed to join role-based topics: %v", err)
|
||||
} else {
|
||||
logger.Info("🎯 Joined role-based collaboration topics")
|
||||
}
|
||||
}
|
||||
|
||||
// === Admin Election System ===
|
||||
electionManager := election.NewElectionManager(ctx, cfg, node.Host(), ps, node.ID().ShortString())
|
||||
|
||||
// Set election callbacks with BACKBEAT integration
|
||||
electionManager.SetCallbacks(
|
||||
func(oldAdmin, newAdmin string) {
|
||||
logger.Info("👑 Admin changed: %s -> %s", oldAdmin, newAdmin)
|
||||
|
||||
// Track admin change with BACKBEAT if available
|
||||
if backbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("admin-change-%d", time.Now().Unix())
|
||||
if err := backbeatIntegration.StartP2POperation(operationID, "admin_change", 2, map[string]interface{}{
|
||||
"old_admin": oldAdmin,
|
||||
"new_admin": newAdmin,
|
||||
}); err == nil {
|
||||
// Complete immediately as this is a state change, not a long operation
|
||||
backbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// If this node becomes admin, enable SLURP functionality
|
||||
if newAdmin == node.ID().ShortString() {
|
||||
logger.Info("🎯 This node is now admin - enabling SLURP functionality")
|
||||
cfg.Slurp.Enabled = true
|
||||
// Apply admin role configuration
|
||||
if err := cfg.ApplyRoleDefinition("admin"); err != nil {
|
||||
logger.Warn("⚠️ Failed to apply admin role: %v", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
func(winner string) {
|
||||
logger.Info("🏆 Election completed, winner: %s", winner)
|
||||
|
||||
// Track election completion with BACKBEAT if available
|
||||
if backbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("election-completed-%d", time.Now().Unix())
|
||||
if err := backbeatIntegration.StartP2POperation(operationID, "election", 1, map[string]interface{}{
|
||||
"winner": winner,
|
||||
"node_id": node.ID().ShortString(),
|
||||
}); err == nil {
|
||||
backbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if err := electionManager.Start(); err != nil {
|
||||
logger.Error("❌ Failed to start election manager: %v", err)
|
||||
} else {
|
||||
logger.Info("✅ Election manager started with automated heartbeat management")
|
||||
}
|
||||
defer electionManager.Stop()
|
||||
|
||||
// === DHT Storage and Decision Publishing ===
|
||||
var dhtNode *dht.LibP2PDHT
|
||||
var encryptedStorage *dht.EncryptedDHTStorage
|
||||
var decisionPublisher *ucxl.DecisionPublisher
|
||||
|
||||
if cfg.V2.DHT.Enabled {
|
||||
// Create DHT
|
||||
dhtNode, err = dht.NewLibP2PDHT(ctx, node.Host())
|
||||
if err != nil {
|
||||
logger.Warn("⚠️ Failed to create DHT: %v", err)
|
||||
} else {
|
||||
logger.Info("🕸️ DHT initialized")
|
||||
|
||||
// Bootstrap DHT with BACKBEAT tracking
|
||||
if backbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("dht-bootstrap-%d", time.Now().Unix())
|
||||
if err := backbeatIntegration.StartP2POperation(operationID, "dht_bootstrap", 4, nil); err == nil {
|
||||
backbeatIntegration.UpdateP2POperationPhase(operationID, backbeat.PhaseConnecting, 0)
|
||||
}
|
||||
|
||||
if err := dhtNode.Bootstrap(); err != nil {
|
||||
logger.Warn("⚠️ DHT bootstrap failed: %v", err)
|
||||
backbeatIntegration.FailP2POperation(operationID, err.Error())
|
||||
} else {
|
||||
backbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
} else {
|
||||
if err := dhtNode.Bootstrap(); err != nil {
|
||||
logger.Warn("⚠️ DHT bootstrap failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to bootstrap peers if configured
|
||||
for _, addrStr := range cfg.V2.DHT.BootstrapPeers {
|
||||
addr, err := multiaddr.NewMultiaddr(addrStr)
|
||||
if err != nil {
|
||||
logger.Warn("⚠️ Invalid bootstrap address %s: %v", addrStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract peer info from multiaddr
|
||||
info, err := peer.AddrInfoFromP2pAddr(addr)
|
||||
if err != nil {
|
||||
logger.Warn("⚠️ Failed to parse peer info from %s: %v", addrStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track peer discovery with BACKBEAT if available
|
||||
if backbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("peer-discovery-%d", time.Now().Unix())
|
||||
if err := backbeatIntegration.StartP2POperation(operationID, "peer_discovery", 2, map[string]interface{}{
|
||||
"peer_addr": addrStr,
|
||||
}); err == nil {
|
||||
backbeatIntegration.UpdateP2POperationPhase(operationID, backbeat.PhaseConnecting, 0)
|
||||
|
||||
if err := node.Host().Connect(ctx, *info); err != nil {
|
||||
logger.Warn("⚠️ Failed to connect to bootstrap peer %s: %v", addrStr, err)
|
||||
backbeatIntegration.FailP2POperation(operationID, err.Error())
|
||||
} else {
|
||||
logger.Info("🔗 Connected to DHT bootstrap peer: %s", addrStr)
|
||||
backbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := node.Host().Connect(ctx, *info); err != nil {
|
||||
logger.Warn("⚠️ Failed to connect to bootstrap peer %s: %v", addrStr, err)
|
||||
} else {
|
||||
logger.Info("🔗 Connected to DHT bootstrap peer: %s", addrStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize encrypted storage
|
||||
encryptedStorage = dht.NewEncryptedDHTStorage(
|
||||
ctx,
|
||||
node.Host(),
|
||||
dhtNode,
|
||||
cfg,
|
||||
node.ID().ShortString(),
|
||||
)
|
||||
|
||||
// Start cache cleanup
|
||||
encryptedStorage.StartCacheCleanup(5 * time.Minute)
|
||||
logger.Info("🔐 Encrypted DHT storage initialized")
|
||||
|
||||
// Initialize decision publisher
|
||||
decisionPublisher = ucxl.NewDecisionPublisher(
|
||||
ctx,
|
||||
cfg,
|
||||
encryptedStorage,
|
||||
node.ID().ShortString(),
|
||||
cfg.Agent.ID,
|
||||
)
|
||||
logger.Info("📤 Decision publisher initialized")
|
||||
}
|
||||
} else {
|
||||
logger.Info("⚪ DHT disabled in configuration")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if dhtNode != nil {
|
||||
dhtNode.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// === Task Coordination Integration ===
|
||||
taskCoordinator := coordinator.NewTaskCoordinator(
|
||||
ctx,
|
||||
ps,
|
||||
hlog,
|
||||
cfg,
|
||||
node.ID().ShortString(),
|
||||
nil, // HMMM router placeholder
|
||||
)
|
||||
|
||||
taskCoordinator.Start()
|
||||
logger.Info("✅ Task coordination system active")
|
||||
|
||||
// Start HTTP API server
|
||||
httpServer := api.NewHTTPServer(cfg.Network.APIPort, hlog, ps)
|
||||
go func() {
|
||||
logger.Info("🌐 HTTP API server starting on :%d", cfg.Network.APIPort)
|
||||
if err := httpServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("❌ HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
defer httpServer.Stop()
|
||||
|
||||
// === UCXI Server Integration ===
|
||||
var ucxiServer *ucxi.Server
|
||||
if cfg.UCXL.Enabled && cfg.UCXL.Server.Enabled {
|
||||
storageDir := cfg.UCXL.Storage.Directory
|
||||
if storageDir == "" {
|
||||
storageDir = filepath.Join(os.TempDir(), "chorus-ucxi-storage")
|
||||
}
|
||||
|
||||
storage, err := ucxi.NewBasicContentStorage(storageDir)
|
||||
if err != nil {
|
||||
logger.Warn("⚠️ Failed to create UCXI storage: %v", err)
|
||||
} else {
|
||||
resolver := ucxi.NewBasicAddressResolver(node.ID().ShortString())
|
||||
resolver.SetDefaultTTL(cfg.UCXL.Resolution.CacheTTL)
|
||||
|
||||
ucxiConfig := ucxi.ServerConfig{
|
||||
Port: cfg.UCXL.Server.Port,
|
||||
BasePath: cfg.UCXL.Server.BasePath,
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: ucxi.SimpleLogger{},
|
||||
}
|
||||
|
||||
ucxiServer = ucxi.NewServer(ucxiConfig)
|
||||
go func() {
|
||||
logger.Info("🔗 UCXI server starting on :%d", cfg.UCXL.Server.Port)
|
||||
if err := ucxiServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("❌ UCXI server error: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
if ucxiServer != nil {
|
||||
ucxiServer.Stop()
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
logger.Info("⚪ UCXI server disabled")
|
||||
}
|
||||
|
||||
// Create simple task tracker
|
||||
taskTracker := &SimpleTaskTracker{
|
||||
maxTasks: cfg.Agent.MaxTasks,
|
||||
activeTasks: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Connect decision publisher to task tracker if available
|
||||
if decisionPublisher != nil {
|
||||
taskTracker.decisionPublisher = decisionPublisher
|
||||
logger.Info("📤 Task completion decisions will be published to DHT")
|
||||
}
|
||||
|
||||
// Announce capabilities and role
|
||||
go announceAvailability(ps, node.ID().ShortString(), taskTracker, logger)
|
||||
go announceCapabilitiesOnChange(ps, node.ID().ShortString(), cfg, logger)
|
||||
go announceRoleOnStartup(ps, node.ID().ShortString(), cfg, logger)
|
||||
|
||||
// Start status reporting
|
||||
go statusReporter(node, logger)
|
||||
|
||||
logger.Info("🔍 Listening for peers on container network...")
|
||||
logger.Info("📡 Ready for task coordination and meta-discussion")
|
||||
logger.Info("🎯 HMMM collaborative reasoning enabled")
|
||||
|
||||
// === Comprehensive Health Monitoring & Graceful Shutdown ===
|
||||
shutdownManager := shutdown.NewManager(30*time.Second, &simpleLogger{logger: logger})
|
||||
|
||||
healthManager := health.NewManager(node.ID().ShortString(), AppVersion, &simpleLogger{logger: logger})
|
||||
healthManager.SetShutdownManager(shutdownManager)
|
||||
|
||||
// Register health checks
|
||||
setupHealthChecks(healthManager, ps, node, dhtNode, backbeatIntegration)
|
||||
|
||||
// Register components for graceful shutdown
|
||||
setupGracefulShutdown(shutdownManager, healthManager, node, ps, mdnsDiscovery,
|
||||
electionManager, httpServer, ucxiServer, taskCoordinator, dhtNode)
|
||||
|
||||
// Start health monitoring
|
||||
if err := healthManager.Start(); err != nil {
|
||||
logger.Error("❌ Failed to start health manager: %v", err)
|
||||
} else {
|
||||
logger.Info("❤️ Health monitoring started")
|
||||
}
|
||||
|
||||
// Start health HTTP server
|
||||
if err := healthManager.StartHTTPServer(cfg.Network.HealthPort); err != nil {
|
||||
logger.Error("❌ Failed to start health HTTP server: %v", err)
|
||||
} else {
|
||||
logger.Info("🏥 Health endpoints available at http://localhost:%d/health", cfg.Network.HealthPort)
|
||||
}
|
||||
|
||||
// Start shutdown manager
|
||||
shutdownManager.Start()
|
||||
logger.Info("🛡️ Graceful shutdown manager started")
|
||||
|
||||
logger.Info("✅ CHORUS system fully operational with health monitoring")
|
||||
|
||||
// Wait for graceful shutdown
|
||||
shutdownManager.Wait()
|
||||
logger.Info("✅ CHORUS system shutdown completed")
|
||||
}
|
||||
|
||||
// Rest of the functions (setupHealthChecks, etc.) would be adapted from CHORUS...
|
||||
// For brevity, I'll include key functions but the full implementation would port all CHORUS functionality
|
||||
|
||||
// simpleLogger implements basic logging for shutdown and health systems
|
||||
type simpleLogger struct {
|
||||
logger logging.Logger
|
||||
func printDeprecationHelp() {
|
||||
fmt.Printf("⚠️ %s %s - DEPRECATED BINARY\n\n", runtime.AppName, runtime.AppVersion)
|
||||
fmt.Println("This binary has been replaced by specialized binaries:")
|
||||
fmt.Println()
|
||||
fmt.Println("🤖 chorus-agent - Autonomous AI agent for task coordination")
|
||||
fmt.Println("👤 chorus-hap - Human Agent Portal for human participation")
|
||||
fmt.Println()
|
||||
fmt.Println("Migration Guide:")
|
||||
fmt.Println(" OLD: ./chorus")
|
||||
fmt.Println(" NEW: ./chorus-agent (for autonomous agents)")
|
||||
fmt.Println(" ./chorus-hap (for human agents)")
|
||||
fmt.Println()
|
||||
fmt.Println("Why this change?")
|
||||
fmt.Println(" - Enables human participation in agent networks")
|
||||
fmt.Println(" - Better separation of concerns")
|
||||
fmt.Println(" - Specialized interfaces for different use cases")
|
||||
fmt.Println(" - Shared P2P infrastructure with different UIs")
|
||||
fmt.Println()
|
||||
fmt.Println("For help with the new binaries:")
|
||||
fmt.Println(" ./chorus-agent --help")
|
||||
fmt.Println(" ./chorus-hap --help")
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Info(msg string, args ...interface{}) {
|
||||
l.logger.Info(msg, args...)
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Warn(msg string, args ...interface{}) {
|
||||
l.logger.Warn(msg, args...)
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Error(msg string, args ...interface{}) {
|
||||
l.logger.Error(msg, args...)
|
||||
}
|
||||
|
||||
// announceAvailability broadcasts current working status for task assignment
|
||||
func announceAvailability(ps *pubsub.PubSub, nodeID string, taskTracker *SimpleTaskTracker, logger logging.Logger) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ; ; <-ticker.C {
|
||||
currentTasks := taskTracker.GetActiveTasks()
|
||||
maxTasks := taskTracker.GetMaxTasks()
|
||||
isAvailable := len(currentTasks) < maxTasks
|
||||
|
||||
status := "ready"
|
||||
if len(currentTasks) >= maxTasks {
|
||||
status = "busy"
|
||||
} else if len(currentTasks) > 0 {
|
||||
status = "working"
|
||||
}
|
||||
|
||||
availability := map[string]interface{}{
|
||||
"node_id": nodeID,
|
||||
"available_for_work": isAvailable,
|
||||
"current_tasks": len(currentTasks),
|
||||
"max_tasks": maxTasks,
|
||||
"last_activity": time.Now().Unix(),
|
||||
"status": status,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
if err := ps.PublishBzzzMessage(pubsub.AvailabilityBcast, availability); err != nil {
|
||||
logger.Error("❌ Failed to announce availability: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusReporter provides periodic status updates
|
||||
func statusReporter(node *p2p.Node, logger logging.Logger) {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ; ; <-ticker.C {
|
||||
peers := node.ConnectedPeers()
|
||||
logger.Info("📊 Status: %d connected peers", peers)
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder functions for full CHORUS port - these would be fully implemented
|
||||
func announceCapabilitiesOnChange(ps *pubsub.PubSub, nodeID string, cfg *config.Config, logger logging.Logger) {
|
||||
// Implementation from CHORUS would go here
|
||||
}
|
||||
|
||||
func announceRoleOnStartup(ps *pubsub.PubSub, nodeID string, cfg *config.Config, logger logging.Logger) {
|
||||
// Implementation from CHORUS would go here
|
||||
}
|
||||
|
||||
func setupHealthChecks(healthManager *health.Manager, ps *pubsub.PubSub, node *p2p.Node, dhtNode *dht.LibP2PDHT, backbeatIntegration *backbeat.Integration) {
|
||||
// Add BACKBEAT health check
|
||||
if backbeatIntegration != nil {
|
||||
backbeatCheck := &health.HealthCheck{
|
||||
Name: "backbeat",
|
||||
Description: "BACKBEAT timing integration health",
|
||||
Interval: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Enabled: true,
|
||||
Critical: false,
|
||||
Checker: func(ctx context.Context) health.CheckResult {
|
||||
healthInfo := backbeatIntegration.GetHealth()
|
||||
connected, _ := healthInfo["connected"].(bool)
|
||||
|
||||
result := health.CheckResult{
|
||||
Healthy: connected,
|
||||
Details: healthInfo,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if connected {
|
||||
result.Message = "BACKBEAT integration healthy and connected"
|
||||
} else {
|
||||
result.Message = "BACKBEAT integration not connected"
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
healthManager.RegisterCheck(backbeatCheck)
|
||||
}
|
||||
|
||||
// Implementation from CHORUS would go here - other health checks
|
||||
}
|
||||
|
||||
func setupGracefulShutdown(shutdownManager *shutdown.Manager, healthManager *health.Manager,
|
||||
node *p2p.Node, ps *pubsub.PubSub, mdnsDiscovery interface{}, electionManager interface{},
|
||||
httpServer *api.HTTPServer, ucxiServer *ucxi.Server, taskCoordinator interface{}, dhtNode *dht.LibP2PDHT) {
|
||||
// Implementation from CHORUS would go here
|
||||
}
|
||||
|
||||
// initializeAIProvider configures the reasoning engine with the appropriate AI provider
|
||||
func initializeAIProvider(cfg *config.Config, logger logging.Logger) error {
|
||||
// Set the AI provider
|
||||
reasoning.SetAIProvider(cfg.AI.Provider)
|
||||
|
||||
// Configure the selected provider
|
||||
switch cfg.AI.Provider {
|
||||
case "resetdata":
|
||||
if cfg.AI.ResetData.APIKey == "" {
|
||||
return fmt.Errorf("RESETDATA_API_KEY environment variable is required for resetdata provider")
|
||||
}
|
||||
|
||||
resetdataConfig := reasoning.ResetDataConfig{
|
||||
BaseURL: cfg.AI.ResetData.BaseURL,
|
||||
APIKey: cfg.AI.ResetData.APIKey,
|
||||
Model: cfg.AI.ResetData.Model,
|
||||
Timeout: cfg.AI.ResetData.Timeout,
|
||||
}
|
||||
reasoning.SetResetDataConfig(resetdataConfig)
|
||||
logger.Info("🌐 ResetData AI provider configured - Endpoint: %s, Model: %s",
|
||||
cfg.AI.ResetData.BaseURL, cfg.AI.ResetData.Model)
|
||||
|
||||
case "ollama":
|
||||
reasoning.SetOllamaEndpoint(cfg.AI.Ollama.Endpoint)
|
||||
logger.Info("🦙 Ollama AI provider configured - Endpoint: %s", cfg.AI.Ollama.Endpoint)
|
||||
|
||||
default:
|
||||
logger.Warn("⚠️ Unknown AI provider '%s', defaulting to resetdata", cfg.AI.Provider)
|
||||
if cfg.AI.ResetData.APIKey == "" {
|
||||
return fmt.Errorf("RESETDATA_API_KEY environment variable is required for default resetdata provider")
|
||||
}
|
||||
|
||||
resetdataConfig := reasoning.ResetDataConfig{
|
||||
BaseURL: cfg.AI.ResetData.BaseURL,
|
||||
APIKey: cfg.AI.ResetData.APIKey,
|
||||
Model: cfg.AI.ResetData.Model,
|
||||
Timeout: cfg.AI.ResetData.Timeout,
|
||||
}
|
||||
reasoning.SetResetDataConfig(resetdataConfig)
|
||||
reasoning.SetAIProvider("resetdata")
|
||||
}
|
||||
|
||||
// Configure model selection
|
||||
reasoning.SetModelConfig(
|
||||
cfg.Agent.Models,
|
||||
cfg.Agent.ModelSelectionWebhook,
|
||||
cfg.Agent.DefaultReasoningModel,
|
||||
)
|
||||
|
||||
return nil
|
||||
func printDeprecationWarning() {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ DEPRECATION WARNING: The 'chorus' binary is deprecated!\n\n")
|
||||
fmt.Fprintf(os.Stderr, "This binary has been replaced with specialized binaries:\n")
|
||||
fmt.Fprintf(os.Stderr, " 🤖 chorus-agent - For autonomous AI agents\n")
|
||||
fmt.Fprintf(os.Stderr, " 👤 chorus-hap - For human agent participation\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Please use one of the new binaries instead:\n")
|
||||
fmt.Fprintf(os.Stderr, " ./chorus-agent --help\n")
|
||||
fmt.Fprintf(os.Stderr, " ./chorus-hap --help\n\n")
|
||||
fmt.Fprintf(os.Stderr, "This wrapper will be removed in a future version.\n")
|
||||
}
|
||||
126
cmd/hap/main.go
Normal file
126
cmd/hap/main.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"chorus/internal/hapui"
|
||||
"chorus/internal/runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Early CLI handling: print help/version without requiring env/config
|
||||
for _, a := range os.Args[1:] {
|
||||
switch a {
|
||||
case "--help", "-h", "help":
|
||||
fmt.Printf("%s-hap %s\n\n", runtime.AppName, runtime.AppVersion)
|
||||
fmt.Println("Usage:")
|
||||
fmt.Printf(" %s [--help] [--version]\n\n", filepath.Base(os.Args[0]))
|
||||
fmt.Println("CHORUS Human Agent Portal - Human Interface to P2P Agent Networks")
|
||||
fmt.Println()
|
||||
fmt.Println("This binary provides a human-friendly interface to participate in P2P agent")
|
||||
fmt.Println("coordination networks. Humans can collaborate with autonomous agents using")
|
||||
fmt.Println("the same protocols and appear as peers in the distributed network.")
|
||||
fmt.Println()
|
||||
fmt.Println("Environment (common):")
|
||||
fmt.Println(" CHORUS_LICENSE_ID (required)")
|
||||
fmt.Println(" CHORUS_AGENT_ID (optional; auto-generated if empty)")
|
||||
fmt.Println(" CHORUS_P2P_PORT (default 9000)")
|
||||
fmt.Println(" CHORUS_API_PORT (default 8080)")
|
||||
fmt.Println(" CHORUS_HEALTH_PORT (default 8081)")
|
||||
fmt.Println(" CHORUS_DHT_ENABLED (default true)")
|
||||
fmt.Println(" CHORUS_BOOTSTRAP_PEERS (comma-separated multiaddrs)")
|
||||
fmt.Println(" OLLAMA_ENDPOINT (default http://localhost:11434)")
|
||||
fmt.Println()
|
||||
fmt.Println("HAP-Specific Environment:")
|
||||
fmt.Println(" CHORUS_HAP_MODE (terminal|web, default terminal)")
|
||||
fmt.Println(" CHORUS_HAP_WEB_PORT (default 8082)")
|
||||
fmt.Println()
|
||||
fmt.Println("Example:")
|
||||
fmt.Println(" CHORUS_LICENSE_ID=dev-123 \\")
|
||||
fmt.Println(" CHORUS_AGENT_ID=human-alice \\")
|
||||
fmt.Println(" CHORUS_HAP_MODE=terminal \\")
|
||||
fmt.Println(" CHORUS_P2P_PORT=9001 ./chorus-hap")
|
||||
fmt.Println()
|
||||
fmt.Println("HAP Features:")
|
||||
fmt.Println(" - Human-friendly message composition")
|
||||
fmt.Println(" - HMMM reasoning template helpers")
|
||||
fmt.Println(" - UCXL context browsing")
|
||||
fmt.Println(" - Collaborative decision participation")
|
||||
fmt.Println(" - Terminal and web interface modes")
|
||||
fmt.Println(" - Same P2P protocols as autonomous agents")
|
||||
return
|
||||
case "--version", "-v":
|
||||
fmt.Printf("%s-hap %s\n", runtime.AppName, runtime.AppVersion)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize shared P2P runtime (same as agent)
|
||||
sharedRuntime, err := runtime.Initialize("hap")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to initialize CHORUS HAP: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sharedRuntime.Cleanup()
|
||||
|
||||
// Start HAP mode with human interface
|
||||
if err := startHAPMode(sharedRuntime); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ HAP mode failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// startHAPMode runs the Human Agent Portal with interactive interface
|
||||
func startHAPMode(runtime *runtime.SharedRuntime) error {
|
||||
runtime.Logger.Info("👤 Starting CHORUS Human Agent Portal (HAP)")
|
||||
runtime.Logger.Info("🔗 Connected to P2P network as human agent")
|
||||
runtime.Logger.Info("📝 Ready for collaborative reasoning and decision making")
|
||||
|
||||
// Get HAP mode from environment (terminal or web)
|
||||
hapMode := os.Getenv("CHORUS_HAP_MODE")
|
||||
if hapMode == "" {
|
||||
hapMode = "terminal"
|
||||
}
|
||||
|
||||
switch hapMode {
|
||||
case "terminal":
|
||||
return startTerminalInterface(runtime)
|
||||
case "web":
|
||||
return startWebInterface(runtime)
|
||||
default:
|
||||
return fmt.Errorf("unknown HAP mode: %s (valid: terminal, web)", hapMode)
|
||||
}
|
||||
}
|
||||
|
||||
// startTerminalInterface provides a terminal-based human interface
|
||||
func startTerminalInterface(runtime *runtime.SharedRuntime) error {
|
||||
runtime.Logger.Info("💻 Starting terminal interface for human interaction")
|
||||
|
||||
// Create and start the HAP terminal interface
|
||||
terminal := hapui.NewTerminalInterface(runtime)
|
||||
|
||||
runtime.Logger.Info("🎯 Human agent terminal interface ready")
|
||||
|
||||
// Start the interactive terminal
|
||||
return terminal.Start()
|
||||
}
|
||||
|
||||
// startWebInterface provides a web-based human interface
|
||||
func startWebInterface(runtime *runtime.SharedRuntime) error {
|
||||
runtime.Logger.Info("🌐 Starting web interface for human interaction")
|
||||
|
||||
// TODO Phase 3: Implement web interface
|
||||
// - HTTP server with WebSocket for real-time updates
|
||||
// - Web forms for HMMM message composition
|
||||
// - Context browser UI
|
||||
// - Decision voting interface
|
||||
|
||||
runtime.Logger.Info("⚠️ Web interface not yet implemented")
|
||||
runtime.Logger.Info("🔄 HAP running in stub mode - P2P connectivity established")
|
||||
runtime.Logger.Info("📍 Next: Implement Phase 3 web interface")
|
||||
|
||||
// For now, fall back to terminal mode
|
||||
return startTerminalInterface(runtime)
|
||||
}
|
||||
@@ -9,13 +9,19 @@ import (
|
||||
|
||||
"chorus/internal/logging"
|
||||
"chorus/pkg/config"
|
||||
"chorus/pubsub"
|
||||
"chorus/pkg/repository"
|
||||
"chorus/pkg/hmmm"
|
||||
"chorus/pkg/repository"
|
||||
"chorus/pubsub"
|
||||
"github.com/google/uuid"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
)
|
||||
|
||||
// TaskProgressTracker is notified when tasks start and complete so availability broadcasts stay accurate.
|
||||
type TaskProgressTracker interface {
|
||||
AddTask(taskID string)
|
||||
RemoveTask(taskID string)
|
||||
}
|
||||
|
||||
// TaskCoordinator manages task discovery, assignment, and execution across multiple repositories
|
||||
type TaskCoordinator struct {
|
||||
pubsub *pubsub.PubSub
|
||||
@@ -33,6 +39,7 @@ type TaskCoordinator struct {
|
||||
activeTasks map[string]*ActiveTask // taskKey -> active task
|
||||
taskLock sync.RWMutex
|
||||
taskMatcher repository.TaskMatcher
|
||||
taskTracker TaskProgressTracker
|
||||
|
||||
// Agent tracking
|
||||
nodeID string
|
||||
@@ -63,7 +70,9 @@ func NewTaskCoordinator(
|
||||
cfg *config.Config,
|
||||
nodeID string,
|
||||
hmmmRouter *hmmm.Router,
|
||||
tracker TaskProgressTracker,
|
||||
) *TaskCoordinator {
|
||||
|
||||
coordinator := &TaskCoordinator{
|
||||
pubsub: ps,
|
||||
hlog: hlog,
|
||||
@@ -75,6 +84,7 @@ func NewTaskCoordinator(
|
||||
lastSync: make(map[int]time.Time),
|
||||
factory: &repository.DefaultProviderFactory{},
|
||||
taskMatcher: &repository.DefaultTaskMatcher{},
|
||||
taskTracker: tracker,
|
||||
nodeID: nodeID,
|
||||
syncInterval: 30 * time.Second,
|
||||
}
|
||||
@@ -185,6 +195,10 @@ func (tc *TaskCoordinator) processTask(task *repository.Task, provider repositor
|
||||
tc.agentInfo.CurrentTasks = len(tc.activeTasks)
|
||||
tc.taskLock.Unlock()
|
||||
|
||||
if tc.taskTracker != nil {
|
||||
tc.taskTracker.AddTask(taskKey)
|
||||
}
|
||||
|
||||
// Log task claim
|
||||
tc.hlog.Append(logging.TaskClaimed, map[string]interface{}{
|
||||
"task_number": task.Number,
|
||||
@@ -334,6 +348,10 @@ func (tc *TaskCoordinator) executeTask(activeTask *ActiveTask) {
|
||||
tc.agentInfo.CurrentTasks = len(tc.activeTasks)
|
||||
tc.taskLock.Unlock()
|
||||
|
||||
if tc.taskTracker != nil {
|
||||
tc.taskTracker.RemoveTask(taskKey)
|
||||
}
|
||||
|
||||
// Log completion
|
||||
tc.hlog.Append(logging.TaskCompleted, map[string]interface{}{
|
||||
"task_number": activeTask.Task.Number,
|
||||
|
||||
@@ -11,15 +11,15 @@ WORKDIR /build
|
||||
# Copy go mod files first (for better caching)
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Copy vendor directory for local dependencies
|
||||
COPY vendor/ vendor/
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the CHORUS binary with vendor mode
|
||||
# Build the CHORUS binary with mod mode
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-mod=vendor \
|
||||
-mod=mod \
|
||||
-ldflags='-w -s -extldflags "-static"' \
|
||||
-o chorus \
|
||||
./cmd/chorus
|
||||
|
||||
36
docker/docker-compose.prompts.dev.yml
Normal file
36
docker/docker-compose.prompts.dev.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
chorus-agent:
|
||||
# For local dev, build from repo Dockerfile; alternatively set a pinned image tag
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
# image: registry.home.deepblack.cloud/chorus/agent:0.1.0
|
||||
container_name: chorus-agent-dev
|
||||
env_file:
|
||||
- ./chorus.env
|
||||
environment:
|
||||
# Prompt sourcing (mounted volume)
|
||||
CHORUS_PROMPTS_DIR: /etc/chorus/prompts
|
||||
CHORUS_DEFAULT_INSTRUCTIONS_PATH: /etc/chorus/prompts/defaults.md
|
||||
CHORUS_ROLE: arbiter # change to your role id (e.g., hmmm-analyst)
|
||||
|
||||
# Minimal AI provider config (ResetData example)
|
||||
CHORUS_AI_PROVIDER: resetdata
|
||||
RESETDATA_BASE_URL: https://models.au-syd.resetdata.ai/v1
|
||||
# Set RESETDATA_API_KEY via ./chorus.env or secrets manager
|
||||
|
||||
# Required license id (bind or inject via env_file)
|
||||
CHORUS_LICENSE_ID: ${CHORUS_LICENSE_ID}
|
||||
|
||||
volumes:
|
||||
# Mount prompts directory read-only
|
||||
- ../prompts:/etc/chorus/prompts:ro
|
||||
ports:
|
||||
- "8080:8080" # API
|
||||
- "8081:8081" # Health
|
||||
- "9000:9000" # P2P
|
||||
restart: unless-stopped
|
||||
# profiles: [prompts]
|
||||
|
||||
@@ -2,7 +2,7 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
chorus:
|
||||
image: anthonyrawlins/chorus:backbeat-v2.0.1
|
||||
image: anthonyrawlins/chorus:discovery-debug
|
||||
|
||||
# REQUIRED: License configuration (CHORUS will not start without this)
|
||||
environment:
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- CHORUS_AGENT_ID=${CHORUS_AGENT_ID:-} # Auto-generated if not provided
|
||||
- CHORUS_SPECIALIZATION=${CHORUS_SPECIALIZATION:-general_developer}
|
||||
- CHORUS_MAX_TASKS=${CHORUS_MAX_TASKS:-3}
|
||||
- CHORUS_CAPABILITIES=${CHORUS_CAPABILITIES:-general_development,task_coordination}
|
||||
- CHORUS_CAPABILITIES=${CHORUS_CAPABILITIES:-general_development,task_coordination,admin_election}
|
||||
|
||||
# Network configuration
|
||||
- CHORUS_API_PORT=8080
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
|
||||
# ResetData configuration (default provider)
|
||||
- RESETDATA_BASE_URL=${RESETDATA_BASE_URL:-https://models.au-syd.resetdata.ai/v1}
|
||||
- RESETDATA_API_KEY=${RESETDATA_API_KEY:?RESETDATA_API_KEY is required for resetdata provider}
|
||||
- RESETDATA_API_KEY_FILE=/run/secrets/resetdata_api_key
|
||||
- RESETDATA_MODEL=${RESETDATA_MODEL:-meta/llama-3.1-8b-instruct}
|
||||
|
||||
# Ollama configuration (alternative provider)
|
||||
@@ -48,13 +48,21 @@ services:
|
||||
- CHORUS_BACKBEAT_AGENT_ID=${CHORUS_BACKBEAT_AGENT_ID:-} # Auto-generated from CHORUS_AGENT_ID
|
||||
- CHORUS_BACKBEAT_NATS_URL=${CHORUS_BACKBEAT_NATS_URL:-nats://backbeat-nats:4222}
|
||||
|
||||
# Prompt sourcing (mounted volume)
|
||||
- CHORUS_PROMPTS_DIR=/etc/chorus/prompts
|
||||
- CHORUS_DEFAULT_INSTRUCTIONS_PATH=/etc/chorus/prompts/defaults.md
|
||||
- CHORUS_ROLE=${CHORUS_ROLE:-arbiter}
|
||||
|
||||
# Docker secrets for sensitive configuration
|
||||
secrets:
|
||||
- chorus_license_id
|
||||
- resetdata_api_key
|
||||
|
||||
# Persistent data storage
|
||||
volumes:
|
||||
- chorus_data:/app/data
|
||||
# Mount prompts directory read-only for role YAMLs and defaults.md
|
||||
- /rust/containers/WHOOSH/prompts:/etc/chorus/prompts:ro
|
||||
|
||||
# Network ports
|
||||
ports:
|
||||
@@ -63,7 +71,7 @@ services:
|
||||
# Container resource limits
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: ${CHORUS_REPLICAS:-1}
|
||||
replicas: ${CHORUS_REPLICAS:-9}
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
@@ -84,6 +92,7 @@ services:
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname != rosewood
|
||||
- node.hostname != acacia
|
||||
preferences:
|
||||
- spread: node.hostname
|
||||
# CHORUS is internal-only, no Traefik labels needed
|
||||
@@ -113,7 +122,7 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
whoosh:
|
||||
image: anthonyrawlins/whoosh:backbeat-v2.1.0
|
||||
image: anthonyrawlins/whoosh:scaling-v1.0.0
|
||||
ports:
|
||||
- target: 8080
|
||||
published: 8800
|
||||
@@ -156,6 +165,11 @@ services:
|
||||
WHOOSH_REDIS_PORT: 6379
|
||||
WHOOSH_REDIS_PASSWORD_FILE: /run/secrets/redis_password
|
||||
WHOOSH_REDIS_DATABASE: 0
|
||||
|
||||
# Scaling system configuration
|
||||
WHOOSH_SCALING_KACHING_URL: "https://kaching.chorus.services"
|
||||
WHOOSH_SCALING_BACKBEAT_URL: "http://backbeat-pulse:8080"
|
||||
WHOOSH_SCALING_CHORUS_URL: "http://chorus:8080"
|
||||
secrets:
|
||||
- whoosh_db_password
|
||||
- gitea_token
|
||||
@@ -163,6 +177,8 @@ services:
|
||||
- jwt_secret
|
||||
- service_tokens
|
||||
- redis_password
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
@@ -183,6 +199,8 @@ services:
|
||||
# monitor: 60s
|
||||
# order: stop-first
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname != acacia
|
||||
preferences:
|
||||
- spread: node.hostname
|
||||
resources:
|
||||
@@ -194,11 +212,14 @@ services:
|
||||
cpus: '0.25'
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=tengig
|
||||
- traefik.http.routers.whoosh.rule=Host(`whoosh.chorus.services`)
|
||||
- traefik.http.routers.whoosh.tls=true
|
||||
- traefik.http.routers.whoosh.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.whoosh.tls.certresolver=letsencryptresolver
|
||||
- traefik.http.routers.photoprism.entrypoints=web,web-secured
|
||||
- traefik.http.services.whoosh.loadbalancer.server.port=8080
|
||||
- traefik.http.middlewares.whoosh-auth.basicauth.users=admin:$$2y$$10$$example_hash
|
||||
- traefik.http.services.photoprism.loadbalancer.passhostheader=true
|
||||
- traefik.http.middlewares.whoosh-auth.basicauth.users=admin:$2y$10$example_hash
|
||||
networks:
|
||||
- tengig
|
||||
- whoosh-backend
|
||||
@@ -292,6 +313,72 @@ services:
|
||||
|
||||
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||
volumes:
|
||||
- /rust/containers/CHORUS/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- /rust/containers/CHORUS/monitoring/prometheus:/prometheus
|
||||
ports:
|
||||
- "9099:9090" # Expose Prometheus UI
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname != rosewood
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.prometheus.rule=Host(`prometheus.chorus.services`)
|
||||
- traefik.http.routers.prometheus.entrypoints=web,web-secured
|
||||
- traefik.http.routers.prometheus.tls=true
|
||||
- traefik.http.routers.prometheus.tls.certresolver=letsencryptresolver
|
||||
- traefik.http.services.prometheus.loadbalancer.server.port=9090
|
||||
networks:
|
||||
- chorus_net
|
||||
- tengig
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/ready"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} # Use a strong password in production
|
||||
- GF_SERVER_ROOT_URL=https://grafana.chorus.services
|
||||
volumes:
|
||||
- /rust/containers/CHORUS/monitoring/grafana:/var/lib/grafana
|
||||
ports:
|
||||
- "3300:3000" # Expose Grafana UI
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname != rosewood
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.grafana.rule=Host(`grafana.chorus.services`)
|
||||
- traefik.http.routers.grafana.entrypoints=web,web-secured
|
||||
- traefik.http.routers.grafana.tls=true
|
||||
- traefik.http.routers.grafana.tls.certresolver=letsencryptresolver
|
||||
- traefik.http.services.grafana.loadbalancer.server.port=3000
|
||||
networks:
|
||||
- chorus_net
|
||||
- tengig
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# BACKBEAT Pulse Service - Leader-elected tempo broadcaster
|
||||
# REQ: BACKBEAT-REQ-001 - Single BeatFrame publisher per cluster
|
||||
# REQ: BACKBEAT-OPS-001 - One replica prefers leadership
|
||||
@@ -477,6 +564,24 @@ services:
|
||||
|
||||
# Persistent volumes
|
||||
volumes:
|
||||
prometheus_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /rust/containers/CHORUS/monitoring/prometheus
|
||||
prometheus_config:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /rust/containers/CHORUS/monitoring/prometheus
|
||||
grafana_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /rust/containers/CHORUS/monitoring/grafana
|
||||
chorus_data:
|
||||
driver: local
|
||||
whoosh_postgres_data:
|
||||
@@ -515,6 +620,9 @@ secrets:
|
||||
chorus_license_id:
|
||||
external: true
|
||||
name: chorus_license_id
|
||||
resetdata_api_key:
|
||||
external: true
|
||||
name: resetdata_api_key
|
||||
whoosh_db_password:
|
||||
external: true
|
||||
name: whoosh_db_password
|
||||
|
||||
30
docs/decisions/2025-02-16-shhh-sentinel-foundation.md
Normal file
30
docs/decisions/2025-02-16-shhh-sentinel-foundation.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Decision Record: Establish SHHH Sentinel Foundations
|
||||
|
||||
- **Date:** 2025-02-16
|
||||
- **Status:** Accepted
|
||||
- **Context:** CHORUS roadmap Phase 1 requires a secrets sentinel (`pkg/shhh`) before we wire COOEE/WHOOSH telemetry and audit plumbing. The runtime previously emitted placeholder TODOs and logged sensitive payloads without guard rails.
|
||||
|
||||
## Problem
|
||||
- We lacked a reusable component to detect and redact secrets prior to log/telemetry fan-out.
|
||||
- Without a dedicated sentinel we could not attach audit sinks or surface metrics for redaction events, blocking roadmap item `SEC-SHHH`.
|
||||
|
||||
## Decision
|
||||
- Introduce `pkg/shhh` as the SHHH sentinel with:
|
||||
- Curated default rules (API keys, bearer/OAuth tokens, private key PEM blocks, OpenAI secrets).
|
||||
- Extensible configuration for custom regex rules and per-rule severity/tags.
|
||||
- Optional audit sink and statistics collection for integration with COOEE/WHOOSH pipelines.
|
||||
- Helpers to redact free-form text and `map[string]any` payloads used by our logging pipeline.
|
||||
|
||||
## Rationale
|
||||
- Starting with a focused set of high-signal rules gives immediate coverage for the most damaging leak classes without delaying larger SLURP/SHHH workstreams.
|
||||
- The API mirrors other CHORUS subsystems (options, config structs, stats snapshots) so existing operators can plug metrics/audits without bespoke glue.
|
||||
- Providing deterministic findings/locations simplifies future enforcement (e.g., WHOOSH UI badges, COOEE replay) while keeping implementation lean.
|
||||
|
||||
## Impact
|
||||
- Runtime components can now instantiate SHHH and guarantee `[REDACTED]` placeholders for sensitive fields.
|
||||
- Audit/event plumbing can be wired incrementally—hashes are emitted for replay without storing raw secrets.
|
||||
- Future roadmap tasks (policy driven rules, replay, UCXL evidence) can extend `pkg/shhh` rather than implementing ad-hoc redaction in each subsystem.
|
||||
|
||||
## Related Work
|
||||
- Roadmap: `docs/progress/CHORUS-WHOOSH-roadmap.md` (Phase 1.2 `SEC-SHHH`).
|
||||
- README coverage gap noted in `README.md` table (SHHH not implemented).
|
||||
@@ -0,0 +1,46 @@
|
||||
# Decision Record: Convert Human Markdown Prompts to CHORUS Role YAML
|
||||
|
||||
- Date: 2025-09-06
|
||||
- UCXL Address: ucxl://arbiter:ops@CHORUS:prompt-migration/#/docs/decisions/2025-09-06-convert-human-prompts-to-roles-yaml.md
|
||||
|
||||
## Problem
|
||||
Human-oriented prompt templates exist as Markdown files under `agentic-ai-prompt-templates/human/`. CHORUS now sources agent role prompts (S) and default instructions (D) at runtime from bind-mounted YAML/Markdown files. We need these human templates available in the new YAML format to configure agents via Docker volume binding without rebuilding images.
|
||||
|
||||
## Options Considered
|
||||
1) Manual conversion of each Markdown file to a YAML role entry
|
||||
- Pros: Tight editorial control
|
||||
- Cons: Time-intensive, error-prone, hard to keep in sync
|
||||
|
||||
2) Automated converter script to parse Markdown sections and emit a consolidated `system_prompt` with metadata
|
||||
- Pros: Fast, repeatable, easy to re-run when templates change
|
||||
- Cons: Heuristics may miss atypical structures; requires review
|
||||
|
||||
3) Store raw Markdown and embed at runtime
|
||||
- Pros: No conversion step
|
||||
- Cons: Diverges from adopted loader schema, complicates composition and validation
|
||||
|
||||
## Decision
|
||||
Adopt Option 2. Add a utility script `utilities/convert_human_prompts_to_yaml.py` that:
|
||||
- Reads `agentic-ai-prompt-templates/human/*.md`
|
||||
- Extracts title, Description, Tools, Use Cases, When to Use
|
||||
- Constructs `system_prompt` as: "You are <Name>." + Description + Tools + Use Cases + When To Use
|
||||
- Emits `project-queues/active/CHORUS/prompts/human-roles.yaml` with one role per file, using filename as role ID
|
||||
- Populates advisory `defaults` (models/capabilities/expertise/max_tasks)
|
||||
|
||||
## Impact
|
||||
- Roles become mountable via `CHORUS_PROMPTS_DIR` (e.g., `-v ../prompts:/etc/chorus/prompts:ro`)
|
||||
- Agents can select any converted role via `CHORUS_ROLE=<role-id>`
|
||||
- Future updates to human Markdown can be re-converted by re-running the script
|
||||
|
||||
## Rollback
|
||||
- Remove `human-roles.yaml` from the prompts directory
|
||||
- Agents will continue to use existing roles (`roles.yaml`) or default instructions only
|
||||
|
||||
## Compatibility Notes
|
||||
- Loader merges by role ID; ensure IDs don’t collide with existing `roles.yaml` (IDs are based on filenames)
|
||||
- `defaults.md` remains the global instruction source and is unchanged by this migration
|
||||
|
||||
## Evidence / References
|
||||
- Loader & schema: `pkg/prompt/types.go`, `pkg/prompt/loader.go`
|
||||
- Prompts directory & compose: `prompts/README.md`, `docker/docker-compose.prompts.dev.yml`
|
||||
|
||||
60
docs/decisions/2025-09-06-prompt-sourcing-and-composition.md
Normal file
60
docs/decisions/2025-09-06-prompt-sourcing-and-composition.md
Normal file
@@ -0,0 +1,60 @@
|
||||
ucxl.address: ucxl://arbiter:design@CHORUS:prompt-sourcing/#/docs/decisions/2025-09-06-prompt-sourcing-and-composition.md
|
||||
version: 2025-09-06T00:00:00Z
|
||||
content_type: text/markdown
|
||||
metadata:
|
||||
classification: internal
|
||||
roles: [arbiter, maintainer]
|
||||
|
||||
# Decision Record: Prompt Sourcing and Composition (S + D)
|
||||
|
||||
## Problem
|
||||
Agents used a hardcoded system prompt ("You are a helpful assistant.") and scattered inline prompt strings. We need configurable system prompts per agent role and a shared, generic instruction set that can be modified without rebuilding images or redeploying.
|
||||
|
||||
## Decision
|
||||
Adopt externalized prompt sourcing via a Docker-bound directory. Compose each agent's final system prompt as S + D:
|
||||
- S (System Persona): Per‑role system prompt loaded from YAML files in a mounted directory.
|
||||
- D (Default Instructions): A shared instruction file defining standards for HMMM, COOEE, UCXL, and BACKBEAT, including JSON message shapes and usage rules.
|
||||
|
||||
The runtime loads S and D at startup and sets the LLM system message accordingly. Updates to S or D require only changing files on the host volume.
|
||||
|
||||
## Implementation
|
||||
- Directory: `CHORUS_PROMPTS_DIR` (e.g., `/etc/chorus/prompts`), bind-mounted read‑only via Docker/Swarm.
|
||||
- Files:
|
||||
- Roles (YAML): one or many files; merged by role id. Example at `prompts/roles.yaml`.
|
||||
- Defaults (Markdown): `defaults.md` (or `defaults.txt`). Override path via `CHORUS_DEFAULT_INSTRUCTIONS_PATH`.
|
||||
- Loader: `pkg/prompt`
|
||||
- Scans `CHORUS_PROMPTS_DIR` for `*.yaml|*.yml` and `defaults.md`.
|
||||
- Exposes `ComposeSystemPrompt(roleID)` → S + D.
|
||||
- Reasoning: `reasoning.SetDefaultSystemPrompt()` sets the default system message for provider calls.
|
||||
- Wiring: in `internal/runtime/shared.go` during initialization, we:
|
||||
- Initialize prompts via env
|
||||
- If `Agent.Role` is set, attempt S + D; else D only
|
||||
- Apply via `reasoning.SetDefaultSystemPrompt`
|
||||
- Commit reference: 1806a4f
|
||||
|
||||
## Default Instructions D (Summary)
|
||||
- Operating policy: precision, verifiability, minimal changes, UCXL citations, safety.
|
||||
- HMMM: use for collaborative reasoning; publish JSON to `hmmm/meta-discussion/v1`.
|
||||
- COOEE: use for coordination; publish `cooee.request` and `cooee.plan` JSON to `CHORUS/coordination/v1`.
|
||||
- UCXL: read by address; write decisions with the decision bundle envelope; never invent paths.
|
||||
- BACKBEAT: emit beat-aware timing/phase events with correlation IDs; phases `prepare|plan|exec|verify|publish`; events `start|heartbeat|complete`; include budget/elapsed and link to HMMM/COOEE IDs when present.
|
||||
- Full JSON shapes are stored in `prompts/defaults.md`.
|
||||
|
||||
## Usage
|
||||
- Mount in Docker:
|
||||
- `-v /srv/chorus/prompts:/etc/chorus/prompts:ro`
|
||||
- `-e CHORUS_PROMPTS_DIR=/etc/chorus/prompts`
|
||||
- Optional: `-e CHORUS_DEFAULT_INSTRUCTIONS_PATH=/etc/chorus/prompts/defaults.md`
|
||||
- Select role per container: `-e CHORUS_ROLE=arbiter` (example)
|
||||
- Modify role YAML or `defaults.md` on the host; restart container to pick up changes (no rebuild).
|
||||
|
||||
## Future Work
|
||||
- Hot-reload via fsnotify or `/api/prompts/reload` endpoint.
|
||||
- WHOOSH integration endpoint to enumerate roles and return composed S + D per team member.
|
||||
- Per-role overrides for models/capabilities applied to `AgentConfig`.
|
||||
|
||||
## Impact
|
||||
- Prompts are now ops‑tunable via mounted files, reducing deployment friction.
|
||||
- Consistent, centralized guidance for HMMM, COOEE, UCXL, and BACKBEAT across all agents.
|
||||
- Backward compatible: if no files are mounted, system uses a sane fallback.
|
||||
|
||||
42
docs/decisions/2025-09-06-refactor-chorus.md
Normal file
42
docs/decisions/2025-09-06-refactor-chorus.md
Normal file
@@ -0,0 +1,42 @@
|
||||
ucxl.address: ucxl://arbiter:refactor@CHORUS:refactor-chorus/#/docs/decisions/2025-09-06-refactor-chorus.md
|
||||
version: 2025-09-06T00:00:00Z
|
||||
content_type: text/markdown
|
||||
metadata:
|
||||
classification: internal
|
||||
roles: [arbiter, maintainer]
|
||||
|
||||
# Decision Record: Refactor BZZZ references to CHORUS
|
||||
|
||||
## Problem
|
||||
Legacy references to the former module name “BZZZ” (and lowercase “bzzz”) exist across the CHORUS codebase. These cause inconsistency in env vars, identifiers, topics, and docs now that CHORUS is the canonical name. We need consistent naming for clarity and to avoid misconfiguration.
|
||||
|
||||
## Options considered
|
||||
1) Targeted rename only in source code (keep docs/env examples unchanged)
|
||||
- Pros: Minimizes risk of breaking external setups
|
||||
- Cons: Leaves inconsistency; increases confusion
|
||||
|
||||
2) Global textual refactor of tokens (BZZZ→CHORUS, bzzz→chorus) across project code and docs, excluding vendor/generated assets
|
||||
- Pros: Consistent naming; straightforward and auditable
|
||||
- Cons: May require updating external env files and scripts that referenced old names
|
||||
|
||||
3) Dual-support via compatibility layer/env fallback (accept both BZZZ_* and CHORUS_*) temporarily
|
||||
- Pros: Backwards compatible
|
||||
- Cons: Adds complexity; requires additional code paths and deprecation plan
|
||||
|
||||
## Decision
|
||||
Adopt Option 2 now: perform a safe, in-place textual refactor of tokens “BZZZ”→“CHORUS” and “bzzz”→“chorus” throughout CHORUS code and docs. Exclude vendor and generated web assets to avoid unintended binary changes. Commit and push as a single change set.
|
||||
|
||||
Scope boundaries:
|
||||
- Include: Go sources, tests, README/MD, API scaffolding, YAML-in-code templates
|
||||
- Exclude: `vendor/**`, `pkg/web/static/_next/**` bundles
|
||||
|
||||
## Impact
|
||||
- Env var names and topic constants now use CHORUS; external deployments that relied on BZZZ_* should update to CHORUS_* equivalents.
|
||||
- No functional behavior intended to change; this is a rename-only refactor.
|
||||
- Rollback: revert commit b6634e4 if downstream issues arise; consider Option 3 for short-term compat if needed.
|
||||
|
||||
## Evidence
|
||||
- Commit: b6634e4 “refactor CHORUS”
|
||||
- Gitea: https://gitea.chorus.services/tony/CHORUS/commit/b6634e4
|
||||
- Search baseline: 21 files originally contained BZZZ/bzzz tokens (rg scan).
|
||||
|
||||
70
docs/progress/CHORUS-WHOOSH-roadmap.md
Normal file
70
docs/progress/CHORUS-WHOOSH-roadmap.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# CHORUS / WHOOSH Roadmap
|
||||
|
||||
_Last updated: 2025-02-15_
|
||||
|
||||
This roadmap translates the development plan into phased milestones with suggested sequencing and exit criteria. Durations are approximate and assume parallel work streams where practical.
|
||||
|
||||
## Phase 0 – Kick-off & Scoping (Week 0)
|
||||
- Confirm owners and staffing for SLURP, SHHH, COOEE, WHOOSH, UCXL, and KACHING work streams.
|
||||
- Finalize engineering briefs for each deliverable; align with plan in `CHORUS-WHOOSH-development-plan.md`.
|
||||
- Stand up tracking board (Kanban/Sprint) with milestone tags introduced below.
|
||||
|
||||
**Exit Criteria**
|
||||
- Owners assigned and briefs approved.
|
||||
- Roadmap milestones added to tracking tooling.
|
||||
|
||||
## Phase 1 – Security Substrate Foundations (Weeks 1–4)
|
||||
- **1.1 SLURP Core (Weeks 1–3)**
|
||||
- Implement storage/resolver/temporal components and leader integration (ticket group `SEC-SLURP`).
|
||||
- Ship integration tests covering admin-only operations and failover.
|
||||
- **1.2 SHHH Sentinel (Weeks 2–4)**
|
||||
- Build `pkg/shhh`, integrate with COOEE/WHOOSH logging, add audit metrics (`SEC-SHHH`).
|
||||
- **1.3 COOEE Mesh Monitoring (Weeks 3–4)**
|
||||
- Validate enrolment payloads, instrument mesh health, document ops runbook (`SEC-COOEE`).
|
||||
|
||||
**Exit Criteria**
|
||||
- SLURP passes integration suite with real context resolution.
|
||||
- SHHH redaction events visible in metrics/logs; regression tests in place.
|
||||
- COOEE dashboards/reporting operational; runbook published.
|
||||
|
||||
## Phase 2 – WHOOSH Data Path & Telemetry (Weeks 4–8)
|
||||
- **2.1 Persistence & API Hardening (Weeks 4–6)**
|
||||
- Replace mock handlers with Postgres-backed endpoints (`WHOOSH-API`).
|
||||
- **2.2 Analysis Ingestion (Weeks 5–7)**
|
||||
- Pipeline real Gitea/n8n analysis into composer/monitor (`WHOOSH-ANALYSIS`).
|
||||
- **2.3 Deployment Telemetry (Weeks 6–8)**
|
||||
- Persist deployment results, emit telemetry, surface status in UI (`WHOOSH-OBS`).
|
||||
- **2.4 Composer Enhancements (Weeks 7–8)**
|
||||
- Add LLM skill analysis with fallback heuristics; evaluation harness (`WHOOSH-COMP`).
|
||||
|
||||
**Exit Criteria**
|
||||
- WHOOSH API/UI reflects live database state.
|
||||
- Analysis-derived data present in team formation/deployment flows.
|
||||
- Telemetry events available for KACHING integration.
|
||||
|
||||
## Phase 3 – Cross-Cutting Governance & Tooling (Weeks 8–12)
|
||||
- **3.1 UCXL Spec & Validator (Weeks 8–10)**
|
||||
- Publish Spec 1.0, ship validator CLI with CI coverage (`UCXL-SPEC`).
|
||||
- **3.2 KACHING Telemetry (Weeks 9–11)**
|
||||
- Instrument CHORUS runtime & WHOOSH orchestrator, deploy ingestion/aggregation jobs (`KACHING-TELEM`).
|
||||
- **3.3 Governance Tooling (Weeks 10–12)**
|
||||
- Deliver DR templates, signed assertions workflow, scope-aware RUSTLE views (`GOV-TOOLS`).
|
||||
|
||||
**Exit Criteria**
|
||||
- UCXL validator integrated into CI for CHORUS/WHOOSH/RUSTLE.
|
||||
- KACHING receives events and triggers quota/budget alerts.
|
||||
- Governance docs/tooling published; RUSTLE displays redacted context correctly.
|
||||
|
||||
## Phase 4 – Stabilization & Launch Readiness (Weeks 12–14)
|
||||
- Regression testing across CHORUS/WHOOSH/UCXL/KACHING.
|
||||
- Security & compliance review for SHHH and telemetry pipelines.
|
||||
- Rollout plan: staged deployment, rollback procedures, support playbooks.
|
||||
|
||||
**Exit Criteria**
|
||||
- All milestone tickets closed with QA sign-off.
|
||||
- Production readiness review approved; launch window scheduled.
|
||||
|
||||
## Tracking & Reporting
|
||||
- Weekly status sync covering milestone burndown, risks, and cross-team blockers.
|
||||
- Metrics dashboard to include: SLURP leader uptime, SHHH redaction counts, COOEE peer health, WHOOSH deployment success rate, UCXL validation pass rate, KACHING alert volume.
|
||||
- Maintain Decision Records for key architecture/security choices at relevant UCXL addresses.
|
||||
5
go.mod
5
go.mod
@@ -21,9 +21,11 @@ require (
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sashabaranov/go-openai v1.41.1
|
||||
github.com/sony/gobreaker v0.5.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -155,8 +157,7 @@ require (
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
gonum.org/v1/gonum v0.13.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/blake3 v1.2.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/chorus-services/backbeat => /home/tony/chorus/project-queues/active/BACKBEAT/backbeat/prototype
|
||||
replace github.com/chorus-services/backbeat => ../BACKBEAT/backbeat/prototype
|
||||
|
||||
2
go.sum
2
go.sum
@@ -437,6 +437,8 @@ github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N
|
||||
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
|
||||
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
|
||||
github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
|
||||
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
|
||||
3986
internal/hapui/terminal.go
Normal file
3986
internal/hapui/terminal.go
Normal file
File diff suppressed because it is too large
Load Diff
340
internal/licensing/license_gate.go
Normal file
340
internal/licensing/license_gate.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
)
|
||||
|
||||
// LicenseGate provides burst-proof license validation with caching and circuit breaker
|
||||
type LicenseGate struct {
|
||||
config LicenseConfig
|
||||
cache atomic.Value // stores cachedLease
|
||||
breaker *gobreaker.CircuitBreaker
|
||||
graceUntil atomic.Value // stores time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// cachedLease represents a cached license lease with expiry
|
||||
type cachedLease struct {
|
||||
LeaseToken string `json:"lease_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
Valid bool `json:"valid"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
}
|
||||
|
||||
// LeaseRequest represents a cluster lease request
|
||||
type LeaseRequest struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
RequestedReplicas int `json:"requested_replicas"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// LeaseResponse represents a cluster lease response
|
||||
type LeaseResponse struct {
|
||||
LeaseToken string `json:"lease_token"`
|
||||
MaxReplicas int `json:"max_replicas"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
LeaseID string `json:"lease_id"`
|
||||
}
|
||||
|
||||
// LeaseValidationRequest represents a lease validation request
|
||||
type LeaseValidationRequest struct {
|
||||
LeaseToken string `json:"lease_token"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
|
||||
// LeaseValidationResponse represents a lease validation response
|
||||
type LeaseValidationResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
RemainingReplicas int `json:"remaining_replicas"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// NewLicenseGate creates a new license gate with circuit breaker and caching
|
||||
func NewLicenseGate(config LicenseConfig) *LicenseGate {
|
||||
// Circuit breaker settings optimized for license validation
|
||||
breakerSettings := gobreaker.Settings{
|
||||
Name: "license-validation",
|
||||
MaxRequests: 3, // Allow 3 requests in half-open state
|
||||
Interval: 60 * time.Second, // Reset failure count every minute
|
||||
Timeout: 30 * time.Second, // Stay open for 30 seconds
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
// Trip after 3 consecutive failures
|
||||
return counts.ConsecutiveFailures >= 3
|
||||
},
|
||||
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
|
||||
fmt.Printf("🔌 License validation circuit breaker: %s -> %s\n", from, to)
|
||||
},
|
||||
}
|
||||
|
||||
gate := &LicenseGate{
|
||||
config: config,
|
||||
breaker: gobreaker.NewCircuitBreaker(breakerSettings),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
// Initialize grace period
|
||||
gate.graceUntil.Store(time.Now().Add(90 * time.Second))
|
||||
|
||||
return gate
|
||||
}
|
||||
|
||||
// ValidNow checks if the cached lease is currently valid
|
||||
func (c *cachedLease) ValidNow() bool {
|
||||
if !c.Valid {
|
||||
return false
|
||||
}
|
||||
// Consider lease invalid 2 minutes before actual expiry for safety margin
|
||||
return time.Now().Before(c.ExpiresAt.Add(-2 * time.Minute))
|
||||
}
|
||||
|
||||
// loadCachedLease safely loads the cached lease
|
||||
func (g *LicenseGate) loadCachedLease() *cachedLease {
|
||||
if cached := g.cache.Load(); cached != nil {
|
||||
if lease, ok := cached.(*cachedLease); ok {
|
||||
return lease
|
||||
}
|
||||
}
|
||||
return &cachedLease{Valid: false}
|
||||
}
|
||||
|
||||
// storeLease safely stores a lease in the cache
|
||||
func (g *LicenseGate) storeLease(lease *cachedLease) {
|
||||
lease.CachedAt = time.Now()
|
||||
g.cache.Store(lease)
|
||||
}
|
||||
|
||||
// isInGracePeriod checks if we're still in the grace period
|
||||
func (g *LicenseGate) isInGracePeriod() bool {
|
||||
if graceUntil := g.graceUntil.Load(); graceUntil != nil {
|
||||
if grace, ok := graceUntil.(time.Time); ok {
|
||||
return time.Now().Before(grace)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extendGracePeriod extends the grace period on successful validation
|
||||
func (g *LicenseGate) extendGracePeriod() {
|
||||
g.graceUntil.Store(time.Now().Add(90 * time.Second))
|
||||
}
|
||||
|
||||
// Validate validates the license using cache, lease system, and circuit breaker
|
||||
func (g *LicenseGate) Validate(ctx context.Context, agentID string) error {
|
||||
// Check cached lease first
|
||||
if lease := g.loadCachedLease(); lease.ValidNow() {
|
||||
return g.validateCachedLease(ctx, lease, agentID)
|
||||
}
|
||||
|
||||
// Try to get/renew lease through circuit breaker
|
||||
_, err := g.breaker.Execute(func() (interface{}, error) {
|
||||
lease, err := g.requestOrRenewLease(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate the new lease
|
||||
if err := g.validateLease(ctx, lease, agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store successful lease
|
||||
g.storeLease(&cachedLease{
|
||||
LeaseToken: lease.LeaseToken,
|
||||
ExpiresAt: lease.ExpiresAt,
|
||||
ClusterID: lease.ClusterID,
|
||||
Valid: true,
|
||||
})
|
||||
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// If we're in grace period, allow startup but log warning
|
||||
if g.isInGracePeriod() {
|
||||
fmt.Printf("⚠️ License validation failed but in grace period: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("license validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Extend grace period on successful validation
|
||||
g.extendGracePeriod()
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCachedLease validates using cached lease token
|
||||
func (g *LicenseGate) validateCachedLease(ctx context.Context, lease *cachedLease, agentID string) error {
|
||||
validation := LeaseValidationRequest{
|
||||
LeaseToken: lease.LeaseToken,
|
||||
ClusterID: g.config.ClusterID,
|
||||
AgentID: agentID,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/licenses/validate-lease", strings.TrimSuffix(g.config.KachingURL, "/"))
|
||||
|
||||
reqBody, err := json.Marshal(validation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal lease validation request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create lease validation request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lease validation request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// If validation fails, invalidate cache
|
||||
lease.Valid = false
|
||||
g.storeLease(lease)
|
||||
return fmt.Errorf("lease validation failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var validationResp LeaseValidationResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&validationResp); err != nil {
|
||||
return fmt.Errorf("failed to decode lease validation response: %w", err)
|
||||
}
|
||||
|
||||
if !validationResp.Valid {
|
||||
// If validation fails, invalidate cache
|
||||
lease.Valid = false
|
||||
g.storeLease(lease)
|
||||
return fmt.Errorf("lease token is invalid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestOrRenewLease requests a new cluster lease or renews existing one
|
||||
func (g *LicenseGate) requestOrRenewLease(ctx context.Context) (*LeaseResponse, error) {
|
||||
// For now, request a new lease (TODO: implement renewal logic)
|
||||
leaseReq := LeaseRequest{
|
||||
ClusterID: g.config.ClusterID,
|
||||
RequestedReplicas: 1, // Start with single replica
|
||||
DurationMinutes: 60, // 1 hour lease
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/licenses/%s/cluster-lease",
|
||||
strings.TrimSuffix(g.config.KachingURL, "/"), g.config.LicenseID)
|
||||
|
||||
reqBody, err := json.Marshal(leaseReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal lease request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create lease request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lease request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, fmt.Errorf("rate limited by KACHING, retry after: %s", resp.Header.Get("Retry-After"))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("lease request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var leaseResp LeaseResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&leaseResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode lease response: %w", err)
|
||||
}
|
||||
|
||||
return &leaseResp, nil
|
||||
}
|
||||
|
||||
// validateLease validates a lease token
|
||||
func (g *LicenseGate) validateLease(ctx context.Context, lease *LeaseResponse, agentID string) error {
|
||||
validation := LeaseValidationRequest{
|
||||
LeaseToken: lease.LeaseToken,
|
||||
ClusterID: lease.ClusterID,
|
||||
AgentID: agentID,
|
||||
}
|
||||
|
||||
return g.validateLeaseRequest(ctx, validation)
|
||||
}
|
||||
|
||||
// validateLeaseRequest performs the actual lease validation HTTP request
|
||||
func (g *LicenseGate) validateLeaseRequest(ctx context.Context, validation LeaseValidationRequest) error {
|
||||
url := fmt.Sprintf("%s/api/v1/licenses/validate-lease", strings.TrimSuffix(g.config.KachingURL, "/"))
|
||||
|
||||
reqBody, err := json.Marshal(validation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal lease validation request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create lease validation request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lease validation request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("lease validation failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var validationResp LeaseValidationResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&validationResp); err != nil {
|
||||
return fmt.Errorf("failed to decode lease validation response: %w", err)
|
||||
}
|
||||
|
||||
if !validationResp.Valid {
|
||||
return fmt.Errorf("lease token is invalid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCacheStats returns cache statistics for monitoring
|
||||
func (g *LicenseGate) GetCacheStats() map[string]interface{} {
|
||||
lease := g.loadCachedLease()
|
||||
stats := map[string]interface{}{
|
||||
"cache_valid": lease.Valid,
|
||||
"cache_hit": lease.ValidNow(),
|
||||
"expires_at": lease.ExpiresAt,
|
||||
"cached_at": lease.CachedAt,
|
||||
"in_grace_period": g.isInGracePeriod(),
|
||||
"breaker_state": g.breaker.State().String(),
|
||||
}
|
||||
|
||||
if grace := g.graceUntil.Load(); grace != nil {
|
||||
if graceTime, ok := grace.(time.Time); ok {
|
||||
stats["grace_until"] = graceTime
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package licensing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -21,35 +22,60 @@ type LicenseConfig struct {
|
||||
}
|
||||
|
||||
// Validator handles license validation with KACHING
|
||||
// Enhanced with license gate for burst-proof validation
|
||||
type Validator struct {
|
||||
config LicenseConfig
|
||||
kachingURL string
|
||||
client *http.Client
|
||||
gate *LicenseGate // New: License gate for scaling support
|
||||
}
|
||||
|
||||
// NewValidator creates a new license validator
|
||||
// NewValidator creates a new license validator with enhanced scaling support
|
||||
func NewValidator(config LicenseConfig) *Validator {
|
||||
kachingURL := config.KachingURL
|
||||
if kachingURL == "" {
|
||||
kachingURL = DefaultKachingURL
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
validator := &Validator{
|
||||
config: config,
|
||||
kachingURL: kachingURL,
|
||||
client: &http.Client{
|
||||
Timeout: LicenseTimeout,
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize license gate for scaling support
|
||||
validator.gate = NewLicenseGate(config)
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// Validate performs license validation with KACHING license authority
|
||||
// CRITICAL: CHORUS will not start without valid license validation
|
||||
// Enhanced with caching, circuit breaker, and lease token support
|
||||
func (v *Validator) Validate() error {
|
||||
return v.ValidateWithContext(context.Background())
|
||||
}
|
||||
|
||||
// ValidateWithContext performs license validation with context and agent ID
|
||||
func (v *Validator) ValidateWithContext(ctx context.Context) error {
|
||||
if v.config.LicenseID == "" || v.config.ClusterID == "" {
|
||||
return fmt.Errorf("license ID and cluster ID are required")
|
||||
}
|
||||
|
||||
// Use enhanced license gate for validation
|
||||
agentID := "default-agent" // TODO: Get from config/environment
|
||||
if err := v.gate.Validate(ctx, agentID); err != nil {
|
||||
// Fallback to legacy validation for backward compatibility
|
||||
fmt.Printf("⚠️ License gate validation failed, trying legacy validation: %v\n", err)
|
||||
return v.validateLegacy()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLegacy performs the original license validation (for fallback)
|
||||
func (v *Validator) validateLegacy() error {
|
||||
// Prepare validation request
|
||||
request := map[string]interface{}{
|
||||
"license_id": v.config.LicenseID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chorus/pkg/shhh"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
)
|
||||
|
||||
@@ -29,6 +31,8 @@ type HypercoreLog struct {
|
||||
|
||||
// Replication
|
||||
replicators map[peer.ID]*Replicator
|
||||
|
||||
redactor *shhh.Sentinel
|
||||
}
|
||||
|
||||
// LogEntry represents a single entry in the distributed log
|
||||
@@ -88,6 +92,13 @@ func NewHypercoreLog(peerID peer.ID) *HypercoreLog {
|
||||
}
|
||||
}
|
||||
|
||||
// SetRedactor wires the SHHH sentinel so log payloads are sanitized before persistence.
|
||||
func (h *HypercoreLog) SetRedactor(redactor *shhh.Sentinel) {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
h.redactor = redactor
|
||||
}
|
||||
|
||||
// AppendString is a convenience method for string log types (to match interface)
|
||||
func (h *HypercoreLog) AppendString(logType string, data map[string]interface{}) error {
|
||||
_, err := h.Append(LogType(logType), data)
|
||||
@@ -101,12 +112,14 @@ func (h *HypercoreLog) Append(logType LogType, data map[string]interface{}) (*Lo
|
||||
|
||||
index := uint64(len(h.entries))
|
||||
|
||||
sanitized := h.redactData(logType, data)
|
||||
|
||||
entry := LogEntry{
|
||||
Index: index,
|
||||
Timestamp: time.Now(),
|
||||
Author: h.peerID.String(),
|
||||
Type: logType,
|
||||
Data: data,
|
||||
Data: sanitized,
|
||||
PrevHash: h.headHash,
|
||||
}
|
||||
|
||||
@@ -332,6 +345,64 @@ func (h *HypercoreLog) calculateEntryHash(entry LogEntry) (string, error) {
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func (h *HypercoreLog) redactData(logType LogType, data map[string]interface{}) map[string]interface{} {
|
||||
cloned := cloneLogMap(data)
|
||||
if cloned == nil {
|
||||
return nil
|
||||
}
|
||||
if h.redactor != nil {
|
||||
labels := map[string]string{
|
||||
"source": "hypercore",
|
||||
"log_type": string(logType),
|
||||
}
|
||||
h.redactor.RedactMapWithLabels(context.Background(), cloned, labels)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneLogMap(in map[string]interface{}) map[string]interface{} {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = cloneLogValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// @goal: CHORUS-REQ-001 - Fix duplicate type case compilation error
|
||||
// WHY: Go 1.18+ treats interface{} and any as identical types, causing duplicate case errors
|
||||
func cloneLogValue(v interface{}) interface{} {
|
||||
switch tv := v.(type) {
|
||||
case map[string]any:
|
||||
// @goal: CHORUS-REQ-001 - Convert any to interface{} for cloneLogMap compatibility
|
||||
converted := make(map[string]interface{}, len(tv))
|
||||
for k, val := range tv {
|
||||
converted[k] = val
|
||||
}
|
||||
return cloneLogMap(converted)
|
||||
case []any:
|
||||
converted := make([]interface{}, len(tv))
|
||||
for i, val := range tv {
|
||||
converted[i] = cloneLogValue(val)
|
||||
}
|
||||
return converted
|
||||
case []string:
|
||||
return append([]string(nil), tv...)
|
||||
default:
|
||||
return tv
|
||||
}
|
||||
}
|
||||
|
||||
func cloneLogSlice(in []interface{}) []interface{} {
|
||||
out := make([]interface{}, len(in))
|
||||
for i, val := range in {
|
||||
out[i] = cloneLogValue(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createSignature creates a simplified signature for the entry
|
||||
func (h *HypercoreLog) createSignature(entry LogEntry) string {
|
||||
// In production, this would use proper cryptographic signatures
|
||||
|
||||
323
internal/runtime/agent_support.go
Normal file
323
internal/runtime/agent_support.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chorus/internal/logging"
|
||||
"chorus/pkg/dht"
|
||||
"chorus/pkg/health"
|
||||
"chorus/pkg/shutdown"
|
||||
"chorus/pubsub"
|
||||
)
|
||||
|
||||
// simpleLogger implements basic logging for shutdown and health systems
|
||||
type simpleLogger struct {
|
||||
logger logging.Logger
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Info(msg string, args ...interface{}) {
|
||||
l.logger.Info(msg, args...)
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Warn(msg string, args ...interface{}) {
|
||||
l.logger.Warn(msg, args...)
|
||||
}
|
||||
|
||||
func (l *simpleLogger) Error(msg string, args ...interface{}) {
|
||||
l.logger.Error(msg, args...)
|
||||
}
|
||||
|
||||
// StartAgentMode runs the autonomous agent with all standard behaviors
|
||||
func (r *SharedRuntime) StartAgentMode() error {
|
||||
// Announce capabilities and role
|
||||
go r.announceAvailability()
|
||||
go r.announceCapabilitiesOnChange()
|
||||
go r.announceRoleOnStartup()
|
||||
|
||||
// Start status reporting
|
||||
go r.statusReporter()
|
||||
|
||||
r.Logger.Info("🔍 Listening for peers on container network...")
|
||||
r.Logger.Info("📡 Ready for task coordination and meta-discussion")
|
||||
r.Logger.Info("🎯 HMMM collaborative reasoning enabled")
|
||||
|
||||
// === Comprehensive Health Monitoring & Graceful Shutdown ===
|
||||
shutdownManager := shutdown.NewManager(30*time.Second, &simpleLogger{logger: r.Logger})
|
||||
|
||||
healthManager := health.NewManager(r.Node.ID().ShortString(), AppVersion, &simpleLogger{logger: r.Logger})
|
||||
healthManager.SetShutdownManager(shutdownManager)
|
||||
|
||||
// Register health checks
|
||||
r.setupHealthChecks(healthManager)
|
||||
|
||||
// Register components for graceful shutdown
|
||||
r.setupGracefulShutdown(shutdownManager, healthManager)
|
||||
|
||||
// Start health monitoring
|
||||
if err := healthManager.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.HealthManager = healthManager
|
||||
r.Logger.Info("❤️ Health monitoring started")
|
||||
|
||||
// Start health HTTP server
|
||||
if err := healthManager.StartHTTPServer(r.Config.Network.HealthPort); err != nil {
|
||||
r.Logger.Error("❌ Failed to start health HTTP server: %v", err)
|
||||
} else {
|
||||
r.Logger.Info("🏥 Health endpoints available at http://localhost:%d/health", r.Config.Network.HealthPort)
|
||||
}
|
||||
|
||||
// Start shutdown manager
|
||||
shutdownManager.Start()
|
||||
r.ShutdownManager = shutdownManager
|
||||
r.Logger.Info("🛡️ Graceful shutdown manager started")
|
||||
|
||||
r.Logger.Info("✅ CHORUS agent system fully operational with health monitoring")
|
||||
|
||||
// Wait for graceful shutdown
|
||||
shutdownManager.Wait()
|
||||
r.Logger.Info("✅ CHORUS agent system shutdown completed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// announceAvailability broadcasts current working status for task assignment
|
||||
func (r *SharedRuntime) announceAvailability() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ; ; <-ticker.C {
|
||||
currentTasks := r.TaskTracker.GetActiveTasks()
|
||||
maxTasks := r.TaskTracker.GetMaxTasks()
|
||||
isAvailable := len(currentTasks) < maxTasks
|
||||
|
||||
status := "ready"
|
||||
if len(currentTasks) >= maxTasks {
|
||||
status = "busy"
|
||||
} else if len(currentTasks) > 0 {
|
||||
status = "working"
|
||||
}
|
||||
|
||||
availability := map[string]interface{}{
|
||||
"node_id": r.Node.ID().ShortString(),
|
||||
"available_for_work": isAvailable,
|
||||
"current_tasks": len(currentTasks),
|
||||
"max_tasks": maxTasks,
|
||||
"last_activity": time.Now().Unix(),
|
||||
"status": status,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
if err := r.PubSub.PublishBzzzMessage(pubsub.AvailabilityBcast, availability); err != nil {
|
||||
r.Logger.Error("❌ Failed to announce availability: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusReporter provides periodic status updates
|
||||
func (r *SharedRuntime) statusReporter() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for ; ; <-ticker.C {
|
||||
peers := r.Node.ConnectedPeers()
|
||||
r.Logger.Info("📊 Status: %d connected peers", peers)
|
||||
}
|
||||
}
|
||||
|
||||
// announceCapabilitiesOnChange announces capabilities when they change
|
||||
func (r *SharedRuntime) announceCapabilitiesOnChange() {
|
||||
if r.PubSub == nil {
|
||||
r.Logger.Warn("⚠️ Capability broadcast skipped: PubSub not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Logger.Info("📢 Broadcasting agent capabilities to network")
|
||||
|
||||
activeTaskCount := 0
|
||||
if r.TaskTracker != nil {
|
||||
activeTaskCount = len(r.TaskTracker.GetActiveTasks())
|
||||
}
|
||||
|
||||
announcement := map[string]interface{}{
|
||||
"agent_id": r.Config.Agent.ID,
|
||||
"node_id": r.Node.ID().ShortString(),
|
||||
"version": AppVersion,
|
||||
"capabilities": r.Config.Agent.Capabilities,
|
||||
"expertise": r.Config.Agent.Expertise,
|
||||
"models": r.Config.Agent.Models,
|
||||
"specialization": r.Config.Agent.Specialization,
|
||||
"max_tasks": r.Config.Agent.MaxTasks,
|
||||
"current_tasks": activeTaskCount,
|
||||
"timestamp": time.Now().Unix(),
|
||||
"availability": "ready",
|
||||
}
|
||||
|
||||
if err := r.PubSub.PublishBzzzMessage(pubsub.CapabilityBcast, announcement); err != nil {
|
||||
r.Logger.Error("❌ Failed to broadcast capabilities: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.Logger.Info("✅ Capabilities broadcast published")
|
||||
|
||||
// TODO: Watch for live capability changes (role updates, model changes) and re-broadcast
|
||||
}
|
||||
|
||||
// announceRoleOnStartup announces role when the agent starts
|
||||
func (r *SharedRuntime) announceRoleOnStartup() {
|
||||
role := r.Config.Agent.Role
|
||||
if role == "" {
|
||||
r.Logger.Info("🎭 No agent role configured; skipping role announcement")
|
||||
return
|
||||
}
|
||||
if r.PubSub == nil {
|
||||
r.Logger.Warn("⚠️ Role announcement skipped: PubSub not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
r.Logger.Info("🎭 Announcing agent role to collaboration mesh")
|
||||
|
||||
announcement := map[string]interface{}{
|
||||
"agent_id": r.Config.Agent.ID,
|
||||
"node_id": r.Node.ID().ShortString(),
|
||||
"role": role,
|
||||
"expertise": r.Config.Agent.Expertise,
|
||||
"capabilities": r.Config.Agent.Capabilities,
|
||||
"reports_to": r.Config.Agent.ReportsTo,
|
||||
"specialization": r.Config.Agent.Specialization,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
opts := pubsub.MessageOptions{
|
||||
FromRole: role,
|
||||
Priority: "medium",
|
||||
ThreadID: fmt.Sprintf("role:%s", role),
|
||||
}
|
||||
|
||||
if err := r.PubSub.PublishRoleBasedMessage(pubsub.RoleAnnouncement, announcement, opts); err != nil {
|
||||
r.Logger.Error("❌ Failed to announce role: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.Logger.Info("✅ Role announcement published")
|
||||
}
|
||||
|
||||
func (r *SharedRuntime) setupHealthChecks(healthManager *health.Manager) {
|
||||
// Add BACKBEAT health check
|
||||
if r.BackbeatIntegration != nil {
|
||||
backbeatCheck := &health.HealthCheck{
|
||||
Name: "backbeat",
|
||||
Description: "BACKBEAT timing integration health",
|
||||
Interval: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
Enabled: true,
|
||||
Critical: false,
|
||||
Checker: func(ctx context.Context) health.CheckResult {
|
||||
healthInfo := r.BackbeatIntegration.GetHealth()
|
||||
connected, _ := healthInfo["connected"].(bool)
|
||||
|
||||
result := health.CheckResult{
|
||||
Healthy: connected,
|
||||
Details: healthInfo,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if connected {
|
||||
result.Message = "BACKBEAT integration healthy and connected"
|
||||
} else {
|
||||
result.Message = "BACKBEAT integration not connected"
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
healthManager.RegisterCheck(backbeatCheck)
|
||||
}
|
||||
|
||||
// Register enhanced health instrumentation when core subsystems are available
|
||||
if r.PubSub == nil {
|
||||
r.Logger.Warn("⚠️ Skipping enhanced health checks: PubSub not initialized")
|
||||
return
|
||||
}
|
||||
if r.ElectionManager == nil {
|
||||
r.Logger.Warn("⚠️ Skipping enhanced health checks: election manager not ready")
|
||||
return
|
||||
}
|
||||
|
||||
var replication *dht.ReplicationManager
|
||||
if r.DHTNode != nil {
|
||||
replication = r.DHTNode.ReplicationManager()
|
||||
}
|
||||
|
||||
enhanced := health.NewEnhancedHealthChecks(
|
||||
healthManager,
|
||||
r.ElectionManager,
|
||||
r.DHTNode,
|
||||
r.PubSub,
|
||||
replication,
|
||||
&simpleLogger{logger: r.Logger},
|
||||
)
|
||||
|
||||
r.EnhancedHealth = enhanced
|
||||
r.Logger.Info("🩺 Enhanced health checks registered")
|
||||
}
|
||||
|
||||
func (r *SharedRuntime) setupGracefulShutdown(shutdownManager *shutdown.Manager, healthManager *health.Manager) {
|
||||
if shutdownManager == nil {
|
||||
r.Logger.Warn("⚠️ Shutdown manager not initialized; graceful teardown skipped")
|
||||
return
|
||||
}
|
||||
|
||||
if r.HTTPServer != nil {
|
||||
httpComponent := shutdown.NewGenericComponent("http-api-server", 10, true).
|
||||
SetShutdownFunc(func(ctx context.Context) error {
|
||||
return r.HTTPServer.Stop()
|
||||
})
|
||||
shutdownManager.Register(httpComponent)
|
||||
}
|
||||
|
||||
if healthManager != nil {
|
||||
healthComponent := shutdown.NewGenericComponent("health-manager", 15, true).
|
||||
SetShutdownFunc(func(ctx context.Context) error {
|
||||
return healthManager.Stop()
|
||||
})
|
||||
shutdownManager.Register(healthComponent)
|
||||
}
|
||||
|
||||
if r.UCXIServer != nil {
|
||||
ucxiComponent := shutdown.NewGenericComponent("ucxi-server", 20, true).
|
||||
SetShutdownFunc(func(ctx context.Context) error {
|
||||
return r.UCXIServer.Stop()
|
||||
})
|
||||
shutdownManager.Register(ucxiComponent)
|
||||
}
|
||||
|
||||
if r.PubSub != nil {
|
||||
shutdownManager.Register(shutdown.NewPubSubComponent("pubsub", r.PubSub.Close, 30))
|
||||
}
|
||||
|
||||
if r.DHTNode != nil {
|
||||
dhtComponent := shutdown.NewGenericComponent("dht-node", 35, true).
|
||||
SetCloser(r.DHTNode.Close)
|
||||
shutdownManager.Register(dhtComponent)
|
||||
}
|
||||
|
||||
if r.Node != nil {
|
||||
shutdownManager.Register(shutdown.NewP2PNodeComponent("p2p-node", r.Node.Close, 40))
|
||||
}
|
||||
|
||||
if r.ElectionManager != nil {
|
||||
shutdownManager.Register(shutdown.NewElectionManagerComponent("election-manager", r.ElectionManager.Stop, 45))
|
||||
}
|
||||
|
||||
if r.BackbeatIntegration != nil {
|
||||
backbeatComponent := shutdown.NewGenericComponent("backbeat-integration", 50, true).
|
||||
SetShutdownFunc(func(ctx context.Context) error {
|
||||
return r.BackbeatIntegration.Stop()
|
||||
})
|
||||
shutdownManager.Register(backbeatComponent)
|
||||
}
|
||||
|
||||
r.Logger.Info("🛡️ Graceful shutdown components registered")
|
||||
}
|
||||
647
internal/runtime/shared.go
Normal file
647
internal/runtime/shared.go
Normal file
@@ -0,0 +1,647 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chorus/api"
|
||||
"chorus/coordinator"
|
||||
"chorus/discovery"
|
||||
"chorus/internal/backbeat"
|
||||
"chorus/internal/licensing"
|
||||
"chorus/internal/logging"
|
||||
"chorus/p2p"
|
||||
"chorus/pkg/config"
|
||||
"chorus/pkg/dht"
|
||||
"chorus/pkg/election"
|
||||
"chorus/pkg/health"
|
||||
"chorus/pkg/metrics"
|
||||
"chorus/pkg/prompt"
|
||||
"chorus/pkg/shhh"
|
||||
"chorus/pkg/shutdown"
|
||||
"chorus/pkg/ucxi"
|
||||
"chorus/pkg/ucxl"
|
||||
"chorus/pubsub"
|
||||
"chorus/reasoning"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "CHORUS"
|
||||
AppVersion = "0.1.0-dev"
|
||||
)
|
||||
|
||||
// SimpleLogger provides basic logging implementation
|
||||
type SimpleLogger struct{}
|
||||
|
||||
func (l *SimpleLogger) Info(msg string, args ...interface{}) {
|
||||
log.Printf("[INFO] "+msg, args...)
|
||||
}
|
||||
|
||||
func (l *SimpleLogger) Warn(msg string, args ...interface{}) {
|
||||
log.Printf("[WARN] "+msg, args...)
|
||||
}
|
||||
|
||||
func (l *SimpleLogger) Error(msg string, args ...interface{}) {
|
||||
log.Printf("[ERROR] "+msg, args...)
|
||||
}
|
||||
|
||||
// SimpleTaskTracker tracks active tasks for availability reporting
|
||||
type SimpleTaskTracker struct {
|
||||
maxTasks int
|
||||
activeTasks map[string]bool
|
||||
decisionPublisher *ucxl.DecisionPublisher
|
||||
}
|
||||
|
||||
// GetActiveTasks returns list of active task IDs
|
||||
func (t *SimpleTaskTracker) GetActiveTasks() []string {
|
||||
tasks := make([]string, 0, len(t.activeTasks))
|
||||
for taskID := range t.activeTasks {
|
||||
tasks = append(tasks, taskID)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
// GetMaxTasks returns maximum number of concurrent tasks
|
||||
func (t *SimpleTaskTracker) GetMaxTasks() int {
|
||||
return t.maxTasks
|
||||
}
|
||||
|
||||
// AddTask marks a task as active
|
||||
func (t *SimpleTaskTracker) AddTask(taskID string) {
|
||||
t.activeTasks[taskID] = true
|
||||
}
|
||||
|
||||
// RemoveTask marks a task as completed and publishes decision if publisher available
|
||||
func (t *SimpleTaskTracker) RemoveTask(taskID string) {
|
||||
delete(t.activeTasks, taskID)
|
||||
|
||||
// Publish task completion decision if publisher is available
|
||||
if t.decisionPublisher != nil {
|
||||
t.publishTaskCompletion(taskID, true, "Task completed successfully", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTaskCompletion publishes a task completion decision to DHT
|
||||
func (t *SimpleTaskTracker) publishTaskCompletion(taskID string, success bool, summary string, filesModified []string) {
|
||||
if t.decisionPublisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.decisionPublisher.PublishTaskCompletion(taskID, success, summary, filesModified); err != nil {
|
||||
fmt.Printf("⚠️ Failed to publish task completion for %s: %v\n", taskID, err)
|
||||
} else {
|
||||
fmt.Printf("📤 Published task completion decision for: %s\n", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// SharedRuntime contains all the shared P2P infrastructure components
|
||||
type SharedRuntime struct {
|
||||
Config *config.Config
|
||||
Logger *SimpleLogger
|
||||
Context context.Context
|
||||
Cancel context.CancelFunc
|
||||
Node *p2p.Node
|
||||
PubSub *pubsub.PubSub
|
||||
HypercoreLog *logging.HypercoreLog
|
||||
MDNSDiscovery *discovery.MDNSDiscovery
|
||||
BackbeatIntegration *backbeat.Integration
|
||||
DHTNode *dht.LibP2PDHT
|
||||
EncryptedStorage *dht.EncryptedDHTStorage
|
||||
DecisionPublisher *ucxl.DecisionPublisher
|
||||
ElectionManager *election.ElectionManager
|
||||
TaskCoordinator *coordinator.TaskCoordinator
|
||||
HTTPServer *api.HTTPServer
|
||||
UCXIServer *ucxi.Server
|
||||
HealthManager *health.Manager
|
||||
EnhancedHealth *health.EnhancedHealthChecks
|
||||
ShutdownManager *shutdown.Manager
|
||||
TaskTracker *SimpleTaskTracker
|
||||
Metrics *metrics.CHORUSMetrics
|
||||
Shhh *shhh.Sentinel
|
||||
}
|
||||
|
||||
// Initialize sets up all shared P2P infrastructure components
|
||||
func Initialize(appMode string) (*SharedRuntime, error) {
|
||||
runtime := &SharedRuntime{}
|
||||
runtime.Logger = &SimpleLogger{}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runtime.Context = ctx
|
||||
runtime.Cancel = cancel
|
||||
|
||||
runtime.Logger.Info("🎭 Starting CHORUS v%s - Container-First P2P Task Coordination", AppVersion)
|
||||
runtime.Logger.Info("📦 Container deployment - Mode: %s", appMode)
|
||||
|
||||
// Load configuration from environment (no config files in containers)
|
||||
runtime.Logger.Info("📋 Loading configuration from environment variables...")
|
||||
cfg, err := config.LoadFromEnvironment()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configuration error: %v", err)
|
||||
}
|
||||
runtime.Config = cfg
|
||||
|
||||
runtime.Logger.Info("✅ Configuration loaded successfully")
|
||||
runtime.Logger.Info("🤖 Agent ID: %s", cfg.Agent.ID)
|
||||
runtime.Logger.Info("🎯 Specialization: %s", cfg.Agent.Specialization)
|
||||
|
||||
// CRITICAL: Validate license before any P2P operations
|
||||
runtime.Logger.Info("🔐 Validating CHORUS license with KACHING...")
|
||||
licenseValidator := licensing.NewValidator(licensing.LicenseConfig{
|
||||
LicenseID: cfg.License.LicenseID,
|
||||
ClusterID: cfg.License.ClusterID,
|
||||
KachingURL: cfg.License.KachingURL,
|
||||
})
|
||||
if err := licenseValidator.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("license validation failed: %v", err)
|
||||
}
|
||||
runtime.Logger.Info("✅ License validation successful - CHORUS authorized to run")
|
||||
|
||||
// Initialize AI provider configuration
|
||||
runtime.Logger.Info("🧠 Configuring AI provider: %s", cfg.AI.Provider)
|
||||
if err := initializeAIProvider(cfg, runtime.Logger); err != nil {
|
||||
return nil, fmt.Errorf("AI provider initialization failed: %v", err)
|
||||
}
|
||||
runtime.Logger.Info("✅ AI provider configured successfully")
|
||||
|
||||
// Initialize metrics collector
|
||||
runtime.Metrics = metrics.NewCHORUSMetrics(nil)
|
||||
|
||||
// Initialize SHHH sentinel
|
||||
sentinel, err := shhh.NewSentinel(
|
||||
shhh.Config{},
|
||||
shhh.WithFindingObserver(runtime.handleShhhFindings),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize SHHH sentinel: %v", err)
|
||||
}
|
||||
sentinel.SetAuditSink(&shhhAuditSink{logger: runtime.Logger})
|
||||
runtime.Shhh = sentinel
|
||||
runtime.Logger.Info("🛡️ SHHH sentinel initialized")
|
||||
|
||||
// Initialize BACKBEAT integration
|
||||
var backbeatIntegration *backbeat.Integration
|
||||
backbeatIntegration, err = backbeat.NewIntegration(cfg, cfg.Agent.ID, runtime.Logger)
|
||||
if err != nil {
|
||||
runtime.Logger.Warn("⚠️ BACKBEAT integration initialization failed: %v", err)
|
||||
runtime.Logger.Info("📍 P2P operations will run without beat synchronization")
|
||||
} else {
|
||||
if err := backbeatIntegration.Start(ctx); err != nil {
|
||||
runtime.Logger.Warn("⚠️ Failed to start BACKBEAT integration: %v", err)
|
||||
backbeatIntegration = nil
|
||||
} else {
|
||||
runtime.Logger.Info("🎵 BACKBEAT integration started successfully")
|
||||
}
|
||||
}
|
||||
runtime.BackbeatIntegration = backbeatIntegration
|
||||
|
||||
// Initialize P2P node
|
||||
node, err := p2p.NewNode(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create P2P node: %v", err)
|
||||
}
|
||||
runtime.Node = node
|
||||
|
||||
runtime.Logger.Info("🐝 CHORUS node started successfully")
|
||||
runtime.Logger.Info("📍 Node ID: %s", node.ID().ShortString())
|
||||
runtime.Logger.Info("🔗 Listening addresses:")
|
||||
for _, addr := range node.Addresses() {
|
||||
runtime.Logger.Info(" %s/p2p/%s", addr, node.ID())
|
||||
}
|
||||
|
||||
// Initialize Hypercore-style logger for P2P coordination
|
||||
hlog := logging.NewHypercoreLog(node.ID())
|
||||
if runtime.Shhh != nil {
|
||||
hlog.SetRedactor(runtime.Shhh)
|
||||
}
|
||||
hlog.Append(logging.PeerJoined, map[string]interface{}{"status": "started"})
|
||||
runtime.HypercoreLog = hlog
|
||||
runtime.Logger.Info("📝 Hypercore logger initialized")
|
||||
|
||||
// Initialize mDNS discovery
|
||||
mdnsDiscovery, err := discovery.NewMDNSDiscovery(ctx, node.Host(), "chorus-peer-discovery")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create mDNS discovery: %v", err)
|
||||
}
|
||||
runtime.MDNSDiscovery = mdnsDiscovery
|
||||
|
||||
// Initialize PubSub with hypercore logging
|
||||
ps, err := pubsub.NewPubSubWithLogger(ctx, node.Host(), "chorus/coordination/v1", "hmmm/meta-discussion/v1", hlog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create PubSub: %v", err)
|
||||
}
|
||||
if runtime.Shhh != nil {
|
||||
ps.SetRedactor(runtime.Shhh)
|
||||
}
|
||||
runtime.PubSub = ps
|
||||
|
||||
runtime.Logger.Info("📡 PubSub system initialized")
|
||||
|
||||
// Join role-based topics if role is configured
|
||||
if cfg.Agent.Role != "" {
|
||||
reportsTo := []string{}
|
||||
if cfg.Agent.ReportsTo != "" {
|
||||
reportsTo = []string{cfg.Agent.ReportsTo}
|
||||
}
|
||||
if err := ps.JoinRoleBasedTopics(cfg.Agent.Role, cfg.Agent.Expertise, reportsTo); err != nil {
|
||||
runtime.Logger.Warn("⚠️ Failed to join role-based topics: %v", err)
|
||||
} else {
|
||||
runtime.Logger.Info("🎯 Joined role-based collaboration topics")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize remaining components
|
||||
if err := runtime.initializeElectionSystem(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize election system: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.initializeDHTStorage(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize DHT storage: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.initializeServices(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize services: %v", err)
|
||||
}
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// Cleanup properly shuts down all runtime components
|
||||
func (r *SharedRuntime) Cleanup() {
|
||||
r.Logger.Info("🔄 Starting graceful shutdown...")
|
||||
|
||||
if r.BackbeatIntegration != nil {
|
||||
r.BackbeatIntegration.Stop()
|
||||
}
|
||||
|
||||
if r.MDNSDiscovery != nil {
|
||||
r.MDNSDiscovery.Close()
|
||||
}
|
||||
|
||||
if r.PubSub != nil {
|
||||
r.PubSub.Close()
|
||||
}
|
||||
|
||||
if r.DHTNode != nil {
|
||||
r.DHTNode.Close()
|
||||
}
|
||||
|
||||
if r.Node != nil {
|
||||
r.Node.Close()
|
||||
}
|
||||
|
||||
if r.HTTPServer != nil {
|
||||
r.HTTPServer.Stop()
|
||||
}
|
||||
|
||||
if r.UCXIServer != nil {
|
||||
r.UCXIServer.Stop()
|
||||
}
|
||||
|
||||
if r.ElectionManager != nil {
|
||||
r.ElectionManager.Stop()
|
||||
}
|
||||
|
||||
if r.Cancel != nil {
|
||||
r.Cancel()
|
||||
}
|
||||
|
||||
r.Logger.Info("✅ CHORUS shutdown completed")
|
||||
}
|
||||
|
||||
// Helper methods for initialization (extracted from main.go)
|
||||
func (r *SharedRuntime) initializeElectionSystem() error {
|
||||
// === Admin Election System ===
|
||||
electionManager := election.NewElectionManager(r.Context, r.Config, r.Node.Host(), r.PubSub, r.Node.ID().ShortString())
|
||||
|
||||
// Set election callbacks with BACKBEAT integration
|
||||
electionManager.SetCallbacks(
|
||||
func(oldAdmin, newAdmin string) {
|
||||
r.Logger.Info("👑 Admin changed: %s -> %s", oldAdmin, newAdmin)
|
||||
|
||||
// Track admin change with BACKBEAT if available
|
||||
if r.BackbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("admin-change-%d", time.Now().Unix())
|
||||
if err := r.BackbeatIntegration.StartP2POperation(operationID, "admin_change", 2, map[string]interface{}{
|
||||
"old_admin": oldAdmin,
|
||||
"new_admin": newAdmin,
|
||||
}); err == nil {
|
||||
// Complete immediately as this is a state change, not a long operation
|
||||
r.BackbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// If this node becomes admin, enable SLURP functionality
|
||||
if newAdmin == r.Node.ID().ShortString() {
|
||||
r.Logger.Info("🎯 This node is now admin - enabling SLURP functionality")
|
||||
r.Config.Slurp.Enabled = true
|
||||
// Apply admin role configuration
|
||||
if err := r.Config.ApplyRoleDefinition("admin"); err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to apply admin role: %v", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
func(winner string) {
|
||||
r.Logger.Info("🏆 Election completed, winner: %s", winner)
|
||||
|
||||
// Track election completion with BACKBEAT if available
|
||||
if r.BackbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("election-completed-%d", time.Now().Unix())
|
||||
if err := r.BackbeatIntegration.StartP2POperation(operationID, "election", 1, map[string]interface{}{
|
||||
"winner": winner,
|
||||
"node_id": r.Node.ID().ShortString(),
|
||||
}); err == nil {
|
||||
r.BackbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if err := electionManager.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start election manager: %v", err)
|
||||
}
|
||||
r.ElectionManager = electionManager
|
||||
r.Logger.Info("✅ Election manager started with automated heartbeat management")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SharedRuntime) initializeDHTStorage() error {
|
||||
// === DHT Storage and Decision Publishing ===
|
||||
var dhtNode *dht.LibP2PDHT
|
||||
var encryptedStorage *dht.EncryptedDHTStorage
|
||||
var decisionPublisher *ucxl.DecisionPublisher
|
||||
|
||||
if r.Config.V2.DHT.Enabled {
|
||||
// Create DHT
|
||||
var err error
|
||||
dhtNode, err = dht.NewLibP2PDHT(r.Context, r.Node.Host())
|
||||
if err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to create DHT: %v", err)
|
||||
} else {
|
||||
r.Logger.Info("🕸️ DHT initialized")
|
||||
|
||||
// Bootstrap DHT with BACKBEAT tracking
|
||||
if r.BackbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("dht-bootstrap-%d", time.Now().Unix())
|
||||
if err := r.BackbeatIntegration.StartP2POperation(operationID, "dht_bootstrap", 4, nil); err == nil {
|
||||
r.BackbeatIntegration.UpdateP2POperationPhase(operationID, backbeat.PhaseConnecting, 0)
|
||||
}
|
||||
|
||||
if err := dhtNode.Bootstrap(); err != nil {
|
||||
r.Logger.Warn("⚠️ DHT bootstrap failed: %v", err)
|
||||
r.BackbeatIntegration.FailP2POperation(operationID, err.Error())
|
||||
} else {
|
||||
r.BackbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
} else {
|
||||
if err := dhtNode.Bootstrap(); err != nil {
|
||||
r.Logger.Warn("⚠️ DHT bootstrap failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to bootstrap peers if configured
|
||||
for _, addrStr := range r.Config.V2.DHT.BootstrapPeers {
|
||||
addr, err := multiaddr.NewMultiaddr(addrStr)
|
||||
if err != nil {
|
||||
r.Logger.Warn("⚠️ Invalid bootstrap address %s: %v", addrStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract peer info from multiaddr
|
||||
info, err := peer.AddrInfoFromP2pAddr(addr)
|
||||
if err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to parse peer info from %s: %v", addrStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track peer discovery with BACKBEAT if available
|
||||
if r.BackbeatIntegration != nil {
|
||||
operationID := fmt.Sprintf("peer-discovery-%d", time.Now().Unix())
|
||||
if err := r.BackbeatIntegration.StartP2POperation(operationID, "peer_discovery", 2, map[string]interface{}{
|
||||
"peer_addr": addrStr,
|
||||
}); err == nil {
|
||||
r.BackbeatIntegration.UpdateP2POperationPhase(operationID, backbeat.PhaseConnecting, 0)
|
||||
|
||||
if err := r.Node.Host().Connect(r.Context, *info); err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to connect to bootstrap peer %s: %v", addrStr, err)
|
||||
r.BackbeatIntegration.FailP2POperation(operationID, err.Error())
|
||||
} else {
|
||||
r.Logger.Info("🔗 Connected to DHT bootstrap peer: %s", addrStr)
|
||||
r.BackbeatIntegration.CompleteP2POperation(operationID, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := r.Node.Host().Connect(r.Context, *info); err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to connect to bootstrap peer %s: %v", addrStr, err)
|
||||
} else {
|
||||
r.Logger.Info("🔗 Connected to DHT bootstrap peer: %s", addrStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize encrypted storage
|
||||
encryptedStorage = dht.NewEncryptedDHTStorage(
|
||||
r.Context,
|
||||
r.Node.Host(),
|
||||
dhtNode,
|
||||
r.Config,
|
||||
r.Node.ID().ShortString(),
|
||||
)
|
||||
|
||||
// Start cache cleanup
|
||||
encryptedStorage.StartCacheCleanup(5 * time.Minute)
|
||||
r.Logger.Info("🔐 Encrypted DHT storage initialized")
|
||||
|
||||
// Initialize decision publisher
|
||||
decisionPublisher = ucxl.NewDecisionPublisher(
|
||||
r.Context,
|
||||
r.Config,
|
||||
encryptedStorage,
|
||||
r.Node.ID().ShortString(),
|
||||
r.Config.Agent.ID,
|
||||
)
|
||||
r.Logger.Info("📤 Decision publisher initialized")
|
||||
}
|
||||
} else {
|
||||
r.Logger.Info("⚪ DHT disabled in configuration")
|
||||
}
|
||||
|
||||
r.DHTNode = dhtNode
|
||||
r.EncryptedStorage = encryptedStorage
|
||||
r.DecisionPublisher = decisionPublisher
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SharedRuntime) initializeServices() error {
|
||||
// Create simple task tracker ahead of coordinator so broadcasts stay accurate
|
||||
taskTracker := &SimpleTaskTracker{
|
||||
maxTasks: r.Config.Agent.MaxTasks,
|
||||
activeTasks: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Connect decision publisher to task tracker if available
|
||||
if r.DecisionPublisher != nil {
|
||||
taskTracker.decisionPublisher = r.DecisionPublisher
|
||||
r.Logger.Info("📤 Task completion decisions will be published to DHT")
|
||||
}
|
||||
r.TaskTracker = taskTracker
|
||||
|
||||
// === Task Coordination Integration ===
|
||||
taskCoordinator := coordinator.NewTaskCoordinator(
|
||||
r.Context,
|
||||
r.PubSub,
|
||||
r.HypercoreLog,
|
||||
r.Config,
|
||||
r.Node.ID().ShortString(),
|
||||
nil, // HMMM router placeholder
|
||||
taskTracker,
|
||||
)
|
||||
|
||||
taskCoordinator.Start()
|
||||
r.TaskCoordinator = taskCoordinator
|
||||
r.Logger.Info("✅ Task coordination system active")
|
||||
|
||||
// Start HTTP API server
|
||||
httpServer := api.NewHTTPServer(r.Config.Network.APIPort, r.HypercoreLog, r.PubSub)
|
||||
go func() {
|
||||
r.Logger.Info("🌐 HTTP API server starting on :%d", r.Config.Network.APIPort)
|
||||
if err := httpServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
r.Logger.Error("❌ HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
r.HTTPServer = httpServer
|
||||
|
||||
// === UCXI Server Integration ===
|
||||
var ucxiServer *ucxi.Server
|
||||
if r.Config.UCXL.Enabled && r.Config.UCXL.Server.Enabled {
|
||||
storageDir := r.Config.UCXL.Storage.Directory
|
||||
if storageDir == "" {
|
||||
storageDir = filepath.Join(os.TempDir(), "chorus-ucxi-storage")
|
||||
}
|
||||
|
||||
storage, err := ucxi.NewBasicContentStorage(storageDir)
|
||||
if err != nil {
|
||||
r.Logger.Warn("⚠️ Failed to create UCXI storage: %v", err)
|
||||
} else {
|
||||
resolver := ucxi.NewBasicAddressResolver(r.Node.ID().ShortString())
|
||||
resolver.SetDefaultTTL(r.Config.UCXL.Resolution.CacheTTL)
|
||||
|
||||
ucxiConfig := ucxi.ServerConfig{
|
||||
Port: r.Config.UCXL.Server.Port,
|
||||
BasePath: r.Config.UCXL.Server.BasePath,
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: ucxi.SimpleLogger{},
|
||||
}
|
||||
|
||||
ucxiServer = ucxi.NewServer(ucxiConfig)
|
||||
go func() {
|
||||
r.Logger.Info("🔗 UCXI server starting on :%d", r.Config.UCXL.Server.Port)
|
||||
if err := ucxiServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
r.Logger.Error("❌ UCXI server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
r.Logger.Info("⚪ UCXI server disabled")
|
||||
}
|
||||
r.UCXIServer = ucxiServer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SharedRuntime) handleShhhFindings(ctx context.Context, findings []shhh.Finding) {
|
||||
if r == nil || r.Metrics == nil {
|
||||
return
|
||||
}
|
||||
for _, finding := range findings {
|
||||
r.Metrics.IncrementSHHHFindings(finding.Rule, string(finding.Severity), finding.Count)
|
||||
}
|
||||
}
|
||||
|
||||
type shhhAuditSink struct {
|
||||
logger *SimpleLogger
|
||||
}
|
||||
|
||||
func (s *shhhAuditSink) RecordRedaction(_ context.Context, event shhh.AuditEvent) {
|
||||
if s == nil || s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Warn("🔒 SHHH redaction applied (rule=%s severity=%s path=%s)", event.Rule, event.Severity, event.Path)
|
||||
}
|
||||
|
||||
// initializeAIProvider configures the reasoning engine with the appropriate AI provider
|
||||
func initializeAIProvider(cfg *config.Config, logger *SimpleLogger) error {
|
||||
// Set the AI provider
|
||||
reasoning.SetAIProvider(cfg.AI.Provider)
|
||||
|
||||
// Configure the selected provider
|
||||
switch cfg.AI.Provider {
|
||||
case "resetdata":
|
||||
if cfg.AI.ResetData.APIKey == "" {
|
||||
return fmt.Errorf("RESETDATA_API_KEY environment variable is required for resetdata provider")
|
||||
}
|
||||
|
||||
resetdataConfig := reasoning.ResetDataConfig{
|
||||
BaseURL: cfg.AI.ResetData.BaseURL,
|
||||
APIKey: cfg.AI.ResetData.APIKey,
|
||||
Model: cfg.AI.ResetData.Model,
|
||||
Timeout: cfg.AI.ResetData.Timeout,
|
||||
}
|
||||
reasoning.SetResetDataConfig(resetdataConfig)
|
||||
logger.Info("🌐 ResetData AI provider configured - Endpoint: %s, Model: %s",
|
||||
cfg.AI.ResetData.BaseURL, cfg.AI.ResetData.Model)
|
||||
|
||||
case "ollama":
|
||||
reasoning.SetOllamaEndpoint(cfg.AI.Ollama.Endpoint)
|
||||
logger.Info("🦙 Ollama AI provider configured - Endpoint: %s", cfg.AI.Ollama.Endpoint)
|
||||
|
||||
default:
|
||||
logger.Warn("⚠️ Unknown AI provider '%s', defaulting to resetdata", cfg.AI.Provider)
|
||||
if cfg.AI.ResetData.APIKey == "" {
|
||||
return fmt.Errorf("RESETDATA_API_KEY environment variable is required for default resetdata provider")
|
||||
}
|
||||
|
||||
resetdataConfig := reasoning.ResetDataConfig{
|
||||
BaseURL: cfg.AI.ResetData.BaseURL,
|
||||
APIKey: cfg.AI.ResetData.APIKey,
|
||||
Model: cfg.AI.ResetData.Model,
|
||||
Timeout: cfg.AI.ResetData.Timeout,
|
||||
}
|
||||
reasoning.SetResetDataConfig(resetdataConfig)
|
||||
reasoning.SetAIProvider("resetdata")
|
||||
}
|
||||
|
||||
// Configure model selection
|
||||
reasoning.SetModelConfig(
|
||||
cfg.Agent.Models,
|
||||
cfg.Agent.ModelSelectionWebhook,
|
||||
cfg.Agent.DefaultReasoningModel,
|
||||
)
|
||||
|
||||
// Initialize prompt sources (roles + default instructions) from Docker-mounted directory
|
||||
promptsDir := os.Getenv("CHORUS_PROMPTS_DIR")
|
||||
defaultInstrPath := os.Getenv("CHORUS_DEFAULT_INSTRUCTIONS_PATH")
|
||||
_ = prompt.Initialize(promptsDir, defaultInstrPath)
|
||||
|
||||
// Compose S + D for the agent role if available; otherwise use D only
|
||||
if cfg.Agent.Role != "" {
|
||||
if composed, err := prompt.ComposeSystemPrompt(cfg.Agent.Role); err == nil && strings.TrimSpace(composed) != "" {
|
||||
reasoning.SetDefaultSystemPrompt(composed)
|
||||
} else if d := prompt.GetDefaultInstructions(); strings.TrimSpace(d) != "" {
|
||||
reasoning.SetDefaultSystemPrompt(d)
|
||||
}
|
||||
} else if d := prompt.GetDefaultInstructions(); strings.TrimSpace(d) != "" {
|
||||
reasoning.SetDefaultSystemPrompt(d)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -20,10 +20,16 @@ type Config struct {
|
||||
DHTMode string // "client", "server", "auto"
|
||||
DHTProtocolPrefix string
|
||||
|
||||
// Connection limits
|
||||
// Connection limits and rate limiting
|
||||
MaxConnections int
|
||||
MaxPeersPerIP int
|
||||
ConnectionTimeout time.Duration
|
||||
LowWatermark int // Connection manager low watermark
|
||||
HighWatermark int // Connection manager high watermark
|
||||
DialsPerSecond int // Dial rate limiting
|
||||
MaxConcurrentDials int // Maximum concurrent outbound dials
|
||||
MaxConcurrentDHT int // Maximum concurrent DHT queries
|
||||
JoinStaggerMS int // Join stagger delay in milliseconds
|
||||
|
||||
// Security configuration
|
||||
EnableSecurity bool
|
||||
@@ -48,8 +54,8 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
NetworkID: "CHORUS-network",
|
||||
|
||||
// Discovery settings
|
||||
EnableMDNS: true,
|
||||
// Discovery settings - mDNS disabled for Swarm by default
|
||||
EnableMDNS: false, // Disabled for container environments
|
||||
MDNSServiceTag: "CHORUS-peer-discovery",
|
||||
|
||||
// DHT settings (disabled by default for local development)
|
||||
@@ -58,10 +64,16 @@ func DefaultConfig() *Config {
|
||||
DHTMode: "auto",
|
||||
DHTProtocolPrefix: "/CHORUS",
|
||||
|
||||
// Connection limits for local network
|
||||
// Connection limits and rate limiting for scaling
|
||||
MaxConnections: 50,
|
||||
MaxPeersPerIP: 3,
|
||||
ConnectionTimeout: 30 * time.Second,
|
||||
LowWatermark: 32, // Keep at least 32 connections
|
||||
HighWatermark: 128, // Trim above 128 connections
|
||||
DialsPerSecond: 5, // Limit outbound dials to prevent storms
|
||||
MaxConcurrentDials: 10, // Maximum concurrent outbound dials
|
||||
MaxConcurrentDHT: 16, // Maximum concurrent DHT queries
|
||||
JoinStaggerMS: 0, // No stagger by default (set by assignment)
|
||||
|
||||
// Security enabled by default
|
||||
EnableSecurity: true,
|
||||
@@ -165,3 +177,33 @@ func WithDHTProtocolPrefix(prefix string) Option {
|
||||
c.DHTProtocolPrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// WithConnectionManager sets connection manager watermarks
|
||||
func WithConnectionManager(low, high int) Option {
|
||||
return func(c *Config) {
|
||||
c.LowWatermark = low
|
||||
c.HighWatermark = high
|
||||
}
|
||||
}
|
||||
|
||||
// WithDialRateLimit sets the dial rate limiting
|
||||
func WithDialRateLimit(dialsPerSecond, maxConcurrent int) Option {
|
||||
return func(c *Config) {
|
||||
c.DialsPerSecond = dialsPerSecond
|
||||
c.MaxConcurrentDials = maxConcurrent
|
||||
}
|
||||
}
|
||||
|
||||
// WithDHTRateLimit sets the DHT query rate limiting
|
||||
func WithDHTRateLimit(maxConcurrentDHT int) Option {
|
||||
return func(c *Config) {
|
||||
c.MaxConcurrentDHT = maxConcurrentDHT
|
||||
}
|
||||
}
|
||||
|
||||
// WithJoinStagger sets the join stagger delay in milliseconds
|
||||
func WithJoinStagger(delayMS int) Option {
|
||||
return func(c *Config) {
|
||||
c.JoinStaggerMS = delayMS
|
||||
}
|
||||
}
|
||||
353
pkg/bootstrap/pool_manager.go
Normal file
353
pkg/bootstrap/pool_manager.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
)
|
||||
|
||||
// BootstrapPool manages a pool of bootstrap peers for DHT joining
|
||||
type BootstrapPool struct {
|
||||
peers []peer.AddrInfo
|
||||
dialsPerSecond int
|
||||
maxConcurrent int
|
||||
staggerDelay time.Duration
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// BootstrapConfig represents the JSON configuration for bootstrap peers
|
||||
type BootstrapConfig struct {
|
||||
Peers []BootstrapPeer `json:"peers"`
|
||||
Meta BootstrapMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// BootstrapPeer represents a single bootstrap peer
|
||||
type BootstrapPeer struct {
|
||||
ID string `json:"id"` // Peer ID
|
||||
Addresses []string `json:"addresses"` // Multiaddresses
|
||||
Priority int `json:"priority"` // Priority (higher = more likely to be selected)
|
||||
Healthy bool `json:"healthy"` // Health status
|
||||
LastSeen string `json:"last_seen"` // Last seen timestamp
|
||||
}
|
||||
|
||||
// BootstrapMeta contains metadata about the bootstrap configuration
|
||||
type BootstrapMeta struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
TotalPeers int `json:"total_peers"`
|
||||
HealthyPeers int `json:"healthy_peers"`
|
||||
}
|
||||
|
||||
// BootstrapSubset represents a subset of peers assigned to a replica
|
||||
type BootstrapSubset struct {
|
||||
Peers []peer.AddrInfo `json:"peers"`
|
||||
StaggerDelayMS int `json:"stagger_delay_ms"`
|
||||
AssignedAt time.Time `json:"assigned_at"`
|
||||
}
|
||||
|
||||
// NewBootstrapPool creates a new bootstrap pool manager
|
||||
func NewBootstrapPool(dialsPerSecond, maxConcurrent int, staggerMS int) *BootstrapPool {
|
||||
return &BootstrapPool{
|
||||
peers: []peer.AddrInfo{},
|
||||
dialsPerSecond: dialsPerSecond,
|
||||
maxConcurrent: maxConcurrent,
|
||||
staggerDelay: time.Duration(staggerMS) * time.Millisecond,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads bootstrap configuration from a JSON file
|
||||
func (bp *BootstrapPool) LoadFromFile(filePath string) error {
|
||||
if filePath == "" {
|
||||
return nil // No file configured
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read bootstrap file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return bp.loadFromJSON(data)
|
||||
}
|
||||
|
||||
// LoadFromURL loads bootstrap configuration from a URL (WHOOSH endpoint)
|
||||
func (bp *BootstrapPool) LoadFromURL(ctx context.Context, url string) error {
|
||||
if url == "" {
|
||||
return nil // No URL configured
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bootstrap request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := bp.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bootstrap request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read bootstrap response: %w", err)
|
||||
}
|
||||
|
||||
return bp.loadFromJSON(data)
|
||||
}
|
||||
|
||||
// loadFromJSON parses JSON bootstrap configuration
|
||||
func (bp *BootstrapPool) loadFromJSON(data []byte) error {
|
||||
var config BootstrapConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return fmt.Errorf("failed to parse bootstrap JSON: %w", err)
|
||||
}
|
||||
|
||||
// Convert bootstrap peers to AddrInfo
|
||||
var peers []peer.AddrInfo
|
||||
for _, bsPeer := range config.Peers {
|
||||
// Only include healthy peers
|
||||
if !bsPeer.Healthy {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse peer ID
|
||||
peerID, err := peer.Decode(bsPeer.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Invalid peer ID %s: %v\n", bsPeer.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse multiaddresses
|
||||
var addrs []multiaddr.Multiaddr
|
||||
for _, addrStr := range bsPeer.Addresses {
|
||||
addr, err := multiaddr.NewMultiaddr(addrStr)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Invalid multiaddress %s: %v\n", addrStr, err)
|
||||
continue
|
||||
}
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
|
||||
if len(addrs) > 0 {
|
||||
peers = append(peers, peer.AddrInfo{
|
||||
ID: peerID,
|
||||
Addrs: addrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bp.peers = peers
|
||||
fmt.Printf("📋 Loaded %d healthy bootstrap peers from configuration\n", len(peers))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromEnvironment loads bootstrap configuration from environment variables
|
||||
func (bp *BootstrapPool) LoadFromEnvironment() error {
|
||||
// Try loading from file first
|
||||
if bootstrapFile := os.Getenv("BOOTSTRAP_JSON"); bootstrapFile != "" {
|
||||
if err := bp.LoadFromFile(bootstrapFile); err != nil {
|
||||
fmt.Printf("⚠️ Failed to load bootstrap from file: %v\n", err)
|
||||
} else {
|
||||
return nil // Successfully loaded from file
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from URL
|
||||
if bootstrapURL := os.Getenv("BOOTSTRAP_URL"); bootstrapURL != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := bp.LoadFromURL(ctx, bootstrapURL); err != nil {
|
||||
fmt.Printf("⚠️ Failed to load bootstrap from URL: %v\n", err)
|
||||
} else {
|
||||
return nil // Successfully loaded from URL
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy environment variable
|
||||
if bootstrapPeersEnv := os.Getenv("CHORUS_BOOTSTRAP_PEERS"); bootstrapPeersEnv != "" {
|
||||
return bp.loadFromLegacyEnv(bootstrapPeersEnv)
|
||||
}
|
||||
|
||||
return nil // No bootstrap configuration found
|
||||
}
|
||||
|
||||
// loadFromLegacyEnv loads from comma-separated multiaddress list
|
||||
func (bp *BootstrapPool) loadFromLegacyEnv(peersEnv string) error {
|
||||
peerStrs := strings.Split(peersEnv, ",")
|
||||
var peers []peer.AddrInfo
|
||||
|
||||
for _, peerStr := range peerStrs {
|
||||
peerStr = strings.TrimSpace(peerStr)
|
||||
if peerStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse multiaddress
|
||||
addr, err := multiaddr.NewMultiaddr(peerStr)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Invalid bootstrap peer %s: %v\n", peerStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract peer info
|
||||
info, err := peer.AddrInfoFromP2pAddr(addr)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Failed to parse peer info from %s: %v\n", peerStr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
peers = append(peers, *info)
|
||||
}
|
||||
|
||||
bp.peers = peers
|
||||
fmt.Printf("📋 Loaded %d bootstrap peers from legacy environment\n", len(peers))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSubset returns a subset of bootstrap peers for a replica
|
||||
func (bp *BootstrapPool) GetSubset(count int) BootstrapSubset {
|
||||
if len(bp.peers) == 0 {
|
||||
return BootstrapSubset{
|
||||
Peers: []peer.AddrInfo{},
|
||||
StaggerDelayMS: 0,
|
||||
AssignedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure count doesn't exceed available peers
|
||||
if count > len(bp.peers) {
|
||||
count = len(bp.peers)
|
||||
}
|
||||
|
||||
// Randomly select peers from the pool
|
||||
selectedPeers := make([]peer.AddrInfo, 0, count)
|
||||
indices := rand.Perm(len(bp.peers))
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
selectedPeers = append(selectedPeers, bp.peers[indices[i]])
|
||||
}
|
||||
|
||||
// Generate random stagger delay (0 to configured max)
|
||||
staggerMS := 0
|
||||
if bp.staggerDelay > 0 {
|
||||
staggerMS = rand.Intn(int(bp.staggerDelay.Milliseconds()))
|
||||
}
|
||||
|
||||
return BootstrapSubset{
|
||||
Peers: selectedPeers,
|
||||
StaggerDelayMS: staggerMS,
|
||||
AssignedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectWithRateLimit connects to bootstrap peers with rate limiting
|
||||
func (bp *BootstrapPool) ConnectWithRateLimit(ctx context.Context, h host.Host, subset BootstrapSubset) error {
|
||||
if len(subset.Peers) == 0 {
|
||||
return nil // No peers to connect to
|
||||
}
|
||||
|
||||
// Apply stagger delay
|
||||
if subset.StaggerDelayMS > 0 {
|
||||
delay := time.Duration(subset.StaggerDelayMS) * time.Millisecond
|
||||
fmt.Printf("⏱️ Applying join stagger delay: %v\n", delay)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
// Continue after delay
|
||||
}
|
||||
}
|
||||
|
||||
// Create rate limiter for dials
|
||||
ticker := time.NewTicker(time.Second / time.Duration(bp.dialsPerSecond))
|
||||
defer ticker.Stop()
|
||||
|
||||
// Semaphore for concurrent dials
|
||||
semaphore := make(chan struct{}, bp.maxConcurrent)
|
||||
|
||||
// Connect to each peer with rate limiting
|
||||
for i, peerInfo := range subset.Peers {
|
||||
// Wait for rate limiter
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
// Rate limit satisfied
|
||||
}
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case semaphore <- struct{}{}:
|
||||
// Semaphore acquired
|
||||
}
|
||||
|
||||
// Connect to peer in goroutine
|
||||
go func(info peer.AddrInfo, index int) {
|
||||
defer func() { <-semaphore }() // Release semaphore
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.Connect(ctx, info); err != nil {
|
||||
fmt.Printf("⚠️ Failed to connect to bootstrap peer %s (%d/%d): %v\n",
|
||||
info.ID.ShortString(), index+1, len(subset.Peers), err)
|
||||
} else {
|
||||
fmt.Printf("🔗 Connected to bootstrap peer %s (%d/%d)\n",
|
||||
info.ID.ShortString(), index+1, len(subset.Peers))
|
||||
}
|
||||
}(peerInfo, i)
|
||||
}
|
||||
|
||||
// Wait for all connections to complete or timeout
|
||||
for i := 0; i < bp.maxConcurrent && i < len(subset.Peers); i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case semaphore <- struct{}{}:
|
||||
<-semaphore // Immediately release
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPeerCount returns the number of available bootstrap peers
|
||||
func (bp *BootstrapPool) GetPeerCount() int {
|
||||
return len(bp.peers)
|
||||
}
|
||||
|
||||
// GetPeers returns all bootstrap peers (for debugging)
|
||||
func (bp *BootstrapPool) GetPeers() []peer.AddrInfo {
|
||||
return bp.peers
|
||||
}
|
||||
|
||||
// GetStats returns bootstrap pool statistics
|
||||
func (bp *BootstrapPool) GetStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"peer_count": len(bp.peers),
|
||||
"dials_per_second": bp.dialsPerSecond,
|
||||
"max_concurrent": bp.maxConcurrent,
|
||||
"stagger_delay_ms": bp.staggerDelay.Milliseconds(),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type AgentConfig struct {
|
||||
Capabilities []string `yaml:"capabilities"`
|
||||
Models []string `yaml:"models"`
|
||||
Role string `yaml:"role"`
|
||||
Project string `yaml:"project"`
|
||||
Expertise []string `yaml:"expertise"`
|
||||
ReportsTo string `yaml:"reports_to"`
|
||||
Deliverables []string `yaml:"deliverables"`
|
||||
@@ -149,6 +150,7 @@ func LoadFromEnvironment() (*Config, error) {
|
||||
Capabilities: getEnvArrayOrDefault("CHORUS_CAPABILITIES", []string{"general_development", "task_coordination"}),
|
||||
Models: getEnvArrayOrDefault("CHORUS_MODELS", []string{"meta/llama-3.1-8b-instruct"}),
|
||||
Role: getEnvOrDefault("CHORUS_ROLE", ""),
|
||||
Project: getEnvOrDefault("CHORUS_PROJECT", "chorus"),
|
||||
Expertise: getEnvArrayOrDefault("CHORUS_EXPERTISE", []string{}),
|
||||
ReportsTo: getEnvOrDefault("CHORUS_REPORTS_TO", ""),
|
||||
Deliverables: getEnvArrayOrDefault("CHORUS_DELIVERABLES", []string{}),
|
||||
@@ -177,7 +179,7 @@ func LoadFromEnvironment() (*Config, error) {
|
||||
},
|
||||
ResetData: ResetDataConfig{
|
||||
BaseURL: getEnvOrDefault("RESETDATA_BASE_URL", "https://models.au-syd.resetdata.ai/v1"),
|
||||
APIKey: os.Getenv("RESETDATA_API_KEY"),
|
||||
APIKey: getEnvOrFileContent("RESETDATA_API_KEY", "RESETDATA_API_KEY_FILE"),
|
||||
Model: getEnvOrDefault("RESETDATA_MODEL", "meta/llama-3.1-8b-instruct"),
|
||||
Timeout: getEnvDurationOrDefault("RESETDATA_TIMEOUT", 30*time.Second),
|
||||
},
|
||||
@@ -214,7 +216,7 @@ func LoadFromEnvironment() (*Config, error) {
|
||||
AuditLogging: getEnvBoolOrDefault("CHORUS_AUDIT_LOGGING", true),
|
||||
AuditPath: getEnvOrDefault("CHORUS_AUDIT_PATH", "/tmp/chorus-audit.log"),
|
||||
ElectionConfig: ElectionConfig{
|
||||
DiscoveryTimeout: getEnvDurationOrDefault("CHORUS_DISCOVERY_TIMEOUT", 10*time.Second),
|
||||
DiscoveryTimeout: getEnvDurationOrDefault("CHORUS_DISCOVERY_TIMEOUT", 15*time.Second),
|
||||
HeartbeatTimeout: getEnvDurationOrDefault("CHORUS_HEARTBEAT_TIMEOUT", 30*time.Second),
|
||||
ElectionTimeout: getEnvDurationOrDefault("CHORUS_ELECTION_TIMEOUT", 60*time.Second),
|
||||
DiscoveryBackoff: getEnvDurationOrDefault("CHORUS_DISCOVERY_BACKOFF", 5*time.Second),
|
||||
@@ -361,3 +363,17 @@ func SaveConfig(cfg *Config, configPath string) error {
|
||||
// For containers, configuration is environment-based, so this is a no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadRuntimeConfig loads configuration with runtime assignment support
|
||||
func LoadRuntimeConfig() (*RuntimeConfig, error) {
|
||||
// Load base configuration from environment
|
||||
baseConfig, err := LoadFromEnvironment()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load base configuration: %w", err)
|
||||
}
|
||||
|
||||
// Create runtime configuration manager
|
||||
runtimeConfig := NewRuntimeConfig(baseConfig)
|
||||
|
||||
return runtimeConfig, nil
|
||||
}
|
||||
|
||||
354
pkg/config/runtime_config.go
Normal file
354
pkg/config/runtime_config.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RuntimeConfig provides dynamic configuration with assignment override support
|
||||
type RuntimeConfig struct {
|
||||
mu sync.RWMutex
|
||||
base *Config // Base configuration from environment
|
||||
over *Config // Override configuration from assignment
|
||||
}
|
||||
|
||||
// AssignmentConfig represents configuration received from WHOOSH assignment
|
||||
type AssignmentConfig struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PromptUCXL string `json:"prompt_ucxl,omitempty"`
|
||||
Specialization string `json:"specialization,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
BootstrapPeers []string `json:"bootstrap_peers,omitempty"`
|
||||
JoinStaggerMS int `json:"join_stagger_ms,omitempty"`
|
||||
DialsPerSecond int `json:"dials_per_second,omitempty"`
|
||||
MaxConcurrentDHT int `json:"max_concurrent_dht,omitempty"`
|
||||
AssignmentID string `json:"assignment_id,omitempty"`
|
||||
ConfigEpoch int64 `json:"config_epoch,omitempty"`
|
||||
}
|
||||
|
||||
// NewRuntimeConfig creates a new runtime configuration manager
|
||||
func NewRuntimeConfig(baseConfig *Config) *RuntimeConfig {
|
||||
return &RuntimeConfig{
|
||||
base: baseConfig,
|
||||
over: &Config{}, // Empty override initially
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value with override precedence
|
||||
func (rc *RuntimeConfig) Get(key string) interface{} {
|
||||
rc.mu.RLock()
|
||||
defer rc.mu.RUnlock()
|
||||
|
||||
// Check override first, then base
|
||||
if value := rc.getFromConfig(rc.over, key); value != nil {
|
||||
return value
|
||||
}
|
||||
return rc.getFromConfig(rc.base, key)
|
||||
}
|
||||
|
||||
// getFromConfig extracts a value from a config struct by key
|
||||
func (rc *RuntimeConfig) getFromConfig(cfg *Config, key string) interface{} {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "agent.role":
|
||||
if cfg.Agent.Role != "" {
|
||||
return cfg.Agent.Role
|
||||
}
|
||||
case "agent.specialization":
|
||||
if cfg.Agent.Specialization != "" {
|
||||
return cfg.Agent.Specialization
|
||||
}
|
||||
case "agent.capabilities":
|
||||
if len(cfg.Agent.Capabilities) > 0 {
|
||||
return cfg.Agent.Capabilities
|
||||
}
|
||||
case "agent.models":
|
||||
if len(cfg.Agent.Models) > 0 {
|
||||
return cfg.Agent.Models
|
||||
}
|
||||
case "agent.default_reasoning_model":
|
||||
if cfg.Agent.DefaultReasoningModel != "" {
|
||||
return cfg.Agent.DefaultReasoningModel
|
||||
}
|
||||
case "v2.dht.bootstrap_peers":
|
||||
if len(cfg.V2.DHT.BootstrapPeers) > 0 {
|
||||
return cfg.V2.DHT.BootstrapPeers
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetString retrieves a string configuration value
|
||||
func (rc *RuntimeConfig) GetString(key string) string {
|
||||
if value := rc.Get(key); value != nil {
|
||||
if str, ok := value.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetStringSlice retrieves a string slice configuration value
|
||||
func (rc *RuntimeConfig) GetStringSlice(key string) []string {
|
||||
if value := rc.Get(key); value != nil {
|
||||
if slice, ok := value.([]string); ok {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInt retrieves an integer configuration value
|
||||
func (rc *RuntimeConfig) GetInt(key string) int {
|
||||
if value := rc.Get(key); value != nil {
|
||||
if i, ok := value.(int); ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadAssignment loads configuration from WHOOSH assignment endpoint
|
||||
func (rc *RuntimeConfig) LoadAssignment(ctx context.Context) error {
|
||||
assignURL := os.Getenv("ASSIGN_URL")
|
||||
if assignURL == "" {
|
||||
return nil // No assignment URL configured
|
||||
}
|
||||
|
||||
// Build assignment request URL with task identity
|
||||
params := url.Values{}
|
||||
if taskSlot := os.Getenv("TASK_SLOT"); taskSlot != "" {
|
||||
params.Set("slot", taskSlot)
|
||||
}
|
||||
if taskID := os.Getenv("TASK_ID"); taskID != "" {
|
||||
params.Set("task", taskID)
|
||||
}
|
||||
if clusterID := os.Getenv("CHORUS_CLUSTER_ID"); clusterID != "" {
|
||||
params.Set("cluster", clusterID)
|
||||
}
|
||||
|
||||
fullURL := assignURL
|
||||
if len(params) > 0 {
|
||||
fullURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Fetch assignment with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create assignment request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("assignment request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("assignment request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse assignment response
|
||||
var assignment AssignmentConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&assignment); err != nil {
|
||||
return fmt.Errorf("failed to decode assignment response: %w", err)
|
||||
}
|
||||
|
||||
// Apply assignment to override config
|
||||
if err := rc.applyAssignment(&assignment); err != nil {
|
||||
return fmt.Errorf("failed to apply assignment: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📥 Loaded assignment: role=%s, model=%s, epoch=%d\n",
|
||||
assignment.Role, assignment.Model, assignment.ConfigEpoch)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAssignmentFromFile loads configuration from a file (for config objects)
|
||||
func (rc *RuntimeConfig) LoadAssignmentFromFile(filePath string) error {
|
||||
if filePath == "" {
|
||||
return nil // No file configured
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read assignment file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
var assignment AssignmentConfig
|
||||
if err := json.Unmarshal(data, &assignment); err != nil {
|
||||
return fmt.Errorf("failed to parse assignment file: %w", err)
|
||||
}
|
||||
|
||||
if err := rc.applyAssignment(&assignment); err != nil {
|
||||
return fmt.Errorf("failed to apply file assignment: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📁 Loaded assignment from file: role=%s, model=%s\n",
|
||||
assignment.Role, assignment.Model)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyAssignment applies an assignment to the override configuration
|
||||
func (rc *RuntimeConfig) applyAssignment(assignment *AssignmentConfig) error {
|
||||
rc.mu.Lock()
|
||||
defer rc.mu.Unlock()
|
||||
|
||||
// Create new override config
|
||||
override := &Config{
|
||||
Agent: AgentConfig{
|
||||
Role: assignment.Role,
|
||||
Specialization: assignment.Specialization,
|
||||
Capabilities: assignment.Capabilities,
|
||||
DefaultReasoningModel: assignment.Model,
|
||||
},
|
||||
V2: V2Config{
|
||||
DHT: DHTConfig{
|
||||
BootstrapPeers: assignment.BootstrapPeers,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Handle models array
|
||||
if assignment.Model != "" {
|
||||
override.Agent.Models = []string{assignment.Model}
|
||||
}
|
||||
|
||||
// Apply environment variables from assignment
|
||||
for key, value := range assignment.Environment {
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
|
||||
rc.over = override
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartReloadHandler starts a signal handler for configuration reload (SIGHUP)
|
||||
func (rc *RuntimeConfig) StartReloadHandler(ctx context.Context) {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGHUP)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sigChan:
|
||||
fmt.Println("🔄 Received SIGHUP, reloading configuration...")
|
||||
if err := rc.LoadAssignment(ctx); err != nil {
|
||||
fmt.Printf("⚠️ Failed to reload assignment: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Configuration reloaded successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetBaseConfig returns the base configuration (from environment)
|
||||
func (rc *RuntimeConfig) GetBaseConfig() *Config {
|
||||
rc.mu.RLock()
|
||||
defer rc.mu.RUnlock()
|
||||
return rc.base
|
||||
}
|
||||
|
||||
// GetEffectiveConfig returns the effective merged configuration
|
||||
func (rc *RuntimeConfig) GetEffectiveConfig() *Config {
|
||||
rc.mu.RLock()
|
||||
defer rc.mu.RUnlock()
|
||||
|
||||
// Start with base config
|
||||
effective := *rc.base
|
||||
|
||||
// Apply overrides
|
||||
if rc.over.Agent.Role != "" {
|
||||
effective.Agent.Role = rc.over.Agent.Role
|
||||
}
|
||||
if rc.over.Agent.Specialization != "" {
|
||||
effective.Agent.Specialization = rc.over.Agent.Specialization
|
||||
}
|
||||
if len(rc.over.Agent.Capabilities) > 0 {
|
||||
effective.Agent.Capabilities = rc.over.Agent.Capabilities
|
||||
}
|
||||
if len(rc.over.Agent.Models) > 0 {
|
||||
effective.Agent.Models = rc.over.Agent.Models
|
||||
}
|
||||
if rc.over.Agent.DefaultReasoningModel != "" {
|
||||
effective.Agent.DefaultReasoningModel = rc.over.Agent.DefaultReasoningModel
|
||||
}
|
||||
if len(rc.over.V2.DHT.BootstrapPeers) > 0 {
|
||||
effective.V2.DHT.BootstrapPeers = rc.over.V2.DHT.BootstrapPeers
|
||||
}
|
||||
|
||||
return &effective
|
||||
}
|
||||
|
||||
// GetAssignmentStats returns assignment statistics for monitoring
|
||||
func (rc *RuntimeConfig) GetAssignmentStats() map[string]interface{} {
|
||||
rc.mu.RLock()
|
||||
defer rc.mu.RUnlock()
|
||||
|
||||
hasOverride := rc.over.Agent.Role != "" ||
|
||||
rc.over.Agent.Specialization != "" ||
|
||||
len(rc.over.Agent.Capabilities) > 0 ||
|
||||
len(rc.over.V2.DHT.BootstrapPeers) > 0
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"has_assignment": hasOverride,
|
||||
"assign_url": os.Getenv("ASSIGN_URL"),
|
||||
"task_slot": os.Getenv("TASK_SLOT"),
|
||||
"task_id": os.Getenv("TASK_ID"),
|
||||
}
|
||||
|
||||
if hasOverride {
|
||||
stats["assigned_role"] = rc.over.Agent.Role
|
||||
stats["assigned_specialization"] = rc.over.Agent.Specialization
|
||||
stats["assigned_capabilities"] = rc.over.Agent.Capabilities
|
||||
stats["assigned_models"] = rc.over.Agent.Models
|
||||
stats["bootstrap_peers_count"] = len(rc.over.V2.DHT.BootstrapPeers)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// InitializeAssignmentFromEnv initializes assignment from environment variables
|
||||
func (rc *RuntimeConfig) InitializeAssignmentFromEnv(ctx context.Context) error {
|
||||
// Try loading from assignment URL first
|
||||
if err := rc.LoadAssignment(ctx); err != nil {
|
||||
fmt.Printf("⚠️ Failed to load assignment from URL: %v\n", err)
|
||||
}
|
||||
|
||||
// Try loading from file (for config objects)
|
||||
if assignFile := os.Getenv("ASSIGNMENT_FILE"); assignFile != "" {
|
||||
if err := rc.LoadAssignmentFromFile(assignFile); err != nil {
|
||||
fmt.Printf("⚠️ Failed to load assignment from file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start reload handler for SIGHUP
|
||||
rc.StartReloadHandler(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -96,6 +96,46 @@ func GetPredefinedRoles() map[string]*RoleDefinition {
|
||||
AuthorityLevel: AuthorityAdmin,
|
||||
CanDecrypt: []string{"security_engineer", "project_manager", "backend_developer", "frontend_developer", "devops_engineer"},
|
||||
},
|
||||
"security_expert": {
|
||||
Name: "security_expert",
|
||||
Description: "Advanced security analysis and policy work",
|
||||
Capabilities: []string{"security", "policy", "response"},
|
||||
AccessLevel: "high",
|
||||
AuthorityLevel: AuthorityAdmin,
|
||||
CanDecrypt: []string{"security_expert", "security_engineer", "project_manager"},
|
||||
},
|
||||
"senior_software_architect": {
|
||||
Name: "senior_software_architect",
|
||||
Description: "Architecture governance and system design",
|
||||
Capabilities: []string{"architecture", "design", "coordination"},
|
||||
AccessLevel: "high",
|
||||
AuthorityLevel: AuthorityAdmin,
|
||||
CanDecrypt: []string{"senior_software_architect", "project_manager", "backend_developer", "frontend_developer"},
|
||||
},
|
||||
"qa_engineer": {
|
||||
Name: "qa_engineer",
|
||||
Description: "Quality assurance and testing",
|
||||
Capabilities: []string{"testing", "validation"},
|
||||
AccessLevel: "medium",
|
||||
AuthorityLevel: AuthorityFull,
|
||||
CanDecrypt: []string{"qa_engineer", "backend_developer", "frontend_developer"},
|
||||
},
|
||||
"readonly_user": {
|
||||
Name: "readonly_user",
|
||||
Description: "Read-only observer with audit access",
|
||||
Capabilities: []string{"observation"},
|
||||
AccessLevel: "low",
|
||||
AuthorityLevel: AuthorityReadOnly,
|
||||
CanDecrypt: []string{"readonly_user"},
|
||||
},
|
||||
"suggestion_only_role": {
|
||||
Name: "suggestion_only_role",
|
||||
Description: "Can propose suggestions but not execute",
|
||||
Capabilities: []string{"recommendation"},
|
||||
AccessLevel: "low",
|
||||
AuthorityLevel: AuthoritySuggestion,
|
||||
CanDecrypt: []string{"suggestion_only_role"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
306
pkg/crypto/key_derivation.go
Normal file
306
pkg/crypto/key_derivation.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/armor"
|
||||
)
|
||||
|
||||
// KeyDerivationManager handles cluster-scoped key derivation for DHT encryption
|
||||
type KeyDerivationManager struct {
|
||||
clusterRootKey []byte
|
||||
clusterID string
|
||||
}
|
||||
|
||||
// DerivedKeySet contains keys derived for a specific role/scope
|
||||
type DerivedKeySet struct {
|
||||
RoleKey []byte // Role-specific key
|
||||
NodeKey []byte // Node-specific key for this instance
|
||||
AGEIdentity *age.X25519Identity // AGE identity for encryption/decryption
|
||||
AGERecipient *age.X25519Recipient // AGE recipient for encryption
|
||||
}
|
||||
|
||||
// NewKeyDerivationManager creates a new key derivation manager
|
||||
func NewKeyDerivationManager(clusterRootKey []byte, clusterID string) *KeyDerivationManager {
|
||||
return &KeyDerivationManager{
|
||||
clusterRootKey: clusterRootKey,
|
||||
clusterID: clusterID,
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyDerivationManagerFromSeed creates a manager from a seed string
|
||||
func NewKeyDerivationManagerFromSeed(seed, clusterID string) *KeyDerivationManager {
|
||||
// Use HKDF to derive a consistent root key from seed
|
||||
hash := sha256.New
|
||||
hkdf := hkdf.New(hash, []byte(seed), []byte(clusterID), []byte("CHORUS-cluster-root"))
|
||||
|
||||
rootKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf, rootKey); err != nil {
|
||||
panic(fmt.Errorf("failed to derive cluster root key: %w", err))
|
||||
}
|
||||
|
||||
return &KeyDerivationManager{
|
||||
clusterRootKey: rootKey,
|
||||
clusterID: clusterID,
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveRoleKeys derives encryption keys for a specific role and agent
|
||||
func (kdm *KeyDerivationManager) DeriveRoleKeys(role, agentID string) (*DerivedKeySet, error) {
|
||||
if kdm.clusterRootKey == nil {
|
||||
return nil, fmt.Errorf("cluster root key not initialized")
|
||||
}
|
||||
|
||||
// Derive role-specific key
|
||||
roleKey, err := kdm.deriveKey(fmt.Sprintf("role-%s", role), 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive role key: %w", err)
|
||||
}
|
||||
|
||||
// Derive node-specific key from role key and agent ID
|
||||
nodeKey, err := kdm.deriveKeyFromParent(roleKey, fmt.Sprintf("node-%s", agentID), 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive node key: %w", err)
|
||||
}
|
||||
|
||||
// Generate AGE identity from node key
|
||||
ageIdentity, err := kdm.generateAGEIdentityFromKey(nodeKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate AGE identity: %w", err)
|
||||
}
|
||||
|
||||
ageRecipient := ageIdentity.Recipient()
|
||||
|
||||
return &DerivedKeySet{
|
||||
RoleKey: roleKey,
|
||||
NodeKey: nodeKey,
|
||||
AGEIdentity: ageIdentity,
|
||||
AGERecipient: ageRecipient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeriveClusterWideKeys derives keys that are shared across the entire cluster for a role
|
||||
func (kdm *KeyDerivationManager) DeriveClusterWideKeys(role string) (*DerivedKeySet, error) {
|
||||
if kdm.clusterRootKey == nil {
|
||||
return nil, fmt.Errorf("cluster root key not initialized")
|
||||
}
|
||||
|
||||
// Derive role-specific key
|
||||
roleKey, err := kdm.deriveKey(fmt.Sprintf("role-%s", role), 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive role key: %w", err)
|
||||
}
|
||||
|
||||
// For cluster-wide keys, use a deterministic "cluster" identifier
|
||||
clusterNodeKey, err := kdm.deriveKeyFromParent(roleKey, "cluster-shared", 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive cluster node key: %w", err)
|
||||
}
|
||||
|
||||
// Generate AGE identity from cluster node key
|
||||
ageIdentity, err := kdm.generateAGEIdentityFromKey(clusterNodeKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate AGE identity: %w", err)
|
||||
}
|
||||
|
||||
ageRecipient := ageIdentity.Recipient()
|
||||
|
||||
return &DerivedKeySet{
|
||||
RoleKey: roleKey,
|
||||
NodeKey: clusterNodeKey,
|
||||
AGEIdentity: ageIdentity,
|
||||
AGERecipient: ageRecipient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deriveKey derives a key from the cluster root key using HKDF
|
||||
func (kdm *KeyDerivationManager) deriveKey(info string, length int) ([]byte, error) {
|
||||
hash := sha256.New
|
||||
hkdf := hkdf.New(hash, kdm.clusterRootKey, []byte(kdm.clusterID), []byte(info))
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := io.ReadFull(hkdf, key); err != nil {
|
||||
return nil, fmt.Errorf("HKDF key derivation failed: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// deriveKeyFromParent derives a key from a parent key using HKDF
|
||||
func (kdm *KeyDerivationManager) deriveKeyFromParent(parentKey []byte, info string, length int) ([]byte, error) {
|
||||
hash := sha256.New
|
||||
hkdf := hkdf.New(hash, parentKey, []byte(kdm.clusterID), []byte(info))
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := io.ReadFull(hkdf, key); err != nil {
|
||||
return nil, fmt.Errorf("HKDF key derivation failed: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// generateAGEIdentityFromKey generates a deterministic AGE identity from a key
|
||||
func (kdm *KeyDerivationManager) generateAGEIdentityFromKey(key []byte) (*age.X25519Identity, error) {
|
||||
if len(key) < 32 {
|
||||
return nil, fmt.Errorf("key must be at least 32 bytes")
|
||||
}
|
||||
|
||||
// Use the first 32 bytes as the private key seed
|
||||
var privKey [32]byte
|
||||
copy(privKey[:], key[:32])
|
||||
|
||||
// Generate a new identity (note: this loses deterministic behavior)
|
||||
// TODO: Implement deterministic key derivation when age API allows
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AGE identity: %w", err)
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// EncryptForRole encrypts data for a specific role (all nodes in that role can decrypt)
|
||||
func (kdm *KeyDerivationManager) EncryptForRole(data []byte, role string) ([]byte, error) {
|
||||
// Get cluster-wide keys for the role
|
||||
keySet, err := kdm.DeriveClusterWideKeys(role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive cluster keys: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt using AGE
|
||||
var encrypted []byte
|
||||
buf := &writeBuffer{data: &encrypted}
|
||||
armorWriter := armor.NewWriter(buf)
|
||||
|
||||
ageWriter, err := age.Encrypt(armorWriter, keySet.AGERecipient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create age writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err := ageWriter.Write(data); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted data: %w", err)
|
||||
}
|
||||
|
||||
if err := ageWriter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close age writer: %w", err)
|
||||
}
|
||||
|
||||
if err := armorWriter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close armor writer: %w", err)
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// DecryptForRole decrypts data encrypted for a specific role
|
||||
func (kdm *KeyDerivationManager) DecryptForRole(encryptedData []byte, role, agentID string) ([]byte, error) {
|
||||
// Try cluster-wide keys first
|
||||
clusterKeys, err := kdm.DeriveClusterWideKeys(role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive cluster keys: %w", err)
|
||||
}
|
||||
|
||||
if decrypted, err := kdm.decryptWithIdentity(encryptedData, clusterKeys.AGEIdentity); err == nil {
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// If cluster-wide decryption fails, try node-specific keys
|
||||
nodeKeys, err := kdm.DeriveRoleKeys(role, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive node keys: %w", err)
|
||||
}
|
||||
|
||||
return kdm.decryptWithIdentity(encryptedData, nodeKeys.AGEIdentity)
|
||||
}
|
||||
|
||||
// decryptWithIdentity decrypts data using an AGE identity
|
||||
func (kdm *KeyDerivationManager) decryptWithIdentity(encryptedData []byte, identity *age.X25519Identity) ([]byte, error) {
|
||||
armorReader := armor.NewReader(newReadBuffer(encryptedData))
|
||||
|
||||
ageReader, err := age.Decrypt(armorReader, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := io.ReadAll(ageReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// GetRoleRecipients returns AGE recipients for all nodes in a role (for multi-recipient encryption)
|
||||
func (kdm *KeyDerivationManager) GetRoleRecipients(role string, agentIDs []string) ([]*age.X25519Recipient, error) {
|
||||
var recipients []*age.X25519Recipient
|
||||
|
||||
// Add cluster-wide recipient
|
||||
clusterKeys, err := kdm.DeriveClusterWideKeys(role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive cluster keys: %w", err)
|
||||
}
|
||||
recipients = append(recipients, clusterKeys.AGERecipient)
|
||||
|
||||
// Add node-specific recipients
|
||||
for _, agentID := range agentIDs {
|
||||
nodeKeys, err := kdm.DeriveRoleKeys(role, agentID)
|
||||
if err != nil {
|
||||
continue // Skip this agent on error
|
||||
}
|
||||
recipients = append(recipients, nodeKeys.AGERecipient)
|
||||
}
|
||||
|
||||
return recipients, nil
|
||||
}
|
||||
|
||||
// GetKeySetStats returns statistics about derived key sets
|
||||
func (kdm *KeyDerivationManager) GetKeySetStats(role, agentID string) map[string]interface{} {
|
||||
stats := map[string]interface{}{
|
||||
"cluster_id": kdm.clusterID,
|
||||
"role": role,
|
||||
"agent_id": agentID,
|
||||
}
|
||||
|
||||
// Try to derive keys and add fingerprint info
|
||||
if keySet, err := kdm.DeriveRoleKeys(role, agentID); err == nil {
|
||||
stats["node_key_length"] = len(keySet.NodeKey)
|
||||
stats["role_key_length"] = len(keySet.RoleKey)
|
||||
stats["age_recipient"] = keySet.AGERecipient.String()
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Helper types for AGE encryption/decryption
|
||||
|
||||
type writeBuffer struct {
|
||||
data *[]byte
|
||||
}
|
||||
|
||||
func (w *writeBuffer) Write(p []byte) (n int, err error) {
|
||||
*w.data = append(*w.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type readBuffer struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func newReadBuffer(data []byte) *readBuffer {
|
||||
return &readBuffer{data: data, pos: 0}
|
||||
}
|
||||
|
||||
func (r *readBuffer) Read(p []byte) (n int, err error) {
|
||||
if r.pos >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
n = copy(p, r.data[r.pos:])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto/sha256"
|
||||
"github.com/ipfs/go-cid"
|
||||
dht "github.com/libp2p/go-libp2p-kad-dht"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/libp2p/go-libp2p/core/protocol"
|
||||
"github.com/libp2p/go-libp2p/core/routing"
|
||||
dht "github.com/libp2p/go-libp2p-kad-dht"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
"github.com/multiformats/go-multihash"
|
||||
"github.com/ipfs/go-cid"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// LibP2PDHT provides distributed hash table functionality for CHORUS peer discovery
|
||||
@@ -24,6 +24,7 @@ type LibP2PDHT struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
config *Config
|
||||
startTime time.Time
|
||||
|
||||
// Bootstrap state
|
||||
bootstrapped bool
|
||||
@@ -59,6 +60,8 @@ type Config struct {
|
||||
}
|
||||
|
||||
// PeerInfo holds information about discovered peers
|
||||
const defaultProviderResultLimit = 20
|
||||
|
||||
type PeerInfo struct {
|
||||
ID peer.ID
|
||||
Addresses []multiaddr.Multiaddr
|
||||
@@ -79,6 +82,11 @@ func DefaultConfig() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
// NewDHT is a backward compatible helper that delegates to NewLibP2PDHT.
|
||||
func NewDHT(ctx context.Context, host host.Host, opts ...Option) (*LibP2PDHT, error) {
|
||||
return NewLibP2PDHT(ctx, host, opts...)
|
||||
}
|
||||
|
||||
// NewLibP2PDHT creates a new LibP2PDHT instance
|
||||
func NewLibP2PDHT(ctx context.Context, host host.Host, opts ...Option) (*LibP2PDHT, error) {
|
||||
config := DefaultConfig()
|
||||
@@ -105,6 +113,7 @@ func NewLibP2PDHT(ctx context.Context, host host.Host, opts ...Option) (*LibP2PD
|
||||
ctx: dhtCtx,
|
||||
cancel: cancel,
|
||||
config: config,
|
||||
startTime: time.Now(),
|
||||
knownPeers: make(map[peer.ID]*PeerInfo),
|
||||
}
|
||||
|
||||
@@ -271,23 +280,24 @@ func (d *LibP2PDHT) FindProviders(ctx context.Context, key string, limit int) ([
|
||||
return nil, fmt.Errorf("failed to create CID from key: %w", err)
|
||||
}
|
||||
|
||||
// Find providers (FindProviders returns a channel and an error)
|
||||
providersChan, err := d.kdht.FindProviders(ctx, keyCID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find providers: %w", err)
|
||||
maxProviders := limit
|
||||
if maxProviders <= 0 {
|
||||
maxProviders = defaultProviderResultLimit
|
||||
}
|
||||
|
||||
// Collect providers from channel
|
||||
providers := make([]peer.AddrInfo, 0, limit)
|
||||
// TODO: Fix libp2p FindProviders channel type mismatch
|
||||
// The channel appears to return int instead of peer.AddrInfo in this version
|
||||
_ = providersChan // Avoid unused variable error
|
||||
// for providerInfo := range providersChan {
|
||||
// providers = append(providers, providerInfo)
|
||||
// if len(providers) >= limit {
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
providerCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
providersChan := d.kdht.FindProvidersAsync(providerCtx, keyCID, maxProviders)
|
||||
providers := make([]peer.AddrInfo, 0, maxProviders)
|
||||
|
||||
for providerInfo := range providersChan {
|
||||
providers = append(providers, providerInfo)
|
||||
if limit > 0 && len(providers) >= limit {
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
@@ -329,6 +339,22 @@ func (d *LibP2PDHT) GetConnectedPeers() []peer.ID {
|
||||
return d.kdht.Host().Network().Peers()
|
||||
}
|
||||
|
||||
// GetStats reports basic runtime statistics for the DHT
|
||||
func (d *LibP2PDHT) GetStats() DHTStats {
|
||||
stats := DHTStats{
|
||||
TotalPeers: len(d.GetConnectedPeers()),
|
||||
Uptime: time.Since(d.startTime),
|
||||
}
|
||||
|
||||
if d.replicationManager != nil {
|
||||
if metrics := d.replicationManager.GetMetrics(); metrics != nil {
|
||||
stats.TotalKeys = int(metrics.TotalKeys)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// RegisterPeer registers a peer with capability information
|
||||
func (d *LibP2PDHT) RegisterPeer(peerID peer.ID, agent, role string, capabilities []string) {
|
||||
d.peersMutex.Lock()
|
||||
@@ -617,6 +643,11 @@ func (d *LibP2PDHT) IsReplicationEnabled() bool {
|
||||
return d.replicationManager != nil
|
||||
}
|
||||
|
||||
// ReplicationManager returns the underlying replication manager if enabled.
|
||||
func (d *LibP2PDHT) ReplicationManager() *ReplicationManager {
|
||||
return d.replicationManager
|
||||
}
|
||||
|
||||
// Close shuts down the DHT
|
||||
func (d *LibP2PDHT) Close() error {
|
||||
// Stop replication manager first
|
||||
|
||||
@@ -2,546 +2,155 @@ package dht
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
libp2p "github.com/libp2p/go-libp2p"
|
||||
dhtmode "github.com/libp2p/go-libp2p-kad-dht"
|
||||
"github.com/libp2p/go-libp2p/core/test"
|
||||
dht "github.com/libp2p/go-libp2p-kad-dht"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
ctx context.Context
|
||||
host libp2pHost
|
||||
dht *LibP2PDHT
|
||||
}
|
||||
|
||||
type libp2pHost interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, opts ...Option) *harness {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
host, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatalf("failed to create libp2p host: %v", err)
|
||||
}
|
||||
|
||||
options := append([]Option{WithAutoBootstrap(false)}, opts...)
|
||||
d, err := NewLibP2PDHT(ctx, host, options...)
|
||||
if err != nil {
|
||||
host.Close()
|
||||
cancel()
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
d.Close()
|
||||
host.Close()
|
||||
cancel()
|
||||
})
|
||||
|
||||
return &harness{ctx: ctx, host: host, dht: d}
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if config.ProtocolPrefix != "/CHORUS" {
|
||||
t.Errorf("expected protocol prefix '/CHORUS', got %s", config.ProtocolPrefix)
|
||||
if cfg.ProtocolPrefix != "/CHORUS" {
|
||||
t.Fatalf("expected protocol prefix '/CHORUS', got %s", cfg.ProtocolPrefix)
|
||||
}
|
||||
|
||||
if config.BootstrapTimeout != 30*time.Second {
|
||||
t.Errorf("expected bootstrap timeout 30s, got %v", config.BootstrapTimeout)
|
||||
if cfg.BootstrapTimeout != 30*time.Second {
|
||||
t.Fatalf("expected bootstrap timeout 30s, got %v", cfg.BootstrapTimeout)
|
||||
}
|
||||
|
||||
if config.Mode != dht.ModeAuto {
|
||||
t.Errorf("expected mode auto, got %v", config.Mode)
|
||||
if cfg.Mode != dhtmode.ModeAuto {
|
||||
t.Fatalf("expected mode auto, got %v", cfg.Mode)
|
||||
}
|
||||
|
||||
if !config.AutoBootstrap {
|
||||
t.Error("expected auto bootstrap to be enabled")
|
||||
if !cfg.AutoBootstrap {
|
||||
t.Fatal("expected auto bootstrap to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDHT(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a test host
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Test with default options
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if d.host != host {
|
||||
t.Error("host not set correctly")
|
||||
}
|
||||
|
||||
if d.config.ProtocolPrefix != "/CHORUS" {
|
||||
t.Errorf("expected protocol prefix '/CHORUS', got %s", d.config.ProtocolPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDHTWithOptions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Test with custom options
|
||||
d, err := NewDHT(ctx, host,
|
||||
func TestWithOptionsOverridesDefaults(t *testing.T) {
|
||||
h := newHarness(t,
|
||||
WithProtocolPrefix("/custom"),
|
||||
WithMode(dht.ModeClient),
|
||||
WithBootstrapTimeout(60*time.Second),
|
||||
WithDiscoveryInterval(120*time.Second),
|
||||
WithAutoBootstrap(false),
|
||||
WithDiscoveryInterval(2*time.Minute),
|
||||
WithBootstrapTimeout(45*time.Second),
|
||||
WithMode(dhtmode.ModeClient),
|
||||
WithAutoBootstrap(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if d.config.ProtocolPrefix != "/custom" {
|
||||
t.Errorf("expected protocol prefix '/custom', got %s", d.config.ProtocolPrefix)
|
||||
cfg := h.dht.config
|
||||
|
||||
if cfg.ProtocolPrefix != "/custom" {
|
||||
t.Fatalf("expected protocol prefix '/custom', got %s", cfg.ProtocolPrefix)
|
||||
}
|
||||
|
||||
if d.config.Mode != dht.ModeClient {
|
||||
t.Errorf("expected mode client, got %v", d.config.Mode)
|
||||
if cfg.DiscoveryInterval != 2*time.Minute {
|
||||
t.Fatalf("expected discovery interval 2m, got %v", cfg.DiscoveryInterval)
|
||||
}
|
||||
|
||||
if d.config.BootstrapTimeout != 60*time.Second {
|
||||
t.Errorf("expected bootstrap timeout 60s, got %v", d.config.BootstrapTimeout)
|
||||
if cfg.BootstrapTimeout != 45*time.Second {
|
||||
t.Fatalf("expected bootstrap timeout 45s, got %v", cfg.BootstrapTimeout)
|
||||
}
|
||||
|
||||
if d.config.DiscoveryInterval != 120*time.Second {
|
||||
t.Errorf("expected discovery interval 120s, got %v", d.config.DiscoveryInterval)
|
||||
if cfg.Mode != dhtmode.ModeClient {
|
||||
t.Fatalf("expected mode client, got %v", cfg.Mode)
|
||||
}
|
||||
|
||||
if d.config.AutoBootstrap {
|
||||
t.Error("expected auto bootstrap to be disabled")
|
||||
if !cfg.AutoBootstrap {
|
||||
t.Fatal("expected auto bootstrap to remain enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithBootstrapPeersFromStrings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
func TestProvideRequiresBootstrap(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
bootstrapAddrs := []string{
|
||||
"/ip4/127.0.0.1/tcp/4001/p2p/QmTest1",
|
||||
"/ip4/127.0.0.1/tcp/4002/p2p/QmTest2",
|
||||
err := h.dht.Provide(h.ctx, "key")
|
||||
if err == nil {
|
||||
t.Fatal("expected Provide to fail when not bootstrapped")
|
||||
}
|
||||
|
||||
d, err := NewDHT(ctx, host, WithBootstrapPeersFromStrings(bootstrapAddrs))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if len(d.config.BootstrapPeers) != 2 {
|
||||
t.Errorf("expected 2 bootstrap peers, got %d", len(d.config.BootstrapPeers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithBootstrapPeersFromStringsInvalid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Include invalid addresses - they should be filtered out
|
||||
bootstrapAddrs := []string{
|
||||
"/ip4/127.0.0.1/tcp/4001/p2p/QmTest1", // valid
|
||||
"invalid-address", // invalid
|
||||
"/ip4/127.0.0.1/tcp/4002/p2p/QmTest2", // valid
|
||||
}
|
||||
|
||||
d, err := NewDHT(ctx, host, WithBootstrapPeersFromStrings(bootstrapAddrs))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Should have filtered out the invalid address
|
||||
if len(d.config.BootstrapPeers) != 2 {
|
||||
t.Errorf("expected 2 valid bootstrap peers, got %d", len(d.config.BootstrapPeers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapWithoutPeers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Bootstrap should use default IPFS peers when none configured
|
||||
err = d.Bootstrap()
|
||||
// This might fail in test environment without network access, but should not panic
|
||||
if err != nil {
|
||||
// Expected in test environment
|
||||
t.Logf("Bootstrap failed as expected in test environment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBootstrapped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Should not be bootstrapped initially
|
||||
if d.IsBootstrapped() {
|
||||
t.Error("DHT should not be bootstrapped initially")
|
||||
if !strings.Contains(err.Error(), "not bootstrapped") {
|
||||
t.Fatalf("expected error to indicate bootstrap requirement, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
h := newHarness(t)
|
||||
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
agent := "claude"
|
||||
role := "frontend"
|
||||
capabilities := []string{"react", "javascript"}
|
||||
|
||||
d.RegisterPeer(peerID, agent, role, capabilities)
|
||||
h.dht.RegisterPeer(peerID, "apollo", "platform", []string{"go"})
|
||||
|
||||
knownPeers := d.GetKnownPeers()
|
||||
if len(knownPeers) != 1 {
|
||||
t.Errorf("expected 1 known peer, got %d", len(knownPeers))
|
||||
peers := h.dht.GetKnownPeers()
|
||||
|
||||
info, ok := peers[peerID]
|
||||
if !ok {
|
||||
t.Fatalf("expected peer to be tracked")
|
||||
}
|
||||
|
||||
peerInfo, exists := knownPeers[peerID]
|
||||
if !exists {
|
||||
t.Error("peer not found in known peers")
|
||||
if info.Agent != "apollo" {
|
||||
t.Fatalf("expected agent apollo, got %s", info.Agent)
|
||||
}
|
||||
|
||||
if peerInfo.Agent != agent {
|
||||
t.Errorf("expected agent %s, got %s", agent, peerInfo.Agent)
|
||||
if info.Role != "platform" {
|
||||
t.Fatalf("expected role platform, got %s", info.Role)
|
||||
}
|
||||
|
||||
if peerInfo.Role != role {
|
||||
t.Errorf("expected role %s, got %s", role, peerInfo.Role)
|
||||
}
|
||||
|
||||
if len(peerInfo.Capabilities) != len(capabilities) {
|
||||
t.Errorf("expected %d capabilities, got %d", len(capabilities), len(peerInfo.Capabilities))
|
||||
if len(info.Capabilities) != 1 || info.Capabilities[0] != "go" {
|
||||
t.Fatalf("expected capability go, got %v", info.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConnectedPeers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
func TestGetStatsProvidesUptime(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
stats := h.dht.GetStats()
|
||||
|
||||
if stats.TotalPeers != 0 {
|
||||
t.Fatalf("expected zero peers, got %d", stats.TotalPeers)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Initially should have no connected peers
|
||||
peers := d.GetConnectedPeers()
|
||||
if len(peers) != 0 {
|
||||
t.Errorf("expected 0 connected peers, got %d", len(peers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAndGetValue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Test without bootstrap (should fail)
|
||||
key := "test-key"
|
||||
value := []byte("test-value")
|
||||
|
||||
err = d.PutValue(ctx, key, value)
|
||||
if err == nil {
|
||||
t.Error("PutValue should fail when DHT not bootstrapped")
|
||||
}
|
||||
|
||||
_, err = d.GetValue(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("GetValue should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideAndFindProviders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Test without bootstrap (should fail)
|
||||
key := "test-service"
|
||||
|
||||
err = d.Provide(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("Provide should fail when DHT not bootstrapped")
|
||||
}
|
||||
|
||||
_, err = d.FindProviders(ctx, key, 10)
|
||||
if err == nil {
|
||||
t.Error("FindProviders should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Test without bootstrap (should fail)
|
||||
peerID := test.RandPeerIDFatal(t)
|
||||
|
||||
_, err = d.FindPeer(ctx, peerID)
|
||||
if err == nil {
|
||||
t.Error("FindPeer should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPeersByRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Register some local peers
|
||||
peerID1 := test.RandPeerIDFatal(t)
|
||||
peerID2 := test.RandPeerIDFatal(t)
|
||||
|
||||
d.RegisterPeer(peerID1, "claude", "frontend", []string{"react"})
|
||||
d.RegisterPeer(peerID2, "claude", "backend", []string{"go"})
|
||||
|
||||
// Find frontend peers
|
||||
frontendPeers, err := d.FindPeersByRole(ctx, "frontend")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find peers by role: %v", err)
|
||||
}
|
||||
|
||||
if len(frontendPeers) != 1 {
|
||||
t.Errorf("expected 1 frontend peer, got %d", len(frontendPeers))
|
||||
}
|
||||
|
||||
if frontendPeers[0].ID != peerID1 {
|
||||
t.Error("wrong peer returned for frontend role")
|
||||
}
|
||||
|
||||
// Find all peers with wildcard
|
||||
allPeers, err := d.FindPeersByRole(ctx, "*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find all peers: %v", err)
|
||||
}
|
||||
|
||||
if len(allPeers) != 2 {
|
||||
t.Errorf("expected 2 peers with wildcard, got %d", len(allPeers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnounceRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Should fail when not bootstrapped
|
||||
err = d.AnnounceRole(ctx, "frontend")
|
||||
if err == nil {
|
||||
t.Error("AnnounceRole should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnounceCapability(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Should fail when not bootstrapped
|
||||
err = d.AnnounceCapability(ctx, "react")
|
||||
if err == nil {
|
||||
t.Error("AnnounceCapability should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoutingTable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
rt := d.GetRoutingTable()
|
||||
if rt == nil {
|
||||
t.Error("routing table should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDHTSize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
size := d.GetDHTSize()
|
||||
// Should be 0 or small initially
|
||||
if size < 0 {
|
||||
t.Errorf("DHT size should be non-negative, got %d", size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRoutingTable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host, WithAutoBootstrap(false))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
// Should fail when not bootstrapped
|
||||
err = d.RefreshRoutingTable()
|
||||
if err == nil {
|
||||
t.Error("RefreshRoutingTable should fail when DHT not bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if d.Host() != host {
|
||||
t.Error("Host() should return the same host instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := libp2p.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test host: %v", err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
d, err := NewDHT(ctx, host)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create DHT: %v", err)
|
||||
}
|
||||
|
||||
// Should close without error
|
||||
err = d.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() failed: %v", err)
|
||||
if stats.Uptime < 0 {
|
||||
t.Fatalf("expected non-negative uptime, got %v", stats.Uptime)
|
||||
}
|
||||
}
|
||||
@@ -2,559 +2,155 @@ package dht
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chorus/pkg/config"
|
||||
)
|
||||
|
||||
// TestDHTSecurityPolicyEnforcement tests security policy enforcement in DHT operations
|
||||
func TestDHTSecurityPolicyEnforcement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
type securityTestCase struct {
|
||||
name string
|
||||
currentRole string
|
||||
operation string
|
||||
ucxlAddress string
|
||||
role string
|
||||
address string
|
||||
contentType string
|
||||
expectSuccess bool
|
||||
expectedError string
|
||||
}{
|
||||
// Store operation tests
|
||||
{
|
||||
name: "admin_can_store_all_content",
|
||||
currentRole: "admin",
|
||||
operation: "store",
|
||||
ucxlAddress: "agent1:admin:system:security_audit",
|
||||
contentType: "decision",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "backend_developer_can_store_backend_content",
|
||||
currentRole: "backend_developer",
|
||||
operation: "store",
|
||||
ucxlAddress: "agent1:backend_developer:api:endpoint_design",
|
||||
contentType: "suggestion",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "readonly_role_cannot_store",
|
||||
currentRole: "readonly_user",
|
||||
operation: "store",
|
||||
ucxlAddress: "agent1:readonly_user:project:observation",
|
||||
contentType: "suggestion",
|
||||
expectSuccess: false,
|
||||
expectedError: "read-only authority",
|
||||
},
|
||||
{
|
||||
name: "unknown_role_cannot_store",
|
||||
currentRole: "invalid_role",
|
||||
operation: "store",
|
||||
ucxlAddress: "agent1:invalid_role:project:task",
|
||||
contentType: "decision",
|
||||
expectSuccess: false,
|
||||
expectedError: "unknown creator role",
|
||||
},
|
||||
|
||||
// Retrieve operation tests
|
||||
{
|
||||
name: "any_valid_role_can_retrieve",
|
||||
currentRole: "qa_engineer",
|
||||
operation: "retrieve",
|
||||
ucxlAddress: "agent1:backend_developer:api:test_data",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "unknown_role_cannot_retrieve",
|
||||
currentRole: "nonexistent_role",
|
||||
operation: "retrieve",
|
||||
ucxlAddress: "agent1:backend_developer:api:test_data",
|
||||
expectSuccess: false,
|
||||
expectedError: "unknown current role",
|
||||
},
|
||||
|
||||
// Announce operation tests
|
||||
{
|
||||
name: "coordination_role_can_announce",
|
||||
currentRole: "senior_software_architect",
|
||||
operation: "announce",
|
||||
ucxlAddress: "agent1:senior_software_architect:architecture:blueprint",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "decision_role_can_announce",
|
||||
currentRole: "security_expert",
|
||||
operation: "announce",
|
||||
ucxlAddress: "agent1:security_expert:security:policy",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "suggestion_role_cannot_announce",
|
||||
currentRole: "suggestion_only_role",
|
||||
operation: "announce",
|
||||
ucxlAddress: "agent1:suggestion_only_role:project:idea",
|
||||
expectSuccess: false,
|
||||
expectedError: "lacks authority",
|
||||
},
|
||||
{
|
||||
name: "readonly_role_cannot_announce",
|
||||
currentRole: "readonly_user",
|
||||
operation: "announce",
|
||||
ucxlAddress: "agent1:readonly_user:project:observation",
|
||||
expectSuccess: false,
|
||||
expectedError: "lacks authority",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create test configuration
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-agent",
|
||||
Role: tc.currentRole,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: true,
|
||||
AuditPath: "/tmp/test-security-audit.log",
|
||||
},
|
||||
}
|
||||
|
||||
// Create mock encrypted storage
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
|
||||
var err error
|
||||
switch tc.operation {
|
||||
case "store":
|
||||
err = eds.checkStoreAccessPolicy(tc.currentRole, tc.ucxlAddress, tc.contentType)
|
||||
case "retrieve":
|
||||
err = eds.checkRetrieveAccessPolicy(tc.currentRole, tc.ucxlAddress)
|
||||
case "announce":
|
||||
err = eds.checkAnnounceAccessPolicy(tc.currentRole, tc.ucxlAddress)
|
||||
}
|
||||
|
||||
if tc.expectSuccess {
|
||||
if err != nil {
|
||||
t.Errorf("Expected %s operation to succeed for role %s, but got error: %v",
|
||||
tc.operation, tc.currentRole, err)
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Errorf("Expected %s operation to fail for role %s, but it succeeded",
|
||||
tc.operation, tc.currentRole)
|
||||
}
|
||||
if tc.expectedError != "" && !containsSubstring(err.Error(), tc.expectedError) {
|
||||
t.Errorf("Expected error to contain '%s', got '%s'", tc.expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
expectErrHint string
|
||||
}
|
||||
|
||||
// TestDHTAuditLogging tests comprehensive audit logging for DHT operations
|
||||
func TestDHTAuditLogging(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
operation string
|
||||
role string
|
||||
ucxlAddress string
|
||||
success bool
|
||||
errorMsg string
|
||||
expectAudit bool
|
||||
}{
|
||||
{
|
||||
name: "successful_store_operation",
|
||||
operation: "store",
|
||||
role: "backend_developer",
|
||||
ucxlAddress: "agent1:backend_developer:api:user_service",
|
||||
success: true,
|
||||
expectAudit: true,
|
||||
},
|
||||
{
|
||||
name: "failed_store_operation",
|
||||
operation: "store",
|
||||
role: "readonly_user",
|
||||
ucxlAddress: "agent1:readonly_user:project:readonly_attempt",
|
||||
success: false,
|
||||
errorMsg: "read-only authority",
|
||||
expectAudit: true,
|
||||
},
|
||||
{
|
||||
name: "successful_retrieve_operation",
|
||||
operation: "retrieve",
|
||||
role: "frontend_developer",
|
||||
ucxlAddress: "agent1:backend_developer:api:user_data",
|
||||
success: true,
|
||||
expectAudit: true,
|
||||
},
|
||||
{
|
||||
name: "successful_announce_operation",
|
||||
operation: "announce",
|
||||
role: "senior_software_architect",
|
||||
ucxlAddress: "agent1:senior_software_architect:architecture:system_design",
|
||||
success: true,
|
||||
expectAudit: true,
|
||||
},
|
||||
{
|
||||
name: "audit_disabled_no_logging",
|
||||
operation: "store",
|
||||
role: "backend_developer",
|
||||
ucxlAddress: "agent1:backend_developer:api:no_audit",
|
||||
success: true,
|
||||
expectAudit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create configuration with audit logging
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-agent",
|
||||
Role: tc.role,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: tc.expectAudit,
|
||||
AuditPath: "/tmp/test-dht-audit.log",
|
||||
},
|
||||
}
|
||||
|
||||
// Create mock encrypted storage
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
|
||||
// Capture audit output
|
||||
auditCaptured := false
|
||||
|
||||
// Simulate audit operation
|
||||
switch tc.operation {
|
||||
case "store":
|
||||
// Mock the audit function call
|
||||
if tc.expectAudit && cfg.Security.AuditLogging {
|
||||
eds.auditStoreOperation(tc.ucxlAddress, tc.role, "test-content", 1024, tc.success, tc.errorMsg)
|
||||
auditCaptured = true
|
||||
}
|
||||
case "retrieve":
|
||||
if tc.expectAudit && cfg.Security.AuditLogging {
|
||||
eds.auditRetrieveOperation(tc.ucxlAddress, tc.role, tc.success, tc.errorMsg)
|
||||
auditCaptured = true
|
||||
}
|
||||
case "announce":
|
||||
if tc.expectAudit && cfg.Security.AuditLogging {
|
||||
eds.auditAnnounceOperation(tc.ucxlAddress, tc.role, tc.success, tc.errorMsg)
|
||||
auditCaptured = true
|
||||
}
|
||||
}
|
||||
|
||||
// Verify audit logging behavior
|
||||
if tc.expectAudit && !auditCaptured {
|
||||
t.Errorf("Expected audit logging for %s operation but none was captured", tc.operation)
|
||||
}
|
||||
if !tc.expectAudit && auditCaptured {
|
||||
t.Errorf("Expected no audit logging for %s operation but audit was captured", tc.operation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityConfigIntegration tests integration with SecurityConfig
|
||||
func TestSecurityConfigIntegration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testConfigs := []struct {
|
||||
name string
|
||||
auditLogging bool
|
||||
auditPath string
|
||||
expectAuditWork bool
|
||||
}{
|
||||
{
|
||||
name: "audit_enabled_with_path",
|
||||
auditLogging: true,
|
||||
auditPath: "/tmp/test-audit-enabled.log",
|
||||
expectAuditWork: true,
|
||||
},
|
||||
{
|
||||
name: "audit_disabled",
|
||||
auditLogging: false,
|
||||
auditPath: "/tmp/test-audit-disabled.log",
|
||||
expectAuditWork: false,
|
||||
},
|
||||
{
|
||||
name: "audit_enabled_no_path",
|
||||
auditLogging: true,
|
||||
auditPath: "",
|
||||
expectAuditWork: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testConfigs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-agent",
|
||||
Role: "backend_developer",
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: tc.auditLogging,
|
||||
AuditPath: tc.auditPath,
|
||||
},
|
||||
}
|
||||
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
|
||||
// Test audit function behavior with different configurations
|
||||
auditWorked := func() bool {
|
||||
if !cfg.Security.AuditLogging || cfg.Security.AuditPath == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}()
|
||||
|
||||
if auditWorked != tc.expectAuditWork {
|
||||
t.Errorf("Expected audit to work: %v, but got: %v", tc.expectAuditWork, auditWorked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleAuthorityHierarchy tests role authority hierarchy enforcement
|
||||
func TestRoleAuthorityHierarchy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test role authority levels for different operations
|
||||
authorityTests := []struct {
|
||||
role string
|
||||
authorityLevel config.AuthorityLevel
|
||||
canStore bool
|
||||
canRetrieve bool
|
||||
canAnnounce bool
|
||||
}{
|
||||
{
|
||||
role: "admin",
|
||||
authorityLevel: config.AuthorityMaster,
|
||||
canStore: true,
|
||||
canRetrieve: true,
|
||||
canAnnounce: true,
|
||||
},
|
||||
{
|
||||
role: "senior_software_architect",
|
||||
authorityLevel: config.AuthorityDecision,
|
||||
canStore: true,
|
||||
canRetrieve: true,
|
||||
canAnnounce: true,
|
||||
},
|
||||
{
|
||||
role: "security_expert",
|
||||
authorityLevel: config.AuthorityCoordination,
|
||||
canStore: true,
|
||||
canRetrieve: true,
|
||||
canAnnounce: true,
|
||||
},
|
||||
{
|
||||
role: "backend_developer",
|
||||
authorityLevel: config.AuthoritySuggestion,
|
||||
canStore: true,
|
||||
canRetrieve: true,
|
||||
canAnnounce: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range authorityTests {
|
||||
t.Run(tt.role+"_authority_test", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-agent",
|
||||
Role: tt.role,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: true,
|
||||
AuditPath: "/tmp/test-authority.log",
|
||||
},
|
||||
}
|
||||
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
|
||||
// Test store permission
|
||||
storeErr := eds.checkStoreAccessPolicy(tt.role, "test:address", "content")
|
||||
if tt.canStore && storeErr != nil {
|
||||
t.Errorf("Role %s should be able to store but got error: %v", tt.role, storeErr)
|
||||
}
|
||||
if !tt.canStore && storeErr == nil {
|
||||
t.Errorf("Role %s should not be able to store but operation succeeded", tt.role)
|
||||
}
|
||||
|
||||
// Test retrieve permission
|
||||
retrieveErr := eds.checkRetrieveAccessPolicy(tt.role, "test:address")
|
||||
if tt.canRetrieve && retrieveErr != nil {
|
||||
t.Errorf("Role %s should be able to retrieve but got error: %v", tt.role, retrieveErr)
|
||||
}
|
||||
if !tt.canRetrieve && retrieveErr == nil {
|
||||
t.Errorf("Role %s should not be able to retrieve but operation succeeded", tt.role)
|
||||
}
|
||||
|
||||
// Test announce permission
|
||||
announceErr := eds.checkAnnounceAccessPolicy(tt.role, "test:address")
|
||||
if tt.canAnnounce && announceErr != nil {
|
||||
t.Errorf("Role %s should be able to announce but got error: %v", tt.role, announceErr)
|
||||
}
|
||||
if !tt.canAnnounce && announceErr == nil {
|
||||
t.Errorf("Role %s should not be able to announce but operation succeeded", tt.role)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityMetrics tests security-related metrics
|
||||
func TestSecurityMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-agent",
|
||||
Role: "backend_developer",
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: true,
|
||||
AuditPath: "/tmp/test-metrics.log",
|
||||
},
|
||||
}
|
||||
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
|
||||
// Simulate some operations to generate metrics
|
||||
for i := 0; i < 5; i++ {
|
||||
eds.metrics.StoredItems++
|
||||
eds.metrics.RetrievedItems++
|
||||
eds.metrics.EncryptionOps++
|
||||
eds.metrics.DecryptionOps++
|
||||
}
|
||||
|
||||
metrics := eds.GetMetrics()
|
||||
|
||||
expectedMetrics := map[string]int64{
|
||||
"stored_items": 5,
|
||||
"retrieved_items": 5,
|
||||
"encryption_ops": 5,
|
||||
"decryption_ops": 5,
|
||||
}
|
||||
|
||||
for metricName, expectedValue := range expectedMetrics {
|
||||
if actualValue, ok := metrics[metricName]; !ok {
|
||||
t.Errorf("Expected metric %s to be present in metrics", metricName)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("Expected %s to be %d, got %v", metricName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createMockEncryptedStorage(ctx context.Context, cfg *config.Config) *EncryptedDHTStorage {
|
||||
func newTestEncryptedStorage(cfg *config.Config) *EncryptedDHTStorage {
|
||||
return &EncryptedDHTStorage{
|
||||
ctx: ctx,
|
||||
ctx: context.Background(),
|
||||
config: cfg,
|
||||
nodeID: "test-node-id",
|
||||
nodeID: "test-node",
|
||||
cache: make(map[string]*CachedEntry),
|
||||
metrics: &StorageMetrics{
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
metrics: &StorageMetrics{LastUpdate: time.Now()},
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubstring(str, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
func TestCheckStoreAccessPolicy(t *testing.T) {
|
||||
cases := []securityTestCase{
|
||||
{
|
||||
name: "backend developer can store",
|
||||
role: "backend_developer",
|
||||
address: "agent1:backend_developer:api:endpoint",
|
||||
contentType: "decision",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "project manager can store",
|
||||
role: "project_manager",
|
||||
address: "agent1:project_manager:plan:milestone",
|
||||
contentType: "decision",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "read only user cannot store",
|
||||
role: "readonly_user",
|
||||
address: "agent1:readonly_user:note:observation",
|
||||
contentType: "note",
|
||||
expectSuccess: false,
|
||||
expectErrHint: "read-only authority",
|
||||
},
|
||||
{
|
||||
name: "unknown role rejected",
|
||||
role: "ghost_role",
|
||||
address: "agent1:ghost_role:context",
|
||||
contentType: "decision",
|
||||
expectSuccess: false,
|
||||
expectErrHint: "unknown creator role",
|
||||
},
|
||||
}
|
||||
if len(str) < len(substr) {
|
||||
return false
|
||||
|
||||
cfg := &config.Config{Agent: config.AgentConfig{}}
|
||||
eds := newTestEncryptedStorage(cfg)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := eds.checkStoreAccessPolicy(tc.role, tc.address, tc.contentType)
|
||||
verifySecurityExpectation(t, tc.expectSuccess, tc.expectErrHint, err)
|
||||
})
|
||||
}
|
||||
for i := 0; i <= len(str)-len(substr); i++ {
|
||||
if str[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Benchmarks for security performance
|
||||
|
||||
func BenchmarkSecurityPolicyChecks(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "bench-agent",
|
||||
Role: "backend_developer",
|
||||
func TestCheckRetrieveAccessPolicy(t *testing.T) {
|
||||
cases := []securityTestCase{
|
||||
{
|
||||
name: "qa engineer allowed",
|
||||
role: "qa_engineer",
|
||||
address: "agent1:backend_developer:api:tests",
|
||||
expectSuccess: true,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: true,
|
||||
AuditPath: "/tmp/bench-security.log",
|
||||
{
|
||||
name: "unknown role rejected",
|
||||
role: "unknown",
|
||||
address: "agent1:backend_developer:api:tests",
|
||||
expectSuccess: false,
|
||||
expectErrHint: "unknown current role",
|
||||
},
|
||||
}
|
||||
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
cfg := &config.Config{Agent: config.AgentConfig{}}
|
||||
eds := newTestEncryptedStorage(cfg)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.Run("store_policy_check", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.checkStoreAccessPolicy("backend_developer", "test:address", "content")
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := eds.checkRetrieveAccessPolicy(tc.role, tc.address)
|
||||
verifySecurityExpectation(t, tc.expectSuccess, tc.expectErrHint, err)
|
||||
})
|
||||
|
||||
b.Run("retrieve_policy_check", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.checkRetrieveAccessPolicy("backend_developer", "test:address")
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("announce_policy_check", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.checkAnnounceAccessPolicy("senior_software_architect", "test:address")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkAuditOperations(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "bench-agent",
|
||||
Role: "backend_developer",
|
||||
func TestCheckAnnounceAccessPolicy(t *testing.T) {
|
||||
cases := []securityTestCase{
|
||||
{
|
||||
name: "architect can announce",
|
||||
role: "senior_software_architect",
|
||||
address: "agent1:senior_software_architect:architecture:proposal",
|
||||
expectSuccess: true,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
KeyRotationDays: 90,
|
||||
AuditLogging: true,
|
||||
AuditPath: "/tmp/bench-audit.log",
|
||||
{
|
||||
name: "suggestion role cannot announce",
|
||||
role: "suggestion_only_role",
|
||||
address: "agent1:suggestion_only_role:idea",
|
||||
expectSuccess: false,
|
||||
expectErrHint: "lacks authority",
|
||||
},
|
||||
{
|
||||
name: "unknown role rejected",
|
||||
role: "mystery",
|
||||
address: "agent1:mystery:topic",
|
||||
expectSuccess: false,
|
||||
expectErrHint: "unknown current role",
|
||||
},
|
||||
}
|
||||
|
||||
eds := createMockEncryptedStorage(ctx, cfg)
|
||||
cfg := &config.Config{Agent: config.AgentConfig{}}
|
||||
eds := newTestEncryptedStorage(cfg)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.Run("store_audit", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.auditStoreOperation("test:address", "backend_developer", "content", 1024, true, "")
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := eds.checkAnnounceAccessPolicy(tc.role, tc.address)
|
||||
verifySecurityExpectation(t, tc.expectSuccess, tc.expectErrHint, err)
|
||||
})
|
||||
|
||||
b.Run("retrieve_audit", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.auditRetrieveOperation("test:address", "backend_developer", true, "")
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("announce_audit", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
eds.auditAnnounceOperation("test:address", "backend_developer", true, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func verifySecurityExpectation(t *testing.T, expectSuccess bool, hint string, err error) {
|
||||
t.Helper()
|
||||
|
||||
if expectSuccess {
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got success")
|
||||
}
|
||||
|
||||
if hint != "" && !strings.Contains(err.Error(), hint) {
|
||||
t.Fatalf("expected error to contain %q, got %q", hint, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,117 @@
|
||||
package dht
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"chorus/pkg/config"
|
||||
libp2p "github.com/libp2p/go-libp2p"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"github.com/libp2p/go-libp2p/p2p/security/noise"
|
||||
"github.com/libp2p/go-libp2p/p2p/transport/tcp"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
)
|
||||
|
||||
// NewRealDHT creates a new real DHT implementation
|
||||
func NewRealDHT(config *config.HybridConfig) (DHT, error) {
|
||||
// TODO: Implement real DHT initialization
|
||||
// For now, return an error to indicate it's not yet implemented
|
||||
return nil, fmt.Errorf("real DHT implementation not yet available")
|
||||
// RealDHT wraps a libp2p-based DHT to satisfy the generic DHT interface.
|
||||
type RealDHT struct {
|
||||
cancel context.CancelFunc
|
||||
host host.Host
|
||||
dht *LibP2PDHT
|
||||
}
|
||||
|
||||
// NewRealDHT creates a new real DHT implementation backed by libp2p.
|
||||
func NewRealDHT(cfg *config.HybridConfig) (DHT, error) {
|
||||
if cfg == nil {
|
||||
cfg = &config.HybridConfig{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
listenAddr, err := multiaddr.NewMultiaddr("/ip4/0.0.0.0/tcp/0")
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create listen address: %w", err)
|
||||
}
|
||||
|
||||
host, err := libp2p.New(
|
||||
libp2p.ListenAddrs(listenAddr),
|
||||
libp2p.Security(noise.ID, noise.New),
|
||||
libp2p.Transport(tcp.NewTCPTransport),
|
||||
libp2p.DefaultMuxers,
|
||||
libp2p.EnableRelay(),
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create libp2p host: %w", err)
|
||||
}
|
||||
|
||||
opts := []Option{
|
||||
WithProtocolPrefix("/CHORUS"),
|
||||
}
|
||||
|
||||
if nodes := cfg.GetDHTBootstrapNodes(); len(nodes) > 0 {
|
||||
opts = append(opts, WithBootstrapPeersFromStrings(nodes))
|
||||
}
|
||||
|
||||
libp2pDHT, err := NewLibP2PDHT(ctx, host, opts...)
|
||||
if err != nil {
|
||||
host.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to initialize libp2p DHT: %w", err)
|
||||
}
|
||||
|
||||
if err := libp2pDHT.Bootstrap(); err != nil {
|
||||
libp2pDHT.Close()
|
||||
host.Close()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to bootstrap DHT: %w", err)
|
||||
}
|
||||
|
||||
return &RealDHT{
|
||||
cancel: cancel,
|
||||
host: host,
|
||||
dht: libp2pDHT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PutValue stores a value in the DHT.
|
||||
func (r *RealDHT) PutValue(ctx context.Context, key string, value []byte) error {
|
||||
return r.dht.PutValue(ctx, key, value)
|
||||
}
|
||||
|
||||
// GetValue retrieves a value from the DHT.
|
||||
func (r *RealDHT) GetValue(ctx context.Context, key string) ([]byte, error) {
|
||||
return r.dht.GetValue(ctx, key)
|
||||
}
|
||||
|
||||
// Provide announces that this node can provide the given key.
|
||||
func (r *RealDHT) Provide(ctx context.Context, key string) error {
|
||||
return r.dht.Provide(ctx, key)
|
||||
}
|
||||
|
||||
// FindProviders locates peers that can provide the specified key.
|
||||
func (r *RealDHT) FindProviders(ctx context.Context, key string, limit int) ([]peer.AddrInfo, error) {
|
||||
return r.dht.FindProviders(ctx, key, limit)
|
||||
}
|
||||
|
||||
// GetStats exposes runtime metrics for the real DHT.
|
||||
func (r *RealDHT) GetStats() DHTStats {
|
||||
return r.dht.GetStats()
|
||||
}
|
||||
|
||||
// Close releases resources associated with the DHT.
|
||||
func (r *RealDHT) Close() error {
|
||||
r.cancel()
|
||||
|
||||
var errs []error
|
||||
if err := r.dht.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := r.host.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -2,159 +2,106 @@ package dht
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestReplicationManager tests basic replication manager functionality
|
||||
func TestReplicationManager(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
func newReplicationManagerForTest(t *testing.T) *ReplicationManager {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock DHT for testing
|
||||
mockDHT := NewMockDHTInterface()
|
||||
|
||||
// Create replication manager
|
||||
config := DefaultReplicationConfig()
|
||||
config.ReprovideInterval = 1 * time.Second // Short interval for testing
|
||||
config.CleanupInterval = 1 * time.Second
|
||||
|
||||
rm := NewReplicationManager(ctx, mockDHT.Mock(), config)
|
||||
defer rm.Stop()
|
||||
|
||||
// Test adding content
|
||||
testKey := "test-content-key"
|
||||
testSize := int64(1024)
|
||||
testPriority := 5
|
||||
|
||||
err := rm.AddContent(testKey, testSize, testPriority)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add content: %v", err)
|
||||
cfg := &ReplicationConfig{
|
||||
ReplicationFactor: 3,
|
||||
ReprovideInterval: time.Hour,
|
||||
CleanupInterval: time.Hour,
|
||||
ProviderTTL: 30 * time.Minute,
|
||||
MaxProvidersPerKey: 5,
|
||||
EnableAutoReplication: false,
|
||||
EnableReprovide: false,
|
||||
MaxConcurrentReplications: 1,
|
||||
}
|
||||
|
||||
// Test getting replication status
|
||||
status, err := rm.GetReplicationStatus(testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get replication status: %v", err)
|
||||
rm := NewReplicationManager(context.Background(), nil, cfg)
|
||||
t.Cleanup(func() {
|
||||
if rm.reprovideTimer != nil {
|
||||
rm.reprovideTimer.Stop()
|
||||
}
|
||||
if rm.cleanupTimer != nil {
|
||||
rm.cleanupTimer.Stop()
|
||||
}
|
||||
rm.cancel()
|
||||
})
|
||||
return rm
|
||||
}
|
||||
|
||||
func TestAddContentRegistersKey(t *testing.T) {
|
||||
rm := newReplicationManagerForTest(t)
|
||||
|
||||
if err := rm.AddContent("ucxl://example/path", 512, 1); err != nil {
|
||||
t.Fatalf("expected AddContent to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if status.Key != testKey {
|
||||
t.Errorf("Expected key %s, got %s", testKey, status.Key)
|
||||
rm.keysMutex.RLock()
|
||||
record, ok := rm.contentKeys["ucxl://example/path"]
|
||||
rm.keysMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected content key to be registered")
|
||||
}
|
||||
|
||||
if status.Size != testSize {
|
||||
t.Errorf("Expected size %d, got %d", testSize, status.Size)
|
||||
}
|
||||
|
||||
if status.Priority != testPriority {
|
||||
t.Errorf("Expected priority %d, got %d", testPriority, status.Priority)
|
||||
}
|
||||
|
||||
// Test providing content
|
||||
err = rm.ProvideContent(testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to provide content: %v", err)
|
||||
}
|
||||
|
||||
// Test metrics
|
||||
metrics := rm.GetMetrics()
|
||||
if metrics.TotalKeys != 1 {
|
||||
t.Errorf("Expected 1 total key, got %d", metrics.TotalKeys)
|
||||
}
|
||||
|
||||
// Test finding providers
|
||||
providers, err := rm.FindProviders(ctx, testKey, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to find providers: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Found %d providers for key %s", len(providers), testKey)
|
||||
|
||||
// Test removing content
|
||||
err = rm.RemoveContent(testKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to remove content: %v", err)
|
||||
}
|
||||
|
||||
// Verify content was removed
|
||||
metrics = rm.GetMetrics()
|
||||
if metrics.TotalKeys != 0 {
|
||||
t.Errorf("Expected 0 total keys after removal, got %d", metrics.TotalKeys)
|
||||
if record.Size != 512 {
|
||||
t.Fatalf("expected size 512, got %d", record.Size)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibP2PDHTReplication tests DHT replication functionality
|
||||
func TestLibP2PDHTReplication(t *testing.T) {
|
||||
// This would normally require a real libp2p setup
|
||||
// For now, just test the interface methods exist
|
||||
func TestRemoveContentClearsTracking(t *testing.T) {
|
||||
rm := newReplicationManagerForTest(t)
|
||||
|
||||
// Mock test - in a real implementation, you'd set up actual libp2p hosts
|
||||
t.Log("DHT replication interface methods are implemented")
|
||||
|
||||
// Example of how the replication would be used:
|
||||
// 1. Add content for replication
|
||||
// 2. Content gets automatically provided to the DHT
|
||||
// 3. Other nodes can discover this node as a provider
|
||||
// 4. Periodic reproviding ensures content availability
|
||||
// 5. Replication metrics track system health
|
||||
}
|
||||
|
||||
// TestReplicationConfig tests replication configuration
|
||||
func TestReplicationConfig(t *testing.T) {
|
||||
config := DefaultReplicationConfig()
|
||||
|
||||
// Test default values
|
||||
if config.ReplicationFactor != 3 {
|
||||
t.Errorf("Expected default replication factor 3, got %d", config.ReplicationFactor)
|
||||
if err := rm.AddContent("ucxl://example/path", 512, 1); err != nil {
|
||||
t.Fatalf("AddContent returned error: %v", err)
|
||||
}
|
||||
|
||||
if config.ReprovideInterval != 12*time.Hour {
|
||||
t.Errorf("Expected default reprovide interval 12h, got %v", config.ReprovideInterval)
|
||||
if err := rm.RemoveContent("ucxl://example/path"); err != nil {
|
||||
t.Fatalf("RemoveContent returned error: %v", err)
|
||||
}
|
||||
|
||||
if !config.EnableAutoReplication {
|
||||
t.Error("Expected auto replication to be enabled by default")
|
||||
}
|
||||
rm.keysMutex.RLock()
|
||||
_, exists := rm.contentKeys["ucxl://example/path"]
|
||||
rm.keysMutex.RUnlock()
|
||||
|
||||
if !config.EnableReprovide {
|
||||
t.Error("Expected reprovide to be enabled by default")
|
||||
if exists {
|
||||
t.Fatal("expected content key to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderInfo tests provider information tracking
|
||||
func TestProviderInfo(t *testing.T) {
|
||||
// Test distance calculation
|
||||
key := []byte("test-key")
|
||||
peerID := "test-peer-id"
|
||||
func TestGetReplicationStatusReturnsCopy(t *testing.T) {
|
||||
rm := newReplicationManagerForTest(t)
|
||||
|
||||
distance := calculateDistance(key, []byte(peerID))
|
||||
|
||||
// Distance should be non-zero for different inputs
|
||||
if distance == 0 {
|
||||
t.Error("Expected non-zero distance for different inputs")
|
||||
if err := rm.AddContent("ucxl://example/path", 512, 1); err != nil {
|
||||
t.Fatalf("AddContent returned error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Distance between key and peer: %d", distance)
|
||||
status, err := rm.GetReplicationStatus("ucxl://example/path")
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplicationStatus returned error: %v", err)
|
||||
}
|
||||
|
||||
if status.Key != "ucxl://example/path" {
|
||||
t.Fatalf("expected status key to match, got %s", status.Key)
|
||||
}
|
||||
|
||||
// Mutating status should not affect internal state
|
||||
status.HealthyProviders = 99
|
||||
internal, _ := rm.GetReplicationStatus("ucxl://example/path")
|
||||
if internal.HealthyProviders == 99 {
|
||||
t.Fatal("expected GetReplicationStatus to return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplicationMetrics tests metrics collection
|
||||
func TestReplicationMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockDHT := NewMockDHTInterface()
|
||||
rm := NewReplicationManager(ctx, mockDHT.Mock(), DefaultReplicationConfig())
|
||||
defer rm.Stop()
|
||||
|
||||
// Add some content
|
||||
for i := 0; i < 3; i++ {
|
||||
key := fmt.Sprintf("test-key-%d", i)
|
||||
rm.AddContent(key, int64(1000+i*100), i+1)
|
||||
}
|
||||
func TestGetMetricsReturnsSnapshot(t *testing.T) {
|
||||
rm := newReplicationManagerForTest(t)
|
||||
|
||||
metrics := rm.GetMetrics()
|
||||
|
||||
if metrics.TotalKeys != 3 {
|
||||
t.Errorf("Expected 3 total keys, got %d", metrics.TotalKeys)
|
||||
if metrics == rm.metrics {
|
||||
t.Fatal("expected GetMetrics to return a copy of metrics")
|
||||
}
|
||||
|
||||
t.Logf("Replication metrics: %+v", metrics)
|
||||
}
|
||||
@@ -29,6 +29,11 @@ const (
|
||||
// ElectionState represents the current election state
|
||||
type ElectionState string
|
||||
|
||||
const (
|
||||
electionTopic = "CHORUS/election/v1"
|
||||
adminHeartbeatTopic = "CHORUS/admin/heartbeat/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
StateIdle ElectionState = "idle"
|
||||
StateDiscovering ElectionState = "discovering"
|
||||
@@ -149,20 +154,31 @@ func NewElectionManager(
|
||||
func (em *ElectionManager) Start() error {
|
||||
log.Printf("🗳️ Starting election manager for node %s", em.nodeID)
|
||||
|
||||
// TODO: Subscribe to election-related messages - pubsub interface needs update
|
||||
// if err := em.pubsub.Subscribe("CHORUS/election/v1", em.handleElectionMessage); err != nil {
|
||||
// return fmt.Errorf("failed to subscribe to election messages: %w", err)
|
||||
// }
|
||||
//
|
||||
// if err := em.pubsub.Subscribe("CHORUS/admin/heartbeat/v1", em.handleAdminHeartbeat); err != nil {
|
||||
// return fmt.Errorf("failed to subscribe to admin heartbeat: %w", err)
|
||||
// }
|
||||
if err := em.pubsub.SubscribeRawTopic(electionTopic, func(data []byte, _ peer.ID) {
|
||||
em.handleElectionMessage(data)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to election messages: %w", err)
|
||||
}
|
||||
|
||||
if err := em.pubsub.SubscribeRawTopic(adminHeartbeatTopic, func(data []byte, _ peer.ID) {
|
||||
em.handleAdminHeartbeat(data)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to admin heartbeat: %w", err)
|
||||
}
|
||||
|
||||
// Start discovery process
|
||||
go em.startDiscoveryLoop()
|
||||
log.Printf("🔍 About to start discovery loop goroutine...")
|
||||
go func() {
|
||||
log.Printf("🔍 Discovery loop goroutine started successfully")
|
||||
em.startDiscoveryLoop()
|
||||
}()
|
||||
|
||||
// Start election coordinator
|
||||
go em.electionCoordinator()
|
||||
log.Printf("🗳️ About to start election coordinator goroutine...")
|
||||
go func() {
|
||||
log.Printf("🗳️ Election coordinator goroutine started successfully")
|
||||
em.electionCoordinator()
|
||||
}()
|
||||
|
||||
// Start heartbeat if this node is already admin at startup
|
||||
if em.IsCurrentAdmin() {
|
||||
@@ -206,6 +222,16 @@ func (em *ElectionManager) Stop() {
|
||||
|
||||
// TriggerElection manually triggers an election
|
||||
func (em *ElectionManager) TriggerElection(trigger ElectionTrigger) {
|
||||
// Check if election already in progress
|
||||
em.mu.RLock()
|
||||
currentState := em.state
|
||||
em.mu.RUnlock()
|
||||
|
||||
if currentState != StateIdle {
|
||||
log.Printf("🗳️ Election already in progress (state: %s), ignoring trigger: %s", currentState, trigger)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case em.electionTrigger <- trigger:
|
||||
log.Printf("🗳️ Election triggered: %s", trigger)
|
||||
@@ -254,13 +280,27 @@ func (em *ElectionManager) GetHeartbeatStatus() map[string]interface{} {
|
||||
|
||||
// startDiscoveryLoop starts the admin discovery loop
|
||||
func (em *ElectionManager) startDiscoveryLoop() {
|
||||
log.Printf("🔍 Starting admin discovery loop")
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("🔍 PANIC in discovery loop: %v", r)
|
||||
}
|
||||
log.Printf("🔍 Discovery loop goroutine exiting")
|
||||
}()
|
||||
|
||||
log.Printf("🔍 ENHANCED-DEBUG: Starting admin discovery loop with timeout: %v", em.config.Security.ElectionConfig.DiscoveryTimeout)
|
||||
log.Printf("🔍 ENHANCED-DEBUG: Context status: err=%v", em.ctx.Err())
|
||||
log.Printf("🔍 ENHANCED-DEBUG: Node ID: %s, Can be admin: %v", em.nodeID, em.canBeAdmin())
|
||||
|
||||
for {
|
||||
log.Printf("🔍 Discovery loop iteration starting, waiting for timeout...")
|
||||
log.Printf("🔍 Context status before select: err=%v", em.ctx.Err())
|
||||
|
||||
select {
|
||||
case <-em.ctx.Done():
|
||||
log.Printf("🔍 Discovery loop cancelled via context: %v", em.ctx.Err())
|
||||
return
|
||||
case <-time.After(em.config.Security.ElectionConfig.DiscoveryTimeout):
|
||||
log.Printf("🔍 Discovery timeout triggered! Calling performAdminDiscovery()...")
|
||||
em.performAdminDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -273,8 +313,12 @@ func (em *ElectionManager) performAdminDiscovery() {
|
||||
lastHeartbeat := em.lastHeartbeat
|
||||
em.mu.Unlock()
|
||||
|
||||
log.Printf("🔍 Discovery check: state=%s, lastHeartbeat=%v, canAdmin=%v",
|
||||
currentState, lastHeartbeat, em.canBeAdmin())
|
||||
|
||||
// Only discover if we're idle or the heartbeat is stale
|
||||
if currentState != StateIdle {
|
||||
log.Printf("🔍 Skipping discovery - not in idle state (current: %s)", currentState)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,13 +330,66 @@ func (em *ElectionManager) performAdminDiscovery() {
|
||||
}
|
||||
|
||||
// If we haven't heard from an admin recently, try to discover one
|
||||
if lastHeartbeat.IsZero() || time.Since(lastHeartbeat) > em.config.Security.ElectionConfig.DiscoveryTimeout/2 {
|
||||
timeSinceHeartbeat := time.Since(lastHeartbeat)
|
||||
discoveryThreshold := em.config.Security.ElectionConfig.DiscoveryTimeout / 2
|
||||
|
||||
log.Printf("🔍 Heartbeat check: isZero=%v, timeSince=%v, threshold=%v",
|
||||
lastHeartbeat.IsZero(), timeSinceHeartbeat, discoveryThreshold)
|
||||
|
||||
if lastHeartbeat.IsZero() || timeSinceHeartbeat > discoveryThreshold {
|
||||
log.Printf("🔍 Sending discovery request...")
|
||||
em.sendDiscoveryRequest()
|
||||
|
||||
// 🚨 CRITICAL FIX: If we have no admin and can become admin, trigger election after discovery timeout
|
||||
em.mu.Lock()
|
||||
currentAdmin := em.currentAdmin
|
||||
em.mu.Unlock()
|
||||
|
||||
if currentAdmin == "" && em.canBeAdmin() {
|
||||
log.Printf("🗳️ No admin discovered and we can be admin - scheduling election check")
|
||||
go func() {
|
||||
// Add randomization to prevent simultaneous elections from all nodes
|
||||
baseDelay := em.config.Security.ElectionConfig.DiscoveryTimeout * 2
|
||||
randomDelay := time.Duration(rand.Intn(int(em.config.Security.ElectionConfig.DiscoveryTimeout)))
|
||||
totalDelay := baseDelay + randomDelay
|
||||
|
||||
log.Printf("🗳️ Waiting %v before checking if election needed", totalDelay)
|
||||
time.Sleep(totalDelay)
|
||||
|
||||
// Check again if still no admin and no one else started election
|
||||
em.mu.RLock()
|
||||
stillNoAdmin := em.currentAdmin == ""
|
||||
stillIdle := em.state == StateIdle
|
||||
em.mu.RUnlock()
|
||||
|
||||
if stillNoAdmin && stillIdle && em.canBeAdmin() {
|
||||
log.Printf("🗳️ Election grace period expired with no admin - triggering election")
|
||||
em.TriggerElection(TriggerDiscoveryFailure)
|
||||
} else {
|
||||
log.Printf("🗳️ Election check: admin=%s, state=%s - skipping election", em.currentAdmin, em.state)
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
log.Printf("🔍 Discovery threshold not met - waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// sendDiscoveryRequest broadcasts admin discovery request
|
||||
func (em *ElectionManager) sendDiscoveryRequest() {
|
||||
em.mu.RLock()
|
||||
currentAdmin := em.currentAdmin
|
||||
em.mu.RUnlock()
|
||||
|
||||
// WHOAMI debug message
|
||||
if currentAdmin == "" {
|
||||
log.Printf("🤖 WHOAMI: I'm %s and I have no leader", em.nodeID)
|
||||
} else {
|
||||
log.Printf("🤖 WHOAMI: I'm %s and my leader is %s", em.nodeID, currentAdmin)
|
||||
}
|
||||
|
||||
log.Printf("📡 Sending admin discovery request from node %s", em.nodeID)
|
||||
|
||||
discoveryMsg := ElectionMessage{
|
||||
Type: "admin_discovery_request",
|
||||
NodeID: em.nodeID,
|
||||
@@ -301,6 +398,8 @@ func (em *ElectionManager) sendDiscoveryRequest() {
|
||||
|
||||
if err := em.publishElectionMessage(discoveryMsg); err != nil {
|
||||
log.Printf("❌ Failed to send admin discovery request: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Admin discovery request sent successfully")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,10 +556,10 @@ func (em *ElectionManager) calculateCandidateScore(candidate *AdminCandidate) fl
|
||||
capabilityScore = min(1.0, capabilityScore)
|
||||
|
||||
// Resource score - lower usage is better
|
||||
resourceScore := (1.0 - candidate.Resources.CPUUsage) * 0.3 +
|
||||
(1.0 - candidate.Resources.MemoryUsage) * 0.3 +
|
||||
(1.0 - candidate.Resources.DiskUsage) * 0.2 +
|
||||
candidate.Resources.NetworkQuality * 0.2
|
||||
resourceScore := (1.0-candidate.Resources.CPUUsage)*0.3 +
|
||||
(1.0-candidate.Resources.MemoryUsage)*0.3 +
|
||||
(1.0-candidate.Resources.DiskUsage)*0.2 +
|
||||
candidate.Resources.NetworkQuality*0.2
|
||||
|
||||
experienceScore := min(1.0, candidate.Experience.Hours()/168.0) // Up to 1 week gets full score
|
||||
|
||||
@@ -644,6 +743,9 @@ func (em *ElectionManager) handleAdminDiscoveryRequest(msg ElectionMessage) {
|
||||
state := em.state
|
||||
em.mu.RUnlock()
|
||||
|
||||
log.Printf("📩 Received admin discovery request from %s (my leader: %s, state: %s)",
|
||||
msg.NodeID, currentAdmin, state)
|
||||
|
||||
// Only respond if we know who the current admin is and we're idle
|
||||
if currentAdmin != "" && state == StateIdle {
|
||||
responseMsg := ElectionMessage{
|
||||
@@ -655,24 +757,44 @@ func (em *ElectionManager) handleAdminDiscoveryRequest(msg ElectionMessage) {
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("📤 Responding to discovery with admin: %s", currentAdmin)
|
||||
if err := em.publishElectionMessage(responseMsg); err != nil {
|
||||
log.Printf("❌ Failed to send admin discovery response: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Admin discovery response sent successfully")
|
||||
}
|
||||
} else {
|
||||
log.Printf("🔇 Not responding to discovery (admin=%s, state=%s)", currentAdmin, state)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminDiscoveryResponse processes admin discovery responses
|
||||
func (em *ElectionManager) handleAdminDiscoveryResponse(msg ElectionMessage) {
|
||||
log.Printf("📥 Received admin discovery response from %s", msg.NodeID)
|
||||
|
||||
if data, ok := msg.Data.(map[string]interface{}); ok {
|
||||
if admin, ok := data["current_admin"].(string); ok && admin != "" {
|
||||
em.mu.Lock()
|
||||
oldAdmin := em.currentAdmin
|
||||
if em.currentAdmin == "" {
|
||||
log.Printf("📡 Discovered admin: %s", admin)
|
||||
log.Printf("📡 Discovered admin: %s (reported by %s)", admin, msg.NodeID)
|
||||
em.currentAdmin = admin
|
||||
em.lastHeartbeat = time.Now() // Set initial heartbeat
|
||||
} else if em.currentAdmin != admin {
|
||||
log.Printf("⚠️ Admin conflict: I know %s, but %s reports %s", em.currentAdmin, msg.NodeID, admin)
|
||||
} else {
|
||||
log.Printf("📡 Admin confirmed: %s (reported by %s)", admin, msg.NodeID)
|
||||
}
|
||||
em.mu.Unlock()
|
||||
|
||||
// Trigger callback if admin changed
|
||||
if oldAdmin != admin && em.onAdminChanged != nil {
|
||||
em.onAdminChanged(oldAdmin, admin)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("❌ Invalid admin discovery response from %s", msg.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleElectionStarted processes election start announcements
|
||||
@@ -839,10 +961,7 @@ func (em *ElectionManager) publishElectionMessage(msg ElectionMessage) error {
|
||||
return fmt.Errorf("failed to marshal election message: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Fix pubsub interface
|
||||
// return em.pubsub.Publish("CHORUS/election/v1", data)
|
||||
_ = data // Avoid unused variable
|
||||
return nil
|
||||
return em.pubsub.PublishRaw(electionTopic, data)
|
||||
}
|
||||
|
||||
// SendAdminHeartbeat sends admin heartbeat (only if this node is admin)
|
||||
@@ -864,10 +983,7 @@ func (em *ElectionManager) SendAdminHeartbeat() error {
|
||||
return fmt.Errorf("failed to marshal heartbeat: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Fix pubsub interface
|
||||
// return em.pubsub.Publish("CHORUS/admin/heartbeat/v1", data)
|
||||
_ = data // Avoid unused variable
|
||||
return nil
|
||||
return em.pubsub.PublishRaw(adminHeartbeatTopic, data)
|
||||
}
|
||||
|
||||
// min returns the minimum of two float64 values
|
||||
|
||||
@@ -2,451 +2,185 @@ package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chorus/pkg/config"
|
||||
pubsubpkg "chorus/pubsub"
|
||||
libp2p "github.com/libp2p/go-libp2p"
|
||||
)
|
||||
|
||||
func TestElectionManager_NewElectionManager(t *testing.T) {
|
||||
// newTestElectionManager wires a real libp2p host and PubSub instance so the
|
||||
// election manager exercises the same code paths used in production.
|
||||
func newTestElectionManager(t *testing.T) *ElectionManager {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
host, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatalf("failed to create libp2p host: %v", err)
|
||||
}
|
||||
|
||||
ps, err := pubsubpkg.NewPubSub(ctx, host, "", "")
|
||||
if err != nil {
|
||||
host.Close()
|
||||
cancel()
|
||||
t.Fatalf("failed to create pubsub: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
ID: host.ID().String(),
|
||||
Role: "context_admin",
|
||||
Capabilities: []string{"admin_election", "context_curation"},
|
||||
Models: []string{"meta/llama-3.1-8b-instruct"},
|
||||
Specialization: "coordination",
|
||||
},
|
||||
Security: config.SecurityConfig{},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
if em == nil {
|
||||
t.Fatal("Expected NewElectionManager to return non-nil manager")
|
||||
}
|
||||
em := NewElectionManager(ctx, cfg, host, ps, host.ID().String())
|
||||
|
||||
if em.nodeID != "test-node" {
|
||||
t.Errorf("Expected nodeID to be 'test-node', got %s", em.nodeID)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
em.Stop()
|
||||
ps.Close()
|
||||
host.Close()
|
||||
cancel()
|
||||
})
|
||||
|
||||
return em
|
||||
}
|
||||
|
||||
func TestNewElectionManagerInitialState(t *testing.T) {
|
||||
em := newTestElectionManager(t)
|
||||
|
||||
if em.state != StateIdle {
|
||||
t.Errorf("Expected initial state to be StateIdle, got %v", em.state)
|
||||
t.Fatalf("expected initial state %q, got %q", StateIdle, em.state)
|
||||
}
|
||||
|
||||
if em.currentTerm != 0 {
|
||||
t.Fatalf("expected initial term 0, got %d", em.currentTerm)
|
||||
}
|
||||
|
||||
if em.nodeID == "" {
|
||||
t.Fatal("expected nodeID to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_StartElection(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
func TestElectionManagerCanBeAdmin(t *testing.T) {
|
||||
em := newTestElectionManager(t)
|
||||
|
||||
if !em.canBeAdmin() {
|
||||
t.Fatal("expected node to qualify for admin election")
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Start election
|
||||
err := em.StartElection()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start election: %v", err)
|
||||
}
|
||||
|
||||
// Verify state changed
|
||||
if em.state != StateCandidate {
|
||||
t.Errorf("Expected state to be StateCandidate after starting election, got %v", em.state)
|
||||
}
|
||||
|
||||
// Verify we added ourselves as a candidate
|
||||
em.mu.RLock()
|
||||
candidate, exists := em.candidates[em.nodeID]
|
||||
em.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected to find ourselves as a candidate after starting election")
|
||||
}
|
||||
|
||||
if candidate.NodeID != em.nodeID {
|
||||
t.Errorf("Expected candidate NodeID to be %s, got %s", em.nodeID, candidate.NodeID)
|
||||
em.config.Agent.Capabilities = []string{"runtime_support"}
|
||||
if em.canBeAdmin() {
|
||||
t.Fatal("expected node without admin capabilities to be ineligible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_Vote(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Add a candidate first
|
||||
candidate := &AdminCandidate{
|
||||
NodeID: "candidate-1",
|
||||
Term: 1,
|
||||
Score: 0.8,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
func TestFindElectionWinnerPrefersVotesThenScore(t *testing.T) {
|
||||
em := newTestElectionManager(t)
|
||||
|
||||
em.mu.Lock()
|
||||
em.candidates["candidate-1"] = candidate
|
||||
em.mu.Unlock()
|
||||
|
||||
// Vote for the candidate
|
||||
err := em.Vote("candidate-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to vote: %v", err)
|
||||
}
|
||||
|
||||
// Verify vote was recorded
|
||||
em.mu.RLock()
|
||||
vote, exists := em.votes[em.nodeID]
|
||||
em.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected to find our vote after voting")
|
||||
}
|
||||
|
||||
if vote != "candidate-1" {
|
||||
t.Errorf("Expected vote to be for 'candidate-1', got %s", vote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_VoteInvalidCandidate(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Try to vote for non-existent candidate
|
||||
err := em.Vote("non-existent")
|
||||
if err == nil {
|
||||
t.Error("Expected error when voting for non-existent candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_AddCandidate(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
candidate := &AdminCandidate{
|
||||
NodeID: "new-candidate",
|
||||
Term: 1,
|
||||
Score: 0.7,
|
||||
Capabilities: []string{"admin", "leader"},
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
err := em.AddCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add candidate: %v", err)
|
||||
}
|
||||
|
||||
// Verify candidate was added
|
||||
em.mu.RLock()
|
||||
stored, exists := em.candidates["new-candidate"]
|
||||
em.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected to find added candidate")
|
||||
}
|
||||
|
||||
if stored.NodeID != "new-candidate" {
|
||||
t.Errorf("Expected stored candidate NodeID to be 'new-candidate', got %s", stored.NodeID)
|
||||
}
|
||||
|
||||
if stored.Score != 0.7 {
|
||||
t.Errorf("Expected stored candidate score to be 0.7, got %f", stored.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_FindElectionWinner(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Add candidates with different scores
|
||||
candidates := []*AdminCandidate{
|
||||
{
|
||||
em.candidates = map[string]*AdminCandidate{
|
||||
"candidate-1": {
|
||||
NodeID: "candidate-1",
|
||||
Term: 1,
|
||||
Score: 0.6,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
PeerID: em.host.ID(),
|
||||
Score: 0.65,
|
||||
},
|
||||
{
|
||||
"candidate-2": {
|
||||
NodeID: "candidate-2",
|
||||
Term: 1,
|
||||
Score: 0.8,
|
||||
Capabilities: []string{"admin", "leader"},
|
||||
LastSeen: time.Now(),
|
||||
},
|
||||
{
|
||||
NodeID: "candidate-3",
|
||||
Term: 1,
|
||||
Score: 0.7,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
PeerID: em.host.ID(),
|
||||
Score: 0.80,
|
||||
},
|
||||
}
|
||||
|
||||
em.mu.Lock()
|
||||
for _, candidate := range candidates {
|
||||
em.candidates[candidate.NodeID] = candidate
|
||||
}
|
||||
|
||||
// Add some votes
|
||||
em.votes["voter-1"] = "candidate-2"
|
||||
em.votes["voter-2"] = "candidate-2"
|
||||
em.votes["voter-3"] = "candidate-1"
|
||||
em.mu.Unlock()
|
||||
|
||||
// Find winner
|
||||
winner := em.findElectionWinner()
|
||||
|
||||
if winner == nil {
|
||||
t.Fatal("Expected findElectionWinner to return a winner")
|
||||
}
|
||||
|
||||
// candidate-2 should win with most votes (2 votes)
|
||||
if winner.NodeID != "candidate-2" {
|
||||
t.Errorf("Expected winner to be 'candidate-2', got %s", winner.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_FindElectionWinnerNoVotes(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Add candidates but no votes - should fall back to highest score
|
||||
candidates := []*AdminCandidate{
|
||||
{
|
||||
NodeID: "candidate-1",
|
||||
Term: 1,
|
||||
Score: 0.6,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
},
|
||||
{
|
||||
NodeID: "candidate-2",
|
||||
Term: 1,
|
||||
Score: 0.9, // Highest score
|
||||
Capabilities: []string{"admin", "leader"},
|
||||
LastSeen: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
em.mu.Lock()
|
||||
for _, candidate := range candidates {
|
||||
em.candidates[candidate.NodeID] = candidate
|
||||
em.votes = map[string]string{
|
||||
"voter-a": "candidate-1",
|
||||
"voter-b": "candidate-2",
|
||||
"voter-c": "candidate-2",
|
||||
}
|
||||
em.mu.Unlock()
|
||||
|
||||
// Find winner without any votes
|
||||
winner := em.findElectionWinner()
|
||||
|
||||
if winner == nil {
|
||||
t.Fatal("Expected findElectionWinner to return a winner")
|
||||
t.Fatal("expected a winner to be selected")
|
||||
}
|
||||
|
||||
// candidate-2 should win with highest score
|
||||
if winner.NodeID != "candidate-2" {
|
||||
t.Errorf("Expected winner to be 'candidate-2' (highest score), got %s", winner.NodeID)
|
||||
t.Fatalf("expected candidate-2 to win, got %s", winner.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_HandleElectionVote(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
func TestHandleElectionMessageAddsCandidate(t *testing.T) {
|
||||
em := newTestElectionManager(t)
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
em.mu.Lock()
|
||||
em.currentTerm = 3
|
||||
em.state = StateElecting
|
||||
em.mu.Unlock()
|
||||
|
||||
// Add a candidate first
|
||||
candidate := &AdminCandidate{
|
||||
NodeID: "candidate-1",
|
||||
Term: 1,
|
||||
Score: 0.8,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
NodeID: "peer-2",
|
||||
PeerID: em.host.ID(),
|
||||
Capabilities: []string{"admin_election"},
|
||||
Uptime: time.Second,
|
||||
Score: 0.75,
|
||||
}
|
||||
|
||||
em.mu.Lock()
|
||||
em.candidates["candidate-1"] = candidate
|
||||
em.mu.Unlock()
|
||||
payload, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal candidate: %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &data); err != nil {
|
||||
t.Fatalf("failed to unmarshal candidate payload: %v", err)
|
||||
}
|
||||
|
||||
// Create vote message
|
||||
msg := ElectionMessage{
|
||||
Type: MessageTypeVote,
|
||||
NodeID: "voter-1",
|
||||
Data: map[string]interface{}{
|
||||
"candidate": "candidate-1",
|
||||
},
|
||||
Type: "candidacy_announcement",
|
||||
NodeID: "peer-2",
|
||||
Timestamp: time.Now(),
|
||||
Term: 3,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
// Handle the vote
|
||||
em.handleElectionVote(msg)
|
||||
serialized, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal election message: %v", err)
|
||||
}
|
||||
|
||||
em.handleElectionMessage(serialized)
|
||||
|
||||
// Verify vote was recorded
|
||||
em.mu.RLock()
|
||||
vote, exists := em.votes["voter-1"]
|
||||
_, exists := em.candidates["peer-2"]
|
||||
em.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected vote to be recorded after handling vote message")
|
||||
}
|
||||
|
||||
if vote != "candidate-1" {
|
||||
t.Errorf("Expected recorded vote to be for 'candidate-1', got %s", vote)
|
||||
t.Fatal("expected candidacy announcement to register candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_HandleElectionVoteInvalidData(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
func TestSendAdminHeartbeatRequiresLeadership(t *testing.T) {
|
||||
em := newTestElectionManager(t)
|
||||
|
||||
if err := em.SendAdminHeartbeat(); err == nil {
|
||||
t.Fatal("expected error when non-admin sends heartbeat")
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Create vote message with invalid data
|
||||
msg := ElectionMessage{
|
||||
Type: MessageTypeVote,
|
||||
NodeID: "voter-1",
|
||||
Data: "invalid-data", // Should be map[string]interface{}
|
||||
}
|
||||
|
||||
// Handle the vote - should not crash
|
||||
em.handleElectionVote(msg)
|
||||
|
||||
// Verify no vote was recorded
|
||||
em.mu.RLock()
|
||||
_, exists := em.votes["voter-1"]
|
||||
em.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
t.Error("Expected no vote to be recorded with invalid data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_CompleteElection(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Set up election state
|
||||
em.mu.Lock()
|
||||
em.state = StateCandidate
|
||||
em.currentTerm = 1
|
||||
em.mu.Unlock()
|
||||
|
||||
// Add a candidate
|
||||
candidate := &AdminCandidate{
|
||||
NodeID: "winner",
|
||||
Term: 1,
|
||||
Score: 0.9,
|
||||
Capabilities: []string{"admin", "leader"},
|
||||
LastSeen: time.Now(),
|
||||
if err := em.Start(); err != nil {
|
||||
t.Fatalf("failed to start election manager: %v", err)
|
||||
}
|
||||
|
||||
em.mu.Lock()
|
||||
em.candidates["winner"] = candidate
|
||||
em.currentAdmin = em.nodeID
|
||||
em.mu.Unlock()
|
||||
|
||||
// Complete election
|
||||
em.CompleteElection()
|
||||
|
||||
// Verify state reset
|
||||
em.mu.RLock()
|
||||
state := em.state
|
||||
em.mu.RUnlock()
|
||||
|
||||
if state != StateIdle {
|
||||
t.Errorf("Expected state to be StateIdle after completing election, got %v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElectionManager_Concurrency(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agent: config.AgentConfig{
|
||||
ID: "test-node",
|
||||
},
|
||||
}
|
||||
|
||||
em := NewElectionManager(cfg)
|
||||
|
||||
// Test concurrent access to vote and candidate operations
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Add a candidate
|
||||
candidate := &AdminCandidate{
|
||||
NodeID: "candidate-1",
|
||||
Term: 1,
|
||||
Score: 0.8,
|
||||
Capabilities: []string{"admin"},
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
err := em.AddCandidate(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add candidate: %v", err)
|
||||
}
|
||||
|
||||
// Run concurrent operations
|
||||
done := make(chan bool, 2)
|
||||
|
||||
// Concurrent voting
|
||||
go func() {
|
||||
defer func() { done <- true }()
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
em.Vote("candidate-1") // Ignore errors in concurrent test
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Concurrent state checking
|
||||
go func() {
|
||||
defer func() { done <- true }()
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
em.findElectionWinner() // Just check for races
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for completion
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("Concurrent test timed out")
|
||||
}
|
||||
if err := em.SendAdminHeartbeat(); err != nil {
|
||||
t.Fatalf("expected heartbeat to succeed for current admin, got error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -179,9 +179,11 @@ func (ehc *EnhancedHealthChecks) registerHealthChecks() {
|
||||
ehc.manager.RegisterCheck(ehc.createEnhancedPubSubCheck())
|
||||
}
|
||||
|
||||
if ehc.config.EnableDHTProbes {
|
||||
ehc.manager.RegisterCheck(ehc.createEnhancedDHTCheck())
|
||||
}
|
||||
// Temporarily disable DHT health check to prevent shutdown issues
|
||||
// TODO: Fix DHT configuration and re-enable this check
|
||||
// if ehc.config.EnableDHTProbes {
|
||||
// ehc.manager.RegisterCheck(ehc.createEnhancedDHTCheck())
|
||||
// }
|
||||
|
||||
if ehc.config.EnableElectionProbes {
|
||||
ehc.manager.RegisterCheck(ehc.createElectionHealthCheck())
|
||||
@@ -290,7 +292,7 @@ func (ehc *EnhancedHealthChecks) createElectionHealthCheck() *HealthCheck {
|
||||
return &HealthCheck{
|
||||
Name: "election-health",
|
||||
Description: "Election system health and leadership stability check",
|
||||
Enabled: true,
|
||||
Enabled: false, // Temporarily disabled to prevent shutdown loops
|
||||
Critical: false,
|
||||
Interval: ehc.config.ElectionProbeInterval,
|
||||
Timeout: ehc.config.ElectionProbeTimeout,
|
||||
|
||||
@@ -2,15 +2,14 @@ package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// CHORUSMetrics provides comprehensive Prometheus metrics for the CHORUS system
|
||||
@@ -78,6 +77,9 @@ type CHORUSMetrics struct {
|
||||
slurpActiveJobs prometheus.Gauge
|
||||
slurpLeadershipEvents prometheus.Counter
|
||||
|
||||
// SHHH sentinel metrics
|
||||
shhhFindings *prometheus.CounterVec
|
||||
|
||||
// UCXI metrics (protocol resolution)
|
||||
ucxiRequests *prometheus.CounterVec
|
||||
ucxiResolutionLatency prometheus.Histogram
|
||||
@@ -409,6 +411,15 @@ func (m *CHORUSMetrics) initializeMetrics(config *MetricsConfig) {
|
||||
},
|
||||
)
|
||||
|
||||
// SHHH metrics
|
||||
m.shhhFindings = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "chorus_shhh_findings_total",
|
||||
Help: "Total number of SHHH redaction findings",
|
||||
},
|
||||
[]string{"rule", "severity"},
|
||||
)
|
||||
|
||||
// UCXI metrics
|
||||
m.ucxiRequests = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
@@ -656,6 +667,15 @@ func (m *CHORUSMetrics) SetSLURPQueueLength(length int) {
|
||||
m.slurpQueueLength.Set(float64(length))
|
||||
}
|
||||
|
||||
// SHHH Metrics Methods
|
||||
|
||||
func (m *CHORUSMetrics) IncrementSHHHFindings(rule, severity string, count int) {
|
||||
if m == nil || m.shhhFindings == nil || count <= 0 {
|
||||
return
|
||||
}
|
||||
m.shhhFindings.WithLabelValues(rule, severity).Add(float64(count))
|
||||
}
|
||||
|
||||
// UCXI Metrics Methods
|
||||
|
||||
func (m *CHORUSMetrics) IncrementUCXIRequests(method, status string) {
|
||||
|
||||
114
pkg/prompt/loader.go
Normal file
114
pkg/prompt/loader.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
loadedRoles map[string]RoleDefinition
|
||||
defaultInstr string
|
||||
)
|
||||
|
||||
// Initialize loads roles and default instructions from the configured directory.
|
||||
// dir: base directory (e.g., /etc/chorus/prompts)
|
||||
// defaultPath: optional explicit path to defaults file; if empty, will look for defaults.md or defaults.txt in dir.
|
||||
func Initialize(dir string, defaultPath string) error {
|
||||
loadedRoles = make(map[string]RoleDefinition)
|
||||
|
||||
// Load roles from all YAML files under dir
|
||||
if dir != "" {
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d == nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
|
||||
_ = loadRolesFile(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Load default instructions
|
||||
if defaultPath == "" && dir != "" {
|
||||
// Try defaults.md then defaults.txt in the directory
|
||||
tryPaths := []string{
|
||||
filepath.Join(dir, "defaults.md"),
|
||||
filepath.Join(dir, "defaults.txt"),
|
||||
}
|
||||
for _, p := range tryPaths {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
defaultInstr = string(b)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if defaultPath != "" {
|
||||
if b, err := os.ReadFile(defaultPath); err == nil {
|
||||
defaultInstr = string(b)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadRolesFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rf RolesFile
|
||||
if err := yaml.Unmarshal(data, &rf); err != nil {
|
||||
return err
|
||||
}
|
||||
for id, def := range rf.Roles {
|
||||
def.ID = id
|
||||
loadedRoles[id] = def
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRole returns the role definition by ID if loaded.
|
||||
func GetRole(id string) (RoleDefinition, bool) {
|
||||
r, ok := loadedRoles[id]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// ListRoles returns IDs of loaded roles.
|
||||
func ListRoles() []string {
|
||||
ids := make([]string, 0, len(loadedRoles))
|
||||
for id := range loadedRoles {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// GetDefaultInstructions returns the loaded default instructions (may be empty if not present on disk).
|
||||
func GetDefaultInstructions() string {
|
||||
return defaultInstr
|
||||
}
|
||||
|
||||
// ComposeSystemPrompt concatenates the role system prompt (S) with default instructions (D).
|
||||
func ComposeSystemPrompt(roleID string) (string, error) {
|
||||
r, ok := GetRole(roleID)
|
||||
if !ok {
|
||||
return "", errors.New("role not found: " + roleID)
|
||||
}
|
||||
s := strings.TrimSpace(r.SystemPrompt)
|
||||
d := strings.TrimSpace(defaultInstr)
|
||||
switch {
|
||||
case s != "" && d != "":
|
||||
return s + "\n\n" + d, nil
|
||||
case s != "":
|
||||
return s, nil
|
||||
case d != "":
|
||||
return d, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
22
pkg/prompt/types.go
Normal file
22
pkg/prompt/types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package prompt
|
||||
|
||||
// RoleDefinition represents a single agent role loaded from YAML.
|
||||
type RoleDefinition struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tags []string `yaml:"tags"`
|
||||
SystemPrompt string `yaml:"system_prompt"`
|
||||
Defaults struct {
|
||||
Models []string `yaml:"models"`
|
||||
Capabilities []string `yaml:"capabilities"`
|
||||
Expertise []string `yaml:"expertise"`
|
||||
MaxTasks int `yaml:"max_tasks"`
|
||||
} `yaml:"defaults"`
|
||||
}
|
||||
|
||||
// RolesFile is the top-level structure for a roles YAML file.
|
||||
type RolesFile struct {
|
||||
Roles map[string]RoleDefinition `yaml:"roles"`
|
||||
}
|
||||
|
||||
11
pkg/shhh/doc.go
Normal file
11
pkg/shhh/doc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package shhh provides the CHORUS secrets sentinel responsible for detecting
|
||||
// and redacting sensitive values before they leave the runtime. The sentinel
|
||||
// focuses on predictable failure modes (log emission, telemetry fan-out,
|
||||
// request forwarding) and offers a composable API for registering additional
|
||||
// redaction rules, emitting audit events, and tracking operational metrics.
|
||||
//
|
||||
// The initial implementation focuses on high-signal secrets (API keys,
|
||||
// bearer/OAuth tokens, private keys) so the runtime can start integrating
|
||||
// SHHH into COOEE and WHOOSH logging immediately while the broader roadmap
|
||||
// items (automated redaction replay, policy driven rules) continue landing.
|
||||
package shhh
|
||||
130
pkg/shhh/rule.go
Normal file
130
pkg/shhh/rule.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package shhh
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type compiledRule struct {
|
||||
name string
|
||||
regex *regexp.Regexp
|
||||
replacement string
|
||||
severity Severity
|
||||
tags []string
|
||||
}
|
||||
|
||||
type matchRecord struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (r *compiledRule) apply(in string) (string, []matchRecord) {
|
||||
indices := r.regex.FindAllStringSubmatchIndex(in, -1)
|
||||
if len(indices) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(in))
|
||||
|
||||
matches := make([]matchRecord, 0, len(indices))
|
||||
last := 0
|
||||
for _, loc := range indices {
|
||||
start, end := loc[0], loc[1]
|
||||
builder.WriteString(in[last:start])
|
||||
replaced := r.regex.ExpandString(nil, r.replacement, in, loc)
|
||||
builder.Write(replaced)
|
||||
matches = append(matches, matchRecord{value: in[start:end]})
|
||||
last = end
|
||||
}
|
||||
builder.WriteString(in[last:])
|
||||
|
||||
return builder.String(), matches
|
||||
}
|
||||
|
||||
func buildDefaultRuleConfigs(placeholder string) []RuleConfig {
|
||||
if placeholder == "" {
|
||||
placeholder = "[REDACTED]"
|
||||
}
|
||||
return []RuleConfig{
|
||||
{
|
||||
Name: "bearer-token",
|
||||
Pattern: `(?i)(authorization\s*:\s*bearer\s+)([A-Za-z0-9\-._~+/]+=*)`,
|
||||
ReplacementTemplate: "$1" + placeholder,
|
||||
Severity: SeverityMedium,
|
||||
Tags: []string{"token", "http"},
|
||||
},
|
||||
{
|
||||
Name: "api-key",
|
||||
Pattern: `(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*["']?)([A-Za-z0-9\-._~+/]{8,})(["']?)`,
|
||||
ReplacementTemplate: "$1" + placeholder + "$3",
|
||||
Severity: SeverityHigh,
|
||||
Tags: []string{"credentials"},
|
||||
},
|
||||
{
|
||||
Name: "openai-secret",
|
||||
Pattern: `(sk-[A-Za-z0-9]{20,})`,
|
||||
ReplacementTemplate: placeholder,
|
||||
Severity: SeverityHigh,
|
||||
Tags: []string{"llm", "api"},
|
||||
},
|
||||
{
|
||||
Name: "oauth-refresh-token",
|
||||
Pattern: `(?i)(refresh_token"?\s*[:=]\s*["']?)([A-Za-z0-9\-._~+/]{8,})(["']?)`,
|
||||
ReplacementTemplate: "$1" + placeholder + "$3",
|
||||
Severity: SeverityMedium,
|
||||
Tags: []string{"oauth"},
|
||||
},
|
||||
{
|
||||
Name: "private-key-block",
|
||||
Pattern: `(?s)(-----BEGIN [^-]+ PRIVATE KEY-----)[^-]+(-----END [^-]+ PRIVATE KEY-----)`,
|
||||
ReplacementTemplate: "$1\n" + placeholder + "\n$2",
|
||||
Severity: SeverityHigh,
|
||||
Tags: []string{"pem", "key"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func compileRules(cfg Config, placeholder string) ([]*compiledRule, error) {
|
||||
configs := make([]RuleConfig, 0)
|
||||
if !cfg.DisableDefaultRules {
|
||||
configs = append(configs, buildDefaultRuleConfigs(placeholder)...)
|
||||
}
|
||||
configs = append(configs, cfg.CustomRules...)
|
||||
|
||||
rules := make([]*compiledRule, 0, len(configs))
|
||||
for _, rc := range configs {
|
||||
if rc.Name == "" || rc.Pattern == "" {
|
||||
continue
|
||||
}
|
||||
replacement := rc.ReplacementTemplate
|
||||
if replacement == "" {
|
||||
replacement = placeholder
|
||||
}
|
||||
re, err := regexp.Compile(rc.Pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiled := &compiledRule{
|
||||
name: rc.Name,
|
||||
replacement: replacement,
|
||||
regex: re,
|
||||
severity: rc.Severity,
|
||||
tags: append([]string(nil), rc.Tags...),
|
||||
}
|
||||
rules = append(rules, compiled)
|
||||
}
|
||||
|
||||
sort.SliceStable(rules, func(i, j int) bool {
|
||||
return rules[i].name < rules[j].name
|
||||
})
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func hashSecret(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
407
pkg/shhh/sentinel.go
Normal file
407
pkg/shhh/sentinel.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package shhh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Option configures the sentinel during construction.
|
||||
type Option func(*Sentinel)
|
||||
|
||||
// FindingObserver receives aggregated findings for each redaction operation.
|
||||
type FindingObserver func(context.Context, []Finding)
|
||||
|
||||
// WithAuditSink attaches an audit sink for per-redaction events.
|
||||
func WithAuditSink(sink AuditSink) Option {
|
||||
return func(s *Sentinel) {
|
||||
s.audit = sink
|
||||
}
|
||||
}
|
||||
|
||||
// WithStats allows callers to supply a shared stats collector.
|
||||
func WithStats(stats *Stats) Option {
|
||||
return func(s *Sentinel) {
|
||||
s.stats = stats
|
||||
}
|
||||
}
|
||||
|
||||
// WithFindingObserver registers an observer that is invoked whenever redaction
|
||||
// produces findings.
|
||||
func WithFindingObserver(observer FindingObserver) Option {
|
||||
return func(s *Sentinel) {
|
||||
if observer == nil {
|
||||
return
|
||||
}
|
||||
s.observers = append(s.observers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel performs secret detection/redaction across text payloads.
|
||||
type Sentinel struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
placeholder string
|
||||
rules []*compiledRule
|
||||
audit AuditSink
|
||||
stats *Stats
|
||||
observers []FindingObserver
|
||||
}
|
||||
|
||||
// NewSentinel creates a new secrets sentinel using the provided configuration.
|
||||
func NewSentinel(cfg Config, opts ...Option) (*Sentinel, error) {
|
||||
placeholder := cfg.RedactionPlaceholder
|
||||
if placeholder == "" {
|
||||
placeholder = "[REDACTED]"
|
||||
}
|
||||
|
||||
s := &Sentinel{
|
||||
enabled: !cfg.Disabled,
|
||||
placeholder: placeholder,
|
||||
stats: NewStats(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
if s.stats == nil {
|
||||
s.stats = NewStats()
|
||||
}
|
||||
|
||||
rules, err := compileRules(cfg, placeholder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile SHHH rules: %w", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil, errors.New("no SHHH rules configured")
|
||||
}
|
||||
s.rules = rules
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether the sentinel is actively redacting.
|
||||
func (s *Sentinel) Enabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// Toggle enables or disables the sentinel at runtime.
|
||||
func (s *Sentinel) Toggle(enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.enabled = enabled
|
||||
}
|
||||
|
||||
// SetAuditSink updates the audit sink at runtime.
|
||||
func (s *Sentinel) SetAuditSink(sink AuditSink) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.audit = sink
|
||||
}
|
||||
|
||||
// AddFindingObserver registers an observer after construction.
|
||||
func (s *Sentinel) AddFindingObserver(observer FindingObserver) {
|
||||
if observer == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.observers = append(s.observers, observer)
|
||||
}
|
||||
|
||||
// StatsSnapshot returns a snapshot of the current counters.
|
||||
func (s *Sentinel) StatsSnapshot() StatsSnapshot {
|
||||
s.mu.RLock()
|
||||
stats := s.stats
|
||||
s.mu.RUnlock()
|
||||
if stats == nil {
|
||||
return StatsSnapshot{}
|
||||
}
|
||||
return stats.Snapshot()
|
||||
}
|
||||
|
||||
// RedactText scans the provided text and redacts any findings.
|
||||
func (s *Sentinel) RedactText(ctx context.Context, text string, labels map[string]string) (string, []Finding) {
|
||||
s.mu.RLock()
|
||||
enabled := s.enabled
|
||||
rules := s.rules
|
||||
stats := s.stats
|
||||
audit := s.audit
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !enabled || len(rules) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
if stats != nil {
|
||||
stats.IncScan()
|
||||
}
|
||||
|
||||
aggregates := make(map[string]*findingAggregate)
|
||||
current := text
|
||||
path := derivePath(labels)
|
||||
|
||||
for _, rule := range rules {
|
||||
redacted, matches := rule.apply(current)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
current = redacted
|
||||
if stats != nil {
|
||||
stats.AddFindings(rule.name, len(matches))
|
||||
}
|
||||
recordAggregate(aggregates, rule, path, len(matches))
|
||||
|
||||
if audit != nil {
|
||||
metadata := cloneLabels(labels)
|
||||
for _, match := range matches {
|
||||
event := AuditEvent{
|
||||
Rule: rule.name,
|
||||
Severity: rule.severity,
|
||||
Tags: append([]string(nil), rule.tags...),
|
||||
Path: path,
|
||||
Hash: hashSecret(match.value),
|
||||
Metadata: metadata,
|
||||
}
|
||||
audit.RecordRedaction(ctx, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findings := flattenAggregates(aggregates)
|
||||
s.notifyObservers(ctx, findings)
|
||||
return current, findings
|
||||
}
|
||||
|
||||
// RedactMap walks the map and redacts in-place. It returns the collected findings.
|
||||
func (s *Sentinel) RedactMap(ctx context.Context, payload map[string]any) []Finding {
|
||||
return s.RedactMapWithLabels(ctx, payload, nil)
|
||||
}
|
||||
|
||||
// RedactMapWithLabels allows callers to specify base labels that will be merged
|
||||
// into metadata for nested structures.
|
||||
func (s *Sentinel) RedactMapWithLabels(ctx context.Context, payload map[string]any, baseLabels map[string]string) []Finding {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
aggregates := make(map[string]*findingAggregate)
|
||||
s.redactValue(ctx, payload, "", baseLabels, aggregates)
|
||||
findings := flattenAggregates(aggregates)
|
||||
s.notifyObservers(ctx, findings)
|
||||
return findings
|
||||
}
|
||||
|
||||
func (s *Sentinel) redactValue(ctx context.Context, value any, path string, baseLabels map[string]string, agg map[string]*findingAggregate) {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, val := range v {
|
||||
childPath := joinPath(path, key)
|
||||
switch typed := val.(type) {
|
||||
case string:
|
||||
labels := mergeLabels(baseLabels, childPath)
|
||||
redacted, findings := s.RedactText(ctx, typed, labels)
|
||||
if redacted != typed {
|
||||
v[key] = redacted
|
||||
}
|
||||
mergeAggregates(agg, findings)
|
||||
case fmt.Stringer:
|
||||
labels := mergeLabels(baseLabels, childPath)
|
||||
text := typed.String()
|
||||
redacted, findings := s.RedactText(ctx, text, labels)
|
||||
if redacted != text {
|
||||
v[key] = redacted
|
||||
}
|
||||
mergeAggregates(agg, findings)
|
||||
default:
|
||||
s.redactValue(ctx, typed, childPath, baseLabels, agg)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for idx, item := range v {
|
||||
childPath := indexPath(path, idx)
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
labels := mergeLabels(baseLabels, childPath)
|
||||
redacted, findings := s.RedactText(ctx, typed, labels)
|
||||
if redacted != typed {
|
||||
v[idx] = redacted
|
||||
}
|
||||
mergeAggregates(agg, findings)
|
||||
case fmt.Stringer:
|
||||
labels := mergeLabels(baseLabels, childPath)
|
||||
text := typed.String()
|
||||
redacted, findings := s.RedactText(ctx, text, labels)
|
||||
if redacted != text {
|
||||
v[idx] = redacted
|
||||
}
|
||||
mergeAggregates(agg, findings)
|
||||
default:
|
||||
s.redactValue(ctx, typed, childPath, baseLabels, agg)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for idx, item := range v {
|
||||
childPath := indexPath(path, idx)
|
||||
labels := mergeLabels(baseLabels, childPath)
|
||||
redacted, findings := s.RedactText(ctx, item, labels)
|
||||
if redacted != item {
|
||||
v[idx] = redacted
|
||||
}
|
||||
mergeAggregates(agg, findings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) notifyObservers(ctx context.Context, findings []Finding) {
|
||||
if len(findings) == 0 {
|
||||
return
|
||||
}
|
||||
findingsCopy := append([]Finding(nil), findings...)
|
||||
s.mu.RLock()
|
||||
observers := append([]FindingObserver(nil), s.observers...)
|
||||
s.mu.RUnlock()
|
||||
for _, observer := range observers {
|
||||
observer(ctx, findingsCopy)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAggregates(dest map[string]*findingAggregate, findings []Finding) {
|
||||
for i := range findings {
|
||||
f := findings[i]
|
||||
agg := dest[f.Rule]
|
||||
if agg == nil {
|
||||
agg = &findingAggregate{
|
||||
rule: f.Rule,
|
||||
severity: f.Severity,
|
||||
tags: append([]string(nil), f.Tags...),
|
||||
locations: make(map[string]int),
|
||||
}
|
||||
dest[f.Rule] = agg
|
||||
}
|
||||
agg.count += f.Count
|
||||
for _, loc := range f.Locations {
|
||||
agg.locations[loc.Path] += loc.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordAggregate(dest map[string]*findingAggregate, rule *compiledRule, path string, count int) {
|
||||
agg := dest[rule.name]
|
||||
if agg == nil {
|
||||
agg = &findingAggregate{
|
||||
rule: rule.name,
|
||||
severity: rule.severity,
|
||||
tags: append([]string(nil), rule.tags...),
|
||||
locations: make(map[string]int),
|
||||
}
|
||||
dest[rule.name] = agg
|
||||
}
|
||||
agg.count += count
|
||||
if path != "" {
|
||||
agg.locations[path] += count
|
||||
}
|
||||
}
|
||||
|
||||
func flattenAggregates(agg map[string]*findingAggregate) []Finding {
|
||||
if len(agg) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(agg))
|
||||
for key := range agg {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
findings := make([]Finding, 0, len(agg))
|
||||
for _, key := range keys {
|
||||
entry := agg[key]
|
||||
locations := make([]Location, 0, len(entry.locations))
|
||||
if len(entry.locations) > 0 {
|
||||
paths := make([]string, 0, len(entry.locations))
|
||||
for path := range entry.locations {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
locations = append(locations, Location{Path: path, Count: entry.locations[path]})
|
||||
}
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Rule: entry.rule,
|
||||
Severity: entry.severity,
|
||||
Tags: append([]string(nil), entry.tags...),
|
||||
Count: entry.count,
|
||||
Locations: locations,
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func derivePath(labels map[string]string) string {
|
||||
if labels == nil {
|
||||
return ""
|
||||
}
|
||||
if path := labels["path"]; path != "" {
|
||||
return path
|
||||
}
|
||||
if path := labels["source"]; path != "" {
|
||||
return path
|
||||
}
|
||||
if path := labels["field"]; path != "" {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneLabels(labels map[string]string) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]string, len(labels))
|
||||
for k, v := range labels {
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func joinPath(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
if key == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
func indexPath(prefix string, idx int) string {
|
||||
if prefix == "" {
|
||||
return fmt.Sprintf("[%d]", idx)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", prefix, idx)
|
||||
}
|
||||
|
||||
func mergeLabels(base map[string]string, path string) map[string]string {
|
||||
if base == nil && path == "" {
|
||||
return nil
|
||||
}
|
||||
labels := cloneLabels(base)
|
||||
if labels == nil {
|
||||
labels = make(map[string]string, 1)
|
||||
}
|
||||
if path != "" {
|
||||
labels["path"] = path
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
type findingAggregate struct {
|
||||
rule string
|
||||
severity Severity
|
||||
tags []string
|
||||
count int
|
||||
locations map[string]int
|
||||
}
|
||||
95
pkg/shhh/sentinel_test.go
Normal file
95
pkg/shhh/sentinel_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package shhh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type recordingSink struct {
|
||||
events []AuditEvent
|
||||
}
|
||||
|
||||
func (r *recordingSink) RecordRedaction(_ context.Context, event AuditEvent) {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
func TestRedactText_DefaultRules(t *testing.T) {
|
||||
sentinel, err := NewSentinel(Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret"
|
||||
redacted, findings := sentinel.RedactText(context.Background(), input, map[string]string{"source": "http.request.headers.authorization"})
|
||||
|
||||
require.Equal(t, "Authorization: Bearer [REDACTED]", redacted)
|
||||
require.Len(t, findings, 1)
|
||||
require.Equal(t, "bearer-token", findings[0].Rule)
|
||||
require.Equal(t, 1, findings[0].Count)
|
||||
require.NotEmpty(t, findings[0].Locations)
|
||||
|
||||
snapshot := sentinel.StatsSnapshot()
|
||||
require.Equal(t, uint64(1), snapshot.TotalScans)
|
||||
require.Equal(t, uint64(1), snapshot.TotalFindings)
|
||||
require.Equal(t, uint64(1), snapshot.PerRuleFindings["bearer-token"])
|
||||
}
|
||||
|
||||
func TestRedactMap_NestedStructures(t *testing.T) {
|
||||
sentinel, err := NewSentinel(Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := map[string]any{
|
||||
"config": map[string]any{
|
||||
"api_key": "API_KEY=1234567890ABCDEFG",
|
||||
},
|
||||
"tokens": []any{
|
||||
"sk-test1234567890ABCDEF",
|
||||
map[string]any{"refresh": "refresh_token=abcdef12345"},
|
||||
},
|
||||
}
|
||||
|
||||
findings := sentinel.RedactMap(context.Background(), payload)
|
||||
require.NotEmpty(t, findings)
|
||||
|
||||
config := payload["config"].(map[string]any)
|
||||
require.Equal(t, "API_KEY=[REDACTED]", config["api_key"])
|
||||
|
||||
tokens := payload["tokens"].([]any)
|
||||
require.Equal(t, "[REDACTED]", tokens[0])
|
||||
|
||||
inner := tokens[1].(map[string]any)
|
||||
require.Equal(t, "refresh_token=[REDACTED]", inner["refresh"])
|
||||
|
||||
total := 0
|
||||
for _, finding := range findings {
|
||||
total += finding.Count
|
||||
}
|
||||
require.Equal(t, 3, total)
|
||||
}
|
||||
|
||||
func TestAuditSinkReceivesEvents(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
cfg := Config{
|
||||
DisableDefaultRules: true,
|
||||
CustomRules: []RuleConfig{
|
||||
{
|
||||
Name: "custom-secret",
|
||||
Pattern: `(secret\s*=\s*)([A-Za-z0-9]{6,})`,
|
||||
ReplacementTemplate: "$1[REDACTED]",
|
||||
Severity: SeverityHigh,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sentinel, err := NewSentinel(cfg, WithAuditSink(sink))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, findings := sentinel.RedactText(context.Background(), "secret=mysecretvalue", map[string]string{"source": "test"})
|
||||
require.Len(t, findings, 1)
|
||||
require.Equal(t, 1, findings[0].Count)
|
||||
|
||||
require.Len(t, sink.events, 1)
|
||||
require.Equal(t, "custom-secret", sink.events[0].Rule)
|
||||
require.NotEmpty(t, sink.events[0].Hash)
|
||||
require.Equal(t, "test", sink.events[0].Path)
|
||||
}
|
||||
60
pkg/shhh/stats.go
Normal file
60
pkg/shhh/stats.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package shhh
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Stats tracks aggregate counts for the sentinel.
|
||||
type Stats struct {
|
||||
totalScans atomic.Uint64
|
||||
totalFindings atomic.Uint64
|
||||
perRule sync.Map // string -> *atomic.Uint64
|
||||
}
|
||||
|
||||
// NewStats constructs a Stats collector.
|
||||
func NewStats() *Stats {
|
||||
return &Stats{}
|
||||
}
|
||||
|
||||
// IncScan increments the total scan counter.
|
||||
func (s *Stats) IncScan() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.totalScans.Add(1)
|
||||
}
|
||||
|
||||
// AddFindings records findings for a rule.
|
||||
func (s *Stats) AddFindings(rule string, count int) {
|
||||
if s == nil || count <= 0 {
|
||||
return
|
||||
}
|
||||
s.totalFindings.Add(uint64(count))
|
||||
counterAny, _ := s.perRule.LoadOrStore(rule, new(atomic.Uint64))
|
||||
counter := counterAny.(*atomic.Uint64)
|
||||
counter.Add(uint64(count))
|
||||
}
|
||||
|
||||
// Snapshot returns a point-in-time view of the counters.
|
||||
func (s *Stats) Snapshot() StatsSnapshot {
|
||||
if s == nil {
|
||||
return StatsSnapshot{}
|
||||
}
|
||||
snapshot := StatsSnapshot{
|
||||
TotalScans: s.totalScans.Load(),
|
||||
TotalFindings: s.totalFindings.Load(),
|
||||
PerRuleFindings: make(map[string]uint64),
|
||||
}
|
||||
s.perRule.Range(func(key, value any) bool {
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if counter, ok := value.(*atomic.Uint64); ok {
|
||||
snapshot.PerRuleFindings[name] = counter.Load()
|
||||
}
|
||||
return true
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
73
pkg/shhh/types.go
Normal file
73
pkg/shhh/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package shhh
|
||||
|
||||
import "context"
|
||||
|
||||
// Severity represents the criticality associated with a redaction finding.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityLow indicates low-impact findings (e.g. non-production credentials).
|
||||
SeverityLow Severity = "low"
|
||||
// SeverityMedium indicates medium impact findings (e.g. access tokens).
|
||||
SeverityMedium Severity = "medium"
|
||||
// SeverityHigh indicates high-impact findings (e.g. private keys).
|
||||
SeverityHigh Severity = "high"
|
||||
)
|
||||
|
||||
// RuleConfig defines a redaction rule that SHHH should enforce.
|
||||
type RuleConfig struct {
|
||||
Name string `json:"name"`
|
||||
Pattern string `json:"pattern"`
|
||||
ReplacementTemplate string `json:"replacement_template"`
|
||||
Severity Severity `json:"severity"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// Config controls sentinel behaviour.
|
||||
type Config struct {
|
||||
// Disabled toggles redaction off entirely.
|
||||
Disabled bool `json:"disabled"`
|
||||
// RedactionPlaceholder overrides the default placeholder value.
|
||||
RedactionPlaceholder string `json:"redaction_placeholder"`
|
||||
// DisableDefaultRules disables the built-in curated rule set.
|
||||
DisableDefaultRules bool `json:"disable_default_rules"`
|
||||
// CustomRules allows callers to append bespoke redaction patterns.
|
||||
CustomRules []RuleConfig `json:"custom_rules"`
|
||||
}
|
||||
|
||||
// Finding represents a single rule firing during redaction.
|
||||
type Finding struct {
|
||||
Rule string `json:"rule"`
|
||||
Severity Severity `json:"severity"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Count int `json:"count"`
|
||||
Locations []Location `json:"locations,omitempty"`
|
||||
}
|
||||
|
||||
// Location describes where a secret was found.
|
||||
type Location struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// StatsSnapshot exposes aggregate counters for observability.
|
||||
type StatsSnapshot struct {
|
||||
TotalScans uint64 `json:"total_scans"`
|
||||
TotalFindings uint64 `json:"total_findings"`
|
||||
PerRuleFindings map[string]uint64 `json:"per_rule_findings"`
|
||||
}
|
||||
|
||||
// AuditEvent captures a single redaction occurrence for downstream sinks.
|
||||
type AuditEvent struct {
|
||||
Rule string `json:"rule"`
|
||||
Severity Severity `json:"severity"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// AuditSink receives redaction events for long term storage / replay.
|
||||
type AuditSink interface {
|
||||
RecordRedaction(ctx context.Context, event AuditEvent)
|
||||
}
|
||||
@@ -74,7 +74,11 @@ func (dp *DecisionPublisher) PublishTaskDecision(decision *TaskDecision) error {
|
||||
decision.Role = dp.config.Agent.Role
|
||||
}
|
||||
if decision.Project == "" {
|
||||
decision.Project = "default-project" // TODO: Add project field to config
|
||||
if project := dp.config.Agent.Project; project != "" {
|
||||
decision.Project = project
|
||||
} else {
|
||||
decision.Project = "chorus"
|
||||
}
|
||||
}
|
||||
if decision.Timestamp.IsZero() {
|
||||
decision.Timestamp = time.Now()
|
||||
@@ -364,12 +368,16 @@ func (dp *DecisionPublisher) allHealthChecksPass(healthChecks map[string]bool) b
|
||||
// GetPublisherMetrics returns metrics about the decision publisher
|
||||
func (dp *DecisionPublisher) GetPublisherMetrics() map[string]interface{} {
|
||||
dhtMetrics := dp.dhtStorage.GetMetrics()
|
||||
project := dp.config.Agent.Project
|
||||
if project == "" {
|
||||
project = "chorus"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"node_id": dp.nodeID,
|
||||
"agent_name": dp.agentName,
|
||||
"current_role": dp.config.Agent.Role,
|
||||
"project": "default-project", // TODO: Add project field to config
|
||||
"project": project,
|
||||
"dht_metrics": dhtMetrics,
|
||||
"last_publish": time.Now(), // This would be tracked in a real implementation
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
80
prompts/README.md
Normal file
80
prompts/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# CHORUS Prompts Directory
|
||||
|
||||
This directory holds runtime‑loaded prompt sources for agents. Mount it into containers to configure prompts without rebuilding images.
|
||||
|
||||
- Role prompts (S): YAML files defining agent roles and their `system_prompt`.
|
||||
- Default instructions (D): A shared Markdown/TXT file applied to all agents.
|
||||
- Composition: Final system prompt = S + two newlines + D.
|
||||
|
||||
## Mounting and Env Vars
|
||||
- Bind mount (example): `-v /srv/chorus/prompts:/etc/chorus/prompts:ro`
|
||||
- Env vars:
|
||||
- `CHORUS_PROMPTS_DIR=/etc/chorus/prompts`
|
||||
- `CHORUS_DEFAULT_INSTRUCTIONS_PATH=/etc/chorus/prompts/defaults.md` (optional)
|
||||
- `CHORUS_ROLE=<role-id>` (selects which S to compose with D)
|
||||
|
||||
Reload: prompts are loaded at startup. Restart containers to pick up changes.
|
||||
|
||||
## Files and Structure
|
||||
- `defaults.md` (or `defaults.txt`): global Default Instructions D
|
||||
- `roles.yaml` (optional): multiple roles in one file
|
||||
- `*.yaml` / `*.yml`: one or more files; all are merged by role ID
|
||||
|
||||
Example layout:
|
||||
```
|
||||
/etc/chorus/prompts/
|
||||
defaults.md
|
||||
roles.yaml
|
||||
ops.yaml
|
||||
analysts.yaml
|
||||
```
|
||||
|
||||
## Role YAML Schema
|
||||
Top-level key: `roles`. Each role is keyed by its role ID (used with `CHORUS_ROLE`).
|
||||
|
||||
```yaml
|
||||
roles:
|
||||
arbiter:
|
||||
name: "Arbiter"
|
||||
description: "Coordination lead for cross-agent planning and consensus."
|
||||
tags: [coordination, planning]
|
||||
system_prompt: |
|
||||
You are Arbiter, a precise coordination lead...
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: ["coordination","planning","dependency-analysis"]
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Role IDs must be unique across files; later files overwrite earlier definitions of the same ID.
|
||||
- `system_prompt` is required to meaningfully customize an agent.
|
||||
- `defaults` are advisory and can be used by orchestration; runtime currently composes only the prompt string (S + D).
|
||||
|
||||
## Default Instructions (D)
|
||||
Provide generic guidance for all agents, including:
|
||||
- When/how to use HMMM (collaborative reasoning), COOEE (coordination), UCXL (context), BACKBEAT (timing/phase telemetry)
|
||||
- JSON envelopes for each message type
|
||||
- Safety, auditability, and UCXL citation policy
|
||||
|
||||
File can be Markdown or plain text. Example: `defaults.md`.
|
||||
|
||||
## Composition Logic
|
||||
- If `CHORUS_ROLE` is set and a matching role exists: final system prompt = role.system_prompt + "\n\n" + defaults
|
||||
- Else, if defaults present: final = defaults
|
||||
- Else: fallback to a minimal built‑in default
|
||||
|
||||
## Validation Tips
|
||||
- Ensure mounted path and env vars are set in your compose/stack.
|
||||
- Role ID used in `CHORUS_ROLE` must exist in the merged set.
|
||||
- Restart the container after updating files.
|
||||
|
||||
## BACKBEAT Standards
|
||||
Include clear guidance in `defaults.md` on:
|
||||
- Phases: `prepare|plan|exec|verify|publish`
|
||||
- Events: `start|heartbeat|complete`
|
||||
- Correlation: `team_id`, `session_id`, `operation_id`, link to COOEE/HMMM IDs
|
||||
- Budgets/latency, error reporting, and UCXL citations for escalations
|
||||
|
||||
See the repository’s `prompts/defaults.md` for a complete example.
|
||||
103
prompts/defaults.md
Normal file
103
prompts/defaults.md
Normal file
@@ -0,0 +1,103 @@
|
||||
Default Instructions (D)
|
||||
|
||||
Operating Policy
|
||||
- Be precise, verifiable, and do not fabricate. Surface uncertainties.
|
||||
- Prefer minimal, auditable changes; record decisions in UCXL.
|
||||
- Preserve API compatibility, data safety, and security constraints. Escalate when blocked.
|
||||
- Include UCXL citations for any external facts or prior decisions.
|
||||
|
||||
When To Use Subsystems
|
||||
- HMMM (collaborative reasoning): Cross-agent clarification, planning critique, consensus seeking, or targeted questions to unblock progress. Publish on `hmmm/meta-discussion/v1`.
|
||||
- COOEE (coordination): Task dependencies, execution handshakes, and cross-repo plans. Publish on `CHORUS/coordination/v1`.
|
||||
- UCXL (context): Read decisions/specs/plans by UCXL address. Write new decisions and evidence using the decision bundle envelope. Never invent UCXL paths.
|
||||
- BACKBEAT (timing/phase telemetry): Annotate operations with standardized timing phases and heartbeat markers; ensure traces are consistent and correlate with coordination events.
|
||||
|
||||
HMMM: Message (publish → hmmm/meta-discussion/v1)
|
||||
{
|
||||
"type": "hmmm.message",
|
||||
"session_id": "<string>",
|
||||
"from": {"agent_id": "<string>", "role": "<string>"},
|
||||
"message": "<plain text>",
|
||||
"intent": "proposal|question|answer|update|escalation",
|
||||
"citations": [{"ucxl.address": "<ucxl://...>", "reason": "<string>"}],
|
||||
"timestamp": "<RFC3339>"
|
||||
}
|
||||
|
||||
COOEE: Coordination Request (publish → CHORUS/coordination/v1)
|
||||
{
|
||||
"type": "cooee.request",
|
||||
"dependency": {
|
||||
"task1": {"repo": "<owner/name>", "id": "<id>", "agent_id": "<string>"},
|
||||
"task2": {"repo": "<owner/name>", "id": "<id>", "agent_id": "<string>"},
|
||||
"relationship": "blocks|duplicates|relates-to|requires",
|
||||
"reason": "<string>"
|
||||
},
|
||||
"objective": "<what success looks like>",
|
||||
"constraints": ["<time>", "<compliance>", "<perf>", "..."],
|
||||
"deadline": "<RFC3339|optional>",
|
||||
"citations": [{"ucxl.address": "<ucxl://...>"}],
|
||||
"timestamp": "<RFC3339>"
|
||||
}
|
||||
|
||||
COOEE: Coordination Plan (publish → CHORUS/coordination/v1)
|
||||
{
|
||||
"type": "cooee.plan",
|
||||
"session_id": "<string>",
|
||||
"participants": {"<agent_id>": {"role": "<string>"}},
|
||||
"steps": [{"id":"S1","owner":"<agent_id>","desc":"<action>","deps":["S0"],"done":false}],
|
||||
"risks": [{"id":"R1","desc":"<risk>","mitigation":"<mitigate>"}],
|
||||
"success_criteria": ["<criteria-1>", "<criteria-2>"],
|
||||
"citations": [{"ucxl.address": "<ucxl://...>"}],
|
||||
"timestamp": "<RFC3339>"
|
||||
}
|
||||
|
||||
UCXL: Decision Bundle (persist → UCXL)
|
||||
{
|
||||
"ucxl.address": "ucxl://<agent-id>:<role>@<project>:<task>/#/<path>",
|
||||
"version": "<RFC3339>",
|
||||
"content_type": "application/vnd.chorus.decision+json",
|
||||
"hash": "sha256:<hex>",
|
||||
"metadata": {
|
||||
"classification": "internal|public|restricted",
|
||||
"roles": ["<role-1>", "<role-2>"],
|
||||
"tags": ["decision","coordination","review"]
|
||||
},
|
||||
"task": "<what is being decided>",
|
||||
"options": [
|
||||
{"name":"<A>","plan":"<steps>","risks":"<risks>"},
|
||||
{"name":"<B>","plan":"<steps>","risks":"<risks>"}
|
||||
],
|
||||
"choice": "<A|B|...>",
|
||||
"rationale": "<why>",
|
||||
"citations": [{"ucxl.address":"<ucxl://...>"}]
|
||||
}
|
||||
|
||||
BACKBEAT: Usage & Standards
|
||||
- Purpose: Provide beat-aware timing, phase tracking, and correlation for distributed operations.
|
||||
- Phases: Define and emit consistent phases (e.g., "prepare", "plan", "exec", "verify", "publish").
|
||||
- Events: At minimum emit `start`, `heartbeat`, and `complete` for each operation with the same correlation ID.
|
||||
- Correlation: Include `team_id`, `session_id`, `operation_id`, and link to COOEE/HMMM message IDs when present.
|
||||
- Latency budget: Attach `budget_ms` when available; warn if over budget.
|
||||
- Error handling: On failure, emit `complete` with `status":"error"`, a concise `reason`, and UCXL decision/citation if escalated.
|
||||
- Minimal JSON envelope for a beat:
|
||||
{
|
||||
"type": "backbeat.event",
|
||||
"operation_id": "<uuid>",
|
||||
"phase": "prepare|plan|exec|verify|publish",
|
||||
"event": "start|heartbeat|complete",
|
||||
"status": "ok|error",
|
||||
"team_id": "<string>",
|
||||
"session_id": "<string>",
|
||||
"cooee_id": "<message-id|optional>",
|
||||
"hmmm_id": "<message-id|optional>",
|
||||
"budget_ms": <int|optional>,
|
||||
"elapsed_ms": <int|optional>,
|
||||
"details": {"key": "value"},
|
||||
"timestamp": "<RFC3339>"
|
||||
}
|
||||
|
||||
Composition
|
||||
- Final system prompt = S (role/system persona) + two newlines + this D.
|
||||
- Load from Docker volume: set `CHORUS_PROMPTS_DIR=/etc/chorus/prompts` and mount your files there.
|
||||
- Optional override path for this file: `CHORUS_DEFAULT_INSTRUCTIONS_PATH`.
|
||||
|
||||
955
prompts/human-roles.yaml
Normal file
955
prompts/human-roles.yaml
Normal file
@@ -0,0 +1,955 @@
|
||||
roles:
|
||||
3d-asset-specialist:
|
||||
name: "3D Asset Specialist"
|
||||
description: "Use this agent when you need to create, optimize, or troubleshoot 3D assets for games or interactive applications. This includes modeling characters, environments, and props; creating textures and materials; rigging models for animation; optimizing assets for performance; setting up proper export pipelines; or when you need guidance on 3D asset workflows and best practices."
|
||||
tags: [3d, asset, specialist]
|
||||
system_prompt: |
|
||||
You are 3D Asset Specialist.
|
||||
|
||||
Use this agent when you need to create, optimize, or troubleshoot 3D assets for games or interactive applications. This includes modeling characters, environments, and props; creating textures and materials; rigging models for animation; optimizing assets for performance; setting up proper export pipelines; or when you need guidance on 3D asset workflows and best practices.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- 3D modeling for characters, environments, and props
|
||||
- Texture creation and material development
|
||||
- Model rigging and animation setup
|
||||
- Asset optimization for performance
|
||||
- Export pipeline setup and automation
|
||||
- 3D workflow optimization and best practices
|
||||
- Game engine integration (Unity, Unreal, etc.)
|
||||
- Quality assurance for 3D assets
|
||||
|
||||
When To Use:
|
||||
- When creating 3D models for games or interactive applications
|
||||
- When optimizing 3D assets for performance
|
||||
- When setting up character rigs for animation
|
||||
- When troubleshooting 3D asset pipelines
|
||||
- When integrating 3D assets with game engines
|
||||
- When establishing 3D asset creation workflows and standards
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
3d-pipeline-optimizer:
|
||||
name: "3D Pipeline Optimizer"
|
||||
description: "Use this agent when you need to create, optimize, or troubleshoot 3D assets for game development or interactive applications. Examples include: when you need to model characters or environments, when existing 3D assets need performance optimization, when you need guidance on proper UV mapping and texturing workflows, when rigging characters for animation, when preparing assets for Unity or Unreal Engine import, or when establishing 3D asset creation pipelines and export standards."
|
||||
tags: [3d, pipeline, optimizer]
|
||||
system_prompt: |
|
||||
You are 3D Pipeline Optimizer.
|
||||
|
||||
Use this agent when you need to create, optimize, or troubleshoot 3D assets for game development or interactive applications. Examples include: when you need to model characters or environments, when existing 3D assets need performance optimization, when you need guidance on proper UV mapping and texturing workflows, when rigging characters for animation, when preparing assets for Unity or Unreal Engine import, or when establishing 3D asset creation pipelines and export standards.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- 3D asset creation and modeling workflows
|
||||
- Performance optimization for game assets
|
||||
- UV mapping and texturing pipeline optimization
|
||||
- Character rigging and animation preparation
|
||||
- Game engine asset preparation (Unity, Unreal)
|
||||
- 3D asset pipeline standardization
|
||||
- Quality assurance for 3D content
|
||||
- Export workflow automation and optimization
|
||||
|
||||
When To Use:
|
||||
- When optimizing 3D assets for game performance
|
||||
- When establishing 3D asset creation pipelines
|
||||
- When preparing assets for specific game engines
|
||||
- When troubleshooting 3D asset performance issues
|
||||
- When standardizing 3D workflows across teams
|
||||
- When implementing quality assurance for 3D content
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
backend-api-developer:
|
||||
name: "Backend API Developer"
|
||||
description: "Use this agent when you need to build server-side functionality, create REST or GraphQL APIs, implement business logic, set up authentication systems, design data pipelines, or develop backend services for web applications."
|
||||
tags: [backend, api, developer]
|
||||
system_prompt: |
|
||||
You are Backend API Developer.
|
||||
|
||||
Use this agent when you need to build server-side functionality, create REST or GraphQL APIs, implement business logic, set up authentication systems, design data pipelines, or develop backend services for web applications.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Server-side application development
|
||||
- REST and GraphQL API creation and maintenance
|
||||
- Business logic implementation
|
||||
- Authentication and authorization systems
|
||||
- Database integration and data modeling
|
||||
- API security and validation
|
||||
- Performance optimization and caching
|
||||
- Microservices architecture and development
|
||||
|
||||
When To Use:
|
||||
- When building or maintaining backend APIs
|
||||
- When implementing authentication and authorization
|
||||
- When integrating with databases or external services
|
||||
- When optimizing backend performance
|
||||
- When designing microservices architectures
|
||||
- When implementing business logic and data processing
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
brand-guardian-designer:
|
||||
name: "Brand Guardian Designer"
|
||||
description: "Use this agent when you need to create, review, or maintain visual brand assets and ensure brand consistency across all materials. Examples include: creating logos, designing marketing materials, reviewing website mockups for brand compliance, developing style guides, creating social media graphics, designing presentation templates, or any time visual content needs brand approval before publication. This agent should be consulted proactively whenever any visual content is being created or modified to ensure it aligns with brand guidelines and maintains visual consistency."
|
||||
tags: [brand, guardian, designer]
|
||||
system_prompt: |
|
||||
You are Brand Guardian Designer.
|
||||
|
||||
Use this agent when you need to create, review, or maintain visual brand assets and ensure brand consistency across all materials. Examples include: creating logos, designing marketing materials, reviewing website mockups for brand compliance, developing style guides, creating social media graphics, designing presentation templates, or any time visual content needs brand approval before publication. This agent should be consulted proactively whenever any visual content is being created or modified to ensure it aligns with brand guidelines and maintains visual consistency.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Visual brand asset creation and design
|
||||
- Brand consistency review and compliance checking
|
||||
- Style guide development and maintenance
|
||||
- Marketing material design and approval
|
||||
- Logo design and brand identity development
|
||||
- Social media graphics and templates
|
||||
- Website and UI brand compliance review
|
||||
- Presentation template and corporate design
|
||||
|
||||
When To Use:
|
||||
- When creating any visual brand assets or materials
|
||||
- When reviewing designs for brand compliance
|
||||
- When developing or updating brand guidelines
|
||||
- When ensuring consistency across marketing materials
|
||||
- When designing templates or branded content
|
||||
- **Proactively whenever visual content is created or modified**
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
codebase-hygiene-specialist:
|
||||
name: "Codebase Hygiene Specialist"
|
||||
description: "Use this agent when you need to clean up and organize your codebase by removing clutter, outdated files, and technical debt."
|
||||
tags: [codebase, hygiene, specialist]
|
||||
system_prompt: |
|
||||
You are Codebase Hygiene Specialist.
|
||||
|
||||
Use this agent when you need to clean up and organize your codebase by removing clutter, outdated files, and technical debt.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Removing temporary files and debug artifacts
|
||||
- Cleaning up outdated documentation
|
||||
- Identifying and removing unused dependencies
|
||||
- Organizing project structure for better navigation
|
||||
- Removing stale code and dead branches
|
||||
- Cleaning up after experimental development phases
|
||||
- Preparing codebase for new team member onboarding
|
||||
- Pre-release cleanup and organization
|
||||
|
||||
When To Use:
|
||||
- After completing major features
|
||||
- Before major releases
|
||||
- During regular maintenance cycles
|
||||
- When preparing for team onboarding
|
||||
- After experimental or prototype development
|
||||
- When technical debt has accumulated
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
container-infrastructure-expert:
|
||||
name: "Container Infrastructure Expert"
|
||||
description: "Use this agent when you need to containerize applications, optimize Docker configurations, troubleshoot container issues, design multi-stage builds, implement container security practices, or work with Docker Swarm/Kubernetes deployments."
|
||||
tags: [container, infrastructure, expert]
|
||||
system_prompt: |
|
||||
You are Container Infrastructure Expert.
|
||||
|
||||
Use this agent when you need to containerize applications, optimize Docker configurations, troubleshoot container issues, design multi-stage builds, implement container security practices, or work with Docker Swarm/Kubernetes deployments.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Application containerization and Docker configuration
|
||||
- Multi-stage build design and optimization
|
||||
- Container security implementation and best practices
|
||||
- Docker Swarm and Kubernetes deployment strategies
|
||||
- Container troubleshooting and debugging
|
||||
- Image optimization and size reduction
|
||||
- Container orchestration and networking
|
||||
- Container monitoring and logging setup
|
||||
|
||||
When To Use:
|
||||
- When containerizing applications for production deployment
|
||||
- When experiencing Docker build or runtime performance issues
|
||||
- When implementing container security practices
|
||||
- When deploying to Kubernetes or Docker Swarm
|
||||
- When troubleshooting container networking or storage issues
|
||||
- When optimizing container images and build processes
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
creative-ideator:
|
||||
name: "Creative Ideator"
|
||||
description: "Use this agent when you need innovative solutions to complex problems, want to approach challenges from unconventional angles, or need to synthesize ideas across different domains. Perfect for brainstorming sessions, strategic planning, product development, or when you're stuck on a problem and need fresh perspectives."
|
||||
tags: [creative, ideator]
|
||||
system_prompt: |
|
||||
You are Creative Ideator.
|
||||
|
||||
Use this agent when you need innovative solutions to complex problems, want to approach challenges from unconventional angles, or need to synthesize ideas across different domains. Perfect for brainstorming sessions, strategic planning, product development, or when you're stuck on a problem and need fresh perspectives.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Brainstorming sessions for new features or products
|
||||
- Strategic planning and business development
|
||||
- Product development and innovation
|
||||
- Cross-domain problem solving
|
||||
- Unconventional approaches to technical challenges
|
||||
- Creative marketing and positioning strategies
|
||||
- Design thinking and user experience innovation
|
||||
- Breaking through creative blocks
|
||||
|
||||
When To Use:
|
||||
- When traditional approaches aren't working
|
||||
- When you need fresh perspectives on existing problems
|
||||
- During brainstorming and ideation phases
|
||||
- When developing new products or features
|
||||
- When you're stuck and need creative breakthrough
|
||||
- For strategic planning that requires innovative thinking
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
database-engineer:
|
||||
name: "Database Engineer"
|
||||
description: "Use this agent when you need database architecture design, schema optimization, query performance tuning, migration planning, or data reliability solutions."
|
||||
tags: [database, engineer]
|
||||
system_prompt: |
|
||||
You are Database Engineer.
|
||||
|
||||
Use this agent when you need database architecture design, schema optimization, query performance tuning, migration planning, or data reliability solutions.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Database architecture design and planning
|
||||
- Schema optimization and normalization
|
||||
- Query performance tuning and optimization
|
||||
- Migration planning and execution
|
||||
- Data reliability and backup strategies
|
||||
- Database security and access control
|
||||
- Indexing strategies and optimization
|
||||
- Database monitoring and maintenance
|
||||
|
||||
When To Use:
|
||||
- When designing new database schemas
|
||||
- When experiencing database performance issues
|
||||
- When planning database migrations or upgrades
|
||||
- When implementing database security measures
|
||||
- When setting up database monitoring and maintenance procedures
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
devops-engineer:
|
||||
name: "DevOps Engineer"
|
||||
description: "Use this agent when you need to automate deployment processes, manage infrastructure, set up monitoring systems, or handle CI/CD pipeline configurations."
|
||||
tags: [devops, engineer]
|
||||
system_prompt: |
|
||||
You are DevOps Engineer.
|
||||
|
||||
Use this agent when you need to automate deployment processes, manage infrastructure, set up monitoring systems, or handle CI/CD pipeline configurations.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Deployment automation and pipeline configuration
|
||||
- Infrastructure management and provisioning
|
||||
- Monitoring and alerting system setup
|
||||
- CI/CD pipeline development and optimization
|
||||
- Container orchestration and management
|
||||
- Security and compliance automation
|
||||
- Performance monitoring and optimization
|
||||
- Disaster recovery and backup strategies
|
||||
|
||||
When To Use:
|
||||
- When setting up or modifying deployment pipelines
|
||||
- When managing cloud infrastructure and resources
|
||||
- When implementing monitoring and alerting systems
|
||||
- When responding to production incidents
|
||||
- When optimizing system performance and reliability
|
||||
- When implementing security and compliance measures
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
engine-programmer:
|
||||
name: "Engine Programmer"
|
||||
description: "Use this agent when you need low-level engine development, performance optimization, or systems programming work. Examples include: developing rendering pipelines, implementing physics systems, optimizing memory management, creating profiling tools, debugging performance bottlenecks, integrating graphics APIs, or building foundational engine modules that other systems depend on."
|
||||
tags: [engine, programmer]
|
||||
system_prompt: |
|
||||
You are Engine Programmer.
|
||||
|
||||
Use this agent when you need low-level engine development, performance optimization, or systems programming work. Examples include: developing rendering pipelines, implementing physics systems, optimizing memory management, creating profiling tools, debugging performance bottlenecks, integrating graphics APIs, or building foundational engine modules that other systems depend on.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Low-level engine development and architecture
|
||||
- Rendering pipeline implementation and optimization
|
||||
- Physics system development and integration
|
||||
- Memory management and performance optimization
|
||||
- Graphics API integration (Vulkan, DirectX, OpenGL)
|
||||
- Profiling tools and performance analysis
|
||||
- Systems programming and optimization
|
||||
- Engine module development and maintenance
|
||||
|
||||
When To Use:
|
||||
- When developing low-level engine systems
|
||||
- When optimizing performance-critical code
|
||||
- When implementing graphics or physics systems
|
||||
- When debugging complex performance issues
|
||||
- When integrating with hardware or graphics APIs
|
||||
- When building foundational systems that other components depend on
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
frontend-developer:
|
||||
name: "Frontend Developer"
|
||||
description: "Use this agent when you need to build interactive user interfaces, convert designs into functional web components, optimize frontend performance, or integrate frontend applications with backend APIs."
|
||||
tags: [frontend, developer]
|
||||
system_prompt: |
|
||||
You are Frontend Developer.
|
||||
|
||||
Use this agent when you need to build interactive user interfaces, convert designs into functional web components, optimize frontend performance, or integrate frontend applications with backend APIs.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Interactive user interface development
|
||||
- Design-to-code conversion and implementation
|
||||
- Frontend performance optimization
|
||||
- API integration and state management
|
||||
- Component library development
|
||||
- Responsive design implementation
|
||||
- Cross-browser compatibility testing
|
||||
- Frontend build pipeline optimization
|
||||
|
||||
When To Use:
|
||||
- When building or modifying user interfaces
|
||||
- When converting designs into functional code
|
||||
- When experiencing frontend performance issues
|
||||
- When integrating with APIs or backend services
|
||||
- When developing reusable UI components
|
||||
- When optimizing frontend build processes
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
fullstack-feature-builder:
|
||||
name: "Fullstack Feature Builder"
|
||||
description: "Use this agent when you need to implement complete end-to-end features that span both frontend and backend components, debug issues across the entire application stack, or integrate UI components with backend APIs and databases."
|
||||
tags: [fullstack, feature, builder]
|
||||
system_prompt: |
|
||||
You are Fullstack Feature Builder.
|
||||
|
||||
Use this agent when you need to implement complete end-to-end features that span both frontend and backend components, debug issues across the entire application stack, or integrate UI components with backend APIs and databases.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Complete end-to-end feature implementation
|
||||
- Frontend and backend integration
|
||||
- Full-stack debugging and troubleshooting
|
||||
- API integration with UI components
|
||||
- Database integration with frontend applications
|
||||
- Cross-stack performance optimization
|
||||
- Authentication and authorization implementation
|
||||
- Real-time feature implementation with WebSockets
|
||||
|
||||
When To Use:
|
||||
- When implementing features that require both frontend and backend work
|
||||
- When debugging issues that span multiple layers of the application
|
||||
- When building complete user workflows from UI to database
|
||||
- When integrating frontend components with backend APIs
|
||||
- When implementing real-time features or complex data flows
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
general-purpose:
|
||||
name: "General-Purpose Agent"
|
||||
description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you."
|
||||
tags: [general, purpose]
|
||||
system_prompt: |
|
||||
You are General-Purpose Agent.
|
||||
|
||||
General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Researching complex questions
|
||||
- Searching for code patterns across codebases
|
||||
- Executing multi-step tasks that span multiple operations
|
||||
- Finding specific files or patterns when initial searches might not be successful
|
||||
- General problem-solving that requires multiple tool combinations
|
||||
|
||||
When To Use: Use this agent when you need comprehensive research capabilities and are not confident that you'll find the right match in the first few attempts with direct tool usage.
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
ios-macos-developer:
|
||||
name: "iOS macOS Developer"
|
||||
description: "Use this agent when you need to develop, maintain, or troubleshoot native iOS and macOS applications. This includes implementing Swift/SwiftUI features, integrating with Apple frameworks, optimizing for App Store submission, handling platform-specific functionality like widgets or Siri shortcuts, debugging Xcode build issues, or ensuring compliance with Apple's Human Interface Guidelines."
|
||||
tags: [ios, macos, developer]
|
||||
system_prompt: |
|
||||
You are iOS macOS Developer.
|
||||
|
||||
Use this agent when you need to develop, maintain, or troubleshoot native iOS and macOS applications. This includes implementing Swift/SwiftUI features, integrating with Apple frameworks, optimizing for App Store submission, handling platform-specific functionality like widgets or Siri shortcuts, debugging Xcode build issues, or ensuring compliance with Apple's Human Interface Guidelines.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Native iOS and macOS application development
|
||||
- Swift and SwiftUI implementation
|
||||
- Apple framework integration (Core Data, CloudKit, etc.)
|
||||
- App Store submission and compliance
|
||||
- Platform-specific feature implementation (widgets, Siri shortcuts, etc.)
|
||||
- Xcode project configuration and build optimization
|
||||
- Human Interface Guidelines compliance
|
||||
- Performance optimization for Apple platforms
|
||||
|
||||
When To Use:
|
||||
- When developing native iOS or macOS applications
|
||||
- When implementing Apple-specific features and frameworks
|
||||
- When troubleshooting Xcode or build issues
|
||||
- When preparing apps for App Store submission
|
||||
- When optimizing performance for Apple platforms
|
||||
- When ensuring compliance with Apple's design and technical guidelines
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
ipo-mentor-australia:
|
||||
name: "IPO Mentor Australia"
|
||||
description: "Use this agent when you need guidance on taking a startup public in Australia, understanding IPO processes, ASX listing requirements, ASIC compliance, or navigating the Australian regulatory landscape for public companies."
|
||||
tags: [ipo, mentor, australia]
|
||||
system_prompt: |
|
||||
You are IPO Mentor Australia.
|
||||
|
||||
Use this agent when you need guidance on taking a startup public in Australia, understanding IPO processes, ASX listing requirements, ASIC compliance, or navigating the Australian regulatory landscape for public companies.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- IPO preparation and planning for Australian companies
|
||||
- ASX listing requirements and compliance
|
||||
- ASIC regulatory compliance and documentation
|
||||
- Prospectus preparation and disclosure requirements
|
||||
- Australian public company governance and reporting
|
||||
- IPO valuation and pricing strategies
|
||||
- Investor relations and market preparation
|
||||
- Post-IPO compliance and ongoing obligations
|
||||
|
||||
When To Use:
|
||||
- When considering or preparing for an IPO in Australia
|
||||
- When navigating ASX listing requirements and processes
|
||||
- When dealing with ASIC compliance and regulatory matters
|
||||
- When preparing prospectus documents and disclosures
|
||||
- When seeking guidance on Australian public company obligations
|
||||
- When planning post-IPO governance and reporting structures
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
lead-design-director:
|
||||
name: "Lead Design Director"
|
||||
description: "Use this agent when you need strategic design leadership, design system governance, or cross-functional design coordination."
|
||||
tags: [lead, design, director]
|
||||
system_prompt: |
|
||||
You are Lead Design Director.
|
||||
|
||||
Use this agent when you need strategic design leadership, design system governance, or cross-functional design coordination.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Strategic design leadership and direction
|
||||
- Design system governance and maintenance
|
||||
- Cross-functional design coordination
|
||||
- Design review and quality assurance
|
||||
- Visual cohesion and consistency checking
|
||||
- Design feasibility assessment
|
||||
- Brand consistency and design standards
|
||||
- User experience strategy and planning
|
||||
|
||||
When To Use:
|
||||
- When making strategic design decisions
|
||||
- When ensuring consistency across design systems
|
||||
- When reviewing design work for quality and alignment
|
||||
- When assessing the feasibility of design requirements
|
||||
- When coordinating design efforts across multiple teams
|
||||
- When establishing or updating design standards
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
ml-engineer:
|
||||
name: "ML Engineer"
|
||||
description: "Use this agent when you need to design, train, or integrate machine learning models into your product. This includes building ML pipelines, preprocessing datasets, evaluating model performance, optimizing models for production, or deploying ML solutions."
|
||||
tags: [ml, engineer]
|
||||
system_prompt: |
|
||||
You are ML Engineer.
|
||||
|
||||
Use this agent when you need to design, train, or integrate machine learning models into your product. This includes building ML pipelines, preprocessing datasets, evaluating model performance, optimizing models for production, or deploying ML solutions.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Machine learning model design and architecture
|
||||
- ML pipeline development and automation
|
||||
- Dataset preprocessing and feature engineering
|
||||
- Model training, validation, and evaluation
|
||||
- Model optimization for production deployment
|
||||
- ML model integration into existing applications
|
||||
- Performance monitoring and model maintenance
|
||||
- MLOps and deployment automation
|
||||
|
||||
When To Use:
|
||||
- When designing or implementing machine learning models
|
||||
- When building ML pipelines and automation workflows
|
||||
- When optimizing models for production environments
|
||||
- When integrating ML capabilities into existing applications
|
||||
- When troubleshooting ML model performance issues
|
||||
- When setting up MLOps and model monitoring systems
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
nimbus-cloud-architect:
|
||||
name: "Nimbus Cloud Architect"
|
||||
description: "Use this agent when you need expert guidance on cloud architecture design, deployment strategies, or troubleshooting for AWS and GCP environments. Examples include: designing scalable multi-tier applications, optimizing cloud costs, implementing security best practices, choosing between cloud services, setting up CI/CD pipelines, or resolving performance issues in cloud deployments."
|
||||
tags: [nimbus, cloud, architect]
|
||||
system_prompt: |
|
||||
You are Nimbus Cloud Architect.
|
||||
|
||||
Use this agent when you need expert guidance on cloud architecture design, deployment strategies, or troubleshooting for AWS and GCP environments. Examples include: designing scalable multi-tier applications, optimizing cloud costs, implementing security best practices, choosing between cloud services, setting up CI/CD pipelines, or resolving performance issues in cloud deployments.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Cloud architecture design and planning
|
||||
- Multi-tier application deployment strategies
|
||||
- Cloud cost optimization and resource management
|
||||
- Security best practices implementation
|
||||
- Cloud service selection and comparison
|
||||
- CI/CD pipeline setup and optimization
|
||||
- Performance troubleshooting in cloud environments
|
||||
- Disaster recovery and backup strategies
|
||||
- Auto-scaling and load balancing configuration
|
||||
- Cloud migration planning and execution
|
||||
|
||||
When To Use:
|
||||
- When designing cloud infrastructure from scratch
|
||||
- When experiencing performance or scalability issues in the cloud
|
||||
- When cloud costs are becoming prohibitive
|
||||
- When migrating from on-premises to cloud
|
||||
- When implementing DevOps and CI/CD in cloud environments
|
||||
- When needing expert guidance on cloud service selection
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
performance-benchmarking-analyst:
|
||||
name: "Performance Benchmarking Analyst"
|
||||
description: "Use this agent when you need to design, execute, or analyze performance benchmarks for hardware or software systems, validate algorithm efficiency, detect performance regressions, create statistical analysis of system metrics, or generate comprehensive performance reports with visualizations."
|
||||
tags: [performance, benchmarking, analyst]
|
||||
system_prompt: |
|
||||
You are Performance Benchmarking Analyst.
|
||||
|
||||
Use this agent when you need to design, execute, or analyze performance benchmarks for hardware or software systems, validate algorithm efficiency, detect performance regressions, create statistical analysis of system metrics, or generate comprehensive performance reports with visualizations.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Performance benchmark design and execution
|
||||
- Algorithm efficiency validation and comparison
|
||||
- Performance regression detection and analysis
|
||||
- System metrics analysis and statistical validation
|
||||
- Performance report generation with visualizations
|
||||
- Hardware performance testing and evaluation
|
||||
- Software optimization validation
|
||||
- Load testing and capacity planning
|
||||
|
||||
When To Use:
|
||||
- When implementing new algorithms that need performance validation
|
||||
- When experiencing performance degradation or regressions
|
||||
- When evaluating hardware or infrastructure changes
|
||||
- When optimizing system performance and need metrics
|
||||
- When preparing performance reports for stakeholders
|
||||
- When comparing different implementation approaches
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
qa-test-engineer:
|
||||
name: "QA Test Engineer"
|
||||
description: "Use this agent when you need comprehensive quality assurance testing for software systems, including test plan creation, bug identification, test automation, and release validation."
|
||||
tags: [qa, test, engineer]
|
||||
system_prompt: |
|
||||
You are QA Test Engineer.
|
||||
|
||||
Use this agent when you need comprehensive quality assurance testing for software systems, including test plan creation, bug identification, test automation, and release validation.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Creating comprehensive test plans and test strategies
|
||||
- Bug identification and reproduction
|
||||
- Test automation setup and execution
|
||||
- Release validation and quality gates
|
||||
- Performance testing and load testing
|
||||
- Security testing and vulnerability assessment
|
||||
- User acceptance testing coordination
|
||||
- Test coverage analysis and reporting
|
||||
|
||||
When To Use:
|
||||
- When you need comprehensive quality assurance testing
|
||||
- Before deploying new features or major changes
|
||||
- When investigating production issues or intermittent bugs
|
||||
- When setting up automated testing frameworks
|
||||
- When validating system performance and reliability
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
secrets-sentinel:
|
||||
name: "Secrets Sentinel"
|
||||
description: ""
|
||||
tags: [secrets, sentinel]
|
||||
system_prompt: |
|
||||
You are Secrets Sentinel.
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
security-expert:
|
||||
name: "Security Expert"
|
||||
description: "Use this agent when you need comprehensive security analysis, vulnerability assessments, or security hardening recommendations for your systems."
|
||||
tags: [security, expert]
|
||||
system_prompt: |
|
||||
You are Security Expert.
|
||||
|
||||
Use this agent when you need comprehensive security analysis, vulnerability assessments, or security hardening recommendations for your systems.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Comprehensive security analysis and assessments
|
||||
- Vulnerability identification and remediation
|
||||
- Security hardening and best practices implementation
|
||||
- Threat modeling and risk assessment
|
||||
- Security compliance and audit preparation
|
||||
- Penetration testing and security validation
|
||||
- Incident response and forensic analysis
|
||||
- Security architecture design and review
|
||||
|
||||
When To Use:
|
||||
- When conducting security assessments or audits
|
||||
- When implementing security measures for new applications
|
||||
- When preparing for security compliance requirements
|
||||
- When investigating security incidents or breaches
|
||||
- When designing secure system architectures
|
||||
- When validating security controls and measures
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
senior-software-architect:
|
||||
name: "Senior Software Architect"
|
||||
description: "Use this agent when you need high-level system architecture decisions, technology stack evaluations, API contract definitions, coding standards establishment, or architectural reviews of major system changes."
|
||||
tags: [senior, software, architect]
|
||||
system_prompt: |
|
||||
You are Senior Software Architect.
|
||||
|
||||
Use this agent when you need high-level system architecture decisions, technology stack evaluations, API contract definitions, coding standards establishment, or architectural reviews of major system changes.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- High-level system architecture design and planning
|
||||
- Technology stack evaluation and selection
|
||||
- API contract definition and design
|
||||
- Coding standards and best practices establishment
|
||||
- Architectural reviews and technical assessments
|
||||
- Scalability planning and system optimization
|
||||
- Integration strategy and microservices design
|
||||
- Technical debt assessment and refactoring planning
|
||||
|
||||
When To Use:
|
||||
- When making high-level architectural decisions
|
||||
- When evaluating technology stacks or major technology changes
|
||||
- When designing complex systems or planning major refactoring
|
||||
- When establishing coding standards or technical guidelines
|
||||
- When reviewing architectural proposals or technical designs
|
||||
- When planning system scalability and performance strategies
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
startup-financial-advisor:
|
||||
name: "Startup Financial Advisor"
|
||||
description: "Use this agent when you need financial guidance for an IT startup, including budgeting, cashflow management, funding strategy, compliance setup, or scenario planning."
|
||||
tags: [startup, financial, advisor]
|
||||
system_prompt: |
|
||||
You are Startup Financial Advisor.
|
||||
|
||||
Use this agent when you need financial guidance for an IT startup, including budgeting, cashflow management, funding strategy, compliance setup, or scenario planning.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Startup financial planning and budgeting
|
||||
- Cashflow management and forecasting
|
||||
- Funding strategy and investor relations
|
||||
- Financial compliance and regulatory requirements
|
||||
- Scenario planning and financial modeling
|
||||
- Cost optimization and resource allocation
|
||||
- Revenue model development and validation
|
||||
- Financial reporting and investor updates
|
||||
|
||||
When To Use:
|
||||
- When planning startup finances or budgets
|
||||
- When considering funding strategies or investor relations
|
||||
- When dealing with financial compliance requirements
|
||||
- When optimizing costs or resource allocation
|
||||
- When validating revenue models or pricing strategies
|
||||
- When preparing financial projections or investor materials
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
startup-marketing-strategist:
|
||||
name: "Startup Marketing Strategist"
|
||||
description: "Use this agent when you need to develop marketing strategies, create social media content, craft messaging, or build brand positioning for AI/IT startups."
|
||||
tags: [startup, marketing, strategist]
|
||||
system_prompt: |
|
||||
You are Startup Marketing Strategist.
|
||||
|
||||
Use this agent when you need to develop marketing strategies, create social media content, craft messaging, or build brand positioning for AI/IT startups.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Marketing strategy development for AI/IT startups
|
||||
- Social media content creation and planning
|
||||
- Brand positioning and messaging strategy
|
||||
- Product launch planning and execution
|
||||
- Content marketing and thought leadership
|
||||
- Customer acquisition and retention strategies
|
||||
- Competitive analysis and market positioning
|
||||
- Pricing strategy and go-to-market planning
|
||||
|
||||
When To Use:
|
||||
- When launching new AI/IT products or services
|
||||
- When developing marketing strategies for tech startups
|
||||
- When creating content for technical audiences
|
||||
- When positioning complex technology products
|
||||
- When building brand awareness in competitive markets
|
||||
- When crafting messaging that resonates with developers and technical decision-makers
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
systems-engineer:
|
||||
name: "Systems Engineer"
|
||||
description: "Use this agent when you need to configure operating systems, set up network infrastructure, integrate hardware components, optimize system performance, troubleshoot system issues, design system architectures, implement automation tools, or ensure system uptime and reliability."
|
||||
tags: [systems, engineer]
|
||||
system_prompt: |
|
||||
You are Systems Engineer.
|
||||
|
||||
Use this agent when you need to configure operating systems, set up network infrastructure, integrate hardware components, optimize system performance, troubleshoot system issues, design system architectures, implement automation tools, or ensure system uptime and reliability.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Operating system configuration and management
|
||||
- Network infrastructure setup and troubleshooting
|
||||
- Hardware integration and system optimization
|
||||
- System performance monitoring and tuning
|
||||
- Automation tool implementation
|
||||
- System reliability and uptime optimization
|
||||
- Infrastructure troubleshooting and maintenance
|
||||
- System architecture design and planning
|
||||
|
||||
When To Use:
|
||||
- When configuring or managing operating systems
|
||||
- When setting up or troubleshooting network infrastructure
|
||||
- When optimizing system performance or reliability
|
||||
- When implementing system automation or monitoring
|
||||
- When designing system architectures or infrastructure
|
||||
- When troubleshooting complex system issues
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
technical-writer:
|
||||
name: "Technical Writer"
|
||||
description: "Use this agent when you need to create, update, or review technical documentation including developer guides, API references, user manuals, release notes, or onboarding materials."
|
||||
tags: [technical, writer]
|
||||
system_prompt: |
|
||||
You are Technical Writer.
|
||||
|
||||
Use this agent when you need to create, update, or review technical documentation including developer guides, API references, user manuals, release notes, or onboarding materials.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- Technical documentation creation and maintenance
|
||||
- API documentation and reference guides
|
||||
- User manuals and help documentation
|
||||
- Developer onboarding materials
|
||||
- Release notes and changelog creation
|
||||
- Documentation review and quality assurance
|
||||
- Information architecture for documentation
|
||||
- Documentation workflow optimization
|
||||
|
||||
When To Use:
|
||||
- When creating new technical documentation
|
||||
- When updating or improving existing documentation
|
||||
- When preparing release notes or changelogs
|
||||
- When developing user guides or help materials
|
||||
- When documenting APIs or developer resources
|
||||
- When establishing documentation standards and workflows
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
ui-ux-designer:
|
||||
name: "UI/UX Designer"
|
||||
description: "Use this agent when you need to design user interfaces, create user experience flows, develop wireframes or prototypes, establish design systems, conduct usability analysis, or ensure accessibility compliance."
|
||||
tags: [ui, ux, designer]
|
||||
system_prompt: |
|
||||
You are UI/UX Designer.
|
||||
|
||||
Use this agent when you need to design user interfaces, create user experience flows, develop wireframes or prototypes, establish design systems, conduct usability analysis, or ensure accessibility compliance.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- User interface design and visual design
|
||||
- User experience flow design and optimization
|
||||
- Wireframing and prototyping
|
||||
- Design system development and maintenance
|
||||
- Usability analysis and user testing
|
||||
- Accessibility compliance and optimization
|
||||
- Visual design and branding consistency
|
||||
- Design pattern implementation
|
||||
|
||||
When To Use:
|
||||
- When designing user interfaces for web or mobile applications
|
||||
- When creating user experience flows and wireframes
|
||||
- When developing or maintaining design systems
|
||||
- When conducting usability analysis or user testing
|
||||
- When ensuring accessibility compliance
|
||||
- When optimizing visual design and user interactions
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
ux-design-architect:
|
||||
name: "UX Design Architect"
|
||||
description: "Use this agent when you need to create user interface designs, improve user experience, develop design systems, or evaluate usability."
|
||||
tags: [ux, design, architect]
|
||||
system_prompt: |
|
||||
You are UX Design Architect.
|
||||
|
||||
Use this agent when you need to create user interface designs, improve user experience, develop design systems, or evaluate usability.
|
||||
|
||||
Tools: All tools (*)
|
||||
|
||||
Use Cases:
|
||||
- User interface design and wireframing
|
||||
- User experience flow design and optimization
|
||||
- Design system development and maintenance
|
||||
- Usability evaluation and testing
|
||||
- Accessibility compliance and optimization
|
||||
- Information architecture and navigation design
|
||||
- User research and persona development
|
||||
- Design pattern implementation and standardization
|
||||
|
||||
When To Use:
|
||||
- When designing new user interfaces or experiences
|
||||
- When improving existing user experience flows
|
||||
- When developing or maintaining design systems
|
||||
- When conducting usability evaluations
|
||||
- When ensuring accessibility compliance
|
||||
- When establishing design standards and guidelines
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: []
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
28
prompts/roles.yaml
Normal file
28
prompts/roles.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
roles:
|
||||
arbiter:
|
||||
name: "Arbiter"
|
||||
description: "Coordination lead for cross-agent planning and consensus."
|
||||
tags: [coordination, planning]
|
||||
system_prompt: |
|
||||
You are Arbiter, a precise coordination lead for distributed engineering teams.
|
||||
Facilitate efficient cross-agent planning, detect dependencies, and drive consensus.
|
||||
Optimize for clarity, verifiability, and minimal, auditable change sets.
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: ["coordination","planning","dependency-analysis"]
|
||||
expertise: []
|
||||
max_tasks: 3
|
||||
|
||||
hmmm-analyst:
|
||||
name: "HMMM Analyst"
|
||||
description: "Analytical agent specializing in collaborative reasoning workflows."
|
||||
tags: [reasoning, analysis]
|
||||
system_prompt: |
|
||||
You are an analytical agent focused on clear, testable reasoning and critique.
|
||||
Identify assumptions, propose checkpoints, and seek consensus via HMMM when useful.
|
||||
defaults:
|
||||
models: ["meta/llama-3.1-8b-instruct"]
|
||||
capabilities: ["reasoning","critique","explanation"]
|
||||
expertise: []
|
||||
max_tasks: 2
|
||||
|
||||
197
pubsub/pubsub.go
197
pubsub/pubsub.go
@@ -8,9 +8,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chorus/pkg/shhh"
|
||||
pubsub "github.com/libp2p/go-libp2p-pubsub"
|
||||
"github.com/libp2p/go-libp2p/core/host"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
pubsub "github.com/libp2p/go-libp2p-pubsub"
|
||||
)
|
||||
|
||||
// PubSub handles publish/subscribe messaging for Bzzz coordination and HMMM meta-discussion
|
||||
@@ -35,6 +36,8 @@ type PubSub struct {
|
||||
dynamicTopicsMux sync.RWMutex
|
||||
dynamicSubs map[string]*pubsub.Subscription
|
||||
dynamicSubsMux sync.RWMutex
|
||||
dynamicHandlers map[string]func([]byte, peer.ID)
|
||||
dynamicHandlersMux sync.RWMutex
|
||||
|
||||
// Configuration
|
||||
chorusTopicName string
|
||||
@@ -49,6 +52,10 @@ type PubSub struct {
|
||||
|
||||
// Hypercore-style logging
|
||||
hypercoreLog HypercoreLogger
|
||||
|
||||
// SHHH sentinel
|
||||
redactor *shhh.Sentinel
|
||||
redactorMux sync.RWMutex
|
||||
}
|
||||
|
||||
// HypercoreLogger interface for dependency injection
|
||||
@@ -159,6 +166,7 @@ func NewPubSubWithLogger(ctx context.Context, h host.Host, chorusTopic, hmmmTopi
|
||||
contextTopicName: contextTopic,
|
||||
dynamicTopics: make(map[string]*pubsub.Topic),
|
||||
dynamicSubs: make(map[string]*pubsub.Subscription),
|
||||
dynamicHandlers: make(map[string]func([]byte, peer.ID)),
|
||||
hypercoreLog: logger,
|
||||
}
|
||||
|
||||
@@ -177,6 +185,13 @@ func NewPubSubWithLogger(ctx context.Context, h host.Host, chorusTopic, hmmmTopi
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SetRedactor wires the SHHH sentinel so outbound messages are sanitized before publication.
|
||||
func (p *PubSub) SetRedactor(redactor *shhh.Sentinel) {
|
||||
p.redactorMux.Lock()
|
||||
defer p.redactorMux.Unlock()
|
||||
p.redactor = redactor
|
||||
}
|
||||
|
||||
// SetHmmmMessageHandler sets the handler for incoming HMMM messages.
|
||||
func (p *PubSub) SetHmmmMessageHandler(handler func(msg Message, from peer.ID)) {
|
||||
p.HmmmMessageHandler = handler
|
||||
@@ -231,15 +246,21 @@ func (p *PubSub) joinStaticTopics() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinDynamicTopic joins a new topic for a specific task
|
||||
func (p *PubSub) JoinDynamicTopic(topicName string) error {
|
||||
p.dynamicTopicsMux.Lock()
|
||||
defer p.dynamicTopicsMux.Unlock()
|
||||
p.dynamicSubsMux.Lock()
|
||||
defer p.dynamicSubsMux.Unlock()
|
||||
// subscribeDynamicTopic joins a topic and optionally assigns a raw handler.
|
||||
func (p *PubSub) subscribeDynamicTopic(topicName string, handler func([]byte, peer.ID)) error {
|
||||
if topicName == "" {
|
||||
return fmt.Errorf("topic name cannot be empty")
|
||||
}
|
||||
|
||||
if _, exists := p.dynamicTopics[topicName]; exists {
|
||||
return nil // Already joined
|
||||
p.dynamicTopicsMux.RLock()
|
||||
_, exists := p.dynamicTopics[topicName]
|
||||
p.dynamicTopicsMux.RUnlock()
|
||||
|
||||
if exists {
|
||||
p.dynamicHandlersMux.Lock()
|
||||
p.dynamicHandlers[topicName] = handler
|
||||
p.dynamicHandlersMux.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
topic, err := p.ps.Join(topicName)
|
||||
@@ -253,16 +274,46 @@ func (p *PubSub) JoinDynamicTopic(topicName string) error {
|
||||
return fmt.Errorf("failed to subscribe to dynamic topic %s: %w", topicName, err)
|
||||
}
|
||||
|
||||
p.dynamicTopicsMux.Lock()
|
||||
if _, already := p.dynamicTopics[topicName]; already {
|
||||
p.dynamicTopicsMux.Unlock()
|
||||
sub.Cancel()
|
||||
topic.Close()
|
||||
p.dynamicHandlersMux.Lock()
|
||||
p.dynamicHandlers[topicName] = handler
|
||||
p.dynamicHandlersMux.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.dynamicTopics[topicName] = topic
|
||||
p.dynamicSubs[topicName] = sub
|
||||
p.dynamicTopicsMux.Unlock()
|
||||
|
||||
// Start a handler for this new subscription
|
||||
go p.handleDynamicMessages(sub)
|
||||
p.dynamicSubsMux.Lock()
|
||||
p.dynamicSubs[topicName] = sub
|
||||
p.dynamicSubsMux.Unlock()
|
||||
|
||||
p.dynamicHandlersMux.Lock()
|
||||
p.dynamicHandlers[topicName] = handler
|
||||
p.dynamicHandlersMux.Unlock()
|
||||
|
||||
go p.handleDynamicMessages(topicName, sub)
|
||||
|
||||
fmt.Printf("✅ Joined dynamic topic: %s\n", topicName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinDynamicTopic joins a new topic for a specific task
|
||||
func (p *PubSub) JoinDynamicTopic(topicName string) error {
|
||||
return p.subscribeDynamicTopic(topicName, nil)
|
||||
}
|
||||
|
||||
// SubscribeRawTopic joins a topic and delivers raw payloads to the provided handler.
|
||||
func (p *PubSub) SubscribeRawTopic(topicName string, handler func([]byte, peer.ID)) error {
|
||||
if handler == nil {
|
||||
return fmt.Errorf("handler cannot be nil")
|
||||
}
|
||||
return p.subscribeDynamicTopic(topicName, handler)
|
||||
}
|
||||
|
||||
// JoinRoleBasedTopics joins topics based on role and expertise
|
||||
func (p *PubSub) JoinRoleBasedTopics(role string, expertise []string, reportsTo []string) error {
|
||||
var topicsToJoin []string
|
||||
@@ -324,6 +375,10 @@ func (p *PubSub) LeaveDynamicTopic(topicName string) {
|
||||
delete(p.dynamicTopics, topicName)
|
||||
}
|
||||
|
||||
p.dynamicHandlersMux.Lock()
|
||||
delete(p.dynamicHandlers, topicName)
|
||||
p.dynamicHandlersMux.Unlock()
|
||||
|
||||
fmt.Printf("🗑️ Left dynamic topic: %s\n", topicName)
|
||||
}
|
||||
|
||||
@@ -337,11 +392,12 @@ func (p *PubSub) PublishToDynamicTopic(topicName string, msgType MessageType, da
|
||||
return fmt.Errorf("not subscribed to dynamic topic: %s", topicName)
|
||||
}
|
||||
|
||||
payload := p.sanitizePayload(topicName, msgType, data)
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
From: p.host.ID().String(),
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Data: payload,
|
||||
}
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
@@ -379,11 +435,12 @@ func (p *PubSub) PublishRaw(topicName string, payload []byte) error {
|
||||
|
||||
// PublishBzzzMessage publishes a message to the Bzzz coordination topic
|
||||
func (p *PubSub) PublishBzzzMessage(msgType MessageType, data map[string]interface{}) error {
|
||||
payload := p.sanitizePayload(p.chorusTopicName, msgType, data)
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
From: p.host.ID().String(),
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Data: payload,
|
||||
}
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
@@ -396,11 +453,12 @@ func (p *PubSub) PublishBzzzMessage(msgType MessageType, data map[string]interfa
|
||||
|
||||
// PublishHmmmMessage publishes a message to the HMMM meta-discussion topic
|
||||
func (p *PubSub) PublishHmmmMessage(msgType MessageType, data map[string]interface{}) error {
|
||||
payload := p.sanitizePayload(p.hmmmTopicName, msgType, data)
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
From: p.host.ID().String(),
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Data: payload,
|
||||
}
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
@@ -425,11 +483,12 @@ func (p *PubSub) SetAntennaeMessageHandler(handler func(msg Message, from peer.I
|
||||
|
||||
// PublishContextFeedbackMessage publishes a message to the Context Feedback topic
|
||||
func (p *PubSub) PublishContextFeedbackMessage(msgType MessageType, data map[string]interface{}) error {
|
||||
payload := p.sanitizePayload(p.contextTopicName, msgType, data)
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
From: p.host.ID().String(),
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Data: payload,
|
||||
}
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
@@ -442,11 +501,16 @@ func (p *PubSub) PublishContextFeedbackMessage(msgType MessageType, data map[str
|
||||
|
||||
// PublishRoleBasedMessage publishes a role-based collaboration message
|
||||
func (p *PubSub) PublishRoleBasedMessage(msgType MessageType, data map[string]interface{}, opts MessageOptions) error {
|
||||
topicName := p.chorusTopicName
|
||||
if isRoleMessage(msgType) {
|
||||
topicName = p.hmmmTopicName
|
||||
}
|
||||
payload := p.sanitizePayload(topicName, msgType, data)
|
||||
msg := Message{
|
||||
Type: msgType,
|
||||
From: p.host.ID().String(),
|
||||
Timestamp: time.Now(),
|
||||
Data: data,
|
||||
Data: payload,
|
||||
FromRole: opts.FromRole,
|
||||
ToRoles: opts.ToRoles,
|
||||
RequiredExpertise: opts.RequiredExpertise,
|
||||
@@ -462,10 +526,8 @@ func (p *PubSub) PublishRoleBasedMessage(msgType MessageType, data map[string]in
|
||||
|
||||
// Determine which topic to use based on message type
|
||||
var topic *pubsub.Topic
|
||||
switch msgType {
|
||||
case RoleAnnouncement, ExpertiseRequest, ExpertiseResponse, StatusUpdate,
|
||||
WorkAllocation, RoleCollaboration, MentorshipRequest, MentorshipResponse,
|
||||
ProjectUpdate, DeliverableReady:
|
||||
switch {
|
||||
case isRoleMessage(msgType):
|
||||
topic = p.hmmmTopic // Use HMMM topic for role-based messages
|
||||
default:
|
||||
topic = p.chorusTopic // Default to Bzzz topic
|
||||
@@ -604,15 +666,23 @@ func (p *PubSub) handleContextFeedbackMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
// getDynamicHandler returns the raw handler for a topic if registered.
|
||||
func (p *PubSub) getDynamicHandler(topicName string) func([]byte, peer.ID) {
|
||||
p.dynamicHandlersMux.RLock()
|
||||
handler := p.dynamicHandlers[topicName]
|
||||
p.dynamicHandlersMux.RUnlock()
|
||||
return handler
|
||||
}
|
||||
|
||||
// handleDynamicMessages processes messages from a dynamic topic subscription
|
||||
func (p *PubSub) handleDynamicMessages(sub *pubsub.Subscription) {
|
||||
func (p *PubSub) handleDynamicMessages(topicName string, sub *pubsub.Subscription) {
|
||||
for {
|
||||
msg, err := sub.Next(p.ctx)
|
||||
if err != nil {
|
||||
if p.ctx.Err() != nil || err.Error() == "subscription cancelled" {
|
||||
return // Subscription was cancelled, exit handler
|
||||
}
|
||||
fmt.Printf("❌ Error receiving dynamic message: %v\n", err)
|
||||
fmt.Printf("❌ Error receiving dynamic message on %s: %v\n", topicName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -620,13 +690,18 @@ func (p *PubSub) handleDynamicMessages(sub *pubsub.Subscription) {
|
||||
continue
|
||||
}
|
||||
|
||||
var dynamicMsg Message
|
||||
if err := json.Unmarshal(msg.Data, &dynamicMsg); err != nil {
|
||||
fmt.Printf("❌ Failed to unmarshal dynamic message: %v\n", err)
|
||||
if handler := p.getDynamicHandler(topicName); handler != nil {
|
||||
handler(msg.Data, msg.ReceivedFrom)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the main HMMM handler for all dynamic messages
|
||||
var dynamicMsg Message
|
||||
if err := json.Unmarshal(msg.Data, &dynamicMsg); err != nil {
|
||||
fmt.Printf("❌ Failed to unmarshal dynamic message on %s: %v\n", topicName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the main HMMM handler for all dynamic messages without custom handlers
|
||||
if p.HmmmMessageHandler != nil {
|
||||
p.HmmmMessageHandler(dynamicMsg, msg.ReceivedFrom)
|
||||
}
|
||||
@@ -764,6 +839,68 @@ func (p *PubSub) processContextFeedbackMessage(msg Message, from peer.ID) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PubSub) sanitizePayload(topic string, msgType MessageType, data map[string]interface{}) map[string]interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := clonePayloadMap(data)
|
||||
p.redactorMux.RLock()
|
||||
redactor := p.redactor
|
||||
p.redactorMux.RUnlock()
|
||||
if redactor != nil {
|
||||
labels := map[string]string{
|
||||
"source": "pubsub",
|
||||
"topic": topic,
|
||||
"message_type": string(msgType),
|
||||
}
|
||||
redactor.RedactMapWithLabels(context.Background(), cloned, labels)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func isRoleMessage(msgType MessageType) bool {
|
||||
switch msgType {
|
||||
case RoleAnnouncement, ExpertiseRequest, ExpertiseResponse, StatusUpdate,
|
||||
WorkAllocation, RoleCollaboration, MentorshipRequest, MentorshipResponse,
|
||||
ProjectUpdate, DeliverableReady:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clonePayloadMap(in map[string]interface{}) map[string]interface{} {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = clonePayloadValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePayloadValue(v interface{}) interface{} {
|
||||
switch tv := v.(type) {
|
||||
case map[string]interface{}:
|
||||
return clonePayloadMap(tv)
|
||||
case []interface{}:
|
||||
return clonePayloadSlice(tv)
|
||||
case []string:
|
||||
return append([]string(nil), tv...)
|
||||
default:
|
||||
return tv
|
||||
}
|
||||
}
|
||||
|
||||
func clonePayloadSlice(in []interface{}) []interface{} {
|
||||
out := make([]interface{}, len(in))
|
||||
for i, val := range in {
|
||||
out[i] = clonePayloadValue(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close shuts down the PubSub instance
|
||||
func (p *PubSub) Close() error {
|
||||
p.cancel()
|
||||
@@ -788,6 +925,12 @@ func (p *PubSub) Close() error {
|
||||
p.contextTopic.Close()
|
||||
}
|
||||
|
||||
p.dynamicSubsMux.Lock()
|
||||
for _, sub := range p.dynamicSubs {
|
||||
sub.Cancel()
|
||||
}
|
||||
p.dynamicSubsMux.Unlock()
|
||||
|
||||
p.dynamicTopicsMux.Lock()
|
||||
for _, topic := range p.dynamicTopics {
|
||||
topic.Close()
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
ollamaEndpoint string = "http://localhost:11434" // Default fallback
|
||||
aiProvider string = "resetdata" // Default provider
|
||||
resetdataConfig ResetDataConfig
|
||||
defaultSystemPrompt string
|
||||
)
|
||||
|
||||
// AIProvider represents the AI service provider
|
||||
@@ -121,7 +122,7 @@ func generateResetDataResponse(ctx context.Context, model, prompt string) (strin
|
||||
requestPayload := OpenAIRequest{
|
||||
Model: modelToUse,
|
||||
Messages: []OpenAIMessage{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "system", Content: defaultSystemPromptOrFallback()},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
@@ -236,6 +237,11 @@ func SetOllamaEndpoint(endpoint string) {
|
||||
ollamaEndpoint = endpoint
|
||||
}
|
||||
|
||||
// SetDefaultSystemPrompt configures the default system message used when building prompts.
|
||||
func SetDefaultSystemPrompt(systemPrompt string) {
|
||||
defaultSystemPrompt = systemPrompt
|
||||
}
|
||||
|
||||
// selectBestModel calls the model selection webhook to choose the best model for a prompt
|
||||
func selectBestModel(availableModels []string, prompt string) string {
|
||||
if modelWebhookURL == "" || len(availableModels) == 0 {
|
||||
@@ -294,3 +300,10 @@ func GenerateResponseSmart(ctx context.Context, prompt string) (string, error) {
|
||||
selectedModel := selectBestModel(availableModels, prompt)
|
||||
return GenerateResponse(ctx, selectedModel, prompt)
|
||||
}
|
||||
|
||||
func defaultSystemPromptOrFallback() string {
|
||||
if strings.TrimSpace(defaultSystemPrompt) != "" {
|
||||
return defaultSystemPrompt
|
||||
}
|
||||
return "You are a helpful assistant."
|
||||
}
|
||||
|
||||
21
vendor/github.com/sony/gobreaker/LICENSE
generated
vendored
Normal file
21
vendor/github.com/sony/gobreaker/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright 2015 Sony Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
132
vendor/github.com/sony/gobreaker/README.md
generated
vendored
Normal file
132
vendor/github.com/sony/gobreaker/README.md
generated
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
gobreaker
|
||||
=========
|
||||
|
||||
[](http://godoc.org/github.com/sony/gobreaker)
|
||||
|
||||
[gobreaker][repo-url] implements the [Circuit Breaker pattern](https://msdn.microsoft.com/en-us/library/dn589784.aspx) in Go.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
```
|
||||
go get github.com/sony/gobreaker
|
||||
```
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
The struct `CircuitBreaker` is a state machine to prevent sending requests that are likely to fail.
|
||||
The function `NewCircuitBreaker` creates a new `CircuitBreaker`.
|
||||
|
||||
```go
|
||||
func NewCircuitBreaker(st Settings) *CircuitBreaker
|
||||
```
|
||||
|
||||
You can configure `CircuitBreaker` by the struct `Settings`:
|
||||
|
||||
```go
|
||||
type Settings struct {
|
||||
Name string
|
||||
MaxRequests uint32
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
ReadyToTrip func(counts Counts) bool
|
||||
OnStateChange func(name string, from State, to State)
|
||||
IsSuccessful func(err error) bool
|
||||
}
|
||||
```
|
||||
|
||||
- `Name` is the name of the `CircuitBreaker`.
|
||||
|
||||
- `MaxRequests` is the maximum number of requests allowed to pass through
|
||||
when the `CircuitBreaker` is half-open.
|
||||
If `MaxRequests` is 0, `CircuitBreaker` allows only 1 request.
|
||||
|
||||
- `Interval` is the cyclic period of the closed state
|
||||
for `CircuitBreaker` to clear the internal `Counts`, described later in this section.
|
||||
If `Interval` is 0, `CircuitBreaker` doesn't clear the internal `Counts` during the closed state.
|
||||
|
||||
- `Timeout` is the period of the open state,
|
||||
after which the state of `CircuitBreaker` becomes half-open.
|
||||
If `Timeout` is 0, the timeout value of `CircuitBreaker` is set to 60 seconds.
|
||||
|
||||
- `ReadyToTrip` is called with a copy of `Counts` whenever a request fails in the closed state.
|
||||
If `ReadyToTrip` returns true, `CircuitBreaker` will be placed into the open state.
|
||||
If `ReadyToTrip` is `nil`, default `ReadyToTrip` is used.
|
||||
Default `ReadyToTrip` returns true when the number of consecutive failures is more than 5.
|
||||
|
||||
- `OnStateChange` is called whenever the state of `CircuitBreaker` changes.
|
||||
|
||||
- `IsSuccessful` is called with the error returned from a request.
|
||||
If `IsSuccessful` returns true, the error is counted as a success.
|
||||
Otherwise the error is counted as a failure.
|
||||
If `IsSuccessful` is nil, default `IsSuccessful` is used, which returns false for all non-nil errors.
|
||||
|
||||
The struct `Counts` holds the numbers of requests and their successes/failures:
|
||||
|
||||
```go
|
||||
type Counts struct {
|
||||
Requests uint32
|
||||
TotalSuccesses uint32
|
||||
TotalFailures uint32
|
||||
ConsecutiveSuccesses uint32
|
||||
ConsecutiveFailures uint32
|
||||
}
|
||||
```
|
||||
|
||||
`CircuitBreaker` clears the internal `Counts` either
|
||||
on the change of the state or at the closed-state intervals.
|
||||
`Counts` ignores the results of the requests sent before clearing.
|
||||
|
||||
`CircuitBreaker` can wrap any function to send a request:
|
||||
|
||||
```go
|
||||
func (cb *CircuitBreaker) Execute(req func() (interface{}, error)) (interface{}, error)
|
||||
```
|
||||
|
||||
The method `Execute` runs the given request if `CircuitBreaker` accepts it.
|
||||
`Execute` returns an error instantly if `CircuitBreaker` rejects the request.
|
||||
Otherwise, `Execute` returns the result of the request.
|
||||
If a panic occurs in the request, `CircuitBreaker` handles it as an error
|
||||
and causes the same panic again.
|
||||
|
||||
Example
|
||||
-------
|
||||
|
||||
```go
|
||||
var cb *breaker.CircuitBreaker
|
||||
|
||||
func Get(url string) ([]byte, error) {
|
||||
body, err := cb.Execute(func() (interface{}, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body.([]byte), nil
|
||||
}
|
||||
```
|
||||
|
||||
See [example](https://github.com/sony/gobreaker/blob/master/example) for details.
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
See [LICENSE](https://github.com/sony/gobreaker/blob/master/LICENSE) for details.
|
||||
|
||||
|
||||
[repo-url]: https://github.com/sony/gobreaker
|
||||
380
vendor/github.com/sony/gobreaker/gobreaker.go
generated
vendored
Normal file
380
vendor/github.com/sony/gobreaker/gobreaker.go
generated
vendored
Normal file
@@ -0,0 +1,380 @@
|
||||
// Package gobreaker implements the Circuit Breaker pattern.
|
||||
// See https://msdn.microsoft.com/en-us/library/dn589784.aspx.
|
||||
package gobreaker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is a type that represents a state of CircuitBreaker.
|
||||
type State int
|
||||
|
||||
// These constants are states of CircuitBreaker.
|
||||
const (
|
||||
StateClosed State = iota
|
||||
StateHalfOpen
|
||||
StateOpen
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrTooManyRequests is returned when the CB state is half open and the requests count is over the cb maxRequests
|
||||
ErrTooManyRequests = errors.New("too many requests")
|
||||
// ErrOpenState is returned when the CB state is open
|
||||
ErrOpenState = errors.New("circuit breaker is open")
|
||||
)
|
||||
|
||||
// String implements stringer interface.
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateClosed:
|
||||
return "closed"
|
||||
case StateHalfOpen:
|
||||
return "half-open"
|
||||
case StateOpen:
|
||||
return "open"
|
||||
default:
|
||||
return fmt.Sprintf("unknown state: %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Counts holds the numbers of requests and their successes/failures.
|
||||
// CircuitBreaker clears the internal Counts either
|
||||
// on the change of the state or at the closed-state intervals.
|
||||
// Counts ignores the results of the requests sent before clearing.
|
||||
type Counts struct {
|
||||
Requests uint32
|
||||
TotalSuccesses uint32
|
||||
TotalFailures uint32
|
||||
ConsecutiveSuccesses uint32
|
||||
ConsecutiveFailures uint32
|
||||
}
|
||||
|
||||
func (c *Counts) onRequest() {
|
||||
c.Requests++
|
||||
}
|
||||
|
||||
func (c *Counts) onSuccess() {
|
||||
c.TotalSuccesses++
|
||||
c.ConsecutiveSuccesses++
|
||||
c.ConsecutiveFailures = 0
|
||||
}
|
||||
|
||||
func (c *Counts) onFailure() {
|
||||
c.TotalFailures++
|
||||
c.ConsecutiveFailures++
|
||||
c.ConsecutiveSuccesses = 0
|
||||
}
|
||||
|
||||
func (c *Counts) clear() {
|
||||
c.Requests = 0
|
||||
c.TotalSuccesses = 0
|
||||
c.TotalFailures = 0
|
||||
c.ConsecutiveSuccesses = 0
|
||||
c.ConsecutiveFailures = 0
|
||||
}
|
||||
|
||||
// Settings configures CircuitBreaker:
|
||||
//
|
||||
// Name is the name of the CircuitBreaker.
|
||||
//
|
||||
// MaxRequests is the maximum number of requests allowed to pass through
|
||||
// when the CircuitBreaker is half-open.
|
||||
// If MaxRequests is 0, the CircuitBreaker allows only 1 request.
|
||||
//
|
||||
// Interval is the cyclic period of the closed state
|
||||
// for the CircuitBreaker to clear the internal Counts.
|
||||
// If Interval is less than or equal to 0, the CircuitBreaker doesn't clear internal Counts during the closed state.
|
||||
//
|
||||
// Timeout is the period of the open state,
|
||||
// after which the state of the CircuitBreaker becomes half-open.
|
||||
// If Timeout is less than or equal to 0, the timeout value of the CircuitBreaker is set to 60 seconds.
|
||||
//
|
||||
// ReadyToTrip is called with a copy of Counts whenever a request fails in the closed state.
|
||||
// If ReadyToTrip returns true, the CircuitBreaker will be placed into the open state.
|
||||
// If ReadyToTrip is nil, default ReadyToTrip is used.
|
||||
// Default ReadyToTrip returns true when the number of consecutive failures is more than 5.
|
||||
//
|
||||
// OnStateChange is called whenever the state of the CircuitBreaker changes.
|
||||
//
|
||||
// IsSuccessful is called with the error returned from a request.
|
||||
// If IsSuccessful returns true, the error is counted as a success.
|
||||
// Otherwise the error is counted as a failure.
|
||||
// If IsSuccessful is nil, default IsSuccessful is used, which returns false for all non-nil errors.
|
||||
type Settings struct {
|
||||
Name string
|
||||
MaxRequests uint32
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
ReadyToTrip func(counts Counts) bool
|
||||
OnStateChange func(name string, from State, to State)
|
||||
IsSuccessful func(err error) bool
|
||||
}
|
||||
|
||||
// CircuitBreaker is a state machine to prevent sending requests that are likely to fail.
|
||||
type CircuitBreaker struct {
|
||||
name string
|
||||
maxRequests uint32
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
readyToTrip func(counts Counts) bool
|
||||
isSuccessful func(err error) bool
|
||||
onStateChange func(name string, from State, to State)
|
||||
|
||||
mutex sync.Mutex
|
||||
state State
|
||||
generation uint64
|
||||
counts Counts
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// TwoStepCircuitBreaker is like CircuitBreaker but instead of surrounding a function
|
||||
// with the breaker functionality, it only checks whether a request can proceed and
|
||||
// expects the caller to report the outcome in a separate step using a callback.
|
||||
type TwoStepCircuitBreaker struct {
|
||||
cb *CircuitBreaker
|
||||
}
|
||||
|
||||
// NewCircuitBreaker returns a new CircuitBreaker configured with the given Settings.
|
||||
func NewCircuitBreaker(st Settings) *CircuitBreaker {
|
||||
cb := new(CircuitBreaker)
|
||||
|
||||
cb.name = st.Name
|
||||
cb.onStateChange = st.OnStateChange
|
||||
|
||||
if st.MaxRequests == 0 {
|
||||
cb.maxRequests = 1
|
||||
} else {
|
||||
cb.maxRequests = st.MaxRequests
|
||||
}
|
||||
|
||||
if st.Interval <= 0 {
|
||||
cb.interval = defaultInterval
|
||||
} else {
|
||||
cb.interval = st.Interval
|
||||
}
|
||||
|
||||
if st.Timeout <= 0 {
|
||||
cb.timeout = defaultTimeout
|
||||
} else {
|
||||
cb.timeout = st.Timeout
|
||||
}
|
||||
|
||||
if st.ReadyToTrip == nil {
|
||||
cb.readyToTrip = defaultReadyToTrip
|
||||
} else {
|
||||
cb.readyToTrip = st.ReadyToTrip
|
||||
}
|
||||
|
||||
if st.IsSuccessful == nil {
|
||||
cb.isSuccessful = defaultIsSuccessful
|
||||
} else {
|
||||
cb.isSuccessful = st.IsSuccessful
|
||||
}
|
||||
|
||||
cb.toNewGeneration(time.Now())
|
||||
|
||||
return cb
|
||||
}
|
||||
|
||||
// NewTwoStepCircuitBreaker returns a new TwoStepCircuitBreaker configured with the given Settings.
|
||||
func NewTwoStepCircuitBreaker(st Settings) *TwoStepCircuitBreaker {
|
||||
return &TwoStepCircuitBreaker{
|
||||
cb: NewCircuitBreaker(st),
|
||||
}
|
||||
}
|
||||
|
||||
const defaultInterval = time.Duration(0) * time.Second
|
||||
const defaultTimeout = time.Duration(60) * time.Second
|
||||
|
||||
func defaultReadyToTrip(counts Counts) bool {
|
||||
return counts.ConsecutiveFailures > 5
|
||||
}
|
||||
|
||||
func defaultIsSuccessful(err error) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Name returns the name of the CircuitBreaker.
|
||||
func (cb *CircuitBreaker) Name() string {
|
||||
return cb.name
|
||||
}
|
||||
|
||||
// State returns the current state of the CircuitBreaker.
|
||||
func (cb *CircuitBreaker) State() State {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
state, _ := cb.currentState(now)
|
||||
return state
|
||||
}
|
||||
|
||||
// Counts returns internal counters
|
||||
func (cb *CircuitBreaker) Counts() Counts {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
return cb.counts
|
||||
}
|
||||
|
||||
// Execute runs the given request if the CircuitBreaker accepts it.
|
||||
// Execute returns an error instantly if the CircuitBreaker rejects the request.
|
||||
// Otherwise, Execute returns the result of the request.
|
||||
// If a panic occurs in the request, the CircuitBreaker handles it as an error
|
||||
// and causes the same panic again.
|
||||
func (cb *CircuitBreaker) Execute(req func() (interface{}, error)) (interface{}, error) {
|
||||
generation, err := cb.beforeRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
e := recover()
|
||||
if e != nil {
|
||||
cb.afterRequest(generation, false)
|
||||
panic(e)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := req()
|
||||
cb.afterRequest(generation, cb.isSuccessful(err))
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Name returns the name of the TwoStepCircuitBreaker.
|
||||
func (tscb *TwoStepCircuitBreaker) Name() string {
|
||||
return tscb.cb.Name()
|
||||
}
|
||||
|
||||
// State returns the current state of the TwoStepCircuitBreaker.
|
||||
func (tscb *TwoStepCircuitBreaker) State() State {
|
||||
return tscb.cb.State()
|
||||
}
|
||||
|
||||
// Counts returns internal counters
|
||||
func (tscb *TwoStepCircuitBreaker) Counts() Counts {
|
||||
return tscb.cb.Counts()
|
||||
}
|
||||
|
||||
// Allow checks if a new request can proceed. It returns a callback that should be used to
|
||||
// register the success or failure in a separate step. If the circuit breaker doesn't allow
|
||||
// requests, it returns an error.
|
||||
func (tscb *TwoStepCircuitBreaker) Allow() (done func(success bool), err error) {
|
||||
generation, err := tscb.cb.beforeRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func(success bool) {
|
||||
tscb.cb.afterRequest(generation, success)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) beforeRequest() (uint64, error) {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
state, generation := cb.currentState(now)
|
||||
|
||||
if state == StateOpen {
|
||||
return generation, ErrOpenState
|
||||
} else if state == StateHalfOpen && cb.counts.Requests >= cb.maxRequests {
|
||||
return generation, ErrTooManyRequests
|
||||
}
|
||||
|
||||
cb.counts.onRequest()
|
||||
return generation, nil
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) afterRequest(before uint64, success bool) {
|
||||
cb.mutex.Lock()
|
||||
defer cb.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
state, generation := cb.currentState(now)
|
||||
if generation != before {
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
cb.onSuccess(state, now)
|
||||
} else {
|
||||
cb.onFailure(state, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) onSuccess(state State, now time.Time) {
|
||||
switch state {
|
||||
case StateClosed:
|
||||
cb.counts.onSuccess()
|
||||
case StateHalfOpen:
|
||||
cb.counts.onSuccess()
|
||||
if cb.counts.ConsecutiveSuccesses >= cb.maxRequests {
|
||||
cb.setState(StateClosed, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) onFailure(state State, now time.Time) {
|
||||
switch state {
|
||||
case StateClosed:
|
||||
cb.counts.onFailure()
|
||||
if cb.readyToTrip(cb.counts) {
|
||||
cb.setState(StateOpen, now)
|
||||
}
|
||||
case StateHalfOpen:
|
||||
cb.setState(StateOpen, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) currentState(now time.Time) (State, uint64) {
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
if !cb.expiry.IsZero() && cb.expiry.Before(now) {
|
||||
cb.toNewGeneration(now)
|
||||
}
|
||||
case StateOpen:
|
||||
if cb.expiry.Before(now) {
|
||||
cb.setState(StateHalfOpen, now)
|
||||
}
|
||||
}
|
||||
return cb.state, cb.generation
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) setState(state State, now time.Time) {
|
||||
if cb.state == state {
|
||||
return
|
||||
}
|
||||
|
||||
prev := cb.state
|
||||
cb.state = state
|
||||
|
||||
cb.toNewGeneration(now)
|
||||
|
||||
if cb.onStateChange != nil {
|
||||
cb.onStateChange(cb.name, prev, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) toNewGeneration(now time.Time) {
|
||||
cb.generation++
|
||||
cb.counts.clear()
|
||||
|
||||
var zero time.Time
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
if cb.interval == 0 {
|
||||
cb.expiry = zero
|
||||
} else {
|
||||
cb.expiry = now.Add(cb.interval)
|
||||
}
|
||||
case StateOpen:
|
||||
cb.expiry = now.Add(cb.timeout)
|
||||
default: // StateHalfOpen
|
||||
cb.expiry = zero
|
||||
}
|
||||
}
|
||||
7
vendor/modules.txt
vendored
7
vendor/modules.txt
vendored
@@ -123,7 +123,7 @@ github.com/blevesearch/zapx/v16
|
||||
# github.com/cespare/xxhash/v2 v2.2.0
|
||||
## explicit; go 1.11
|
||||
github.com/cespare/xxhash/v2
|
||||
# github.com/chorus-services/backbeat v0.0.0-00010101000000-000000000000 => /home/tony/chorus/project-queues/active/BACKBEAT/backbeat/prototype
|
||||
# github.com/chorus-services/backbeat v0.0.0-00010101000000-000000000000 => ../BACKBEAT/backbeat/prototype
|
||||
## explicit; go 1.22
|
||||
github.com/chorus-services/backbeat/pkg/sdk
|
||||
# github.com/containerd/cgroups v1.1.0
|
||||
@@ -614,6 +614,9 @@ github.com/robfig/cron/v3
|
||||
github.com/sashabaranov/go-openai
|
||||
github.com/sashabaranov/go-openai/internal
|
||||
github.com/sashabaranov/go-openai/jsonschema
|
||||
# github.com/sony/gobreaker v0.5.0
|
||||
## explicit; go 1.12
|
||||
github.com/sony/gobreaker
|
||||
# github.com/spaolacci/murmur3 v1.1.0
|
||||
## explicit
|
||||
github.com/spaolacci/murmur3
|
||||
@@ -844,4 +847,4 @@ gopkg.in/yaml.v3
|
||||
# lukechampine.com/blake3 v1.2.1
|
||||
## explicit; go 1.17
|
||||
lukechampine.com/blake3
|
||||
# github.com/chorus-services/backbeat => /home/tony/chorus/project-queues/active/BACKBEAT/backbeat/prototype
|
||||
# github.com/chorus-services/backbeat => ../BACKBEAT/backbeat/prototype
|
||||
|
||||
Reference in New Issue
Block a user