feat: add task queue with human-in-the-loop approval + work orders + UI bug fixes

Task Queue system:
- New /tasks page with three-column layout (Pending/Active/Completed)
- Full CRUD API at /api/tasks with approve/veto/modify/pause/cancel/retry
- SQLite persistence in task_queue table
- WebSocket live updates via ws_manager
- Create task modal with agent assignment and priority
- Auto-approve rules for low-risk tasks
- HTMX polling for real-time column updates
- HOME TASK buttons now link to task queue with agent pre-selected
- MARKET HIRE buttons link to task queue with agent pre-selected

Work Order system:
- External submission API for agents/users (POST /work-orders/submit)
- Risk scoring and configurable auto-execution thresholds
- Dashboard at /work-orders/queue with approve/reject/execute flow
- Integration with swarm task system for execution

UI & Dashboard bug fixes:
- EVENTS: add startup event so page is never empty
- LEDGER: fix empty filter params in URL
- MISSION CONTROL: LLM backend and model now read from /health
- MISSION CONTROL: agent count fallback to /swarm/agents
- SWARM: HTMX fallback loads initial data if WebSocket is slow
- MEMORY: add edit/delete buttons for personal facts
- UPGRADES: add empty state guidance with links
- BRIEFING: add regenerate button and POST /briefing/regenerate endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Payne
2026-02-26 10:27:08 -05:00
parent 4e78f7102e
commit 5f9bbb8435
31 changed files with 3159 additions and 47 deletions

View File

@@ -84,6 +84,12 @@ class Settings(BaseSettings):
self_modify_allowed_dirs: str = "src,tests"
self_modify_backend: str = "auto" # "ollama", "anthropic", or "auto"
# ── Work Orders ──────────────────────────────────────────────────
# External users and agents can submit work orders for improvements.
work_orders_enabled: bool = True
work_orders_auto_execute: bool = False # Master switch for auto-execution
work_orders_auto_threshold: str = "low" # Max priority that auto-executes: "low" | "medium" | "high" | "none"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",

View File

@@ -32,6 +32,8 @@ from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from router.api import router as cascade_router
logging.basicConfig(
@@ -107,6 +109,17 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
# Log system startup event so the Events page is never empty
try:
from swarm.event_log import log_event, EventType
log_event(
EventType.SYSTEM_INFO,
source="coordinator",
data={"message": "Timmy Time system started"},
)
except Exception:
pass
# Auto-bootstrap MCP tools
from mcp.bootstrap import auto_bootstrap, get_bootstrap_status
try:
@@ -182,6 +195,8 @@ app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(cascade_router)

View File

@@ -10,7 +10,7 @@ import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from timmy.briefing import engine as briefing_engine
@@ -68,3 +68,14 @@ async def reject_item(request: Request, item_id: str):
"partials/approval_card_single.html",
{"item": item},
)
@router.post("/regenerate", response_class=JSONResponse)
async def regenerate_briefing():
"""Force-regenerate today's briefing."""
try:
briefing = briefing_engine.generate()
return JSONResponse({"success": True, "generated_at": str(briefing.generated_at)})
except Exception as exc:
logger.exception("Failed to regenerate briefing")
return JSONResponse({"success": False, "error": str(exc)}, status_code=500)

View File

@@ -243,6 +243,8 @@ async def health_check():
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": "2.0.0",
"uptime_seconds": uptime,
"llm_backend": settings.timmy_model_backend,
"llm_model": settings.ollama_model,
}

View File

