mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
V0 finally out
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.egg-info
|
||||||
|
.tasks
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
Makefile
|
||||||
|
.pre-commit-config.yaml
|
||||||
+51
-5
@@ -1,18 +1,52 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RoboCo Environment Configuration
|
# RoboCo Environment Configuration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env
|
||||||
|
#
|
||||||
|
# NOTE: No API keys needed for agents - they use your Claude Code authentication.
|
||||||
|
# Run `claude` on the host to authenticate before starting RoboCo.
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Docker Deployment (NAS/Server)
|
||||||
|
# =============================================================================
|
||||||
|
# These are REQUIRED when running via docker compose on a NAS/server.
|
||||||
|
# They tell the orchestrator container where to find files on the HOST.
|
||||||
|
|
||||||
|
# Path to the project on the host (absolute path)
|
||||||
|
# ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
|
||||||
|
|
||||||
|
# Path to Claude Code auth directory on the host
|
||||||
|
# ROBOCO_HOST_CLAUDE_DIR=/root/.claude
|
||||||
|
|
||||||
|
# Claude auth directory to mount into orchestrator
|
||||||
|
# CLAUDE_AUTH_DIR=~/.claude
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Persistence
|
||||||
|
# =============================================================================
|
||||||
|
# Set to a path on your NAS RAID array for durability
|
||||||
|
# Path to data directory on the host (MUST be absolute for Docker-in-Docker)
|
||||||
|
# ROBOCO_DATA_DIR=/volume1/roboco/data
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Application
|
# Application
|
||||||
|
# =============================================================================
|
||||||
ROBOCO_ENVIRONMENT=development
|
ROBOCO_ENVIRONMENT=development
|
||||||
ROBOCO_DEBUG=true
|
ROBOCO_DEBUG=true
|
||||||
ROBOCO_LOG_LEVEL=INFO
|
ROBOCO_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# API Server
|
# API Server
|
||||||
|
# =============================================================================
|
||||||
ROBOCO_HOST=0.0.0.0
|
ROBOCO_HOST=0.0.0.0
|
||||||
ROBOCO_PORT=8000
|
ROBOCO_PORT=8000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Database (PostgreSQL)
|
# Database (PostgreSQL)
|
||||||
|
# =============================================================================
|
||||||
|
# For docker compose deployment, use container name:
|
||||||
|
# ROBOCO_DATABASE_HOST=roboco-postgres
|
||||||
|
# For local development:
|
||||||
ROBOCO_DATABASE_HOST=localhost
|
ROBOCO_DATABASE_HOST=localhost
|
||||||
ROBOCO_DATABASE_PORT=5432
|
ROBOCO_DATABASE_PORT=5432
|
||||||
ROBOCO_DATABASE_USER=roboco
|
ROBOCO_DATABASE_USER=roboco
|
||||||
@@ -20,23 +54,35 @@ ROBOCO_DATABASE_PASSWORD=roboco
|
|||||||
ROBOCO_DATABASE_NAME=roboco
|
ROBOCO_DATABASE_NAME=roboco
|
||||||
ROBOCO_DATABASE_ECHO=false
|
ROBOCO_DATABASE_ECHO=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Redis
|
# Redis
|
||||||
|
# =============================================================================
|
||||||
|
# For docker compose deployment, use container name:
|
||||||
|
# ROBOCO_REDIS_HOST=roboco-redis
|
||||||
|
# For local development:
|
||||||
ROBOCO_REDIS_HOST=localhost
|
ROBOCO_REDIS_HOST=localhost
|
||||||
ROBOCO_REDIS_PORT=6379
|
ROBOCO_REDIS_PORT=6379
|
||||||
ROBOCO_REDIS_DB=0
|
ROBOCO_REDIS_DB=0
|
||||||
# ROBOCO_REDIS_PASSWORD=
|
# ROBOCO_REDIS_PASSWORD=
|
||||||
|
|
||||||
# Qdrant (Vector DB)
|
# =============================================================================
|
||||||
|
# Qdrant (Vector DB) - Optional
|
||||||
|
# =============================================================================
|
||||||
ROBOCO_QDRANT_HOST=localhost
|
ROBOCO_QDRANT_HOST=localhost
|
||||||
ROBOCO_QDRANT_PORT=6333
|
ROBOCO_QDRANT_PORT=6333
|
||||||
# ROBOCO_QDRANT_API_KEY=
|
# ROBOCO_QDRANT_API_KEY=
|
||||||
|
|
||||||
# AI/LLM Providers
|
# =============================================================================
|
||||||
ROBOCO_ANTHROPIC_API_KEY=your-anthropic-api-key
|
# OpenAI (optional, for embeddings)
|
||||||
ROBOCO_OPENAI_API_KEY=your-openai-api-key
|
# =============================================================================
|
||||||
|
# ROBOCO_OPENAI_API_KEY=your-openai-api-key
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# Security
|
# Security
|
||||||
|
# =============================================================================
|
||||||
ROBOCO_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars
|
ROBOCO_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
# CORS (comma-separated origins)
|
# CORS (comma-separated origins)
|
||||||
|
# =============================================================================
|
||||||
ROBOCO_CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
ROBOCO_CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||||
|
|||||||
@@ -28,6 +28,119 @@ upgrade:
|
|||||||
@uv sync --all-extras
|
@uv sync --all-extras
|
||||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# INFRASTRUCTURE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default agents to spawn
|
||||||
|
AGENTS ?= main-pm be-dev-1 be-qa
|
||||||
|
|
||||||
|
# Start infrastructure (PostgreSQL + Redis)
|
||||||
|
.PHONY: infra
|
||||||
|
infra:
|
||||||
|
@echo "Starting infrastructure..."
|
||||||
|
@docker compose up -d postgres redis
|
||||||
|
@echo "Waiting for services to be healthy..."
|
||||||
|
@sleep 3
|
||||||
|
@docker compose ps
|
||||||
|
|
||||||
|
# Stop infrastructure
|
||||||
|
.PHONY: infra-down
|
||||||
|
infra-down:
|
||||||
|
@echo "Stopping infrastructure..."
|
||||||
|
@docker compose down
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
.PHONY: migrate
|
||||||
|
migrate:
|
||||||
|
@echo "Running database migrations..."
|
||||||
|
@uv run alembic upgrade head
|
||||||
|
|
||||||
|
# Create new migration
|
||||||
|
.PHONY: migration
|
||||||
|
migration:
|
||||||
|
@read -p "Migration message: " msg; \
|
||||||
|
uv run alembic revision --autogenerate -m "$$msg"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RUNNING THE APPLICATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Start API server only (development mode with reload)
|
||||||
|
.PHONY: api
|
||||||
|
api:
|
||||||
|
@echo "Starting RoboCo API (development mode)..."
|
||||||
|
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# Start API server (production mode, no reload)
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
@echo "Starting RoboCo API (production mode)..."
|
||||||
|
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# Start orchestrator only (spawns agents)
|
||||||
|
.PHONY: orchestrator
|
||||||
|
orchestrator:
|
||||||
|
@echo "Starting orchestrator with agents: $(AGENTS)..."
|
||||||
|
@uv run python -m roboco.cli --spawn $(AGENTS)
|
||||||
|
|
||||||
|
# Start API + Orchestrator (full development mode)
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
@echo "Starting RoboCo in development mode..."
|
||||||
|
@echo "Agents to spawn: $(AGENTS)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Starting API in background..."
|
||||||
|
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000 &
|
||||||
|
@sleep 2
|
||||||
|
@echo "Starting orchestrator..."
|
||||||
|
@uv run python -m roboco.cli --spawn $(AGENTS)
|
||||||
|
|
||||||
|
# Initialize database only (seed data)
|
||||||
|
.PHONY: db-init
|
||||||
|
db-init:
|
||||||
|
@echo "Initializing database..."
|
||||||
|
@uv run python -m roboco.cli --db-only
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MONITORING & STATUS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Show system status
|
||||||
|
.PHONY: status
|
||||||
|
status:
|
||||||
|
@echo "=== Infrastructure ==="
|
||||||
|
@docker compose ps
|
||||||
|
@echo ""
|
||||||
|
@echo "=== API Health ==="
|
||||||
|
@curl -s http://localhost:8000/health 2>/dev/null | jq . || echo "API not running"
|
||||||
|
@echo ""
|
||||||
|
@echo "=== Orchestrator Status ==="
|
||||||
|
@curl -s http://localhost:8000/api/v1/orchestrator/status 2>/dev/null | jq . || echo "Orchestrator not available"
|
||||||
|
|
||||||
|
# Tail all logs
|
||||||
|
.PHONY: logs
|
||||||
|
logs:
|
||||||
|
@docker compose logs -f
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TMUX SESSION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Create tmux session with all components
|
||||||
|
.PHONY: tmux
|
||||||
|
tmux:
|
||||||
|
@echo "Creating tmux session 'roboco'..."
|
||||||
|
@tmux kill-session -t roboco 2>/dev/null || true
|
||||||
|
@tmux new-session -d -s roboco -n infra
|
||||||
|
@tmux send-keys -t roboco:infra "cd $(PWD) && docker compose logs -f" Enter
|
||||||
|
@tmux new-window -t roboco -n api
|
||||||
|
@tmux send-keys -t roboco:api "cd $(PWD) && make api" Enter
|
||||||
|
@tmux new-window -t roboco -n orch
|
||||||
|
@tmux send-keys -t roboco:orch "cd $(PWD) && sleep 3 && make orchestrator AGENTS='$(AGENTS)'" Enter
|
||||||
|
@tmux select-window -t roboco:api
|
||||||
|
@echo "tmux session 'roboco' created. Attach with: tmux attach -t roboco"
|
||||||
|
|
||||||
# Stop
|
# Stop
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
stop:
|
stop:
|
||||||
@@ -254,41 +367,55 @@ clean:
|
|||||||
# Help
|
# Help
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help:
|
help:
|
||||||
@echo "Available commands:"
|
@echo "RoboCo - AI Agents Company"
|
||||||
@echo " make install - Install dependencies"
|
@echo ""
|
||||||
@echo " make install-dev - Install dev dependencies"
|
@echo "Infrastructure:"
|
||||||
@echo " make lock - Update dependencies"
|
@echo " make infra - Start PostgreSQL + Redis"
|
||||||
@echo " make start-example - Start example application with docker compose"
|
@echo " make infra-down - Stop infrastructure"
|
||||||
@echo " make run-example - Build and run example container directly"
|
@echo " make migrate - Run database migrations"
|
||||||
@echo " make stop - Stop all containers and clean up resources"
|
@echo " make migration - Create new migration"
|
||||||
@echo " make restart - Restart example application"
|
@echo " make db-init - Initialize/seed database"
|
||||||
@echo " make lint - Run linting checks"
|
@echo ""
|
||||||
@echo " make fix - Auto-fix linting issues"
|
@echo "Running:"
|
||||||
@echo " make vulture - Find dead code with Vulture"
|
@echo " make dev - Start API + Orchestrator (development)"
|
||||||
@echo " make bandit - Run Bandit security scan"
|
@echo " make dev AGENTS='a b c' - Start with specific agents"
|
||||||
@echo " make safety - Check dependencies with Safety"
|
@echo " make api - Start API only (with reload)"
|
||||||
@echo " make pip-audit - Audit dependencies with pip-audit"
|
@echo " make run - Start API only (production)"
|
||||||
@echo " make radon - Analyze code complexity with Radon"
|
@echo " make orchestrator - Start orchestrator only"
|
||||||
@echo " make xenon - Check complexity thresholds with Xenon"
|
@echo " make tmux - Create tmux session with all components"
|
||||||
@echo " make deptry - Analyze dependencies with Deptry"
|
@echo ""
|
||||||
@echo " make semgrep - Run Semgrep static analysis"
|
@echo "Monitoring:"
|
||||||
@echo " make security - Run all security checks"
|
@echo " make status - Show system status"
|
||||||
@echo " make quality - Run all code quality checks"
|
@echo " make logs - Tail infrastructure logs"
|
||||||
@echo " make analysis - Run all analysis tools"
|
@echo ""
|
||||||
@echo " make check-all - Run all checks (lint, security, quality, analysis)"
|
@echo "Dependencies:"
|
||||||
@echo " make test - Run tests with Python $(DEFAULT_PYTHON)"
|
@echo " make install - Install dependencies"
|
||||||
@echo " make test-all - Run tests with all Python versions ($(PYTHON_VERSIONS))"
|
@echo " make install-dev - Install dev dependencies"
|
||||||
@echo " make test-<version> - Run tests with specific Python version (e.g., make test-3.10)"
|
@echo " make lock - Update lock file"
|
||||||
@echo " make local-test - Run tests locally"
|
@echo " make upgrade - Upgrade all dependencies"
|
||||||
@echo " make stress-test - Run stress test"
|
@echo ""
|
||||||
@echo " make high-load-stress-test - Run high-load stress test"
|
@echo "Code Quality:"
|
||||||
@echo " make serve-docs - Serve documentation"
|
@echo " make lint - Run linting (ruff, mypy, vulture)"
|
||||||
@echo " make lint-docs - Run markdownlint on documentation"
|
@echo " make fix - Auto-fix linting issues"
|
||||||
@echo " make fix-docs - Auto-fix markdownlint issues"
|
@echo " make quality - Run all quality checks"
|
||||||
@echo " make prune - Prune docker resources"
|
@echo " make security - Run security checks (bandit, safety, pip-audit)"
|
||||||
|
@echo " make check-all - Run ALL checks"
|
||||||
|
@echo ""
|
||||||
|
@echo "Testing:"
|
||||||
|
@echo " make test - Run tests (Python $(DEFAULT_PYTHON))"
|
||||||
|
@echo " make test-all - Run tests (all Python versions)"
|
||||||
|
@echo " make stress-test - Run stress test"
|
||||||
|
@echo ""
|
||||||
|
@echo "Documentation:"
|
||||||
|
@echo " make serve-docs - Serve documentation"
|
||||||
|
@echo " make lint-docs - Lint markdown files"
|
||||||
|
@echo ""
|
||||||
|
@echo "Cleanup:"
|
||||||
|
@echo " make stop - Stop all containers"
|
||||||
@echo " make clean - Clean cache files"
|
@echo " make clean - Clean cache files"
|
||||||
@echo " make help - Show this help message"
|
@echo " make prune - Prune docker resources"
|
||||||
@echo " make show-python-versions - Show supported Python versions"
|
@echo ""
|
||||||
|
@echo "See docs/deployment.md and docs/usage.md for detailed guides."
|
||||||
|
|
||||||
# Python versions list
|
# Python versions list
|
||||||
.PHONY: show-python-versions
|
.PHONY: show-python-versions
|
||||||
|
|||||||
+68
-9
@@ -11,9 +11,9 @@ services:
|
|||||||
POSTGRES_PASSWORD: roboco
|
POSTGRES_PASSWORD: roboco
|
||||||
POSTGRES_DB: roboco
|
POSTGRES_DB: roboco
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "15432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
|
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -21,23 +21,82 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Redis - Cache, Sessions, Message Queue
|
# Redis - Cache, Sessions, Event Bus
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:8-alpine
|
||||||
container_name: roboco-redis
|
container_name: roboco-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "16379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- ${ROBOCO_DATA_DIR:-./data}/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
# ==========================================================================
|
||||||
postgres_data:
|
# Agent Image Builder (pre-builds the agent image at compose time)
|
||||||
redis_data:
|
# ==========================================================================
|
||||||
|
agent-image:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/agent.Dockerfile
|
||||||
|
image: roboco-agent
|
||||||
|
container_name: roboco-agent-builder
|
||||||
|
entrypoint: ["/bin/sh", "-c", "echo 'Agent image built successfully'"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Orchestrator - API Server + Agent Spawner
|
||||||
|
# ==========================================================================
|
||||||
|
orchestrator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/orchestrator.Dockerfile
|
||||||
|
image: roboco-orchestrator
|
||||||
|
container_name: roboco-orchestrator
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
# Database (use container name, not localhost)
|
||||||
|
ROBOCO_DATABASE_HOST: roboco-postgres
|
||||||
|
ROBOCO_DATABASE_PORT: 5432
|
||||||
|
ROBOCO_DATABASE_USER: roboco
|
||||||
|
ROBOCO_DATABASE_PASSWORD: roboco
|
||||||
|
ROBOCO_DATABASE_NAME: roboco
|
||||||
|
# Redis (use container name)
|
||||||
|
ROBOCO_REDIS_HOST: roboco-redis
|
||||||
|
ROBOCO_REDIS_PORT: 6379
|
||||||
|
# API
|
||||||
|
ROBOCO_HOST: 0.0.0.0
|
||||||
|
ROBOCO_PORT: 8000
|
||||||
|
# Host paths for spawning agent containers (required for Docker-in-Docker)
|
||||||
|
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
|
||||||
|
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
|
||||||
|
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
|
||||||
|
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||||
|
volumes:
|
||||||
|
# Docker socket - allows spawning agent containers
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Claude Code auth - mount your ~/.claude directory
|
||||||
|
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||||
|
# Shared config directory for MCP configs (writable)
|
||||||
|
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
agent-image:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
# Default agents to spawn (override in .env or command line)
|
||||||
|
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: roboco_default
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 22
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Claude Code CLI
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
# Install uv for Python package management
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
# Create agent user BEFORE copying files
|
||||||
|
RUN useradd -m -s /bin/bash agent
|
||||||
|
|
||||||
|
# Create app directory and set ownership
|
||||||
|
WORKDIR /app
|
||||||
|
RUN chown agent:agent /app
|
||||||
|
|
||||||
|
# Copy MCP server code (needed for agent tools) AS agent
|
||||||
|
COPY --chown=agent:agent roboco /app/roboco
|
||||||
|
COPY --chown=agent:agent pyproject.toml uv.lock README.md /app/
|
||||||
|
|
||||||
|
# Switch to agent user for installing dependencies
|
||||||
|
USER agent
|
||||||
|
|
||||||
|
# Install Python dependencies for MCP servers (as agent)
|
||||||
|
RUN uv python install 3.13 && uv sync --frozen --python 3.13
|
||||||
|
|
||||||
|
# Claude Code will use mounted ~/.claude for auth
|
||||||
|
# Blueprints mounted at /app/agents/blueprints
|
||||||
|
# MCP config generated at runtime
|
||||||
|
|
||||||
|
ENTRYPOINT ["claude"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install dependencies + Docker CLI
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
gnupg \
|
||||||
|
lsb-release \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Docker CLI (for spawning agent containers)
|
||||||
|
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y docker-ce-cli \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 22 (for building agent image)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv for Python package management
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY roboco /app/roboco
|
||||||
|
COPY agents /app/agents
|
||||||
|
COPY docker /app/docker
|
||||||
|
COPY pyproject.toml uv.lock alembic.ini README.md /app/
|
||||||
|
COPY alembic /app/alembic
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN uv python install 3.13 && uv sync --frozen --python 3.13
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Default: start with main-pm, be-dev-1, be-qa
|
||||||
|
# Override with: docker run ... roboco-orchestrator --spawn main-pm fe-dev-1
|
||||||
|
ENTRYPOINT ["uv", "run", "python", "-m", "roboco.cli"]
|
||||||
|
CMD ["--spawn", "main-pm", "be-dev-1", "be-qa"]
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# RoboCo Deployment Guide
|
||||||
|
|
||||||
|
Deploy RoboCo on your server/NAS.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Software | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| Docker | All services (PostgreSQL, Redis, Orchestrator, Agents) |
|
||||||
|
| Claude Code CLI | Authenticate once on host (creates ~/.claude) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install and authenticate Claude Code (one time)
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude # Login via browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start (NAS/Server)
|
||||||
|
|
||||||
|
Everything runs in Docker - no need to install Python/uv on the host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the project
|
||||||
|
git clone <repo-url> roboco
|
||||||
|
cd roboco
|
||||||
|
|
||||||
|
# 2. Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. Set host paths for your NAS (IMPORTANT!)
|
||||||
|
# Edit .env and set:
|
||||||
|
# ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
|
||||||
|
# ROBOCO_HOST_CLAUDE_DIR=/root/.claude (or your user's home)
|
||||||
|
|
||||||
|
# 4. Start everything (PostgreSQL + Redis + Orchestrator)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 5. View logs
|
||||||
|
docker compose logs -f orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Your NAS/Server
|
||||||
|
│
|
||||||
|
├── docker compose up -d
|
||||||
|
│ │
|
||||||
|
│ ├── roboco-postgres (pgvector)
|
||||||
|
│ ├── roboco-redis
|
||||||
|
│ └── roboco-orchestrator
|
||||||
|
│ │
|
||||||
|
│ ├── Runs FastAPI on port 8000
|
||||||
|
│ ├── Builds roboco-agent image (once)
|
||||||
|
│ └── Spawns agent containers:
|
||||||
|
│ ├── roboco-agent-main-pm
|
||||||
|
│ ├── roboco-agent-be-dev-1
|
||||||
|
│ ├── roboco-agent-be-qa
|
||||||
|
│ └── ... (each mounts ~/.claude for auth)
|
||||||
|
│
|
||||||
|
├── ~/.claude/ ← Your Claude Code auth (from host)
|
||||||
|
│
|
||||||
|
└── ./data/ ← Persistent data
|
||||||
|
├── postgres/
|
||||||
|
├── redis/
|
||||||
|
└── mcp-configs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Host paths - REQUIRED for NAS deployment
|
||||||
|
# These tell the orchestrator where to find files on the HOST
|
||||||
|
ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
|
||||||
|
ROBOCO_HOST_CLAUDE_DIR=/root/.claude
|
||||||
|
ROBOCO_DATA_DIR=./data
|
||||||
|
|
||||||
|
# Claude Code auth directory (mounted into containers)
|
||||||
|
CLAUDE_AUTH_DIR=/root/.claude
|
||||||
|
|
||||||
|
# Database (defaults work for docker compose)
|
||||||
|
ROBOCO_DATABASE_HOST=roboco-postgres
|
||||||
|
ROBOCO_DATABASE_PORT=5432
|
||||||
|
ROBOCO_DATABASE_USER=roboco
|
||||||
|
ROBOCO_DATABASE_PASSWORD=roboco
|
||||||
|
ROBOCO_DATABASE_NAME=roboco
|
||||||
|
|
||||||
|
# Redis (defaults work for docker compose)
|
||||||
|
ROBOCO_REDIS_HOST=roboco-redis
|
||||||
|
ROBOCO_REDIS_PORT=6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customizing Agent Spawn
|
||||||
|
|
||||||
|
By default, the orchestrator spawns `main-pm`, `be-dev-1`, and `be-qa`. To change this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Override in docker-compose.yml
|
||||||
|
docker compose up -d --scale orchestrator=0
|
||||||
|
docker compose run orchestrator --spawn main-pm fe-dev-1 fe-qa
|
||||||
|
|
||||||
|
# Option 2: Edit docker-compose.yml command section
|
||||||
|
# Uncomment and modify the command line
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API health
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
|
||||||
|
# Orchestrator status (shows running containers)
|
||||||
|
curl http://localhost:8000/api/v1/orchestrator/status | jq
|
||||||
|
|
||||||
|
# List all RoboCo containers
|
||||||
|
docker ps --filter "name=roboco"
|
||||||
|
|
||||||
|
# View orchestrator logs
|
||||||
|
docker compose logs -f orchestrator
|
||||||
|
|
||||||
|
# View agent logs
|
||||||
|
docker logs -f roboco-agent-main-pm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
All data is persisted to the host:
|
||||||
|
|
||||||
|
| Container Path | Host Path |
|
||||||
|
|----------------|-----------|
|
||||||
|
| postgres data | `./data/postgres/` |
|
||||||
|
| redis data | `./data/redis/` |
|
||||||
|
| MCP configs | `./data/mcp-configs/` |
|
||||||
|
|
||||||
|
For NAS RAID protection, set `ROBOCO_DATA_DIR` to your RAID volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ROBOCO_DATA_DIR=/volume1/roboco/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Orchestrator can't spawn agents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker socket is mounted
|
||||||
|
docker compose logs orchestrator | grep -i docker
|
||||||
|
|
||||||
|
# Verify host paths are set correctly
|
||||||
|
docker compose exec orchestrator env | grep ROBOCO_HOST
|
||||||
|
|
||||||
|
# Check if agent image was built
|
||||||
|
docker images | grep roboco-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent containers exit immediately
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Claude auth is mounted
|
||||||
|
docker logs roboco-agent-main-pm
|
||||||
|
|
||||||
|
# Verify ~/.claude exists on host
|
||||||
|
ls -la ~/.claude/
|
||||||
|
|
||||||
|
# Re-authenticate if needed
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps postgres
|
||||||
|
docker compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### API not responding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
docker compose logs orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop all agent containers (if needed)
|
||||||
|
docker ps --filter "name=roboco-agent" -q | xargs docker stop
|
||||||
|
|
||||||
|
# Full cleanup (removes volumes)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Mode (Host)
|
||||||
|
|
||||||
|
For local development without Docker orchestrator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start only infrastructure
|
||||||
|
docker compose up -d postgres redis
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
# Start orchestrator directly
|
||||||
|
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
|
||||||
|
```
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
# RoboCo Usage Guide
|
||||||
|
|
||||||
|
Operating the AI company after deployment.
|
||||||
|
|
||||||
|
## The Organization
|
||||||
|
|
||||||
|
18 AI agents organized as a company:
|
||||||
|
|
||||||
|
```
|
||||||
|
CEO (You)
|
||||||
|
└── Board
|
||||||
|
├── Product Owner
|
||||||
|
├── Head of Marketing
|
||||||
|
└── Auditor
|
||||||
|
└── Main PM
|
||||||
|
├── Backend Cell (PM, 2 Devs, QA, Documenter)
|
||||||
|
├── Frontend Cell (PM, 2 Devs, QA, Documenter)
|
||||||
|
└── UX/UI Cell (PM, Dev, QA, Documenter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent IDs
|
||||||
|
|
||||||
|
| ID | Role | Team |
|
||||||
|
|----|------|------|
|
||||||
|
| `main-pm` | Main PM | Management |
|
||||||
|
| `be-pm` | Cell PM | Backend |
|
||||||
|
| `be-dev-1`, `be-dev-2` | Developers | Backend |
|
||||||
|
| `be-qa` | QA | Backend |
|
||||||
|
| `be-doc` | Documenter | Backend |
|
||||||
|
| `fe-pm` | Cell PM | Frontend |
|
||||||
|
| `fe-dev-1`, `fe-dev-2` | Developers | Frontend |
|
||||||
|
| `fe-qa` | QA | Frontend |
|
||||||
|
| `fe-doc` | Documenter | Frontend |
|
||||||
|
| `ux-pm` | Cell PM | UX/UI |
|
||||||
|
| `ux-dev` | Developer | UX/UI |
|
||||||
|
| `ux-qa` | QA | UX/UI |
|
||||||
|
| `ux-doc` | Documenter | UX/UI |
|
||||||
|
| `product-owner` | Product Owner | Board |
|
||||||
|
| `head-marketing` | Head of Marketing | Board |
|
||||||
|
| `auditor` | Auditor | Board |
|
||||||
|
|
||||||
|
## Spawning Agents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with minimal team
|
||||||
|
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
|
||||||
|
|
||||||
|
# Add more agents
|
||||||
|
uv run python -m roboco.cli --spawn main-pm be-pm be-dev-1 be-dev-2 be-qa
|
||||||
|
|
||||||
|
# Full organization
|
||||||
|
uv run python -m roboco.cli --spawn \
|
||||||
|
main-pm \
|
||||||
|
be-pm be-dev-1 be-dev-2 be-qa be-doc \
|
||||||
|
fe-pm fe-dev-1 fe-dev-2 fe-qa fe-doc \
|
||||||
|
ux-pm ux-dev ux-qa ux-doc \
|
||||||
|
product-owner head-marketing auditor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring Agents
|
||||||
|
|
||||||
|
### Check Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via API
|
||||||
|
curl http://localhost:8000/api/v1/orchestrator/status | jq
|
||||||
|
|
||||||
|
# Via Docker
|
||||||
|
docker ps --filter "name=roboco-agent"
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Agent Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow specific agent's output
|
||||||
|
docker logs -f roboco-agent-be-dev-1
|
||||||
|
|
||||||
|
# All agent containers
|
||||||
|
docker ps --filter "name=roboco-agent" --format "{{.Names}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop one agent
|
||||||
|
docker stop roboco-agent-be-dev-1
|
||||||
|
|
||||||
|
# Restart an agent
|
||||||
|
docker restart roboco-agent-be-dev-1
|
||||||
|
|
||||||
|
# Stop all agents
|
||||||
|
docker ps --filter "name=roboco-agent" -q | xargs docker stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "Implement user authentication",
|
||||||
|
"description": "Add JWT-based auth to the API",
|
||||||
|
"team": "backend",
|
||||||
|
"complexity": "medium",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Users can register",
|
||||||
|
"Users can login",
|
||||||
|
"Protected routes require valid JWT"
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → claimed → in_progress → verifying → awaiting_qa → awaiting_docs → completed
|
||||||
|
↓
|
||||||
|
blocked/paused
|
||||||
|
```
|
||||||
|
|
||||||
|
Agents automatically:
|
||||||
|
1. Scan for pending tasks (`roboco_task_scan`)
|
||||||
|
2. Claim tasks they can work on
|
||||||
|
3. Follow the workflow: UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES
|
||||||
|
4. Submit for QA when done
|
||||||
|
5. Move to next task
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /health` | Health check |
|
||||||
|
| `GET /docs` | Swagger UI |
|
||||||
|
| `GET /api/v1/orchestrator/status` | Agent states |
|
||||||
|
| `GET /api/v1/tasks` | List tasks |
|
||||||
|
| `POST /api/v1/tasks` | Create task |
|
||||||
|
| `GET /api/v1/tasks/{id}` | Task details |
|
||||||
|
|
||||||
|
## Viewing the API
|
||||||
|
|
||||||
|
Open http://localhost:8000/docs in your browser for the Swagger UI.
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Start a Development Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start infrastructure
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 2. Run migrations (if needed)
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
# 3. Start with a small team
|
||||||
|
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create and Monitor a Task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create task
|
||||||
|
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title": "Fix login bug", "team": "backend", "complexity": "trivial"}'
|
||||||
|
|
||||||
|
# Watch agent pick it up
|
||||||
|
docker logs -f roboco-agent-be-dev-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shutdown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop orchestrator (Ctrl+C in terminal)
|
||||||
|
|
||||||
|
# Stop agent containers
|
||||||
|
docker ps --filter "name=roboco-agent" -q | xargs docker stop
|
||||||
|
|
||||||
|
# Stop infrastructure
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
### Start Small
|
||||||
|
|
||||||
|
Don't spawn all 18 agents at once. Start with:
|
||||||
|
1. `main-pm` alone - verify spawning works
|
||||||
|
2. Add `be-dev-1` - verify task claiming
|
||||||
|
3. Add `be-qa` - verify full workflow
|
||||||
|
|
||||||
|
### Check Agent Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick status
|
||||||
|
curl -s http://localhost:8000/api/v1/orchestrator/status | jq '.agents'
|
||||||
|
|
||||||
|
# Detailed container info
|
||||||
|
docker inspect roboco-agent-be-dev-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug an Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View full logs
|
||||||
|
docker logs roboco-agent-be-dev-1
|
||||||
|
|
||||||
|
# Attach to container (read-only)
|
||||||
|
docker logs -f roboco-agent-be-dev-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Usage
|
||||||
|
|
||||||
|
Each agent container uses ~500MB-2GB RAM depending on context. With 128GB RAM:
|
||||||
|
- 3 agents: ~6GB
|
||||||
|
- 6 agents: ~12GB
|
||||||
|
- 18 agents: ~36GB
|
||||||
|
|
||||||
|
Monitor with:
|
||||||
|
```bash
|
||||||
|
docker stats --filter "name=roboco-agent"
|
||||||
|
```
|
||||||
@@ -40,6 +40,10 @@ class Settings(BaseSettings):
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
host: str = Field(default="127.0.0.1", description="Use 0.0.0.0 for containers")
|
host: str = Field(default="127.0.0.1", description="Use 0.0.0.0 for containers")
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
api_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Override API URL for containerized agents (e.g., http://roboco-orchestrator:8000)",
|
||||||
|
)
|
||||||
reload: bool = Field(default=True, description="Auto-reload on code changes")
|
reload: bool = Field(default=True, description="Auto-reload on code changes")
|
||||||
workers: int = Field(default=1, ge=1)
|
workers: int = Field(default=1, ge=1)
|
||||||
|
|
||||||
@@ -49,6 +53,18 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
cors_allow_credentials: bool = True
|
cors_allow_credentials: bool = True
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def internal_api_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Internal API base URL for service-to-service communication.
|
||||||
|
|
||||||
|
Uses api_url if set (for containerized agents), otherwise builds from host/port.
|
||||||
|
"""
|
||||||
|
if self.api_url:
|
||||||
|
return f"{self.api_url.rstrip('/')}/api/v1"
|
||||||
|
return f"http://{self.host}:{self.port}/api/v1"
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Database
|
# Database
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
+64
-36
@@ -91,10 +91,10 @@ class AgentTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -154,15 +154,23 @@ class TaskTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
|
)
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
target_date: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
target_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Planning (stored as JSON)
|
# Planning (stored as JSON)
|
||||||
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -252,14 +260,16 @@ class ChannelTable(Base):
|
|||||||
# Statistics
|
# Statistics
|
||||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
group_count: Mapped[int] = mapped_column(Integer, default=0)
|
group_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
last_activity: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_activity: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -311,14 +321,16 @@ class GroupTable(Base):
|
|||||||
# Statistics
|
# Statistics
|
||||||
total_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
total_sessions: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
total_messages: Mapped[int] = mapped_column(Integer, default=0)
|
total_messages: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
last_activity: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_activity: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -372,12 +384,14 @@ class SessionTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
last_activity_at: Mapped[datetime] = mapped_column(
|
last_activity_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
|
)
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
@@ -385,7 +399,7 @@ class SessionTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -464,7 +478,7 @@ class MessageTable(Base):
|
|||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
timestamp: Mapped[datetime] = mapped_column(
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False, index=True
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extraction metadata
|
# Extraction metadata
|
||||||
@@ -472,12 +486,14 @@ class MessageTable(Base):
|
|||||||
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Edit tracking (stored as JSON)
|
# Edit tracking (stored as JSON)
|
||||||
edited_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
edited_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -545,9 +561,11 @@ class NotificationTable(Base):
|
|||||||
|
|
||||||
# Timing
|
# Timing
|
||||||
timestamp: Mapped[datetime] = mapped_column(
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False, index=True
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Read tracking
|
# Read tracking
|
||||||
read_by: Mapped[list[PyUUID]] = mapped_column(
|
read_by: Mapped[list[PyUUID]] = mapped_column(
|
||||||
@@ -555,11 +573,13 @@ class NotificationTable(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Delivery tracking
|
# Delivery tracking
|
||||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -592,21 +612,25 @@ class JournalTable(Base):
|
|||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
total_entries: Mapped[int] = mapped_column(Integer, default=0)
|
total_entries: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
last_entry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_entry_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
summary_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
summary_updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Growth metrics (stored as JSON)
|
# Growth metrics (stored as JSON)
|
||||||
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
|
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -651,7 +675,7 @@ class JournalEntryTable(Base):
|
|||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
timestamp: Mapped[datetime] = mapped_column(
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False, index=True
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
|
||||||
)
|
)
|
||||||
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||||
|
|
||||||
@@ -663,10 +687,10 @@ class JournalEntryTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -750,13 +774,17 @@ class HandoffTable(Base):
|
|||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.now(UTC), nullable=False
|
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
|
||||||
)
|
)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, onupdate=datetime.now(UTC), nullable=True
|
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
|
||||||
|
)
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Documenter feedback
|
# Documenter feedback
|
||||||
documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ _toon = ToonAdapter()
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _get_api_url() -> str:
|
|
||||||
"""Get the RoboCo API base URL."""
|
|
||||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
def _format_error_response(
|
||||||
error_code: str,
|
error_code: str,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -67,7 +62,7 @@ async def _post_journal_entry(
|
|||||||
"""Post to a journal endpoint. Returns (data, error)."""
|
"""Post to a journal endpoint. Returns (data, error)."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{_get_api_url()}/journals/me/{endpoint}",
|
f"{settings.internal_api_url}/journals/me/{endpoint}",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
@@ -224,7 +219,7 @@ async def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any
|
|||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
payload = {"query": query, "top_k": min(top_k, 20)}
|
payload = {"query": query, "top_k": min(top_k, 20)}
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{_get_api_url()}/journals/me/search",
|
f"{settings.internal_api_url}/journals/me/search",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
@@ -253,11 +248,11 @@ async def _handle_stats(agent_id: str) -> dict[str, Any]:
|
|||||||
"""Handle journal stats retrieval."""
|
"""Handle journal stats retrieval."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
stats_resp = await client.get(
|
stats_resp = await client.get(
|
||||||
f"{_get_api_url()}/journals/me/stats",
|
f"{settings.internal_api_url}/journals/me/stats",
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
growth_resp = await client.get(
|
growth_resp = await client.get(
|
||||||
f"{_get_api_url()}/journals/me/growth",
|
f"{settings.internal_api_url}/journals/me/growth",
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -302,7 +297,7 @@ async def _handle_recent(
|
|||||||
params["task_id"] = task_id
|
params["task_id"] = task_id
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{_get_api_url()}/journals/me/entries",
|
f"{settings.internal_api_url}/journals/me/entries",
|
||||||
params=params,
|
params=params,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
|
|||||||
return bool(action == "read" and agent_id in channel.get("silent", []))
|
return bool(action == "read" and agent_id in channel.get("silent", []))
|
||||||
|
|
||||||
|
|
||||||
def _get_api_url() -> str:
|
|
||||||
"""Get the RoboCo API base URL."""
|
|
||||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
def _format_error_response(
|
||||||
error_code: str,
|
error_code: str,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -122,13 +117,15 @@ async def _get_or_create_session(
|
|||||||
channel_id: str,
|
channel_id: str,
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get or create session for channel. Returns session_id or error dict."""
|
"""Get or create session for channel. Returns session_id or error dict."""
|
||||||
session_resp = await client.get(f"{_get_api_url()}/channels/{channel_id}/session")
|
session_resp = await client.get(
|
||||||
|
f"{settings.internal_api_url}/channels/{channel_id}/session"
|
||||||
|
)
|
||||||
|
|
||||||
if session_resp.status_code == status.HTTP_200_OK:
|
if session_resp.status_code == status.HTTP_200_OK:
|
||||||
return str(session_resp.json()["id"])
|
return str(session_resp.json()["id"])
|
||||||
|
|
||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
f"{_get_api_url()}/sessions",
|
f"{settings.internal_api_url}/sessions",
|
||||||
json={"channel_id": channel_id},
|
json={"channel_id": channel_id},
|
||||||
)
|
)
|
||||||
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
||||||
@@ -184,7 +181,7 @@ async def _handle_channel_history(
|
|||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
channels_resp = await client.get(
|
channels_resp = await client.get(
|
||||||
f"{_get_api_url()}/channels",
|
f"{settings.internal_api_url}/channels",
|
||||||
params={"slug": channel_slug},
|
params={"slug": channel_slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,7 +197,7 @@ async def _handle_channel_history(
|
|||||||
channel_id = channels[0]["id"]
|
channel_id = channels[0]["id"]
|
||||||
|
|
||||||
messages_resp = await client.get(
|
messages_resp = await client.get(
|
||||||
f"{_get_api_url()}/channels/{channel_id}/messages",
|
f"{settings.internal_api_url}/channels/{channel_id}/messages",
|
||||||
params={"after": since.isoformat(), "limit": limit},
|
params={"after": since.isoformat(), "limit": limit},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,7 +227,7 @@ async def _handle_message_send(
|
|||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
channels_resp = await client.get(
|
channels_resp = await client.get(
|
||||||
f"{_get_api_url()}/channels",
|
f"{settings.internal_api_url}/channels",
|
||||||
params={"slug": data.channel_slug},
|
params={"slug": data.channel_slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,7 +255,7 @@ async def _handle_message_send(
|
|||||||
}
|
}
|
||||||
|
|
||||||
send_resp = await client.post(
|
send_resp = await client.post(
|
||||||
f"{_get_api_url()}/messages",
|
f"{settings.internal_api_url}/messages",
|
||||||
json=message_data,
|
json=message_data,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
@@ -279,7 +276,7 @@ async def _handle_message_send(
|
|||||||
async def _handle_message_get(message_id: str) -> dict[str, Any]:
|
async def _handle_message_get(message_id: str) -> dict[str, Any]:
|
||||||
"""Handle message retrieval."""
|
"""Handle message retrieval."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(f"{_get_api_url()}/messages/{message_id}")
|
resp = await client.get(f"{settings.internal_api_url}/messages/{message_id}")
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response(
|
return _format_error_response(
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
|
|||||||
return False, f"You cannot send notifications to {recipient_id}"
|
return False, f"You cannot send notifications to {recipient_id}"
|
||||||
|
|
||||||
|
|
||||||
def _get_api_url() -> str:
|
|
||||||
"""Get the RoboCo API base URL."""
|
|
||||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
def _format_error_response(
|
||||||
error_code: str,
|
error_code: str,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -100,7 +95,7 @@ async def _handle_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{_get_api_url()}/notifications",
|
f"{settings.internal_api_url}/notifications",
|
||||||
params=params,
|
params=params,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
@@ -137,7 +132,7 @@ async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
|
|||||||
"""Handle getting a specific notification."""
|
"""Handle getting a specific notification."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{_get_api_url()}/notifications/{notification_id}",
|
f"{settings.internal_api_url}/notifications/{notification_id}",
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +163,7 @@ async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
|
|||||||
"""Handle acknowledging a notification."""
|
"""Handle acknowledging a notification."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{_get_api_url()}/notifications/{notification_id}/ack",
|
f"{settings.internal_api_url}/notifications/{notification_id}/ack",
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,7 +244,7 @@ async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{_get_api_url()}/notifications",
|
f"{settings.internal_api_url}/notifications",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"X-Agent-Id": agent_id},
|
headers={"X-Agent-Id": agent_id},
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-42
@@ -35,16 +35,6 @@ _toon = ToonAdapter()
|
|||||||
# NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS
|
# NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPER FUNCTIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def _get_api_url() -> str:
|
|
||||||
"""Get the RoboCo API base URL."""
|
|
||||||
return f"http://{settings.host}:{settings.port}/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_task_response(
|
def _format_task_response(
|
||||||
task: dict[str, Any],
|
task: dict[str, Any],
|
||||||
next_step: str,
|
next_step: str,
|
||||||
@@ -158,7 +148,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
|
|||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
# Get paused tasks for this agent
|
# Get paused tasks for this agent
|
||||||
paused_resp = await client.get(
|
paused_resp = await client.get(
|
||||||
f"{_get_api_url()}/tasks",
|
f"{settings.internal_api_url}/tasks",
|
||||||
params={"assigned_to": agent_id, "status": "paused"},
|
params={"assigned_to": agent_id, "status": "paused"},
|
||||||
)
|
)
|
||||||
paused_tasks = (
|
paused_tasks = (
|
||||||
@@ -167,7 +157,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
# Get assigned tasks (claimed, in_progress)
|
# Get assigned tasks (claimed, in_progress)
|
||||||
assigned_resp = await client.get(
|
assigned_resp = await client.get(
|
||||||
f"{_get_api_url()}/tasks",
|
f"{settings.internal_api_url}/tasks",
|
||||||
params={"assigned_to": agent_id},
|
params={"assigned_to": agent_id},
|
||||||
)
|
)
|
||||||
assigned_data = (
|
assigned_data = (
|
||||||
@@ -187,7 +177,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
|
|||||||
if team:
|
if team:
|
||||||
params["team"] = team
|
params["team"] = team
|
||||||
available_resp = await client.get(
|
available_resp = await client.get(
|
||||||
f"{_get_api_url()}/tasks",
|
f"{settings.internal_api_url}/tasks",
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
available_tasks = (
|
available_tasks = (
|
||||||
@@ -229,7 +219,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
|
|||||||
async def _handle_task_get(task_id: str) -> dict[str, Any]:
|
async def _handle_task_get(task_id: str) -> dict[str, Any]:
|
||||||
"""Handle getting task details."""
|
"""Handle getting task details."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response(
|
return _format_error_response(
|
||||||
@@ -285,7 +275,7 @@ def _validate_task_claimable(task: dict) -> dict[str, Any] | None:
|
|||||||
async def _get_project_context(project_id: str) -> dict[str, Any] | None:
|
async def _get_project_context(project_id: str) -> dict[str, Any] | None:
|
||||||
"""Fetch project context if available."""
|
"""Fetch project context if available."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(f"{_get_api_url()}/projects/{project_id}")
|
resp = await client.get(f"{settings.internal_api_url}/projects/{project_id}")
|
||||||
if resp.status_code == status.HTTP_200_OK:
|
if resp.status_code == status.HTTP_200_OK:
|
||||||
result: dict[str, Any] = resp.json()
|
result: dict[str, Any] = resp.json()
|
||||||
return result
|
return result
|
||||||
@@ -296,7 +286,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
|
|||||||
"""Handle task claiming."""
|
"""Handle task claiming."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
active_resp = await client.get(
|
active_resp = await client.get(
|
||||||
f"{_get_api_url()}/tasks",
|
f"{settings.internal_api_url}/tasks",
|
||||||
params={"assigned_to": agent_id},
|
params={"assigned_to": agent_id},
|
||||||
)
|
)
|
||||||
if active_resp.status_code == status.HTTP_200_OK:
|
if active_resp.status_code == status.HTTP_200_OK:
|
||||||
@@ -306,7 +296,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
|
|||||||
if error := _check_paused_tasks(active_tasks):
|
if error := _check_paused_tasks(active_tasks):
|
||||||
return error
|
return error
|
||||||
|
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -315,7 +305,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
claim_resp = await client.post(
|
claim_resp = await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/claim",
|
f"{settings.internal_api_url}/tasks/{task_id}/claim",
|
||||||
json={"agent_id": agent_id},
|
json={"agent_id": agent_id},
|
||||||
)
|
)
|
||||||
if claim_resp.status_code != status.HTTP_200_OK:
|
if claim_resp.status_code != status.HTTP_200_OK:
|
||||||
@@ -392,7 +382,7 @@ async def _handle_task_plan(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle task planning."""
|
"""Handle task planning."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -404,7 +394,7 @@ async def _handle_task_plan(
|
|||||||
|
|
||||||
plan_data = _build_plan_data(plan_params)
|
plan_data = _build_plan_data(plan_params)
|
||||||
update_resp = await client.patch(
|
update_resp = await client.patch(
|
||||||
f"{_get_api_url()}/tasks/{task_id}",
|
f"{settings.internal_api_url}/tasks/{task_id}",
|
||||||
json={"plan": plan_data},
|
json={"plan": plan_data},
|
||||||
)
|
)
|
||||||
if update_resp.status_code != status.HTTP_200_OK:
|
if update_resp.status_code != status.HTTP_200_OK:
|
||||||
@@ -470,7 +460,7 @@ def _validate_task_start(task: dict[str, Any], agent_id: str) -> dict[str, Any]
|
|||||||
async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
|
async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
|
||||||
"""Handle task start."""
|
"""Handle task start."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -480,7 +470,9 @@ async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
|
|||||||
return validation_error
|
return validation_error
|
||||||
|
|
||||||
# Start the task
|
# Start the task
|
||||||
start_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/start")
|
start_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/start"
|
||||||
|
)
|
||||||
|
|
||||||
if start_resp.status_code != status.HTTP_200_OK:
|
if start_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response(
|
return _format_error_response(
|
||||||
@@ -509,7 +501,7 @@ async def _handle_task_progress(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle task progress update."""
|
"""Handle task progress update."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -528,7 +520,7 @@ async def _handle_task_progress(
|
|||||||
|
|
||||||
# Add progress update
|
# Add progress update
|
||||||
progress_resp = await client.post(
|
progress_resp = await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/progress",
|
f"{settings.internal_api_url}/tasks/{task_id}/progress",
|
||||||
json={
|
json={
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -566,7 +558,7 @@ async def _handle_task_block(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -585,7 +577,7 @@ async def _handle_task_block(
|
|||||||
|
|
||||||
# Block the task
|
# Block the task
|
||||||
block_resp = await client.post(
|
block_resp = await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/block",
|
f"{settings.internal_api_url}/tasks/{task_id}/block",
|
||||||
json={
|
json={
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"blocker_type": blocker_type,
|
"blocker_type": blocker_type,
|
||||||
@@ -614,7 +606,7 @@ async def _handle_task_block(
|
|||||||
async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
|
async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
|
||||||
"""Handle task unblocking."""
|
"""Handle task unblocking."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -631,7 +623,9 @@ async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
|
|||||||
"Task is not blocked",
|
"Task is not blocked",
|
||||||
)
|
)
|
||||||
|
|
||||||
unblock_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/unblock")
|
unblock_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/unblock"
|
||||||
|
)
|
||||||
|
|
||||||
if unblock_resp.status_code != status.HTTP_200_OK:
|
if unblock_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
|
return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
|
||||||
@@ -654,7 +648,7 @@ async def _handle_task_pause(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle task pausing."""
|
"""Handle task pausing."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -673,7 +667,7 @@ async def _handle_task_pause(
|
|||||||
|
|
||||||
# Add checkpoint
|
# Add checkpoint
|
||||||
await client.post(
|
await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/checkpoint",
|
f"{settings.internal_api_url}/tasks/{task_id}/checkpoint",
|
||||||
json={
|
json={
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"state_summary": checkpoint_summary,
|
"state_summary": checkpoint_summary,
|
||||||
@@ -683,7 +677,9 @@ async def _handle_task_pause(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Pause the task
|
# Pause the task
|
||||||
pause_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/pause")
|
pause_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/pause"
|
||||||
|
)
|
||||||
|
|
||||||
if pause_resp.status_code != status.HTTP_200_OK:
|
if pause_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response("PAUSE_FAILED", "Failed to pause task")
|
return _format_error_response("PAUSE_FAILED", "Failed to pause task")
|
||||||
@@ -705,7 +701,7 @@ async def _handle_task_submit_verification(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle task verification submission."""
|
"""Handle task verification submission."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -731,7 +727,9 @@ async def _handle_task_submit_verification(
|
|||||||
"before verification.",
|
"before verification.",
|
||||||
)
|
)
|
||||||
|
|
||||||
verify_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/verify")
|
verify_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/verify"
|
||||||
|
)
|
||||||
|
|
||||||
if verify_resp.status_code != status.HTTP_200_OK:
|
if verify_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response(
|
return _format_error_response(
|
||||||
@@ -768,7 +766,7 @@ async def _handle_task_submit_qa(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -787,7 +785,7 @@ async def _handle_task_submit_qa(
|
|||||||
|
|
||||||
# Update with notes
|
# Update with notes
|
||||||
await client.patch(
|
await client.patch(
|
||||||
f"{_get_api_url()}/tasks/{task_id}",
|
f"{settings.internal_api_url}/tasks/{task_id}",
|
||||||
json={
|
json={
|
||||||
"dev_notes": dev_notes,
|
"dev_notes": dev_notes,
|
||||||
"documenter_handoff": handoff_summary,
|
"documenter_handoff": handoff_summary,
|
||||||
@@ -795,7 +793,9 @@ async def _handle_task_submit_qa(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Submit for QA
|
# Submit for QA
|
||||||
qa_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/submit-qa")
|
qa_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/submit-qa"
|
||||||
|
)
|
||||||
|
|
||||||
if qa_resp.status_code != status.HTTP_200_OK:
|
if qa_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
|
return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
|
||||||
@@ -825,7 +825,7 @@ async def _handle_task_qa_pass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -845,7 +845,7 @@ async def _handle_task_qa_pass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
pass_resp = await client.post(
|
pass_resp = await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/pass-qa",
|
f"{settings.internal_api_url}/tasks/{task_id}/pass-qa",
|
||||||
json={"notes": qa_notes},
|
json={"notes": qa_notes},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -882,7 +882,7 @@ async def _handle_task_qa_fail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -897,7 +897,7 @@ async def _handle_task_qa_fail(
|
|||||||
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues)
|
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues)
|
||||||
|
|
||||||
fail_resp = await client.post(
|
fail_resp = await client.post(
|
||||||
f"{_get_api_url()}/tasks/{task_id}/fail-qa",
|
f"{settings.internal_api_url}/tasks/{task_id}/fail-qa",
|
||||||
json={"notes": full_notes},
|
json={"notes": full_notes},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -918,7 +918,7 @@ async def _handle_task_qa_fail(
|
|||||||
async def _handle_task_complete(task_id: str) -> dict[str, Any]:
|
async def _handle_task_complete(task_id: str) -> dict[str, Any]:
|
||||||
"""Handle task completion."""
|
"""Handle task completion."""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
|
task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
|
||||||
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||||
|
|
||||||
@@ -930,7 +930,9 @@ async def _handle_task_complete(task_id: str) -> dict[str, Any]:
|
|||||||
"Task must be awaiting documentation to complete",
|
"Task must be awaiting documentation to complete",
|
||||||
)
|
)
|
||||||
|
|
||||||
complete_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/complete")
|
complete_resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/tasks/{task_id}/complete"
|
||||||
|
)
|
||||||
|
|
||||||
if complete_resp.status_code != status.HTTP_200_OK:
|
if complete_resp.status_code != status.HTTP_200_OK:
|
||||||
return _format_error_response("COMPLETE_FAILED", "Failed to complete task")
|
return _format_error_response("COMPLETE_FAILED", "Failed to complete task")
|
||||||
@@ -944,6 +946,32 @@ async def _handle_task_complete(task_id: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_agent_idle(agent_id: str) -> dict[str, Any]:
|
||||||
|
"""Handle agent going idle (no work available)."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Signal to orchestrator that this agent is idle
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.internal_api_url}/orchestrator/agents/{agent_id}/mark-waiting",
|
||||||
|
params={"waiting_for": "task_assignment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == status.HTTP_204_NO_CONTENT:
|
||||||
|
return {
|
||||||
|
"status": "idle",
|
||||||
|
"message": (
|
||||||
|
"You are now in WAITING state. Your container will terminate "
|
||||||
|
"to save resources. You will be respawned when work is available."
|
||||||
|
),
|
||||||
|
"action": "EXIT_GRACEFULLY",
|
||||||
|
}
|
||||||
|
|
||||||
|
return _format_error_response(
|
||||||
|
"IDLE_FAILED",
|
||||||
|
"Failed to signal idle state to orchestrator",
|
||||||
|
{"status_code": resp.status_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MCP SERVER FACTORY
|
# MCP SERVER FACTORY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1262,6 +1290,20 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
return await _handle_task_complete(task_id)
|
return await _handle_task_complete(task_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def roboco_agent_idle() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Signal that you have no work and should go idle.
|
||||||
|
|
||||||
|
Call this when roboco_task_scan returns no tasks.
|
||||||
|
Your container will be terminated to save resources.
|
||||||
|
You will be automatically respawned when new work is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of idle state
|
||||||
|
"""
|
||||||
|
return await _handle_agent_idle(agent_id)
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Runtime Models
|
|||||||
Domain types for the agent orchestrator system.
|
Domain types for the agent orchestrator system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -38,12 +37,12 @@ class OrchestratorAgentConfig:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AgentInstance:
|
class AgentInstance:
|
||||||
"""A running Claude Code agent instance."""
|
"""A running Claude Code agent instance (Docker container)."""
|
||||||
|
|
||||||
id: UUID = field(default_factory=uuid4)
|
id: UUID = field(default_factory=uuid4)
|
||||||
agent_id: str = ""
|
agent_id: str = ""
|
||||||
state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE
|
state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE
|
||||||
process: asyncio.subprocess.Process | None = None
|
container_id: str | None = None # Docker container ID
|
||||||
config: OrchestratorAgentConfig | None = None
|
config: OrchestratorAgentConfig | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
last_activity: datetime | None = None
|
last_activity: datetime | None = None
|
||||||
|
|||||||
+221
-70
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Agent Orchestrator
|
Agent Orchestrator
|
||||||
|
|
||||||
Manages Claude Code instances for all RoboCo agents.
|
Manages Claude Code containers for all RoboCo agents.
|
||||||
Handles spawning, monitoring, health checks, and graceful shutdown.
|
Handles spawning, monitoring, health checks, and graceful shutdown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@ logger = structlog.get_logger()
|
|||||||
AgentState = OrchestratorAgentState
|
AgentState = OrchestratorAgentState
|
||||||
AgentConfig = OrchestratorAgentConfig
|
AgentConfig = OrchestratorAgentConfig
|
||||||
|
|
||||||
|
# Docker configuration
|
||||||
|
AGENT_IMAGE = "roboco-agent"
|
||||||
|
AGENT_NETWORK = "roboco_default"
|
||||||
|
|
||||||
|
# When running in a container, we need host paths for volume mounts.
|
||||||
|
# These can be overridden via environment variables.
|
||||||
|
CLAUDE_AUTH_HOST_PATH = os.environ.get(
|
||||||
|
"ROBOCO_HOST_CLAUDE_DIR",
|
||||||
|
str(Path.home() / ".claude"),
|
||||||
|
)
|
||||||
|
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
|
||||||
|
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ORCHESTRATOR
|
# ORCHESTRATOR
|
||||||
@@ -39,11 +52,11 @@ AgentConfig = OrchestratorAgentConfig
|
|||||||
|
|
||||||
class AgentOrchestrator:
|
class AgentOrchestrator:
|
||||||
"""
|
"""
|
||||||
Manages Claude Code instances for all agents.
|
Manages Claude Code containers for all agents.
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- Spawn agents with correct blueprints
|
- Spawn agents as Docker containers
|
||||||
- Monitor health (heartbeat, errors)
|
- Monitor health via docker inspect
|
||||||
- Handle waiting states and respawning
|
- Handle waiting states and respawning
|
||||||
- Provide status API
|
- Provide status API
|
||||||
- Cost-efficient on-demand spawning
|
- Cost-efficient on-demand spawning
|
||||||
@@ -53,9 +66,11 @@ class AgentOrchestrator:
|
|||||||
self,
|
self,
|
||||||
blueprints_dir: Path | None = None,
|
blueprints_dir: Path | None = None,
|
||||||
mcp_config_dir: Path | None = None,
|
mcp_config_dir: Path | None = None,
|
||||||
|
project_root: Path | None = None,
|
||||||
):
|
):
|
||||||
self.blueprints_dir = blueprints_dir or Path("agents/blueprints")
|
self.blueprints_dir = blueprints_dir or Path("agents/blueprints")
|
||||||
self.mcp_config_dir = mcp_config_dir or Path(".mcp")
|
self.mcp_config_dir = mcp_config_dir or Path(".mcp")
|
||||||
|
self.project_root = project_root or Path.cwd()
|
||||||
|
|
||||||
self._instances: dict[str, AgentInstance] = {}
|
self._instances: dict[str, AgentInstance] = {}
|
||||||
self._waiting_records: dict[str, WaitingRecord] = {}
|
self._waiting_records: dict[str, WaitingRecord] = {}
|
||||||
@@ -70,6 +85,10 @@ class AgentOrchestrator:
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the orchestrator."""
|
"""Start the orchestrator."""
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
|
# Ensure agent image is built
|
||||||
|
await self._ensure_agent_image()
|
||||||
|
|
||||||
self._health_task = asyncio.create_task(self._health_loop())
|
self._health_task = asyncio.create_task(self._health_loop())
|
||||||
logger.info("Orchestrator started")
|
logger.info("Orchestrator started")
|
||||||
|
|
||||||
@@ -88,6 +107,42 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
logger.info("Orchestrator stopped")
|
logger.info("Orchestrator stopped")
|
||||||
|
|
||||||
|
async def _ensure_agent_image(self) -> None:
|
||||||
|
"""Ensure the agent Docker image is built."""
|
||||||
|
# Check if image exists
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "image", "inspect", AGENT_IMAGE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.info("Building agent Docker image...")
|
||||||
|
|
||||||
|
# Determine paths - use host paths when running in container
|
||||||
|
if PROJECT_HOST_PATH:
|
||||||
|
# Running in container - use host paths for Docker build
|
||||||
|
dockerfile_path = f"{PROJECT_HOST_PATH}/docker/agent.Dockerfile"
|
||||||
|
build_context = PROJECT_HOST_PATH
|
||||||
|
else:
|
||||||
|
# Running on host
|
||||||
|
dockerfile_path = str(self.project_root / "docker" / "agent.Dockerfile")
|
||||||
|
build_context = str(self.project_root)
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "build",
|
||||||
|
"-t", AGENT_IMAGE,
|
||||||
|
"-f", dockerfile_path,
|
||||||
|
build_context,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to build agent image: {stderr.decode()}")
|
||||||
|
logger.info("Agent Docker image built successfully")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# AGENT SPAWNING
|
# AGENT SPAWNING
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -100,7 +155,7 @@ class AgentOrchestrator:
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> AgentInstance:
|
) -> AgentInstance:
|
||||||
"""
|
"""
|
||||||
Spawn a Claude Code instance for an agent.
|
Spawn a Claude Code container for an agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_id: Agent identifier (e.g., "be-dev-1")
|
agent_id: Agent identifier (e.g., "be-dev-1")
|
||||||
@@ -154,10 +209,10 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
self._instances[agent_id] = instance
|
self._instances[agent_id] = instance
|
||||||
|
|
||||||
# Spawn the process
|
# Spawn the container
|
||||||
try:
|
try:
|
||||||
process = await self._spawn_process(config, initial_prompt)
|
container_id = await self._spawn_container(config, initial_prompt)
|
||||||
instance.process = process
|
instance.container_id = container_id
|
||||||
instance.state = AgentState.ACTIVE
|
instance.state = AgentState.ACTIVE
|
||||||
instance.started_at = datetime.now(UTC)
|
instance.started_at = datetime.now(UTC)
|
||||||
instance.last_activity = datetime.now(UTC)
|
instance.last_activity = datetime.now(UTC)
|
||||||
@@ -165,6 +220,7 @@ class AgentOrchestrator:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Agent spawned",
|
"Agent spawned",
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
|
container_id=container_id[:12],
|
||||||
model=model,
|
model=model,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
@@ -181,90 +237,149 @@ class AgentOrchestrator:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _spawn_process(
|
async def _spawn_container(
|
||||||
self,
|
self,
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
initial_prompt: str | None = None,
|
initial_prompt: str | None = None,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> str:
|
||||||
"""Spawn the actual Claude Code process."""
|
"""Spawn a Docker container for the agent."""
|
||||||
|
container_name = f"roboco-agent-{config.agent_id}"
|
||||||
|
|
||||||
|
# Remove existing container if any
|
||||||
|
await self._remove_container(container_name)
|
||||||
|
|
||||||
|
# Determine host paths for volume mounts
|
||||||
|
# When running in a container, use PROJECT_HOST_PATH; otherwise use local paths
|
||||||
|
if not config.mcp_config_path:
|
||||||
|
raise RuntimeError("MCP config path not set")
|
||||||
|
|
||||||
|
if PROJECT_HOST_PATH:
|
||||||
|
# Running inside orchestrator container - use host paths
|
||||||
|
blueprints_host = f"{PROJECT_HOST_PATH}/agents/blueprints"
|
||||||
|
claude_host = CLAUDE_AUTH_HOST_PATH
|
||||||
|
mcp_config_host = (
|
||||||
|
f"{DATA_HOST_PATH}/mcp-configs/{config.mcp_config_path.name}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Running directly on host
|
||||||
|
blueprints_host = str(self.blueprints_dir.absolute())
|
||||||
|
claude_host = CLAUDE_AUTH_HOST_PATH
|
||||||
|
mcp_config_host = str(config.mcp_config_path)
|
||||||
|
|
||||||
|
# Build docker run command
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude",
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
container_name,
|
||||||
|
"--network",
|
||||||
|
AGENT_NETWORK,
|
||||||
|
# Mount Claude auth (needs write access for debug logs)
|
||||||
|
"-v",
|
||||||
|
f"{claude_host}:/home/agent/.claude",
|
||||||
|
# Mount blueprints
|
||||||
|
"-v",
|
||||||
|
f"{blueprints_host}:/app/agents/blueprints:ro",
|
||||||
|
# Mount MCP config
|
||||||
|
"-v",
|
||||||
|
f"{mcp_config_host}:/app/mcp-config.json:ro",
|
||||||
|
# Environment
|
||||||
|
"-e",
|
||||||
|
f"ROBOCO_AGENT_ID={config.agent_id}",
|
||||||
|
"-e",
|
||||||
|
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
|
||||||
|
# The image
|
||||||
|
AGENT_IMAGE,
|
||||||
|
# Claude Code arguments
|
||||||
"--model",
|
"--model",
|
||||||
MODEL_MAP.get(config.model, config.model),
|
MODEL_MAP.get(config.model, config.model),
|
||||||
"--system-prompt-file",
|
"--system-prompt-file",
|
||||||
str(config.blueprint_path),
|
f"/app/agents/blueprints/{self._get_blueprint_rel_path(config.agent_id)}",
|
||||||
|
"--mcp-config",
|
||||||
|
"/app/mcp-config.json",
|
||||||
"--output-format",
|
"--output-format",
|
||||||
"stream-json",
|
"stream-json",
|
||||||
|
"--verbose",
|
||||||
|
# Always provide a prompt (required for non-interactive mode)
|
||||||
|
"-p",
|
||||||
|
initial_prompt or (
|
||||||
|
"You are now online. Run roboco_task_scan() to check for work. "
|
||||||
|
"If no tasks are available, call roboco_agent_idle() to go into "
|
||||||
|
"waiting state and conserve resources."
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if config.mcp_config_path:
|
proc = await asyncio.create_subprocess_exec(
|
||||||
cmd.extend(["--mcp-config", str(config.mcp_config_path)])
|
|
||||||
|
|
||||||
if initial_prompt:
|
|
||||||
cmd.extend(["-p", initial_prompt])
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["ROBOCO_AGENT_ID"] = config.agent_id
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
*cmd,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=env,
|
|
||||||
cwd=config.working_directory,
|
|
||||||
)
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
|
||||||
return process
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to start container: {stderr.decode()}")
|
||||||
|
|
||||||
|
container_id = stdout.decode().strip()
|
||||||
|
return container_id
|
||||||
|
|
||||||
|
async def _remove_container(self, container_name: str) -> None:
|
||||||
|
"""Remove a container if it exists."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "rm", "-f", container_name,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
async def _generate_mcp_config(self, agent_id: str) -> Path:
|
async def _generate_mcp_config(self, agent_id: str) -> Path:
|
||||||
"""Generate MCP config for an agent with embedded agent_id."""
|
"""Generate MCP config for an agent."""
|
||||||
|
# MCP servers run inside the container, connect to API via network
|
||||||
config = {
|
config = {
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"roboco-task": {
|
"roboco-task": {
|
||||||
"command": "python",
|
"command": "uv",
|
||||||
"args": [
|
"args": ["run", "python", "-m", "roboco.mcp.task_server", agent_id],
|
||||||
"-m",
|
|
||||||
"roboco.mcp.task_server",
|
|
||||||
agent_id,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
"roboco-message": {
|
"roboco-message": {
|
||||||
"command": "python",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"run", "python", "-m", "roboco.mcp.message_server", agent_id
|
||||||
"roboco.mcp.message_server",
|
|
||||||
agent_id,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"roboco-notify": {
|
"roboco-notify": {
|
||||||
"command": "python",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"run", "python", "-m", "roboco.mcp.notify_server", agent_id
|
||||||
"roboco.mcp.notify_server",
|
|
||||||
agent_id,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"roboco-journal": {
|
"roboco-journal": {
|
||||||
"command": "python",
|
"command": "uv",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"run", "python", "-m", "roboco.mcp.journal_server", agent_id
|
||||||
"roboco.mcp.journal_server",
|
|
||||||
agent_id,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write to temp file
|
# Write to shared config directory (mounted in both orchestrator and agents)
|
||||||
config_path = Path(tempfile.gettempdir()) / f"roboco-mcp-{agent_id}.json"
|
# When running in container: /app/mcp-configs -> host's ./data/mcp-configs
|
||||||
|
# When running on host: use temp directory
|
||||||
|
if DATA_HOST_PATH:
|
||||||
|
# Running in container - use shared mounted directory
|
||||||
|
config_dir = Path("/app/mcp-configs")
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
# Running on host - use temp directory
|
||||||
|
config_dir = Path(tempfile.gettempdir())
|
||||||
|
|
||||||
|
config_path = config_dir / f"roboco-mcp-{agent_id}.json"
|
||||||
config_path.write_text(json.dumps(config, indent=2))
|
config_path.write_text(json.dumps(config, indent=2))
|
||||||
|
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
def _get_blueprint_path(self, agent_id: str) -> Path:
|
def _get_blueprint_path(self, agent_id: str) -> Path:
|
||||||
"""Get blueprint path for an agent."""
|
"""Get blueprint path for an agent."""
|
||||||
# Map agent_id to blueprint
|
|
||||||
role = self._get_agent_role(agent_id)
|
role = self._get_agent_role(agent_id)
|
||||||
team = self._get_agent_team(agent_id)
|
team = self._get_agent_team(agent_id)
|
||||||
|
|
||||||
@@ -278,9 +393,25 @@ class AgentOrchestrator:
|
|||||||
cell_dir = "board"
|
cell_dir = "board"
|
||||||
|
|
||||||
blueprint_file = f"{role.replace('_', '-')}.md"
|
blueprint_file = f"{role.replace('_', '-')}.md"
|
||||||
|
|
||||||
return self.blueprints_dir / cell_dir / blueprint_file
|
return self.blueprints_dir / cell_dir / blueprint_file
|
||||||
|
|
||||||
|
def _get_blueprint_rel_path(self, agent_id: str) -> str:
|
||||||
|
"""Get relative blueprint path for container mount."""
|
||||||
|
role = self._get_agent_role(agent_id)
|
||||||
|
team = self._get_agent_team(agent_id)
|
||||||
|
|
||||||
|
if team == "backend":
|
||||||
|
cell_dir = "backend"
|
||||||
|
elif team == "frontend":
|
||||||
|
cell_dir = "frontend"
|
||||||
|
elif team == "uxui":
|
||||||
|
cell_dir = "ux_ui"
|
||||||
|
else:
|
||||||
|
cell_dir = "board"
|
||||||
|
|
||||||
|
blueprint_file = f"{role.replace('_', '-')}.md"
|
||||||
|
return f"{cell_dir}/{blueprint_file}"
|
||||||
|
|
||||||
def _get_agent_role(self, agent_id: str) -> str:
|
def _get_agent_role(self, agent_id: str) -> str:
|
||||||
"""Get role from agent_id."""
|
"""Get role from agent_id."""
|
||||||
role_map = {
|
role_map = {
|
||||||
@@ -320,33 +451,39 @@ class AgentOrchestrator:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
|
async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
|
||||||
"""Stop an agent."""
|
"""Stop an agent container."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if agent_id not in self._instances:
|
if agent_id not in self._instances:
|
||||||
return
|
return
|
||||||
|
|
||||||
instance = self._instances[agent_id]
|
instance = self._instances[agent_id]
|
||||||
|
|
||||||
if instance.process and instance.process.returncode is None:
|
if instance.container_id:
|
||||||
instance.state = AgentState.STOPPING
|
instance.state = AgentState.STOPPING
|
||||||
|
container_name = f"roboco-agent-{agent_id}"
|
||||||
|
|
||||||
if graceful:
|
if graceful:
|
||||||
# Send interrupt
|
# Graceful stop with timeout
|
||||||
instance.process.terminate()
|
proc = await asyncio.create_subprocess_exec(
|
||||||
try:
|
"docker", "stop", "-t", "10", container_name,
|
||||||
await asyncio.wait_for(
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
instance.process.wait(),
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
timeout=10.0,
|
)
|
||||||
)
|
await proc.wait()
|
||||||
except TimeoutError:
|
|
||||||
instance.process.kill()
|
|
||||||
await instance.process.wait()
|
|
||||||
else:
|
else:
|
||||||
instance.process.kill()
|
# Force kill
|
||||||
await instance.process.wait()
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "kill", container_name,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
# Remove container
|
||||||
|
await self._remove_container(container_name)
|
||||||
|
|
||||||
instance.state = AgentState.OFFLINE
|
instance.state = AgentState.OFFLINE
|
||||||
instance.process = None
|
instance.container_id = None
|
||||||
|
|
||||||
logger.info("Agent stopped", agent_id=agent_id)
|
logger.info("Agent stopped", agent_id=agent_id)
|
||||||
|
|
||||||
@@ -501,18 +638,30 @@ Start by:
|
|||||||
if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
|
if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if instance.process is None:
|
if instance.container_id is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if process died
|
# Check if container is still running
|
||||||
if instance.process.returncode is not None:
|
container_name = f"roboco-agent-{agent_id}"
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker", "inspect", "-f", "{{.State.Running}}", container_name,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
stdout, _ = await proc.communicate()
|
||||||
|
|
||||||
|
is_running = stdout.decode().strip() == "true"
|
||||||
|
|
||||||
|
if not is_running:
|
||||||
|
cid = instance.container_id[:12] if instance.container_id else None
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Agent process died",
|
"Agent container stopped",
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
returncode=instance.process.returncode,
|
container_id=cid,
|
||||||
)
|
)
|
||||||
instance.state = AgentState.OFFLINE
|
instance.state = AgentState.OFFLINE
|
||||||
instance.error_count += 1
|
instance.error_count += 1
|
||||||
|
instance.container_id = None
|
||||||
|
|
||||||
# Auto-restart if not too many errors
|
# Auto-restart if not too many errors
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
@@ -556,10 +705,12 @@ Start by:
|
|||||||
by_state[state.value] = count
|
by_state[state.value] = count
|
||||||
|
|
||||||
for agent_id, instance in self._instances.items():
|
for agent_id, instance in self._instances.items():
|
||||||
|
cid = instance.container_id[:12] if instance.container_id else None
|
||||||
agents.append(
|
agents.append(
|
||||||
{
|
{
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"state": instance.state.value,
|
"state": instance.state.value,
|
||||||
|
"container_id": cid,
|
||||||
"task_id": instance.current_task_id,
|
"task_id": instance.current_task_id,
|
||||||
"error_count": instance.error_count,
|
"error_count": instance.error_count,
|
||||||
"started_at": instance.started_at.isoformat()
|
"started_at": instance.started_at.isoformat()
|
||||||
|
|||||||
@@ -36,25 +36,25 @@ DEFAULT_CHANNELS = [
|
|||||||
"slug": "dev-all",
|
"slug": "dev-all",
|
||||||
"name": "All Developers",
|
"name": "All Developers",
|
||||||
"description": "Cross-cell developer discussion",
|
"description": "Cross-cell developer discussion",
|
||||||
"channel_type": "role",
|
"channel_type": "cross_cell",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "qa-all",
|
"slug": "qa-all",
|
||||||
"name": "All QA",
|
"name": "All QA",
|
||||||
"description": "Cross-cell QA discussion",
|
"description": "Cross-cell QA discussion",
|
||||||
"channel_type": "role",
|
"channel_type": "cross_cell",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "pm-all",
|
"slug": "pm-all",
|
||||||
"name": "All PMs",
|
"name": "All PMs",
|
||||||
"description": "Cross-cell PM coordination",
|
"description": "Cross-cell PM coordination",
|
||||||
"channel_type": "role",
|
"channel_type": "cross_cell",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "doc-all",
|
"slug": "doc-all",
|
||||||
"name": "All Documenters",
|
"name": "All Documenters",
|
||||||
"description": "Cross-cell documentation discussion",
|
"description": "Cross-cell documentation discussion",
|
||||||
"channel_type": "role",
|
"channel_type": "cross_cell",
|
||||||
},
|
},
|
||||||
# Management channels
|
# Management channels
|
||||||
{
|
{
|
||||||
@@ -74,13 +74,13 @@ DEFAULT_CHANNELS = [
|
|||||||
"slug": "announcements",
|
"slug": "announcements",
|
||||||
"name": "Announcements",
|
"name": "Announcements",
|
||||||
"description": "Company-wide announcements (read-only for most)",
|
"description": "Company-wide announcements (read-only for most)",
|
||||||
"channel_type": "broadcast",
|
"channel_type": "special",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "all-hands",
|
"slug": "all-hands",
|
||||||
"name": "All Hands",
|
"name": "All Hands",
|
||||||
"description": "Company-wide open discussion",
|
"description": "Company-wide open discussion",
|
||||||
"channel_type": "broadcast",
|
"channel_type": "special",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -137,15 +137,15 @@ DEFAULT_AGENTS: list[dict[str, Any]] = [
|
|||||||
"agent_id": "ux-dev",
|
"agent_id": "ux-dev",
|
||||||
"name": "UX/UI Developer",
|
"name": "UX/UI Developer",
|
||||||
"role": "developer",
|
"role": "developer",
|
||||||
"team": "uxui",
|
"team": "ux_ui",
|
||||||
},
|
},
|
||||||
{"agent_id": "ux-qa", "name": "UX/UI QA", "role": "qa", "team": "uxui"},
|
{"agent_id": "ux-qa", "name": "UX/UI QA", "role": "qa", "team": "ux_ui"},
|
||||||
{"agent_id": "ux-pm", "name": "UX/UI PM", "role": "cell_pm", "team": "uxui"},
|
{"agent_id": "ux-pm", "name": "UX/UI PM", "role": "cell_pm", "team": "ux_ui"},
|
||||||
{
|
{
|
||||||
"agent_id": "ux-doc",
|
"agent_id": "ux-doc",
|
||||||
"name": "UX/UI Documenter",
|
"name": "UX/UI Documenter",
|
||||||
"role": "documenter",
|
"role": "documenter",
|
||||||
"team": "uxui",
|
"team": "ux_ui",
|
||||||
},
|
},
|
||||||
# Board / Management
|
# Board / Management
|
||||||
{"agent_id": "main-pm", "name": "Main PM", "role": "main_pm", "team": None},
|
{"agent_id": "main-pm", "name": "Main PM", "role": "main_pm", "team": None},
|
||||||
|
|||||||
Reference in New Issue
Block a user