fix: resolve 6 dashboard bugs and rebuild Task Queue + Work Orders (#144) (#144)

Round 2+3 bug fix batch:

1. Ollama timeout: Add request_timeout=300 to prevent socket read errors
   on complex 30-60s prompts (production crash fix)

2. Memory API: Create missing HTMX partial templates (memory_facts.html,
   memory_results.html) so Save/Search buttons work

3. CALM page: Add create_tables() call so SQLAlchemy tables exist on
   first request (was returning HTTP 500)

4. Task Queue: Full SQLite-backed rebuild with CRUD endpoints, HTMX
   partials, and action buttons (approve/veto/pause/cancel/retry)

5. Work Orders: Full SQLite-backed rebuild with submit/approve/reject/
   execute pipeline and HTMX polling partials

6. Memory READ tool: Add memory_read function so Timmy stops calling
   read_file when trying to recall stored facts

Also: Close GitHub issues #115, #114, #112, #110 as won't-fix.
Comment on #107 confirming prune_memories() already wired to startup.

Tests: 33 new tests across 4 test files, all passing.
Full suite: 1155 passed, 2 pre-existing failures (hands_shell).

Co-authored-by: Trip T <trip@local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-07 23:21:30 -05:00
committed by GitHub
parent b8164e46b0
commit e36a1dc939
18 changed files with 1036 additions and 14 deletions

View File

@@ -38,6 +38,8 @@ from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.calm import router as calm_router
from dashboard.routes.swarm import router as swarm_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.system import router as system_router
from dashboard.routes.paperclip import router as paperclip_router
from infrastructure.router.api import router as cascade_router
@@ -333,6 +335,8 @@ app.include_router(chat_api_router)
app.include_router(thinking_router)
app.include_router(calm_router)
app.include_router(swarm_router)
app.include_router(tasks_router)
app.include_router(work_orders_router)
app.include_router(system_router)
app.include_router(paperclip_router)
app.include_router(cascade_router)

View File

@@ -12,6 +12,11 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def create_tables():
"""Create all tables defined by models that have imported Base."""
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:

View File

@@ -8,9 +8,12 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from dashboard.models.calm import JournalEntry, Task, TaskCertainty, TaskState
from dashboard.models.database import SessionLocal, engine, get_db
from dashboard.models.database import SessionLocal, engine, get_db, create_tables
from dashboard.templating import templates
# Ensure CALM tables exist (safe to call multiple times)
create_tables()
logger = logging.getLogger(__name__)
router = APIRouter(tags=["calm"])

View File

@@ -77,11 +77,6 @@ async def self_modify_queue(request: Request):
)
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(request: Request):
return templates.TemplateResponse(request, "tasks.html", {"tasks": []})
@router.get("/swarm/mission-control", response_class=HTMLResponse)
async def mission_control(request: Request):
return templates.TemplateResponse(request, "mission_control.html", {})
@@ -104,11 +99,6 @@ async def hands_page(request: Request):
return templates.TemplateResponse(request, "hands.html", {"executions": []})
@router.get("/work-orders/queue", response_class=HTMLResponse)
async def work_orders(request: Request):
return templates.TemplateResponse(request, "work_orders.html", {"orders": []})
@router.get("/creative/ui", response_class=HTMLResponse)
async def creative_ui(request: Request):
return templates.TemplateResponse(request, "creative.html", {})

View File

