Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2 et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 ✅, 2-8 🟡). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
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 deshared/types.ts. - Aucun changement de comportement : on n'altère PAS
refresh.ts/execute.tsen 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 aliasreduceLines. Ne PAS renommer le fichieraptReduce.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/ExecutionResultpar les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuisdocs/design/tache2/40-contrats-json.md) :
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<string, string | number | boolean>;
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,ServerCapabilitieset tout autre contenu présent. Le blocaptdeUpdateSnapshotreste requis (forme jalon 1) ;modedeExecutionResultétait le littéral"manual"→ l'union l'inclut.
- Step 3 : Test de rétro-compat
shared/types.test.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). Puisrtk 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). Sichecksignale 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
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 (reduceLinesintrouvable / lignes Docker non gardées). -
Step 4 : Étendre
server/templates/aptReduce.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é parrefresh.ts/execute.ts).reduceLinesest 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
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 toucherrenderTemplate/TemplateVarsexistants ; ajouter l'importexistsSync) :
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) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.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 :
renderTemplateaccepte déjà unrelPath(ex.apt/full-upgrade.sh.tpl), doncrenderTemplate(resolveTemplate(action, osFamily), vars)fonctionnera en SJ-1 sans modifierrenderTemplate.
-
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 buildExpected: 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,
resolveTemplateprê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+ aliasreduceAptLinesconservé). ✓ resolveTemplate(action, osFamily)+ fallback base → Task 3. ✓schemaVersion→ présent dansUpdateSnapshot/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.