Commit 364ae92e authored by Kourser's avatar Kourser
Browse files

Les services du jour J, ré-éprouvés sans attendre le jour J

L'indicateur de préparation mesurait le dossier. Il ne disait rien de ce sur
quoi la cellule s'appuiera dans la première heure — et ces choses se dégradent
seules : un certificat expire, un port sortant se ferme, un modèle n'est plus
chargé. On le découvrait au moment d'en avoir besoin (EF-209b).

Toutes les six heures, Kastell éprouve la visio, le dépôt d'objets (écrire,
relire, effacer), le moteur de transcription et son modèle, le relais de
courriel, le relais des notifications, chaque destination de diffusion et
l'adresse de la page publique. Rien n'est envoyé ni diffusé pour le faire. Une
épreuve ne tombe qu'après deux échecs ; une épreuve trop ancienne cesse de
compter comme bonne ; ce qui n'est pas configuré est dit, marqué indispensable
ou facultatif.

La personne chargée de la préparation est prévenue une fois par panne, puis de
son rétablissement. L'alerte est réservée dans la même transaction que la
panne : la passe périodique et un « Réessayer » qui se croisaient envoyaient
deux courriels pour une panne. Une alerte qui ne part pas — le courriel est
parfois la panne — n'est pas comptée comme partie, et se réessaie. Le courriel
dit lequel, pour qui, le geste à faire, et mène à l'onglet.

Le tableau garde le dernier succès, le début de la panne et une semaine
d'historique. Chaque constat est dit dans la langue de qui le lit, jamais par
un code système, et nomme l'écran où le service se règle ; il nomme aussi qui a
été prévenu, sans montrer les membres d'une autre organisation. Chaque ligne
s'éprouve à son rythme.

L'onglet vit dans l'adresse, et le dossier se range en cinq sections.
« Prêt techniquement » se lit à côté de la note du dossier, pas dedans. L'écran
des destinations et celui de la page publique lisent le même état, par la même
épreuve. Une panne se voit dans la barre d'état d'une crise ouverte, sur l'entrée
du dossier dans le rail et sur l'accueil.

