Files
Software Orchestrator 5769c1cd22 Initial commit: a0_software_orchestrator v1.0
- Auto-Registration-Bug behoben (register_project/get_project_id/resolve_project Trennung)
- 25 Tests gruen (Pytest)
- block_compactor-Tool refactored (Option B: Soft-Check statt Hard-Block)
- 4 Restbaustellen gefixt
- DB-Schema: plugin_settings-Tabelle hinzugefuegt
- 3 Schattenprojekte aus DB geloescht
- Plan v3 + Refactor-Plan + Worklog dokumentiert
2026-06-16 22:13:06 +00:00

1045 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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