From 835950047626f4fd1058cffb9fdc63c51dd0a7a5 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 28 May 2026 23:52:46 +0200 Subject: [PATCH] feat(skills): P3 config_schema + P4 Versionierung mit Rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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_/ (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) --- android/src/components/SkillBrowser.tsx | 189 ++++++++++++++- android/src/services/brainApi.ts | 61 ++++- aria-brain/agent.py | 128 +++++++++++ aria-brain/main.py | 65 ++++++ aria-brain/skills.py | 290 ++++++++++++++++++++++++ diagnostic/index.html | 153 +++++++++++++ 6 files changed, 884 insertions(+), 2 deletions(-) diff --git a/android/src/components/SkillBrowser.tsx b/android/src/components/SkillBrowser.tsx index 4ede5db..738a380 100644 --- a/android/src/components/SkillBrowser.tsx +++ b/android/src/components/SkillBrowser.tsx @@ -24,7 +24,7 @@ import { View, } from 'react-native'; -import brainApi, { Skill } from '../services/brainApi'; +import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi'; const COL_ACTIVE = '#34C759'; const COL_INACTIVE = '#555570'; @@ -177,8 +177,30 @@ const SkillDetailModal: React.FC = ({ skill, onClose, onReload }) = const [logs, setLogs] = useState(null); const [loadingLogs, setLoadingLogs] = useState(false); + // P3: Skill-Config (statische Werte je Skill, z.B. API-Keys) + const [cfgSchema, setCfgSchema] = useState([]); + const [cfgValues, setCfgValues] = useState>({}); + const [cfgDraft, setCfgDraft] = useState>({}); + const [cfgSaving, setCfgSaving] = useState(false); + + // P4: Versionen + Rollback + const [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = useState(false); + const args = Array.isArray(skill.args) ? skill.args : []; + // Config + Versionen beim Mount laden + useEffect(() => { + brainApi.getSkillConfig(skill.name) + .then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); }) + .catch(() => {}); + setVersionsLoading(true); + brainApi.listSkillVersions(skill.name) + .then(setVersions) + .catch(() => setVersions([])) + .finally(() => setVersionsLoading(false)); + }, [skill.name]); + const setArg = (name: string, value: string) => setArgValues(prev => ({ ...prev, [name]: value })); @@ -225,6 +247,85 @@ const SkillDetailModal: React.FC = ({ skill, onClose, onReload }) = ); }; + const saveConfig = () => { + // secret-Felder die als '***SET***' angezeigt sind und vom User NICHT + // angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur + // explizit getippte Werte; alles andere uebernehmen wir aus cfgValues. + const next: Record = { ...cfgValues }; + for (const f of cfgSchema) { + const draft = cfgDraft[f.name]; + const isSecret = f.secret || f.type === 'password'; + if (draft === undefined) continue; + if (isSecret && draft === '') continue; // leer = unveraendert + if (draft === '') { delete next[f.name]; continue; } + if (f.type === 'number') { + const n = Number(draft); next[f.name] = isNaN(n) ? draft : n; + } else if (f.type === 'boolean') { + next[f.name] = draft === 'true' || draft === '1'; + } else { + next[f.name] = draft; + } + } + // Maskierte Werte (***SET***) niemals zurueckschreiben + for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k]; + setCfgSaving(true); + brainApi.setSkillConfig(skill.name, next) + .then(() => { + // frisch laden um neuen masked-State zu zeigen + return brainApi.getSkillConfig(skill.name); + }) + .then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); }) + .catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e))) + .finally(() => setCfgSaving(false)); + }; + + const reloadVersions = () => { + setVersionsLoading(true); + brainApi.listSkillVersions(skill.name) + .then(setVersions) + .catch(() => {}) + .finally(() => setVersionsLoading(false)); + }; + + const doRollback = (versionId: string) => { + Alert.alert( + 'Rollback?', + `Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Rollback', style: 'destructive', + onPress: () => { + brainApi.rollbackSkill(skill.name, versionId) + .then(r => { + Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`); + reloadVersions(); onReload(); + }) + .catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e))); + }, + }, + ], + ); + }; + + const removeVersion = (versionId: string) => { + Alert.alert( + 'Version loeschen?', + `${versionId} dauerhaft entfernen?`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', style: 'destructive', + onPress: () => { + brainApi.deleteSkillVersion(skill.name, versionId) + .then(reloadVersions) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }, + }, + ], + ); + }; + return ( @@ -274,6 +375,92 @@ const SkillDetailModal: React.FC = ({ skill, onClose, onReload }) = ) : null} + {/* Config-Schema-Form (P3) */} + {cfgSchema.length > 0 ? ( + <> + ⚙ Konfiguration + {cfgSchema.map((f) => { + const isSecret = f.secret || f.type === 'password'; + const cur = cfgValues[f.name]; + const isSet = isSecret && cur === '***SET***'; + const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert' + : (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string')); + const valStr = cfgDraft[f.name] !== undefined + ? cfgDraft[f.name] + : (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : '')); + if (f.type === 'boolean') { + const bv = cfgDraft[f.name] !== undefined + ? (cfgDraft[f.name] === 'true') + : (cur === true || cur === 'true'); + return ( + + setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))} + trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" /> + + {f.label || f.name} + {f.description ? {f.description} : null} + + + ); + } + return ( + + + {f.label || f.name}{isSecret ? ' 🔒' : ''} + {f.description ? — {f.description} : null} + + setCfgDraft(p => ({...p, [f.name]: v}))} + placeholder={placeholder} + placeholderTextColor="#444460" + autoCapitalize="none" + autoCorrect={false} + secureTextEntry={isSecret} + keyboardType={f.type === 'number' ? 'numeric' : 'default'} + /> + + ); + })} + + + {cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'} + + + + ) : null} + + {/* Versionen (P4) */} + {versions.length > 0 ? ( + <> + 📦 Versionen ({versions.length}) + {versions.map(v => ( + + + {v.version_id} + {v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'} + {v.summary ? {v.summary} : null} + + doRollback(v.version_id)} + style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}> + + + removeVersion(v.version_id)} + style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}> + 🗑 + + + ))} + + ) : versionsLoading ? ( + + ) : null} + ENV. Werte selbst kommen via /config. + config_schema?: SkillConfigField[]; + // P4: Versions-Historie. Detail-Liste kommt via /versions. + version_history?: { version_id: string; archived_at?: string; summary?: string }[]; +} + +export interface SkillConfigField { + name: string; + type: 'string' | 'number' | 'boolean' | 'password'; + label?: string; + secret?: boolean; + description?: string; + default?: any; +} + +export interface SkillVersion { + version_id: string; + archived_at?: string; + summary?: string; } /** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */ @@ -395,7 +415,46 @@ export const brainApi = { /** Letzte Run-Logs eines Skills. */ getSkillLogs(name: string, limit: number = 20): Promise { - return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`); + return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`) + .then((r: any) => Array.isArray(r) ? r : (r?.logs || [])); + }, + + /** P3: Config-Schema + aktuelle Werte (secret-Felder gemaskt mit '***SET***'). */ + getSkillConfig(name: string): Promise<{ schema: SkillConfigField[]; values: Record }> { + return _send(`/skills/${encodeURIComponent(name)}/config`) + .then((r: any) => ({ schema: r?.schema || [], values: r?.values || {} })); + }, + + /** P3: Config-Werte komplett ueberschreiben. Werte greifen ab dem naechsten Run. */ + setSkillConfig(name: string, values: Record): Promise<{ ok: boolean; values: Record }> { + return _send(`/skills/${encodeURIComponent(name)}/config`, { + method: 'POST', + body: { values }, + timeoutMs: 10000, + }); + }, + + /** P4: Liste archivierter Versionen, neueste zuerst. */ + listSkillVersions(name: string): Promise { + return _send(`/skills/${encodeURIComponent(name)}/versions`) + .then((r: any) => r?.versions || []); + }, + + /** P4: Rollback auf eine fruehere Version. Aktueller Stand wird automatisch gesichert. */ + rollbackSkill(name: string, versionId: string): Promise<{ ok: boolean; rolled_back_to: string; safety_snapshot: string }> { + return _send(`/skills/${encodeURIComponent(name)}/rollback`, { + method: 'POST', + body: { version_id: versionId }, + timeoutMs: 60000, // venv-Rebuild kann dauern + }); + }, + + /** P4: Einzelne Version dauerhaft loeschen. */ + deleteSkillVersion(name: string, versionId: string): Promise<{ ok: boolean; deleted: string }> { + return _send(`/skills/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`, { + method: 'DELETE', + timeoutMs: 10000, + }); }, // ── OAuth ──────────────────────────────────────────────────────── diff --git a/aria-brain/agent.py b/aria-brain/agent.py index cd7df17..7d81cd7 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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_ 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_ 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_"}, + }, + "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) diff --git a/aria-brain/main.py b/aria-brain/main.py index 24e197d..c55e56f 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -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: diff --git a/aria-brain/skills.py b/aria-brain/skills.py index 815f48b..a2efbd6 100644 --- a/aria-brain/skills.py +++ b/aria-brain/skills.py @@ -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_` — 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_-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_/ 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_/. 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_-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": diff --git a/diagnostic/index.html b/diagnostic/index.html index c67deb8..79773ad 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -3514,6 +3514,8 @@ +
+
Logs (letzte 20)
(Logs lädt...)
@@ -3547,6 +3549,8 @@ const el = document.getElementById('skill-readme-' + name); if (el && d.readme) el.innerHTML = '
' + escapeHtml(d.readme) + '
'; } catch {} + loadSkillConfigSection(name); + loadSkillVersionsSection(name); try { const r2 = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/logs'); const d2 = await r2.json(); @@ -3565,6 +3569,155 @@ } } + // ── Skill-Configs (P3) ───────────────────────────────── + async function loadSkillConfigSection(name) { + const el = document.getElementById('skill-config-' + name); + if (!el) return; + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config'); + if (!r.ok) { el.innerHTML = ''; return; } + const d = await r.json(); + const schema = d.schema || []; + if (!schema.length) { el.innerHTML = ''; return; } + const values = d.values || {}; + const inputs = schema.map(f => { + const fname = f.name; + const label = f.label || fname; + const desc = f.description ? `
${escapeHtml(f.description)}
` : ''; + const isSecret = f.secret || f.type === 'password'; + const cur = values[fname]; + const placeholder = isSecret && cur === '***SET***' ? '••• gesetzt (leer lassen = unverändert) •••' + : (f.default !== undefined && f.default !== null ? `Default: ${f.default}` : ''); + let inputEl; + if (f.type === 'boolean') { + const checked = (cur === true || cur === 'true') ? 'checked' : ''; + inputEl = ``; + } else { + const type = isSecret ? 'password' : (f.type === 'number' ? 'number' : 'text'); + const val = (isSecret) ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? escapeHtml(String(cur)) : ''); + inputEl = ``; + } + return `
+
+ + ${inputEl} +
+ ${desc} +
`; + }).join(''); + el.innerHTML = ` +
+
⚙ Konfiguration
+ ${inputs} + + +
`; + } catch (e) { + el.innerHTML = `
Config-Load: ${escapeHtml(e.message)}
`; + } + } + + async function saveSkillConfig(name) { + const el = document.getElementById('skill-config-' + name); + if (!el) return; + const inputs = el.querySelectorAll('[data-cfg]'); + // Erst aktuelle gespeicherte Werte holen — secret-Felder die leer sind sollen unverändert bleiben + let existing = {}; + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config'); + const d = await r.json(); + existing = d.values || {}; + } catch {} + const values = { ...existing }; + inputs.forEach(inp => { + const fname = inp.getAttribute('data-cfg'); + const type = inp.getAttribute('data-type'); + let v; + if (type === 'boolean') v = inp.checked; + else if (type === 'number') v = inp.value === '' ? null : Number(inp.value); + else v = inp.value; + const isPassword = inp.type === 'password'; + if (isPassword && v === '') return; // leer bei secret = unverändert + if (v === '' || v === null) { delete values[fname]; return; } + if (v === '***SET***') return; + values[fname] = v; + }); + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ values }), + }); + const stat = document.getElementById('skill-cfg-status-' + name); + if (r.ok) { + if (stat) { stat.textContent = '✓ gespeichert'; stat.style.color = '#3FFF3F'; } + loadSkillConfigSection(name); + } else { + if (stat) { stat.textContent = 'Fehler ' + r.status; stat.style.color = '#FF6B6B'; } + } + } catch (e) { + alert('Speichern fehlgeschlagen: ' + e.message); + } + } + + // ── Skill-Versions (P4) ───────────────────────────────── + async function loadSkillVersionsSection(name) { + const el = document.getElementById('skill-versions-' + name); + if (!el) return; + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions'); + if (!r.ok) { el.innerHTML = ''; return; } + const d = await r.json(); + const versions = d.versions || []; + if (!versions.length) { el.innerHTML = ''; return; } + const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '?'; + const rows = versions.map(v => ` +
+ ${escapeHtml(v.version_id)} + ${fmtDate(v.archived_at)} + ${escapeHtml(v.summary || '')} + + +
+ `).join(''); + el.innerHTML = ` +
+
📦 Versionen (${versions.length})
+ ${rows} +
`; + } catch (e) { + el.innerHTML = `
Versions-Load: ${escapeHtml(e.message)}
`; + } + } + + async function rollbackSkillVersion(name, versionId) { + if (!confirm(`Skill "${name}" auf Version ${versionId} zurückrollen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`)) return; + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/rollback', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ version_id: versionId }), + }); + const d = await r.json(); + if (r.ok) { + alert(`✓ Rollback OK\nSicherheits-Snapshot: ${d.safety_snapshot}`); + loadSkillVersionsSection(name); + loadSkills(); + } else { + alert('Rollback fehlgeschlagen: ' + (d.detail || JSON.stringify(d))); + } + } catch (e) { alert('Rollback-Fehler: ' + e.message); } + } + + async function deleteSkillVersion(name, versionId) { + if (!confirm(`Version ${versionId} von "${name}" wirklich löschen?\n\nNicht rückholbar.`)) return; + try { + const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions/' + encodeURIComponent(versionId), { + method: 'DELETE', + }); + if (r.ok) loadSkillVersionsSection(name); + else { const d = await r.json().catch(()=>({})); alert('Löschen fehlgeschlagen: ' + (d.detail || r.status)); } + } catch (e) { alert('Fehler: ' + e.message); } + } + async function toggleSkillActive(name, newActive) { try { await fetch('/api/brain/skills/' + encodeURIComponent(name), {