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}