5769c1cd22
- 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
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
|