diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b763d0..caad899 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1709,5 +1709,6 @@ docker compose restart flowdeck - **Base de données avancée** — Relations inter-collections, rollups - **Kanban flexible** — Colonnes custom, WIP limits - **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat) +- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py` - **Volume Docker persistant** — `/data` monté pour survie des données - **PostgreSQL** — Migration optionnelle pour scaling diff --git a/CHANGELOG.md b/CHANGELOG.md index 57cbe1e..08e50eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog - FlowDeck +## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace + +> Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient +> pilotable par des intégrations tierces via `/api/v2`, et les skills deviennent +> partageables (export/import portable + galerie de presets installables). + +### Added + +- **API publique agent** — `app/routers/api_v2_agent.py` (15 routes, Bearer + scopes `read`/`write`, + rate limit par token, idempotence, `api_audit_log`) : + - `GET/POST /api/v2/agents`, `GET/PUT/DELETE /api/v2/agents/{id}` + - `GET/POST /api/v2/agents/conversations`, `GET/DELETE .../{id}`, `GET .../{id}/actions` + - `POST /api/v2/agents/conversations/{id}/run` — **run synchrone JSON** (le flux SSE reste interne) : + `{status, final, error, reasoning[], actions[], events[], duration_ms}` (500 si `failed`) + - `POST /api/v2/agents/{id}/trigger` — déclenchement externe d'un agent custom + - propriété des conversations vérifiée par `user_id` → `404` pour un token tiers +- **Marketplace de skills** — `app/services/skill_gallery.py` (source unique pour l'interne et le v2) : + - `GET /api/v2/skills/{id}/export` → document portable `{format: "flowdeck-skill", version: 1, skill{…}}` + (aucun id/workspace/auteur local) ; `POST /api/v2/skills/import` (`409` sur collision, `overwrite: true` pour écraser) + - `GET /api/v2/skills/gallery` + `POST /api/v2/skills/gallery/{slug}/install` — **6 presets** : + rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document + - `GET/POST/DELETE /api/v2/skills`, `GET /api/v2/skills/{id}`, `POST /api/v2/skills/{id}/apply` + - routes jumelles session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`, + `POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}` + (le CRUD skills n'avait **aucune** suppression) + - **UI** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage du chip +- **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis (seul + `agent.run.finished` l'était) via `_fire_agent_webhook()` ; les 3 sont déjà au catalogue → abonnement `agent.*` + +### Changed + +- **Docs** — `docs/API_GUIDE_V6.md` : nouveau §2.4 (tables d'endpoints + règles agent) ; nom d'événement + corrigé `agent.run.completed` → `agent.run.finished` ; `ROADMAP.md` : 5 phases agent toutes cochées + section v6.6.0 +- **OpenAPI** — `docs/openapi-v2.json` régénéré : **427 chemins** (was 402), `info.version = 6.6.0` +- **Version** — 6.6.0 (`VERSION` + `app/main.py`) + +### Tests + +- `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, idempotence, run synchrone, + ownership 404, trigger, export/import/validation, galerie + validité des outils des presets, + routes internes, cycle de vie webhooks started→finished et started→failed) +- `pytest -n auto` → **764 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème + ## v6.5.1 (2026-09-24) — Tests webhooks_v2 complets + roadmap rattrapée > Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis diff --git a/README.md b/README.md index 55af1b2..41fb5fe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users. -> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline +> **v6.6.0** — Agent phase 5 : API publique agent (`/api/v2/agents`) + marketplace de skills · avant : v6.5.x synced blocks, v6.4.0 realtime, PWA offline ## Quick Start @@ -49,8 +49,10 @@ docker compose up -d - **CSV Import/Export** - **Public Sharing**: lien de partage lecture seule -### API & Intégrations (v6.3) +### API & Intégrations (v6.3–v6.6) - **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs` +- **API agent publique (v6.6)**: `/api/v2/agents/*` — agents, conversations, **run synchrone JSON**, journal d'actions + rollback, `trigger` externe +- **Marketplace de skills (v6.6)**: export/import portable + galerie de 6 presets installables (`/api/v2/skills/*`), section « Galerie » dans la palette `/` de l'agent - **API publique v1**: `/api/v1` (lecture seule, compat) - **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2) - **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot) @@ -87,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db ## Tests ```bash -python3 -m pytest tests/ -v # 725/725 passent +python3 -m pytest tests/ -v # 764/764 passent (0 skip) ``` ## Roadmap diff --git a/ROADMAP.md b/ROADMAP.md index 391e36f..e87e7ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -404,11 +404,11 @@ app/ - **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport #### Plan de migration (5 phases) -1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming +1. **Phase 1 — Core Agent** ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming 2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1) -3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads) -4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés -5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills +3. **Phase 3 — Outils avancés** ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads) +4. **Phase 4 — Autonomie** ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés +5. **Phase 5 — Plateforme** ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills #### Limitations (v1) - Pas de modification concurrente (lock optimiste DB) @@ -849,6 +849,36 @@ Détails livrés : --- +## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24) +> **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » : +> exposer l'agent en API publique pour les intégrations tierces et rendre les skills +> partageables (marketplace). **COMPLETED**. Voir [`docs/API_GUIDE_V6.md` §2.4](docs/API_GUIDE_V6.md). + +- [x] **API publique agent** — `app/routers/api_v2_agent.py` (15 routes `/api/v2/agents/*`, + Bearer + scopes `read`/`write`, rate limit par token, idempotence `Idempotency-Key`, `api_audit_log`) : + - agents CRUD, conversations + messages, **run synchrone JSON** (le flux SSE reste réservé à l'UI), + journal `agent_actions` + `POST /agents/actions/{id}/undo`, `POST /agents/{id}/trigger` + - propriété des conversations vérifiée par `user_id` : un token tiers reçoit `404` (pas de fuite) + - réponse de run : `{status, final, error, reasoning[], actions[], events[], duration_ms}` +- [x] **Marketplace skills** — `app/services/skill_gallery.py` (source unique interne + v2) : + - **export portable** `{format: "flowdeck-skill", version: 1, skill{name, description, prompt_template, allowed_tools}}` + (aucun id/workspace/auteur local ne fuit) + **import** avec contrôle de collision (`409`, `overwrite: true` pour écraser) + - **galerie de 6 presets** installables : rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document + (`GET /api/v2/skills/gallery`, `POST /api/v2/skills/gallery/{slug}/install`) + - routes jumelles côté session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`, + `POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}` + (comble le trou CRUD : pas de suppression de skill avant) + - **UI branchée** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage automatique du chip +- [x] **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis pour la 1re fois + (seul `agent.run.finished` l'était) ; les 3 sont dans le catalogue `webhook_outbound` → abonnement `agent.*` +- [x] **Docs** — `docs/API_GUIDE_V6.md` (§2.4 + correction du nom d'événement `agent.run.completed` → `agent.run.finished`), + OpenAPI régénéré : `docs/openapi-v2.json` → **427 chemins**, `info.version = 6.6.0` +- [x] **Tests** — `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, run synchrone, + ownership 404, trigger, export/import/validation, galerie + outils valides, routes internes, cycle de vie webhooks) +- [x] **Version** — 6.6.0 · `ruff check app tests` OK · `eslint static/js` 0 problème · **suite 764 verts, 0 skip** + +--- + ## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24) > **Objectif** : passer les synced blocks en « production » (résolution + > propagation fiables partout) et les faire vivre dans les databases/vues — @@ -932,4 +962,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, avancée Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ -*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés) + **v6.4.0** realtime + **v6.3.0** API v2. Aussi: case `sync.py` → Bearer cochée (livrée v6.4.0) + 7 tests webhooks_v2 dé-skipés → **suite 749 verts, 0 skip**. Reste: **SSO/SAML**.* +*Dernière mise à jour: 2026-09-24 — **v6.6.0 Agent phase 5 : API publique agent & skill marketplace COMPLETED** (`/api/v2/agents/*` en Bearer+scopes avec run synchrone JSON + audit/undo, export/import de skills + galerie de 6 presets, entrée « Galerie » branchée dans la palette `/` du panneau, webhooks `agent.run.started`/`failed` enfin émis, OpenAPI régénéré 427 chemins ; 15 tests dédiés) + **v6.5.1** webhooks_v2 (749 verts) + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **suite 764 verts, 0 skip**. Les 5 phases du plan agent sont ✅. Reste: **SSO/SAML**.* diff --git a/VERSION b/VERSION index a194c18..826f5ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.5.1 +6.6.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 09b6786..eafd25c 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,7 +1,7 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄 -> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues) +> **Début**: 2026-07-08 | **Version**: v6.6.0 | **Statut**: EN COURS 🔄 +> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML (design seul) ## Avancement Global @@ -25,6 +25,8 @@ | v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ | | v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ | | **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** | +| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** | +| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** | ## Blocs Complétés @@ -61,5 +63,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen - **BDD**: SQLite WAL mode, 21 tables, foreign keys ON - **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API - **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080 -- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire +- **Tests**: pytest, 764+ tests, TestClient avec SQLite temporaire - **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml) diff --git a/app/main.py b/app/main.py index 2c7b1a8..c9403ec 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.routers import ( workspace, ) from app.routers.api_v2 import router as api_v2_router +from app.routers.api_v2_agent import router as api_v2_agent_router from app.routers.automations import router as automations_router from app.routers.collaboration import router as collaboration_router from app.routers.emoji import router as emoji_router @@ -122,7 +123,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="6.5.1", + version="6.6.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -169,6 +170,7 @@ app.include_router(permissions_router) app.include_router(web_clipper_api_router) app.include_router(web_clipper_router) app.include_router(api_v2_router) +app.include_router(api_v2_agent_router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/routers/agent.py b/app/routers/agent.py index 7c80902..c3c1337 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -14,6 +14,7 @@ from fastapi.responses import StreamingResponse from app.auth.session import get_current_user from app.config import settings from app.db import get_conn +from app.services import skill_gallery from app.services.agent_engine import AgentEngine, undo_action from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_config import ( @@ -524,6 +525,81 @@ async def apply_skill(request: Request, skill_id: int): return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"} +# ── Skill marketplace (v6.6.0, Agent phase 5) ── +# Galerie de presets + export/import portable — même implémentation que +# l'API publique (/api/v2/skills/*), via app.services.skill_gallery. + + +@router.get("/skills/gallery") +async def skills_gallery(request: Request): + presets = skill_gallery.list_gallery() + return {"gallery": presets, "total": len(presets), + "install": "POST /api/agent/skills/gallery/{slug}/install"} + + +@router.post("/skills/gallery/{slug}/install") +async def install_gallery_skill(request: Request, slug: str): + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + preset = skill_gallery.get_gallery(slug) + if not preset: + raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}") + body = await request.json() if request.headers.get("content-type") else {} + try: + row, created = skill_gallery.upsert_skill( + skill_gallery.parse_payload(preset), + workspace_id=ws, created_by=user_id, + overwrite=bool(body.get("overwrite", True)), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"slug": slug, "id": row.get("id"), "name": row.get("name"), + "status": "installed" if created else "updated", "skill": row} + + +@router.post("/skills/import") +async def import_skill(request: Request): + """Importe un skill portable (JSON exporté depuis une autre instance).""" + user_id = await _current_user_id(request) + ws = await _workspace_id(request) + body = await request.json() if request.headers.get("content-type") else {} + payload = body.get("payload") if isinstance(body.get("payload"), dict) else body + try: + fields = skill_gallery.parse_payload(payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + try: + row, created = skill_gallery.upsert_skill( + fields, workspace_id=ws, created_by=user_id, + overwrite=bool(body.get("overwrite")), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"id": row.get("id"), "name": fields["name"], + "status": "imported" if created else "updated", "skill": row} + + +@router.get("/skills/{skill_id}/export") +async def export_skill(request: Request, skill_id: int): + """Document JSON portable — à rejouer sur /api/agent/skills/import.""" + with get_conn() as conn: + row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Skill introuvable") + return skill_gallery.export_skill(row) + + +@router.delete("/skills/{skill_id}") +async def delete_skill(request: Request, skill_id: int): + with get_conn() as conn: + row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Skill introuvable") + conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,)) + conn.commit() + return {"id": skill_id, "status": "deleted"} + + # ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ── diff --git a/app/routers/api_v2_agent.py b/app/routers/api_v2_agent.py new file mode 100644 index 0000000..2180aa9 --- /dev/null +++ b/app/routers/api_v2_agent.py @@ -0,0 +1,657 @@ +"""FlowDeck — Public API v2 : Agent & Skill marketplace (v6.6.0, phase 5). + +Thin Bearer+scopes wrappers over the existing agent logic (AgentEngine, +`agent_skills`, the gallery service) so third-party integrations can drive +FlowDeck Agent without a browser session: + +* ``/api/v2/agents`` — agents CRUD, conversations, synchronous runs (JSON, + the SSE stream stays an internal/UI concern), audit journal & rollback. +* ``/api/v2/skills`` — the skill marketplace: CRUD, portable export/import and + the built-in gallery of installable presets. + +Rules honoured (see docs/API_GUIDE_V6.md): one code path (the engine and the +gallery service are reused, never re-implemented), JSON only, no secrets or +internal columns, rate limit + audit + idempotency on every mutation. +""" +from __future__ import annotations + +import json +import time + +from fastapi import APIRouter, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import get_conn +from app.routers.agent import _default_agent +from app.services import skill_gallery +from app.services.agent_engine import AgentEngine, undo_action +from app.services.api_v2_helpers import ( + audit_log, + check_idempotency, + check_v2_rate_limit, + get_bearer_user, + has_scope, + paginate_headers, + parse_pagination, + row_to_dict, + store_idempotency, +) +from app.services.llm_client import LLMClient +from app.services.llm_config import get_user_llm_key + +router = APIRouter(prefix="/api/v2", tags=["api-v2-agent"]) + + +# ── Shared guards ────────────────────────────────────────────────────────── + +def _guard(request: Request, authorization: str | None, *, write: bool = False) -> dict: + """Bearer auth + per-token rate limit (+ write scope when required).""" + user = get_bearer_user(request, authorization) + ip = request.client.host if request.client else "unknown" + if not check_v2_rate_limit(user.get("_token_hash"), ip): + raise HTTPException(429, "Rate limit exceeded: 300 req/min per token") + if write and not has_scope(user.get("_token_scopes"), "write"): + raise HTTPException(403, "Insufficient scope. Required: write") + return user + + +async def _json_body(request: Request) -> dict: + try: + body = await request.json() + except Exception: # noqa: BLE001 + return {} + return body if isinstance(body, dict) else {} + + +def _workspace_of(request: Request, body: dict | None = None) -> int | None: + """Workspace resolution mirrors the internal agent router: explicit param + wins, then the token's own workspace, else NULL (shared/global scope).""" + body = body or {} + raw = body.get("workspace_id") or request.query_params.get("workspace_id") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _owned_conversation(conn, conversation_id: int, user_id: int): + """Conversation visible to this token's user (ownership is enforced here, + unlike the session router where the browser is already authenticated).""" + return conn.execute( + "SELECT * FROM agent_conversations WHERE id=? AND user_id=?", + (conversation_id, user_id), + ).fetchone() + + +def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) -> AgentEngine: + engine = AgentEngine(user_id, workspace_id=workspace_id) + if provider: + user_key = get_user_llm_key(user_id, provider) + if user_key and user_key.get("api_key"): + engine.llm = LLMClient( + provider=provider, + api_key=user_key["api_key"], + api_base=user_key.get("api_base") or None, + ) + else: + engine.llm = LLMClient(provider=provider) + return engine + + +# ── Agents ───────────────────────────────────────────────────────────────── + +@router.get("/agents") +async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization) + limit, offset = parse_pagination(request) + ws = _workspace_of(request) + with get_conn() as conn: + _default_agent(conn, user["id"]) + clause = "WHERE workspace_id IS ? OR workspace_id=?" + total = conn.execute(f"SELECT COUNT(*) FROM agents {clause}", (ws, ws)).fetchone()[0] + rows = conn.execute( + f"SELECT * FROM agents {clause} ORDER BY agent_type, name LIMIT ? OFFSET ?", + (ws, ws, limit, offset), + ).fetchall() + return JSONResponse( + content={"agents": [row_to_dict(r) for r in rows], "total": total, + "limit": limit, "offset": offset}, + headers=paginate_headers(total), + ) + + +@router.post("/agents") +async def create_agent_v2(request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + body = await _json_body(request) + name = (body.get("name") or "").strip() or "Custom Agent" + ws = _workspace_of(request, body) + with get_conn() as conn: + try: + cur = conn.execute( + """INSERT INTO agents (workspace_id, name, icon, agent_type, description, + system_instructions, model, scope_json, trigger_json, approval_mode, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + (ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"), + body.get("description", ""), body.get("system_instructions", ""), + body.get("model", "gpt-4o"), + json.dumps(body.get("scope", {})), json.dumps(body.get("trigger", {})), + body.get("approval_mode", "auto"), user["id"]), + ) + conn.commit() + agent_id = cur.lastrowid + row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + except Exception as exc: # noqa: BLE001 + raise HTTPException(409, f"Cannot create agent: {exc}") from exc + audit_log(user, "agent.create", "agent", agent_id, name, request) + data = {"id": agent_id, "name": name, "status": "created", "agent": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201) + return JSONResponse(content=data, status_code=201) + + +@router.get("/agents/{agent_id}") +async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): + _guard(request, authorization) + with get_conn() as conn: + row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not row: + raise HTTPException(404, "Agent not found") + return row_to_dict(row) + + +@router.put("/agents/{agent_id}") +async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + body = await _json_body(request) + with get_conn() as conn: + existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not existing: + raise HTTPException(404, "Agent not found") + sets, params = [], [] + for col in ("name", "icon", "description", "system_instructions", "model", + "approval_mode", "is_active"): + if col in body: + sets.append(f"{col}=?") + params.append(body[col]) + if "scope" in body: + sets.append("scope_json=?") + params.append(json.dumps(body["scope"])) + if "trigger" in body: + sets.append("trigger_json=?") + params.append(json.dumps(body["trigger"])) + if sets: + params.append(agent_id) + conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + audit_log(user, "agent.update", "agent", agent_id, "", request) + return {"id": agent_id, "status": "updated"} + + +@router.delete("/agents/{agent_id}") +async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + with get_conn() as conn: + if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone(): + raise HTTPException(404, "Agent not found") + conn.execute("DELETE FROM agents WHERE id=?", (agent_id,)) + conn.commit() + audit_log(user, "agent.delete", "agent", agent_id, "", request) + return {"id": agent_id, "status": "deleted"} + + +# ── Conversations (static paths declared before /agents/{agent_id}) ──────── + +@router.get("/agents/conversations") +async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization) + limit, offset = parse_pagination(request) + with get_conn() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM agent_conversations WHERE user_id=?", (user["id"],) + ).fetchone()[0] + rows = conn.execute( + """SELECT * FROM agent_conversations WHERE user_id=? + ORDER BY updated_at DESC LIMIT ? OFFSET ?""", + (user["id"], limit, offset), + ).fetchall() + return JSONResponse( + content={"conversations": [row_to_dict(r) for r in rows], "total": total, + "limit": limit, "offset": offset}, + headers=paginate_headers(total), + ) + + +@router.post("/agents/conversations") +async def create_conversation_v2(request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + body = await _json_body(request) + ws = _workspace_of(request, body) + agent_id = body.get("agent_id") + with get_conn() as conn: + if agent_id is not None: + agent = conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone() + if not agent: + raise HTTPException(404, "Agent not found") + agent_id = agent["id"] + else: + agent_id = _default_agent(conn, user["id"])["id"] + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model) + VALUES (?,?,?,?,?,?)""", + (agent_id, user["id"], body.get("title") or "New conversation", + json.dumps({"workspace_id": ws}), + body.get("provider") or "", body.get("model") or ""), + ) + conv_id = cur.lastrowid + conn.commit() + row = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conv_id,)).fetchone() + audit_log(user, "agent.conversation.create", "agent_conversation", conv_id, "", request) + data = {"id": conv_id, "status": "created", "conversation": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201) + return JSONResponse(content=data, status_code=201) + + +@router.get("/agents/conversations/{conversation_id}") +async def get_conversation_v2(conversation_id: int, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization) + with get_conn() as conn: + conv = _owned_conversation(conn, conversation_id, user["id"]) + if not conv: + raise HTTPException(404, "Conversation not found") + messages = conn.execute( + "SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id", + (conversation_id,), + ).fetchall() + return {"conversation": row_to_dict(conv), "messages": [row_to_dict(m) for m in messages]} + + +@router.delete("/agents/conversations/{conversation_id}") +async def delete_conversation_v2(conversation_id: int, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + with get_conn() as conn: + if not _owned_conversation(conn, conversation_id, user["id"]): + raise HTTPException(404, "Conversation not found") + conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,)) + conn.commit() + audit_log(user, "agent.conversation.delete", "agent_conversation", conversation_id, "", request) + return {"id": conversation_id, "status": "deleted"} + + +@router.get("/agents/conversations/{conversation_id}/actions") +async def list_actions_v2(conversation_id: int, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization) + with get_conn() as conn: + if not _owned_conversation(conn, conversation_id, user["id"]): + raise HTTPException(404, "Conversation not found") + rows = conn.execute( + "SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id", + (conversation_id,), + ).fetchall() + return {"actions": [row_to_dict(r) for r in rows]} + + +@router.post("/agents/actions/{action_id}/undo") +async def undo_action_v2(action_id: int, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + with get_conn() as conn: + row = conn.execute( + """SELECT a.id FROM agent_actions a + JOIN agent_conversations c ON c.id = a.conversation_id + WHERE a.id=? AND c.user_id=?""", + (action_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Action not found") + try: + undo_action(action_id) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(500, f"Rollback failed: {exc}") from exc + audit_log(user, "agent.action.undo", "agent_action", action_id, "", request) + return {"id": action_id, "status": "reverted"} + + +# ── Runs (JSON — the SSE stream stays internal) ──────────────────────────── + +def _collect_run_events(events: list[dict]) -> dict: + """Aggregate an engine event stream into a JSON run result. + + Engine events are flat (``{"type": "final", "content": ...}``), the same + shape the SSE panel consumes. + """ + final = None + reasoning = [] + actions = [] + error = None + for ev in events: + etype = ev.get("type") + if etype == "final": + final = ev.get("content") or final + elif etype == "reasoning": + reasoning.append(ev.get("content") or "") + elif etype == "action": + actions.append({k: v for k, v in ev.items() if k != "type"}) + elif etype == "error": + error = ev.get("message") or "run failed" + return { + "status": "failed" if error else "completed", + "final": final, + "error": error, + "reasoning": reasoning, + "actions": actions, + } + + +@router.post("/agents/conversations/{conversation_id}/run") +async def run_conversation_v2(conversation_id: int, request: Request, + authorization: str | None = Header(default=None)): + """Synchronous agent run: buffers the engine stream and returns JSON. + + Third parties get one HTTP round-trip instead of an SSE subscription; the + same AgentEngine, permissions, journal and webhooks are used as the UI. + """ + user = _guard(request, authorization, write=True) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + body = await _json_body(request) + objective = (body.get("message") or body.get("objective") or "").strip() + if not objective: + raise HTTPException(400, "message is required") + + with get_conn() as conn: + conv = _owned_conversation(conn, conversation_id, user["id"]) + if not conv: + raise HTTPException(404, "Conversation not found") + eff_provider = body.get("provider") or conv["provider"] or None + eff_model = body.get("model") or conv["model"] or None + if body.get("provider") is not None or body.get("model") is not None: + conn.execute( + "UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (body.get("provider", conv["provider"] or ""), + body.get("model", conv["model"] or ""), conversation_id), + ) + conn.commit() + conv_context = {} + try: + conv_context = json.loads(conv["context_json"] or "{}") or {} + except (TypeError, ValueError): + conv_context = {} + + ws = _workspace_of(request, body) + if ws is None: + ws = conv_context.get("workspace_id") + + engine = _engine_for(user["id"], ws, eff_provider) + started = time.time() + events = [ + ev async for ev in engine.run( + conversation_id, objective, + model=eff_model, + mentions=body.get("mentions"), + files=body.get("files"), + skill_id=body.get("skill_id"), + skill_ids=body.get("skill_ids"), + extra_context=body.get("context"), + ) + ] + result = _collect_run_events(events) + with get_conn() as conn: + actions = conn.execute( + "SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id", + (conversation_id,), + ).fetchall() + payload = { + "conversation_id": conversation_id, + "status": result["status"], + "final": result["final"], + "error": result["error"], + "reasoning": result["reasoning"], + "actions": [row_to_dict(a) for a in actions], + "events": events, + "duration_ms": int((time.time() - started) * 1000), + } + audit_log(user, "agent.run", "agent_conversation", conversation_id, objective[:200], request) + status_code = 200 if result["status"] == "completed" else 500 + data = payload + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, status_code) + return JSONResponse(content=data, status_code=status_code) + + +@router.post("/agents/{agent_id}/trigger") +async def trigger_agent_v2(agent_id: int, request: Request, + authorization: str | None = Header(default=None)): + """Fire a custom agent from an external integration (JSON, synchronous).""" + user = _guard(request, authorization, write=True) + body = await _json_body(request) + ws = _workspace_of(request, body) + with get_conn() as conn: + agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + if not agent: + raise HTTPException(404, "Agent not found") + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent_id, user["id"], f"Run: {agent['name']}", json.dumps({"workspace_id": ws})), + ) + conv_id = cur.lastrowid + conn.commit() + + objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »." + if body.get("message"): + objective = f"{objective}\n\n{body['message']}" + engine = _engine_for(user["id"], ws, agent["model"] or None) + started = time.time() + events = [ev async for ev in engine.run(conv_id, objective, model=agent["model"])] + result = _collect_run_events(events) + payload = { + "conversation_id": conv_id, + "agent_id": agent_id, + "status": result["status"], + "final": result["final"], + "error": result["error"], + "reasoning": result["reasoning"], + "actions": result["actions"], + "duration_ms": int((time.time() - started) * 1000), + } + audit_log(user, "agent.trigger", "agent", agent_id, objective[:200], request) + return JSONResponse(content=payload, status_code=200 if result["status"] == "completed" else 500) + + +# ── Skill marketplace ────────────────────────────────────────────────────── + +@router.get("/skills") +async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)): + _guard(request, authorization) + limit, offset = parse_pagination(request) + ws = _workspace_of(request) + with get_conn() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?", + (ws, ws), + ).fetchone()[0] + rows = conn.execute( + """SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? + ORDER BY name LIMIT ? OFFSET ?""", + (ws, ws, limit, offset), + ).fetchall() + return JSONResponse( + content={"skills": [row_to_dict(r) for r in rows], "total": total, + "limit": limit, "offset": offset}, + headers=paginate_headers(total), + ) + + +@router.post("/skills") +async def create_skill_v2(request: Request, authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + body = await _json_body(request) + ws = _workspace_of(request, body) + try: + fields = skill_gallery.parse_payload( + {k: body[k] for k in ("name", "description", "prompt_template", "allowed_tools") + if k in body} | {"format": skill_gallery.EXPORT_FORMAT} + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + try: + row, created = skill_gallery.upsert_skill( + fields, workspace_id=ws, created_by=user["id"], + overwrite=bool(body.get("overwrite")), + ) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + audit_log(user, "skill.create", "skill", row.get("id"), fields["name"], request) + data = {"id": row.get("id"), "name": fields["name"], + "status": "created" if created else "updated", "skill": row_to_dict(row) if row else {}} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201 if created else 200) + return JSONResponse(content=data, status_code=201 if created else 200) + + +# Gallery & import are static segments: declared before /skills/{skill_id} so +# FastAPI never tries to coerce "gallery" into an int path parameter. +@router.get("/skills/gallery") +async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)): + _guard(request, authorization) + presets = skill_gallery.list_gallery() + return {"gallery": presets, "total": len(presets), + "install": "POST /api/v2/skills/gallery/{slug}/install"} + + +@router.post("/skills/gallery/{slug}/install") +async def install_gallery_skill_v2(slug: str, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + preset = skill_gallery.get_gallery(slug) + if not preset: + raise HTTPException(404, f"Unknown gallery skill: {slug}") + body = await _json_body(request) + ws = _workspace_of(request, body) + try: + row, created = skill_gallery.upsert_skill( + skill_gallery.parse_payload(preset), + workspace_id=ws, created_by=user["id"], + overwrite=bool(body.get("overwrite", True)), + ) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + audit_log(user, "skill.gallery.install", "skill", row.get("id"), slug, request) + data = {"slug": slug, "id": row.get("id"), "name": row.get("name"), + "status": "installed" if created else "updated", "skill": row_to_dict(row)} + return JSONResponse(content=data, status_code=201 if created else 200) + + +@router.post("/skills/import") +async def import_skill_v2(request: Request, authorization: str | None = Header(default=None)): + """Import a portable skill document (from another FlowDeck instance).""" + user = _guard(request, authorization, write=True) + idem = check_idempotency(request, user["id"]) + if idem: + return JSONResponse(content=idem["data"], status_code=idem["status"]) + body = await _json_body(request) + payload = body.get("payload") if isinstance(body.get("payload"), dict) else body + try: + fields = skill_gallery.parse_payload(payload) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + ws = _workspace_of(request, body) + try: + row, created = skill_gallery.upsert_skill( + fields, workspace_id=ws, created_by=user["id"], + overwrite=bool(body.get("overwrite")), + ) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + audit_log(user, "skill.import", "skill", row.get("id"), fields["name"], request) + data = {"id": row.get("id"), "name": fields["name"], + "status": "imported" if created else "updated", "skill": row_to_dict(row)} + key = (request.headers.get("Idempotency-Key") or "").strip() + if key: + store_idempotency(key, user["id"], data, 201 if created else 200) + return JSONResponse(content=data, status_code=201 if created else 200) + + +@router.get("/skills/{skill_id}") +async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)): + _guard(request, authorization) + with get_conn() as conn: + row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not row: + raise HTTPException(404, "Skill not found") + return row_to_dict(row) + + +@router.get("/skills/{skill_id}/export") +async def export_skill_v2(skill_id: int, request: Request, + authorization: str | None = Header(default=None)): + """Portable JSON document — POST it to /api/v2/skills/import elsewhere.""" + _guard(request, authorization) + with get_conn() as conn: + row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not row: + raise HTTPException(404, "Skill not found") + return skill_gallery.export_skill(row) + + +@router.delete("/skills/{skill_id}") +async def delete_skill_v2(skill_id: int, request: Request, + authorization: str | None = Header(default=None)): + user = _guard(request, authorization, write=True) + with get_conn() as conn: + row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not row: + raise HTTPException(404, "Skill not found") + conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,)) + conn.commit() + audit_log(user, "skill.delete", "skill", skill_id, row["name"] or "", request) + return {"id": skill_id, "status": "deleted"} + + +@router.post("/skills/{skill_id}/apply") +async def apply_skill_v2(skill_id: int, request: Request, + authorization: str | None = Header(default=None)): + """Open a conversation pre-loaded with the skill (ready to run).""" + user = _guard(request, authorization, write=True) + body = await _json_body(request) + ws = _workspace_of(request, body) + with get_conn() as conn: + skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + if not skill: + raise HTTPException(404, "Skill not found") + agent_id = _default_agent(conn, user["id"])["id"] + cur = conn.execute( + """INSERT INTO agent_conversations (agent_id, user_id, title, context_json) + VALUES (?,?,?,?)""", + (agent_id, user["id"], skill["name"], + json.dumps({"workspace_id": ws if ws is not None else skill["workspace_id"], + "skill_id": skill_id})), + ) + conv_id = cur.lastrowid + conn.commit() + audit_log(user, "skill.apply", "skill", skill_id, skill["name"], request) + return JSONResponse(content={"conversation_id": conv_id, "skill": skill["name"], + "status": "ready"}, status_code=201) diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index f97cf96..e619e73 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -27,6 +27,20 @@ logger = logging.getLogger(__name__) MAX_ITERATIONS = 12 + +async def _fire_agent_webhook(event: str, payload: dict) -> None: + """Dispatch an outbound agent lifecycle event (never raises). + + Lifecycle: ``agent.run.started`` → ``agent.run.finished`` | ``agent.run.failed``. + All three are in the webhook_outbound catalogue, so integrations can subscribe + to `agent.*` and drive FlowDeck Agent from outside (Agent phase 5). + """ + try: + from app.services.webhook_outbound import fire_event as _fire_wh + await _fire_wh(event, payload) + except Exception: # noqa: BLE001 + logger.debug("%s webhook dispatch failed", event) + # Compact in-app guide so the LLM can answer « comment faire… ? » questions even # when no document is attached to the conversation (generic help / onboarding). APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? ») @@ -151,6 +165,11 @@ class AgentEngine: self._persist_message(conversation_id, "user", objective) self._update_conversation(conversation_id, status="running") + await _fire_agent_webhook("agent.run.started", { + "conversation_id": conversation_id, + "objective": objective[:500], + "model": model or "", + }) # Update the history title right away (before the run finishes) and # refine it once we have the final answer (_autotitle below). @@ -260,19 +279,20 @@ class AgentEngine: model=used_model, tokens=self._tokens) await self._autotitle(conversation_id, objective, final_text) # v6.4.0: emit agent.run.finished (outbound webhooks only). - try: - from app.services.webhook_outbound import fire_event as _fire_wh - await _fire_wh("agent.run.finished", { - "conversation_id": conversation_id, - "objective": objective[:500], - "model": used_model, - "tokens": self._tokens, - }) - except Exception: # noqa: BLE001 - logger.debug("agent.run.finished webhook dispatch failed") + await _fire_agent_webhook("agent.run.finished", { + "conversation_id": conversation_id, + "objective": objective[:500], + "model": used_model, + "tokens": self._tokens, + }) except Exception as exc: # noqa: BLE001 logger.exception("AgentEngine run failed") + await _fire_agent_webhook("agent.run.failed", { + "conversation_id": conversation_id, + "objective": objective[:500], + "error": str(exc)[:500], + }) yield self._event("error", {"message": f"Erreur interne: {exc}"}) finally: self._update_conversation(conversation_id, status="idle") diff --git a/app/services/skill_gallery.py b/app/services/skill_gallery.py new file mode 100644 index 0000000..74393e4 --- /dev/null +++ b/app/services/skill_gallery.py @@ -0,0 +1,231 @@ +"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »). + +Single source of truth for shareable agent skills: + +* **Gallery** — built-in presets shipped with FlowDeck, installable into any + workspace with one call (the self-hosted equivalent of a skill marketplace). +* **Portable payloads** — a skill exports to a versioned JSON document that any + other FlowDeck instance can re-import unchanged. + +Both the internal router (``/api/agent/skills/*``, session cookie) and the +public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so +there is exactly one implementation of export/import/install. +""" +from __future__ import annotations + +import json +from typing import Any + +from app.db import get_conn + +EXPORT_FORMAT = "flowdeck-skill" +EXPORT_VERSION = 1 + +# ── Gallery presets ──────────────────────────────────────────────────────── +# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set), +# so an installed preset can never expose a tool that does not exist. + +GALLERY: dict[str, dict] = { + "rapport-hebdo": { + "name": "Rapport hebdo", + "icon": "📊", + "description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.", + "prompt_template": ( + "Rédige le rapport hebdomadaire de l'équipe.\n" + "1. Repère les pages et documents modifiés cette semaine (search_workspace).\n" + "2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n" + "3. Crée un document « Rapport hebdo — » (create_document) structuré ainsi : " + "Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n" + "Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition." + ), + "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"], + }, + "compte-rendu-reunion": { + "name": "Compte rendu de réunion", + "icon": "📝", + "description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.", + "prompt_template": ( + "À partir de la note de réunion fournie (ou demandée) :\n" + "1. Crée un document « CR — » (create_document).\n" + "2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n" + "3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants." + ), + "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"], + }, + "base-crm": { + "name": "Base CRM", + "icon": "🗂️", + "description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.", + "prompt_template": ( + "Crée une base CRM complète :\n" + "1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), " + "Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n" + "2. Vue board groupée sur Statut (create_view).\n" + "3. Trois lignes d'exemple réalistes (create_page).\n" + "Termine par le lien/identifiant de la collection créée." + ), + "allowed_tools": ["create_collection", "add_property", "create_view", "create_page"], + }, + "okr": { + "name": "Objectifs OKR", + "icon": "🎯", + "description": "Génère une base d'OKR avec objectifs, résultats clés et progression.", + "prompt_template": ( + "Crée une base « OKR » :\n" + "1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), " + "Progression (nombre 0-100).\n" + "2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n" + "3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n" + "4. Vue board par période (create_view)." + ), + "allowed_tools": [ + "create_collection", "add_property", "add_relation", + "create_view", "create_page", "create_sub_item", + ], + }, + "analyse-repo": { + "name": "Analyse repo Gitea", + "icon": "🛠️", + "description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.", + "prompt_template": ( + "Analyse le dépôt Gitea du workspace :\n" + "1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n" + "2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n" + "3. Crée un document « Suivi repo — » (create_document) avec un tableau des issues " + "et 3 recommandations de priorisation." + ), + "allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"], + }, + "resume-document": { + "name": "Résumé de document", + "icon": "📄", + "description": "Résume un document long en une page : points clés, chiffres, décisions.", + "prompt_template": ( + "Résume le document fourni (ou demandé) :\n" + "1. Lis-le intégralement (read_document).\n" + "2. Crée un document « Résumé — » (create_document) : 5 points clés, chiffres marquants, " + "décisions/engagements, questions restées ouvertes.\n" + "3. Maximum une page, phrases courtes, aucune reformulation qui change le sens." + ), + "allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"], + }, +} + + +# ── Gallery access ───────────────────────────────────────────────────────── + +def list_gallery() -> list[dict]: + """Return every preset with its slug, ready for API responses.""" + return [{"slug": slug, **preset} for slug, preset in GALLERY.items()] + + +def get_gallery(slug: str) -> dict | None: + preset = GALLERY.get(str(slug or "").strip().lower()) + return {"slug": slug, **preset} if preset else None + + +# ── Portable payload (export / import) ───────────────────────────────────── + +def export_skill(row: Any) -> dict: + """Build the portable JSON document for a stored skill row. + + Only shareable fields are included: ids, workspace and author stay local so + an import never leaks (nor depends on) the source instance's internals. + """ + skill = row if isinstance(row, dict) else dict(row) + tools = skill.get("allowed_tools_json") or "[]" + if isinstance(tools, str): + try: + tools = json.loads(tools) + except (TypeError, ValueError): + tools = [] + return { + "format": EXPORT_FORMAT, + "version": EXPORT_VERSION, + "skill": { + "name": skill.get("name") or "", + "description": skill.get("description") or "", + "prompt_template": skill.get("prompt_template") or "", + "allowed_tools": list(tools) if isinstance(tools, list) else [], + }, + } + + +def parse_payload(payload: Any) -> dict: + """Validate an import payload → normalized skill fields. + + Accepts a full exported document (``{format, version, skill}``) or a bare + skill object (``{name, prompt_template, ...}``) for hand-written imports. + Raises ``ValueError`` with a human-readable message on invalid input. + """ + if not isinstance(payload, dict): + raise ValueError("Payload JSON attendu (objet)") + skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload + if payload.get("format") and payload.get("format") != EXPORT_FORMAT: + raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})") + version = payload.get("version") + if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION): + raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})") + + name = str(skill.get("name") or "").strip() + prompt = str(skill.get("prompt_template") or "").strip() + if not name: + raise ValueError("name est requis") + if not prompt: + raise ValueError("prompt_template est requis") + tools = skill.get("allowed_tools") or [] + if isinstance(tools, str): + try: + tools = json.loads(tools) + except (TypeError, ValueError) as exc: + raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc + if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools): + raise ValueError("allowed_tools doit être une liste de noms d'outils") + + return { + "name": name[:120], + "description": str(skill.get("description") or "")[:500], + "prompt_template": prompt, + "allowed_tools": tools, + } + + +def upsert_skill( + fields: dict, + *, + workspace_id: int | None, + created_by: int | None, + overwrite: bool = False, +) -> tuple[dict, bool]: + """Insert (or replace) a skill in the current workspace. + + Returns ``(row, created)``. Raises ``ValueError`` when the name already + exists and ``overwrite`` is False — callers translate that to HTTP 409. + """ + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?", + (workspace_id, fields["name"]), + ).fetchone() + if existing and not overwrite: + raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}") + tools_json = json.dumps(fields["allowed_tools"]) + if existing: + conn.execute( + "UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?", + (fields["description"], fields["prompt_template"], tools_json, existing["id"]), + ) + skill_id = existing["id"] + else: + cur = conn.execute( + """INSERT INTO agent_skills + (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) + VALUES (?,?,?,?,?,?)""", + (workspace_id, fields["name"], fields["description"], + fields["prompt_template"], tools_json, created_by), + ) + skill_id = cur.lastrowid + conn.commit() + row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() + data = dict(row) if row else {"id": skill_id} + return data, existing is None diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index bb00fc1..eb3239c 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -500,7 +500,7 @@ allProviders: [], providers: [], providerModels: [], llmProvider: 'offline', llmModel: '', llmKeys: [], defaultProvider: 'offline', activeContext: null, ctxPinnedKey: null, - contextChips: [], skills: [], + contextChips: [], skills: [], gallery: [], mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false, mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null, cmdFocus: -1, slashDismiss: false, @@ -811,6 +811,42 @@ fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){ self.skills = d.skills || []; }).catch(function(){ self.skills = []; }); + // Galerie de skills (marketplace, v6.6.0) — presets installables en 1 clic. + fetch('/api/agent/skills/gallery').then(function(r){return r.json()}).then(function(d){ + var installed = {}; + (self.skills || []).forEach(function(s){ installed[String(s.name).toLowerCase()] = true; }); + self.gallery = (d.gallery || []).filter(function(g){ + return !installed[String(g.name).toLowerCase()]; + }); + }).catch(function(){ self.gallery = []; }); + }, + // Installe un preset de galerie puis l'épingle au contexte (phase 5). + installGallerySkill(slug, icon, name){ + var self = this; + fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' + }).then(function(r){ + return r.json().then(function(d){ return {ok: r.ok, d: d}; }); + }).then(function(res){ + if(!res.ok){ + self.toast((res.d && res.d.detail) || 'Installation impossible.', true); + return; + } + self.toast('Skill « ' + (res.d.name || name || slug) + ' » installée.'); + self.fetchSkills(); + self._pinDbSkill(res.d.id, res.d.name || name || slug, res.d.icon || icon || '📦'); + }).catch(function(){ self.toast('Installation impossible (réseau).', true); }); + }, + // Épingle un skill enregistré (id DB) dans les chips de contexte. + _pinDbSkill(id, name, icon){ + var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === id; }); + if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); return; } + var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨', + label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id}; + this.contextChips.push(pin); + this.clearComposer(); + this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'}); + this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.'); }, loadConversations(){ var self = this; @@ -1254,7 +1290,11 @@ items.forEach(function(o){ o._i = seq++; out.push(o); }); } var skillsPresent = false, admPresent = false; - var built = [], dbs = [], adm = []; + var built = [], dbs = [], adm = [], gal = []; + var installedNames = {}; + for(var j=0;j<(this.skills||[]).length;j++){ + installedNames[String(this.skills[j].name).toLowerCase()] = true; + } for(var i=0;i **Statut** : ✅ **IMPLÉMENTÉ (v6.3.0, 2026-09-21)** — l'API publique `/api/v2` est livrée : > routeur `app/routers/api_v2.py`, helpers `app/services/api_v2_helpers.py`, migration 20, -> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json` — 402 chemins), 24 tests dédiés. +> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json`), 24 tests dédiés. +> **v6.6.0 (2026-09-24)** — **Agent phase 5** : wrappers agent (`/api/v2/agents/*`) + +> marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique +> partagée `app/services/skill_gallery.py`, OpenAPI régénéré (**427 chemins**), 15 tests dédiés +> (`tests/test_v66_agent_api.py`). Voir §2.4. > Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception. -> **Dernière mise à jour** : 2026-09-21 +> **Dernière mise à jour** : 2026-09-24 > **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation. > > **Reste reporté (v6.4)** : webhooks HMAC `X-FlowDeck-Signature` + retry 2s/10s/60s + événements étendus ; @@ -208,6 +212,53 @@ POST /api/move, /col-mapping (GET/DELETE/POST), /board-config/{owner}/{repo} (GE - Pas de pagination, filtres, tri. - Pas de documentation OpenAPI en production (`docs_url` n'est actif qu'en DEBUG). +### 2.4 API publique v2 — Agent & Skill marketplace (`app/routers/api_v2_agent.py`, v6.6.0) + +> **Agent phase 5 « Plateforme »** : permettre à une intégration tierce de piloter +> FlowDeck Agent et d'installer/partager des skills, sans session navigateur. + +**Agents, conversations & runs** +| Méthode | Route | Scope | Description | +|---------|-------|-------|-------------| +| GET | `/api/v2/agents` | read | Liste les agents (pagination `limit`/`offset`) | +| POST | `/api/v2/agents` | write | Crée un agent (`name`, `system_instructions`, `model`, `scope`, `trigger`) | +| GET/PUT/DELETE | `/api/v2/agents/{id}` | read / write | Détail, mise à jour, suppression | +| GET | `/api/v2/agents/conversations` | read | Conversations **de l'utilisateur du token** | +| POST | `/api/v2/agents/conversations` | write | Crée une conversation (`agent_id` optionnel) | +| GET/DELETE | `/api/v2/agents/conversations/{id}` | read / write | Conversation + messages / suppression | +| POST | `/api/v2/agents/conversations/{id}/run` | write | **Run synchrone JSON** (le flux SSE reste interne) | +| GET | `/api/v2/agents/conversations/{id}/actions` | read | Journal d'audit (`agent_actions`, snapshots) | +| POST | `/api/v2/agents/actions/{id}/undo` | write | Rollback d'une action | +| POST | `/api/v2/agents/{id}/trigger` | write | Déclenche un agent custom (JSON, synchrone) | + +Réponse de `run` : `{conversation_id, status: completed|failed, final, error, reasoning[], actions[], events[], duration_ms}` — +HTTP 500 quand `status = failed`. Les événements `events[]` sont ceux du flux SSE +(`reasoning`, `action`, `final`, `notice`, `error`), à plat : `{"type": "final", "content": ...}`. + +**Skill marketplace** +| Méthode | Route | Scope | Description | +|---------|-------|-------|-------------| +| GET/POST | `/api/v2/skills` | read / write | Liste / création (`409` si nom déjà présent) | +| GET/DELETE | `/api/v2/skills/{id}` | read / write | Détail / suppression | +| GET | `/api/v2/skills/{id}/export` | read | **Document portable** `{format, version, skill{...}}` | +| POST | `/api/v2/skills/import` | write | Import (identique → `409`, `overwrite: true` → maj) | +| GET | `/api/v2/skills/gallery` | read | Presets embarqués (6) | +| POST | `/api/v2/skills/gallery/{slug}/install` | write | Installe un preset dans le workspace | +| POST | `/api/v2/skills/{id}/apply` | write | Ouvre une conversation préchargée du skill | + +Mêmes endpoints (session cookie) côté interne : `/api/agent/skills/gallery`, +`/api/agent/skills/{id}/export`, `/api/agent/skills/import`, `DELETE /api/agent/skills/{id}` — +**même implémentation** via `app/services/skill_gallery.py`. + +**Règles spécifiques agent** +1. La **propriété** des conversations est vérifiée (`user_id` du token) : un token ne voit + jamais les conversations d'un autre utilisateur (`404`, pas `403`, pour ne pas fuiter). +2. Le run est **synchrone** : le moteur (`AgentEngine`), l'ACL (`PermissionManager`), le journal + `agent_actions` et les webhooks sont exactement ceux de l'UI — aucun second chemin. +3. Cycle de vie webhook : `agent.run.started` → `agent.run.finished` | `agent.run.failed` + (catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible). +4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …). + --- ## 3. Conventions cibles pour l'API v2 @@ -606,7 +657,7 @@ EVENTS = [ "mention.created", "notification.created", "sprint.created", "sprint.updated", "sprint.completed", "share.created", "share.revoked", "page.published", "page.unpublished", -"agent.run.completed", +"agent.run.started", "agent.run.finished", "agent.run.failed", ``` **Payload type** : diff --git a/docs/Flowdeck_Agent_integration.md b/docs/Flowdeck_Agent_integration.md index 0830239..d12d056 100644 --- a/docs/Flowdeck_Agent_integration.md +++ b/docs/Flowdeck_Agent_integration.md @@ -663,7 +663,16 @@ Phase 5 — Custom Agents 1. Scope + trigger_json 2. Scheduler (lifespan FastAPI) 3. Déclencheurs webhook (via webhooks.py) - 4. Mode approbation (auto/confirm) + 4. Mode approbation (auto/confirm) ✅ (v4.10.0) + +> **Note numérotation** — ce document suit l'ordre historique d'implémentation. +> Le plan officiel à 5 phases de `ROADMAP.md` se lit : phase 3 « Outils avancés » ✅ v4.10.0 · +> phase 4 « Autonomie » (agents, skills, déclencheurs) ✅ v4.10.0 · +> **phase 5 « Plateforme » ✅ v6.6.0** — API publique `/api/v2/agents/*` (run synchrone JSON, +> journal + rollback, `trigger` externe), marketplace de skills (export/import portable, +> galerie de 6 presets, section « Galerie » dans la palette `/` du panneau) et webhooks de cycle de vie +> `agent.run.started` → `agent.run.finished` | `agent.run.failed`. +> Référence : `docs/API_GUIDE_V6.md` §2.4 · tests : `tests/test_v66_agent_api.py`. ------------------------- diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index b15c7d6..06654b5 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "6.3.0" + "version": "6.6.0" }, "paths": { "/auth/register": { @@ -6509,6 +6509,47 @@ } } }, + "/db/pages/{page_id}/open/api": { + "get": { + "tags": [ + "collections" + ], + "summary": "Open Row Page Api", + "description": "v6.5.0 — content page of a database row (lazy-created).\n\nAny DB view (table/board/gallery/list/calendar) opens a row through\nthis endpoint: it returns the shadow ``pages`` id whose full page\neditor carries the row's block content (synced blocks included).", + "operationId": "open_row_page_api_db_pages__page_id__open_api_get", + "parameters": [ + { + "name": "page_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Page Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/db/boards/api": { "get": { "tags": [ @@ -11647,7 +11688,7 @@ "sharing" ], "summary": "Share Page", - "description": "Invite a user or email to a page.", + "description": "Invite a user, an email, or a group to a page.", "operationId": "share_page_api_pages__page_id__share_post", "parameters": [ { @@ -12901,6 +12942,26 @@ } } }, + "/api/realtime/stats": { + "get": { + "tags": [ + "realtime" + ], + "summary": "Realtime Stats", + "description": "Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.\n\nRéservé aux utilisateurs authentifiés (données d'activité internes).", + "operationId": "realtime_stats_api_realtime_stats_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/api/agent": { "get": { "tags": [ @@ -13347,6 +13408,166 @@ } } }, + "/api/agent/skills/gallery": { + "get": { + "tags": [ + "agent" + ], + "summary": "Skills Gallery", + "operationId": "skills_gallery_api_agent_skills_gallery_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/agent/skills/gallery/{slug}/install": { + "post": { + "tags": [ + "agent" + ], + "summary": "Install Gallery Skill", + "operationId": "install_gallery_skill_api_agent_skills_gallery__slug__install_post", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Slug" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/skills/import": { + "post": { + "tags": [ + "agent" + ], + "summary": "Import Skill", + "description": "Importe un skill portable (JSON exporté depuis une autre instance).", + "operationId": "import_skill_api_agent_skills_import_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/agent/skills/{skill_id}/export": { + "get": { + "tags": [ + "agent" + ], + "summary": "Export Skill", + "description": "Document JSON portable — à rejouer sur /api/agent/skills/import.", + "operationId": "export_skill_api_agent_skills__skill_id__export_get", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/skills/{skill_id}": { + "delete": { + "tags": [ + "agent" + ], + "summary": "Delete Skill", + "operationId": "delete_skill_api_agent_skills__skill_id__delete", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/agent/mentions": { "get": { "tags": [ @@ -14096,6 +14317,22 @@ "type": "integer", "title": "Workspace Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } } ], "responses": { @@ -14128,6 +14365,24 @@ "summary": "Sync Batch", "description": "Apply a batch of offline mutations and return per-mutation results.", "operationId": "sync_batch_api_v2_sync_batch_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -14136,6 +14391,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -14157,6 +14422,22 @@ "type": "integer", "title": "Workspace Id" } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } } ], "responses": { @@ -21381,6 +21662,54 @@ } } }, + "/api/v2/webhooks/events": { + "get": { + "tags": [ + "api-v2" + ], + "summary": "List Webhook Events V2", + "description": "Catalogue of deliverable events (+ wildcard syntax).", + "operationId": "list_webhook_events_v2_api_v2_webhooks_events_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v2/webhooks": { "get": { "tags": [ @@ -21656,6 +21985,1278 @@ "title": "Webhook Id" } }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/webhooks/{webhook_id}/retry": { + "post": { + "tags": [ + "api-v2" + ], + "summary": "Retry Webhook Deliveries", + "description": "Manually retry failed deliveries for a webhook.", + "operationId": "retry_webhook_deliveries_api_v2_webhooks__webhook_id__retry_post", + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Webhook Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/webhooks/verify-signature": { + "post": { + "tags": [ + "api-v2" + ], + "summary": "Verify Webhook Signature", + "description": "Verify a webhook signature (for debugging/testing).", + "operationId": "verify_webhook_signature_api_v2_webhooks_verify_signature_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "List Agents V2", + "operationId": "list_agents_v2_api_v2_agents_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Create Agent V2", + "operationId": "create_agent_v2_api_v2_agents_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/{agent_id}": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "Get Agent V2", + "operationId": "get_agent_v2_api_v2_agents__agent_id__get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "api-v2-agent" + ], + "summary": "Update Agent V2", + "operationId": "update_agent_v2_api_v2_agents__agent_id__put", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "api-v2-agent" + ], + "summary": "Delete Agent V2", + "operationId": "delete_agent_v2_api_v2_agents__agent_id__delete", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/conversations": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "List Conversations V2", + "operationId": "list_conversations_v2_api_v2_agents_conversations_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Create Conversation V2", + "operationId": "create_conversation_v2_api_v2_agents_conversations_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/conversations/{conversation_id}": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "Get Conversation V2", + "operationId": "get_conversation_v2_api_v2_agents_conversations__conversation_id__get", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Conversation Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "api-v2-agent" + ], + "summary": "Delete Conversation V2", + "operationId": "delete_conversation_v2_api_v2_agents_conversations__conversation_id__delete", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Conversation Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/conversations/{conversation_id}/actions": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "List Actions V2", + "operationId": "list_actions_v2_api_v2_agents_conversations__conversation_id__actions_get", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Conversation Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/actions/{action_id}/undo": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Undo Action V2", + "operationId": "undo_action_v2_api_v2_agents_actions__action_id__undo_post", + "parameters": [ + { + "name": "action_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Action Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/conversations/{conversation_id}/run": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Run Conversation V2", + "description": "Synchronous agent run: buffers the engine stream and returns JSON.\n\nThird parties get one HTTP round-trip instead of an SSE subscription; the\nsame AgentEngine, permissions, journal and webhooks are used as the UI.", + "operationId": "run_conversation_v2_api_v2_agents_conversations__conversation_id__run_post", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Conversation Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/agents/{agent_id}/trigger": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Trigger Agent V2", + "description": "Fire a custom agent from an external integration (JSON, synchronous).", + "operationId": "trigger_agent_v2_api_v2_agents__agent_id__trigger_post", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Agent Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "List Skills V2", + "operationId": "list_skills_v2_api_v2_skills_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Create Skill V2", + "operationId": "create_skill_v2_api_v2_skills_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/gallery": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "Skills Gallery V2", + "operationId": "skills_gallery_v2_api_v2_skills_gallery_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/gallery/{slug}/install": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Install Gallery Skill V2", + "operationId": "install_gallery_skill_v2_api_v2_skills_gallery__slug__install_post", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Slug" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/import": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Import Skill V2", + "description": "Import a portable skill document (from another FlowDeck instance).", + "operationId": "import_skill_v2_api_v2_skills_import_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/{skill_id}": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "Get Skill V2", + "operationId": "get_skill_v2_api_v2_skills__skill_id__get", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "api-v2-agent" + ], + "summary": "Delete Skill V2", + "operationId": "delete_skill_v2_api_v2_skills__skill_id__delete", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/{skill_id}/export": { + "get": { + "tags": [ + "api-v2-agent" + ], + "summary": "Export Skill V2", + "description": "Portable JSON document — POST it to /api/v2/skills/import elsewhere.", + "operationId": "export_skill_v2_api_v2_skills__skill_id__export_get", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/skills/{skill_id}/apply": { + "post": { + "tags": [ + "api-v2-agent" + ], + "summary": "Apply Skill V2", + "description": "Open a conversation pre-loaded with the skill (ready to run).", + "operationId": "apply_skill_v2_api_v2_skills__skill_id__apply_post", + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Skill Id" + } + }, { "name": "authorization", "in": "header", diff --git a/tests/test_v66_agent_api.py b/tests/test_v66_agent_api.py new file mode 100644 index 0000000..af6a97c --- /dev/null +++ b/tests/test_v66_agent_api.py @@ -0,0 +1,445 @@ +"""FlowDeck — v6.6.0 : Agent phase 5 (API publique agent + skill marketplace). + +Covers the Bearer+scopes wrappers under ``/api/v2/agents`` and +``/api/v2/skills``, the portable skill export/import + gallery install, the +internal (session) marketplace routes, ownership checks, and the agent run +lifecycle webhooks (``agent.run.started`` / ``finished`` / ``failed``). +""" +from __future__ import annotations + +import os +import tempfile + +import pytest + +from app.services import skill_gallery +from app.services.webhook_outbound import EVENTS + +# ── Fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def client(): + """Fresh SQLite DB + offline (mock) LLM — no network, no shared state.""" + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network + + from app.config import settings + from app.db import get_conn, init_db + from app.main import app + from app.password_utils import hash_password + + settings.database_url = f"sqlite:///{db_path}" + settings.llm_provider = "offline" + settings.agent_max_iterations = 12 + settings.agent_max_tokens_budget = 500_000 + settings.agent_run_timeout_seconds = 30 + + init_db() + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " + "VALUES ('admin', 'Admin', '', ?, 1)", + (hash_password("test"),), + ) + conn.commit() + + from fastapi.testclient import TestClient + yield TestClient(app) + + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def _token(client, login: str, scopes: str = "read,write") -> dict: + """Register an account (opens a session) → legacy token → scoped v2 token.""" + r = client.post("/auth/register", json={ + "email": f"{login}@test.dev", "password": "secret123", "name": login, + }) + assert r.status_code == 200, r.text + legacy = client.post("/api/v1/token").json()["token"] + r = client.post("/api/v2/tokens", json={"name": "phase5", "scopes": scopes}, + headers={"Authorization": f"Bearer {legacy}"}) + assert r.status_code == 200, r.text + return {"Authorization": f"Bearer {r.json()['token']}"} + + +# ── Auth & scopes ─────────────────────────────────────────────────────────── + +def test_v2_agents_requires_bearer(client): + r = client.get("/api/v2/agents") + assert r.status_code == 401 + assert r.headers["content-type"].startswith("application/problem+json") + assert r.json()["status"] == 401 + + +def test_v2_skills_rejects_bad_token(client): + r = client.get("/api/v2/skills", headers={"Authorization": "Bearer nope"}) + assert r.status_code == 401 + + +def test_v2_agent_write_requires_write_scope(client): + headers = _token(client, "readonly", scopes="read") + assert client.get("/api/v2/agents", headers=headers).status_code == 200 + r = client.post("/api/v2/agents", json={"name": "Nope"}, headers=headers) + assert r.status_code == 403 + assert "write" in r.json()["detail"] + + +# ── Agents CRUD ───────────────────────────────────────────────────────────── + +def test_v2_agents_crud(client): + headers = _token(client, "agentcrud") + + r = client.post("/api/v2/agents", json={ + "name": "Analyste", "description": "Synthèses", "model": "gpt-4o", + "system_instructions": "Sois concis.", "scope": {"tools": ["search_workspace"]}, + }, headers=headers) + assert r.status_code == 201, r.text + agent_id = r.json()["id"] + scope = r.json()["agent"]["scope_json"] + assert scope == {"tools": ["search_workspace"]} or scope == '{"tools": ["search_workspace"]}' + + listed = client.get("/api/v2/agents", headers=headers).json() + assert any(a["id"] == agent_id for a in listed["agents"]) + assert listed["total"] >= 1 + + got = client.get(f"/api/v2/agents/{agent_id}", headers=headers) + assert got.status_code == 200 + assert got.json()["name"] == "Analyste" + assert "password_hash" not in got.text + + upd = client.put(f"/api/v2/agents/{agent_id}", + json={"description": "V2", "approval_mode": "confirm"}, + headers=headers) + assert upd.status_code == 200 + assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).json()["description"] == "V2" + + assert client.get("/api/v2/agents/999999", headers=headers).status_code == 404 + assert client.delete(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 200 + assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 404 + + +def test_v2_agent_idempotency(client): + headers = _token(client, "agentidem") + idem = {"Idempotency-Key": "agent-create-1"} + r1 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem}) + r2 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem}) + assert r1.status_code == 201 + assert r2.status_code == r1.status_code + assert r1.json()["id"] == r2.json()["id"] + with_id = [a for a in client.get("/api/v2/agents", headers=headers).json()["agents"] + if a["name"] == "Idem"] + assert len(with_id) == 1, "idempotent replay must not create a second agent" + + +# ── Conversations, run & audit ────────────────────────────────────────────── + +def test_v2_conversation_and_sync_run(client): + headers = _token(client, "runner") + + r = client.post("/api/v2/agents/conversations", json={"title": "Run test"}, + headers=headers) + assert r.status_code == 201, r.text + conv_id = r.json()["id"] + + bad = client.post(f"/api/v2/agents/conversations/{conv_id}/run", + json={}, headers=headers) + assert bad.status_code == 400 + + r = client.post(f"/api/v2/agents/conversations/{conv_id}/run", + json={"message": "Crée une collection CRM"}, headers=headers) + assert r.status_code == 200, r.text + run = r.json() + assert run["conversation_id"] == conv_id + assert run["status"] == "completed" + assert run["final"], "offline mock must yield a final answer" + assert isinstance(run["events"], list) and run["events"] + + detail = client.get(f"/api/v2/agents/conversations/{conv_id}", headers=headers).json() + roles = [m["role"] for m in detail["messages"]] + assert "user" in roles and "assistant" in roles + + # Audit journal + rollback surface exposed to integrations + actions = client.get(f"/api/v2/agents/conversations/{conv_id}/actions", + headers=headers) + assert actions.status_code == 200 + assert isinstance(actions.json()["actions"], list) + + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT action FROM api_audit_log WHERE resource_id=? ORDER BY id", + (str(conv_id),), + ).fetchall() + assert "agent.run" in [r_[0] for r_ in rows] + + +def test_v2_conversation_ownership(client): + alice = _token(client, "alice") + bob = _token(client, "bob") + + conv_id = client.post("/api/v2/agents/conversations", json={"title": "Privé"}, + headers=alice).json()["id"] + + # Bob sees nothing of Alice's conversation (and gets 404, not 403 leakage). + assert client.get(f"/api/v2/agents/conversations/{conv_id}", + headers=bob).status_code == 404 + assert client.post(f"/api/v2/agents/conversations/{conv_id}/run", + json={"message": "hack"}, headers=bob).status_code == 404 + assert client.get(f"/api/v2/agents/conversations/{conv_id}/actions", + headers=bob).status_code == 404 + assert client.delete(f"/api/v2/agents/conversations/{conv_id}", + headers=bob).status_code == 404 + + assert client.get(f"/api/v2/agents/conversations/{conv_id}", + headers=alice).status_code == 200 + + +def test_v2_trigger_agent(client): + headers = _token(client, "trigger") + agent_id = client.post("/api/v2/agents", json={ + "name": "Déclenché", "system_instructions": "Crée un document de statut.", + }, headers=headers).json()["id"] + + r = client.post(f"/api/v2/agents/{agent_id}/trigger", json={}, headers=headers) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["agent_id"] == agent_id + assert payload["conversation_id"] > 0 + assert payload["status"] == "completed" + assert payload["final"] + + +def test_v2_conversation_delete(client): + headers = _token(client, "deleter") + conv_id = client.post("/api/v2/agents/conversations", json={"title": "Bye"}, + headers=headers).json()["id"] + assert client.delete(f"/api/v2/agents/conversations/{conv_id}", + headers=headers).status_code == 200 + assert client.get(f"/api/v2/agents/conversations/{conv_id}", + headers=headers).status_code == 404 + + +# ── Skill marketplace ─────────────────────────────────────────────────────── + +def test_v2_skills_crud_and_scopes(client): + headers = _token(client, "skillcrud") + + r = client.post("/api/v2/skills", json={ + "name": "Veille", "description": "Surveille un sujet", + "prompt_template": "Cherche les infos sur le sujet et résume.", + "allowed_tools": ["search_workspace"], + }, headers=headers) + assert r.status_code == 201, r.text + skill_id = r.json()["id"] + + listed = client.get("/api/v2/skills", headers=headers).json() + assert any(s["id"] == skill_id for s in listed["skills"]) + + got = client.get(f"/api/v2/skills/{skill_id}", headers=headers) + assert got.status_code == 200 + tools = got.json()["allowed_tools_json"] + assert tools == ["search_workspace"] or tools == '["search_workspace"]' + + dup = client.post("/api/v2/skills", json={ + "name": "Veille", "prompt_template": "autre", + }, headers=headers) + assert dup.status_code == 409 + + assert client.delete(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 200 + assert client.get(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 404 + + +def test_v2_skill_export_import_roundtrip(client): + headers = _token(client, "porter") + + created = client.post("/api/v2/skills", json={ + "name": "Rapport CRM", "description": "d", "prompt_template": "fais le rapport", + "allowed_tools": ["read_document", "create_document"], + }, headers=headers).json() + + export = client.get(f"/api/v2/skills/{created['id']}/export", headers=headers) + assert export.status_code == 200 + doc = export.json() + assert doc["format"] == skill_gallery.EXPORT_FORMAT + assert doc["version"] == skill_gallery.EXPORT_VERSION + assert doc["skill"]["name"] == "Rapport CRM" + assert doc["skill"]["allowed_tools"] == ["read_document", "create_document"] + # Portable = no instance internals leak + assert "id" not in doc["skill"] and "workspace_id" not in doc["skill"] + assert "created_by" not in doc["skill"] + + # Same instance, different name → import as-is would clash → 409 first + clash = client.post("/api/v2/skills/import", json=doc, headers=headers) + assert clash.status_code == 409 + + # Renamed import succeeds, overwrite updates the existing one + doc["skill"]["name"] = "Rapport CRM (copie)" + imp = client.post("/api/v2/skills/import", json=doc, headers=headers) + assert imp.status_code == 201, imp.text + assert imp.json()["skill"]["prompt_template"] == "fais le rapport" + + doc["skill"]["prompt_template"] = "version 2" + upd = client.post("/api/v2/skills/import", json={**doc, "overwrite": True}, + headers=headers) + assert upd.status_code in (200, 201), upd.text + reimported = client.get(f"/api/v2/skills/{imp.json()['id']}", headers=headers).json() + assert reimported["prompt_template"] == "version 2" + + +def test_v2_skill_import_validation(client): + headers = _token(client, "validator") + + assert client.post("/api/v2/skills/import", json={"nope": True}, + headers=headers).status_code == 400 + assert client.post("/api/v2/skills/import", + json={"format": "not-flowdeck", "skill": {"name": "x", + "prompt_template": "y"}}, + headers=headers).status_code == 400 + assert client.post("/api/v2/skills/import", + json={"format": skill_gallery.EXPORT_FORMAT, "version": 99, + "skill": {"name": "x", "prompt_template": "y"}}, + headers=headers).status_code == 400 + assert client.post("/api/v2/skills/import", + json={"name": "sans outils", "prompt_template": "", + "allowed_tools": "not-a-list"}, + headers=headers).status_code == 400 + + +def test_v2_gallery_install(client): + headers = _token(client, "galery") + + gallery = client.get("/api/v2/skills/gallery", headers=headers) + assert gallery.status_code == 200 + presets = gallery.json()["gallery"] + assert len(presets) >= 6 + slugs = [p["slug"] for p in presets] + assert "rapport-hebdo" in slugs and "base-crm" in slugs + for p in presets: + assert p["prompt_template"], f"preset {p['slug']} has no prompt" + for tool in p["allowed_tools"]: + assert tool in _known_tools(), f"preset {p['slug']} uses unknown tool {tool}" + + r = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={}, + headers=headers) + assert r.status_code == 201, r.text + assert r.json()["status"] == "installed" + skill_id = r.json()["id"] + + # Installed preset shows up in the list, and can be applied + names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]] + assert "Rapport hebdo" in names + applied = client.post(f"/api/v2/skills/{skill_id}/apply", json={}, headers=headers) + assert applied.status_code == 201 + conv = client.get(f"/api/v2/agents/conversations/{applied.json()['conversation_id']}", + headers=headers) + assert conv.status_code == 200 + + # Re-install updates instead of duplicating + again = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={}, + headers=headers) + assert again.status_code in (200, 201) + names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]] + assert names.count("Rapport hebdo") == 1 + + assert client.post("/api/v2/skills/gallery/does-not-exist/install", json={}, + headers=headers).status_code == 404 + + +def _known_tools() -> set[str]: + from app.services.tool_registry import ToolRegistry + return set(ToolRegistry().tools.keys()) + + +# ── Internal (session) marketplace routes ─────────────────────────────────── + +def test_internal_gallery_and_skill_lifecycle(client): + gallery = client.get("/api/agent/skills/gallery") + assert gallery.status_code == 200 + assert gallery.json()["total"] >= 6 + + installed = client.post("/api/agent/skills/gallery/base-crm/install", json={}) + assert installed.status_code == 200, installed.text + skill_id = installed.json()["id"] + + export = client.get(f"/api/agent/skills/{skill_id}/export") + assert export.status_code == 200 + assert export.json()["format"] == skill_gallery.EXPORT_FORMAT + + doc = export.json() + doc["skill"]["name"] = "Base CRM (import)" + imp = client.post("/api/agent/skills/import", json=doc) + assert imp.status_code == 200, imp.text + assert imp.json()["name"] == "Base CRM (import)" + + assert client.post("/api/agent/skills/gallery/nope/install", + json={}).status_code == 404 + assert client.delete(f"/api/agent/skills/{skill_id}").status_code == 200 + assert client.get(f"/api/agent/skills/{skill_id}/export").status_code == 404 + + +# ── Agent run lifecycle webhooks ──────────────────────────────────────────── + +def test_agent_run_lifecycle_webhooks(client, monkeypatch): + """started → finished on success, started → failed on LLM error.""" + from app.db import get_conn + from app.services import webhook_outbound + + fired: list[tuple[str, dict]] = [] + + async def record(event, payload): + fired.append((event, dict(payload))) + + monkeypatch.setattr(webhook_outbound, "fire_event", record) + + headers = _token(client, "hooks") + conv_id = client.post("/api/v2/agents/conversations", json={"title": "Hooks"}, + headers=headers).json()["id"] + + r = client.post(f"/api/v2/agents/conversations/{conv_id}/run", + json={"message": "Crée une page de notes"}, headers=headers) + assert r.status_code == 200, r.text + events = [e for e, _ in fired] + assert "agent.run.started" in events + assert "agent.run.finished" in events + assert events.index("agent.run.started") < events.index("agent.run.finished") + for event in events: + assert event in EVENTS, f"{event} must be in the webhook catalogue" + + # Failure path: the LLM blows up → started + failed, never finished. + fired.clear() + + async def boom(self, *args, **kwargs): + raise RuntimeError("llm down") + + from app.services.llm_client import LLMClient + monkeypatch.setattr(LLMClient, "complete", boom) + + conv2 = client.post("/api/v2/agents/conversations", json={"title": "KO"}, + headers=headers).json()["id"] + r = client.post(f"/api/v2/agents/conversations/{conv2}/run", + json={"message": "ça va planter"}, headers=headers) + assert r.status_code == 500 + assert r.json()["status"] == "failed" + events = [e for e, _ in fired] + assert "agent.run.started" in events + assert "agent.run.failed" in events + assert "agent.run.finished" not in events + failed_payload = next(p for e, p in fired if e == "agent.run.failed") + assert failed_payload["conversation_id"] == conv2 + assert "llm down" in failed_payload["error"] + + # Conversation status must be released (finally block) for both paths. + with get_conn() as conn: + rows = conn.execute( + "SELECT status FROM agent_conversations WHERE id IN (?, ?)", (conv_id, conv2) + ).fetchall() + assert {r_["status"] for r_ in rows} == {"idle"}