1
0

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