feat(skills): P3 config_schema + P4 Versionierung mit Rollback

P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
  als zentrale Werte-Persistenz. _normalize_config_schema validiert die
  Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
  ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
  config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.

P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
  versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
  bevor strukturelle Aenderungen passieren. list_skill_versions,
  rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
  delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
  DELETE /skills/{name}/versions/{version_id}.

UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
  Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
  mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
  on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
  Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
  getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
  deleteSkillVersion erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:52:46 +02:00
parent 1a72f27861
commit 8359500476
6 changed files with 884 additions and 2 deletions
+128
View File
@@ -171,11 +171,85 @@ META_TOOLS = [
},
"description": {"type": "string", "description": "Neue Beschreibung (optional)"},
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
"config_schema": {
"type": "array",
"items": {"type": "object"},
"description": (
"Optional neues config_schema fuer den Skill. Liste von "
"Feldern [{name, type, label, secret?, description?, default?}]. "
"type: string|number|boolean|password (password impliziert secret=true). "
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
),
},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_set_config",
"description": (
"Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
"User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
"im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
"Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
"NICHT in den Skill-Code, sondern hierher.\n\n"
"WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
"aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
"aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name"},
"values": {
"type": "object",
"description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
},
},
"required": ["name", "values"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_list_versions",
"description": (
"Listet archivierte Versionen eines Skills (jeder skill_update "
"legt automatisch eine an). Returns [{version_id, archived_at, "
"summary}]. Brauchst Du fuer skill_rollback."
),
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_rollback",
"description": (
"Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
"wird der aktuelle Stand automatisch archiviert — du verlierst "
"nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
"oder Stefan sagt 'mach den letzten Stand wieder her'. "
"version_id bekommst Du aus skill_list_versions."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version_id": {"type": "string", "description": "Format v_<timestamp>"},
},
"required": ["name", "version_id"],
},
},
},
{
"type": "function",
"function": {
@@ -856,6 +930,7 @@ class Agent:
readme=arguments.get("readme", ""),
args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -888,6 +963,8 @@ class Agent:
patch[k] = arguments[k]
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"]
if not patch:
return "FEHLER: keine Felder zum Update angegeben."
try:
@@ -918,6 +995,57 @@ class Agent:
except ValueError as exc:
return f"FEHLER: {exc}"
return f"OK — Skill '{skill_name}' geloescht."
if name == "skill_set_config":
skill_name = (arguments.get("name") or "").strip()
values = arguments.get("values")
if not skill_name or not isinstance(values, dict):
return "FEHLER: name + values (dict) erforderlich."
try:
skills_mod.set_skill_config(skill_name, values)
except ValueError as exc:
return f"FEHLER: {exc}"
masked = skills_mod.get_skill_config_masked(skill_name)
return (
f"OK — Config fuer Skill '{skill_name}' gesetzt. "
f"Aktuelle Werte (secrets gemasked): {masked}"
)
if name == "skill_list_versions":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
return "FEHLER: name ist Pflicht."
versions = skills_mod.list_skill_versions(skill_name)
if not versions:
return f"Skill '{skill_name}' hat keine archivierten Versionen."
lines = [
f"- {v.get('version_id')} ({v.get('archived_at','?')}) {v.get('summary','')}"
for v in versions
]
return "Versionen (neueste zuerst):\n" + "\n".join(lines)
if name == "skill_rollback":
skill_name = (arguments.get("name") or "").strip()
version_id = (arguments.get("version_id") or "").strip()
if not skill_name or not version_id:
return "FEHLER: name + version_id erforderlich."
try:
res = skills_mod.rollback_skill(skill_name, version_id)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event als skill_created getarnt — App/Diagnostic
# zeigen Rollback dann als sichtbare Aktion an
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": skill_name,
"description": "(rollback)",
"execution": "local-venv",
"active": True,
"updated": True,
},
})
return (
f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
)
if name.startswith("run_"):
skill_name = name[len("run_"):]
res = skills_mod.run_skill(skill_name, args=arguments)
+65
View File
@@ -757,6 +757,7 @@ class SkillCreate(BaseModel):
requires: dict = Field(default_factory=dict)
pip_packages: list = Field(default_factory=list)
author: str = "stefan"
config_schema: list = Field(default_factory=list)
class SkillRun(BaseModel):
@@ -769,6 +770,18 @@ class SkillPatch(BaseModel):
description: str | None = None
active: bool | None = None
args: list | None = None
entry_code: str | None = None
readme: str | None = None
pip_packages: list | None = None
config_schema: list | None = None
class SkillConfigSet(BaseModel):
values: dict
class SkillRollback(BaseModel):
version_id: str
@app.get("/skills/list")
@@ -798,6 +811,7 @@ def skills_create(body: SkillCreate):
requires=body.requires,
pip_packages=body.pip_packages,
author=body.author,
config_schema=body.config_schema,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@@ -834,6 +848,57 @@ def skills_logs(name: str, limit: int = 50):
return {"logs": skills_mod.list_logs(name, limit=limit)}
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
@app.get("/skills/{name}/config")
def skills_config_get(name: str):
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
'***SET***')."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {
"schema": manifest.get("config_schema") or [],
"values": skills_mod.get_skill_config_masked(name),
}
@app.post("/skills/{name}/config")
def skills_config_set(name: str, body: SkillConfigSet):
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
skills_mod.set_skill_config(name, body.values)
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
# ── Skill-Versions (P4): rollback ──────────────────────────────────
@app.get("/skills/{name}/versions")
def skills_versions_list(name: str):
if skills_mod.read_manifest(name) is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {"versions": skills_mod.list_skill_versions(name)}
@app.post("/skills/{name}/rollback")
def skills_rollback(name: str, body: SkillRollback):
try:
return skills_mod.rollback_skill(name, body.version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/skills/{name}/versions/{version_id}")
def skills_versions_delete(name: str, version_id: str):
try:
return skills_mod.delete_skill_version(name, version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.get("/skills/{name}/export")
def skills_export(name: str):
try:
+290
View File
@@ -47,6 +47,10 @@ logger = logging.getLogger(__name__)
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
SHARED_UPLOADS = Path("/shared/uploads")
SKILL_CONFIGS_FILE = Path(os.environ.get("SKILL_CONFIGS_FILE", "/shared/config/skill_configs.json"))
# Beim Archivieren in versions/ ausgenommen (gross, regenerierbar, sind keine Sources)
_VERSION_SKIP = {"venv", "logs", "versions", "__pycache__"}
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
@@ -159,6 +163,7 @@ def create_skill(
requires: Optional[dict] = None,
pip_packages: Optional[list[str]] = None,
author: str = "aria",
config_schema: Optional[list] = None,
) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -207,6 +212,8 @@ def create_skill(
"use_count": 0,
"version": "1.0",
"author": author,
"config_schema": _normalize_config_schema(config_schema),
"version_history": [],
}
write_manifest(name, manifest)
@@ -225,6 +232,35 @@ def create_skill(
return manifest
def _normalize_config_schema(schema: Optional[list]) -> list:
"""Filter + Normalisiert das config_schema. Erwartet Liste von Dicts mit
Pflichtfeld 'name'. Optional: label, type (string|number|boolean|password),
secret (bool), default, description."""
if not schema:
return []
out = []
for f in schema:
if not isinstance(f, dict):
continue
fname = (f.get("name") or "").strip()
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,40}$", fname):
continue
ftype = (f.get("type") or "string").lower()
if ftype not in ("string", "number", "boolean", "password"):
ftype = "string"
# password impliziert secret=True
secret = bool(f.get("secret")) or ftype == "password"
out.append({
"name": fname,
"type": ftype,
"label": (f.get("label") or fname),
"secret": secret,
"description": (f.get("description") or "")[:300],
"default": f.get("default"),
})
return out
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv)
@@ -247,10 +283,30 @@ def update_skill(name: str, patch: dict) -> dict:
if manifest is None:
raise ValueError(f"Skill '{name}' nicht gefunden")
d = _skill_dir(name)
# Auto-Archive: wenn strukturelle Aenderung (Code/README/Deps/Schema), erst
# snapshot machen. So kann jeder skill_update zurueckgerollt werden.
structural = any(k in patch for k in ("entry_code", "readme", "pip_packages",
"config_schema", "args"))
if structural:
try:
archive_current_version(
name,
summary=patch.get("_change_summary") or ", ".join(
sorted(k for k in patch.keys() if k != "_change_summary")
)[:200],
)
except Exception as exc:
logger.warning("update_skill: Auto-Archive %s fehlgeschlagen: %s", name, exc)
# nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest
allowed = {"description", "args", "requires", "active", "version", "entry"}
for k, v in patch.items():
if k in allowed:
manifest[k] = v
if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
# Code austauschen
if "entry_code" in patch and patch["entry_code"]:
@@ -296,9 +352,230 @@ def delete_skill(name: str) -> None:
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
shutil.rmtree(d)
# Configs auch raeumen — sonst Karteileiche in skill_configs.json
try:
all_cfg = _load_all_skill_configs()
if name in all_cfg:
all_cfg.pop(name)
_save_all_skill_configs(all_cfg)
except Exception:
pass
logger.info("Skill geloescht: %s", name)
# ─── Skill-Configs (statische Werte je Skill — API-Keys, IDs etc.) ──
# Werte liegen zentral in /shared/config/skill_configs.json damit Stefan
# sie im Diagnostic-UI editieren kann. Skill bekommt sie zur Laufzeit
# als ENV `CFG_<UPPER_NAME>` — kein hardcoden im Code noetig.
def _load_all_skill_configs() -> dict:
if not SKILL_CONFIGS_FILE.exists():
return {}
try:
return json.loads(SKILL_CONFIGS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("skill_configs.json kaputt (%s) — leeres dict", exc)
return {}
def _save_all_skill_configs(data: dict) -> None:
SKILL_CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SKILL_CONFIGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8")
def get_skill_config(name: str) -> dict:
"""Liefert die rohen Config-Werte fuer einen Skill (ungemasked).
Wird intern beim run_skill genutzt um CFG_<NAME>-Env zu bauen."""
return _load_all_skill_configs().get(name, {})
def set_skill_config(name: str, values: dict) -> dict:
"""Speichert die Config-Werte fuer einen Skill (komplett ueberschreiben).
Werte landen sofort persistent; naechster run_skill nutzt sie."""
if not isinstance(values, dict):
raise ValueError("values muss ein Dict sein")
all_cfg = _load_all_skill_configs()
all_cfg[name] = values
_save_all_skill_configs(all_cfg)
return values
def get_skill_config_masked(name: str) -> dict:
"""Wie get_skill_config, aber secret-Felder werden auf '***SET***' maskiert.
Schema kommt aus dem skill.json Felder ohne secret=True werden klar
zurueckgegeben. Fuer UI-Anzeige."""
manifest = read_manifest(name)
schema = (manifest or {}).get("config_schema") or []
secret_fields = {f.get("name") for f in schema if f.get("secret")}
values = get_skill_config(name)
return {k: ("***SET***" if (k in secret_fields and v) else v)
for k, v in values.items()}
def _config_env_name(field_name: str) -> str:
"""API-Key → CFG_API_KEY. Erlaubt nur a-zA-Z0-9_."""
safe = re.sub(r"[^a-zA-Z0-9]", "_", field_name).upper()
return f"CFG_{safe}"
# ─── Versionierung (Rollback-fähiges update_skill) ───────────────────
# Vor jedem strukturellen update wird der aktuelle Stand nach
# versions/v_<ts>/ kopiert (ohne venv/logs/versions). Rollback kopiert
# eine Version zurueck — vorher noch ein Auto-Snapshot, damit auch der
# Rollback rueckholbar ist.
def _versions_dir(name: str) -> Path:
return _skill_dir(name) / "versions"
def _copytree_skill(src: Path, dst: Path) -> None:
"""Kopiert Skill-Sources (alles ausser venv/logs/versions/__pycache__)."""
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
if item.name in _VERSION_SKIP:
continue
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
def archive_current_version(name: str, summary: str = "") -> str:
"""Kopiert den aktuellen Skill-Stand nach versions/v_<ts>/. Returnt die
version_id. Im Manifest wird `version_history` gepflegt."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
ts = int(time.time())
version_id = f"v_{ts}"
# Kollisionsschutz bei sub-Sekunden-Calls
while (_versions_dir(name) / version_id).exists():
ts += 1
version_id = f"v_{ts}"
archive = _versions_dir(name) / version_id
_copytree_skill(d, archive)
(archive / "_version.json").write_text(json.dumps({
"version_id": version_id,
"archived_at": _now(),
"summary": (summary or "")[:300],
}, indent=2, ensure_ascii=False), encoding="utf-8")
# Manifest-History pflegen (read-back nach _copytree, damit history konsistent)
manifest = read_manifest(name)
if manifest is not None:
hist = list(manifest.get("version_history") or [])
hist.append({"version_id": version_id, "archived_at": _now(),
"summary": (summary or "")[:300]})
# Cap auf 50 Versionen — alte Eintraege wegrotieren (Dateien bleiben aber)
manifest["version_history"] = hist[-50:]
write_manifest(name, manifest)
return version_id
def list_skill_versions(name: str) -> list[dict]:
"""Liste aller archivierten Versionen, neueste zuerst."""
versions = _versions_dir(name)
if not versions.exists():
return []
out = []
for entry in sorted(versions.iterdir(), reverse=True):
if not entry.is_dir():
continue
meta = entry / "_version.json"
if meta.exists():
try:
out.append(json.loads(meta.read_text(encoding="utf-8")))
continue
except Exception:
pass
out.append({"version_id": entry.name, "archived_at": "", "summary": ""})
return out
def rollback_skill(name: str, version_id: str) -> dict:
"""Stellt eine archivierte Version wieder her. Vorher wird der aktuelle
Stand automatisch als neue Version archiviert ('safety_snapshot')
Rollback ist also nicht destruktiv. venv wird neu aufgebaut wenn
requirements.txt vorhanden ist."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
archive = _versions_dir(name) / version_id
if not archive.exists() or not archive.is_dir():
raise ValueError(f"Version '{version_id}' fuer Skill '{name}' nicht gefunden")
# 1. Sicherung des aktuellen Stands
safety = archive_current_version(name, summary=f"safety-snapshot vor rollback auf {version_id}")
# 2. Aktuelle Sources loeschen (venv/logs/versions bleiben)
for item in d.iterdir():
if item.name in _VERSION_SKIP:
continue
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
else:
try:
item.unlink()
except FileNotFoundError:
pass
# 3. Archive zurueck kopieren (ohne _version.json — das ist Versions-Metadata)
for item in archive.iterdir():
if item.name == "_version.json":
continue
target = d / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
# 4. Manifest-Stempel
manifest = read_manifest(name)
if manifest is not None:
manifest["updated_at"] = _now()
manifest["last_rollback"] = {"to": version_id, "safety": safety, "at": _now()}
write_manifest(name, manifest)
# 5. venv-Rebuild bei local-venv
req_file = d / "requirements.txt"
if (manifest or {}).get("execution") == "local-venv" and req_file.exists():
pip_packages = [l.strip() for l in req_file.read_text(encoding="utf-8").splitlines()
if l.strip() and not l.strip().startswith("#")]
venv = d / "venv"
if venv.exists():
shutil.rmtree(venv, ignore_errors=True)
try:
_setup_venv(d, pip_packages)
if manifest is not None:
manifest.pop("setup_error", None)
manifest["active"] = True
write_manifest(name, manifest)
except Exception as exc:
if manifest is not None:
manifest["active"] = False
manifest["setup_error"] = str(exc)[:500]
write_manifest(name, manifest)
logger.warning("Rollback %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
return {"ok": True, "name": name, "rolled_back_to": version_id,
"safety_snapshot": safety}
def delete_skill_version(name: str, version_id: str) -> dict:
"""Loescht eine einzelne Version aus versions/. Nicht-rueckholbar."""
archive = _versions_dir(name) / version_id
if not archive.exists():
raise ValueError(f"Version '{version_id}' nicht gefunden")
shutil.rmtree(archive)
manifest = read_manifest(name)
if manifest is not None:
manifest["version_history"] = [v for v in (manifest.get("version_history") or [])
if v.get("version_id") != version_id]
write_manifest(name, manifest)
return {"ok": True, "deleted": version_id}
# ─── Run ────────────────────────────────────────────────────────────
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
@@ -328,6 +605,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
# Brain-API fuer Skills die OAuth-Tokens / Brain-Helpers brauchen.
# Beispiel: requests.get(f"{os.environ['BRAIN_INTERNAL_URL']}/oauth/spotify/token")
env["BRAIN_INTERNAL_URL"] = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
# Config-Schema-Werte als CFG_<NAME>-ENV (P3). Default greift wenn Stefan
# noch keinen Wert gesetzt hat — None wird uebersprungen damit der Skill
# selbst entscheiden kann ob das ein Fehler ist.
schema = manifest.get("config_schema") or []
values = get_skill_config(name)
for field in schema:
fname = field.get("name")
if not fname:
continue
val = values.get(fname, field.get("default"))
if val is None:
continue
env[_config_env_name(fname)] = str(val)
# Command bauen
if exec_mode == "local-venv":