feat: v6.6.0 — Agent phase 5 : API publique agent (/api/v2/agents, run synchrone JSON) + marketplace skills (export/import portable + galerie de 6 presets, palette / du panneau) + webhooks agent.run.started/failed · 764 tests verts
FlowDeck CI / docker (push) Successful in 1m21s
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 11m5s

This commit is contained in:
2026-09-24 10:16:24 -04:00
parent 6dfd6d718e
commit 9562f30366
16 changed files with 3262 additions and 42 deletions
+1
View File
@@ -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
+43
View File
@@ -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
+5 -3
View File
@@ -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
+35 -5
View File
@@ -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**.*
+1 -1
View File
@@ -1 +1 @@
6.5.1
6.6.0
+5 -3
View File
@@ -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)
+3 -1
View File
@@ -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")
+76
View File
@@ -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 👍 / 👎) ──
+657
View File
@@ -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)
+30 -10
View File
@@ -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")
+231
View File
@@ -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 — <date> » (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 — <titre> » (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 — <dépôt> » (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é — <titre> » (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
+63 -13
View File
@@ -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<FD_SKILLS.length;i++){
if(qmatch(FD_SKILLS[i].cmd)){
var s = FD_SKILLS[i];
@@ -1267,6 +1307,15 @@
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
}
}
// Galerie (marketplace) — presets pas encore installés.
for(i=0;i<(this.gallery||[]).length;i++){
var g = this.gallery[i];
if(installedNames[String(g.name).toLowerCase()]) continue;
if(qmatch(g.slug)){
gal.push({key:'gal-'+g.slug, cmd:'/'+g.slug, desc:(g.description || 'Skill galerie'),
icon:(g.icon || '📦'), _type:'gallery', slug:g.slug, skillName:g.name});
}
}
for(i=0;i<FD_ADMIN_CMDS.length;i++){
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
}
@@ -1276,7 +1325,11 @@
out.push({_sep:'Skills', key:'sep-skills'});
pushItems(built.concat(dbs));
}
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
if(gal.length){
out.push({_sep:'Galerie', key:'sep-gal'});
pushItems(gal);
}
if((skillsPresent || gal.length) && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
if(admPresent) pushItems(adm);
this._slashSeq = seq;
return out;
@@ -1302,17 +1355,14 @@
this._focusComposer();
return;
}
if(c._type === 'gallery'){
this.installGallerySkill(c.slug, c.icon, c.skillName);
this.cmdFocus = -1; this.slashDismiss = false;
this._focusComposer();
return;
}
if(c._type === 'db'){
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else {
var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId};
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.');
}
this._pinDbSkill(c.skillId, c.cmd, c.icon);
} else {
var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-');
var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; });
+54 -3
View File
@@ -2,9 +2,13 @@
> **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** :
+10 -1
View File
@@ -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`.
-------------------------
+1603 -2
View File
File diff suppressed because it is too large Load Diff
+445
View File
@@ -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"}