diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..2a66370 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,103 @@ +# Orchestrator Roadmap + +This document tracks Orchestrator-specific work for the AI SysAdmin system. + +## Completed Work + +### Core Infrastructure +- [x] Task model, statuses, DB tables +- [x] Agent model + registration flow +- [x] Task dispatching (`/tasks/next` polling) +- [x] Heartbeat system +- [x] Result ingestion +- [x] Tenant validation, permission flow +- [x] FastAPI app with complete routing structure +- [x] Deployment on live server (nginx + SSL) + +### API Routes +- [x] `/api/v1/agents/*` - Agent registration, heartbeat +- [x] `/api/v1/tasks/*` - Task creation, polling, results +- [x] `/api/v1/tenants/*` - Tenant management +- [x] `/api/v1/env/*` - ENV inspection and update +- [x] `/api/v1/agents/{id}/files/inspect` - File inspection + +### Playbooks +- [x] Chatwoot playbook (`/api/v1/tenants/{id}/chatwoot/setup`) + +--- + +## Remaining Work + +### Phase 1: Tool-Specific Playbooks + +Create a playbook module for each tool in `app/playbooks/`: + +| Tool | Module | API Endpoint | Status | +|------|--------|--------------|--------| +| Chatwoot | `chatwoot.py` | `/tenants/{id}/chatwoot/setup` | ✅ Done | +| NocoDB | `nocodb.py` | `/tenants/{id}/nocodb/setup` | ⬚ Todo | +| Directus | `directus.py` | `/tenants/{id}/directus/setup` | ⬚ Todo | +| Ghost CMS | `ghost.py` | `/tenants/{id}/ghost/setup` | ⬚ Todo | +| MinIO | `minio.py` | `/tenants/{id}/minio/setup` | ⬚ Todo | +| Keycloak | `keycloak.py` | `/tenants/{id}/keycloak/setup` | ⬚ Todo | +| Nextcloud | `nextcloud.py` | `/tenants/{id}/nextcloud/setup` | ⬚ Todo | +| Activepieces | `activepieces.py` | `/tenants/{id}/activepieces/setup` | ⬚ Todo | +| Listmonk | `listmonk.py` | `/tenants/{id}/listmonk/setup` | ⬚ Todo | +| Odoo | `odoo.py` | `/tenants/{id}/odoo/setup` | ⬚ Todo | +| Mixpost | `mixpost.py` | `/tenants/{id}/mixpost/setup` | ⬚ Todo | + +**Each playbook creates a COMPOSITE task with:** +1. `ENV_INSPECT` - Read current configuration +2. `ENV_UPDATE` - Update URLs, domains, settings +3. `DOCKER_RELOAD` - Restart the stack + +**Each playbook module needs:** +- Pydantic request/response schemas +- Route handler in `app/routes/playbooks.py` +- Tests in `tests/test_playbooks/` + +--- + +### Phase 2: Introspection APIs + +- [ ] `/api/v1/servers/{id}/scan` - Discover all services and their state +- [ ] `/api/v1/servers/{id}/diagnose` - Find configuration issues +- [ ] `/api/v1/servers/{id}/health` - Aggregate health check + +--- + +### Phase 3: New Task Types + +Support for new executor types from the agent: + +| Task Type | Purpose | Status | +|-----------|---------|--------| +| NGINX_RELOAD | Reload nginx after config changes | ⬚ Todo | +| HEALTHCHECK | Check service status | ⬚ Todo | +| STACK_HEALTH | Verify docker compose stack | ⬚ Todo | + +--- + +### Phase 4: Autonomous Operations + +- [ ] LLM integration for natural language commands +- [ ] Task chaining based on results +- [ ] Automatic remediation workflows + +--- + +### Phase 5: Dashboard & UI + +- [ ] Task history and logs viewer +- [ ] Agent status dashboard +- [ ] Playbook marketplace +- [ ] RBAC and multi-tenant UI + +--- + +## Next Steps + +1. Create `app/playbooks/nocodb.py` +2. Add route: `POST /api/v1/tenants/{tenant_id}/nocodb/setup` +3. Create COMPOSITE task with ENV_INSPECT, ENV_UPDATE, DOCKER_RELOAD +4. Write tests in `tests/test_playbooks/test_nocodb.py` diff --git a/app/routes/agents.py b/app/routes/agents.py index af8c46e..2ba2cc3 100644 --- a/app/routes/agents.py +++ b/app/routes/agents.py @@ -9,6 +9,7 @@ from sqlalchemy import select from app.db import AsyncSessionDep from app.models.agent import Agent, AgentStatus from app.models.base import utc_now +from app.models.tenant import Tenant from app.schemas.agent import ( AgentHeartbeatResponse, AgentRegisterRequest, @@ -27,6 +28,12 @@ async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | N return result.scalar_one_or_none() +async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None: + """Retrieve a tenant by ID.""" + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + return result.scalar_one_or_none() + + async def validate_agent_token( db: AsyncSessionDep, agent_id: uuid.UUID, @@ -98,9 +105,21 @@ async def register_agent( - **hostname**: Agent hostname (will be used as name) - **version**: Agent software version - **metadata**: Optional JSON metadata + - **tenant_id**: Optional tenant UUID to associate the agent with Returns agent_id and token for subsequent API calls. + + If tenant_id is provided but invalid, returns 404 Not Found. """ + # Validate tenant exists if provided + if request.tenant_id is not None: + tenant = await get_tenant_by_id(db, request.tenant_id) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tenant {request.tenant_id} not found", + ) + agent_id = uuid.uuid4() token = secrets.token_hex(32) @@ -111,7 +130,7 @@ async def register_agent( status=AgentStatus.ONLINE.value, last_heartbeat=utc_now(), token=token, - tenant_id=None, # Agents register without tenant initially + tenant_id=request.tenant_id, ) db.add(agent) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 787d79a..732bc97 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Header, HTTPException, Query, status from sqlalchemy import select from app.db import AsyncSessionDep +from app.models.agent import Agent from app.models.task import Task, TaskStatus from app.routes.agents import validate_agent_token from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate @@ -162,14 +163,19 @@ async def list_tasks_endpoint( # NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues -async def get_next_pending_task(db: AsyncSessionDep) -> Task | None: - """Get the oldest pending task.""" - query = ( - select(Task) - .where(Task.status == TaskStatus.PENDING.value) - .order_by(Task.created_at.asc()) - .limit(1) - ) +async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None: + """Get the oldest pending task for the agent's tenant. + + If the agent has a tenant_id, only returns tasks for that tenant. + If the agent has no tenant_id (shared agent), returns any pending task. + """ + query = select(Task).where(Task.status == TaskStatus.PENDING.value) + + # Filter by agent's tenant if agent is tenant-specific + if agent.tenant_id is not None: + query = query.where(Task.tenant_id == agent.tenant_id) + + query = query.order_by(Task.created_at.asc()).limit(1) result = await db.execute(query) return result.scalar_one_or_none() @@ -188,13 +194,17 @@ async def get_next_task_endpoint( - Setting status to 'running' - Assigning agent_id to the requesting agent + Tasks are filtered by the agent's tenant_id: + - If agent has a tenant_id, only returns tasks for that tenant + - If agent has no tenant_id (shared agent), can claim any task + Returns null (200) if no pending tasks are available. """ - # Validate agent credentials - await validate_agent_token(db, agent_id, authorization) + # Validate agent credentials and get agent object + agent = await validate_agent_token(db, agent_id, authorization) - # Get next pending task - task = await get_next_pending_task(db) + # Get next pending task for this agent's tenant + task = await get_next_pending_task(db, agent) if task is None: return None diff --git a/app/schemas/agent.py b/app/schemas/agent.py index f4339da..8b1997c 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -13,6 +13,10 @@ class AgentRegisterRequest(BaseModel): hostname: str = Field(..., min_length=1, max_length=255) version: str = Field(..., min_length=1, max_length=50) metadata: dict[str, Any] | None = None + tenant_id: uuid.UUID | None = Field( + default=None, + description="Tenant UUID to associate the agent with" + ) class AgentRegisterResponse(BaseModel):