Files
system_update/docs/superpowers/plans/2026-06-05-tache2-sj2-apt-apply-diff.md
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
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>
2026-06-05 19:50:25 +02:00

15 KiB

Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.

Goal: Enrichir apt/full-upgrade.sh.tpl du snapshot dpkg avant/après, ajouter apt/upgrade.sh.tpl, apt/autoremove.sh.tpl, apt/clean.sh.tpl, calculer le diff dpkg réel (AptExecutionResult : applied/installed/removed), brancher les actions apt_upgrade/apt_autoremove/apt_clean (+ apt_full_upgrade enrichi) dans runAction, et ajouter un timeout d'inactivité optionnel à la couche SSH.

Architecture: Additif. Référence : docs/design/tache2/10-templates-apt.md §4.2-4.4, 40-contrats-json.md §4 (AptExecutionResult/AptChange), 50-erreurs.md (human_interaction_required). Le diff dpkg est calculé en TS (buildAptExecutionResult, pure, TDD). runScriptSudo reçoit une option inactivityTimeoutMs (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; runAction la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose removed[] dans le résultat.

Tech Stack: TypeScript, Mustache, ssh2, vitest.


Invariants

  • apt_full_upgrade et reboot (jalon 1) restent fonctionnels ; on enrichit sans casser le parsing exit/reboot existant de execute.ts.
  • runScriptSudo : nouveau paramètre optionnel inactivityTimeoutMs (défaut 0 = pas de timeout) ⇒ refreshMachine et tout appelant existant inchangés de comportement.
  • ExecutionResult.apt est optionnel (SJ-0) ⇒ une exécution sans diff reste valide.
  • Tree partagé / WIP concurrent : ne toucher QUE server/services/aptParse.ts (+test/fixtures), templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl, server/ssh/client.ts, server/services/execute.ts. Ne pas committer.

File Structure

server/services/aptParse.ts                    # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult
server/services/aptParse.test.ts               # MODIF : +tests diff dpkg
templates/apt/full-upgrade.sh.tpl              # MODIF : +DPKG_BEFORE/AFTER
templates/apt/upgrade.sh.tpl                   # NOUVEAU
templates/apt/autoremove.sh.tpl                # NOUVEAU
templates/apt/clean.sh.tpl                     # NOUVEAU
server/ssh/client.ts                           # MODIF : +inactivityTimeoutMs (additif)
server/services/execute.ts                     # MODIF : actions APT + buildAptExecutionResult + timeout

Task 1 : Diff dpkg (TDD)

Files: Modify server/services/aptParse.ts, server/services/aptParse.test.ts.

  • Step 1 : Test (échec attendu) — ajouter à aptParse.test.ts
import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js";

const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER  = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";

describe("parseDpkgList", () => {
  it("indexe par package:arch", () => {
    const m = parseDpkgList("libc6\t2.31-13\tamd64");
    expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
  });
});

describe("buildAptExecutionResult", () => {
  it("calcule le diff réel before/after", () => {
    const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
    expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
    expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
    expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
    expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
    expect(r.rebootRequiredAfterRun).toBe(true);
  });
});
  • Step 2 : Lancer (échec)rtk pnpm vitest run server/services/aptParse.test.ts → FAIL.

  • Step 3 : Étendre server/services/aptParse.ts

import type { AptChange, AptExecutionResult } from "@shared/types.js";

export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
  const out: Record<string, { version: string; arch: string }> = {};
  for (const line of raw.split("\n")) {
    const parts = line.split("\t");
    if (parts.length < 3) continue;
    const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
    if (!name) continue;
    out[`${name}:${arch}`] = { version, arch };
  }
  return out;
}

/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
  const before = parseDpkgList(beforeRaw);
  const after = parseDpkgList(afterRaw);
  const applied: AptChange[] = [];
  const installed: AptChange[] = [];
  const removed: AptChange[] = [];

  for (const key of Object.keys(after)) {
    const [name] = key.split(":");
    const a = after[key]!;
    const b = before[key];
    if (!b) {
      const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
      installed.push(change); applied.push(change);
    } else if (b.version !== a.version) {
      applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
    }
  }
  for (const key of Object.keys(before)) {
    if (!after[key]) {
      const [name] = key.split(":");
      const b = before[key]!;
      const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
      removed.push(change); applied.push(change);
    }
  }

  return {
    planned: [],
    applied,
    installed,
    removed,
    held: [],
    rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
  };
}
  • Step 4 : Lancer (succès)rtk pnpm vitest run server/services/aptParse.test.ts → PASS. rtk pnpm check → 0 erreur.

  • Step 5 : (pas de commit)


Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean)

Files: Modify templates/apt/full-upgrade.sh.tpl ; Create upgrade.sh.tpl, autoremove.sh.tpl, clean.sh.tpl.

  • Step 1 : Remplacer templates/apt/full-upgrade.sh.tpl (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que execute.ts parse déjà)
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_FULLUPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
  • Step 2 : Créer templates/apt/upgrade.sh.tpl
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_UPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
  • Step 3 : Créer templates/apt/autoremove.sh.tpl
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
  • Step 4 : Créer templates/apt/clean.sh.tpl
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
  • Step 5 : rtk pnpm vitest run server/templates/render.test.ts reste vert. (pas de commit)

Task 3 : Timeout d'inactivité SSH (additif)

