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
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user