Skip to main content

gradatum_core/
job.rs

1//! Types primitifs pour le système de jobs (v0.2.0+ — ARCH-D15).
2//!
3//! Ce module définit les types canoniques L0 utilisés par toute la couche
4//! job infrastructure. Il appartient à `gradatum-core` pour permettre aux
5//! crates de niveaux supérieurs (`gradatum-queue`, `gradatum-worker`,
6//! `gradatum-db-sqlite`) d'en dépendre sans cycle.
7//!
8//! # Couches architecturales
9//!
10//! ```text
11//! gradatum-core (L0) — Job, JobRecord, QueueStore, QueueEvent, DryRunAware
12//!     ↑
13//! gradatum-db-sqlite (L2) — SqliteQueueStore impl QueueStore
14//!     ↑
15//! gradatum-queue (L3)     — GradatumQueue facade (Apalis backend)
16//!     ↑
17//! gradatum-worker (L4)    — handlers Apalis + orchestration
18//! ```
19//!
20//! # Ordre bincode — IMMUABLE
21//!
22//! Les variants de [`Job`] sont encodés par position par `bincode`.
23//! **Ne jamais réordonner les variants existants.** Ajouter uniquement en fin
24//! de liste. Violation = corruption silencieuse des jobs stockés en base.
25//!
26//! # Références
27//!
28//! - Decision ARCH-D15 : `docs/decisions/ARCH-D15-apalis-embedded.md`
29
30#![allow(dead_code)] // types consommés progressivement selon le pipeline
31
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use std::time::Duration;
35use tokio::sync::broadcast::Receiver;
36use ulid::Ulid;
37
38// ─────────────────────────────────────────────────────────────────────────────
39// VaultScope — alias canonique
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// Scope d'un job ou d'une requête vault.
43///
44/// `VaultScope` est l'alias de type pour [`JobScope`] — les deux noms coexistent
45/// pour maintenir la cohérence avec le code vault existant (`VaultScope`) et le
46/// nouveau système de jobs (`JobScope`).
47pub type VaultScope = JobScope;
48
49// ─────────────────────────────────────────────────────────────────────────────
50// Job enum — ordre bincode figé (v55)
51// ─────────────────────────────────────────────────────────────────────────────
52
53/// Type de job soumis à la queue.
54///
55/// # Ordre bincode — IMMUABLE
56///
57/// Les variants sont encodés par position (0-20). Ne jamais réordonner.
58/// Ajouter uniquement en fin de liste.
59///
60/// Les commentaires de position `(N)` sont informatifs — bincode encode par
61/// ordre de déclaration Rust, pas par valeur numérique explicite.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "type", content = "data")]
64pub enum Job {
65    // System jobs (0-12) — automatiques
66    /// ReAct loop F-04 — position bincode 0.
67    Agent,
68    /// Step `[[pipelines]]` F-52 — position bincode 1.
69    Pipeline,
70    /// Web crawler F-20 — position bincode 2.
71    Collect,
72    /// Semantic/Learn/Peer/Rationale F-22 — position bincode 3.
73    Distill,
74    /// Sauvegarde vault — position bincode 4.
75    Backup,
76    /// Lifecycle purge (suppression des notes Garbage) — position bincode 5.
77    ///
78    /// Variant unit→tuple : position 5 INCHANGÉE. Les jobs Purge existants en queue
79    /// doivent être absents avant déploiement.
80    Purge(PurgeSpec),
81    /// FtsOnly/VectorsOnly/Full/MissingOnly F-15 — position bincode 6.
82    ReIndex(ReIndexMode),
83    /// Zone B compression F-30 — position bincode 7.
84    Summarize,
85    /// Memory Validation + healing F-43 — position bincode 8.
86    Validate,
87    /// Vault score + dédup F-51 — position bincode 9.
88    Audit,
89    /// Modèles mentaux F-49 — position bincode 10.
90    Consolidate,
91    /// Inbox/ classification F-42 — position bincode 11.
92    Curate(CurateSpec),
93    /// Oubli sémantique de notes (forget) — position bincode 12.
94    ///
95    /// Variant unit→tuple : position 12 INCHANGÉE. Les jobs Forget existants en queue
96    /// doivent être absents avant déploiement.
97    Forget(ForgetSpec),
98
99    // Human jobs (13-16) — JobClass::Human requis
100    /// Valide/rejette un lot de notes `needs-review` — position bincode 13.
101    Review,
102    /// Classifie manuellement une note `inbox/` non résolue — position bincode 14.
103    Classify,
104    /// Fusionne deux notes dupliquées (post `Job::Audit`) — position bincode 15.
105    Merge,
106    /// Enrichit les métadonnées d'un lot de notes — position bincode 16.
107    Annotate,
108
109    // Nouveaux variants v59 — ajoutés EN FIN (ordre bincode figé)
110    /// Import predecessor v1.6.2 → Gradatum · `JobClass::Human` — position bincode 17.
111    Migrate(MigrateSource),
112    /// CSV/PDF/JSON depuis notes · `JobClass::Agent|Human` — position bincode 18.
113    Export(ExportSource),
114    /// Notification externe cascade · `JobClass::System` — position bincode 19.
115    Notify(NotifySource),
116    /// Ingestion document F-06 via queue · `JobClass::Agent|Human` — position bincode 20.
117    Ingest(IngestSource),
118
119    // Embed : ajouté EN FIN pour préserver l'ordre bincode des variants 0-20
120    /// Génération d'embedding vectoriel — position bincode 21.
121    Embed(EmbedSpec),
122}
123
124// ─────────────────────────────────────────────────────────────────────────────
125// ReIndexMode
126// ─────────────────────────────────────────────────────────────────────────────
127
128/// Mode de réindexation pour [`Job::ReIndex`].
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub enum ReIndexMode {
131    /// Rebuild FTS5 + PageRank (défaut).
132    FtsOnly,
133    /// Recalcule tous les embeddings (après migration modèle).
134    VectorsOnly,
135    /// FtsOnly + VectorsOnly.
136    Full,
137    /// Embed uniquement les notes sans vecteur (nouvelles notes).
138    ///
139    /// Plus rapide que `VectorsOnly` sur grand vault actif.
140    MissingOnly,
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// Source structs — variants actifs
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Spécification d'un job de curation (`Job::Curate`).
148///
149/// Classification `inbox/`, scoring, mise à jour des métadonnées.
150///
151/// ## Champs write optionnels
152///
153/// Les champs `title`, `body`, `author`, `tags`, `section_hint` sont portés
154/// directement dans `CurateSpec` pour le path `vault_write → job_store`.
155/// Ils sont optionnels (`#[serde(default)]`) — rétrocompatibles avec les
156/// `JobRecord` existants (champ absent en JSON → `None`/`[]`).
157///
158/// Pour les jobs `Job::Curate` déclenchés par `vault_write` :
159/// - `title` + `body` sont `Some` — portent le contenu à créer dans le vault.
160///
161/// Pour les jobs `Job::Curate` déclenchés par reclassification :
162/// - `title` + `body` sont `None` — la note existe déjà dans le vault.
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164pub struct CurateSpec {
165    /// Identifiant ULID de la note à curer.
166    pub note_id: Ulid,
167    /// Identifiant du tenant propriétaire (défaut : `"main"`).
168    #[serde(default = "default_tenant_main")]
169    pub tenant_id: String,
170    /// Titre de la note (présent pour vault_write, absent pour reclassification).
171    #[serde(default)]
172    pub title: Option<String>,
173    /// Corps Markdown de la note (présent pour vault_write).
174    #[serde(default)]
175    pub body: Option<String>,
176    /// Auteur de la note (optionnel).
177    #[serde(default)]
178    pub author: Option<String>,
179    /// Tags initiaux (optionnel — le curator peut en ajouter d'autres).
180    #[serde(default)]
181    pub tags: Vec<String>,
182    /// Section suggérée (optionnel — le curator peut surclasser).
183    #[serde(default)]
184    pub section_hint: Option<String>,
185    /// F-41 — Hash SHA-256 attendu pour l'optimistic-lock (optionnel).
186    ///
187    /// Si `Some`, le worker vérifie que le hash courant de la note correspond
188    /// avant d'écrire. Sur mismatch : job marqué `Conflict`, note non écrasée.
189    /// `None` = écriture inconditionnelle (rétrocompat — comportement actuel inchangé).
190    ///
191    /// Sérialisé en bincode comme `Option<[u8; 32]>` (32 octets fixes ou None).
192    /// Taille payload : +33 octets (1 discriminant bincode + 32 octets hash) → négligeable.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub expected_sha256: Option<[u8; 32]>,
195}
196
197fn default_tenant_main() -> String {
198    "main".to_string()
199}
200
201/// Spécification d'un job d'embedding (`Job::Embed`).
202///
203/// Génération ou régénération du vecteur via `gradatum-embed::FallbackEmbedder`.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct EmbedSpec {
206    /// Identifiant ULID de la note à embedder.
207    pub note_id: Ulid,
208    /// Identifiant du tenant propriétaire (défaut : `"main"`).
209    pub tenant_id: String,
210    /// Forcer la régénération même si un vecteur existe déjà.
211    pub force_regenerate: bool,
212}
213
214// ─────────────────────────────────────────────────────────────────────────────
215// Source structs — nouveaux variants v59
216// ─────────────────────────────────────────────────────────────────────────────
217
218/// Source pour [`Job::Migrate`] — import predecessor vault → Gradatum.
219///
220/// `JobClass::Human` uniquement — action irréversible.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct MigrateSource {
223    /// Chemin du vault source.
224    pub from_path: String,
225    /// Mode de migration.
226    pub mode: MigrateMode,
227    /// Stratégie de résolution des conflits.
228    pub conflict: ConflictStrategy,
229    /// Simuler sans écrire — obligatoire en premier passage.
230    pub dry_run: bool,
231    /// Vault de destination.
232    pub target: VaultScope,
233}
234
235/// Mode de migration pour [`MigrateSource`].
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub enum MigrateMode {
238    /// Mapping 10 sections → `CognitiveCategory` + `ContentSection` §16.
239    PredecessorV1,
240    /// Import depuis un autre vault Gradatum.
241    GradatumVault,
242    /// Import Markdown brut sans mapping.
243    RawMarkdown,
244}
245
246/// Stratégie de résolution des conflits pour [`MigrateSource`].
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub enum ConflictStrategy {
249    /// Écraser les notes existantes.
250    Overwrite,
251    /// Garder les existantes, ignorer les nouvelles.
252    Skip,
253    /// Suffixe `-imported` sur les conflits.
254    Rename,
255}
256
257/// Source pour [`Job::Export`] — générer un fichier depuis des notes vault.
258///
259/// `JobClass::Agent | Human`.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ExportSource {
262    /// Scope des notes à exporter.
263    pub scope: VaultScope,
264    /// Filtre FTS optionnel (ex : `"sections:decisions"`).
265    pub filter: Option<String>,
266    /// Format d'export.
267    pub format: ExportFormat,
268    /// Chemin OpenDAL destination (ex : `"exports/decisions-2026-05.pdf"`).
269    pub target: String,
270    /// Template Markdown pour le rendu.
271    pub template: Option<String>,
272}
273
274/// Format d'export pour [`ExportSource`].
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub enum ExportFormat {
277    /// Une ligne par note — titre, locus, trust, date, sections.
278    Csv,
279    /// Rendu Markdown → PDF (pandoc ou similaire).
280    Pdf,
281    /// Sérialisation complète des `Document` + frontmatter.
282    Json,
283    /// Concaténation des notes en un seul `.md`.
284    Markdown,
285    /// Archive des fichiers `.md` bruts.
286    Zip,
287}
288
289/// Source pour [`Job::Notify`] — notification externe en cascade.
290///
291/// `JobClass::System` — déclenché via `await_jobs` `OnDone`/`OnFailed`.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct NotifySource {
294    /// Canal de notification.
295    pub channel: NotifyChannel,
296    /// Template du message avec variables (ex : `"Job {{job_kind}} terminé : {{notes_created}} notes"`).
297    pub template: String,
298    /// Job dont on notifie la complétion.
299    pub job_ref: Option<Ulid>,
300}
301
302/// Canal de notification pour [`NotifySource`].
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub enum NotifyChannel {
305    /// Notification Telegram.
306    Telegram {
307        /// Identifiant du chat Telegram.
308        chat_id: String,
309    },
310    /// Notification Slack via webhook.
311    Slack {
312        /// URL du webhook Slack.
313        webhook_url: String,
314    },
315    /// Notification HTTP webhook générique.
316    Webhook {
317        /// URL du webhook.
318        url: String,
319        /// Méthode HTTP (`POST` recommandé).
320        method: String,
321    },
322    /// Publication NATS.
323    Nats {
324        /// Subject NATS cible.
325        subject: String,
326    },
327    /// Notification email.
328    Email {
329        /// Adresse email destinataire.
330        to: String,
331    },
332}
333
334/// Source pour [`Job::Ingest`] — ingestion document F-06 via queue.
335///
336/// `JobClass::Agent | Human`. Remplace `gradatum-admin vault import --file`
337/// pour les corpus larges (progress + retry).
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct IngestSource {
340    /// Source d'entrée à ingérer.
341    pub source: IngestInputSource,
342    /// Vault destination.
343    pub vault: String,
344    /// Locus destination (ex : `"rag/"`).
345    pub locus: String,
346    /// Stratégie d'ingestion.
347    pub strategy: IngestStrategy,
348    /// Simuler sans écrire — exceptions légitimes (opération potentiellement
349    /// volumineuse, validation humaine recommandée).
350    pub dry_run: bool,
351}
352
353/// Source d'entrée pour [`IngestSource`].
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub enum IngestInputSource {
356    /// Chemin OpenDAL.
357    File {
358        /// Chemin du fichier.
359        path: String,
360    },
361    /// URL à fetcher.
362    Url {
363        /// URL à ingérer.
364        url: String,
365    },
366    /// Batch d'URLs.
367    Urls {
368        /// Liste des URLs à ingérer.
369        urls: Vec<String>,
370    },
371    /// Dossier de fichiers déjà sur disque.
372    Locus {
373        /// Chemin du dossier.
374        path: String,
375    },
376}
377
378/// Stratégie d'ingestion pour [`IngestSource`].
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub enum IngestStrategy {
381    /// Détecte automatiquement : `StructureGuided` ou `SlidingWindow`.
382    Auto,
383    /// Skeleton tree + structure-guided chunking.
384    ForceStructured,
385    /// Sliding window même si des headings sont présents.
386    ForceSlidingWindow,
387}
388
389// ─────────────────────────────────────────────────────────────────────────────
390// PurgeSpec + PurgeMode — Job::Purge (F-32C)
391// ─────────────────────────────────────────────────────────────────────────────
392
393/// Mode de purge pour [`PurgeSpec`].
394///
395/// Seul `Lifecycle` est implémenté en v0.4.3 : suppression des notes `Garbage`
396/// dont l'ancienneté en Garbage (`status_changed`) dépasse `grace_days`.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398#[non_exhaustive]
399pub enum PurgeMode {
400    /// Supprime les notes `status=Garbage` ayant atteint la période de grâce.
401    ///
402    /// Ancienneté mesurée via la colonne `status_changed` de l'index (mise à jour par `update_status`).
403    Lifecycle,
404}
405
406/// Spécification d'un job de purge (`Job::Purge`).
407///
408/// ## Dry-run obligatoire
409///
410/// `dry_run = true` est le défaut prudent. En mode dry-run, le handler liste les
411/// notes éligibles et retourne [`JobOutput::dry_run`] **sans rien supprimer**.
412///
413/// La purge réelle (`dry_run = false`) doit être déclenchée explicitement.
414///
415/// ## Ancienneté
416///
417/// `grace_days` : durée minimale en état `Garbage` avant suppression.
418/// Mesurée via la colonne `status_changed` de l'index SQLite. Défaut : `Some(30)` (prudent).
419/// `None` = supprime toutes les notes Garbage sans délai (dangereux — CLI expert uniquement).
420///
421/// ## Cron
422///
423/// Le schedule cron purge nightly est INTENTIONNELLEMENT désactivé (voir config-full.toml).
424/// Activation = décision opérateur séparée (prérequis : stratégie backup nocturne configurée).
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct PurgeSpec {
427    /// Mode de purge — seul `Lifecycle` est implémenté en v0.4.3.
428    pub mode: PurgeMode,
429    /// Simulation sans écriture — défaut `true` (prudent).
430    ///
431    /// Exception à la règle v62 (`JobMode::DryRun` dans `JobSpec`) : le purge est une
432    /// opération irréversible à fort impact (suppression permanente). Le double mécanisme
433    /// (`spec.dry_run` + `JobMode::DryRun`) garantit qu'aucune écriture ne se produit
434    /// si l'un des deux est actif.
435    #[serde(default = "PurgeSpec::default_dry_run")]
436    pub dry_run: bool,
437    /// Ancienneté minimale en état Garbage avant suppression (jours).
438    ///
439    /// Défaut : `Some(30)`. `None` = pas de délai de grâce (CLI expert uniquement).
440    #[serde(default = "PurgeSpec::default_grace_days")]
441    pub grace_days: Option<u32>,
442}
443
444impl PurgeSpec {
445    fn default_dry_run() -> bool {
446        true
447    }
448
449    fn default_grace_days() -> Option<u32> {
450        Some(30)
451    }
452}
453
454impl Default for PurgeSpec {
455    fn default() -> Self {
456        Self {
457            mode: PurgeMode::Lifecycle,
458            dry_run: true,
459            grace_days: Some(30),
460        }
461    }
462}
463
464// ─────────────────────────────────────────────────────────────────────────────
465// ForgetSpec + ForgetScope — Job::Forget (F-44)
466// ─────────────────────────────────────────────────────────────────────────────
467
468/// Scope de résolution d'un job de forget (`Job::Forget`).
469///
470/// Trois modes de ciblage :
471/// - `Topic` : résolution via FTS (recherche plein-texte) sur un vault optionnel.
472/// - `Locus` : préfixe de locus (répertoire vault) — échappement LIKE appliqué côté worker.
473/// - `Agent` : toutes les notes d'un agent donné dans les vaults spécifiés.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[non_exhaustive]
476pub enum ForgetScope {
477    /// Résolution via recherche FTS — les notes correspondant à `query` sont ciblées.
478    ///
479    /// `vault` : tenant optionnel (`None` → vault principal `"main"`).
480    /// `limit` : cap prudent sur le nombre de notes ciblées (défaut 50, max 200).
481    Topic {
482        /// Requête FTS (ex. `"secrets api-key"`).
483        query: String,
484        /// Tenant cible (optionnel — `None` = `"main"`).
485        #[serde(default, skip_serializing_if = "Option::is_none")]
486        vault: Option<String>,
487        /// Cap sur le nombre de résultats ciblés (défaut 50).
488        #[serde(default, skip_serializing_if = "Option::is_none")]
489        limit: Option<usize>,
490    },
491    /// Résolution par préfixe de locus.
492    ///
493    /// `locus` est échappé LIKE côté worker. Exemples : `"inbox/"`, `"rag/corpus-x/"`.
494    Locus {
495        /// Tenant cible.
496        vault: String,
497        /// Préfixe locus (ex. `"inbox/old/"`) — matchera toutes les notes dont
498        /// le locus commence par cette valeur.
499        locus: String,
500    },
501    /// Résolution par agent — toutes les notes d'un `agent_id` dans les vaults spécifiés.
502    ///
503    /// `vaults` vide → vault `"main"` uniquement.
504    Agent {
505        /// Identifiant de l'agent (colonne `author_id`).
506        agent_id: String,
507        /// Liste de vaults cibles (`[]` → `["main"]`).
508        #[serde(default)]
509        vaults: Vec<String>,
510    },
511}
512
513/// Spécification d'un job de forget (`Job::Forget`).
514///
515/// ## Dry-run obligatoire
516///
517/// `dry_run = true` est le défaut. En dry-run : le handler liste les ULIDs cibles +
518/// exclusions (sections protégées) et retourne [`JobOutput::dry_run`] **sans aucune mutation**.
519///
520/// La mutation réelle (`dry_run = false`) nécessite en outre un `confirm_ulids`
521/// correspondant exactement aux ULIDs de la preview (double confirmation).
522///
523/// ## Sections protégées
524///
525/// Les notes des sections `AgentIssues` et `Council` sont **toujours exclues** du lot,
526/// quel que soit le scope. Elles sont signalées dans la preview sans bloquer le job.
527///
528/// ## Non-destructif
529///
530/// Le forget modifie le frontmatter YAML (`forgotten=true`, `forgotten_at`, `forgotten_by`)
531/// via le chemin d'écriture normal (CoW tracé). Aucune suppression physique.
532/// La purge physique est réservée à `Job::Purge`.
533///
534/// ## Idempotence
535///
536/// Un double forget est idempotent : `mark_forgotten` met à jour le timestamp si
537/// la note est déjà forgotten — pas d'erreur.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct ForgetSpec {
540    /// Scope de résolution — détermine les notes cibles.
541    pub scope: ForgetScope,
542    /// Dry-run (simulation sans mutation) — défaut `true` (prudent).
543    ///
544    /// Exception à la règle v62 : opération à effet persistant sur le scoring.
545    /// Double mécanisme (`spec.dry_run` + `JobMode::DryRun`) garantit l'absence
546    /// de mutation si l'un des deux est actif.
547    #[serde(default = "ForgetSpec::default_dry_run")]
548    pub dry_run: bool,
549    /// Acteur ayant déclenché le forget (pour auditabilité — `forgotten_by`).
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub forgotten_by: Option<String>,
552    /// ULIDs confirmés depuis une preview préalable (double confirmation).
553    ///
554    /// En mode réel (`dry_run = false`) : doit correspondre exactement aux ULIDs
555    /// retournés par la preview. Mismatch → job en erreur (Business), aucune mutation.
556    /// `None` autorisé uniquement en dry_run.
557    #[serde(default, skip_serializing_if = "Vec::is_empty")]
558    pub confirm_ulids: Vec<String>,
559}
560
561impl ForgetSpec {
562    fn default_dry_run() -> bool {
563        true
564    }
565}
566
567impl Default for ForgetSpec {
568    fn default() -> Self {
569        Self {
570            scope: ForgetScope::Topic {
571                query: String::new(),
572                vault: None,
573                limit: Some(50),
574            },
575            dry_run: true,
576            forgotten_by: None,
577            confirm_ulids: vec![],
578        }
579    }
580}
581
582// ─────────────────────────────────────────────────────────────────────────────
583// job_kind_str — helper de routing
584// ─────────────────────────────────────────────────────────────────────────────
585
586/// Retourne le nom du variant [`Job`] sous forme de chaîne statique.
587///
588/// Utilisé pour dénormaliser la colonne `kind` dans `gradatum_jobs` à l'enqueue,
589/// et pour filtrer par `kind` dans [`QueueStore::dequeue_by_kind`].
590///
591/// # Exhaustivité
592///
593/// Ce match est exhaustif sans `_ =>` pour garantir qu'un nouveau variant de [`Job`]
594/// provoque une erreur de compilation plutôt qu'un routage silencieusement incorrect.
595///
596/// # Correspondance JSON
597///
598/// La valeur retournée correspond à la clé `"type"` du payload sérialisé via
599/// `#[serde(tag = "type", content = "data")]` (ex. `{"spec":{"kind":{"type":"Curate",...}}}`).
600#[must_use]
601pub fn job_kind_str(job: &Job) -> &'static str {
602    match job {
603        Job::Agent => "Agent",
604        Job::Pipeline => "Pipeline",
605        Job::Collect => "Collect",
606        Job::Distill => "Distill",
607        Job::Backup => "Backup",
608        Job::Purge(_) => "Purge",
609        Job::ReIndex(_) => "ReIndex",
610        Job::Summarize => "Summarize",
611        Job::Validate => "Validate",
612        Job::Audit => "Audit",
613        Job::Consolidate => "Consolidate",
614        Job::Curate(_) => "Curate",
615        Job::Forget(_) => "Forget",
616        Job::Review => "Review",
617        Job::Classify => "Classify",
618        Job::Merge => "Merge",
619        Job::Annotate => "Annotate",
620        Job::Migrate(_) => "Migrate",
621        Job::Export(_) => "Export",
622        Job::Notify(_) => "Notify",
623        Job::Ingest(_) => "Ingest",
624        Job::Embed(_) => "Embed",
625    }
626}
627
628// ─────────────────────────────────────────────────────────────────────────────
629// JobSpec — ce que fait le job
630// ─────────────────────────────────────────────────────────────────────────────
631
632/// Spécification fonctionnelle d'un job.
633///
634/// Contient le type de travail, la classe de déclencheur, le mode d'exécution,
635/// le scope et la priorité.
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct JobSpec {
638    /// Type fonctionnel + payload.
639    pub kind: Job,
640    /// Qui déclenche le job.
641    pub class: JobClass,
642    /// Comment s'exécute le job.
643    pub mode: JobMode,
644    /// Sur quoi s'applique le job.
645    pub scope: JobScope,
646    /// Priorité dans la queue.
647    pub priority: JobPriority,
648}
649
650// ─────────────────────────────────────────────────────────────────────────────
651// JobClass
652// ─────────────────────────────────────────────────────────────────────────────
653
654/// Classe de déclencheur d'un job.
655///
656/// Détermine la priorité par défaut et le routage dans la queue.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
658pub enum JobClass {
659    /// Cron autonome — pas d'acteur humain.
660    System,
661    /// Déclenché/exécuté par un agent LLM.
662    Agent,
663    /// Action explicite CLI/studio.
664    Human,
665    /// Appel machine externe (MCP, tiers).
666    Api,
667}
668
669// ─────────────────────────────────────────────────────────────────────────────
670// JobMode
671// ─────────────────────────────────────────────────────────────────────────────
672
673/// Mode d'exécution d'un job.
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
675pub enum JobMode {
676    /// Traite N éléments, s'arrête (défaut).
677    #[default]
678    Batch,
679    /// Traite en continu jusqu'à queue vide.
680    Streaming,
681    /// Requiert allers-retours avec un acteur.
682    Interactive,
683    /// Simule sans écrire.
684    DryRun,
685}
686
687// ─────────────────────────────────────────────────────────────────────────────
688// JobScope
689// ─────────────────────────────────────────────────────────────────────────────
690
691/// Scope d'un job — sur quoi s'applique le travail.
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub enum JobScope {
694    /// Tout le vault.
695    VaultWide,
696    /// Un locus spécifique (répertoire vault).
697    Locus(String),
698    /// Un ensemble de notes ciblées.
699    Notes(Vec<Ulid>),
700    /// Une session agent (contexte isolé).
701    Session(Ulid),
702}
703
704// ─────────────────────────────────────────────────────────────────────────────
705// JobPriority
706// ─────────────────────────────────────────────────────────────────────────────
707
708/// Priorité d'un job dans la queue (v65).
709///
710/// Mapping par défaut :
711/// - `Agent`  → `High`    (agent actif en conversation → réponse attendue)
712/// - `Human`  → `High`    (action humaine explicite → réponse attendue)
713/// - `Api`    → `Normal`  (appel machine → latence acceptable)
714/// - `System` → `Low`     (tâche de fond cron → ne bloque pas les agents)
715///
716/// Câblé dans `GradatumQueue.dequeue()` via `ORDER BY priority DESC`.
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
718pub enum JobPriority {
719    /// Agent actif + Human — réponse attendue.
720    High,
721    /// Api — latence acceptable (défaut).
722    #[default]
723    Normal,
724    /// System cron — tâche de fond, ne bloque pas les agents.
725    Low,
726    /// Schedulé dans le futur (Consolidate trimestriel).
727    Deferred,
728}
729
730impl JobPriority {
731    /// Valeur SQL pour `ORDER BY priority DESC` (High=3 passe avant Low=0).
732    #[must_use]
733    pub fn as_u8(&self) -> u8 {
734        match self {
735            Self::High => 3,
736            Self::Normal => 2,
737            Self::Low => 1,
738            Self::Deferred => 0,
739        }
740    }
741
742    /// Priorité par défaut selon la classe du job.
743    #[must_use]
744    pub fn default_for(class: &JobClass) -> Self {
745        match class {
746            JobClass::Agent => Self::High,
747            JobClass::Human => Self::High,
748            JobClass::Api => Self::Normal,
749            JobClass::System => Self::Low,
750        }
751    }
752}
753
754// ─────────────────────────────────────────────────────────────────────────────
755// JobScheduling — quand s'exécute le job
756// ─────────────────────────────────────────────────────────────────────────────
757
758/// Contraintes de scheduling d'un job.
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct JobScheduling {
761    /// Source de déclenchement.
762    pub trigger: TriggerSource,
763    /// Date/heure de scheduling (UTC).
764    pub scheduled_at: DateTime<Utc>,
765    /// Chaînage déclaratif — `[]` = immédiat · `[x]` = chaîne · `[x,y]` = DAG.
766    ///
767    /// Sémantique : "déclenche-moi quand ces jobs sont terminés".
768    /// Plus robuste que `not_before: DateTime` (fragile, dépend des durées).
769    pub await_jobs: Vec<JobTrigger>,
770    /// Deadline pour les jobs `Interactive` — timeout.
771    pub deadline: Option<DateTime<Utc>>,
772    /// Expression cron (ex : `"0 2 * * *"`).
773    pub cron_expr: Option<String>,
774}
775
776/// Condition de déclenchement en cascade.
777#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct JobTrigger {
779    /// Identifiant du job attendu.
780    pub job_id: Ulid,
781    /// Condition de déclenchement.
782    pub condition: TriggerCondition,
783}
784
785/// Condition sur l'état terminal d'un job attendu.
786#[derive(Debug, Clone, Serialize, Deserialize)]
787pub enum TriggerCondition {
788    /// Uniquement si `Done` (succès).
789    OnDone,
790    /// `Done | Failed | DLQ` — quoi qu'il arrive.
791    OnAnyTerminal,
792    /// Uniquement si `Failed` (alerting).
793    OnFailed,
794}
795
796/// Source de déclenchement d'un job.
797#[derive(Debug, Clone, Serialize, Deserialize)]
798pub enum TriggerSource {
799    /// `[[worker.schedules]]` — tokio-cron-scheduler.
800    Cron,
801    /// `[[pipelines]]` step — pipeline_executor.
802    Pipeline,
803    /// `await_jobs` → `on_job_complete()` → `set_pending()`.
804    Cascade,
805    /// `WriteHook` ou `QaEvent` interceptor.
806    OnEvent,
807    /// `POST /api/v1/jobs/trigger` · admin CLI · `invoke_agent()`.
808    Demand,
809}
810
811// ─────────────────────────────────────────────────────────────────────────────
812// JobLifecycle — où en est le job
813// ─────────────────────────────────────────────────────────────────────────────
814
815/// État courant du cycle de vie d'un job.
816#[derive(Debug, Clone, Serialize, Deserialize)]
817pub struct JobLifecycle {
818    /// Statut courant.
819    pub status: JobStatus,
820    /// Timestamp de création (UTC).
821    pub created_at: DateTime<Utc>,
822    /// Timestamp de démarrage (UTC) — `None` si pas encore démarré.
823    pub started_at: Option<DateTime<Utc>>,
824    /// Timestamp de fin (UTC) — `None` si pas encore terminé.
825    pub completed_at: Option<DateTime<Utc>>,
826    /// Expiration du lease SQLite — anti-doublon.
827    pub lease_until: Option<DateTime<Utc>>,
828    /// Résultat du job — `None` si pas encore terminé.
829    pub result: Option<JobResult>,
830}
831
832/// Statut d'un job dans son cycle de vie.
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
834pub enum JobStatus {
835    /// Dans la queue, prêt à démarrer.
836    Pending,
837    /// Lease active — en cours d'exécution.
838    Running,
839    /// `await_jobs` non satisfaits.
840    Waiting,
841    /// Succès.
842    Done,
843    /// Erreur, retry possible.
844    Failed,
845    /// Dead-letter — `max_retries` atteint.
846    DLQ,
847    /// Deadline dépassée ou orphelin.
848    Cancelled,
849    /// F-41 — Conflit optimistic-lock : l'écriture a été refusée parce que le
850    /// `expected_sha256` fourni ne correspond pas au hash courant de la note.
851    /// État terminal sans retry (la note n'a PAS été modifiée).
852    /// Lire `lifecycle.result.result_note_md` pour obtenir le `current_sha256`
853    /// encodé en JSON (`{ "current_sha256": "hex...", "attempted_sha256": "hex..." }`).
854    Conflict,
855}
856
857/// Résultat d'un job terminé.
858#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct JobResult {
860    /// `true` si le job s'est terminé avec succès.
861    pub success: bool,
862    /// Durée d'exécution en millisecondes.
863    pub duration_ms: u32,
864    /// Coût LLM en USD — `None` si pas de LLM impliqué.
865    pub cost_usd: Option<f32>,
866    /// Note Gradatum résultat — point d'entrée unique pour l'agent (v57).
867    ///
868    /// `vault_read(result_note)` → frontmatter + chemins + wikilinks vers notes produites.
869    /// Présent si `success=true`. Note d'erreur si `DLQ`.
870    pub result_note: Option<Ulid>,
871    /// F-41 — Payload JSON du conflit optimistic-lock (présent si `JobStatus::Conflict`).
872    ///
873    /// Contient les champs `current_sha256` (hash courant) et `attempted_sha256` (hash tenté).
874    /// Permet au client poll de récupérer le hash courant pour résoudre le conflit.
875    ///
876    /// Exemple de payload :
877    /// ```json
878    /// { "current_sha256": "a3f1...", "attempted_sha256": "b2e0...", "timestamp_ms": 1234567890 }
879    /// ```
880    ///
881    /// `None` pour tous les autres statuts.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub conflict_payload: Option<serde_json::Value>,
884}
885
886// ─────────────────────────────────────────────────────────────────────────────
887// JobWorkspace — workspace physique OpenDAL
888// ─────────────────────────────────────────────────────────────────────────────
889
890/// Workspace physique d'un job — structure OpenDAL (v57).
891///
892/// Tout passe par OpenDAL — même API quel que soit le backend (fs/s3/gcs).
893#[derive(Debug, Clone, Serialize, Deserialize)]
894pub struct JobWorkspace {
895    /// Chemin d'entrée — ex : `"worker/2026-05-20/01J-XYZ/input/"`.
896    pub input: String,
897    /// Chemin de sortie — ex : `"worker/2026-05-20/01J-XYZ/output/"`.
898    pub output: String,
899    /// Chemin de métadonnées — ex : `"worker/2026-05-20/01J-XYZ/meta/"`.
900    pub meta: String,
901}
902
903impl JobWorkspace {
904    /// Construit le workspace depuis un [`JobRecord`].
905    ///
906    /// Format : `worker/{YYYY-MM-DD}/{job_id}/{input|output|meta}/`
907    #[must_use]
908    pub fn from_job(job: &JobRecord) -> Self {
909        let date = job.lifecycle.created_at.format("%Y-%m-%d").to_string();
910        let base = format!("worker/{}/{}", date, job.id);
911        Self {
912            input: format!("{}/input/", base),
913            output: format!("{}/output/", base),
914            meta: format!("{}/meta/", base),
915        }
916    }
917}
918
919// ─────────────────────────────────────────────────────────────────────────────
920// JobProgress — progress d'un job en cours
921// ─────────────────────────────────────────────────────────────────────────────
922
923/// Progress d'un job en cours (v58).
924///
925/// Stocké dans SQLite périodiquement.
926/// `GET /api/v1/jobs/:id/status` → `{ status: "running", progress: { current: 47, total: 200 } }`
927#[derive(Debug, Clone, Serialize, Deserialize)]
928pub struct JobProgress {
929    /// Éléments traités.
930    pub current: u32,
931    /// Éléments total (si connu).
932    pub total: u32,
933    /// Description de l'étape courante.
934    pub step: String,
935    /// Estimation du temps restant en secondes.
936    pub eta_secs: Option<u32>,
937}
938
939// ─────────────────────────────────────────────────────────────────────────────
940// JobOutputFile + JobOutput
941// ─────────────────────────────────────────────────────────────────────────────
942
943/// Fichier produit par un job — stocké via OpenDAL dans `output/`.
944#[derive(Debug, Clone, Serialize, Deserialize)]
945pub struct JobOutputFile {
946    /// Nom du fichier (ex : `"export.csv"` | `"report.pdf"` | `"chart.png"`).
947    pub name: String,
948    /// MIME type (ex : `"text/csv"` | `"application/pdf"` | `"image/png"`).
949    pub mime_type: String,
950    /// Taille en bytes.
951    pub size: u64,
952    /// TTL en jours — `None` = défaut du locus (`worker/`=30j, `exports/`=90j).
953    pub ttl_days: Option<u32>,
954}
955
956/// Sorties complètes produites par un job (v57).
957#[derive(Debug, Clone, Serialize, Deserialize)]
958pub struct JobOutput {
959    /// Notes Markdown créées dans le vault.
960    pub notes_created: Vec<Ulid>,
961    /// Notes modifiées (Validate/Heal).
962    pub notes_modified: Vec<Ulid>,
963    /// Binaires/CSV/images dans `output/`.
964    pub files: Vec<JobOutputFile>,
965    /// Contenu Markdown de la note résultat.
966    ///
967    /// Écrit dans `output/result.md`, copié dans `vault work/jobs/` pour `vault_read()`.
968    pub result_note_md: String,
969}
970
971impl JobOutput {
972    /// Retourner si `JobMode::DryRun` — aucune écriture effectuée.
973    #[must_use]
974    pub fn dry_run(would_affect: usize, description: &str) -> Self {
975        Self {
976            notes_created: vec![],
977            notes_modified: vec![],
978            files: vec![],
979            result_note_md: format!(
980                "## DRY-RUN — {description}\n\n\
981                 **Simulation uniquement — aucune écriture effectuée.**\n\n\
982                 Notes qui auraient été affectées : {would_affect}\n",
983            ),
984        }
985    }
986}
987
988// ─────────────────────────────────────────────────────────────────────────────
989// JobRetry — comment le job récupère
990// ─────────────────────────────────────────────────────────────────────────────
991
992/// Politique de retry d'un job.
993#[derive(Debug, Clone, Serialize, Deserialize)]
994pub struct JobRetry {
995    /// Tentatives effectuées.
996    pub count: u32,
997    /// Maximum de tentatives — `0` = pas de retry.
998    pub max: u32,
999    /// Stratégie de backoff.
1000    pub backoff: RetryBackoff,
1001    /// Dernière erreur enregistrée.
1002    pub last_error: Option<String>,
1003    /// Historique complet des erreurs.
1004    pub errors: Vec<JobError>,
1005}
1006
1007impl Default for JobRetry {
1008    fn default() -> Self {
1009        Self {
1010            count: 0,
1011            max: 3,
1012            backoff: RetryBackoff::Exponential { base: 5, max: 120 },
1013            last_error: None,
1014            errors: vec![],
1015        }
1016    }
1017}
1018
1019/// Erreur individuelle enregistrée lors d'une tentative.
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct JobError {
1022    /// Timestamp de l'erreur (UTC).
1023    pub at: DateTime<Utc>,
1024    /// Message d'erreur.
1025    pub message: String,
1026    /// Numéro de la tentative.
1027    pub attempt: u32,
1028}
1029
1030/// Stratégie de backoff entre les tentatives.
1031#[derive(Debug, Clone, Serialize, Deserialize)]
1032pub enum RetryBackoff {
1033    /// N secondes fixes entre chaque tentative.
1034    Fixed(u64),
1035    /// Backoff exponentiel `base → 2×base → ... → max` secondes.
1036    Exponential {
1037        /// Délai de base en secondes.
1038        base: u64,
1039        /// Délai maximal en secondes.
1040        max: u64,
1041    },
1042}
1043
1044impl RetryBackoff {
1045    /// Calcule la durée d'attente pour la tentative `attempt` (0-indexé).
1046    ///
1047    /// Pour `Fixed(n)` : toujours `n` secondes.
1048    /// Pour `Exponential { base, max }` : `min(base * 2^attempt, max)` secondes.
1049    #[must_use]
1050    pub fn duration_for(&self, attempt: u32) -> Duration {
1051        match self {
1052            Self::Fixed(secs) => Duration::from_secs(*secs),
1053            Self::Exponential { base, max } => {
1054                let secs = base.saturating_mul(1_u64 << attempt.min(62));
1055                Duration::from_secs(secs.min(*max))
1056            }
1057        }
1058    }
1059}
1060
1061// ─────────────────────────────────────────────────────────────────────────────
1062// JobLineage — d'où vient le job
1063// ─────────────────────────────────────────────────────────────────────────────
1064
1065/// Traçabilité de l'émetteur et du contexte déclencheur d'un job.
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1067pub struct JobLineage {
1068    /// `agent_id` | `user_id` | `cron_id` — `None` si non tracé.
1069    pub triggered_by: Option<String>,
1070    /// Job parent si job enfant (cascade, agent spawn).
1071    pub parent_job: Option<Ulid>,
1072    /// Pipeline si step d'un `[[pipelines]]`.
1073    pub pipeline_id: Option<Ulid>,
1074    /// Nom du step dans le pipeline.
1075    pub pipeline_step: Option<String>,
1076    /// Jobs créés par ce job (cascade sortante).
1077    pub children: Vec<Ulid>,
1078    /// Coût LLM cumulé en USD.
1079    pub cost_usd: Option<f32>,
1080}
1081
1082// ─────────────────────────────────────────────────────────────────────────────
1083// JobRecord — enveloppe complète 5 blocs
1084// ─────────────────────────────────────────────────────────────────────────────
1085
1086/// Enveloppe complète d'un job structurée en 5 blocs orthogonaux.
1087///
1088/// `JobRecord` est le type canonique L0 circulant dans toute la couche job.
1089/// Sérialisé en JSON par le `QueueStore` pour persistance en SQLite.
1090///
1091/// # Les 5 blocs
1092///
1093/// 1. [`JobSpec`] — CE QUE fait le job
1094/// 2. [`JobScheduling`] — QUAND il s'exécute
1095/// 3. [`JobLifecycle`] — OÙ il en est
1096/// 4. [`JobRetry`] — COMMENT il récupère
1097/// 5. [`JobLineage`] — D'OÙ il vient / liens
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1099pub struct JobRecord {
1100    /// Identifiant unique du job (ULID monotone, ordre FIFO implicite).
1101    pub id: Ulid,
1102    /// Bloc 1 — CE QUE fait le job.
1103    pub spec: JobSpec,
1104    /// Bloc 2 — QUAND il s'exécute.
1105    pub scheduling: JobScheduling,
1106    /// Bloc 3 — OÙ il en est.
1107    pub lifecycle: JobLifecycle,
1108    /// Bloc 4 — COMMENT il récupère.
1109    pub retry: JobRetry,
1110    /// Bloc 5 — D'OÙ il vient / liens.
1111    pub lineage: JobLineage,
1112}
1113
1114// ─────────────────────────────────────────────────────────────────────────────
1115// JobFilter — introspection F-16
1116// ─────────────────────────────────────────────────────────────────────────────
1117
1118/// Filtre pour [`QueueStore::list`].
1119///
1120/// Le champ `cursor` permet la pagination cursor-based (F-16).
1121/// `cursor` est le dernier `id` ULID retourné — la requête suivante retourne les jobs
1122/// avec `id > cursor` (ULID est monotone, donc équivaut à un ordre temporel).
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124pub struct JobFilter {
1125    /// Filtrer par classe de job.
1126    pub class: Option<JobClass>,
1127    /// Filtrer par statut.
1128    pub status: Option<JobStatus>,
1129    /// Filtrer par kind (nom du variant `Job`).
1130    pub kind: Option<String>,
1131    /// Filtrer les jobs créés après cette date.
1132    pub created_after: Option<DateTime<Utc>>,
1133    /// Nombre maximum de résultats (défaut : 50, max : 500).
1134    pub limit: usize,
1135    /// Cursor de pagination — dernier `id` ULID retourné (exclusif).
1136    ///
1137    /// `None` = début de la liste. `Some(ulid)` = jobs après ce ULID.
1138    /// Utiliser `next_cursor` de la réponse API précédente.
1139    pub cursor: Option<Ulid>,
1140}
1141
1142impl Default for JobFilter {
1143    fn default() -> Self {
1144        Self {
1145            class: None,
1146            status: None,
1147            kind: None,
1148            created_after: None,
1149            limit: 50,
1150            cursor: None,
1151        }
1152    }
1153}
1154
1155// ─────────────────────────────────────────────────────────────────────────────
1156// QueueEvent — événements publiés par le backend
1157// ─────────────────────────────────────────────────────────────────────────────
1158
1159/// Événements publiés par le `QueueStore` via broadcast.
1160///
1161/// Consommés par :
1162/// - SSE endpoint `GET /api/v1/jobs/events`
1163/// - Cascade engine (`find_awaiting` + `set_pending`)
1164/// - Dashboard monitoring temps réel
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub enum QueueEvent {
1167    /// Nouveau job inséré — `Pending` ou `Waiting`.
1168    JobInserted(Ulid),
1169    /// Job terminé — `Done` ou `DLQ`.
1170    JobCompleted(Ulid, JobStatus, JobResult),
1171    /// Job échoué — `Failed` + numéro de tentative.
1172    JobFailed(Ulid, u32),
1173    /// Job passé `Waiting → Pending` (cascade satisfaite).
1174    JobReady(Ulid),
1175    /// Job annulé — deadline ou orphelin.
1176    JobCancelled(Ulid),
1177}
1178
1179// ─────────────────────────────────────────────────────────────────────────────
1180// GradatumJob — payload Apalis
1181// ─────────────────────────────────────────────────────────────────────────────
1182
1183/// Payload Apalis wrappant un [`JobRecord`].
1184///
1185/// Sérialisé en JSON dans la colonne `job` de la table Apalis.
1186/// Le champ `priority` duplique `spec.priority.as_u8()` pour permettre
1187/// un `ORDER BY priority DESC` sans désérialisation du payload.
1188#[derive(Debug, Clone, Serialize, Deserialize)]
1189pub struct GradatumJob {
1190    /// Enveloppe complète du job.
1191    pub record: JobRecord,
1192    /// Valeur de priorité dénormalisée pour le tri SQL (0-3).
1193    pub priority: u8,
1194}
1195
1196// ─────────────────────────────────────────────────────────────────────────────
1197// DryRunAware trait
1198// ─────────────────────────────────────────────────────────────────────────────
1199
1200/// Trait pour les jobs et handlers supportant le mode `DryRun` (v58).
1201///
1202/// Règle v62 : UN SEUL mécanisme, [`JobMode::DryRun`] dans [`JobSpec`].
1203/// Les `Source` structs ne portent PAS de champ `dry_run` sauf exceptions
1204/// légitimes (`MigrateSource.dry_run`, `IngestSource.dry_run`) pour les
1205/// opérations irréversibles nécessitant une validation humaine en premier.
1206///
1207/// Dans TOUS les handlers, la vérification est la première instruction :
1208///
1209/// ```rust,ignore
1210/// if job.spec.mode == JobMode::DryRun {
1211///     let count = ctx.vault.count(&src.scopes).await?;
1212///     return Ok(JobOutput::dry_run(count, "description"));
1213/// }
1214/// ```
1215pub trait DryRunAware {
1216    /// Retourne `true` si ce job est en mode DryRun.
1217    fn is_dry_run(&self) -> bool;
1218
1219    /// Nombre de notes qui seraient affectées (estimation).
1220    ///
1221    /// Retourne `0` par défaut — surchargé par les implémentations qui peuvent
1222    /// calculer cette valeur sans effets de bord.
1223    fn notes_would_affect(&self) -> usize {
1224        0
1225    }
1226}
1227
1228impl DryRunAware for JobRecord {
1229    fn is_dry_run(&self) -> bool {
1230        self.spec.mode == JobMode::DryRun
1231    }
1232}
1233
1234// ─────────────────────────────────────────────────────────────────────────────
1235// JobSource trait — factorisation v63
1236// ─────────────────────────────────────────────────────────────────────────────
1237
1238/// Trait commun aux Source structs des variants `Job`.
1239///
1240/// Factorisation v63 — champs communs à 11 Source structs.
1241/// Rust ne supporte pas l'héritage de struct — trait préféré à embed.
1242pub trait JobSource {
1243    /// Scopes vault sur lesquels s'applique le job.
1244    fn scopes(&self) -> &[VaultScope];
1245
1246    /// `true` si le job est en mode simulation (sans écriture).
1247    fn dry_run(&self) -> bool;
1248
1249    /// Fenêtre temporelle optionnelle (notes créées/modifiées dans cette durée).
1250    fn window(&self) -> Option<Duration> {
1251        None
1252    }
1253}
1254
1255// ─────────────────────────────────────────────────────────────────────────────
1256// QueueStore trait — L0
1257// ─────────────────────────────────────────────────────────────────────────────
1258
1259/// Trait de stockage de la queue de jobs — L0 `gradatum-core`.
1260///
1261/// Implémentations :
1262/// - `SqliteQueueStore` dans `gradatum-db-sqlite` (défaut embedded)
1263/// - `LibsqlQueueStore` dans `gradatum-db-sqlite` (remote opt-in F-25)
1264///
1265/// # Erreurs
1266///
1267/// Toutes les méthodes retournent `Result<_, QueueError>`.
1268/// Les implémentations ne doivent pas paniquer — propager les erreurs via `?`.
1269#[async_trait::async_trait]
1270pub trait QueueStore: Send + Sync {
1271    // ── Opérations de base ────────────────────────────────────────────────
1272
1273    /// Insère un nouveau job dans la queue — retourne son `Ulid`.
1274    async fn enqueue(&self, job: JobRecord) -> Result<Ulid, QueueError>;
1275
1276    /// Extrait le prochain job prêt à exécuter (lease atomique).
1277    ///
1278    /// Retourne `None` si la queue est vide ou si aucun job n'est prêt.
1279    async fn dequeue(&self) -> Result<Option<JobRecord>, QueueError>;
1280
1281    /// Extrait le prochain job prêt à exécuter, filtré par `kind` (lease atomique).
1282    ///
1283    /// Garantit qu'un worker `curate` ne reçoit jamais un job `Embed` ou `ReIndex`,
1284    /// éliminant la race condition de routing (bug DLQ `UnexpectedVariant`).
1285    ///
1286    /// # Implémentation par défaut
1287    ///
1288    /// Fallback non filtré — les implémentations qui supportent le filtrage natif SQL
1289    /// (ex. `SqliteQueueStore`) doivent surcharger cette méthode avec `WHERE kind = ?`
1290    /// pour exploiter l'index `idx_jobs_status_kind`.
1291    ///
1292    /// # Paramètre
1293    ///
1294    /// `kind` : nom du variant `Job` tel que retourné par [`job_kind_str`] —
1295    /// ex. `"Curate"`, `"Embed"`, `"ReIndex"`.
1296    async fn dequeue_by_kind(&self, _kind: &str) -> Result<Option<JobRecord>, QueueError> {
1297        self.dequeue().await
1298    }
1299
1300    /// Récupère un job par identifiant — `None` si inexistant.
1301    async fn get(&self, id: Ulid) -> Result<Option<JobRecord>, QueueError>;
1302
1303    /// Marque un job comme `Done` avec son résultat.
1304    async fn complete(&self, id: Ulid, result: JobResult) -> Result<(), QueueError>;
1305
1306    /// Marque un job comme `Failed` (retry possible selon policy).
1307    async fn fail(&self, id: Ulid, err: &str, attempt: u32) -> Result<(), QueueError>;
1308
1309    /// Annule un job (`Cancelled`).
1310    async fn cancel(&self, id: Ulid) -> Result<(), QueueError>;
1311
1312    /// Envoie un job en dead-letter (`DLQ`) — max retries atteint.
1313    async fn fail_dlq(&self, id: Ulid, err: &str) -> Result<(), QueueError>;
1314
1315    // ── Cascade — chaînage await_jobs ────────────────────────────────────
1316
1317    /// Trouve les jobs en `Waiting` dont `await_jobs` contient `job_id`.
1318    async fn find_awaiting(&self, job_id: Ulid) -> Result<Vec<JobRecord>, QueueError>;
1319
1320    /// Passe un job de `Waiting` à `Pending`.
1321    async fn set_pending(&self, id: Ulid) -> Result<(), QueueError>;
1322
1323    // ── Sweep périodique (30s) ────────────────────────────────────────────
1324
1325    /// Récupère les jobs dont le lease a expiré → remet en `Pending`.
1326    async fn recover_stale_leases(&self, ttl: Duration) -> Result<Vec<Ulid>, QueueError>;
1327
1328    /// Annule les jobs dont la deadline est dépassée.
1329    async fn cancel_expired_deadlines(&self, now: DateTime<Utc>) -> Result<Vec<Ulid>, QueueError>;
1330
1331    /// Promeut les jobs schedulés en retry dont `scheduled_at <= now` → `Pending`.
1332    ///
1333    /// Garde v67 : si `retry.count >= retry.max` → `fail_dlq` au lieu de re-Pending.
1334    /// Évite la boucle infinie (`Failed → schedule → Failed → ...`).
1335    async fn promote_retries(&self, now: DateTime<Utc>) -> Result<Vec<Ulid>, QueueError>;
1336
1337    /// Schedule un job en retry à `at` (transition `Failed → Waiting`).
1338    async fn schedule_retry(&self, id: Ulid, at: DateTime<Utc>) -> Result<(), QueueError>;
1339
1340    // ── Introspection F-16 ────────────────────────────────────────────────
1341
1342    /// Liste les jobs selon un filtre.
1343    async fn list(&self, filter: JobFilter) -> Result<Vec<JobRecord>, QueueError>;
1344
1345    // ── Événements ────────────────────────────────────────────────────────
1346
1347    /// Souscrit au broadcast des [`QueueEvent`].
1348    ///
1349    /// Chaque appel retourne un nouveau `Receiver` indépendant.
1350    /// Les événements sont émis sans garantie de livraison si le consommateur
1351    /// est trop lent (channel broadcast avec capacité fixe).
1352    fn subscribe(&self) -> Receiver<QueueEvent>;
1353
1354    /// F-41 — Marque un job en état terminal `Conflict` (optimistic-lock).
1355    ///
1356    /// La note n'a PAS été écrite. Le `result_note_md` contient le JSON du conflit
1357    /// (`WriteConflictDto`) pour permettre au client de récupérer le `current_sha256`.
1358    ///
1359    /// Distinct de `fail()` (qui peut déclencher des retries) et de `complete()`
1360    /// (qui indique un succès). `Conflict` est terminal sans retry.
1361    ///
1362    /// # Implémentation par défaut
1363    ///
1364    /// Délègue à `complete()` avec `success: false`, puis corrige le statut
1365    /// dans le payload JSON (contournement : `complete()` met `Done` en colonne status,
1366    /// mais le `lifecycle.status` dans le payload JSON est le champ lu par le client poll).
1367    ///
1368    /// Les implémentations qui ont accès à la DB peuvent surcharger pour écrire
1369    /// directement `status = 'Conflict'` dans la colonne SQL.
1370    async fn mark_conflict(
1371        &self,
1372        id: Ulid,
1373        result_note_md: String,
1374        duration_ms: u32,
1375    ) -> Result<(), QueueError> {
1376        // Implémentation par défaut : utilise complete() avec success=false,
1377        // puis corrige le lifecycle.status dans le payload via un get+patch+re-save.
1378        // Les implémentations concrètes (SqliteQueueStore) surchargent cette méthode
1379        // pour écrire directement le bon statut SQL.
1380        let result = JobResult {
1381            success: false,
1382            duration_ms,
1383            cost_usd: None,
1384            result_note: None,
1385            conflict_payload: serde_json::from_str(&result_note_md).ok(),
1386        };
1387        // Appel complet() avec le résultat — le lifecycle.status payload sera Done,
1388        // mais l'implémentation concrète le corrige dans sa surcharge.
1389        // L'implémentation par défaut ne peut pas corriger le status SQL sans accès à la DB.
1390        self.complete(id, result).await
1391    }
1392}
1393
1394// ─────────────────────────────────────────────────────────────────────────────
1395// QueueError — erreurs L0 (sans dépendances externes)
1396// ─────────────────────────────────────────────────────────────────────────────
1397
1398/// Erreurs du `QueueStore` — sans dépendance vers `sqlx` ou autre driver.
1399///
1400/// Les implémentations (`SqliteQueueStore`, etc.) mappent leurs erreurs internes
1401/// vers ces variants via `map_err()`.
1402#[derive(Debug, thiserror::Error)]
1403pub enum QueueError {
1404    /// Erreur de stockage (driver SQLite, libsql, etc.).
1405    #[error("erreur de stockage : {0}")]
1406    Storage(String),
1407
1408    /// Job introuvable par identifiant.
1409    #[error("job introuvable : {0}")]
1410    NotFound(Ulid),
1411
1412    /// Erreur de sérialisation/désérialisation du payload JSON.
1413    #[error("erreur de sérialisation : {0}")]
1414    Serialization(String),
1415
1416    /// Transition d'état invalide (ex : `Done → Running`).
1417    #[error("transition d'état invalide : {0}")]
1418    InvalidTransition(String),
1419
1420    /// Opération annulée (timeout, shutdown).
1421    #[error("opération annulée : {0}")]
1422    Cancelled(String),
1423
1424    /// Opération non implémentée dans cette version.
1425    ///
1426    /// Utilisée à la place de `todo!()` pour signaler proprement une méthode
1427    /// du trait public dont l'implémentation est différée (ex. DAG F-14).
1428    /// Ne doit jamais déclencher de panic en production.
1429    #[error("opération non implémentée : {method}")]
1430    NotImplemented {
1431        /// Nom de la méthode non implémentée.
1432        method: &'static str,
1433    },
1434}
1435
1436// ─────────────────────────────────────────────────────────────────────────────
1437// Tests unitaires
1438// ─────────────────────────────────────────────────────────────────────────────
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443
1444    fn make_job_record(job: Job, class: JobClass) -> JobRecord {
1445        let now = Utc::now();
1446        JobRecord {
1447            id: Ulid::new(),
1448            spec: JobSpec {
1449                kind: job,
1450                class,
1451                mode: JobMode::Batch,
1452                scope: JobScope::VaultWide,
1453                priority: JobPriority::default_for(&class),
1454            },
1455            scheduling: JobScheduling {
1456                trigger: TriggerSource::Demand,
1457                scheduled_at: now,
1458                await_jobs: vec![],
1459                deadline: None,
1460                cron_expr: None,
1461            },
1462            lifecycle: JobLifecycle {
1463                status: JobStatus::Pending,
1464                created_at: now,
1465                started_at: None,
1466                completed_at: None,
1467                lease_until: None,
1468                result: None,
1469            },
1470            retry: JobRetry::default(),
1471            lineage: JobLineage {
1472                triggered_by: None,
1473                parent_job: None,
1474                pipeline_id: None,
1475                pipeline_step: None,
1476                children: vec![],
1477                cost_usd: None,
1478            },
1479        }
1480    }
1481
1482    #[test]
1483    fn job_priority_as_u8_ordering() {
1484        assert!(JobPriority::High.as_u8() > JobPriority::Normal.as_u8());
1485        assert!(JobPriority::Normal.as_u8() > JobPriority::Low.as_u8());
1486        assert!(JobPriority::Low.as_u8() > JobPriority::Deferred.as_u8());
1487    }
1488
1489    #[test]
1490    fn job_priority_default_for_class() {
1491        assert_eq!(
1492            JobPriority::default_for(&JobClass::Agent),
1493            JobPriority::High
1494        );
1495        assert_eq!(
1496            JobPriority::default_for(&JobClass::Human),
1497            JobPriority::High
1498        );
1499        assert_eq!(
1500            JobPriority::default_for(&JobClass::Api),
1501            JobPriority::Normal
1502        );
1503        assert_eq!(
1504            JobPriority::default_for(&JobClass::System),
1505            JobPriority::Low
1506        );
1507    }
1508
1509    #[test]
1510    fn job_mode_default_is_batch() {
1511        assert_eq!(JobMode::default(), JobMode::Batch);
1512    }
1513
1514    #[test]
1515    fn job_retry_default_values() {
1516        let r = JobRetry::default();
1517        assert_eq!(r.count, 0);
1518        assert_eq!(r.max, 3);
1519        assert!(r.errors.is_empty());
1520    }
1521
1522    #[test]
1523    fn retry_backoff_fixed_is_constant() {
1524        let b = RetryBackoff::Fixed(10);
1525        assert_eq!(b.duration_for(0), Duration::from_secs(10));
1526        assert_eq!(b.duration_for(5), Duration::from_secs(10));
1527    }
1528
1529    #[test]
1530    fn retry_backoff_exponential_caps_at_max() {
1531        let b = RetryBackoff::Exponential { base: 5, max: 120 };
1532        assert_eq!(b.duration_for(0), Duration::from_secs(5));
1533        assert_eq!(b.duration_for(1), Duration::from_secs(10));
1534        assert_eq!(b.duration_for(10), Duration::from_secs(120)); // plafonné
1535    }
1536
1537    #[test]
1538    fn job_record_serialize_roundtrip() {
1539        let record = make_job_record(
1540            Job::Embed(EmbedSpec {
1541                note_id: Ulid::new(),
1542                tenant_id: "main".to_string(),
1543                force_regenerate: false,
1544            }),
1545            JobClass::Agent,
1546        );
1547
1548        let json =
1549            serde_json::to_string(&record).expect("JobRecord doit être sérialisable en JSON");
1550        let back: JobRecord =
1551            serde_json::from_str(&json).expect("JobRecord doit être désérialisable depuis JSON");
1552        assert_eq!(record.id, back.id);
1553        assert_eq!(record.spec.priority.as_u8(), back.spec.priority.as_u8());
1554    }
1555
1556    #[test]
1557    fn job_workspace_paths_format() {
1558        let record = make_job_record(Job::Consolidate, JobClass::System);
1559        let ws = JobWorkspace::from_job(&record);
1560        assert!(ws.input.ends_with("/input/"));
1561        assert!(ws.output.ends_with("/output/"));
1562        assert!(ws.meta.ends_with("/meta/"));
1563    }
1564
1565    #[test]
1566    fn dry_run_job_record() {
1567        let record = {
1568            let now = Utc::now();
1569            JobRecord {
1570                id: Ulid::new(),
1571                spec: JobSpec {
1572                    kind: Job::Curate(CurateSpec {
1573                        note_id: Ulid::new(),
1574                        tenant_id: "main".to_string(),
1575                        ..Default::default()
1576                    }),
1577                    class: JobClass::Agent,
1578                    mode: JobMode::DryRun,
1579                    scope: JobScope::VaultWide,
1580                    priority: JobPriority::High,
1581                },
1582                scheduling: JobScheduling {
1583                    trigger: TriggerSource::Demand,
1584                    scheduled_at: now,
1585                    await_jobs: vec![],
1586                    deadline: None,
1587                    cron_expr: None,
1588                },
1589                lifecycle: JobLifecycle {
1590                    status: JobStatus::Pending,
1591                    created_at: now,
1592                    started_at: None,
1593                    completed_at: None,
1594                    lease_until: None,
1595                    result: None,
1596                },
1597                retry: JobRetry::default(),
1598                lineage: JobLineage {
1599                    triggered_by: None,
1600                    parent_job: None,
1601                    pipeline_id: None,
1602                    pipeline_step: None,
1603                    children: vec![],
1604                    cost_usd: None,
1605                },
1606            }
1607        };
1608        assert!(record.is_dry_run());
1609    }
1610
1611    #[test]
1612    fn job_output_dry_run_format() {
1613        let out = JobOutput::dry_run(42, "test curate");
1614        assert!(out.notes_created.is_empty());
1615        assert!(out.result_note_md.contains("DRY-RUN"));
1616        assert!(out.result_note_md.contains("42"));
1617    }
1618
1619    #[test]
1620    fn job_filter_default_limit() {
1621        let f = JobFilter::default();
1622        assert_eq!(f.limit, 50);
1623        assert!(f.class.is_none());
1624        assert!(f.status.is_none());
1625    }
1626
1627    #[test]
1628    fn gradatum_job_priority_matches_spec() {
1629        let record = make_job_record(Job::Agent, JobClass::Human);
1630        let expected_priority = record.spec.priority.as_u8();
1631        let job = GradatumJob {
1632            priority: expected_priority,
1633            record,
1634        };
1635        assert_eq!(job.priority, 3); // Human → High → 3
1636    }
1637
1638    #[test]
1639    fn vault_scope_is_alias_of_job_scope() {
1640        // VaultScope = JobScope — vérification que le type alias compile
1641        let vs: VaultScope = JobScope::VaultWide;
1642        let js: JobScope = vs;
1643        assert!(matches!(js, JobScope::VaultWide));
1644    }
1645
1646    #[test]
1647    fn queue_event_variants_serialize() {
1648        let id = Ulid::new();
1649        let ev = QueueEvent::JobInserted(id);
1650        let json = serde_json::to_string(&ev).expect("QueueEvent doit être sérialisable");
1651        assert!(json.contains("JobInserted"));
1652    }
1653
1654    // ── Tests PurgeSpec + stabilité serde position 5 ─────────────────────────
1655
1656    /// PurgeSpec par défaut : dry_run=true, grace_days=Some(30), mode=Lifecycle.
1657    ///
1658    /// Vérifie les valeurs prudentes par défaut.
1659    #[test]
1660    fn purge_spec_default_values() {
1661        let spec = PurgeSpec::default();
1662        assert!(spec.dry_run, "dry_run doit être true par défaut");
1663        assert_eq!(spec.grace_days, Some(30));
1664        assert_eq!(spec.mode, PurgeMode::Lifecycle);
1665    }
1666
1667    /// Job::Purge(PurgeSpec) est sérialisable en JSON et le type discriminant est "Purge".
1668    ///
1669    /// Stabilité serde : `#[serde(tag = "type", content = "data")]` encode le variant
1670    /// par son nom ("Purge") — invariant pour la colonne `kind` de la queue.
1671    #[test]
1672    fn purge_job_serializes_with_correct_type_tag() {
1673        let job = Job::Purge(PurgeSpec::default());
1674        let json = serde_json::to_string(&job).expect("Job::Purge doit être sérialisable");
1675        assert!(
1676            json.contains("\"type\":\"Purge\""),
1677            "le tag serde doit être 'Purge', obtenu : {json}"
1678        );
1679    }
1680
1681    /// Roundtrip JSON de Job::Purge — désérialisation depuis JSON correcte.
1682    #[test]
1683    fn purge_job_json_roundtrip() {
1684        let original = Job::Purge(PurgeSpec {
1685            mode: PurgeMode::Lifecycle,
1686            dry_run: false,
1687            grace_days: Some(7),
1688        });
1689        let json = serde_json::to_string(&original).expect("sérialisation Job::Purge");
1690        let back: Job = serde_json::from_str(&json).expect("désérialisation Job::Purge");
1691        assert!(
1692            matches!(back, Job::Purge(ref s) if !s.dry_run && s.grace_days == Some(7)),
1693            "roundtrip JSON incorrect : {json}"
1694        );
1695    }
1696
1697    /// job_kind_str retourne "Purge" pour Job::Purge(_).
1698    #[test]
1699    fn job_kind_str_purge() {
1700        let job = Job::Purge(PurgeSpec::default());
1701        assert_eq!(job_kind_str(&job), "Purge");
1702    }
1703
1704    /// PurgeSpec grace_days=None : pas de délai de grâce, dry_run=true par défaut.
1705    #[test]
1706    fn purge_spec_no_grace_serializes() {
1707        let spec = PurgeSpec {
1708            mode: PurgeMode::Lifecycle,
1709            dry_run: true,
1710            grace_days: None,
1711        };
1712        let json = serde_json::to_string(&spec).expect("sérialisation PurgeSpec sans grace");
1713        let back: PurgeSpec = serde_json::from_str(&json).expect("désérialisation PurgeSpec");
1714        assert!(back.grace_days.is_none());
1715        assert!(back.dry_run);
1716    }
1717
1718    // ── Tests ForgetSpec + stabilité serde position 12 ───────────────────────
1719
1720    /// ForgetSpec par défaut : dry_run=true, confirm_ulids vide.
1721    #[test]
1722    fn forget_spec_default_values() {
1723        let spec = ForgetSpec::default();
1724        assert!(spec.dry_run, "dry_run doit être true par défaut");
1725        assert!(spec.confirm_ulids.is_empty());
1726        assert!(spec.forgotten_by.is_none());
1727    }
1728
1729    /// Job::Forget(ForgetSpec) est sérialisable en JSON et le type discriminant est "Forget".
1730    ///
1731    /// Stabilité serde : `#[serde(tag = "type", content = "data")]` encode le variant
1732    /// par son nom ("Forget") — invariant pour la colonne `kind` de la queue.
1733    #[test]
1734    fn forget_job_serializes_with_correct_type_tag() {
1735        let job = Job::Forget(ForgetSpec::default());
1736        let json = serde_json::to_string(&job).expect("Job::Forget doit être sérialisable");
1737        assert!(
1738            json.contains("\"type\":\"Forget\""),
1739            "le tag serde doit être 'Forget', obtenu : {json}"
1740        );
1741    }
1742
1743    /// Roundtrip JSON de Job::Forget — désérialisation correcte depuis JSON.
1744    #[test]
1745    fn forget_job_json_roundtrip() {
1746        let original = Job::Forget(ForgetSpec {
1747            scope: ForgetScope::Topic {
1748                query: "secret api-key".to_string(),
1749                vault: None,
1750                limit: Some(10),
1751            },
1752            dry_run: false,
1753            forgotten_by: Some("operator-1".to_string()),
1754            confirm_ulids: vec!["01HTEST00000000000000000AB".to_string()],
1755        });
1756        let json = serde_json::to_string(&original).expect("sérialisation Job::Forget");
1757        let back: Job = serde_json::from_str(&json).expect("désérialisation Job::Forget");
1758        assert!(
1759            matches!(back, Job::Forget(ref s) if !s.dry_run && s.forgotten_by.as_deref() == Some("operator-1")),
1760            "roundtrip JSON incorrect : {json}"
1761        );
1762    }
1763
1764    /// job_kind_str retourne "Forget" pour Job::Forget(_).
1765    #[test]
1766    fn job_kind_str_forget() {
1767        let job = Job::Forget(ForgetSpec::default());
1768        assert_eq!(job_kind_str(&job), "Forget");
1769    }
1770
1771    /// ForgetScope::Locus sérialisable roundtrip.
1772    #[test]
1773    fn forget_scope_locus_roundtrip() {
1774        let spec = ForgetSpec {
1775            scope: ForgetScope::Locus {
1776                vault: "main".to_string(),
1777                locus: "inbox/old/".to_string(),
1778            },
1779            dry_run: true,
1780            forgotten_by: None,
1781            confirm_ulids: vec![],
1782        };
1783        let json = serde_json::to_string(&spec).expect("sérialisation ForgetScope::Locus");
1784        let back: ForgetSpec =
1785            serde_json::from_str(&json).expect("désérialisation ForgetScope::Locus");
1786        assert!(
1787            matches!(back.scope, ForgetScope::Locus { ref locus, .. } if locus == "inbox/old/")
1788        );
1789    }
1790
1791    /// ForgetScope::Agent sérialisable roundtrip avec vaults vide → défaut ["main"] côté handler.
1792    #[test]
1793    fn forget_scope_agent_empty_vaults() {
1794        let spec = ForgetSpec {
1795            scope: ForgetScope::Agent {
1796                agent_id: "claude-agent".to_string(),
1797                vaults: vec![],
1798            },
1799            dry_run: true,
1800            forgotten_by: None,
1801            confirm_ulids: vec![],
1802        };
1803        let json = serde_json::to_string(&spec).expect("sérialisation ForgetScope::Agent");
1804        let back: ForgetSpec =
1805            serde_json::from_str(&json).expect("désérialisation ForgetScope::Agent");
1806        assert!(matches!(back.scope, ForgetScope::Agent { ref vaults, .. } if vaults.is_empty()));
1807    }
1808}