Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9562f30366 |
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
# 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
|
## 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
|
> Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+35
-5
@@ -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)
|
||||||
@@ -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) + **v6.4.0** realtime + **v6.3.0** API v2. Aussi: case `sync.py` → Bearer cochée (livrée v6.4.0) + 7 tests webhooks_v2 dé-skipés → **suite 749 verts, 0 skip**. Reste: **SSO/SAML**.*
|
*Dernière mise à jour: 2026-09-24 — **v6.6.0 Agent phase 5 : API publique agent & skill marketplace COMPLETED** (`/api/v2/agents/*` en Bearer+scopes avec run synchrone JSON + audit/undo, export/import de skills + galerie de 6 presets, entrée « Galerie » branchée dans la palette `/` du panneau, webhooks `agent.run.started`/`failed` enfin émis, OpenAPI régénéré 427 chemins ; 15 tests dédiés) + **v6.5.1** webhooks_v2 (749 verts) + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **suite 764 verts, 0 skip**. Les 5 phases du plan agent sont ✅. Reste: **SSO/SAML**.*
|
||||||
|
|||||||
+5
-3
@@ -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
@@ -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.1",
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 👍 / 👎) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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** :
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||||
Reference in New Issue
Block a user