@@ -0,0 +1,375 @@
"""Task Queue routes — SQLite-backed CRUD for the task management dashboard."""
import logging
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
from dashboard.templating import templates
logger = logging.getLogger(__name__)
router = APIRouter(tags=["tasks"])
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
DB_PATH = Path("data/tasks.db")
VALID_STATUSES = {
"pending_approval", "approved", "running", "paused",
"completed", "vetoed", "failed", "backlogged",
}
VALID_PRIORITIES = {"low", "normal", "high", "urgent"}
def _get_db() -> sqlite3.Connection:
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 tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending_approval',
priority TEXT DEFAULT 'normal',
assigned_to TEXT DEFAULT '',
created_by TEXT DEFAULT 'operator',
result TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
return conn
def _row_to_dict(row: sqlite3.Row) -> dict:
return dict(row)
class _EnumLike:
"""Thin wrapper so Jinja templates can use task.status.value."""
def __init__(self, v: str):
self.value = v
def __str__(self):
return self.value
def __eq__(self, other):
if isinstance(other, str):
return self.value == other
return NotImplemented
class _TaskView:
"""Lightweight view object for Jinja template rendering."""
def __init__(self, row: dict):
self.id = row["id"]
self.title = row.get("title", "")
self.description = row.get("description", "")
self.status = _EnumLike(row.get("status", "pending_approval"))
self.priority = _EnumLike(row.get("priority", "normal"))
self.assigned_to = row.get("assigned_to", "")
self.created_by = row.get("created_by", "operator")
self.result = row.get("result", "")
self.created_at = row.get("created_at", "")
self.completed_at = row.get("completed_at")
self.steps = [] # reserved for future use
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(request: Request):
"""Render the main task queue page with 3-column layout."""
db = _get_db()
try:
pending = [_TaskView(_row_to_dict(r)) for r in db.execute(
"SELECT * FROM tasks WHERE status IN ('pending_approval') ORDER BY created_at DESC"
).fetchall()]
active = [_TaskView(_row_to_dict(r)) for r in db.execute(
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
).fetchall()]
completed = [_TaskView(_row_to_dict(r)) for r in db.execute(
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
).fetchall()]
finally:
db.close()
return templates.TemplateResponse(request, "tasks.html", {
"pending_count": len(pending),
"pending": pending,
"active": active,
"completed": completed,
"agents": [], # no agent roster wired yet
"pre_assign": "",
})
# ---------------------------------------------------------------------------
# HTMX partials (polled by the template)
# ---------------------------------------------------------------------------
@router.get("/tasks/pending", response_class=HTMLResponse)
async def tasks_pending(request: Request):
db = _get_db()
try:
rows = db.execute(
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
).fetchall()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode())
if not parts:
return HTMLResponse('<div class="empty-column">No pending tasks</div>')
return HTMLResponse("".join(parts))
@router.get("/tasks/active", response_class=HTMLResponse)
async def tasks_active(request: Request):
db = _get_db()
try:
rows = db.execute(
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
).fetchall()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode())
if not parts:
return HTMLResponse('<div class="empty-column">No active tasks</div>')
return HTMLResponse("".join(parts))
@router.get("/tasks/completed", response_class=HTMLResponse)
async def tasks_completed(request: Request):
db = _get_db()
try:
rows = db.execute(
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
).fetchall()
finally:
db.close()
tasks = [_TaskView(_row_to_dict(r)) for r in rows]
parts = []
for task in tasks:
parts.append(templates.TemplateResponse(
request, "partials/task_card.html", {"task": task}
).body.decode())
if not parts:
return HTMLResponse('<div class="empty-column">No completed tasks yet</div>')
return HTMLResponse("".join(parts))
# ---------------------------------------------------------------------------
# Form-based create (used by the modal in tasks.html)
# ---------------------------------------------------------------------------
@router.post("/tasks/create", response_class=HTMLResponse)
async def create_task_form(
request: Request,
title: str = Form(...),
description: str = Form(""),
priority: str = Form("normal"),
assigned_to: str = Form(""),
):
"""Create a task from the modal form and return a task card partial."""
task_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
priority = priority if priority in VALID_PRIORITIES else "normal"
db = _get_db()
try:
db.execute(
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(task_id, title, description, priority, assigned_to, now),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
task = _TaskView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
# ---------------------------------------------------------------------------
# Task action endpoints (approve, veto, modify, pause, cancel, retry)
# ---------------------------------------------------------------------------
@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
async def approve_task(request: Request, task_id: str):
return await _set_status(request, task_id, "approved")
@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
async def veto_task(request: Request, task_id: str):
return await _set_status(request, task_id, "vetoed")
@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
async def pause_task(request: Request, task_id: str):
return await _set_status(request, task_id, "paused")
@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
async def cancel_task(request: Request, task_id: str):
return await _set_status(request, task_id, "vetoed")
@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
async def retry_task(request: Request, task_id: str):
return await _set_status(request, task_id, "approved")
@router.post("/tasks/{task_id}/modify", response_class=HTMLResponse)
async def modify_task(
request: Request,
task_id: str,
title: str = Form(...),
description: str = Form(""),
):
db = _get_db()
try:
db.execute(
"UPDATE tasks SET title=?, description=? WHERE id=?",
(title, description, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
async def _set_status(request: Request, task_id: str, new_status: str):
"""Helper to update status and return refreshed task card."""
completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
db = _get_db()
try:
db.execute(
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
(new_status, completed_at, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
task = _TaskView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/task_card.html", {"task": task})
# ---------------------------------------------------------------------------
# JSON API (for programmatic access / Timmy's tool calls)
# ---------------------------------------------------------------------------
@router.post("/api/tasks", response_class=JSONResponse, status_code=201)
async def api_create_task(request: Request):
"""Create a task via JSON API."""
body = await request.json()
title = body.get("title")
if not title:
raise HTTPException(422, "title is required")
task_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
priority = body.get("priority", "normal")
if priority not in VALID_PRIORITIES:
priority = "normal"
db = _get_db()
try:
db.execute(
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
task_id,
title,
body.get("description", ""),
priority,
body.get("assigned_to", ""),
body.get("created_by", "operator"),
now,
),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
return JSONResponse(_row_to_dict(row), status_code=201)
@router.get("/api/tasks", response_class=JSONResponse)
async def api_list_tasks():
"""List all tasks as JSON."""
db = _get_db()
try:
rows = db.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
finally:
db.close()
return JSONResponse([_row_to_dict(r) for r in rows])
@router.patch("/api/tasks/{task_id}/status", response_class=JSONResponse)
async def api_update_status(task_id: str, request: Request):
"""Update task status via JSON API."""
body = await request.json()
new_status = body.get("status")
if not new_status or new_status not in VALID_STATUSES:
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
db = _get_db()
try:
db.execute(
"UPDATE tasks SET status=?, completed_at=COALESCE(?, completed_at) WHERE id=?",
(new_status, completed_at, task_id),
)
db.commit()
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Task not found")
return JSONResponse(_row_to_dict(row))
@router.delete("/api/tasks/{task_id}", response_class=JSONResponse)
async def api_delete_task(task_id: str):
"""Delete a task."""
db = _get_db()
try:
cursor = db.execute("DELETE FROM tasks WHERE id=?", (task_id,))
db.commit()
finally:
db.close()
if cursor.rowcount == 0:
raise HTTPException(404, "Task not found")
return JSONResponse({"success": True, "id": task_id})

View File

@@ -0,0 +1,239 @@
"""Work Orders routes — SQLite-backed submit/review/execute pipeline."""
import logging
import sqlite3
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse
from dashboard.templating import templates
logger = logging.getLogger(__name__)
router = APIRouter(tags=["work-orders"])
DB_PATH = Path("data/work_orders.db")
PRIORITIES = ["low", "medium", "high", "critical"]
CATEGORIES = ["bug", "feature", "suggestion", "maintenance", "security"]
VALID_STATUSES = {"submitted", "triaged", "approved", "in_progress", "completed", "rejected"}
def _get_db() -> sqlite3.Connection:
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 DEFAULT '',
priority TEXT DEFAULT 'medium',
category TEXT DEFAULT 'suggestion',
submitter TEXT DEFAULT 'dashboard',
related_files TEXT DEFAULT '',
status TEXT DEFAULT 'submitted',
result TEXT DEFAULT '',
rejection_reason TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
)
""")
conn.commit()
return conn
class _EnumLike:
def __init__(self, v: str):
self.value = v
def __str__(self):
return self.value
def __eq__(self, other):
if isinstance(other, str):
return self.value == other
return NotImplemented
class _WOView:
"""View object for Jinja template rendering."""
def __init__(self, row: dict):
self.id = row["id"]
self.title = row.get("title", "")
self.description = row.get("description", "")
self.priority = _EnumLike(row.get("priority", "medium"))
self.category = _EnumLike(row.get("category", "suggestion"))
self.submitter = row.get("submitter", "dashboard")
self.status = _EnumLike(row.get("status", "submitted"))
raw_files = row.get("related_files", "")
self.related_files = [f.strip() for f in raw_files.split(",") if f.strip()] if raw_files else []
self.result = row.get("result", "")
self.rejection_reason = row.get("rejection_reason", "")
self.created_at = row.get("created_at", "")
self.completed_at = row.get("completed_at")
self.execution_mode = None
def _row_to_dict(row: sqlite3.Row) -> dict:
return dict(row)
def _query_wos(db, statuses):
placeholders = ",".join("?" for _ in statuses)
return [
_WOView(_row_to_dict(r))
for r in db.execute(
f"SELECT * FROM work_orders WHERE status IN ({placeholders}) ORDER BY created_at DESC",
statuses,
).fetchall()
]
# ---------------------------------------------------------------------------
# Page route
# ---------------------------------------------------------------------------
@router.get("/work-orders/queue", response_class=HTMLResponse)
async def work_orders_page(request: Request):
db = _get_db()
try:
pending = _query_wos(db, ["submitted", "triaged"])
active = _query_wos(db, ["approved", "in_progress"])
completed = _query_wos(db, ["completed"])
rejected = _query_wos(db, ["rejected"])
finally:
db.close()
return templates.TemplateResponse(request, "work_orders.html", {
"pending_count": len(pending),
"pending": pending,
"active": active,
"completed": completed,
"rejected": rejected,
"priorities": PRIORITIES,
"categories": CATEGORIES,
})
# ---------------------------------------------------------------------------
# Form submit
# ---------------------------------------------------------------------------
@router.post("/work-orders/submit", response_class=HTMLResponse)
async def submit_work_order(
request: Request,
title: str = Form(...),
description: str = Form(""),
priority: str = Form("medium"),
category: str = Form("suggestion"),
submitter: str = Form("dashboard"),
related_files: str = Form(""),
):
wo_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
priority = priority if priority in PRIORITIES else "medium"
category = category if category in CATEGORIES else "suggestion"
db = _get_db()
try:
db.execute(
"INSERT INTO work_orders (id, title, description, priority, category, submitter, related_files, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(wo_id, title, description, priority, category, submitter, related_files, now),
)
db.commit()
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
finally:
db.close()
wo = _WOView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo})
# ---------------------------------------------------------------------------
# HTMX partials
# ---------------------------------------------------------------------------
@router.get("/work-orders/queue/pending", response_class=HTMLResponse)
async def pending_partial(request: Request):
db = _get_db()
try:
wos = _query_wos(db, ["submitted", "triaged"])
finally:
db.close()
if not wos:
return HTMLResponse(
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
"No pending work orders.</div>"
)
parts = []
for wo in wos:
parts.append(
templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}).body.decode()
)
return HTMLResponse("".join(parts))
@router.get("/work-orders/queue/active", response_class=HTMLResponse)
async def active_partial(request: Request):
db = _get_db()
try:
wos = _query_wos(db, ["approved", "in_progress"])
finally:
db.close()
if not wos:
return HTMLResponse(
'<div style="color: var(--text-muted); font-size: 0.8rem; padding: 12px 0;">'
"No work orders currently in progress.</div>"
)
parts = []
for wo in wos:
parts.append(
templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo}).body.decode()
)
return HTMLResponse("".join(parts))
# ---------------------------------------------------------------------------
# Action endpoints
# ---------------------------------------------------------------------------
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
completed_at = datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
db = _get_db()
try:
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
vals = [new_status, completed_at]
for col, val in extra.items():
sets.append(f"{col}=?")
vals.append(val)
vals.append(wo_id)
db.execute(f"UPDATE work_orders SET {', '.join(sets)} WHERE id=?", vals)
db.commit()
row = db.execute("SELECT * FROM work_orders WHERE id=?", (wo_id,)).fetchone()
finally:
db.close()
if not row:
raise HTTPException(404, "Work order not found")
wo = _WOView(_row_to_dict(row))
return templates.TemplateResponse(request, "partials/work_order_card.html", {"wo": wo})
@router.post("/work-orders/{wo_id}/approve", response_class=HTMLResponse)
async def approve_wo(request: Request, wo_id: str):
return await _update_status(request, wo_id, "approved")
@router.post("/work-orders/{wo_id}/reject", response_class=HTMLResponse)
async def reject_wo(request: Request, wo_id: str):
return await _update_status(request, wo_id, "rejected")
@router.post("/work-orders/{wo_id}/execute", response_class=HTMLResponse)
async def execute_wo(request: Request, wo_id: str):
return await _update_status(request, wo_id, "in_progress")

View File

@@ -0,0 +1,13 @@
{% if facts %}
<ul class="mc-fact-list" style="list-style: none; padding: 0;">
{% for fact in facts %}
<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 %}
<p class="mc-text-secondary">No personal facts stored yet.</p>
{% endif %}

View File

@@ -0,0 +1,34 @@
<div class="mc-results-section">
<h3>Search Results</h3>
{% if results %}
<div class="memory-results">
{% for mem in results %}
<div class="memory-entry" data-relevance="{{ mem.relevance_score }}">
<div class="memory-header">
<span class="memory-source">{{ mem.source }}</span>
<span class="memory-type mc-badge">{{ mem.context_type }}</span>
{% if mem.relevance_score %}
<span class="memory-score">{{ "%.2f"|format(mem.relevance_score) }}</span>
{% endif %}
</div>
<div class="memory-content">{{ mem.content }}</div>
<div class="memory-meta">
<span class="memory-time">{{ mem.timestamp[11:16] }}</span>
{% if mem.agent_id %}
<span class="memory-agent">Agent: {{ mem.agent_id[:8] }}...</span>
{% endif %}
{% if mem.task_id %}
<span class="memory-task">Task: {{ mem.task_id[:8] }}...</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="mc-empty-state">
<p>No results found for "{{ query }}"</p>
<p class="mc-text-secondary">Try different keywords or check spelling.</p>
</div>
{% endif %}
</div>

View File

@@ -275,7 +275,7 @@ def create_timmy(
return Agent(
name="Agent",
model=Ollama(id=model_name, host=settings.ollama_url),
model=Ollama(id=model_name, host=settings.ollama_url, request_timeout=300),
db=SqliteDb(db_file=db_file),
description=full_prompt,
add_history_to_context=True,

View File

@@ -63,7 +63,7 @@ class BaseAgent(ABC):
return Agent(
name=self.name,
model=Ollama(id=settings.ollama_model, host=settings.ollama_url),
model=Ollama(id=settings.ollama_model, host=settings.ollama_url, request_timeout=300),
description=system_prompt,
tools=tool_instances if tool_instances else None,
add_history_to_context=True,

View File

@@ -365,6 +365,49 @@ def memory_search(query: str, top_k: int = 5) -> str:
return "\n\n".join(parts)
def memory_read(query: str = "", top_k: int = 5) -> str:
"""Read from persistent memory — search facts, notes, and past conversations.
This is the primary tool for recalling stored information. If no query
is given, returns the most recent personal facts. With a query, it
searches semantically across all stored memories.
Args:
query: Optional search term. Leave empty to list recent facts.
top_k: Maximum results to return (default 5).
Returns:
Formatted string of memory contents.
"""
if top_k is None:
top_k = 5
parts: list[str] = []
# Always include personal facts first
try:
from timmy.memory.vector_store import search_memories
facts = search_memories(query or "", limit=top_k, min_relevance=0.0)
fact_entries = [e for e in facts if (e.context_type or "") == "fact"]
if fact_entries:
parts.append("## Personal Facts")
for entry in fact_entries[:top_k]:
parts.append(f"- {entry.content[:300]}")
except Exception as exc:
logger.debug("Vector store unavailable for memory_read: %s", exc)
# If a query was provided, also do semantic search
if query:
search_result = memory_search(query, top_k)
if search_result and search_result != "No relevant memories found.":
parts.append("\n## Search Results")
parts.append(search_result)
if not parts:
return "No memories stored yet. Use memory_write to store information."
return "\n".join(parts)
def memory_write(content: str, context_type: str = "fact") -> str:
"""Store a piece of information in persistent memory.

View File

@@ -447,10 +447,11 @@ def create_full_toolkit(base_dir: str | Path | None = None):
# Memory search and write — persistent recall across all channels
try:
from timmy.semantic_memory import memory_search, memory_write
from timmy.semantic_memory import memory_search, memory_write, memory_read
toolkit.register(memory_search, name="memory_search")
toolkit.register(memory_write, name="memory_write")
toolkit.register(memory_read, name="memory_read")
except Exception:
logger.debug("Memory tools not available")

View File

@@ -61,6 +61,8 @@ def clean_database(tmp_path):
tmp_swarm_db = tmp_path / "swarm.db"
tmp_spark_db = tmp_path / "spark.db"
tmp_self_coding_db = tmp_path / "self_coding.db"
tmp_tasks_db = tmp_path / "tasks.db"
tmp_work_orders_db = tmp_path / "work_orders.db"
_swarm_db_modules = [
"timmy.memory.vector_store",
@@ -98,6 +100,18 @@ def clean_database(tmp_path):
except Exception:
pass
# Redirect task queue and work orders DBs to temp dir
for mod_name, tmp_db in [
("dashboard.routes.tasks", tmp_tasks_db),
("dashboard.routes.work_orders", tmp_work_orders_db),
]:
try:
mod = __import__(mod_name, fromlist=["DB_PATH"])
originals[(mod_name, "DB_PATH")] = getattr(mod, "DB_PATH")
setattr(mod, "DB_PATH", tmp_db)
except Exception:
pass
yield
for (mod_name, attr), original in originals.items():

View File

@@ -0,0 +1,69 @@
"""Tests for the Memory API endpoints.
Verifies that facts can be created, searched, edited, and deleted
through the dashboard memory routes.
"""
def test_memory_page_returns_200(client):
response = client.get("/memory")
assert response.status_code == 200
assert "Memory Browser" in response.text
def test_add_fact_returns_html(client):
"""POST /memory/fact should return HTML partial with the new fact."""
response = client.post("/memory/fact", data={"fact": "Alexander is the operator"})
assert response.status_code == 200
assert "Alexander is the operator" in response.text
def test_add_fact_persists(client):
"""After adding a fact, it should appear on the main memory page."""
client.post("/memory/fact", data={"fact": "Timmy runs on Qwen"})
response = client.get("/memory")
assert response.status_code == 200
assert "Timmy runs on Qwen" in response.text
def test_memory_search_returns_html(client):
"""POST /memory/search should return HTML partial."""
response = client.post("/memory/search", data={"query": "test query"})
assert response.status_code == 200
def test_edit_fact(client):
"""PUT /memory/fact/{id} should update the fact content."""
# First create a fact
client.post("/memory/fact", data={"fact": "Original fact"})
# Get the fact ID from the memory page
page = client.get("/memory")
assert "Original fact" in page.text
# Extract a fact ID from the page (look for fact- pattern)
import re
match = re.search(r'id="fact-([^"]+)"', page.text)
if match:
fact_id = match.group(1)
response = client.put(
f"/memory/fact/{fact_id}",
json={"content": "Updated fact"},
)
assert response.status_code == 200
assert response.json()["success"] is True
def test_delete_fact(client):
"""DELETE /memory/fact/{id} should remove the fact."""
# Create a fact
client.post("/memory/fact", data={"fact": "Fact to delete"})
page = client.get("/memory")
import re
match = re.search(r'id="fact-([^"]+)"', page.text)
if match:
fact_id = match.group(1)
response = client.delete(f"/memory/fact/{fact_id}")
assert response.status_code == 200
assert response.json()["success"] is True

View File

@@ -0,0 +1,91 @@
"""Tests for the Task Queue API endpoints.
Verifies task CRUD operations and the dashboard page rendering.
"""
def test_tasks_page_returns_200(client):
response = client.get("/tasks")
assert response.status_code == 200
assert "TASK QUEUE" in response.text
def test_create_task(client):
"""POST /api/tasks returns 201 with task JSON."""
response = client.post("/api/tasks", json={
"title": "Fix the memory bug",
"priority": "high",
})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Fix the memory bug"
assert data["priority"] == "high"
assert data["status"] == "pending_approval"
assert "id" in data
def test_list_tasks(client):
"""GET /api/tasks returns JSON array."""
response = client.get("/api/tasks")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_create_and_list_roundtrip(client):
"""Creating a task makes it appear in the list."""
client.post("/api/tasks", json={"title": "Roundtrip test"})
response = client.get("/api/tasks")
tasks = response.json()
assert any(t["title"] == "Roundtrip test" for t in tasks)
def test_update_task_status(client):
"""PATCH /api/tasks/{id}/status updates the task."""
create = client.post("/api/tasks", json={"title": "To approve"})
task_id = create.json()["id"]
response = client.patch(
f"/api/tasks/{task_id}/status",
json={"status": "approved"},
)
assert response.status_code == 200
assert response.json()["status"] == "approved"
def test_delete_task(client):
"""DELETE /api/tasks/{id} removes the task."""
create = client.post("/api/tasks", json={"title": "To delete"})
task_id = create.json()["id"]
response = client.delete(f"/api/tasks/{task_id}")
assert response.status_code == 200
# Verify it's gone
tasks = client.get("/api/tasks").json()
assert not any(t["id"] == task_id for t in tasks)
def test_create_task_missing_title_422(client):
"""POST /api/tasks without title returns 422."""
response = client.post("/api/tasks", json={"priority": "high"})
assert response.status_code == 422
def test_create_task_via_form(client):
"""POST /tasks/create via form creates and returns task card HTML."""
response = client.post("/tasks/create", data={
"title": "Form task",
"description": "Created via form",
"priority": "normal",
"assigned_to": "",
})
assert response.status_code == 200
assert "Form task" in response.text
def test_pending_partial(client):
"""GET /tasks/pending returns HTML partial."""
client.post("/api/tasks", json={"title": "Pending task"})
response = client.get("/tasks/pending")
assert response.status_code == 200
assert "Pending task" in response.text

View File

@@ -0,0 +1,64 @@
"""Tests for the Work Orders API endpoints."""
def test_work_orders_page_returns_200(client):
response = client.get("/work-orders/queue")
assert response.status_code == 200
assert "WORK ORDERS" in response.text
def test_submit_work_order(client):
"""POST /work-orders/submit creates a work order."""
response = client.post("/work-orders/submit", data={
"title": "Fix the dashboard",
"description": "Details here",
"priority": "high",
"category": "bug",
"submitter": "dashboard",
"related_files": "src/app.py",
})
assert response.status_code == 200
def test_pending_partial_returns_200(client):
"""GET /work-orders/queue/pending returns HTML."""
response = client.get("/work-orders/queue/pending")
assert response.status_code == 200
def test_active_partial_returns_200(client):
"""GET /work-orders/queue/active returns HTML."""
response = client.get("/work-orders/queue/active")
assert response.status_code == 200
def test_submit_and_list_roundtrip(client):
"""Submitting a work order makes it appear in the pending section."""
client.post("/work-orders/submit", data={
"title": "Roundtrip WO",
"priority": "medium",
"category": "suggestion",
"submitter": "test",
})
response = client.get("/work-orders/queue/pending")
assert "Roundtrip WO" in response.text
def test_approve_work_order(client):
"""POST /work-orders/{id}/approve changes status."""
# Submit one first
client.post("/work-orders/submit", data={
"title": "To approve",
"priority": "medium",
"category": "suggestion",
"submitter": "test",
})
# Get ID from pending
pending = client.get("/work-orders/queue/pending")
import re
match = re.search(r'id="wo-([^"]+)"', pending.text)
if match:
wo_id = match.group(1)
response = client.post(f"/work-orders/{wo_id}/approve")
assert response.status_code == 200
assert "APPROVED" in response.text.upper() or "EXECUTE" in response.text.upper()

View File

@@ -0,0 +1,60 @@
"""Test that Ollama model is created with a generous request timeout.
The default httpx timeout is too short for complex prompts (30-60s generation).
This caused socket read errors in production.
"""
from unittest.mock import patch, MagicMock
def test_base_agent_sets_request_timeout():
"""BaseAgent creates Ollama with request_timeout=300."""
with patch("timmy.agents.base.Ollama") as mock_ollama, \
patch("timmy.agents.base.Agent"):
mock_ollama.return_value = MagicMock()
# Import after patching to get the patched version
from timmy.agents.base import BaseAgent
class ConcreteAgent(BaseAgent):
async def handle_message(self, message: str) -> str:
return ""
# Trigger Ollama construction
try:
ConcreteAgent(
agent_id="test",
name="Test",
role="tester",
system_prompt="You are a test agent.",
tools=[],
)
except Exception:
pass # MCP registry may not be available
# Verify Ollama was called with request_timeout
if mock_ollama.called:
_, kwargs = mock_ollama.call_args
assert kwargs.get("request_timeout") == 300, (
f"Expected request_timeout=300, got {kwargs.get('request_timeout')}"
)
def test_main_agent_sets_request_timeout():
"""create_timmy() creates Ollama with request_timeout=300."""
with patch("timmy.agent.Ollama") as mock_ollama, \
patch("timmy.agent.SqliteDb"), \
patch("timmy.agent.Agent"):
mock_ollama.return_value = MagicMock()
from timmy.agent import create_timmy
try:
create_timmy()
except Exception:
pass
if mock_ollama.called:
_, kwargs = mock_ollama.call_args
assert kwargs.get("request_timeout") == 300, (
f"Expected request_timeout=300, got {kwargs.get('request_timeout')}"
)

View File

@@ -12,6 +12,7 @@ from timmy.semantic_memory import (
MemorySearcher,
MemoryChunk,
memory_search,
memory_read,
_get_embedding_model,
)
@@ -232,6 +233,22 @@ class TestMemorySearch:
assert isinstance(result, str)
class TestMemoryRead:
"""Test module-level memory_read function."""
def test_memory_read_returns_string(self):
result = memory_read()
assert isinstance(result, str)
def test_memory_read_with_query(self):
result = memory_read("some query")
assert isinstance(result, str)
def test_memory_read_none_top_k(self):
result = memory_read("test", top_k=None)
assert isinstance(result, str)
class TestMemoryChunk:
"""Test MemoryChunk dataclass."""