feat: add tenant_id to agent registration and task filtering

- Add tenant_id field to AgentRegisterRequest schema
- Validate tenant exists during agent registration (returns 404 if not found)
- Update /tasks/next to filter tasks by agent's tenant_id
- Tenant-specific agents only see their tenant's tasks
- Shared agents (no tenant_id) can still claim any task
- Add ROADMAP.md tracking project progress

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-05 20:09:58 +01:00
parent 124a91af5a
commit 0975d208ef
4 changed files with 149 additions and 13 deletions

103
ROADMAP.md Normal file
View File

@ -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`

View File

@ -9,6 +9,7 @@ from sqlalchemy import select
from app.db import AsyncSessionDep from app.db import AsyncSessionDep
from app.models.agent import Agent, AgentStatus from app.models.agent import Agent, AgentStatus
from app.models.base import utc_now from app.models.base import utc_now
from app.models.tenant import Tenant
from app.schemas.agent import ( from app.schemas.agent import (
AgentHeartbeatResponse, AgentHeartbeatResponse,
AgentRegisterRequest, 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() 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( async def validate_agent_token(
db: AsyncSessionDep, db: AsyncSessionDep,
agent_id: uuid.UUID, agent_id: uuid.UUID,
@ -98,9 +105,21 @@ async def register_agent(
- **hostname**: Agent hostname (will be used as name) - **hostname**: Agent hostname (will be used as name)
- **version**: Agent software version - **version**: Agent software version
- **metadata**: Optional JSON metadata - **metadata**: Optional JSON metadata
- **tenant_id**: Optional tenant UUID to associate the agent with
Returns agent_id and token for subsequent API calls. 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() agent_id = uuid.uuid4()
token = secrets.token_hex(32) token = secrets.token_hex(32)
@ -111,7 +130,7 @@ async def register_agent(
status=AgentStatus.ONLINE.value, status=AgentStatus.ONLINE.value,
last_heartbeat=utc_now(), last_heartbeat=utc_now(),
token=token, token=token,
tenant_id=None, # Agents register without tenant initially tenant_id=request.tenant_id,
) )
db.add(agent) db.add(agent)

View File

@ -6,6 +6,7 @@ from fastapi import APIRouter, Header, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from app.db import AsyncSessionDep from app.db import AsyncSessionDep
from app.models.agent import Agent
from app.models.task import Task, TaskStatus from app.models.task import Task, TaskStatus
from app.routes.agents import validate_agent_token from app.routes.agents import validate_agent_token
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate 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 # NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues
async def get_next_pending_task(db: AsyncSessionDep) -> Task | None: async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None:
"""Get the oldest pending task.""" """Get the oldest pending task for the agent's tenant.
query = (
select(Task) If the agent has a tenant_id, only returns tasks for that tenant.
.where(Task.status == TaskStatus.PENDING.value) If the agent has no tenant_id (shared agent), returns any pending task.
.order_by(Task.created_at.asc()) """
.limit(1) 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) result = await db.execute(query)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@ -188,13 +194,17 @@ async def get_next_task_endpoint(
- Setting status to 'running' - Setting status to 'running'
- Assigning agent_id to the requesting agent - 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. Returns null (200) if no pending tasks are available.
""" """
# Validate agent credentials # Validate agent credentials and get agent object
await validate_agent_token(db, agent_id, authorization) agent = await validate_agent_token(db, agent_id, authorization)
# Get next pending task # Get next pending task for this agent's tenant
task = await get_next_pending_task(db) task = await get_next_pending_task(db, agent)
if task is None: if task is None:
return None return None

View File

@ -13,6 +13,10 @@ class AgentRegisterRequest(BaseModel):
hostname: str = Field(..., min_length=1, max_length=255) hostname: str = Field(..., min_length=1, max_length=255)
version: str = Field(..., min_length=1, max_length=50) version: str = Field(..., min_length=1, max_length=50)
metadata: dict[str, Any] | None = None 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): class AgentRegisterResponse(BaseModel):