forked from Rockachopa/Timmy-time-dashboard
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:
390
src/task_queue/models.py
Normal file
390
src/task_queue/models.py
Normal 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],
|
||||
}
|
||||
Reference in New Issue
Block a user