# Tâche 2 — SJ-0 (socle : types + réduction + résolution de profil) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox. **Goal:** Poser le socle de la tâche 2, **purement additif** : étendre `shared/types.ts` (unions élargies + blocs optionnels, rétro-compatibles), enrichir le réducteur de lignes (préfixes Docker), et ajouter `resolveTemplate(action, osFamily)` avec fallback `base`. Aucun changement de wiring (refresh/execute inchangés). **Architecture:** Extensions additives. Référence design : `docs/design/tache2/40-contrats-json.md` (types), `60-profils-os-machine.md` (résolution), `99-couverture-gate.md`. Tous les ajouts sont optionnels/élargis ⇒ un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide (vérifié par `tsc`). **Tech Stack:** TypeScript, vitest. --- ## Invariants - **Rétro-compat stricte** : ne rien retirer/renommer. Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` (WIP) et tout autre contenu actuel de `shared/types.ts`. - **Aucun changement de comportement** : on n'altère PAS `refresh.ts`/`execute.ts` en SJ-0 (la bascule du refresh sur les nouveaux templates = SJ-1). - Réducteur : **garder `reduceAptLines`** (imports existants dans refresh/execute) ; ajouter les préfixes Docker et un alias `reduceLines`. **Ne PAS renommer le fichier** `aptReduce.ts` (éviter de toucher les imports de refresh/execute — churn/concurrence). - Tree partagé / WIP concurrent : ne toucher QUE `shared/types.ts`, `server/templates/aptReduce.ts` (+test), `server/templates/render.ts` (+ test resolveTemplate), et les fichiers de test. **Ne pas committer.** ## File Structure ``` shared/types.ts # MODIF : unions élargies + interfaces + champs optionnels shared/types.test.ts # NOUVEAU : verrouille la rétro-compat (compile + runtime léger) server/templates/aptReduce.ts # MODIF : préfixes Docker + alias reduceLines server/templates/aptReduce.test.ts # MODIF : +cas Docker server/templates/render.ts # MODIF : +resolveTemplate server/templates/resolveTemplate.test.ts # NOUVEAU ``` --- ## Task 1 : Étendre `shared/types.ts` **Files:** Modify `shared/types.ts` ; Create `shared/types.test.ts`. - [ ] **Step 1 : Relire le fichier réel** (`rtk read shared/types.ts`) pour repérer le contenu à préserver (`MachineStatus`, `MachineView`, `ServerCapabilities`, etc.). - [ ] **Step 2 : Appliquer les extensions** (élargir les unions existantes, remplacer `AptPackage`/`UpdateSnapshot`/`ExecutionResult` par les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuis `docs/design/tache2/40-contrats-json.md`) : ```ts export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown"; export type MachineKind = | "physical" | "vm" | "proxmox_host" | "lxc" | "raspberry_pi" | "workstation" | "unknown"; export type AptProxyMode = "direct" | "runtime" | "persistent"; export type ActionType = | "apt_full_upgrade" | "reboot" | "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade" | "apt_autoremove" | "apt_clean" | "reboot_verified" | "docker_scan" | "docker_inspect_current" | "docker_pull_check" | "docker_compose_apply" | "docker_prune_images" | "docker_compose_down" | "machine_probe" | "post_install"; export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error"; // ExecutionStatus, MachineStatus : INCHANGÉS (préserver l'existant) export interface AptPackage { name: string; currentVersion: string | null; targetVersion: string; origin: string | null; arch?: string; operation?: "upgrade" | "install" | "remove" | "hold"; severityHint?: "normal" | "security"; } export interface AptSnapshotDetail { enabled: boolean; count: number; rebootRequired: boolean; packages: AptPackage[]; status?: SnapshotStatus; upgradeCount?: number; distUpgradeCount?: number; installed?: AptPackage[]; removed?: AptPackage[]; held?: string[]; rebootPkgs?: string[]; } export interface DockerSnapshotService { serviceName: string; image: string; currentImageId?: string | null; currentDigest?: string | null; candidateImageId?: string | null; candidateDigest?: string | null; currentVersion?: string | null; candidateVersion?: string | null; sourceUrl?: string | null; status?: "up_to_date" | "updates_available" | "warning" | "error"; } export interface DockerSnapshotStack { name: string; workingDir: string; composeFiles: string[]; projectName?: string | null; status: "candidate" | "enabled" | "ignored" | "error"; detectedBy?: "root_scan" | "label" | "manual"; services: DockerSnapshotService[]; } export interface DockerSnapshot { enabled: boolean; installed: boolean; count: number; declaredRoots?: string[]; stacks: DockerSnapshotStack[]; status?: SnapshotStatus; } export interface SnapshotError { source: "apt" | "docker" | "post_install" | "ssh" | "system"; kind: string; severity: "info" | "warning" | "error"; message: string; remediation?: string; importantLines?: string[]; } export interface UpdateSnapshot { machineId: string; hostname: string; os: { family: OsFamily; version: string }; checkedAt: string; status: MachineStatus; apt: AptSnapshotDetail; schemaVersion?: number; kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined"; machineKind?: MachineKind; docker?: DockerSnapshot; errors?: SnapshotError[]; rawHints?: { logImportantLines: string[] }; } export interface AptChange { name: string; arch?: string; fromVersion: string | null; toVersion: string | null; operation: "upgraded" | "installed" | "removed" | "unchanged"; origin?: string | null; } export interface AptExecutionResult { planned: AptPackage[]; applied: AptChange[]; installed: AptChange[]; removed: AptChange[]; held: string[]; errors?: SnapshotError[]; rebootRequiredAfterRun: boolean; } export interface DockerImageChange { stack: string; serviceName?: string; imageRef?: string; fromImageId?: string | null; toImageId?: string | null; fromDigest?: string | null; toDigest?: string | null; operation: "pulled" | "recreated" | "pruned"; } export interface DockerExecutionResult { pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] }; up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] }; prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] }; errors?: SnapshotError[]; } export interface RebootResult { beforeBootId: string | null; afterBootId: string | null; requestedAt: string; sshWentDownAt: string | null; sshCameBackAt: string | null; waitedSeconds: number; status: "ok" | "reboot_command_failed" | "ssh_never_went_down" | "machine_did_not_return" | "boot_id_unchanged" | "timeout"; lastRebootDurationSeconds?: number; nextRecommendedWaitSeconds?: number; errors?: SnapshotError[]; } export interface PostInstallResult { profilesRun: string[]; variablesUsed: Record; filesModified: string[]; packagesInstalled: string[]; servicesEnabled: string[]; rebootsRequested: boolean; networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null }; errors?: SnapshotError[]; } export interface ExecutionResult { executionId: string; machineId: string; startedAt: string; finishedAt: string; mode: "manual" | "scheduled" | "hermes_requested"; action: ActionType; status: ExecutionStatus; rebootRequiredAfterRun: boolean; importantLogLines: string[]; rawLogRef: string; reportRef: string; schemaVersion?: number; apt?: AptExecutionResult; docker?: DockerExecutionResult; reboot?: RebootResult; postInstall?: PostInstallResult; errors?: SnapshotError[]; } ``` > Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` et tout autre contenu présent. Le bloc `apt` de `UpdateSnapshot` reste **requis** (forme jalon 1) ; `mode` de `ExecutionResult` était le littéral `"manual"` → l'union l'inclut. - [ ] **Step 3 : Test de rétro-compat `shared/types.test.ts`** ```ts import { describe, it, expect } from "vitest"; import type { UpdateSnapshot, ExecutionResult } from "./types.js"; describe("rétro-compatibilité des contrats", () => { it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => { const snap: UpdateSnapshot = { machineId: "m1", hostname: "h", os: { family: "debian", version: "12" }, checkedAt: "2026-06-05T10:00:00Z", status: "ok", apt: { enabled: true, count: 0, rebootRequired: false, packages: [] }, }; expect(snap.apt.count).toBe(0); }); it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => { const exec: ExecutionResult = { executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b", mode: "manual", action: "apt_full_upgrade", status: "ok", rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr", }; expect(exec.action).toBe("apt_full_upgrade"); }); it("accepte les nouveaux blocs optionnels", () => { const snap: UpdateSnapshot = { machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" }, checkedAt: "t", status: "updates_available", apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" }, schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host", docker: { enabled: false, installed: false, count: 0, stacks: [] }, errors: [], }; expect(snap.docker?.installed).toBe(false); }); }); ``` - [ ] **Step 4 :** Run `rtk pnpm vitest run shared/types.test.ts` → PASS (3). Puis `rtk pnpm check` → **0 erreur** (c'est le vrai test de rétro-compat : si un consommateur existant casse à cause d'un retrait/renommage, tsc le révèle). Si `check` signale une erreur dans un fichier consommateur (`refresh.ts`/`execute.ts`/`machines.ts`/WIP) causée par TON changement de types, corrige le type (rends additif) — ne casse pas les consommateurs. - [ ] **Step 5 : (pas de commit)** --- ## Task 2 : Réducteur enrichi (préfixes Docker) **Files:** Modify `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`. - [ ] **Step 1 : Relire `server/templates/aptReduce.ts`** (état réel). - [ ] **Step 2 : Ajouter un cas Docker au test `aptReduce.test.ts`** ```ts it("garde aussi les lignes Docker utiles", () => { const raw = [ "Pulling jellyfin ...", "Status: Downloaded newer image for jellyfin/jellyfin:latest", "Recreating jellyfin ...", "Started jellyfin", "blabla inutile", "Total reclaimed space: 1.2GB", ].join("\n"); expect(reduceLines(raw)).toEqual([ "Pulling jellyfin ...", "Status: Downloaded newer image for jellyfin/jellyfin:latest", "Recreating jellyfin ...", "Started jellyfin", "Total reclaimed space: 1.2GB", ]); }); ``` Ajouter `reduceLines` à l'import existant : `import { reduceAptLines, reduceLines } from "./aptReduce.js";` - [ ] **Step 3 : Lancer (échec attendu)** — `rtk pnpm vitest run server/templates/aptReduce.test.ts` → FAIL (`reduceLines` introuvable / lignes Docker non gardées). - [ ] **Step 4 : Étendre `server/templates/aptReduce.ts`** ```ts // server/templates/aptReduce.ts const PREFIXES = [ // APT / dpkg (jalon 1) "Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:", // Docker (SJ-0) "Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error", ]; const CONTAINS = [ "reboot-required", "REBOOT_REQUIRED", "deleted", "Total reclaimed space", ]; /** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */ export function reduceLines(raw: string): string[] { return raw .split("\n") .map((l) => l.trimEnd()) .filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c))); } /** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */ export const reduceAptLines = reduceLines; ``` > Garder l'export `reduceAptLines` (utilisé par `refresh.ts`/`execute.ts`). `reduceLines` est le nouveau nom canonique. - [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/aptReduce.test.ts` → PASS (cas APT existants + nouveau cas Docker). `rtk pnpm check` → 0 erreur. - [ ] **Step 6 : (pas de commit)** --- ## Task 3 : `resolveTemplate(action, osFamily)` **Files:** Modify `server/templates/render.ts` ; Create `server/templates/resolveTemplate.test.ts`. - [ ] **Step 1 : Relire `server/templates/render.ts`** (état réel : `TEMPLATES_ROOT`, `renderTemplate`, `TemplateVars`). - [ ] **Step 2 : Test `server/templates/resolveTemplate.test.ts`** ```ts import { describe, it, expect } from "vitest"; import { resolveTemplate } from "./render.js"; describe("resolveTemplate", () => { it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => { const noneExist = () => false; expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl"); expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl"); }); it("choisit le template OS spécifique quand il existe", () => { const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl"; expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl"); }); it("unknown retombe toujours sur apt/", () => { const all = () => true; expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl"); }); }); ``` - [ ] **Step 3 : Lancer (échec)** — `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → FAIL. - [ ] **Step 4 : Ajouter `resolveTemplate` à `server/templates/render.ts`** (sans toucher `renderTemplate`/`TemplateVars` existants ; ajouter l'import `existsSync`) : ```ts import { existsSync } from "node:fs"; // ... (TEMPLATES_ROOT, renderTemplate existants inchangés) ... /** Existence par défaut d'un template relatif à templates/. */ function defaultExists(rel: string): boolean { return existsSync(resolve(TEMPLATES_ROOT, rel)); } /** * Résout le chemin de template le plus spécifique pour (action, OS) : * `/.sh.tpl` s'il existe, sinon fallback base `apt/.sh.tpl`. * `exists` est injectable pour les tests. */ export function resolveTemplate( action: string, osFamily: string, exists: (rel: string) => boolean = defaultExists, ): string { const specific = `${osFamily}/${action}.sh.tpl`; if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific; return `apt/${action}.sh.tpl`; } ``` > Note : `renderTemplate` accepte déjà un `relPath` (ex. `apt/full-upgrade.sh.tpl`), donc `renderTemplate(resolveTemplate(action, osFamily), vars)` fonctionnera en SJ-1 sans modifier `renderTemplate`. - [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → PASS (3). `rtk pnpm check` → 0 erreur. - [ ] **Step 6 : (pas de commit)** --- ## Task 4 : Vérification finale SJ-0 - [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` Expected: 0 erreur TS ; tous tests verts (49 Phase 2 + 3 types + 1 Docker reduce + 3 resolveTemplate ≈ 56) ; build OK. - [ ] **Step 2 :** Reporter : types étendus rétro-compatibles (tsc vert = preuve), réducteur Docker, `resolveTemplate` prêt pour SJ-1. **Ne pas committer.** --- ## Self-Review (couverture SJ-0) - Types étendus (unions + blocs optionnels) → Task 1, rétro-compat verrouillée par `tsc` + test. ✓ - Réducteur + préfixes Docker → Task 2 (`reduceLines` + alias `reduceAptLines` conservé). ✓ - `resolveTemplate(action, osFamily)` + fallback base → Task 3. ✓ - `schemaVersion` → présent dans `UpdateSnapshot`/`ExecutionResult` (optionnel). ✓ - Aucun wiring modifié (refresh/execute intacts) ⇒ non-régression jalon 1. ✓ Décisions assumées : fichier `aptReduce.ts` NON renommé (alias `reduceLines` ajouté) pour éviter de toucher les imports de refresh/execute (churn/concurrence) — le nom canonique `reduceLines` est exporté ; renommage physique reporté à un nettoyage ultérieur. `resolveTemplate` avec `exists` injectable pour testabilité des deux branches.