"""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