Files

1045 lines
34 KiB
Python
Raw Permalink Normal View History

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