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
458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""Migration script: move state from .a0/ files to patterns.db.
|
||
|
||
Liest alle bestehenden .a0/ und .a0proj/ State-Files (in einem
|
||
Projekt-Wurzelverzeichnis) und schreibt sie in die neue
|
||
DB-Struktur (orch_* Tabellen in patterns.db).
|
||
|
||
Anschließend werden die Quelldateien nach .a0/.backup-<ts>/ verschoben
|
||
(NICHT gelöscht), damit man die Migration verifizieren oder rückgängig
|
||
machen kann.
|
||
|
||
Usage:
|
||
python3 utils/migrate_a0_to_db.py <project_root> [--dry-run] [--skip-backup]
|
||
|
||
Beispiel:
|
||
cd /pfad/zu/mein-projekt
|
||
python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py .
|
||
|
||
ODER für ein registriertes Projekt:
|
||
python3 /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py crm-system
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
# Run this maintenance script from the Agent Zero framework runtime with /a0 available.
|
||
# Example: cd /a0 && /opt/venv-a0/bin/python /a0/usr/plugins/a0_software_orchestrator/utils/migrate_a0_to_db.py <project_root>
|
||
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||
register_project, set_kv, append_worklog, set_status,
|
||
add_todo, add_next_step, set_block_compact,
|
||
create_snapshot, add_score, append_briefing,
|
||
now_iso,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers – Datei-Reader
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _read_json(path: str) -> Optional[dict]:
|
||
if not os.path.exists(path):
|
||
return None
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||
print(f" WARN: {path} not readable: {e}")
|
||
return None
|
||
|
||
|
||
def _read_text(path: str) -> Optional[str]:
|
||
if not os.path.exists(path):
|
||
return None
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
except (OSError, UnicodeDecodeError) as e:
|
||
print(f" WARN: {path} not readable: {e}")
|
||
return None
|
||
|
||
|
||
def _parse_worklog_md(text: str) -> List[Dict[str, str]]:
|
||
"""Parst worklog.md in Einträge.
|
||
|
||
Format (etabliert):
|
||
## 2026-05-10T... | phase=implementation | block=T012 | agent=foo
|
||
- summary text
|
||
- finding ...
|
||
"""
|
||
entries = []
|
||
if not text:
|
||
return entries
|
||
# Sehr tolerant: pro Header-Zeile ein Eintrag
|
||
blocks = re.split(r"\n(?=##\s)", text)
|
||
for block in blocks:
|
||
m = re.match(
|
||
r"^##\s+(\S+)\s*\|\s*phase=([^|]+)\s*\|\s*block=([^|]+)\s*\|\s*agent=(\S+)",
|
||
block.strip(),
|
||
)
|
||
if not m:
|
||
# Fallback: nur Datum, Rest default
|
||
m2 = re.match(r"^##\s+(\S+)", block.strip())
|
||
if not m2:
|
||
continue
|
||
ts, phase, blk, agent = m2.group(1), None, None, None
|
||
else:
|
||
ts, phase, blk, agent = m.groups()
|
||
# Rest des Blocks ist Summary (alles nach erster Zeile)
|
||
rest = block.split("\n", 1)[1] if "\n" in block else ""
|
||
summary = rest.strip()[:500]
|
||
entries.append({
|
||
"created_at": ts,
|
||
"phase": (phase or "").strip() or None,
|
||
"work_block": (blk or "").strip() or None,
|
||
"agent": (agent or "").strip() or None,
|
||
"summary": summary,
|
||
})
|
||
return entries
|
||
|
||
|
||
def _parse_todo_md(text: str) -> List[Dict[str, str]]:
|
||
"""Parst todo.md in einzelne Todos.
|
||
|
||
Format: '- [ ] foo' / '- [x] foo' / '- foo'
|
||
"""
|
||
todos = []
|
||
if not text:
|
||
return todos
|
||
for line in text.splitlines():
|
||
m = re.match(r"^\s*-\s*(?:\[(?P<done>[ xX])\])?\s*(?P<content>.+)$", line)
|
||
if not m:
|
||
continue
|
||
done = m.group("done") in ("x", "X")
|
||
todos.append({
|
||
"content": m.group("content").strip(),
|
||
"status": "done" if done else "open",
|
||
})
|
||
return todos
|
||
|
||
|
||
def _parse_next_steps_md(text: str) -> List[str]:
|
||
"""Parst next_steps.md in Step-Liste (alle pending)."""
|
||
steps = []
|
||
if not text:
|
||
return steps
|
||
for line in text.splitlines():
|
||
m = re.match(r"^\s*-\s*(?:\[\s*\]\s*)?(?P<content>.+)$", line)
|
||
if not m:
|
||
continue
|
||
steps.append(m.group("content").strip())
|
||
return steps
|
||
|
||
|
||
def _parse_known_errors_md(text: str) -> List[Dict[str, str]]:
|
||
"""Parst known_errors.md in Errors.
|
||
|
||
Format:
|
||
- [high] TypeError in user.py: ...
|
||
"""
|
||
errors = []
|
||
if not text:
|
||
return errors
|
||
for line in text.splitlines():
|
||
m = re.match(
|
||
r"^\s*-\s*(?:\[(?P<sev>high|medium|low|critical|info)\])?\s*"
|
||
r"(?P<content>.+)$",
|
||
line, re.IGNORECASE,
|
||
)
|
||
if not m:
|
||
continue
|
||
errors.append({
|
||
"severity": (m.group("sev") or "medium").lower(),
|
||
"message": m.group("content").strip(),
|
||
"status": "open",
|
||
})
|
||
return errors
|
||
|
||
|
||
def _parse_briefing_md(text: str) -> Dict[str, Any]:
|
||
"""Parst ein Briefing-MD in ein Briefing-Dict (für orch_briefings)."""
|
||
# Best-effort: nur die Section als ganzes
|
||
return {
|
||
"section_md": text,
|
||
"summary": text.split("\n")[0][:200] if text else "",
|
||
"decisions": re.findall(r"^- (?:decision|Decision):\s*(.+)$", text, re.M),
|
||
"artifacts": re.findall(r"^- (?:artifact|Artifact):\s*(.+)$", text, re.M),
|
||
"recent_turns": re.findall(r"^> (?:User|Agent):\s*(.+)$", text, re.M),
|
||
"return_condition": "user_request",
|
||
"handover_reason": "migrated from .a0proj/handover/",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Migration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def migrate_a0_to_db(
|
||
project_name: str,
|
||
project_root: str,
|
||
dry_run: bool = False,
|
||
skip_backup: bool = False,
|
||
) -> Dict[str, Any]:
|
||
"""Migriert .a0/ und .a0proj/ State-Files in die DB.
|
||
|
||
Returns dict mit Counts (was migriert wurde).
|
||
"""
|
||
a0_dir = os.path.join(project_root, ".a0")
|
||
a0proj_dir = os.path.join(project_root, ".a0proj")
|
||
handover_dir = os.path.join(a0proj_dir, "handover")
|
||
|
||
counts = {
|
||
"worklog": 0, "todo": 0, "next_steps": 0, "errors": 0,
|
||
"kv_keys": 0, "scorecard": 0, "briefings": 0,
|
||
"block_compact": 0, "snapshots": 0,
|
||
"files_moved": 0, "files_skipped": 0,
|
||
}
|
||
|
||
if not os.path.isdir(a0_dir) and not os.path.isdir(a0proj_dir):
|
||
print(f" No .a0/ or .a0proj/ directory found in {project_root}")
|
||
return counts
|
||
|
||
print(f" Migrating project {project_name!r} from {project_root}")
|
||
|
||
# Projekt registrieren (oder existierendes behalten) – mit Pattern+Blacklist-Plausi.
|
||
# Bug 1/2 Fix: explizit register_project() statt auto-registrierendem resolve_project().
|
||
try:
|
||
pid = register_project(project_name, git_url="")
|
||
except ValueError as e:
|
||
print(f"ERROR: invalid project name {project_name!r}: {e}")
|
||
print(" Bitte --project=<gültiger Name> verwenden (Pattern: ^[a-z][a-z0-9-]{1,40}$)")
|
||
sys.exit(1)
|
||
print(f" Project id: {pid}")
|
||
|
||
# 1. KV-Dateien: project_state.json, orchestrator_mode.json, tool_capabilities.json, …
|
||
for json_file in (
|
||
"project_state.json",
|
||
"orchestrator_mode.json",
|
||
"tool_capabilities.json",
|
||
"tool_registry.json",
|
||
):
|
||
path = os.path.join(a0_dir, json_file)
|
||
data = _read_json(path)
|
||
if not data:
|
||
counts["files_skipped"] += 1
|
||
continue
|
||
key = json_file.replace(".json", "")
|
||
if dry_run:
|
||
print(f" [DRY] would set_kv({key!r}, …)")
|
||
else:
|
||
set_kv(pid, key, data)
|
||
counts["kv_keys"] += 1
|
||
|
||
# 2. Markdown-Dateien
|
||
# worklog.md
|
||
worklog_entries = _parse_worklog_md(_read_text(os.path.join(a0_dir, "worklog.md")) or "")
|
||
for e in worklog_entries:
|
||
if dry_run:
|
||
print(f" [DRY] would append_worklog({e.get('work_block')})")
|
||
else:
|
||
append_worklog(
|
||
pid,
|
||
phase=e.get("phase"),
|
||
work_block=e.get("work_block"),
|
||
agent=e.get("agent"),
|
||
summary=e.get("summary"),
|
||
)
|
||
counts["worklog"] += 1
|
||
|
||
# todo.md
|
||
todos = _parse_todo_md(_read_text(os.path.join(a0_dir, "todo.md")) or "")
|
||
for t in todos:
|
||
if dry_run:
|
||
print(f" [DRY] would add_todo({t['content']!r}, status={t['status']})")
|
||
else:
|
||
tid = add_todo(pid, t["content"], priority=5)
|
||
if t["status"] == "done":
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import update_todo_status
|
||
update_todo_status(tid, "done")
|
||
counts["todo"] += 1
|
||
|
||
# current_status.md
|
||
status_text = _read_text(os.path.join(a0_dir, "current_status.md"))
|
||
if status_text:
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import set_status
|
||
if dry_run:
|
||
print(f" [DRY] would set_status(…)")
|
||
else:
|
||
set_status(pid, status_text.strip()[:1000])
|
||
|
||
# next_steps.md
|
||
next_steps = _parse_next_steps_md(_read_text(os.path.join(a0_dir, "next_steps.md")) or "")
|
||
for s in next_steps:
|
||
if dry_run:
|
||
print(f" [DRY] would add_next_step({s!r})")
|
||
else:
|
||
add_next_step(pid, s)
|
||
counts["next_steps"] += 1
|
||
|
||
# known_errors.md
|
||
errors = _parse_known_errors_md(_read_text(os.path.join(a0_dir, "known_errors.md")) or "")
|
||
for e in errors:
|
||
if dry_run:
|
||
print(f" [DRY] would report_error({e['message']!r}, sev={e['severity']})")
|
||
else:
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import report_error
|
||
report_error(pid, e["message"], severity=e["severity"])
|
||
counts["errors"] += 1
|
||
|
||
# project_scorecard.md
|
||
scorecard = _read_text(os.path.join(a0_dir, "project_scorecard.md"))
|
||
if scorecard:
|
||
for line in scorecard.splitlines():
|
||
m = re.match(r"^\|\s*(\S+)\s*\|\s*(\d+)\s*\|\s*(\d+)\s*\|", line)
|
||
if m and m.group(1) not in ("category", "id"):
|
||
cat, sc, mx = m.group(1), int(m.group(2)), int(m.group(3))
|
||
if dry_run:
|
||
print(f" [DRY] would add_score({cat!r}, {sc}/{mx})")
|
||
else:
|
||
add_score(pid, cat, sc, mx)
|
||
counts["scorecard"] += 1
|
||
|
||
# 3. .a0proj/handover/ Briefing-Files
|
||
if os.path.isdir(handover_dir):
|
||
for fn in sorted(os.listdir(handover_dir)):
|
||
if not fn.endswith(".md"):
|
||
continue
|
||
full = os.path.join(handover_dir, fn)
|
||
text = _read_text(full)
|
||
if not text:
|
||
continue
|
||
# specialist und topic aus Dateiname: <specialist>_<topic>.md
|
||
base = fn[:-3]
|
||
specialist = base.split("_")[0] if "_" in base else base
|
||
topic = "_".join(base.split("_")[1:]) or base
|
||
parsed = _parse_briefing_md(text)
|
||
if dry_run:
|
||
print(f" [DRY] would append_briefing({specialist!r}, {topic!r})")
|
||
else:
|
||
append_briefing(
|
||
pid,
|
||
specialist=specialist,
|
||
topic=topic,
|
||
section_md=parsed["section_md"],
|
||
summary=parsed["summary"],
|
||
decisions=parsed["decisions"],
|
||
artifacts=parsed["artifacts"],
|
||
recent_turns=parsed["recent_turns"],
|
||
return_condition=parsed["return_condition"],
|
||
handover_reason=parsed["handover_reason"],
|
||
)
|
||
counts["briefings"] += 1
|
||
|
||
# 4. Optional: backup + cleanup
|
||
if not skip_backup and not dry_run:
|
||
ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||
backup_root = os.path.join(project_root, ".a0", f".backup-{ts}")
|
||
os.makedirs(backup_root, exist_ok=True)
|
||
|
||
if os.path.isdir(a0_dir):
|
||
for fn in os.listdir(a0_dir):
|
||
# Skip the backup dir we just created and any previous backups
|
||
if fn == os.path.basename(backup_root) or fn.startswith(".backup-"):
|
||
continue
|
||
full = os.path.join(a0_dir, fn)
|
||
if os.path.isfile(full):
|
||
shutil.move(full, os.path.join(backup_root, fn))
|
||
counts["files_moved"] += 1
|
||
|
||
if os.path.isdir(handover_dir):
|
||
handover_backup = os.path.join(project_root, ".a0proj", f".backup-{ts}")
|
||
os.makedirs(handover_backup, exist_ok=True)
|
||
for fn in os.listdir(handover_dir):
|
||
full = os.path.join(handover_dir, fn)
|
||
if os.path.isfile(full):
|
||
shutil.move(full, os.path.join(handover_backup, fn))
|
||
counts["files_moved"] += 1
|
||
# leere handover_dir entfernen
|
||
try:
|
||
os.rmdir(handover_dir)
|
||
except OSError:
|
||
pass
|
||
|
||
return counts
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description="Migrate .a0/ and .a0proj/ state files to patterns.db."
|
||
)
|
||
ap.add_argument(
|
||
"project",
|
||
help="Projektname (registriert in projects-Registry) ODER absoluter Pfad.",
|
||
)
|
||
ap.add_argument(
|
||
"--project-root",
|
||
default="",
|
||
help="Absoluter Pfad zum Projekt-Root (nur wenn project ein Name ist).",
|
||
)
|
||
ap.add_argument(
|
||
"--dry-run", action="store_true",
|
||
help="Zeigt nur an, was migriert würde, schreibt nichts.",
|
||
)
|
||
ap.add_argument(
|
||
"--skip-backup", action="store_true",
|
||
help="Quelldateien NICHT nach .backup-<ts>/ verschieben.",
|
||
)
|
||
args = ap.parse_args()
|
||
|
||
# Bestimme project_name + project_root
|
||
# Bug 3 Fix: stiller basename-Fallback entfernt. Wenn --project=<abs path>
|
||
# übergeben wird, wird basename genutzt MIT sichtbarem WARN-Hinweis,
|
||
# damit der User den Projektnamen explizit setzen kann.
|
||
if not args.project:
|
||
print("ERROR: --project=<name> ist Pflicht (basename-Fallback entfernt)")
|
||
sys.exit(1)
|
||
if os.path.isabs(args.project) and os.path.isdir(args.project):
|
||
project_root = args.project
|
||
project_name = os.path.basename(project_root.rstrip("/"))
|
||
print(f"WARN: --project ist absoluter Pfad; leite project_name={project_name!r} aus basename ab.")
|
||
print(f" Besser: explizit --project={project_name} --project-root={project_root} setzen.")
|
||
elif args.project_root:
|
||
project_root = args.project_root
|
||
project_name = args.project
|
||
else:
|
||
project_name = args.project
|
||
# Versuche, project_root aus projects-Tabelle zu holen
|
||
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import _conn
|
||
with _conn() as c:
|
||
row = c.execute(
|
||
"SELECT git_url FROM projects WHERE name = ?",
|
||
(project_name,),
|
||
).fetchone()
|
||
if row and row["git_url"]:
|
||
# Standard-Repo-Pfad: /a0/usr/workdir/dev-projects/<name>
|
||
project_root = f"/a0/usr/workdir/dev-projects/{project_name}"
|
||
if not os.path.isdir(project_root):
|
||
print(f"ERROR: project_root not found at {project_root}")
|
||
sys.exit(1)
|
||
else:
|
||
print(f"ERROR: project {project_name!r} not in DB and no --project-root given")
|
||
sys.exit(1)
|
||
|
||
print(f"=== Migration: {project_name} ===")
|
||
print(f" project_root: {project_root}")
|
||
print(f" dry-run: {args.dry_run}")
|
||
print(f" skip-backup: {args.skip_backup}")
|
||
print()
|
||
|
||
counts = migrate_a0_to_db(
|
||
project_name=project_name,
|
||
project_root=project_root,
|
||
dry_run=args.dry_run,
|
||
skip_backup=args.skip_backup,
|
||
)
|
||
|
||
print()
|
||
print("=== Summary ===")
|
||
for k, v in counts.items():
|
||
if v:
|
||
print(f" {k}: {v}")
|
||
print("Done.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|