Files: Modify server/ssh/client.ts.

  • Step 1 : Relire server/ssh/client.ts (signatures runScriptSudo, execStream).

  • Step 2 : Ajouter un paramètre optionnel inactivityTimeoutMs (défaut 0 = désactivé) à runScriptSudo et execStream. Dans execStream, armer un timer réinitialisé à chaque data/stderr data ; à expiration, stream.close()/conn.end() et reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s")).

export async function runScriptSudo(
  creds: SshCreds,
  script: string,
  onData: (chunk: string) => void,
  inactivityTimeoutMs = 0,
): Promise<RunResult> {
  const conn = await connect(creds);
  try {
    const b64 = Buffer.from(script, "utf8").toString("base64");
    const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
    return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
  } finally {
    conn.end();
  }
}

Dans execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0) : après obtention du stream,

      let timer: NodeJS.Timeout | undefined;
      const arm = () => {
        if (!inactivityTimeoutMs) return;
        clearTimeout(timer);
        timer = setTimeout(() => {
          stream.close();
          reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
        }, inactivityTimeoutMs);
      };
      arm();

Réinitialiser arm() dans les handlers data et stderr data ; clearTimeout(timer) dans close. (Garder le runPlain existant inchangé : il appelle execStream sans le 5e arg ⇒ timeout 0.)

  • Step 3 : rtk pnpm check → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit)

Task 4 : Brancher les actions APT dans runAction

Files: Modify server/services/execute.ts.

  • Step 1 : Relire server/services/execute.ts (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event).

  • Step 2 : Étendre TEMPLATE_FOR

const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
  apt_full_upgrade: "apt/full-upgrade.sh.tpl",
  apt_upgrade: "apt/upgrade.sh.tpl",
  apt_autoremove: "apt/autoremove.sh.tpl",
  apt_clean: "apt/clean.sh.tpl",
  reboot: "apt/reboot.sh.tpl",
};

(Adapter l'accès : const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);)

  • Step 3 : Passer le timeout d'inactivité pour les actions APT (pas pour reboot) :
  const inactivity = action === "reboot" ? 0 : 600000;
  const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity);
  • Step 4 : Construire result.apt (diff dpkg) pour les actions APT applicatives. Après calcul de raw et avant l'écriture du rapport, ajouter :
  let aptResult: AptExecutionResult | undefined;
  if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
    aptResult = buildAptExecutionResult(
      extractSection(raw, "===SU:DPKG_BEFORE===", "==="),  // jusqu'au prochain marqueur
      extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
      extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
    );
  }

⚠️ extractSection(raw, "===SU:DPKG_BEFORE===", "===") : le 2ᵉ marqueur générique "===" capture jusqu'au prochain ===SU:...===. Vérifier que extractSection (dans refresh.ts) coupe bien au 1ᵉʳ "===" rencontré ; sinon, utiliser le marqueur réel suivant ("===SU:APT_FULLUPGRADE===" / "===SU:APT_UPGRADE===" / "===SU:APT_AUTOREMOVE==="). Préférer le marqueur explicite : détecter lequel est présent. Implémentation robuste :

  const afterBeforeMarker =
    raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
    raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
    "===SU:APT_AUTOREMOVE===";
  if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
    aptResult = buildAptExecutionResult(
      extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
      extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
      extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
    );
  }
  • Step 5 : Attacher aptResult au ExecutionResult : dans la construction de result, ajouter ...(aptResult ? { apt: aptResult } : {}). Importer en tête : import { parseRebootRequired, extractSection } ... (extractSection vient de ./refresh.js — déjà importé) et import { buildAptExecutionResult } from "./aptParse.js"; ainsi que import type { AptExecutionResult } from "@shared/types.js";.

  • Step 6 : Vérifierrtk pnpm check && rtk pnpm test → 0 erreur, tests verts. (pas de commit)


Task 5 : Vérification finale SJ-2

  • Step 1 : rtk pnpm check && rtk pnpm test && rtk pnpm build → tout vert.
  • Step 2 : Boot smoke (DB jetable) → /health OK. Nettoyer.
  • Step 3 : Reporter. Vérif live ultérieure : apt_full_upgrade réel sur Debian → vérifier result.apt.applied (diff dpkg réel) + détection removed/held + comportement du timeout. Ne pas committer.

Self-Review (couverture SJ-2)

  • Templates upgrade/full-upgrade enrichi/autoremove/clean → Task 2. ✓
  • Capture DPKG_BEFORE/AFTER + diff réel (AptExecutionResult) → Task 1 + Task 4. ✓
  • Timeout d'inactivité + human_interaction_required → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓
  • Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée removed[] est exposée dans result.apt. ✓ (noté)
  • Non-régression : apt_full_upgrade/reboot jalon 1 conservés ; runScriptSudo rétro-compatible (timeout 0 par défaut) ; ExecutionResult.apt optionnel ; blocs Phase 1 préservés. ✓

Décision : planned/held laissés vides dans AptExecutionResult (portés par le snapshot SJ-1, pas re-simulés à l'exécution). extractSection utilisé avec marqueur explicite pour DPKG_BEFORE. Noms cohérents : parseDpkgList/buildAptExecutionResult (Task 1) utilisés Task 4 ; inactivityTimeoutMs (Task 3) passé Task 4.