Compare commits

...
Author SHA1 Message Date
bruno 9562f30366 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
2026-09-24 10:16:24 -04:00
bruno 6dfd6d718e feat: v6.5.1 — 7 tests webhooks_v2 dé-skipés (0 skip, 749 verts) + roadmap rattrapée (sync.py Bearer coché)
FlowDeck CI / docker (push) Successful in 1m20s
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m59s
2026-09-24 09:10:08 -04:00
bruno 401d0b17ca merge: feat/v6.5.0-synced-db → main (v6.5.0 Synced blocks production)
FlowDeck CI / docker (push) Successful in 1m21s
FlowDeck CI / lint (push) Successful in 1m28s
FlowDeck CI / test (push) Successful in 11m0s
2026-09-24 08:28:33 -04:00
17 changed files with 3504 additions and 77 deletions
+1
View File
@@ -1709,5 +1709,6 @@ docker compose restart flowdeck
- **Base de données avancée** — Relations inter-collections, rollups - **Base de données avancée** — Relations inter-collections, rollups
- **Kanban flexible** — Colonnes custom, WIP limits - **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 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 - **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling - **PostgreSQL** — Migration optionnelle pour scaling
+62
View File
@@ -1,5 +1,67 @@
# Changelog - FlowDeck # 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
> v5.15.0) sont désormais réellement câblés, et le ROADMAP rattrapé sur ce
> qui avait été livré entre-temps.
### Added
- **7 tests d'intégration webhooks** (`tests/test_webhooks_v2.py`) : retry échec→succès (journal `retrying→retrying→delivered`), échec après `MAX_ATTEMPTS` (4 POST + `failed` final), `fire_event` ignore les events inconnus, aucun abonné → aucun appel, fan-out wildcard (`page.*` reçoit, `collection.created` non), flow complet signé bout en bout (HMAC vérifié sur le body reçu + journal `delivered`), `retry_due_deliveries` (row `retrying` périmée → re-fire → `delivered`, original `superseded`)
- **Fixture `db` isolée** — SQLite temporaire par test (env + singleton `settings` restaurés, safe `-n auto`), tables webhook créées par `init_db()` ; `httpx.MockTransport` injecté via stub module + `RETRY_DELAYS=(0,0,0)` (zéro sleep réel)
### Changed
- **ROADMAP** — case « migration `sync.py` vers Bearer » cochée (livrée v6.4.0 : Bearer-first + repli session PWA) ; ligne v5.15.0 mise à jour ; résumé final → « Reste: SSO/SAML » ; **suite 749 verts, 0 skip**
### Tests
- `pytest -n auto` → **749 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues) ## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
> Les synced blocks passent en production et existent enfin dans les databases : > Les synced blocks passent en production et existent enfin dans les databases :
+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. 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 ## Quick Start
@@ -49,8 +49,10 @@ docker compose up -d
- **CSV Import/Export** - **CSV Import/Export**
- **Public Sharing**: lien de partage lecture seule - **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 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) - **API publique v1**: `/api/v1` (lecture seule, compat)
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2) - **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot) - **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
@@ -87,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
## Tests ## Tests
```bash ```bash
python3 -m pytest tests/ -v # 725/725 passent python3 -m pytest tests/ -v # 764/764 passent (0 skip)
``` ```
## Roadmap ## Roadmap
+37 -7
View File
@@ -404,11 +404,11 @@ app/
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport - **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
#### Plan de migration (5 phases) #### 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) 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) 3. **Phase 3 — Outils avancés** ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés 4. **Phase 4 — Autonomie** ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills 5. **Phase 5 — Plateforme** ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills
#### Limitations (v1) #### Limitations (v1)
- Pas de modification concurrente (lock optimiste DB) - Pas de modification concurrente (lock optimiste DB)
@@ -702,7 +702,7 @@ Détails livrés :
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés - [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.) - [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs) - [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration) - [x] **Tests** — 21 dédiés (`tests/test_webhooks_v2.py`) ; **7 tests d'intégration câblés** (retry échec→succès, échec après MAX_ATTEMPTS, event inconnu ignoré, aucun abonné, fan-out wildcard, flow complet signé bout en bout, `retry_due_deliveries` superseded→delivered) — fixture `db` = SQLite isolée par test + `httpx.MockTransport` + `RETRY_DELAYS` neutralisé ; suite complète **749 verts, 0 skip**
--- ---
@@ -806,7 +806,7 @@ Détails livrés :
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin - [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`) - [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt) - [x] *(reporté, livré v6.4.0)* : migration de `sync.py` vers Bearer — auth `Authorization: Bearer` (scopes read/write) via `get_bearer_user()` + repli cookie session conservé pour compat PWA offline (`app/routers/sync.py`)
#### Phase 8 — OpenAPI, tests & docs ✅ #### Phase 8 — OpenAPI, tests & docs ✅
@@ -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) ## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
> **Objectif** : passer les synced blocks en « production » (résolution + > **Objectif** : passer les synced blocks en « production » (résolution +
> propagation fiables partout) et les faire vivre dans les databases/vues — > 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 DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ 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, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.* *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.0 6.6.0
+5 -3
View File
@@ -1,7 +1,7 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v6.6.0 | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues) > **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML (design seul)
## Avancement Global ## Avancement Global
@@ -25,6 +25,8 @@
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ | | 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.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.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 ## 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 - **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API - **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080 - **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) - **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
+3 -1
View File
@@ -39,6 +39,7 @@ from app.routers import (
workspace, workspace,
) )
from app.routers.api_v2 import router as api_v2_router 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.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router from app.routers.emoji import router as emoji_router
@@ -122,7 +123,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="6.5.0", version="6.6.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -169,6 +170,7 @@ app.include_router(permissions_router)
app.include_router(web_clipper_api_router) app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router) app.include_router(web_clipper_router)
app.include_router(api_v2_router) app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.mount("/static", StaticFiles(directory="static"), name="static") 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.auth.session import get_current_user
from app.config import settings from app.config import settings
from app.db import get_conn from app.db import get_conn
from app.services import skill_gallery
from app.services.agent_engine import AgentEngine, undo_action from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import ( 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"} 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 👍 / 👎) ── # ── 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 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 # Compact in-app guide so the LLM can answer « comment faire… ? » questions even
# when no document is attached to the conversation (generic help / onboarding). # when no document is attached to the conversation (generic help / onboarding).
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? ») 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._persist_message(conversation_id, "user", objective)
self._update_conversation(conversation_id, status="running") 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 # Update the history title right away (before the run finishes) and
# refine it once we have the final answer (_autotitle below). # refine it once we have the final answer (_autotitle below).
@@ -260,19 +279,20 @@ class AgentEngine:
model=used_model, tokens=self._tokens) model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text) await self._autotitle(conversation_id, objective, final_text)
# v6.4.0: emit agent.run.finished (outbound webhooks only). # v6.4.0: emit agent.run.finished (outbound webhooks only).
try: await _fire_agent_webhook("agent.run.finished", {
from app.services.webhook_outbound import fire_event as _fire_wh "conversation_id": conversation_id,
await _fire_wh("agent.run.finished", { "objective": objective[:500],
"conversation_id": conversation_id, "model": used_model,
"objective": objective[:500], "tokens": self._tokens,
"model": used_model, })
"tokens": self._tokens,
})
except Exception: # noqa: BLE001
logger.debug("agent.run.finished webhook dispatch failed")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("AgentEngine run failed") 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}"}) yield self._event("error", {"message": f"Erreur interne: {exc}"})
finally: finally:
self._update_conversation(conversation_id, status="idle") 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', allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline', llmModel: '', llmKeys: [], defaultProvider: 'offline',
activeContext: null, ctxPinnedKey: null, activeContext: null, ctxPinnedKey: null,
contextChips: [], skills: [], contextChips: [], skills: [], gallery: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false, mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null, mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
cmdFocus: -1, slashDismiss: false, cmdFocus: -1, slashDismiss: false,
@@ -811,6 +811,42 @@
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){ fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
self.skills = d.skills || []; self.skills = d.skills || [];
}).catch(function(){ self.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(){ loadConversations(){
var self = this; var self = this;
@@ -1254,7 +1290,11 @@
items.forEach(function(o){ o._i = seq++; out.push(o); }); items.forEach(function(o){ o._i = seq++; out.push(o); });
} }
var skillsPresent = false, admPresent = false; 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++){ for(var i=0;i<FD_SKILLS.length;i++){
if(qmatch(FD_SKILLS[i].cmd)){ if(qmatch(FD_SKILLS[i].cmd)){
var s = FD_SKILLS[i]; 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}); 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++){ 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}); 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'}); out.push({_sep:'Skills', key:'sep-skills'});
pushItems(built.concat(dbs)); 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); if(admPresent) pushItems(adm);
this._slashSeq = seq; this._slashSeq = seq;
return out; return out;
@@ -1302,17 +1355,14 @@
this._focusComposer(); this._focusComposer();
return; 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'){ if(c._type === 'db'){
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; }); this._pinDbSkill(c.skillId, c.cmd, c.icon);
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.');
}
} else { } else {
var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-'); 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; }); 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 : > **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, > 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. > 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. > **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 ; > **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 pagination, filtres, tri.
- Pas de documentation OpenAPI en production (`docs_url` n'est actif qu'en DEBUG). - 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 ## 3. Conventions cibles pour l'API v2
@@ -606,7 +657,7 @@ EVENTS = [
"mention.created", "notification.created", "mention.created", "notification.created",
"sprint.created", "sprint.updated", "sprint.completed", "sprint.created", "sprint.updated", "sprint.completed",
"share.created", "share.revoked", "page.published", "page.unpublished", "share.created", "share.revoked", "page.published", "page.unpublished",
"agent.run.completed", "agent.run.started", "agent.run.finished", "agent.run.failed",
``` ```
**Payload type** : **Payload type** :
+10 -1
View File
@@ -663,7 +663,16 @@ Phase 5 — Custom Agents
1. Scope + trigger_json 1. Scope + trigger_json
2. Scheduler (lifespan FastAPI) 2. Scheduler (lifespan FastAPI)
3. Déclencheurs webhook (via webhooks.py) 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"}
+221 -33
View File
@@ -1,18 +1,26 @@
"""FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events).""" """FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events)."""
from __future__ import annotations from __future__ import annotations
import json
import os
import tempfile
import time
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx import httpx
import pytest import pytest
from app.db import get_conn, init_db
from app.services import webhook_outbound
from app.services.webhook_outbound import ( from app.services.webhook_outbound import (
EVENTS, EVENTS,
MAX_ATTEMPTS, MAX_ATTEMPTS,
RETRY_DELAYS, RETRY_DELAYS,
_deliver_once, _deliver_once,
_event_matches, _event_matches,
init_webhook_tables, deliver_to_sub,
fire_event,
retry_due_deliveries,
sign_payload, sign_payload,
verify_signature, verify_signature,
) )
@@ -21,9 +29,38 @@ from app.services.webhook_outbound import (
@pytest.fixture @pytest.fixture
def db(): def db():
"""Ensure webhook tables exist.""" """Fresh isolated SQLite database with the full FlowDeck schema.
init_webhook_tables()
return True Creates ``webhook_subscriptions`` + ``webhook_deliveries`` (versioned
migrations) so delivery journal rows can be asserted, then restores the
previous settings — safe under ``pytest -n auto``.
"""
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = tmp.name
tmp.close()
prev_env = os.environ.get("DATABASE_URL")
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
import app.config
settings_obj = app.config.settings
prev_url = settings_obj.database_url
settings_obj.database_url = f"sqlite:///{db_path}"
init_db()
yield
settings_obj.database_url = prev_url
if prev_env is None:
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = prev_env
for suffix in ("", "-wal", "-shm"):
try:
os.unlink(db_path + suffix)
except (PermissionError, OSError):
pass
@pytest.fixture @pytest.fixture
@@ -280,53 +317,204 @@ async def test_deliver_once_request_error():
assert "Request error" in error assert "Request error" in error
# ── Integration Tests (Skipped if dependencies are missing) ──────────────────── # ── Integration Tests (isolated DB + mocked HTTP) ───────────────────────────
def _add_sub(url: str = "https://receiver.example/hook", event: str = "*",
secret: str = "test_secret") -> int:
"""Insert an active subscription and return its id."""
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret, active) VALUES (?, ?, ?, 1)",
(url, event, secret),
)
conn.commit()
return cur.lastrowid
def _journal() -> list[dict]:
"""All delivery-journal rows, oldest first."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM webhook_deliveries ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
def _patch_async_client(monkeypatch, handler) -> None:
"""Route every ``httpx.AsyncClient`` built by webhook_outbound to a MockTransport."""
real_client = httpx.AsyncClient
transport = httpx.MockTransport(handler)
def factory(*args, **kwargs):
kwargs.pop("transport", None)
return real_client(transport=transport, **kwargs)
from types import SimpleNamespace
stub = SimpleNamespace(
AsyncClient=factory,
TimeoutException=httpx.TimeoutException,
RequestError=httpx.RequestError,
)
monkeypatch.setattr(webhook_outbound, "httpx", stub)
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliver_to_sub_retries_on_failure(): async def test_deliver_to_sub_retries_on_failure(db, monkeypatch):
"""Test that deliver_to_sub retries on failure.""" """Two failures then success → 3 attempts, journal retrying→retrying→delivered."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0)) # no real sleeps
sub_id = _add_sub(url="https://receiver.example/retry", event="page.created")
mock_client = AsyncMock()
mock_client.post.side_effect = [
MagicMock(status_code=500),
MagicMock(status_code=500),
MagicMock(status_code=200),
]
ok = await deliver_to_sub(
sub_id, "https://receiver.example/retry", "page.created",
{"page_id": 1}, "test_secret", _client=mock_client,
)
assert ok is True
assert mock_client.post.call_count == 3
assert [r["status"] for r in _journal()] == ["retrying", "retrying", "delivered"]
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliver_to_sub_fails_after_max_attempts(): async def test_deliver_to_sub_fails_after_max_attempts(db, monkeypatch):
"""Test that deliver_to_sub fails after MAX_ATTEMPTS.""" """Persistent 500 → exactly MAX_ATTEMPTS attempts, final journal row 'failed'."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
sub_id = _add_sub(url="https://receiver.example/down")
mock_client = AsyncMock()
mock_client.post.return_value = MagicMock(status_code=500)
ok = await deliver_to_sub(
sub_id, "https://receiver.example/down", "page.created",
{}, "test_secret", _client=mock_client,
)
assert ok is False
assert mock_client.post.call_count == MAX_ATTEMPTS
journal = _journal()
assert len(journal) == MAX_ATTEMPTS # 3 'retrying' + 1 'failed'
assert journal[-1]["status"] == "failed"
assert journal[-1]["http_code"] == 500
assert journal[-1]["attempt"] == MAX_ATTEMPTS - 1
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_ignores_unknown_events(): async def test_fire_event_ignores_unknown_events(db, monkeypatch):
"""Test that fire_event ignores unknown events.""" """fire_event returns before touching subscribers/HTTP for unknown events."""
pass delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
_add_sub(url="https://receiver.example/any", event="*")
await fire_event("nope.not_an_event", {"x": 1})
delivered.assert_not_awaited()
assert _journal() == []
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_no_subscribers(): async def test_fire_event_no_subscribers(db, monkeypatch):
"""Test that fire_event does nothing if no subscribers.""" """Valid event but empty webhook_subscriptions → no delivery attempt."""
pass delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
await fire_event("page.created", {"page_id": 1})
delivered.assert_not_awaited()
assert _journal() == []
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fire_event_delivers_to_subscribers(): async def test_fire_event_delivers_to_subscribers(db, monkeypatch):
"""Test that fire_event delivers to matching subscribers.""" """Wildcard fan-out: only the subscription matching page.* receives the event."""
pass matching = _add_sub(url="https://receiver.example/pages", event="page.*")
_add_sub(url="https://receiver.example/collections", event="collection.created")
delivered = AsyncMock()
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
await fire_event("page.created", {"page_id": 42})
delivered.assert_awaited_once()
args = delivered.await_args.args
assert args[0] == matching
assert args[1] == "https://receiver.example/pages"
assert args[2] == "page.created"
assert args[3] == {"page_id": 42}
assert args[4] == "test_secret"
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_webhook_flow(): async def test_full_webhook_flow(db, monkeypatch):
"""Test a full webhook flow: event fired → delivered to subscriber.""" """End-to-end: subscription → fire_event → signed HTTP POST → 'delivered' journal."""
pass secret = "flow_secret_abc"
sub_id = _add_sub(url="https://receiver.example/hook", event="page.*", secret=secret)
seen: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["headers"] = dict(request.headers)
seen["body"] = bytes(request.content)
return httpx.Response(200, json={"ok": True})
_patch_async_client(monkeypatch, handler)
await fire_event("page.created", {"page_id": 7, "title": "Hello"})
# HTTP assertions: right URL, event header, verifiable HMAC over the raw body
assert seen["url"] == "https://receiver.example/hook"
assert seen["headers"]["x-flowdeck-event"] == "page.created"
assert verify_signature(secret, seen["body"],
seen["headers"]["x-flowdeck-signature"]) is True
payload = json.loads(seen["body"])
assert payload["event"] == "page.created"
assert payload["page_id"] == 7
assert payload["title"] == "Hello"
# Journal assertions
journal = _journal()
assert len(journal) == 1
assert journal[0]["webhook_id"] == sub_id
assert journal[0]["status"] == "delivered"
assert journal[0]["http_code"] == 200
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_due_deliveries(): async def test_retry_due_deliveries(db, monkeypatch):
"""Test retry_due_deliveries retries failed deliveries.""" """A 'retrying' row past next_retry_at is re-fired, delivered, then superseded."""
pass monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
sub_id = _add_sub(url="https://receiver.example/retry-due", event="page.*")
with get_conn() as conn:
conn.execute(
"""INSERT INTO webhook_deliveries
(webhook_id, event, status, http_code, error, duration_ms,
attempt, payload, next_retry_at)
VALUES (?, 'page.created', 'retrying', 500, 'HTTP 500', 12, 0, ?, ?)""",
(sub_id, json.dumps({"page_id": 9}), time.time() - 1),
)
conn.commit()
hits: list = []
def handler(request: httpx.Request) -> httpx.Response:
hits.append(request)
return httpx.Response(200)
_patch_async_client(monkeypatch, handler)
retried = await retry_due_deliveries(now=time.time())
assert retried == 1
assert len(hits) == 1
assert hits[0].headers["x-flowdeck-event"] == "page.created"
journal = _journal()
assert len(journal) == 2
assert journal[0]["status"] == "superseded" # original due row
assert journal[1]["status"] == "delivered" # new attempt
assert journal[1]["http_code"] == 200