1045 lines
34 KiB
Python
1045 lines
34 KiB
Python
|
|
"""db_state_store – Einheitliche DB-API für Orchestrator-State.
|
|||
|
|
|
|||
|
|
Migration: ersetzt .a0/*.json/.md und .a0proj/handover/*.md durch
|
|||
|
|
project-scoped DB-Tabellen (orch_*) in patterns.db.
|
|||
|
|
|
|||
|
|
Verwendung:
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
|||
|
|
register_project, get_project_id, resolve_project,
|
|||
|
|
get_kv, set_kv, append_worklog, set_status, add_next_step,
|
|||
|
|
add_todo, set_block_compact, create_snapshot,
|
|||
|
|
append_briefing, add_score,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Neues Projekt anlegen (explizit, mit Plausiprüfung):
|
|||
|
|
pid = register_project("crm-system", git_url="https://...")
|
|||
|
|
set_kv(pid, "phase", "implementation")
|
|||
|
|
append_worklog(pid, phase="implementation", work_block="T012",
|
|||
|
|
agent="implementation_engineer", summary="Auth-Endpoint fertig")
|
|||
|
|
add_next_step(pid, "T013: Test schreiben")
|
|||
|
|
set_status(pid, "12/30 tasks done, Auth läuft")
|
|||
|
|
|
|||
|
|
# Existierendes Projekt nachschlagen (nur lesen):
|
|||
|
|
existing_pid = get_project_id("crm-system") # gibt None wenn unbekannt
|
|||
|
|
|
|||
|
|
# In create-on-missing-Aufrufern (z.B. Migration):
|
|||
|
|
pid = resolve_project("crm-system") # wirft LookupError wenn fehlt
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Any, Dict, List, Optional, Union
|
|||
|
|
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.library.db import PatternDB
|
|||
|
|
|
|||
|
|
DB_PATH = str(PatternDB.DEFAULT_DB_PATH)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Project-Name Validation (Pattern + Blacklist)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
|
|||
|
|
PROJECT_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
|
|||
|
|
FORBIDDEN_PROJECT_NAMES = frozenset({
|
|||
|
|
"a0", "a0-development", "workdir", "dev-projects",
|
|||
|
|
"usr", "tmp", "home", "root", "skills", "plugins",
|
|||
|
|
"opt", "lib", "etc", "var", "data", "venv",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
def _validate_project_name(name: str) -> str:
|
|||
|
|
"""Strippt, prüft Pattern + Blacklist. Wirft ValueError bei Verletzung."""
|
|||
|
|
if not name or not name.strip():
|
|||
|
|
raise ValueError("project name must be non-empty")
|
|||
|
|
name = name.strip()
|
|||
|
|
if name in FORBIDDEN_PROJECT_NAMES:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"project name '{name}' is reserved/forbidden "
|
|||
|
|
f"(likely a system directory, not a software project)"
|
|||
|
|
)
|
|||
|
|
if not PROJECT_NAME_PATTERN.match(name):
|
|||
|
|
raise ValueError(
|
|||
|
|
f"project name '{name}' does not match pattern "
|
|||
|
|
f"{PROJECT_NAME_PATTERN.pattern} (lowercase letters, digits, hyphens)"
|
|||
|
|
)
|
|||
|
|
return name
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Singleton-DB (selbe Instanz wie in library/db.py)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
import sqlite3
|
|||
|
|
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
|
|||
|
|
from functools import wraps
|
|||
|
|
|
|||
|
|
def _db_error_handler(func):
|
|||
|
|
@wraps(func)
|
|||
|
|
def wrapper(*args, **kwargs):
|
|||
|
|
try:
|
|||
|
|
return func(*args, **kwargs)
|
|||
|
|
except sqlite3.Error as e:
|
|||
|
|
raise PluginDBError(f"Database error in {func.__name__}: {e}") from e
|
|||
|
|
return wrapper
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def _db() -> PatternDB:
|
|||
|
|
"""Gibt die PatternDB-Singleton-Instanz zurück."""
|
|||
|
|
return PatternDB()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Project Resolution
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def resolve_project(name: str) -> int:
|
|||
|
|
"""Löst einen Projektnamen zur project_id auf.
|
|||
|
|
|
|||
|
|
WICHTIG: Legt KEIN neues Projekt mehr an. Bei unbekanntem Namen
|
|||
|
|
wird LookupError geworfen. Zum Anlegen explizit register_project()
|
|||
|
|
oder project_registry action=register verwenden.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
name: Projektname (muss Pattern + Blacklist matchen)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
project_id (int)
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
ValueError: wenn Name leer/ungültig (Pattern/Blacklist)
|
|||
|
|
LookupError: wenn Name nicht in DB registriert
|
|||
|
|
"""
|
|||
|
|
name = _validate_project_name(name)
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT id FROM projects WHERE name = ?", (name,)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
raise LookupError(
|
|||
|
|
f"project '{name}' is not registered. "
|
|||
|
|
f"Call register_project() or project_registry action=register first."
|
|||
|
|
)
|
|||
|
|
project_id = int(row[0])
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO project_state (project_id, status, phase, last_active_at)
|
|||
|
|
VALUES (?, 'active', 'intake', datetime('now'))
|
|||
|
|
ON CONFLICT(project_id) DO UPDATE SET last_active_at = datetime('now')
|
|||
|
|
""",
|
|||
|
|
(project_id,),
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return project_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def project_exists(name: str) -> bool:
|
|||
|
|
"""True wenn Projekt bereits registriert ist."""
|
|||
|
|
if not name:
|
|||
|
|
return False
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT 1 FROM projects WHERE name = ?", (name.strip(),)
|
|||
|
|
).fetchone()
|
|||
|
|
return row is not None
|
|||
|
|
|
|||
|
|
def get_project_id(name: str) -> Optional[int]:
|
|||
|
|
"""Return existing project_id without creating or mutating project state.
|
|||
|
|
|
|||
|
|
This is the preferred read-only lookup. For create-or-lookup semantics
|
|||
|
|
use resolve_project(); to create explicitly use register_project().
|
|||
|
|
|
|||
|
|
Returns None when the project name is not registered. Note: This function
|
|||
|
|
does NOT validate the name against pattern/blacklist; it returns None for
|
|||
|
|
any unknown or invalid name (read-only, no side effects).
|
|||
|
|
"""
|
|||
|
|
if not name or not name.strip():
|
|||
|
|
return None
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT id FROM projects WHERE name = ?", (name.strip(),)
|
|||
|
|
).fetchone()
|
|||
|
|
return int(row[0]) if row else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Time helpers
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def now_iso() -> str:
|
|||
|
|
"""ISO-8601 UTC timestamp."""
|
|||
|
|
return datetime.utcnow().isoformat() + "Z"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 1. orch_kv – generischer Key/Value Store (JSON)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_kv(project_id: int, key: str, default: Any = None) -> Any:
|
|||
|
|
"""Liest einen Key (auto-deserialisiert JSON).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
project_id: project_id (int)
|
|||
|
|
key: Schlüssel-Name (z.B. "phase", "orchestrator_mode", "task_graph")
|
|||
|
|
default: Rückgabewert falls Key fehlt
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Deserialisierter Wert oder default
|
|||
|
|
"""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT value_json FROM orch_kv WHERE project_id = ? AND key = ?",
|
|||
|
|
(project_id, key)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return default
|
|||
|
|
try:
|
|||
|
|
return json.loads(row[0])
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def set_kv(project_id: int, key: str, value: Any) -> None:
|
|||
|
|
"""Schreibt einen Key (auto-serialisiert zu JSON).
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
project_id: project_id (int)
|
|||
|
|
key: Schlüssel-Name
|
|||
|
|
value: beliebig JSON-serialisierbarer Wert (dict, list, str, int, …)
|
|||
|
|
"""
|
|||
|
|
db = _db()
|
|||
|
|
payload = json.dumps(value, ensure_ascii=False, default=str)
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_kv (project_id, key, value_json, updated_at)
|
|||
|
|
VALUES (?, ?, ?, datetime('now'))
|
|||
|
|
ON CONFLICT(project_id, key) DO UPDATE SET
|
|||
|
|
value_json = excluded.value_json,
|
|||
|
|
updated_at = datetime('now')
|
|||
|
|
""",
|
|||
|
|
(project_id, key, payload)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def delete_kv(project_id: int, key: str) -> bool:
|
|||
|
|
"""Löscht einen Key. True wenn etwas gelöscht wurde."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"DELETE FROM orch_kv WHERE project_id = ? AND key = ?",
|
|||
|
|
(project_id, key)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.rowcount > 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_kv_keys(project_id: int) -> List[str]:
|
|||
|
|
"""Listet alle Keys eines Projekts."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"SELECT key FROM orch_kv WHERE project_id = ? ORDER BY key",
|
|||
|
|
(project_id,)
|
|||
|
|
).fetchall()
|
|||
|
|
return [r[0] for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 2. orch_worklog – append-only Worklog
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def append_worklog(
|
|||
|
|
project_id: int,
|
|||
|
|
phase: Optional[str] = None,
|
|||
|
|
work_block: Optional[str] = None,
|
|||
|
|
agent: Optional[str] = None,
|
|||
|
|
summary: str = "",
|
|||
|
|
details: Optional[str] = None,
|
|||
|
|
) -> int:
|
|||
|
|
"""Hängt einen Worklog-Eintrag an. Gibt die neue ID zurück."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_worklog
|
|||
|
|
(project_id, phase, work_block, agent, summary, details)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, phase, work_block, agent, summary, details)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_worklog(
|
|||
|
|
project_id: int, limit: int = 100, offset: int = 0
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Liest die letzten N Worklog-Einträge (neueste zuerst)."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, phase, work_block, agent, summary, details, created_at
|
|||
|
|
FROM orch_worklog
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
ORDER BY created_at DESC, id DESC
|
|||
|
|
LIMIT ? OFFSET ?
|
|||
|
|
""",
|
|||
|
|
(project_id, limit, offset)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def worklog_as_markdown(project_id: int, limit: int = 100) -> str:
|
|||
|
|
"""Rendert den Worklog als Markdown (für Repo-Snapshots)."""
|
|||
|
|
entries = list_worklog(project_id, limit=limit)
|
|||
|
|
if not entries:
|
|||
|
|
return "_no worklog entries yet_\n"
|
|||
|
|
lines = ["# Worklog", ""]
|
|||
|
|
lines.append("| Timestamp | Phase | Block | Agent | Summary |")
|
|||
|
|
lines.append("|---|---|---|---|---|")
|
|||
|
|
for e in entries:
|
|||
|
|
ts = e.get("created_at", "")
|
|||
|
|
ph = e.get("phase") or "-"
|
|||
|
|
wb = e.get("work_block") or "-"
|
|||
|
|
ag = e.get("agent") or "-"
|
|||
|
|
sm = (e.get("summary") or "").replace("|", "\\|")
|
|||
|
|
lines.append(f"| {ts} | {ph} | {wb} | {ag} | {sm} |")
|
|||
|
|
return "\n".join(lines) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 3. orch_todos
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def add_todo(
|
|||
|
|
project_id: int, content: str, priority: int = 0, notes: Optional[str] = None
|
|||
|
|
) -> int:
|
|||
|
|
"""Fügt ein neues Todo hinzu. Gibt die ID zurück."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_todos (project_id, content, priority, notes)
|
|||
|
|
VALUES (?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, content, priority, notes)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def update_todo_status(todo_id: int, status: str) -> bool:
|
|||
|
|
"""Setzt den Status eines Todos. Setzt started_at/completed_at automatisch."""
|
|||
|
|
if status not in ("open", "in_progress", "done", "blocked", "cancelled"):
|
|||
|
|
raise ValueError(f"invalid todo status: {status!r}")
|
|||
|
|
db = _db()
|
|||
|
|
now_clause = ", started_at = COALESCE(started_at, datetime('now'))" if status == "in_progress" else ""
|
|||
|
|
done_clause = ", completed_at = datetime('now')" if status == "done" else ""
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
f"""
|
|||
|
|
UPDATE orch_todos
|
|||
|
|
SET status = ?{now_clause}{done_clause}
|
|||
|
|
WHERE id = ?
|
|||
|
|
""",
|
|||
|
|
(status, todo_id)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.rowcount > 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_todos(
|
|||
|
|
project_id: int, status: Optional[str] = None, limit: int = 100
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet Todos, optional gefiltert nach Status."""
|
|||
|
|
db = _db()
|
|||
|
|
if status:
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, content, status, priority, created_at, started_at,
|
|||
|
|
completed_at, notes
|
|||
|
|
FROM orch_todos
|
|||
|
|
WHERE project_id = ? AND status = ?
|
|||
|
|
ORDER BY priority DESC, created_at ASC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, status, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
else:
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, content, status, priority, created_at, started_at,
|
|||
|
|
completed_at, notes
|
|||
|
|
FROM orch_todos
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
ORDER BY priority DESC, created_at ASC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 4. orch_status – Current Status (eine Zeile pro Projekt)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def set_status(project_id: int, content: str) -> None:
|
|||
|
|
"""Setzt den aktuellen 1-Zeilen-Status (überschreibt)."""
|
|||
|
|
db = _db()
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_status (project_id, content, updated_at)
|
|||
|
|
VALUES (?, ?, datetime('now'))
|
|||
|
|
ON CONFLICT(project_id) DO UPDATE SET
|
|||
|
|
content = excluded.content,
|
|||
|
|
updated_at = datetime('now')
|
|||
|
|
""",
|
|||
|
|
(project_id, content)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_status(project_id: int) -> Optional[str]:
|
|||
|
|
"""Liest den aktuellen Status. None falls nicht gesetzt."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"SELECT content, updated_at FROM orch_status WHERE project_id = ?",
|
|||
|
|
(project_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
return row[0]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 5. orch_next_steps
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def add_next_step(
|
|||
|
|
project_id: int, content: str, order_idx: int = 0
|
|||
|
|
) -> int:
|
|||
|
|
"""Fügt einen neuen Next-Step hinzu. Gibt die ID zurück."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_next_steps (project_id, content, order_idx)
|
|||
|
|
VALUES (?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, content, order_idx)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def complete_next_step(step_id: int) -> bool:
|
|||
|
|
"""Markiert einen Next-Step als erledigt."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
UPDATE orch_next_steps
|
|||
|
|
SET status = 'done', completed_at = datetime('now')
|
|||
|
|
WHERE id = ?
|
|||
|
|
""",
|
|||
|
|
(step_id,)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.rowcount > 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_next_steps(
|
|||
|
|
project_id: int, status: Optional[str] = None, limit: int = 50
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet Next-Steps, optional gefiltert nach Status (default: pending)."""
|
|||
|
|
db = _db()
|
|||
|
|
if status is None:
|
|||
|
|
status = "pending"
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, content, status, order_idx, created_at, completed_at
|
|||
|
|
FROM orch_next_steps
|
|||
|
|
WHERE project_id = ? AND status = ?
|
|||
|
|
ORDER BY order_idx ASC, created_at ASC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, status, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 6. orch_errors
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def report_error(
|
|||
|
|
project_id: int,
|
|||
|
|
title: str,
|
|||
|
|
severity: str = "medium",
|
|||
|
|
context: Optional[str] = None,
|
|||
|
|
error_id: Optional[str] = None,
|
|||
|
|
) -> int:
|
|||
|
|
"""Erfasst einen neuen Fehler. Gibt die DB-ID zurück."""
|
|||
|
|
if severity not in ("low", "medium", "high", "critical"):
|
|||
|
|
raise ValueError(f"invalid severity: {severity!r}")
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_errors
|
|||
|
|
(project_id, error_id, title, severity, context)
|
|||
|
|
VALUES (?, ?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, error_id, title, severity, context)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def resolve_error(error_id: int, resolution: str) -> bool:
|
|||
|
|
"""Markiert einen Fehler als gelöst."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
UPDATE orch_errors
|
|||
|
|
SET status = 'resolved', resolution = ?, resolved_at = datetime('now')
|
|||
|
|
WHERE id = ?
|
|||
|
|
""",
|
|||
|
|
(resolution, error_id)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.rowcount > 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_errors(
|
|||
|
|
project_id: int, status: Optional[str] = None, limit: int = 50
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet Fehler eines Projekts, default: nur offene."""
|
|||
|
|
db = _db()
|
|||
|
|
if status is None:
|
|||
|
|
status = "open"
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, error_id, title, severity, status, context, resolution,
|
|||
|
|
discovered, resolved_at
|
|||
|
|
FROM orch_errors
|
|||
|
|
WHERE project_id = ? AND status = ?
|
|||
|
|
ORDER BY
|
|||
|
|
CASE severity
|
|||
|
|
WHEN 'critical' THEN 1
|
|||
|
|
WHEN 'high' THEN 2
|
|||
|
|
WHEN 'medium' THEN 3
|
|||
|
|
WHEN 'low' THEN 4
|
|||
|
|
END,
|
|||
|
|
discovered DESC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, status, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 7. orch_block_compact (ersetzt .a0/resume.md + .a0/conversation_summary.md)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def set_block_compact(
|
|||
|
|
project_id: int,
|
|||
|
|
block_id: str,
|
|||
|
|
next_block: str,
|
|||
|
|
block_summary: str,
|
|||
|
|
decisions: Optional[List[str]] = None,
|
|||
|
|
key_findings: Optional[List[str]] = None,
|
|||
|
|
open_questions: Optional[List[str]] = None,
|
|||
|
|
resume_md: Optional[str] = None,
|
|||
|
|
conversation_summary_md: Optional[str] = None,
|
|||
|
|
context_ratio: Optional[float] = None,
|
|||
|
|
) -> None:
|
|||
|
|
"""Schreibt/überschreibt den Block-Compact-Marker für ein Projekt."""
|
|||
|
|
db = _db()
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_block_compact
|
|||
|
|
(project_id, last_block_id, last_compact_at, next_block,
|
|||
|
|
block_summary, decisions_json, key_findings_json,
|
|||
|
|
open_questions_json, resume_md, conversation_summary_md,
|
|||
|
|
context_ratio)
|
|||
|
|
VALUES (?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
ON CONFLICT(project_id) DO UPDATE SET
|
|||
|
|
last_block_id = excluded.last_block_id,
|
|||
|
|
last_compact_at = excluded.last_compact_at,
|
|||
|
|
next_block = excluded.next_block,
|
|||
|
|
block_summary = excluded.block_summary,
|
|||
|
|
decisions_json = excluded.decisions_json,
|
|||
|
|
key_findings_json = excluded.key_findings_json,
|
|||
|
|
open_questions_json = excluded.open_questions_json,
|
|||
|
|
resume_md = excluded.resume_md,
|
|||
|
|
conversation_summary_md = excluded.conversation_summary_md,
|
|||
|
|
context_ratio = excluded.context_ratio
|
|||
|
|
""",
|
|||
|
|
(
|
|||
|
|
project_id, block_id, next_block, block_summary,
|
|||
|
|
json.dumps(decisions or []),
|
|||
|
|
json.dumps(key_findings or []),
|
|||
|
|
json.dumps(open_questions or []),
|
|||
|
|
resume_md, conversation_summary_md, context_ratio,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_block_compact(project_id: int) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""Liest den Block-Compact-Marker. None falls keiner existiert."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT last_block_id, last_compact_at, next_block, block_summary,
|
|||
|
|
decisions_json, key_findings_json, open_questions_json,
|
|||
|
|
resume_md, conversation_summary_md, context_ratio
|
|||
|
|
FROM orch_block_compact
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
""",
|
|||
|
|
(project_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
d = dict(row)
|
|||
|
|
# JSON-Felder deserialisieren
|
|||
|
|
for jk in ("decisions_json", "key_findings_json", "open_questions_json"):
|
|||
|
|
clean_key = jk.removesuffix("_json")
|
|||
|
|
if d.get(jk):
|
|||
|
|
try:
|
|||
|
|
d[clean_key] = json.loads(d[jk])
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
d[clean_key] = []
|
|||
|
|
else:
|
|||
|
|
d[clean_key] = []
|
|||
|
|
del d[jk]
|
|||
|
|
return d
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 11. Project Registry (projects, project_state, project_snapshots)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_projects() -> List[Dict[str, Any]]:
|
|||
|
|
"""List all projects with their current state."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute("""
|
|||
|
|
SELECT p.id, p.name, p.git_url, p.description,
|
|||
|
|
ps.status, ps.phase, ps.plan_mode,
|
|||
|
|
ps.total_tasks, ps.completed_tasks, ps.open_errors,
|
|||
|
|
ps.last_active_at, ps.completed_at
|
|||
|
|
FROM projects p
|
|||
|
|
JOIN project_state ps ON ps.project_id = p.id
|
|||
|
|
ORDER BY p.name
|
|||
|
|
""").fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_project_detail(name: str) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""Get full detail of a project including state."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute("""
|
|||
|
|
SELECT p.*, ps.status, ps.phase, ps.plan_mode,
|
|||
|
|
ps.total_tasks, ps.completed_tasks, ps.open_errors,
|
|||
|
|
ps.last_active_at, ps.completed_at, ps.updated_at
|
|||
|
|
FROM projects p
|
|||
|
|
JOIN project_state ps ON ps.project_id = p.id
|
|||
|
|
WHERE p.name = ?
|
|||
|
|
""", (name,)).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
return dict(row)
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def register_project(name: str, git_url: str = "", description: str = "",
|
|||
|
|
tech_stack: str = "{}", status: str = "active",
|
|||
|
|
phase: str = "intake") -> int:
|
|||
|
|
"""Register or update a project with initial/current state. Returns project_id.
|
|||
|
|
|
|||
|
|
Validiert den Namen gegen Pattern + Blacklist (siehe _validate_project_name).
|
|||
|
|
Wirft ValueError bei ungültigem Namen, ohne INSERT durchzuführen.
|
|||
|
|
"""
|
|||
|
|
name = _validate_project_name(name)
|
|||
|
|
db = _db()
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO projects (name, git_url, description, tech_stack)
|
|||
|
|
VALUES (?, ?, ?, ?)
|
|||
|
|
ON CONFLICT(name) DO UPDATE SET
|
|||
|
|
git_url = COALESCE(NULLIF(excluded.git_url, ''), projects.git_url),
|
|||
|
|
description = COALESCE(NULLIF(excluded.description, ''), projects.description),
|
|||
|
|
tech_stack = COALESCE(NULLIF(excluded.tech_stack, ''), projects.tech_stack)
|
|||
|
|
""",
|
|||
|
|
(name, git_url, description, tech_stack)
|
|||
|
|
)
|
|||
|
|
row = db.conn.execute("SELECT id FROM projects WHERE name = ?", (name,)).fetchone()
|
|||
|
|
project_id = int(row[0])
|
|||
|
|
db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO project_state (project_id, status, phase, last_active_at)
|
|||
|
|
VALUES (?, ?, ?, datetime('now'))
|
|||
|
|
ON CONFLICT(project_id) DO UPDATE SET
|
|||
|
|
status = excluded.status,
|
|||
|
|
phase = excluded.phase,
|
|||
|
|
last_active_at = datetime('now'),
|
|||
|
|
updated_at = datetime('now')
|
|||
|
|
""",
|
|||
|
|
(project_id, status, phase)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
set_kv(project_id, "phase", phase)
|
|||
|
|
return project_id
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def update_project_state(name: str, **fields) -> None:
|
|||
|
|
"""Update project_state fields for a project by name."""
|
|||
|
|
db = _db()
|
|||
|
|
updates = []
|
|||
|
|
params = []
|
|||
|
|
for field in ["status", "phase", "plan_mode"]:
|
|||
|
|
val = fields.get(field)
|
|||
|
|
if val:
|
|||
|
|
updates.append(f"{field} = ?")
|
|||
|
|
params.append(val)
|
|||
|
|
for field in ["total_tasks", "completed_tasks", "open_errors"]:
|
|||
|
|
val = fields.get(field)
|
|||
|
|
if val is not None:
|
|||
|
|
updates.append(f"{field} = ?")
|
|||
|
|
params.append(int(val))
|
|||
|
|
if "last_active_at" in fields:
|
|||
|
|
updates.append("last_active_at = ?")
|
|||
|
|
params.append(fields["last_active_at"])
|
|||
|
|
if "completed_at" in fields:
|
|||
|
|
updates.append("completed_at = ?")
|
|||
|
|
params.append(fields["completed_at"])
|
|||
|
|
if not updates:
|
|||
|
|
return
|
|||
|
|
updates.append("updated_at = datetime('now')")
|
|||
|
|
params.append(name)
|
|||
|
|
sql = f"UPDATE project_state SET {', '.join(updates)} WHERE project_id = (SELECT id FROM projects WHERE name = ?)"
|
|||
|
|
db.conn.execute(sql, params)
|
|||
|
|
db.conn.commit()
|
|||
|
|
# Keep high-level state mirrored into orch_kv for extensions/resume tools.
|
|||
|
|
row = db.conn.execute("SELECT id FROM projects WHERE name = ?", (name,)).fetchone()
|
|||
|
|
if row:
|
|||
|
|
project_id = int(row[0])
|
|||
|
|
for mirror_key in ("phase", "plan_mode", "status"):
|
|||
|
|
if fields.get(mirror_key):
|
|||
|
|
set_kv(project_id, mirror_key, fields[mirror_key])
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def create_project_snapshot(name: str, trigger: str = "manual",
|
|||
|
|
commit_hash: str = "") -> int:
|
|||
|
|
"""Create a snapshot of the current project state. Returns snapshot_id."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute("""
|
|||
|
|
SELECT p.id, p.name, p.git_url, ps.*
|
|||
|
|
FROM projects p
|
|||
|
|
JOIN project_state ps ON ps.project_id = p.id
|
|||
|
|
WHERE p.name = ?
|
|||
|
|
""", (name,)).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
raise ValueError(f"Project '{name}' not found")
|
|||
|
|
state_dict = dict(row)
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"INSERT INTO project_snapshots (project_id, state_json, trigger, commit_hash) VALUES (?, ?, ?, ?)",
|
|||
|
|
(state_dict["id"], json.dumps(state_dict, ensure_ascii=False), trigger, commit_hash)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_project_snapshots(name: str) -> List[Dict[str, Any]]:
|
|||
|
|
"""List snapshots for a project, newest first."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute("""
|
|||
|
|
SELECT s.id, s.snapshot_at, s.trigger, s.commit_hash, s.state_json
|
|||
|
|
FROM project_snapshots s
|
|||
|
|
JOIN projects p ON s.project_id = p.id
|
|||
|
|
WHERE p.name = ?
|
|||
|
|
ORDER BY s.snapshot_at DESC
|
|||
|
|
""", (name,)).fetchall()
|
|||
|
|
result = []
|
|||
|
|
for r in rows:
|
|||
|
|
rec = dict(r)
|
|||
|
|
try:
|
|||
|
|
rec["state"] = json.loads(rec.pop("state_json"))
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
pass
|
|||
|
|
result.append(rec)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def remove_project(name: str) -> None:
|
|||
|
|
"""Remove a project and all related state/snapshots."""
|
|||
|
|
db = _db()
|
|||
|
|
db.conn.execute("DELETE FROM project_state WHERE project_id = (SELECT id FROM projects WHERE name = ?)", (name,))
|
|||
|
|
db.conn.execute("DELETE FROM project_snapshots WHERE project_id = (SELECT id FROM projects WHERE name = ?)", (name,))
|
|||
|
|
db.conn.execute("DELETE FROM projects WHERE name = ?", (name,))
|
|||
|
|
db.conn.commit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 8. orch_snapshots – Session-Snapshots für Recovery
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def create_snapshot(
|
|||
|
|
project_id: int,
|
|||
|
|
trigger: str = "manual",
|
|||
|
|
commit_hash: Optional[str] = None,
|
|||
|
|
state: Optional[Dict[str, Any]] = None,
|
|||
|
|
) -> int:
|
|||
|
|
"""Erstellt einen vollständigen Snapshot des aktuellen Projekt-States.
|
|||
|
|
|
|||
|
|
Wenn `state` None ist, wird der aktuelle State automatisch gesammelt:
|
|||
|
|
- alle orch_kv Keys
|
|||
|
|
- aktueller Status
|
|||
|
|
- pending Next-Steps
|
|||
|
|
- offene Todos
|
|||
|
|
- offene Errors
|
|||
|
|
- letzter Block-Compact-Marker
|
|||
|
|
"""
|
|||
|
|
if state is None:
|
|||
|
|
state = collect_full_state(project_id)
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_snapshots (project_id, trigger, commit_hash, state_json)
|
|||
|
|
VALUES (?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, trigger, commit_hash, json.dumps(state, ensure_ascii=False, default=str))
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_snapshots(project_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet die letzten Snapshots (neueste zuerst)."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, snapshot_at, trigger, commit_hash
|
|||
|
|
FROM orch_snapshots
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
ORDER BY snapshot_at DESC, id DESC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_snapshot(snapshot_id: int) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""Lädt einen kompletten Snapshot inkl. state_json."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, project_id, snapshot_at, trigger, commit_hash, state_json
|
|||
|
|
FROM orch_snapshots
|
|||
|
|
WHERE id = ?
|
|||
|
|
""",
|
|||
|
|
(snapshot_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
d = dict(row)
|
|||
|
|
try:
|
|||
|
|
d["state"] = json.loads(d.pop("state_json"))
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
d["state"] = {}
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def collect_full_state(project_id: int) -> Dict[str, Any]:
|
|||
|
|
"""Sammelt den gesamten aktuellen State eines Projekts in einem Dict.
|
|||
|
|
|
|||
|
|
Wird vom Snapshot-System und für Repo-Commits verwendet.
|
|||
|
|
"""
|
|||
|
|
db = _db()
|
|||
|
|
project_row = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT p.*, ps.status, ps.phase, ps.plan_mode, ps.total_tasks,
|
|||
|
|
ps.completed_tasks, ps.open_errors, ps.last_active_at, ps.completed_at, ps.updated_at
|
|||
|
|
FROM projects p
|
|||
|
|
LEFT JOIN project_state ps ON ps.project_id = p.id
|
|||
|
|
WHERE p.id = ?
|
|||
|
|
""",
|
|||
|
|
(project_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
return {
|
|||
|
|
"project": dict(project_row) if project_row else None,
|
|||
|
|
"kv": {k: get_kv(project_id, k) for k in list_kv_keys(project_id)},
|
|||
|
|
"status": get_status(project_id),
|
|||
|
|
"next_steps": list_next_steps(project_id, status="pending"),
|
|||
|
|
"open_todos": list_todos(project_id, status="open"),
|
|||
|
|
"open_errors": list_errors(project_id, status="open"),
|
|||
|
|
"block_compact": get_block_compact(project_id),
|
|||
|
|
"snapshot_at": now_iso(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 9. orch_scorecard
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def add_score(
|
|||
|
|
project_id: int, category: str, score: int, max_score: int = 100
|
|||
|
|
) -> int:
|
|||
|
|
"""Erfasst einen Score-Eintrag. Gibt die ID zurück."""
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_scorecard (project_id, category, score, max_score)
|
|||
|
|
VALUES (?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(project_id, category, score, max_score)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_scores(project_id: int, limit: int = 50) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet die letzten Score-Einträge."""
|
|||
|
|
db = _db()
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, category, score, max_score, created_at
|
|||
|
|
FROM orch_scorecard
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
ORDER BY created_at DESC, id DESC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 10. orch_briefings (ersetzt .a0proj/handover/*.md)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def append_briefing(
|
|||
|
|
project_id: int,
|
|||
|
|
specialist: str,
|
|||
|
|
topic: str,
|
|||
|
|
section_md: str,
|
|||
|
|
summary: Optional[str] = None,
|
|||
|
|
decisions: Optional[List[str]] = None,
|
|||
|
|
artifacts: Optional[List[str]] = None,
|
|||
|
|
recent_turns: Optional[List[str]] = None,
|
|||
|
|
return_condition: Optional[str] = None,
|
|||
|
|
handover_reason: Optional[str] = None,
|
|||
|
|
) -> int:
|
|||
|
|
"""Hängt ein neues Briefing-Section an. Gibt die ID zurück."""
|
|||
|
|
if not section_md or not section_md.strip():
|
|||
|
|
raise ValueError("refusing to append empty briefing content")
|
|||
|
|
db = _db()
|
|||
|
|
cur = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO orch_briefings
|
|||
|
|
(project_id, specialist, topic, section_md, summary,
|
|||
|
|
decisions_json, artifacts_json, recent_turns_json,
|
|||
|
|
return_condition, handover_reason)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(
|
|||
|
|
project_id, specialist, topic, section_md, summary,
|
|||
|
|
json.dumps(decisions or []),
|
|||
|
|
json.dumps(artifacts or []),
|
|||
|
|
json.dumps(recent_turns or []),
|
|||
|
|
return_condition, handover_reason,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
db.conn.commit()
|
|||
|
|
return cur.lastrowid
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def list_briefings(
|
|||
|
|
project_id: int, specialist: Optional[str] = None, limit: int = 20
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""Listet die letzten Briefings, optional gefiltert nach Specialist."""
|
|||
|
|
db = _db()
|
|||
|
|
if specialist:
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, specialist, topic, summary, return_condition,
|
|||
|
|
handover_reason, created_at
|
|||
|
|
FROM orch_briefings
|
|||
|
|
WHERE project_id = ? AND specialist = ?
|
|||
|
|
ORDER BY created_at DESC, id DESC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, specialist, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
else:
|
|||
|
|
rows = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, specialist, topic, summary, return_condition,
|
|||
|
|
handover_reason, created_at
|
|||
|
|
FROM orch_briefings
|
|||
|
|
WHERE project_id = ?
|
|||
|
|
ORDER BY created_at DESC, id DESC
|
|||
|
|
LIMIT ?
|
|||
|
|
""",
|
|||
|
|
(project_id, limit)
|
|||
|
|
).fetchall()
|
|||
|
|
return [dict(r) for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@_db_error_handler
|
|||
|
|
def get_briefing(briefing_id: int) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""Lädt ein vollständiges Briefing inkl. section_md."""
|
|||
|
|
db = _db()
|
|||
|
|
row = db.conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, project_id, specialist, topic, section_md, summary,
|
|||
|
|
decisions_json, artifacts_json, recent_turns_json,
|
|||
|
|
return_condition, handover_reason, created_at
|
|||
|
|
FROM orch_briefings
|
|||
|
|
WHERE id = ?
|
|||
|
|
""",
|
|||
|
|
(briefing_id,)
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
return None
|
|||
|
|
d = dict(row)
|
|||
|
|
for jk in ("decisions_json", "artifacts_json", "recent_turns_json"):
|
|||
|
|
clean_key = jk.removesuffix("_json")
|
|||
|
|
if d.get(jk):
|
|||
|
|
try:
|
|||
|
|
d[clean_key] = json.loads(d[jk])
|
|||
|
|
except (json.JSONDecodeError, TypeError):
|
|||
|
|
d[clean_key] = []
|
|||
|
|
else:
|
|||
|
|
d[clean_key] = []
|
|||
|
|
del d[jk]
|
|||
|
|
return d
|