0056 marque comme remises les alertes d'avant 0055 : sans elle, une panne
signalée le matin l'aurait été une seconde fois à la passe suivante — trouvé
sur une instance réelle.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1157fd3b
Loading
Loading
Loading
Loading
+11 −0
Changes for apps/api/src/adaptateurs/notify.ts: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -14,6 +14,17 @@ export interface Message {
export interface Transport {
  readonly nom: string;
  envoyer(message: Message): Promise<void>;
  /**
   * S'assurer que le transport remettrait, sans rien envoyer (EF-209b).
   *
   * Facultatif : un transport qui n'écrit que dans les traces n'a rien à
   * éprouver, et prétendre le contraire afficherait un vert sans objet. Lève
   * l'erreur brute du relais : c'est à l'épreuve de la dire lisiblement, dans
   * la langue de qui la lira.
   */
  eprouver?(): Promise<void>;
  /** Ce qu'on éprouve — « hôte:port » —, pour que le constat le nomme. */
  readonly cible?: string;
}

/**
+3 −0
Changes for apps/api/src/adaptateurs/poussee.ts: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -62,6 +62,8 @@ export class JetonMort extends Error {

export interface Poussee {
  readonly nom: string;
  /** L'hôte du relais, pour qu'un constat d'épreuve le nomme (EF-209b). */
  readonly cible?: string;
  /** Vrai si l'instance peut réellement faire sonner un téléphone (EM-76). */
  disponible(): Promise<boolean>;
  frapper(h: Heurtoir): Promise<void>;
@@ -121,6 +123,7 @@ export function pousseeRelais(url: string, cle: string, delaiMs = 8000): Poussee
  const base = url.replace(/\/+$/, "");
  return {
    nom: `relais(${new URL(base).host})`,
    cible: new URL(base).host,

    /**
     * « Joignable » ne suffit pas : un relais qui répond mais n'a pas de clé
+4 −0
Changes for apps/api/src/adaptateurs/smtp.ts: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -84,6 +84,10 @@ export function transportSmtp(c: ConfigurationSmtp): Transport {
        text: message.texte,
      });
    },
    cible: `${c.hote}:${c.port}`,
    async eprouver() {
      await verifierSmtp(c);
    },
  };
}

+3 −0
Changes for apps/api/src/adaptateurs/stockage.ts: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -22,6 +22,8 @@ export interface Partie {

export interface Stockage {
  readonly nom: string;
  /** L'hôte du dépôt, pour qu'un constat d'épreuve le nomme (EF-209b). */
  readonly cible?: string;
  preparer(): Promise<void>;
  deposer(cle: string, corps: Buffer, typeMime: string): Promise<void>;
  ouvrirDepot(cle: string, typeMime: string): Promise<string>;
@@ -51,6 +53,7 @@ export function stockageS3(c: ConfigurationS3): Stockage {
  });

  return {
    cible: c.endpoint ? new URL(c.endpoint).host : `s3.${c.region}.amazonaws.com`,
    nom: `s3(${c.seau})`,

    /** EX-29 : l'instance ne se déclare prête qu'une fois le seau joignable. */
+31 −0
Changes for apps/api/src/adaptateurs/transcription.ts: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -37,12 +37,26 @@ export interface Transcrit {
export interface Transcription {
  readonly nom: string;
  readonly modele: string;
  /** L'hôte du moteur, pour qu'un constat d'épreuve le nomme (EF-209b). */
  readonly cible?: string;
  /**
   * `langue` est un indice, pas un ordre : la plupart des moteurs détectent
   * seuls, et se trompent moins quand on les oriente. Une réunion de crise
   * mélange volontiers deux langues, et forcer la mauvaise coûte tout le texte.
   */
  transcrire(audio: Buffer, nomFichier: string, langue?: string | undefined): Promise<Transcrit>;
  /**
   * Le moteur répond-il, et a-t-il le modèle configuré (EF-209b) ?
   *
   * Le défaut rencontré en vrai : un modèle configuré mais jamais téléchargé,
   * que le moteur refuse par un 404 — découvert à la fin d'une réunion, quand
   * il n'y a plus rien à rattraper.
   *
   * Rend ce qu'on sait du modèle ; lève l'erreur brute quand le moteur ne
   * répond pas ou refuse (« statut » porte alors le code HTTP). La phrase,
   * c'est l'épreuve qui la tourne.
   */
  eprouver?(): Promise<{ modele: "present" | "absent" | "non_publie" }>;
}

export class TranscriptionIndisponible extends Error {
@@ -87,6 +101,23 @@ export function transcriptionOpenAI(c: ConfigurationTranscription): Transcriptio
  return {
    nom: "openai-compatible",
    modele: c.modele,
    cible: new URL(base).host,

    async eprouver() {
      const reponse = await fetch(`${base}/v1/models`, {
        signal: AbortSignal.timeout(10_000),
        headers: c.cle ? { authorization: `Bearer ${c.cle}` } : {},
      });
      // Tous les serveurs de ce dialecte ne publient pas leur liste. Celui-là
      // répond, ce qui vaut d'être dit ; le modèle, lui, reste non vérifié — et
      // on le dit aussi, plutôt que de le laisser croire.
      if (reponse.status === 404) return { modele: "non_publie" };
      if (!reponse.ok) {
        throw Object.assign(new Error(`HTTP ${reponse.status}`), { statut: reponse.status });
      }
      const corps = await reponse.json().catch(() => null) as { data?: { id?: string }[] } | null;
      return { modele: (corps?.data ?? []).some((m) => m.id === c.modele) ? "present" : "absent" };
    },

    async transcrire(audio, nomFichier, langue) {
      const corps = new FormData();
Loading