@@ -3,8 +3,8 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from memory.vector_store import (
@@ -12,7 +12,10 @@ from memory.vector_store import (
search_memories,
get_memory_stats,
recall_personal_facts,
recall_personal_facts_with_ids,
store_personal_fact,
update_personal_fact,
delete_memory,
)
router = APIRouter(prefix="/memory", tags=["memory"])
@@ -37,7 +40,7 @@ async def memory_page(
)
stats = get_memory_stats()
facts = recall_personal_facts()[:10]
facts = recall_personal_facts_with_ids()[:10]
return templates.TemplateResponse(
request,
@@ -86,13 +89,32 @@ async def add_fact(
):
"""Add a personal fact to memory."""
store_personal_fact(fact, agent_id=agent_id)
# Return updated facts list
facts = recall_personal_facts()[:10]
facts = recall_personal_facts_with_ids()[:10]
return templates.TemplateResponse(
request,
"partials/memory_facts.html",
{
"facts": facts,
},
{"facts": facts},
)
@router.put("/fact/{fact_id}", response_class=JSONResponse)
async def edit_fact(fact_id: str, request: Request):
"""Update a personal fact."""
body = await request.json()
new_content = body.get("content", "").strip()
if not new_content:
raise HTTPException(400, "Content cannot be empty")
ok = update_personal_fact(fact_id, new_content)
if not ok:
raise HTTPException(404, "Fact not found")
return {"success": True, "id": fact_id, "content": new_content}
@router.delete("/fact/{fact_id}", response_class=JSONResponse)
async def delete_fact(fact_id: str):
"""Delete a personal fact."""
ok = delete_memory(fact_id)
if not ok:
raise HTTPException(404, "Fact not found")
return {"success": True, "id": fact_id}

View File

@@ -0,0 +1,472 @@
"""Task Queue routes — Human-in-the-loop approval dashboard.
GET /tasks — Task queue dashboard page
GET /api/tasks — List tasks (JSON)
POST /api/tasks — Create a new task (JSON)
GET /api/tasks/counts — Badge counts
GET /api/tasks/{id} — Get single task
PATCH /api/tasks/{id}/approve — Approve a task
PATCH /api/tasks/{id}/veto — Veto a task
PATCH /api/tasks/{id}/modify — Modify a task
PATCH /api/tasks/{id}/pause — Pause a running task
PATCH /api/tasks/{id}/cancel — Cancel / fail a task
PATCH /api/tasks/{id}/retry — Retry a failed task
GET /tasks/pending — HTMX partial: pending tasks
GET /tasks/active — HTMX partial: active tasks
GET /tasks/completed — HTMX partial: completed tasks
"""
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from task_queue.models import (
QueueTask,
TaskPriority,
TaskStatus,
create_task,
get_counts_by_status,
get_pending_count,
get_task,
list_tasks,
update_task,
update_task_status,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["tasks"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
# ── Helper to broadcast task events via WebSocket ────────────────────────
def _broadcast_task_event(event_type: str, task: QueueTask):
"""Best-effort broadcast a task event to connected WebSocket clients."""
try:
import asyncio
from ws_manager.handler import ws_manager
payload = {
"type": "task_event",
"event": event_type,
"task": {
"id": task.id,
"title": task.title,
"status": task.status.value,
"priority": task.priority.value,
"assigned_to": task.assigned_to,
"created_by": task.created_by,
},
}
try:
loop = asyncio.get_running_loop()
loop.create_task(ws_manager.broadcast_json(payload))
except RuntimeError:
pass # No event loop running (e.g. in tests)
except Exception:
pass # WebSocket is optional
# ── Dashboard page ───────────────────────────────────────────────────────
@router.get("/tasks", response_class=HTMLResponse)
async def task_queue_page(request: Request, assign: Optional[str] = None):
"""Task queue dashboard with three columns."""
pending = list_tasks(status=TaskStatus.PENDING_APPROVAL) + \
list_tasks(status=TaskStatus.APPROVED)
active = list_tasks(status=TaskStatus.RUNNING) + \
list_tasks(status=TaskStatus.PAUSED)
completed = list_tasks(status=TaskStatus.COMPLETED, limit=20) + \
list_tasks(status=TaskStatus.VETOED, limit=10) + \
list_tasks(status=TaskStatus.FAILED, limit=10)
# Get agents for the create modal
agents = []
try:
from swarm.coordinator import coordinator
agents = [
{"id": a.id, "name": a.name}
for a in coordinator.list_swarm_agents()
]
except Exception:
pass
# Always include core agents
core_agents = ["timmy", "forge", "seer", "echo"]
agent_names = {a["name"] for a in agents}
for name in core_agents:
if name not in agent_names:
agents.append({"id": name, "name": name})
return templates.TemplateResponse(
request,
"tasks.html",
{
"page_title": "Task Queue",
"pending": pending,
"active": active,
"completed": completed,
"pending_count": len(pending),
"agents": agents,
"priorities": [p.value for p in TaskPriority],
"pre_assign": assign or "",
},
)
# ── HTMX partials ───────────────────────────────────────────────────────
@router.get("/tasks/pending", response_class=HTMLResponse)
async def tasks_pending_partial(request: Request):
"""HTMX partial: pending approval tasks."""
pending = list_tasks(status=TaskStatus.PENDING_APPROVAL) + \
list_tasks(status=TaskStatus.APPROVED)
return templates.TemplateResponse(
request,
"partials/task_cards.html",
{"tasks": pending, "section": "pending"},
)
@router.get("/tasks/active", response_class=HTMLResponse)
async def tasks_active_partial(request: Request):
"""HTMX partial: active tasks."""
active = list_tasks(status=TaskStatus.RUNNING) + \
list_tasks(status=TaskStatus.PAUSED)
return templates.TemplateResponse(
request,
"partials/task_cards.html",
{"tasks": active, "section": "active"},
)
@router.get("/tasks/completed", response_class=HTMLResponse)
async def tasks_completed_partial(request: Request):
"""HTMX partial: completed tasks."""
completed = list_tasks(status=TaskStatus.COMPLETED, limit=20) + \
list_tasks(status=TaskStatus.VETOED, limit=10) + \
list_tasks(status=TaskStatus.FAILED, limit=10)
return templates.TemplateResponse(
request,
"partials/task_cards.html",
{"tasks": completed, "section": "completed"},
)
# ── JSON API ─────────────────────────────────────────────────────────────
@router.get("/api/tasks", response_class=JSONResponse)
async def api_list_tasks(
status: Optional[str] = None,
priority: Optional[str] = None,
assigned_to: Optional[str] = None,
limit: int = 100,
):
"""List tasks with optional filters."""
s = TaskStatus(status) if status else None
p = TaskPriority(priority) if priority else None
tasks = list_tasks(status=s, priority=p, assigned_to=assigned_to, limit=limit)
return {
"tasks": [_task_to_dict(t) for t in tasks],
"count": len(tasks),
}
@router.post("/api/tasks", response_class=JSONResponse)
async def api_create_task(request: Request):
"""Create a new task (JSON body)."""
body = await request.json()
task = create_task(
title=body.get("title", ""),
description=body.get("description", ""),
assigned_to=body.get("assigned_to", "timmy"),
created_by=body.get("created_by", "user"),
priority=body.get("priority", "normal"),
requires_approval=body.get("requires_approval", True),
auto_approve=body.get("auto_approve", False),
parent_task_id=body.get("parent_task_id"),
steps=body.get("steps"),
)
# Notify
_notify_task_created(task)
_broadcast_task_event("task_created", task)
logger.info("Task created: %s (status=%s)", task.title, task.status.value)
return {"success": True, "task": _task_to_dict(task)}
@router.post("/tasks/create", response_class=HTMLResponse)
async def form_create_task(
request: Request,
title: str = Form(...),
description: str = Form(""),
assigned_to: str = Form("timmy"),
priority: str = Form("normal"),
requires_approval: bool = Form(True),
):
"""Create a task from the dashboard form (Form-encoded)."""
task = create_task(
title=title,
description=description,
assigned_to=assigned_to,
created_by="user",
priority=priority,
requires_approval=requires_approval,
)
_notify_task_created(task)
_broadcast_task_event("task_created", task)
logger.info("Task created (form): %s", task.title)
# Return the new card for HTMX swap
return templates.TemplateResponse(
request,
"partials/task_card.html",
{"task": task},
)
@router.get("/api/tasks/counts", response_class=JSONResponse)
async def api_task_counts():
"""Get task counts by status (for nav badges)."""
counts = get_counts_by_status()
return {
"pending": counts.get("pending_approval", 0),
"approved": counts.get("approved", 0),
"running": counts.get("running", 0),
"completed": counts.get("completed", 0),
"failed": counts.get("failed", 0),
"vetoed": counts.get("vetoed", 0),
"total": sum(counts.values()),
}
@router.get("/api/tasks/{task_id}", response_class=JSONResponse)
async def api_get_task(task_id: str):
"""Get a single task by ID."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
return _task_to_dict(task)
# ── Workflow actions ─────────────────────────────────────────────────────
@router.patch("/api/tasks/{task_id}/approve", response_class=JSONResponse)
async def api_approve_task(task_id: str):
"""Approve a pending task."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status not in (TaskStatus.PENDING_APPROVAL,):
raise HTTPException(400, f"Cannot approve task in {task.status.value} state")
updated = update_task_status(task_id, TaskStatus.APPROVED)
_broadcast_task_event("task_approved", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
async def htmx_approve_task(request: Request, task_id: str):
"""Approve a pending task (HTMX)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task_status(task_id, TaskStatus.APPROVED)
_broadcast_task_event("task_approved", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
@router.patch("/api/tasks/{task_id}/veto", response_class=JSONResponse)
async def api_veto_task(task_id: str):
"""Veto (reject) a task."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
raise HTTPException(400, f"Cannot veto task in {task.status.value} state")
updated = update_task_status(task_id, TaskStatus.VETOED)
_broadcast_task_event("task_vetoed", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
async def htmx_veto_task(request: Request, task_id: str):
"""Veto a task (HTMX)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task_status(task_id, TaskStatus.VETOED)
_broadcast_task_event("task_vetoed", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
@router.patch("/api/tasks/{task_id}/modify", response_class=JSONResponse)
async def api_modify_task(task_id: str, request: Request):
"""Modify a task's title, description, assignment, or priority."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
raise HTTPException(400, f"Cannot modify task in {task.status.value} state")
body = await request.json()
updated = update_task(
task_id,
title=body.get("title"),
description=body.get("description"),
assigned_to=body.get("assigned_to"),
priority=body.get("priority"),
)
_broadcast_task_event("task_modified", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/modify", response_class=HTMLResponse)
async def htmx_modify_task(
request: Request,
task_id: str,
title: str = Form(None),
description: str = Form(None),
assigned_to: str = Form(None),
priority: str = Form(None),
):
"""Modify a task (HTMX form)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task(
task_id,
title=title,
description=description,
assigned_to=assigned_to,
priority=priority,
)
_broadcast_task_event("task_modified", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
@router.patch("/api/tasks/{task_id}/pause", response_class=JSONResponse)
async def api_pause_task(task_id: str):
"""Pause a running task."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status != TaskStatus.RUNNING:
raise HTTPException(400, "Can only pause running tasks")
updated = update_task_status(task_id, TaskStatus.PAUSED)
_broadcast_task_event("task_paused", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
async def htmx_pause_task(request: Request, task_id: str):
"""Pause a running task (HTMX)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task_status(task_id, TaskStatus.PAUSED)
_broadcast_task_event("task_paused", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
@router.patch("/api/tasks/{task_id}/cancel", response_class=JSONResponse)
async def api_cancel_task(task_id: str):
"""Cancel a task (sets to failed)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status in (TaskStatus.COMPLETED, TaskStatus.VETOED):
raise HTTPException(400, f"Cannot cancel task in {task.status.value} state")
updated = update_task_status(task_id, TaskStatus.FAILED, result="Cancelled by user")
_broadcast_task_event("task_cancelled", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
async def htmx_cancel_task(request: Request, task_id: str):
"""Cancel a task (HTMX)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task_status(task_id, TaskStatus.FAILED, result="Cancelled by user")
_broadcast_task_event("task_cancelled", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
@router.patch("/api/tasks/{task_id}/retry", response_class=JSONResponse)
async def api_retry_task(task_id: str):
"""Retry a failed task (resets to approved)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
if task.status != TaskStatus.FAILED:
raise HTTPException(400, "Can only retry failed tasks")
updated = update_task_status(task_id, TaskStatus.APPROVED, result=None)
_broadcast_task_event("task_retried", updated)
return {"success": True, "task": _task_to_dict(updated)}
@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
async def htmx_retry_task(request: Request, task_id: str):
"""Retry a failed task (HTMX)."""
task = get_task(task_id)
if not task:
raise HTTPException(404, "Task not found")
updated = update_task_status(task_id, TaskStatus.APPROVED, result=None)
_broadcast_task_event("task_retried", updated)
return templates.TemplateResponse(
request, "partials/task_card.html", {"task": updated}
)
# ── Helpers ──────────────────────────────────────────────────────────────
def _task_to_dict(task: QueueTask) -> dict:
return {
"id": task.id,
"title": task.title,
"description": task.description,
"assigned_to": task.assigned_to,
"created_by": task.created_by,
"status": task.status.value,
"priority": task.priority.value,
"requires_approval": task.requires_approval,
"auto_approve": task.auto_approve,
"parent_task_id": task.parent_task_id,
"result": task.result,
"steps": task.steps,
"created_at": task.created_at,
"started_at": task.started_at,
"completed_at": task.completed_at,
"updated_at": task.updated_at,
}
def _notify_task_created(task: QueueTask):
try:
from notifications.push import notifier
notifier.notify(
title="New Task",
message=f"{task.created_by} created: {task.title}",
category="task",
native=task.priority in (TaskPriority.HIGH, TaskPriority.URGENT),
)
except Exception:
pass

View File

@@ -0,0 +1,333 @@
"""Work Order queue dashboard routes."""
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from work_orders.models import (
WorkOrder,
WorkOrderCategory,
WorkOrderPriority,
WorkOrderStatus,
create_work_order,
get_counts_by_status,
get_pending_count,
get_work_order,
list_work_orders,
update_work_order_status,
)
from work_orders.risk import compute_risk_score, should_auto_execute
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
# ── Submission ─────────────────────────────────────────────────────────────────
@router.post("/submit", response_class=JSONResponse)
async def submit_work_order(
title: str = Form(...),
description: str = Form(""),
priority: str = Form("medium"),
category: str = Form("suggestion"),
submitter: str = Form("unknown"),
submitter_type: str = Form("user"),
related_files: str = Form(""),
):
"""Submit a new work order (form-encoded).
This is the primary API for external tools (like Comet) to submit
work orders and suggestions.
"""
files = [f.strip() for f in related_files.split(",") if f.strip()] if related_files else []
wo = create_work_order(
title=title,
description=description,
priority=priority,
category=category,
submitter=submitter,
submitter_type=submitter_type,
related_files=files,
)
# Auto-triage: determine execution mode
auto = should_auto_execute(wo)
risk = compute_risk_score(wo)
mode = "auto" if auto else "manual"
update_work_order_status(
wo.id, WorkOrderStatus.TRIAGED, execution_mode=mode,
)
# Notify
try:
from notifications.push import notifier
notifier.notify(
title="New Work Order",
message=f"{wo.submitter} submitted: {wo.title}",
category="work_order",
native=wo.priority in (WorkOrderPriority.CRITICAL, WorkOrderPriority.HIGH),
)
except Exception:
pass
logger.info("Work order submitted: %s (risk=%d, mode=%s)", wo.title, risk, mode)
return {
"success": True,
"work_order_id": wo.id,
"title": wo.title,
"risk_score": risk,
"execution_mode": mode,
"status": "triaged",
}
@router.post("/submit/json", response_class=JSONResponse)
async def submit_work_order_json(request: Request):
"""Submit a new work order (JSON body)."""
body = await request.json()
files = body.get("related_files", [])
if isinstance(files, str):
files = [f.strip() for f in files.split(",") if f.strip()]
wo = create_work_order(
title=body.get("title", ""),
description=body.get("description", ""),
priority=body.get("priority", "medium"),
category=body.get("category", "suggestion"),
submitter=body.get("submitter", "unknown"),
submitter_type=body.get("submitter_type", "user"),
related_files=files,
)
auto = should_auto_execute(wo)
risk = compute_risk_score(wo)
mode = "auto" if auto else "manual"
update_work_order_status(
wo.id, WorkOrderStatus.TRIAGED, execution_mode=mode,
)
try:
from notifications.push import notifier
notifier.notify(
title="New Work Order",
message=f"{wo.submitter} submitted: {wo.title}",
category="work_order",
)
except Exception:
pass
logger.info("Work order submitted (JSON): %s (risk=%d, mode=%s)", wo.title, risk, mode)
return {
"success": True,
"work_order_id": wo.id,
"title": wo.title,
"risk_score": risk,
"execution_mode": mode,
"status": "triaged",
}
# ── CRUD / Query ───────────────────────────────────────────────────────────────
@router.get("", response_class=JSONResponse)
async def list_orders(
status: Optional[str] = None,
priority: Optional[str] = None,
category: Optional[str] = None,
submitter: Optional[str] = None,
limit: int = 100,
):
"""List work orders with optional filters."""
s = WorkOrderStatus(status) if status else None
p = WorkOrderPriority(priority) if priority else None
c = WorkOrderCategory(category) if category else None
orders = list_work_orders(status=s, priority=p, category=c, submitter=submitter, limit=limit)
return {
"work_orders": [
{
"id": wo.id,
"title": wo.title,
"description": wo.description,
"priority": wo.priority.value,
"category": wo.category.value,
"status": wo.status.value,
"submitter": wo.submitter,
"submitter_type": wo.submitter_type,
"execution_mode": wo.execution_mode,
"created_at": wo.created_at,
"updated_at": wo.updated_at,
}
for wo in orders
],
"count": len(orders),
}
@router.get("/api/counts", response_class=JSONResponse)
async def work_order_counts():
"""Get work order counts by status (for nav badges)."""
counts = get_counts_by_status()
return {
"pending": counts.get("submitted", 0) + counts.get("triaged", 0),
"in_progress": counts.get("in_progress", 0),
"total": sum(counts.values()),
"by_status": counts,
}
# ── Dashboard UI (must be before /{wo_id} to avoid path conflict) ─────────────
@router.get("/queue", response_class=HTMLResponse)
async def work_order_queue_page(request: Request):
"""Work order queue dashboard page."""
pending = list_work_orders(status=WorkOrderStatus.SUBMITTED) + \
list_work_orders(status=WorkOrderStatus.TRIAGED)
active = list_work_orders(status=WorkOrderStatus.APPROVED) + \
list_work_orders(status=WorkOrderStatus.IN_PROGRESS)
completed = list_work_orders(status=WorkOrderStatus.COMPLETED, limit=20)
rejected = list_work_orders(status=WorkOrderStatus.REJECTED, limit=10)
return templates.TemplateResponse(
request,
"work_orders.html",
{
"page_title": "Work Orders",
"pending": pending,
"active": active,
"completed": completed,
"rejected": rejected,
"pending_count": len(pending),
"priorities": [p.value for p in WorkOrderPriority],
"categories": [c.value for c in WorkOrderCategory],
},
)
@router.get("/queue/pending", response_class=HTMLResponse)
async def work_order_pending_partial(request: Request):
"""HTMX partial: pending work orders."""
pending = list_work_orders(status=WorkOrderStatus.SUBMITTED) + \
list_work_orders(status=WorkOrderStatus.TRIAGED)
return templates.TemplateResponse(
request,
"partials/work_order_cards.html",
{"orders": pending, "section": "pending"},
)
@router.get("/queue/active", response_class=HTMLResponse)
async def work_order_active_partial(request: Request):
"""HTMX partial: active work orders."""
active = list_work_orders(status=WorkOrderStatus.APPROVED) + \
list_work_orders(status=WorkOrderStatus.IN_PROGRESS)
return templates.TemplateResponse(
request,
"partials/work_order_cards.html",
{"orders": active, "section": "active"},
)
# ── Single work order (must be after /queue, /api to avoid conflict) ──────────
@router.get("/{wo_id}", response_class=JSONResponse)
async def get_order(wo_id: str):
"""Get a single work order by ID."""
wo = get_work_order(wo_id)
if not wo:
raise HTTPException(404, "Work order not found")
return {
"id": wo.id,
"title": wo.title,
"description": wo.description,
"priority": wo.priority.value,
"category": wo.category.value,
"status": wo.status.value,
"submitter": wo.submitter,
"submitter_type": wo.submitter_type,
"estimated_effort": wo.estimated_effort,
"related_files": wo.related_files,
"execution_mode": wo.execution_mode,
"swarm_task_id": wo.swarm_task_id,
"result": wo.result,
"rejection_reason": wo.rejection_reason,
"created_at": wo.created_at,
"triaged_at": wo.triaged_at,
"approved_at": wo.approved_at,
"started_at": wo.started_at,
"completed_at": wo.completed_at,
}
# ── Workflow actions ───────────────────────────────────────────────────────────
@router.post("/{wo_id}/approve", response_class=HTMLResponse)
async def approve_order(request: Request, wo_id: str):
"""Approve a work order for execution."""
wo = update_work_order_status(wo_id, WorkOrderStatus.APPROVED)
if not wo:
raise HTTPException(404, "Work order not found")
return templates.TemplateResponse(
request,
"partials/work_order_card.html",
{"wo": wo},
)
@router.post("/{wo_id}/reject", response_class=HTMLResponse)
async def reject_order(request: Request, wo_id: str, reason: str = Form("")):
"""Reject a work order."""
wo = update_work_order_status(
wo_id, WorkOrderStatus.REJECTED, rejection_reason=reason,
)
if not wo:
raise HTTPException(404, "Work order not found")
return templates.TemplateResponse(
request,
"partials/work_order_card.html",
{"wo": wo},
)
@router.post("/{wo_id}/execute", response_class=JSONResponse)
async def execute_order(wo_id: str):
"""Trigger execution of an approved work order."""
wo = get_work_order(wo_id)
if not wo:
raise HTTPException(404, "Work order not found")
if wo.status not in (WorkOrderStatus.APPROVED, WorkOrderStatus.TRIAGED):
raise HTTPException(400, f"Cannot execute work order in {wo.status.value} status")
update_work_order_status(wo_id, WorkOrderStatus.IN_PROGRESS)
try:
from work_orders.executor import work_order_executor
success, result = work_order_executor.execute(wo)
if success:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=result)
else:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=f"Failed: {result}")
except Exception as exc:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=f"Error: {exc}")
final = get_work_order(wo_id)
return {
"success": True,
"work_order_id": wo_id,
"status": final.status.value if final else "unknown",
"result": final.result if final else str(exc),
}

View File

@@ -26,6 +26,7 @@
<!-- Desktop nav -->
<div class="mc-header-right mc-desktop-nav">
<a href="/tasks" class="mc-test-link">TASKS</a>
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CONTROL</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
@@ -37,6 +38,7 @@
<a href="/memory" class="mc-test-link">MEMORY</a>
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/self-modify/queue" class="mc-test-link">UPGRADES</a>
<a href="/work-orders/queue" class="mc-test-link">WORK ORDERS</a>
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
<button id="enable-notifications" class="mc-test-link" style="background:none;cursor:pointer;" title="Enable notifications">&#x1F514;</button>
@@ -57,6 +59,7 @@
<span class="mc-time" id="clock-mobile"></span>
</div>
<a href="/" class="mc-mobile-link">HOME</a>
<a href="/tasks" class="mc-mobile-link">TASKS</a>
<a href="/briefing" class="mc-mobile-link">BRIEFING</a>
<a href="/swarm/live" class="mc-mobile-link">SWARM</a>
<a href="/spark/ui" class="mc-mobile-link">SPARK</a>
@@ -65,6 +68,7 @@
<a href="/swarm/events" class="mc-mobile-link">EVENTS</a>
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
<a href="/memory" class="mc-mobile-link">MEMORY</a>
<a href="/work-orders/queue" class="mc-mobile-link">WORK ORDERS</a>
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>
<a href="/voice/button" class="mc-mobile-link">VOICE</a>
<a href="/mobile" class="mc-mobile-link">MOBILE</a>

View File

@@ -151,14 +151,19 @@
<div class="container briefing-container py-4">
<div class="briefing-header mb-4">
<div class="briefing-greeting">Good morning.</div>
<div class="briefing-timestamp">
Briefing generated
<span class="briefing-ts-val">{{ briefing.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}</span>
&mdash; covering
<span class="briefing-ts-val">{{ briefing.period_start.strftime('%H:%M') }}</span>
to
<span class="briefing-ts-val">{{ briefing.period_end.strftime('%H:%M UTC') }}</span>
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div class="briefing-greeting">Good morning.</div>
<div class="briefing-timestamp">
Briefing generated
<span class="briefing-ts-val">{{ briefing.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}</span>
&mdash; covering
<span class="briefing-ts-val">{{ briefing.period_start.strftime('%H:%M') }}</span>
to
<span class="briefing-ts-val">{{ briefing.period_end.strftime('%H:%M UTC') }}</span>
</div>
</div>
<button class="btn-refresh" id="btn-regenerate" onclick="regenerateBriefing()">REGENERATE</button>
</div>
</div>
@@ -184,4 +189,24 @@
</div>
</div>
<script>
async function regenerateBriefing() {
var btn = document.getElementById('btn-regenerate');
btn.textContent = 'REGENERATING...';
btn.disabled = true;
try {
var resp = await fetch('/briefing/regenerate', { method: 'POST' });
if (resp.ok) {
window.location.reload();
} else {
btn.textContent = 'FAILED';
setTimeout(function() { btn.textContent = 'REGENERATE'; btn.disabled = false; }, 2000);
}
} catch (e) {
btn.textContent = 'ERROR';
setTimeout(function() { btn.textContent = 'REGENERATE'; btn.disabled = false; }, 2000);
}
}
</script>
{% endblock %}

View File

@@ -53,15 +53,15 @@
<!-- Filters -->
<div class="mc-filters">
<form method="get" action="/lightning/ledger" class="mc-filter-form">
<select name="tx_type" class="mc-select" onchange="this.form.submit()">
<form id="ledger-filter-form" class="mc-filter-form">
<select name="tx_type" class="mc-select" onchange="submitLedgerFilter()">
<option value="">All Types</option>
{% for t in tx_types %}
<option value="{{ t }}" {% if filter_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
<select name="status" class="mc-select" onchange="this.form.submit()">
<select name="status" class="mc-select" onchange="submitLedgerFilter()">
<option value="">All Statuses</option>
{% for s in tx_statuses %}
<option value="{{ s }}" {% if filter_status == s %}selected{% endif %}>{{ s }}</option>
@@ -69,6 +69,18 @@
</select>
</form>
</div>
<script>
function submitLedgerFilter() {
var form = document.getElementById('ledger-filter-form');
var params = new URLSearchParams();
var txType = form.querySelector('[name="tx_type"]').value;
var status = form.querySelector('[name="status"]').value;
if (txType) params.append('tx_type', txType);
if (status) params.append('status', status);
var qs = params.toString();
window.location.href = '/lightning/ledger' + (qs ? '?' + qs : '');
}
</script>
<!-- Transactions Table -->
<div class="mc-table-container">

View File

@@ -113,6 +113,11 @@
<div class="price-label">min bid</div>
<div class="price-stat">{{ agent.tasks_completed }} tasks won</div>
<div class="price-stat"><span class="earned">{{ agent.total_earned }} sats</span> earned</div>
<a href="/tasks?assign={{ agent.name | urlencode }}"
class="btn btn-sm"
style="margin-top:8px; background:var(--purple); color:#fff; border:none; border-radius:var(--radius-sm); padding:6px 16px; font-size:0.75rem; font-weight:600; letter-spacing:0.05em; display:inline-block; text-decoration:none;">
HIRE
</a>
</div>
</div>
{% endfor %}

View File

@@ -105,9 +105,13 @@
<div class="mc-facts-list">
{% if facts %}
<ul class="mc-fact-list">
<ul class="mc-fact-list" style="list-style: none; padding: 0;">
{% for fact in facts %}
<li class="memory-fact">{{ fact }}</li>
<li class="memory-fact" id="fact-{{ fact.id }}" style="display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid rgba(255,255,255,0.08);">
<span class="fact-content" style="flex:1;">{{ fact.content }}</span>
<button class="mc-btn mc-btn-small" onclick="editFact('{{ fact.id }}', this)" style="font-size:0.7rem; padding:2px 8px;">EDIT</button>
<button class="mc-btn mc-btn-small" onclick="deleteFact('{{ fact.id }}')" style="font-size:0.7rem; padding:2px 8px; color:#ef4444;">DEL</button>
</li>
{% endfor %}
</ul>
{% else %}
@@ -116,4 +120,40 @@
</div>
</div>
</div>
<script>
function deleteFact(id) {
if (!confirm('Delete this fact?')) return;
fetch('/memory/fact/' + id, { method: 'DELETE' })
.then(function(r) { if (r.ok) document.getElementById('fact-' + id).remove(); });
}
function editFact(id, btn) {
var li = document.getElementById('fact-' + id);
var span = li.querySelector('.fact-content');
var current = span.textContent.trim();
var input = document.createElement('input');
input.type = 'text'; input.value = current;
input.className = 'mc-input'; input.style.flex = '1';
span.replaceWith(input);
btn.textContent = 'SAVE';
btn.onclick = function() {
var val = input.value.trim();
if (!val) return;
fetch('/memory/fact/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: val })
}).then(function(r) {
if (r.ok) {
var newSpan = document.createElement('span');
newSpan.className = 'fact-content'; newSpan.style.flex = '1';
newSpan.textContent = val;
input.replaceWith(newSpan);
btn.textContent = 'EDIT';
btn.onclick = function() { editFact(id, btn); };
}
});
};
}
</script>
{% endblock %}

View File

@@ -187,16 +187,24 @@ async function loadHealth() {
try {
const response = await fetch('/health');
const data = await response.json();
// Format uptime
const uptime = data.uptime_seconds;
let uptimeStr;
if (uptime < 60) uptimeStr = Math.floor(uptime) + 's';
else if (uptime < 3600) uptimeStr = Math.floor(uptime / 60) + 'm';
else uptimeStr = Math.floor(uptime / 3600) + 'h ' + Math.floor((uptime % 3600) / 60) + 'm';
document.getElementById('metric-uptime').textContent = uptimeStr;
// LLM backend and model from /health response
if (data.llm_backend) {
document.getElementById('hb-backend').textContent = data.llm_backend;
}
if (data.llm_model) {
document.getElementById('hb-model').textContent = data.llm_model;
}
} catch (error) {
console.error('Failed to load health:', error);
}
@@ -207,11 +215,22 @@ async function loadSwarmStats() {
try {
const response = await fetch('/swarm');
const data = await response.json();
document.getElementById('metric-agents').textContent = data.agents || 0;
document.getElementById('metric-tasks').textContent =
var agentCount = data.agents || 0;
// Fallback: if /swarm returns 0, try /swarm/agents for a direct count
if (agentCount === 0) {
try {
const agentResp = await fetch('/swarm/agents');
const agentData = await agentResp.json();
if (Array.isArray(agentData.agents)) {
agentCount = agentData.agents.length;
}
} catch (e) { /* ignore fallback failure */ }
}
document.getElementById('metric-agents').textContent = agentCount;
document.getElementById('metric-tasks').textContent =
(data.tasks_pending || 0) + (data.tasks_running || 0);
} catch (error) {
console.error('Failed to load swarm stats:', error);
}
@@ -222,16 +241,12 @@ async function loadLightningStats() {
try {
const response = await fetch('/serve/status');
const data = await response.json();
document.getElementById('metric-earned').textContent = data.total_earned_sats || 0;
// Update heartbeat backend
document.getElementById('hb-backend').textContent = data.backend || '-';
document.getElementById('hb-model').textContent = 'llama3.2'; // From config
} catch (error) {
console.error('Failed to load lightning stats:', error);
document.getElementById('metric-earned').textContent = '-';
// /serve may not be running — default to 0 instead of '-'
document.getElementById('metric-earned').textContent = '0';
}
}

View File

@@ -35,13 +35,11 @@
hx-swap="outerHTML">
CHAT
</button>
<button class="mc-btn-clear flex-fill"
style="font-size:9px; padding:4px 6px;"
hx-get="/swarm/tasks/panel?agent_id={{ agent.id }}"
hx-target="#main-panel"
hx-swap="outerHTML">
<a class="mc-btn-clear flex-fill"
style="font-size:9px; padding:4px 6px; text-decoration:none; text-align:center;"
href="/tasks?assign={{ agent.name | urlencode }}">
TASK
</button>
</a>
</div>
</div>

View File

@@ -0,0 +1,105 @@
<div id="task-{{ task.id }}" class="task-card priority-{{ task.priority.value }}">
<div class="task-card-title">{{ task.title | e }}</div>
{% if task.description %}
<div class="task-card-desc">{{ task.description | e }}</div>
{% endif %}
<div class="task-card-meta">
<span class="task-badge task-badge-{{ task.priority.value }}">{{ task.priority.value | upper }}</span>
<span class="task-badge task-badge-{{ task.status.value }}">{{ task.status.value | replace("_", " ") | upper }}</span>
<span class="task-badge">{{ task.assigned_to | e }}</span>
<span class="task-badge">by {{ task.created_by | e }}</span>
</div>
{% if task.steps %}
<div class="task-steps">
{% for step in task.steps %}
<div class="task-step {{ step.status if step.status else 'pending' }}">
{% if step.status == 'completed' %}&#10003;{% elif step.status == 'running' %}&#9654;{% else %}&#9675;{% endif %}
{{ step.description if step.description else step }}
</div>
{% endfor %}
</div>
{% endif %}
{% if task.result %}
<div class="task-result" title="Click to expand">{{ task.result | e }}</div>
{% endif %}
<!-- Action buttons based on status -->
{% if task.status.value == 'pending_approval' %}
<div class="task-actions">
<button class="task-btn task-btn-approve"
hx-post="/tasks/{{ task.id }}/approve"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">APPROVE</button>
<button class="task-btn task-btn-modify"
onclick="toggleModify('{{ task.id }}')">MODIFY</button>
<button class="task-btn task-btn-veto"
hx-post="/tasks/{{ task.id }}/veto"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">VETO</button>
</div>
<!-- Inline modify form (hidden by default) -->
<form id="modify-{{ task.id }}" style="display:none; margin-top:8px;"
hx-post="/tasks/{{ task.id }}/modify"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">
<input type="text" name="title" value="{{ task.title | e }}" style="width:100%; padding:4px; margin-bottom:4px; background:var(--bg-tertiary); color:var(--text); border:1px solid var(--border); border-radius:4px; font-size:0.8rem;">
<textarea name="description" style="width:100%; padding:4px; margin-bottom:4px; background:var(--bg-tertiary); color:var(--text); border:1px solid var(--border); border-radius:4px; font-size:0.8rem; min-height:40px;">{{ task.description | e }}</textarea>
<button type="submit" class="task-btn task-btn-approve" style="font-size:0.65rem;">SAVE</button>
<button type="button" class="task-btn task-btn-cancel" style="font-size:0.65rem;" onclick="toggleModify('{{ task.id }}')">CANCEL</button>
</form>
{% elif task.status.value == 'approved' %}
<div class="task-actions">
<span style="font-size:0.7rem; color:var(--green);">Approved &mdash; waiting to run</span>
<button class="task-btn task-btn-veto"
hx-post="/tasks/{{ task.id }}/veto"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">VETO</button>
</div>
{% elif task.status.value == 'running' %}
<div class="task-actions">
<button class="task-btn task-btn-pause"
hx-post="/tasks/{{ task.id }}/pause"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">PAUSE</button>
<button class="task-btn task-btn-cancel"
hx-post="/tasks/{{ task.id }}/cancel"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">CANCEL</button>
</div>
{% elif task.status.value == 'paused' %}
<div class="task-actions">
<button class="task-btn task-btn-approve"
hx-post="/tasks/{{ task.id }}/approve"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">RESUME</button>
<button class="task-btn task-btn-cancel"
hx-post="/tasks/{{ task.id }}/cancel"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">CANCEL</button>
</div>
{% elif task.status.value == 'failed' %}
<div class="task-actions">
<button class="task-btn task-btn-retry"
hx-post="/tasks/{{ task.id }}/retry"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML">RETRY</button>
</div>
{% endif %}
<div class="task-time">{{ task.created_at[:16].replace("T", " ") }}</div>
</div>
<script>
function toggleModify(id) {
var form = document.getElementById('modify-' + id);
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
</script>

View File

@@ -0,0 +1,9 @@
{% if tasks %}
{% for task in tasks %}
{% include "partials/task_card.html" %}
{% endfor %}
{% else %}
<div class="empty-column">
{% if section == 'pending' %}No pending tasks{% elif section == 'active' %}No active tasks{% else %}No completed tasks yet{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
<div id="wo-{{ wo.id }}" class="card" style="padding: 14px; margin-bottom: 12px; border-left: 3px solid {% if wo.priority.value == 'critical' %}var(--danger){% elif wo.priority.value == 'high' %}var(--amber){% elif wo.priority.value == 'medium' %}var(--info, #4ea8de){% else %}var(--text-dim){% endif %};">
<div class="d-flex justify-content-between align-items-start" style="margin-bottom: 8px;">
<div>
<strong style="font-size: 0.9rem;">{{ wo.title | e }}</strong>
<div style="margin-top: 4px; display: flex; gap: 6px; flex-wrap: wrap;">
<span class="badge" style="font-size: 0.65rem; background: {% if wo.priority.value == 'critical' %}var(--danger){% elif wo.priority.value == 'high' %}var(--amber){% elif wo.priority.value == 'medium' %}var(--info, #4ea8de){% else %}var(--bg-tertiary){% endif %}; color: {% if wo.priority.value in ('critical', 'high') %}#000{% else %}var(--text-primary, #fff){% endif %};">
{{ wo.priority.value | upper }}
</span>
<span class="badge" style="font-size: 0.65rem; background: var(--bg-tertiary);">
{{ wo.category.value | upper }}
</span>
<span class="badge" style="font-size: 0.65rem; background: var(--bg-tertiary);">
{{ wo.submitter | e }}
</span>
{% if wo.execution_mode %}
<span class="badge" style="font-size: 0.65rem; background: {% if wo.execution_mode == 'auto' %}var(--success){% else %}var(--bg-tertiary){% endif %}; color: {% if wo.execution_mode == 'auto' %}#000{% else %}var(--text-primary, #fff){% endif %};">
{{ wo.execution_mode | upper }}
</span>
{% endif %}
</div>
</div>
<div style="font-size: 0.65rem; color: var(--text-muted); text-align: right; white-space: nowrap;">
{{ wo.status.value | upper }}<br>
{{ wo.created_at[:16].replace("T", " ") }}
</div>
</div>
{% if wo.description %}
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; max-height: 3em; overflow: hidden;">
{{ wo.description | e }}
</div>
{% endif %}
{% if wo.related_files %}
<div style="font-size: 0.7rem; font-family: monospace; color: var(--text-dim); margin-bottom: 8px;">
{{ wo.related_files | join(", ") | e }}
</div>
{% endif %}
{% if wo.result %}
<div style="font-size: 0.75rem; color: var(--success); margin-bottom: 8px;">
{{ wo.result | e }}
</div>
{% endif %}
{% if wo.rejection_reason %}
<div style="font-size: 0.75rem; color: var(--danger); margin-bottom: 8px;">
Rejected: {{ wo.rejection_reason | e }}
</div>
{% endif %}
<!-- Action buttons based on status -->
{% if wo.status.value in ('submitted', 'triaged') %}
<div class="d-flex gap-2" style="margin-top: 8px;">
<button class="btn mc-btn-send" style="font-size: 0.7rem; padding: 4px 16px;"
hx-post="/work-orders/{{ wo.id }}/approve"
hx-target="#wo-{{ wo.id }}"
hx-swap="outerHTML">
APPROVE
</button>
<button class="btn" style="font-size: 0.7rem; padding: 4px 16px; background: var(--danger); color: #fff; border: none; border-radius: var(--radius-md, 4px);"
hx-post="/work-orders/{{ wo.id }}/reject"
hx-target="#wo-{{ wo.id }}"
hx-swap="outerHTML">
REJECT
</button>
</div>
{% elif wo.status.value == 'approved' %}
<div style="margin-top: 8px;">
<button class="btn mc-btn-send" style="font-size: 0.7rem; padding: 4px 16px;"
hx-post="/work-orders/{{ wo.id }}/execute"
hx-target="#wo-{{ wo.id }}"
hx-swap="outerHTML">
EXECUTE
</button>
</div>
{% elif wo.status.value == 'in_progress' %}
<div style="margin-top: 8px; font-size: 0.7rem; color: var(--amber);">
Executing...
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,15 @@
{% if orders %}
{% for wo in orders %}
{% include "partials/work_order_card.html" %}
{% endfor %}
{% else %}
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">
{% if section == "pending" %}
No pending work orders.
{% elif section == "active" %}
No active work orders.
{% else %}
No work orders found.
{% endif %}
</div>
{% endif %}

View File

@@ -440,5 +440,17 @@ function addLog(message, type) {
}
connect();
// HTMX fallback: load initial data via REST if WebSocket is slow
setTimeout(function() {
if (document.getElementById('stat-agents').textContent === '-') {
refreshStats();
fetch('/swarm/agents').then(function(r) { return r.json(); }).then(function(data) {
if (data.agents && data.agents.length > 0) {
updateAgentsList(data.agents);
}
}).catch(function() {});
}
}, 2000);
</script>
{% endblock %}

View File

@@ -0,0 +1,357 @@
{% extends "base.html" %}
{% block title %}Task Queue - Timmy Time{% endblock %}
{% block extra_styles %}
<style>
.tasks-container { max-width: 1400px; margin: 0 auto; }
.tasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.tasks-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 0.08em;
}
.tasks-columns {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
@media (max-width: 992px) {
.tasks-columns { grid-template-columns: 1fr; }
}
.task-column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.task-column-title {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--text-dim);
}
.task-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
margin-bottom: 10px;
background: rgba(24, 10, 45, 0.6);
transition: border-color 0.2s;
}
.task-card:hover { border-color: rgba(124, 58, 237, 0.3); }
.task-card.priority-urgent { border-left: 3px solid var(--red, #ef4444); }
.task-card.priority-high { border-left: 3px solid var(--amber, #f59e0b); }
.task-card.priority-normal { border-left: 3px solid var(--info, #4ea8de); }
.task-card.priority-low { border-left: 3px solid var(--text-dim); }
.task-card-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-bright);
margin-bottom: 4px;
}
.task-card-desc {
font-size: 0.8rem;
color: var(--text);
margin-bottom: 6px;
max-height: 3em;
overflow: hidden;
}
.task-card-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.task-badge {
font-size: 0.65rem;
padding: 0.15em 0.5em;
border-radius: 3px;
font-weight: 600;
letter-spacing: 0.05em;
background: var(--bg-tertiary);
color: var(--text);
}
.task-badge-urgent { background: rgba(239,68,68,0.2); color: var(--red, #ef4444); }
.task-badge-high { background: rgba(245,158,11,0.2); color: var(--amber, #f59e0b); }
.task-badge-running { background: rgba(59,130,246,0.2); color: #60a5fa; }
.task-badge-completed { background: rgba(16,185,129,0.2); color: var(--green, #10b981); }
.task-badge-failed { background: rgba(239,68,68,0.2); color: var(--red, #ef4444); }
.task-badge-vetoed { background: rgba(107,114,128,0.2); color: #9ca3af; }
.task-badge-paused { background: rgba(245,158,11,0.2); color: var(--amber, #f59e0b); }
.task-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.task-btn {
font-size: 0.7rem;
padding: 4px 12px;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font-weight: 600;
letter-spacing: 0.04em;
font-family: var(--font);
}
.task-btn-approve { background: var(--green, #10b981); color: #000; }
.task-btn-approve:hover { opacity: 0.85; }
.task-btn-modify { background: var(--purple, #7c3aed); color: #fff; }
.task-btn-modify:hover { opacity: 0.85; }
.task-btn-veto { background: var(--red, #ef4444); color: #fff; }
.task-btn-veto:hover { opacity: 0.85; }
.task-btn-pause { background: var(--amber, #f59e0b); color: #000; }
.task-btn-cancel { background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); }
.task-btn-retry { background: var(--info, #4ea8de); color: #000; }
.task-result {
font-size: 0.75rem;
color: var(--text);
margin-top: 6px;
padding: 6px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
max-height: 4em;
overflow: hidden;
cursor: pointer;
}
.task-result.expanded { max-height: none; }
.task-steps {
margin-top: 6px;
font-size: 0.7rem;
}
.task-step { padding: 2px 0; color: var(--text-dim); }
.task-step.running { color: #60a5fa; }
.task-step.completed { color: var(--green, #10b981); }
.task-time {
font-size: 0.6rem;
color: var(--text-dim);
font-family: var(--font);
margin-top: 4px;
}
/* Create modal */
.task-modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
align-items: center;
justify-content: center;
}
.task-modal-overlay.open { display: flex; }
.task-modal {
background: var(--bg-secondary, #1a0a2e);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 24px;
max-width: 480px;
width: 90%;
}
.task-modal h3 {
margin: 0 0 16px;
font-size: 1rem;
color: var(--text-bright);
}
.task-modal label {
display: block;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 4px;
letter-spacing: 0.05em;
}
.task-modal input, .task-modal textarea, .task-modal select {
width: 100%;
padding: 8px;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-tertiary, #0a0f1e);
color: var(--text);
font-family: var(--font);
font-size: 0.85rem;
}
.task-modal textarea { min-height: 80px; resize: vertical; }
.task-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.empty-column {
text-align: center;
padding: 24px;
color: var(--text-dim);
font-size: 0.8rem;
}
</style>
{% endblock %}
{% block content %}
<div class="tasks-container py-3">
<div class="tasks-header">
<div class="tasks-title">TASK QUEUE</div>
<button class="task-btn task-btn-approve" onclick="openCreateModal()" style="padding:6px 16px; font-size:0.8rem;">+ ADD TASK</button>
</div>
<div class="tasks-columns">
<!-- PENDING APPROVAL -->
<div class="card mc-panel">
<div class="card-header mc-panel-header task-column-header">
<span class="task-column-title">// PENDING APPROVAL</span>
<span class="badge badge-warning" id="pending-count">{{ pending_count }}</span>
</div>
<div class="card-body p-2" id="pending-list"
hx-get="/tasks/pending" hx-trigger="every 15s" hx-swap="innerHTML">
{% if pending %}
{% for task in pending %}
{% include "partials/task_card.html" %}
{% endfor %}
{% else %}
<div class="empty-column">No pending tasks</div>
{% endif %}
</div>
</div>
<!-- ACTIVE -->
<div class="card mc-panel">
<div class="card-header mc-panel-header task-column-header">
<span class="task-column-title">// ACTIVE</span>
<span class="badge badge-info" id="active-count">{{ active | length }}</span>
</div>
<div class="card-body p-2" id="active-list"
hx-get="/tasks/active" hx-trigger="every 10s" hx-swap="innerHTML">
{% if active %}
{% for task in active %}
{% include "partials/task_card.html" %}
{% endfor %}
{% else %}
<div class="empty-column">No active tasks</div>
{% endif %}
</div>
</div>
<!-- COMPLETED -->
<div class="card mc-panel">
<div class="card-header mc-panel-header task-column-header">
<span class="task-column-title">// COMPLETED</span>
<span class="badge badge-secondary" id="completed-count">{{ completed | length }}</span>
</div>
<div class="card-body p-2" id="completed-list" style="max-height: 70vh; overflow-y: auto;"
hx-get="/tasks/completed" hx-trigger="every 30s" hx-swap="innerHTML">
{% if completed %}
{% for task in completed %}
{% include "partials/task_card.html" %}
{% endfor %}
{% else %}
<div class="empty-column">No completed tasks yet</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Create Task Modal -->
<div class="task-modal-overlay" id="create-modal">
<div class="task-modal">
<h3>Create Task</h3>
<form id="create-task-form" hx-post="/tasks/create" hx-target="#pending-list" hx-swap="afterbegin" hx-on::after-request="closeCreateModal()">
<label>Title</label>
<input type="text" name="title" required placeholder="Short description of the task">
<label>Description</label>
<textarea name="description" placeholder="Full task details (optional)"></textarea>
<label>Assign To</label>
<select name="assigned_to" id="modal-assigned-to">
{% for agent in agents %}
<option value="{{ agent.name }}" {% if pre_assign == agent.name %}selected{% endif %}>{{ agent.name }}</option>
{% endfor %}
</select>
<label>Priority</label>
<select name="priority">
<option value="low">Low</option>
<option value="normal" selected>Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<div class="task-modal-actions">
<button type="button" class="task-btn task-btn-cancel" onclick="closeCreateModal()">CANCEL</button>
<button type="submit" class="task-btn task-btn-approve">CREATE</button>
</div>
</form>
</div>
</div>
<script>
function openCreateModal(agentName) {
document.getElementById('create-modal').classList.add('open');
if (agentName) {
var sel = document.getElementById('modal-assigned-to');
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === agentName) { sel.selectedIndex = i; break; }
}
}
}
function closeCreateModal() {
document.getElementById('create-modal').classList.remove('open');
document.getElementById('create-task-form').reset();
}
// Close on overlay click
document.getElementById('create-modal').addEventListener('click', function(e) {
if (e.target === this) closeCreateModal();
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeCreateModal();
});
// Toggle result expansion
document.addEventListener('click', function(e) {
if (e.target.classList.contains('task-result')) {
e.target.classList.toggle('expanded');
}
});
// WebSocket live updates for task events
(function() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws;
function connectTaskWs() {
try {
ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
ws.onmessage = function(event) {
try {
var msg = JSON.parse(event.data);
if (msg.type === 'task_event') {
// Refresh the relevant column
htmx.trigger('#pending-list', 'htmx:trigger');
htmx.trigger('#active-list', 'htmx:trigger');
htmx.trigger('#completed-list', 'htmx:trigger');
}
} catch(e) {}
};
ws.onclose = function() {
setTimeout(connectTaskWs, 5000);
};
} catch(e) {}
}
connectTaskWs();
})();
// Open create modal from URL param (?assign=timmy)
var params = new URLSearchParams(window.location.search);
if (params.get('assign')) {
openCreateModal(params.get('assign'));
}
</script>
{% endblock %}

View File

@@ -59,9 +59,13 @@
{% endfor %}
</div>
{% else %}
<div class="mc-empty-state">
<div class="mc-empty-state" style="padding:2rem; text-align:center;">
<p>No pending upgrades.</p>
<p class="mc-text-secondary">Proposed modifications will appear here for review.</p>
<p class="mc-text-secondary" style="margin-bottom:1rem;">Upgrades are proposed by the self-modification system when Timmy identifies improvements. You can also trigger them via work orders or the task queue.</p>
<div style="display:flex; gap:0.75rem; justify-content:center; flex-wrap:wrap;">
<a href="/work-orders/queue" class="mc-btn mc-btn-secondary" style="text-decoration:none;">View Work Orders</a>
<a href="/tasks" class="mc-btn mc-btn-secondary" style="text-decoration:none;">View Task Queue</a>
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Work Orders — Timmy Time{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: 1200px; padding: 24px;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center" style="margin-bottom: 24px;">
<div>
<h2 style="margin: 0; font-size: 1.5rem;">WORK ORDERS</h2>
<div style="font-size: 0.75rem; color: var(--text-muted); letter-spacing: 0.1em; margin-top: 4px;">
SUBMIT &middot; REVIEW &middot; EXECUTE
</div>
</div>
<div class="d-flex gap-2">
<span class="badge" style="background: var(--amber); color: #000; font-size: 0.75rem;">
{{ pending_count }} PENDING
</span>
<button class="btn mc-btn-send" style="font-size: 0.75rem; padding: 6px 16px;"
onclick="document.getElementById('submit-form').style.display = document.getElementById('submit-form').style.display === 'none' ? 'block' : 'none'">
+ NEW
</button>
</div>
</div>
<!-- Submit Form (hidden by default) -->
<div id="submit-form" class="card" style="display: none; margin-bottom: 24px; padding: 20px;">
<h3 style="font-size: 0.875rem; margin-bottom: 16px; letter-spacing: 0.1em;">SUBMIT WORK ORDER</h3>
<form hx-post="/work-orders/submit"
hx-target="#submit-result"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){this.reset(); setTimeout(()=>htmx.trigger('#pending-section','load'),500);}"
class="d-flex flex-column gap-3">
<div>
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">TITLE</label>
<input type="text" name="title" class="form-control mc-input" placeholder="Brief title for this work order" required />
</div>
<div>
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">DESCRIPTION</label>
<textarea name="description" class="form-control mc-input" rows="3" placeholder="Detailed description..."></textarea>
</div>
<div class="d-flex gap-3">
<div style="flex: 1;">
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">PRIORITY</label>
<select name="priority" class="form-control mc-input">
{% for p in priorities %}
<option value="{{ p }}" {{ "selected" if p == "medium" else "" }}>{{ p | upper }}</option>
{% endfor %}
</select>
</div>
<div style="flex: 1;">
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">CATEGORY</label>
<select name="category" class="form-control mc-input">
{% for c in categories %}
<option value="{{ c }}" {{ "selected" if c == "suggestion" else "" }}>{{ c | upper }}</option>
{% endfor %}
</select>
</div>
<div style="flex: 1;">
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">SUBMITTER</label>
<input type="text" name="submitter" class="form-control mc-input" value="dashboard" />
</div>
</div>
<div>
<label style="font-size: 0.7rem; color: var(--text-muted); letter-spacing: 0.15em;">RELATED FILES (comma-separated)</label>
<input type="text" name="related_files" class="form-control mc-input" placeholder="src/timmy/agent.py, src/config.py" />
</div>
<input type="hidden" name="submitter_type" value="user" />
<div class="d-flex justify-content-between align-items-center">
<div id="submit-result" style="font-size: 0.75rem;"></div>
<button type="submit" class="btn mc-btn-send" style="padding: 8px 24px;">SUBMIT</button>
</div>
</form>
</div>
<!-- Pending Queue -->
<div class="card" style="margin-bottom: 24px; padding: 20px;">
<h3 style="font-size: 0.875rem; margin-bottom: 16px; letter-spacing: 0.1em;">
INCOMING QUEUE
</h3>
<div id="pending-section"
hx-get="/work-orders/queue/pending"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
{% if pending %}
{% for wo in pending %}
{% include "partials/work_order_card.html" %}
{% endfor %}
{% else %}
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">
No pending work orders. Use the + NEW button or the API to submit one.
</div>
{% endif %}
</div>
</div>
<!-- Active Work -->
<div class="card" style="margin-bottom: 24px; padding: 20px;">
<h3 style="font-size: 0.875rem; margin-bottom: 16px; letter-spacing: 0.1em;">
ACTIVE WORK
</h3>
<div id="active-section"
hx-get="/work-orders/queue/active"
hx-trigger="load, every 15s"
hx-swap="innerHTML">
{% if active %}
{% for wo in active %}
{% include "partials/work_order_card.html" %}
{% endfor %}
{% else %}
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">
No work orders currently in progress.
</div>
{% endif %}
</div>
</div>
<!-- History -->
<div class="card" style="padding: 20px;">
<h3 style="font-size: 0.875rem; margin-bottom: 16px; letter-spacing: 0.1em;">
HISTORY
</h3>
{% if completed or rejected %}
{% for wo in completed %}
{% include "partials/work_order_card.html" %}
{% endfor %}
{% for wo in rejected %}
{% include "partials/work_order_card.html" %}
{% endfor %}
{% else %}
<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">
No completed or rejected work orders yet.
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -377,6 +377,35 @@ def recall_personal_facts(agent_id: Optional[str] = None) -> list[str]:
return [r["content"] for r in rows]
def recall_personal_facts_with_ids(agent_id: Optional[str] = None) -> list[dict]:
"""Recall personal facts with their IDs for edit/delete operations."""
conn = _get_conn()
if agent_id:
rows = conn.execute(
"SELECT id, content FROM memory_entries WHERE context_type = 'fact' AND agent_id = ? ORDER BY timestamp DESC LIMIT 100",
(agent_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, content FROM memory_entries WHERE context_type = 'fact' ORDER BY timestamp DESC LIMIT 100",
).fetchall()
conn.close()
return [{"id": r["id"], "content": r["content"]} for r in rows]
def update_personal_fact(memory_id: str, new_content: str) -> bool:
"""Update a personal fact's content."""
conn = _get_conn()
cursor = conn.execute(
"UPDATE memory_entries SET content = ? WHERE id = ? AND context_type = 'fact'",
(new_content, memory_id),
)
conn.commit()
updated = cursor.rowcount > 0
conn.close()
return updated
def store_personal_fact(fact: str, agent_id: Optional[str] = None) -> MemoryEntry:
"""Store a personal fact about the user or system.

View File

@@ -0,0 +1 @@
"""Task Queue — Human-in-the-loop approval and execution system."""

390
src/task_queue/models.py Normal file
View File

@@ -0,0 +1,390 @@
"""Task Queue data model — SQLite-backed CRUD with human-in-the-loop states.
Table: task_queue in data/swarm.db
"""
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "swarm.db"
class TaskStatus(str, Enum):
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
VETOED = "vetoed"
FAILED = "failed"
class TaskPriority(str, Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
@dataclass
class TaskStep:
description: str
status: str = "pending" # pending, running, completed, failed
started_at: Optional[str] = None
completed_at: Optional[str] = None
output: Optional[str] = None
@dataclass
class QueueTask:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
title: str = ""
description: str = ""
assigned_to: str = "timmy"
created_by: str = "user"
status: TaskStatus = TaskStatus.PENDING_APPROVAL
priority: TaskPriority = TaskPriority.NORMAL
requires_approval: bool = True
auto_approve: bool = False
parent_task_id: Optional[str] = None
result: Optional[str] = None
steps: list = field(default_factory=list)
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
started_at: Optional[str] = None
completed_at: Optional[str] = None
updated_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
# ── Auto-Approve Rules ──────────────────────────────────────────────────
AUTO_APPROVE_RULES = [
{"assigned_to": "timmy", "type": "chat_response"},
{"assigned_to": "forge", "type": "run_tests"},
{"priority": "urgent", "created_by": "timmy"},
]
def should_auto_approve(task: QueueTask) -> bool:
"""Check if a task matches any auto-approve rule."""
if not task.auto_approve:
return False
for rule in AUTO_APPROVE_RULES:
match = True
for key, val in rule.items():
if key == "type":
continue # type matching is informational for now
task_val = getattr(task, key, None)
if isinstance(task_val, Enum):
task_val = task_val.value
if task_val != val:
match = False
break
if match:
return True
return False
# ── Database ─────────────────────────────────────────────────────────────
def _get_conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS task_queue (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
assigned_to TEXT DEFAULT 'timmy',
created_by TEXT DEFAULT 'user',
status TEXT DEFAULT 'pending_approval',
priority TEXT DEFAULT 'normal',
requires_approval INTEGER DEFAULT 1,
auto_approve INTEGER DEFAULT 0,
parent_task_id TEXT,
result TEXT,
steps TEXT DEFAULT '[]',
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tq_priority ON task_queue(priority)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tq_created ON task_queue(created_at)"
)
conn.commit()
return conn
def _row_to_task(row: sqlite3.Row) -> QueueTask:
d = dict(row)
steps_raw = d.pop("steps", "[]")
try:
steps = json.loads(steps_raw) if steps_raw else []
except (json.JSONDecodeError, TypeError):
steps = []
return QueueTask(
id=d["id"],
title=d["title"],
description=d.get("description", ""),
assigned_to=d.get("assigned_to", "timmy"),
created_by=d.get("created_by", "user"),
status=TaskStatus(d["status"]),
priority=TaskPriority(d.get("priority", "normal")),
requires_approval=bool(d.get("requires_approval", 1)),
auto_approve=bool(d.get("auto_approve", 0)),
parent_task_id=d.get("parent_task_id"),
result=d.get("result"),
steps=steps,
created_at=d["created_at"],
started_at=d.get("started_at"),
completed_at=d.get("completed_at"),
updated_at=d["updated_at"],
)
# ── CRUD ─────────────────────────────────────────────────────────────────
def create_task(
title: str,
description: str = "",
assigned_to: str = "timmy",
created_by: str = "user",
priority: str = "normal",
requires_approval: bool = True,
auto_approve: bool = False,
parent_task_id: Optional[str] = None,
steps: Optional[list] = None,
) -> QueueTask:
"""Create a new task in the queue."""
now = datetime.now(timezone.utc).isoformat()
task = QueueTask(
title=title,
description=description,
assigned_to=assigned_to,
created_by=created_by,
status=TaskStatus.PENDING_APPROVAL,
priority=TaskPriority(priority),
requires_approval=requires_approval,
auto_approve=auto_approve,
parent_task_id=parent_task_id,
steps=steps or [],
created_at=now,
updated_at=now,
)
# Check auto-approve
if should_auto_approve(task):
task.status = TaskStatus.APPROVED
task.requires_approval = False
conn = _get_conn()
conn.execute(
"""INSERT INTO task_queue
(id, title, description, assigned_to, created_by, status, priority,
requires_approval, auto_approve, parent_task_id, result, steps,
created_at, started_at, completed_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
task.id, task.title, task.description, task.assigned_to,
task.created_by, task.status.value, task.priority.value,
int(task.requires_approval), int(task.auto_approve),
task.parent_task_id, task.result, json.dumps(task.steps),
task.created_at, task.started_at, task.completed_at,
task.updated_at,
),
)
conn.commit()
conn.close()
return task
def get_task(task_id: str) -> Optional[QueueTask]:
conn = _get_conn()
row = conn.execute(
"SELECT * FROM task_queue WHERE id = ?", (task_id,)
).fetchone()
conn.close()
return _row_to_task(row) if row else None
def list_tasks(
status: Optional[TaskStatus] = None,
priority: Optional[TaskPriority] = None,
assigned_to: Optional[str] = None,
created_by: Optional[str] = None,
limit: int = 100,
) -> list[QueueTask]:
clauses, params = [], []
if status:
clauses.append("status = ?")
params.append(status.value)
if priority:
clauses.append("priority = ?")
params.append(priority.value)
if assigned_to:
clauses.append("assigned_to = ?")
params.append(assigned_to)
if created_by:
clauses.append("created_by = ?")
params.append(created_by)
where = " WHERE " + " AND ".join(clauses) if clauses else ""
params.append(limit)
conn = _get_conn()
rows = conn.execute(
f"SELECT * FROM task_queue{where} ORDER BY created_at DESC LIMIT ?",
params,
).fetchall()
conn.close()
return [_row_to_task(r) for r in rows]
def update_task_status(
task_id: str,
new_status: TaskStatus,
result: Optional[str] = None,
) -> Optional[QueueTask]:
now = datetime.now(timezone.utc).isoformat()
conn = _get_conn()
updates = ["status = ?", "updated_at = ?"]
params = [new_status.value, now]
if new_status == TaskStatus.RUNNING:
updates.append("started_at = ?")
params.append(now)
elif new_status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.VETOED):
updates.append("completed_at = ?")
params.append(now)
if result is not None:
updates.append("result = ?")
params.append(result)
params.append(task_id)
conn.execute(
f"UPDATE task_queue SET {', '.join(updates)} WHERE id = ?",
params,
)
conn.commit()
row = conn.execute(
"SELECT * FROM task_queue WHERE id = ?", (task_id,)
).fetchone()
conn.close()
return _row_to_task(row) if row else None
def update_task(
task_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
assigned_to: Optional[str] = None,
priority: Optional[str] = None,
) -> Optional[QueueTask]:
"""Update task fields (for MODIFY action)."""
now = datetime.now(timezone.utc).isoformat()
conn = _get_conn()
updates = ["updated_at = ?"]
params = [now]
if title is not None:
updates.append("title = ?")
params.append(title)
if description is not None:
updates.append("description = ?")
params.append(description)
if assigned_to is not None:
updates.append("assigned_to = ?")
params.append(assigned_to)
if priority is not None:
updates.append("priority = ?")
params.append(priority)
params.append(task_id)
conn.execute(
f"UPDATE task_queue SET {', '.join(updates)} WHERE id = ?",
params,
)
conn.commit()
row = conn.execute(
"SELECT * FROM task_queue WHERE id = ?", (task_id,)
).fetchone()
conn.close()
return _row_to_task(row) if row else None
def update_task_steps(task_id: str, steps: list) -> bool:
"""Update the steps array for a running task."""
now = datetime.now(timezone.utc).isoformat()
conn = _get_conn()
cursor = conn.execute(
"UPDATE task_queue SET steps = ?, updated_at = ? WHERE id = ?",
(json.dumps(steps), now, task_id),
)
conn.commit()
ok = cursor.rowcount > 0
conn.close()
return ok
def get_counts_by_status() -> dict[str, int]:
conn = _get_conn()
rows = conn.execute(
"SELECT status, COUNT(*) as cnt FROM task_queue GROUP BY status"
).fetchall()
conn.close()
return {r["status"]: r["cnt"] for r in rows}
def get_pending_count() -> int:
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as cnt FROM task_queue WHERE status = 'pending_approval'"
).fetchone()
conn.close()
return row["cnt"] if row else 0
def get_task_summary_for_briefing() -> dict:
"""Get task stats for the morning briefing."""
counts = get_counts_by_status()
conn = _get_conn()
# Failed tasks
failed = conn.execute(
"SELECT title, result FROM task_queue WHERE status = 'failed' ORDER BY updated_at DESC LIMIT 5"
).fetchall()
conn.close()
return {
"pending_approval": counts.get("pending_approval", 0),
"running": counts.get("running", 0),
"completed": counts.get("completed", 0),
"failed": counts.get("failed", 0),
"vetoed": counts.get("vetoed", 0),
"total": sum(counts.values()),
"recent_failures": [{"title": r["title"], "result": r["result"]} for r in failed],
}

View File

@@ -0,0 +1 @@
"""Work Order system for external and internal task submission."""

View File

@@ -0,0 +1,49 @@
"""Work order execution — bridges work orders to self-modify and swarm."""
import logging
from work_orders.models import WorkOrder, WorkOrderCategory
logger = logging.getLogger(__name__)
class WorkOrderExecutor:
"""Dispatches approved work orders to the appropriate execution backend."""
def execute(self, wo: WorkOrder) -> tuple[bool, str]:
"""Execute a work order.
Returns:
(success, result_message) tuple
"""
if self._is_code_task(wo):
return self._execute_via_swarm(wo, code_hint=True)
return self._execute_via_swarm(wo)
def _is_code_task(self, wo: WorkOrder) -> bool:
"""Check if this work order involves code changes."""
code_categories = {WorkOrderCategory.BUG, WorkOrderCategory.OPTIMIZATION}
if wo.category in code_categories:
return True
if wo.related_files:
return any(f.endswith(".py") for f in wo.related_files)
return False
def _execute_via_swarm(self, wo: WorkOrder, code_hint: bool = False) -> tuple[bool, str]:
"""Dispatch as a swarm task for agent bidding."""
try:
from swarm.coordinator import coordinator
prefix = "[Code] " if code_hint else ""
description = f"{prefix}[WO-{wo.id[:8]}] {wo.title}"
if wo.description:
description += f": {wo.description}"
task = coordinator.post_task(description)
logger.info("Work order %s dispatched as swarm task %s", wo.id[:8], task.id)
return True, f"Dispatched as swarm task {task.id}"
except Exception as exc:
logger.error("Failed to dispatch work order %s: %s", wo.id[:8], exc)
return False, str(exc)
# Module-level singleton
work_order_executor = WorkOrderExecutor()

286
src/work_orders/models.py Normal file
View File

@@ -0,0 +1,286 @@
"""Database models for Work Order system."""
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
DB_PATH = Path("data/swarm.db")
class WorkOrderStatus(str, Enum):
SUBMITTED = "submitted"
TRIAGED = "triaged"
APPROVED = "approved"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
REJECTED = "rejected"
class WorkOrderPriority(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class WorkOrderCategory(str, Enum):
BUG = "bug"
FEATURE = "feature"
IMPROVEMENT = "improvement"
OPTIMIZATION = "optimization"
SUGGESTION = "suggestion"
@dataclass
class WorkOrder:
"""A work order / suggestion submitted by a user or agent."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
title: str = ""
description: str = ""
priority: WorkOrderPriority = WorkOrderPriority.MEDIUM
category: WorkOrderCategory = WorkOrderCategory.SUGGESTION
status: WorkOrderStatus = WorkOrderStatus.SUBMITTED
submitter: str = "unknown"
submitter_type: str = "user" # user | agent | system
estimated_effort: Optional[str] = None # small | medium | large
related_files: list[str] = field(default_factory=list)
execution_mode: Optional[str] = None # auto | manual
swarm_task_id: Optional[str] = None
result: Optional[str] = None
rejection_reason: Optional[str] = None
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
triaged_at: Optional[str] = None
approved_at: Optional[str] = None
started_at: Optional[str] = None
completed_at: Optional[str] = None
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def _get_conn() -> sqlite3.Connection:
"""Get database connection with schema initialized."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'medium',
category TEXT NOT NULL DEFAULT 'suggestion',
status TEXT NOT NULL DEFAULT 'submitted',
submitter TEXT NOT NULL DEFAULT 'unknown',
submitter_type TEXT NOT NULL DEFAULT 'user',
estimated_effort TEXT,
related_files TEXT,
execution_mode TEXT,
swarm_task_id TEXT,
result TEXT,
rejection_reason TEXT,
created_at TEXT NOT NULL,
triaged_at TEXT,
approved_at TEXT,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_status ON work_orders(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_priority ON work_orders(priority)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_submitter ON work_orders(submitter)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_created ON work_orders(created_at)")
conn.commit()
return conn
def _row_to_work_order(row: sqlite3.Row) -> WorkOrder:
"""Convert a database row to a WorkOrder."""
return WorkOrder(
id=row["id"],
title=row["title"],
description=row["description"],
priority=WorkOrderPriority(row["priority"]),
category=WorkOrderCategory(row["category"]),
status=WorkOrderStatus(row["status"]),
submitter=row["submitter"],
submitter_type=row["submitter_type"],
estimated_effort=row["estimated_effort"],
related_files=json.loads(row["related_files"]) if row["related_files"] else [],
execution_mode=row["execution_mode"],
swarm_task_id=row["swarm_task_id"],
result=row["result"],
rejection_reason=row["rejection_reason"],
created_at=row["created_at"],
triaged_at=row["triaged_at"],
approved_at=row["approved_at"],
started_at=row["started_at"],
completed_at=row["completed_at"],
updated_at=row["updated_at"],
)
def create_work_order(
title: str,
description: str = "",
priority: str = "medium",
category: str = "suggestion",
submitter: str = "unknown",
submitter_type: str = "user",
estimated_effort: Optional[str] = None,
related_files: Optional[list[str]] = None,
) -> WorkOrder:
"""Create a new work order."""
wo = WorkOrder(
title=title,
description=description,
priority=WorkOrderPriority(priority),
category=WorkOrderCategory(category),
submitter=submitter,
submitter_type=submitter_type,
estimated_effort=estimated_effort,
related_files=related_files or [],
)
conn = _get_conn()
conn.execute(
"""
INSERT INTO work_orders (
id, title, description, priority, category, status,
submitter, submitter_type, estimated_effort, related_files,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wo.id, wo.title, wo.description,
wo.priority.value, wo.category.value, wo.status.value,
wo.submitter, wo.submitter_type, wo.estimated_effort,
json.dumps(wo.related_files) if wo.related_files else None,
wo.created_at, wo.updated_at,
),
)
conn.commit()
conn.close()
return wo
def get_work_order(wo_id: str) -> Optional[WorkOrder]:
"""Get a work order by ID."""
conn = _get_conn()
row = conn.execute(
"SELECT * FROM work_orders WHERE id = ?", (wo_id,)
).fetchone()
conn.close()
if not row:
return None
return _row_to_work_order(row)
def list_work_orders(
status: Optional[WorkOrderStatus] = None,
priority: Optional[WorkOrderPriority] = None,
category: Optional[WorkOrderCategory] = None,
submitter: Optional[str] = None,
limit: int = 100,
) -> list[WorkOrder]:
"""List work orders with optional filters."""
conn = _get_conn()
conditions = []
params: list = []
if status:
conditions.append("status = ?")
params.append(status.value)
if priority:
conditions.append("priority = ?")
params.append(priority.value)
if category:
conditions.append("category = ?")
params.append(category.value)
if submitter:
conditions.append("submitter = ?")
params.append(submitter)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = conn.execute(
f"SELECT * FROM work_orders {where} ORDER BY created_at DESC LIMIT ?",
params + [limit],
).fetchall()
conn.close()
return [_row_to_work_order(r) for r in rows]
def update_work_order_status(
wo_id: str,
new_status: WorkOrderStatus,
**kwargs,
) -> Optional[WorkOrder]:
"""Update a work order's status and optional fields."""
now = datetime.now(timezone.utc).isoformat()
sets = ["status = ?", "updated_at = ?"]
params: list = [new_status.value, now]
# Auto-set timestamp fields based on status transition
timestamp_map = {
WorkOrderStatus.TRIAGED: "triaged_at",
WorkOrderStatus.APPROVED: "approved_at",
WorkOrderStatus.IN_PROGRESS: "started_at",
WorkOrderStatus.COMPLETED: "completed_at",
WorkOrderStatus.REJECTED: "completed_at",
}
ts_field = timestamp_map.get(new_status)
if ts_field:
sets.append(f"{ts_field} = ?")
params.append(now)
# Apply additional keyword fields
allowed_fields = {
"execution_mode", "swarm_task_id", "result",
"rejection_reason", "estimated_effort",
}
for key, val in kwargs.items():
if key in allowed_fields:
sets.append(f"{key} = ?")
params.append(val)
params.append(wo_id)
conn = _get_conn()
cursor = conn.execute(
f"UPDATE work_orders SET {', '.join(sets)} WHERE id = ?",
params,
)
conn.commit()
updated = cursor.rowcount > 0
conn.close()
if not updated:
return None
return get_work_order(wo_id)
def get_pending_count() -> int:
"""Get count of submitted/triaged work orders awaiting review."""
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as count FROM work_orders WHERE status IN (?, ?)",
(WorkOrderStatus.SUBMITTED.value, WorkOrderStatus.TRIAGED.value),
).fetchone()
conn.close()
return row["count"]
def get_counts_by_status() -> dict[str, int]:
"""Get work order counts grouped by status."""
conn = _get_conn()
rows = conn.execute(
"SELECT status, COUNT(*) as count FROM work_orders GROUP BY status"
).fetchall()
conn.close()
return {r["status"]: r["count"] for r in rows}

74
src/work_orders/risk.py Normal file
View File

@@ -0,0 +1,74 @@
"""Risk scoring and auto-execution threshold logic for work orders."""
from work_orders.models import WorkOrder, WorkOrderCategory, WorkOrderPriority
PRIORITY_WEIGHTS = {
WorkOrderPriority.CRITICAL: 4,
WorkOrderPriority.HIGH: 3,
WorkOrderPriority.MEDIUM: 2,
WorkOrderPriority.LOW: 1,
}
CATEGORY_WEIGHTS = {
WorkOrderCategory.BUG: 3,
WorkOrderCategory.FEATURE: 3,
WorkOrderCategory.IMPROVEMENT: 2,
WorkOrderCategory.OPTIMIZATION: 2,
WorkOrderCategory.SUGGESTION: 1,
}
SENSITIVE_PATHS = [
"swarm/coordinator",
"l402",
"lightning/",
"config.py",
"security",
"auth",
]
def compute_risk_score(wo: WorkOrder) -> int:
"""Compute a risk score for a work order. Higher = riskier.
Score components:
- Priority weight: critical=4, high=3, medium=2, low=1
- Category weight: bug/feature=3, improvement/optimization=2, suggestion=1
- File sensitivity: +2 per related file in security-sensitive areas
"""
score = PRIORITY_WEIGHTS.get(wo.priority, 2)
score += CATEGORY_WEIGHTS.get(wo.category, 1)
for f in wo.related_files:
if any(s in f for s in SENSITIVE_PATHS):
score += 2
return score
def should_auto_execute(wo: WorkOrder) -> bool:
"""Determine if a work order can auto-execute without human approval.
Checks:
1. Global auto-execute must be enabled
2. Work order priority must be at or below the configured threshold
3. Total risk score must be <= 3
"""
from config import settings
if not settings.work_orders_auto_execute:
return False
threshold_map = {"none": 0, "low": 1, "medium": 2, "high": 3}
max_auto = threshold_map.get(settings.work_orders_auto_threshold, 1)
priority_values = {
WorkOrderPriority.LOW: 1,
WorkOrderPriority.MEDIUM: 2,
WorkOrderPriority.HIGH: 3,
WorkOrderPriority.CRITICAL: 4,
}
if priority_values.get(wo.priority, 2) > max_auto:
return False
return compute_risk_score(wo) <= 3

306
tests/test_task_queue.py Normal file
View File

@@ -0,0 +1,306 @@
"""Tests for the Task Queue system."""
import json
import os
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Set test mode before importing app modules
os.environ["TIMMY_TEST_MODE"] = "1"
# ── Model Tests ──────────────────────────────────────────────────────────
def test_create_task():
from task_queue.models import create_task, TaskStatus, TaskPriority
task = create_task(
title="Test task",
description="A test description",
assigned_to="timmy",
created_by="user",
priority="normal",
)
assert task.id
assert task.title == "Test task"
assert task.status == TaskStatus.PENDING_APPROVAL
assert task.priority == TaskPriority.NORMAL
assert task.assigned_to == "timmy"
assert task.created_by == "user"
def test_get_task():
from task_queue.models import create_task, get_task
task = create_task(title="Get me", created_by="test")
retrieved = get_task(task.id)
assert retrieved is not None
assert retrieved.title == "Get me"
def test_get_task_not_found():
from task_queue.models import get_task
assert get_task("nonexistent-id") is None
def test_list_tasks():
from task_queue.models import create_task, list_tasks, TaskStatus
create_task(title="List test 1", created_by="test")
create_task(title="List test 2", created_by="test")
tasks = list_tasks()
assert len(tasks) >= 2
def test_list_tasks_with_status_filter():
from task_queue.models import (
create_task, list_tasks, update_task_status, TaskStatus,
)
task = create_task(title="Filter test", created_by="test")
update_task_status(task.id, TaskStatus.APPROVED)
approved = list_tasks(status=TaskStatus.APPROVED)
assert any(t.id == task.id for t in approved)
def test_update_task_status():
from task_queue.models import (
create_task, update_task_status, TaskStatus,
)
task = create_task(title="Status test", created_by="test")
updated = update_task_status(task.id, TaskStatus.APPROVED)
assert updated.status == TaskStatus.APPROVED
def test_update_task_running_sets_started_at():
from task_queue.models import (
create_task, update_task_status, TaskStatus,
)
task = create_task(title="Running test", created_by="test")
updated = update_task_status(task.id, TaskStatus.RUNNING)
assert updated.started_at is not None
def test_update_task_completed_sets_completed_at():
from task_queue.models import (
create_task, update_task_status, TaskStatus,
)
task = create_task(title="Complete test", created_by="test")
updated = update_task_status(task.id, TaskStatus.COMPLETED, result="Done!")
assert updated.completed_at is not None
assert updated.result == "Done!"
def test_update_task_fields():
from task_queue.models import create_task, update_task
task = create_task(title="Modify test", created_by="test")
updated = update_task(task.id, title="Modified title", priority="high")
assert updated.title == "Modified title"
assert updated.priority.value == "high"
def test_get_counts_by_status():
from task_queue.models import create_task, get_counts_by_status
create_task(title="Count test", created_by="test")
counts = get_counts_by_status()
assert "pending_approval" in counts
def test_get_pending_count():
from task_queue.models import create_task, get_pending_count
create_task(title="Pending count test", created_by="test")
count = get_pending_count()
assert count >= 1
def test_update_task_steps():
from task_queue.models import create_task, update_task_steps, get_task
task = create_task(title="Steps test", created_by="test")
steps = [
{"description": "Step 1", "status": "completed"},
{"description": "Step 2", "status": "running"},
]
ok = update_task_steps(task.id, steps)
assert ok
retrieved = get_task(task.id)
assert len(retrieved.steps) == 2
assert retrieved.steps[0]["description"] == "Step 1"
def test_auto_approve_not_triggered_by_default():
from task_queue.models import create_task, TaskStatus
task = create_task(title="No auto", created_by="user", auto_approve=False)
assert task.status == TaskStatus.PENDING_APPROVAL
def test_get_task_summary_for_briefing():
from task_queue.models import create_task, get_task_summary_for_briefing
create_task(title="Briefing test", created_by="test")
summary = get_task_summary_for_briefing()
assert "pending_approval" in summary
assert "total" in summary
# ── Route Tests ──────────────────────────────────────────────────────────
@pytest.fixture
def client():
"""FastAPI test client."""
from fastapi.testclient import TestClient
from dashboard.app import app
return TestClient(app)
def test_tasks_page(client):
resp = client.get("/tasks")
assert resp.status_code == 200
assert "TASK QUEUE" in resp.text
def test_api_list_tasks(client):
resp = client.get("/api/tasks")
assert resp.status_code == 200
data = resp.json()
assert "tasks" in data
assert "count" in data
def test_api_create_task(client):
resp = client.post(
"/api/tasks",
json={
"title": "API created task",
"description": "Test via API",
"assigned_to": "timmy",
"priority": "high",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["task"]["title"] == "API created task"
assert data["task"]["status"] == "pending_approval"
def test_api_task_counts(client):
resp = client.get("/api/tasks/counts")
assert resp.status_code == 200
data = resp.json()
assert "pending" in data
assert "total" in data
def test_form_create_task(client):
resp = client.post(
"/tasks/create",
data={
"title": "Form created task",
"description": "From form",
"assigned_to": "forge",
"priority": "normal",
},
)
assert resp.status_code == 200
assert "Form created task" in resp.text
def test_approve_task_htmx(client):
# Create then approve
create_resp = client.post(
"/api/tasks",
json={"title": "To approve", "assigned_to": "timmy"},
)
task_id = create_resp.json()["task"]["id"]
resp = client.post(f"/tasks/{task_id}/approve")
assert resp.status_code == 200
assert "APPROVED" in resp.text.upper() or "approved" in resp.text
def test_veto_task_htmx(client):
create_resp = client.post(
"/api/tasks",
json={"title": "To veto", "assigned_to": "timmy"},
)
task_id = create_resp.json()["task"]["id"]
resp = client.post(f"/tasks/{task_id}/veto")
assert resp.status_code == 200
assert "VETOED" in resp.text.upper() or "vetoed" in resp.text
def test_modify_task_htmx(client):
create_resp = client.post(
"/api/tasks",
json={"title": "To modify", "assigned_to": "timmy"},
)
task_id = create_resp.json()["task"]["id"]
resp = client.post(
f"/tasks/{task_id}/modify",
data={"title": "Modified via HTMX"},
)
assert resp.status_code == 200
assert "Modified via HTMX" in resp.text
def test_cancel_task_htmx(client):
create_resp = client.post(
"/api/tasks",
json={"title": "To cancel", "assigned_to": "timmy"},
)
task_id = create_resp.json()["task"]["id"]
resp = client.post(f"/tasks/{task_id}/cancel")
assert resp.status_code == 200
def test_retry_failed_task(client):
from task_queue.models import create_task, update_task_status, TaskStatus
task = create_task(title="To retry", created_by="test")
update_task_status(task.id, TaskStatus.FAILED, result="Something broke")
resp = client.post(f"/tasks/{task.id}/retry")
assert resp.status_code == 200
def test_pending_partial(client):
resp = client.get("/tasks/pending")
assert resp.status_code == 200
def test_active_partial(client):
resp = client.get("/tasks/active")
assert resp.status_code == 200
def test_completed_partial(client):
resp = client.get("/tasks/completed")
assert resp.status_code == 200
def test_api_approve_nonexistent(client):
resp = client.patch("/api/tasks/nonexistent/approve")
assert resp.status_code == 404
def test_api_veto_nonexistent(client):
resp = client.patch("/api/tasks/nonexistent/veto")
assert resp.status_code == 404

285
tests/test_work_orders.py Normal file
View File

@@ -0,0 +1,285 @@
"""Tests for the work order system."""
from work_orders.models import (
WorkOrder,
WorkOrderCategory,
WorkOrderPriority,
WorkOrderStatus,
create_work_order,
get_counts_by_status,
get_pending_count,
get_work_order,
list_work_orders,
update_work_order_status,
)
from work_orders.risk import compute_risk_score, should_auto_execute
# ── Model CRUD tests ──────────────────────────────────────────────────────────
def test_create_work_order():
wo = create_work_order(
title="Fix the login bug",
description="Login fails on mobile",
priority="high",
category="bug",
submitter="comet",
)
assert wo.id
assert wo.title == "Fix the login bug"
assert wo.priority == WorkOrderPriority.HIGH
assert wo.category == WorkOrderCategory.BUG
assert wo.status == WorkOrderStatus.SUBMITTED
assert wo.submitter == "comet"
def test_get_work_order():
wo = create_work_order(title="Test get", submitter="test")
fetched = get_work_order(wo.id)
assert fetched is not None
assert fetched.title == "Test get"
assert fetched.submitter == "test"
def test_get_work_order_not_found():
assert get_work_order("nonexistent-id") is None
def test_list_work_orders_no_filter():
create_work_order(title="Order A", submitter="a")
create_work_order(title="Order B", submitter="b")
orders = list_work_orders()
assert len(orders) >= 2
def test_list_work_orders_by_status():
wo = create_work_order(title="Status test")
update_work_order_status(wo.id, WorkOrderStatus.APPROVED)
approved = list_work_orders(status=WorkOrderStatus.APPROVED)
assert any(o.id == wo.id for o in approved)
def test_list_work_orders_by_priority():
create_work_order(title="Critical item", priority="critical")
critical = list_work_orders(priority=WorkOrderPriority.CRITICAL)
assert len(critical) >= 1
assert all(o.priority == WorkOrderPriority.CRITICAL for o in critical)
def test_update_work_order_status():
wo = create_work_order(title="Update test")
updated = update_work_order_status(wo.id, WorkOrderStatus.APPROVED)
assert updated is not None
assert updated.status == WorkOrderStatus.APPROVED
assert updated.approved_at is not None
def test_update_work_order_with_kwargs():
wo = create_work_order(title="Kwargs test")
updated = update_work_order_status(
wo.id, WorkOrderStatus.REJECTED, rejection_reason="Not needed"
)
assert updated is not None
assert updated.rejection_reason == "Not needed"
def test_update_nonexistent():
result = update_work_order_status("fake-id", WorkOrderStatus.APPROVED)
assert result is None
def test_get_pending_count():
create_work_order(title="Pending 1")
create_work_order(title="Pending 2")
count = get_pending_count()
assert count >= 2
def test_get_counts_by_status():
create_work_order(title="Count test")
counts = get_counts_by_status()
assert "submitted" in counts
assert counts["submitted"] >= 1
def test_related_files_roundtrip():
wo = create_work_order(
title="Files test",
related_files=["src/config.py", "src/timmy/agent.py"],
)
fetched = get_work_order(wo.id)
assert fetched.related_files == ["src/config.py", "src/timmy/agent.py"]
# ── Risk scoring tests ────────────────────────────────────────────────────────
def test_risk_score_low_suggestion():
wo = WorkOrder(
priority=WorkOrderPriority.LOW,
category=WorkOrderCategory.SUGGESTION,
)
score = compute_risk_score(wo)
assert score == 2 # 1 (low) + 1 (suggestion)
def test_risk_score_critical_bug():
wo = WorkOrder(
priority=WorkOrderPriority.CRITICAL,
category=WorkOrderCategory.BUG,
)
score = compute_risk_score(wo)
assert score == 7 # 4 (critical) + 3 (bug)
def test_risk_score_sensitive_files():
wo = WorkOrder(
priority=WorkOrderPriority.LOW,
category=WorkOrderCategory.SUGGESTION,
related_files=["src/swarm/coordinator.py"],
)
score = compute_risk_score(wo)
assert score == 4 # 1 + 1 + 2 (sensitive)
def test_should_auto_execute_disabled(monkeypatch):
monkeypatch.setattr("config.settings.work_orders_auto_execute", False)
wo = WorkOrder(
priority=WorkOrderPriority.LOW,
category=WorkOrderCategory.SUGGESTION,
)
assert should_auto_execute(wo) is False
def test_should_auto_execute_low_risk(monkeypatch):
monkeypatch.setattr("config.settings.work_orders_auto_execute", True)
monkeypatch.setattr("config.settings.work_orders_auto_threshold", "low")
wo = WorkOrder(
priority=WorkOrderPriority.LOW,
category=WorkOrderCategory.SUGGESTION,
)
assert should_auto_execute(wo) is True
def test_should_auto_execute_high_priority_blocked(monkeypatch):
monkeypatch.setattr("config.settings.work_orders_auto_execute", True)
monkeypatch.setattr("config.settings.work_orders_auto_threshold", "low")
wo = WorkOrder(
priority=WorkOrderPriority.HIGH,
category=WorkOrderCategory.BUG,
)
assert should_auto_execute(wo) is False
# ── Route tests ───────────────────────────────────────────────────────────────
def test_submit_work_order(client):
resp = client.post(
"/work-orders/submit",
data={
"title": "Test submission",
"description": "Testing the API",
"priority": "low",
"category": "suggestion",
"submitter": "test-agent",
"submitter_type": "agent",
"related_files": "",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["work_order_id"]
assert data["execution_mode"] in ("auto", "manual")
def test_submit_json(client):
resp = client.post(
"/work-orders/submit/json",
json={
"title": "JSON test",
"description": "Testing JSON API",
"priority": "medium",
"category": "improvement",
"submitter": "comet",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
def test_list_work_orders_route(client):
client.post(
"/work-orders/submit",
data={"title": "List test", "submitter": "test"},
)
resp = client.get("/work-orders")
assert resp.status_code == 200
data = resp.json()
assert "work_orders" in data
assert data["count"] >= 1
def test_get_work_order_route(client):
submit = client.post(
"/work-orders/submit",
data={"title": "Get test", "submitter": "test"},
)
wo_id = submit.json()["work_order_id"]
resp = client.get(f"/work-orders/{wo_id}")
assert resp.status_code == 200
assert resp.json()["title"] == "Get test"
def test_get_work_order_not_found_route(client):
resp = client.get("/work-orders/nonexistent-id")
assert resp.status_code == 404
def test_approve_work_order(client):
submit = client.post(
"/work-orders/submit",
data={"title": "Approve test", "submitter": "test"},
)
wo_id = submit.json()["work_order_id"]
resp = client.post(f"/work-orders/{wo_id}/approve")
assert resp.status_code == 200
def test_reject_work_order(client):
submit = client.post(
"/work-orders/submit",
data={"title": "Reject test", "submitter": "test"},
)
wo_id = submit.json()["work_order_id"]
resp = client.post(
f"/work-orders/{wo_id}/reject",
data={"reason": "Not needed"},
)
assert resp.status_code == 200
def test_work_order_counts(client):
client.post(
"/work-orders/submit",
data={"title": "Count test", "submitter": "test"},
)
resp = client.get("/work-orders/api/counts")
assert resp.status_code == 200
data = resp.json()
assert "pending" in data
assert "total" in data
def test_work_order_queue_page(client):
resp = client.get("/work-orders/queue")
assert resp.status_code == 200
assert b"WORK ORDERS" in resp.content
def test_work_order_pending_partial(client):
resp = client.get("/work-orders/queue/pending")
assert resp.status_code == 200