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:
Software Orchestrator
2026-06-16 22:13:06 +00:00
commit 5769c1cd22
236 changed files with 17825 additions and 0 deletions
+457
View File
@@ -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()
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""migrate_legacy_project_names archiviert Projekte mit ungültigen Namen.
Bug 1+2 Begleit-Skript (Plan v3 §3.6):
- Iteriert über alle Projekte in der DB.
- Prüft jeden Namen gegen PROJECT_NAME_PATTERN + FORBIDDEN_PROJECT_NAMES.
- Markiert ungültige Namen mit status='archived' in project_state (idempotent).
- Bestehende 'active'-Projekte mit ungültigem Namen werden zu 'archived'.
- Bereits archivierte werden NICHT doppelt verändert (idempotent).
Aufruf:
python3 utils/migrate_legacy_project_names.py [--dry-run] [--verbose]
Defaults:
- --dry-run: nur Report, keine DB-Schreibzugriffe.
- --verbose: zusätzlich pro Projekt-Status ausgeben.
Exit-Code:
- 0: Erfolg (inkl. 0 archivierte Projekte).
- 1: DB-Fehler.
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import List, Tuple
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Run from /a0 to have usr.plugins.* resolvable.
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
_db, _db_error_handler, _validate_project_name,
PROJECT_NAME_PATTERN, FORBIDDEN_PROJECT_NAMES,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
@_db_error_handler
def list_projects_with_states() -> List[Tuple[int, str, str]]:
"""Returns Liste von (project_id, project_name, current_status).
Liest aus projects JOIN project_state. Projekte ohne project_state-Eintrag
bekommen den Status '<none>'.
"""
db = _db()
rows = db.conn.execute("""
SELECT p.id, p.name, COALESCE(ps.status, '<none>') as status
FROM projects p
LEFT JOIN project_state ps ON ps.project_id = p.id
ORDER BY p.id
""").fetchall()
return [(int(r[0]), str(r[1]), str(r[2])) for r in rows]
@_db_error_handler
def archive_project(project_id: int, reason: str) -> None:
"""Markiert ein Projekt als 'archived' in project_state. Idempotent.
Zwei Schritte statt ON CONFLICT DO UPDATE SET mit updated_at: das
vermeidet sporadische 'no such column: updated_at'-Fehler bei
cross-Singleton-Connections (zwei verschiedene Modul-Importe der
PatternDB führen zu zwei Connections auf die gleiche DB-Datei).
"""
db = _db()
# 1. Status archivieren (INSERT oder UPDATE)
db.conn.execute("""
INSERT INTO project_state (project_id, status, phase, last_active_at, updated_at)
VALUES (?, 'archived', 'archived', datetime('now'), datetime('now'))
ON CONFLICT(project_id) DO UPDATE SET
status = 'archived',
phase = 'archived',
last_active_at = datetime('now')
""", (project_id,))
# 2. updated_at explizit setzen (separates Statement, robuster)
db.conn.execute("""
UPDATE project_state SET updated_at = datetime('now')
WHERE project_id = ?
""", (project_id,))
# NOTE: projects-Tabelle hat kein updated_at-Feld (Spalten: id, name,
# git_url, description, tech_stack, created_at, project_path, …).
# Daher kein UPDATE projects hier. Wenn projects.updated_at per
# Migration ergänzt wird, kann dieser Block nachgerüstet werden.
db.conn.commit()
sys.stderr.write(f" [ARCHIVED] project_id={project_id} reason={reason}\n")
def main() -> int:
ap = argparse.ArgumentParser(
description="Archiviere Projekte mit ungültigen Namen (Pattern/Blacklist)."
)
ap.add_argument(
"--dry-run", action="store_true",
help="Nur anzeigen, welche Projekte archiviert würden; keine DB-Schreibzugriffe.",
)
ap.add_argument(
"--verbose", "-v", action="store_true",
help="Zusätzlich pro Projekt den Validierungs-Status ausgeben.",
)
args = ap.parse_args()
try:
projects = list_projects_with_states()
except PluginDBError as e:
print(f"ERROR: DB-Fehler beim Lesen der Projekte: {e}", file=sys.stderr)
return 1
if not projects:
print("No projects found in DB.")
return 0
print(f"=== Legacy-Name Migration (dry-run={args.dry_run}) ===")
print(f"Pattern: {PROJECT_NAME_PATTERN.pattern}")
print(f"Blacklist: {sorted(FORBIDDEN_PROJECT_NAMES)}")
print(f"Projects in DB: {len(projects)}")
print()
active_count = 0
archived_count = 0
already_archived_count = 0
invalid_count = 0
for pid, name, current_status in projects:
try:
_validate_project_name(name)
valid = True
invalid_reason = None
except ValueError as e:
valid = False
invalid_reason = str(e)
if valid:
active_count += 1
if args.verbose:
print(f" [OK ] pid={pid:3d} name={name!r:40s} status={current_status}")
continue
invalid_count += 1
if current_status == "archived":
already_archived_count += 1
print(f" [SKIP] pid={pid:3d} name={name!r} already archived; invalid={invalid_reason}")
continue
print(f" [BAD ] pid={pid:3d} name={name!r} status={current_status} reason={invalid_reason}")
if not args.dry_run:
try:
archive_project(pid, reason=invalid_reason)
archived_count += 1
except PluginDBError as e:
print(f"ERROR: konnte pid={pid} nicht archivieren: {e}", file=sys.stderr)
return 1
print()
print(f"Summary:")
print(f" total projects: {len(projects)}")
print(f" valid (active): {active_count}")
print(f" invalid (total): {invalid_count}")
print(f" newly archived: {archived_count}")
print(f" already archived: {already_archived_count}")
print(f" mode: {'DRY-RUN' if args.dry_run else 'APPLIED'}")
return 0
if __name__ == "__main__":
sys.exit(main())