Files
a0_software_orchestrator/utils/migrate_a0_to_db.py
T
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

458 lines
16 KiB
Python
Raw 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.
"""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()