Files
system_update/docs/superpowers/plans/2026-06-05-tache1.9-phase1-schema-socle.md
T
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

27 KiB

Tâche 1.9 — Phase 1 (schéma BDD socle) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implémenter la Phase 1 du schéma BDD cible (tache1.9.md §14) : étendre machines/snapshots/executions, créer les tables socle (machine_state, machine_hardware, machine_metrics_latest, machine_events, important_messages, reports, raw_artifacts), et alimenter l'état dérivé + la timeline lors des refresh/exécutions existants.

Architecture: Extension additive (rétro-compatible) du schéma Drizzle/SQLite. Migration générée par drizzle-kit. Un service machineState dérive l'état courant d'une machine depuis un snapshot/exécution et l'« upsert » dans machine_state ; refreshMachine et runAction (existants) sont enrichis pour peupler machine_state, machine_events, et (pour les exécutions) reports + raw_artifacts, ainsi que les nouveaux champs kind/schema_version/important_json des snapshots/exécutions. Aucune modification de l'API ni du frontend (réservé tâches 3/5).

Tech Stack: Drizzle ORM, better-sqlite3, drizzle-kit, vitest.


Contexte & invariants

  • État actuel : server/db/schema.ts contient machines, snapshots, executions (jalon 1, en prod). Voir le fichier.
  • Rétro-compatibilité stricte : on AJOUTE des colonnes/tables ; on ne renomme ni ne supprime rien. En particulier on conserve snapshots.checked_at (le design tache1.9 le nomme created_at, mais le code jalon 1 refresh.ts utilise checkedAt — on ne casse pas).
  • Les nouveaux champs sont nullable ou ont une valeur par défaut, pour que les lignes du jalon 1 restent valides.
  • payload_json / result_json restent la vérité canonique ; machine_state n'est qu'un cache dérivé pour l'UI (jamais source de vérité métier).
  • Pas de FK vers des tables non encore créées (jobs/action_requests/schedules = phases ultérieures) : running_job_id, request_id, job_id sont de simples colonnes text nullable.
  • Ne pas committer (l'utilisateur gère les commits en fin de parcours). Les étapes « Commit » du template sont remplacées par une vérification ; ne PAS exécuter git commit.

Note exécution : ce plan se construit sur l'état courant du working tree (qui contient du WIP non commité : feature capabilities, scaffold Rust). Ne pas annuler ce WIP. Les fichiers touchés ici (server/db/*, server/services/*) ne chevauchent pas le WIP capabilities/frontend.


File Structure

server/db/
├─ schema.ts            # MODIF : +colonnes machines/snapshots/executions, +7 tables
├─ migrations/          # +1 migration générée (drizzle-kit)
└─ schema.test.ts       # NOUVEAU : test que la migration applique le schéma cible
server/services/
├─ machineState.ts      # NOUVEAU : dériver + upsert machine_state, insert events
├─ machineState.test.ts # NOUVEAU : tests purs de dérivation
├─ refresh.ts           # MODIF : peupler snapshot.kind/schema_version/important_json + machine_state + event
└─ execute.ts           # MODIF : champs executions + machine_state + event + reports + raw_artifacts

Task 1 : Étendre le schéma Drizzle + migration

Files:

  • Modify: server/db/schema.ts

  • Create: migration sous server/db/migrations/ (générée)

  • Create: server/db/schema.test.ts

  • Step 1 : Remplacer le contenu de server/db/schema.ts

⚠️ Tree déplacé : schema.ts contient déjà la table apiClients (WIP api_clients, migration 0001_api_clients.sql). Le contenu ci-dessous préserve apiClients (et l'import uniqueIndex). NE PAS supprimer apiClients. La migration générée à l'étape suivante sera donc 0002_* (et non 0001).

// server/db/schema.ts
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";

export const machines = sqliteTable("machines", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  hostname: text("hostname").notNull(),
  port: integer("port").notNull().default(22),
  osFamily: text("os_family").notNull().default("unknown"),
  osVersion: text("os_version"),
  osCodename: text("os_codename"),
  arch: text("arch"),
  machineKind: text("machine_kind"),          // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
  virtualization: text("virtualization"),     // none | qemu | kvm | lxc | docker | vmware | ...
  hardwareProfile: text("hardware_profile"),  // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
  username: text("username").notNull(),
  encPassword: text("enc_password").notNull(),
  encSudoPassword: text("enc_sudo_password"),
  aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
  aptProxyUrl: text("apt_proxy_url"),
  status: text("status").notNull().default("unknown"),
  lastCheckedAt: text("last_checked_at"),
  lastSeenAt: text("last_seen_at"),
  createdAt: text("created_at").notNull(),
  updatedAt: text("updated_at"),
  deletedAt: text("deleted_at"),
});

export const snapshots = sqliteTable("snapshots", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
  kind: text("kind").notNull().default("apt_update_analyze"),
  schemaVersion: integer("schema_version").notNull().default(1),
  checkedAt: text("checked_at").notNull(),
  status: text("status").notNull(),
  payloadJson: text("payload_json").notNull(),
  importantJson: text("important_json"),
  rawLogPath: text("raw_log_path"),
  rawArtifactId: text("raw_artifact_id"),
  sourceJobId: text("source_job_id"),
});

export const executions = sqliteTable("executions", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
  action: text("action").notNull(),
  mode: text("mode").notNull().default("manual"),
  schemaVersion: integer("schema_version").notNull().default(1),
  startedAt: text("started_at").notNull(),
  finishedAt: text("finished_at"),
  status: text("status").notNull(),
  requestId: text("request_id"),
  jobId: text("job_id"),
  resultJson: text("result_json"),
  importantJson: text("important_json"),
  reportPath: text("report_path"),
  rawLogPath: text("raw_log_path"),
  reportId: text("report_id"),
  exitCode: integer("exit_code"),
  errorKind: text("error_kind"),
  errorMessage: text("error_message"),
});

export const machineState = sqliteTable("machine_state", {
  machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
  status: text("status").notNull(),
  aptStatus: text("apt_status"),
  aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
  aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
  aptLastAnalyzeAt: text("apt_last_analyze_at"),
  dockerStatus: text("docker_status"),
  dockerInstalled: integer("docker_installed").notNull().default(0),
  dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
  dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
  dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
  postInstallStatus: text("post_install_status"),
  metricsLastCollectedAt: text("metrics_last_collected_at"),
  cpuLoad1: real("cpu_load1"),
  memoryUsedPercent: real("memory_used_percent"),
  rootUsedPercent: real("root_used_percent"),
  diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
  hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
  runningJobId: text("running_job_id"),
  lastErrorKind: text("last_error_kind"),
  lastErrorMessage: text("last_error_message"),
  updatedAt: text("updated_at").notNull(),
});

export const machineHardware = sqliteTable("machine_hardware", {
  machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
  probeSnapshotId: text("probe_snapshot_id"),
  cpuModel: text("cpu_model"),
  cpuCores: integer("cpu_cores"),
  memoryBytes: integer("memory_bytes"),
  gpusJson: text("gpus_json"),
  disksJson: text("disks_json"),
  networkJson: text("network_json"),
  firmwareJson: text("firmware_json"),
  driverJson: text("driver_json"),
  warningsJson: text("warnings_json"),
  updatedAt: text("updated_at").notNull(),
});

export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
  machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
  snapshotId: text("snapshot_id"),
  collectedAt: text("collected_at").notNull(),
  cpuLoad1: real("cpu_load1"),
  cpuLoad5: real("cpu_load5"),
  cpuCores: integer("cpu_cores"),
  memoryTotalBytes: integer("memory_total_bytes"),
  memoryUsedBytes: integer("memory_used_bytes"),
  memoryAvailableBytes: integer("memory_available_bytes"),
  memoryUsedPercent: real("memory_used_percent"),
  filesystemsJson: text("filesystems_json"),
  rootUsedPercent: real("root_used_percent"),
  warningsJson: text("warnings_json"),
});

export const machineEvents = sqliteTable("machine_events", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
  eventType: text("event_type").notNull(),
  severity: text("severity").notNull(),       // info | warning | error
  createdAt: text("created_at").notNull(),
  actorType: text("actor_type"),              // user | system | schedule | hermes
  actorId: text("actor_id"),
  snapshotId: text("snapshot_id"),
  executionId: text("execution_id"),
  jobId: text("job_id"),
  message: text("message"),
  payloadJson: text("payload_json"),
});

export const importantMessages = sqliteTable("important_messages", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
  source: text("source").notNull(),           // apt | docker | post_install | ssh | system
  category: text("category").notNull(),        // error | warning | future_major_change | ...
  severity: text("severity").notNull(),
  packageName: text("package_name"),
  component: text("component"),
  message: text("message").notNull(),
  rawLineRef: text("raw_line_ref"),
  snapshotId: text("snapshot_id"),
  executionId: text("execution_id"),
  firstSeenAt: text("first_seen_at").notNull(),
  lastSeenAt: text("last_seen_at").notNull(),
  acknowledged: integer("acknowledged").notNull().default(0),
  acknowledgedAt: text("acknowledged_at"),
  acknowledgedBy: text("acknowledged_by"),
  payloadJson: text("payload_json"),
});

export const reports = sqliteTable("reports", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
  executionId: text("execution_id"),
  kind: text("kind").notNull(),                // machine | global | cleanup | hermes
  title: text("title").notNull(),
  path: text("path").notNull(),
  createdAt: text("created_at").notNull(),
  pinned: integer("pinned").notNull().default(0),
  summaryJson: text("summary_json"),
});

export const rawArtifacts = sqliteTable("raw_artifacts", {
  id: text("id").primaryKey(),
  machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
  kind: text("kind").notNull(),                // raw_log | rendered_template | export | screenshot
  path: text("path").notNull(),
  bytes: integer("bytes"),
  sha256: text("sha256"),
  createdAt: text("created_at").notNull(),
  expiresAt: text("expires_at"),
  pinned: integer("pinned").notNull().default(0),
  redacted: integer("redacted").notNull().default(1),
  retentionPolicy: text("retention_policy"),   // default | failed | pinned | short
  deletedAt: text("deleted_at"),
  deleteReason: text("delete_reason"),
  metadataJson: text("metadata_json"),
});

// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
  "api_clients",
  {
    id: text("id").primaryKey(),
    name: text("name").notNull(),
    tokenPrefix: text("token_prefix").notNull(),
    tokenHash: text("token_hash").notNull(),
    scopesJson: text("scopes_json").notNull(),
    createdAt: text("created_at").notNull(),
    lastUsedAt: text("last_used_at"),
    revokedAt: text("revoked_at"),
  },
  (table) => ({
    tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
  }),
);

Avant d'écrire : relire l'état RÉEL de server/db/schema.ts (rtk read server/db/schema.ts). Si apiClients y a évolué (colonnes différentes), reprendre sa définition à l'identique plutôt que celle ci-dessus, pour ne pas régresser le WIP. N'ajouter que les colonnes étendues + les 7 nouvelles tables.

  • Step 2 : Générer la migration

Run: rtk pnpm db:generate Expected: un nouveau fichier server/db/migrations/0002_*.sql est créé (ALTER TABLE machines/snapshots/executions + CREATE TABLE des 7 nouvelles tables). La migration 0001_api_clients.sql reste intacte. Aucune erreur drizzle-kit, aucun DROP de api_clients.

  • Step 3 : Écrire le test de migration server/db/schema.test.ts
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";

function freshMigratedDb() {
  const sqlite = new Database(":memory:");
  const db = drizzle(sqlite);
  migrate(db, { migrationsFolder: "./server/db/migrations" });
  return sqlite;
}

function tableNames(sqlite: Database.Database): string[] {
  return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
  return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}

describe("schéma Phase 1", () => {
  it("crée les tables socle", () => {
    const sqlite = freshMigratedDb();
    const tables = tableNames(sqlite);
    for (const t of [
      "machines", "snapshots", "executions",
      "machine_state", "machine_hardware", "machine_metrics_latest",
      "machine_events", "important_messages", "reports", "raw_artifacts",
    ]) {
      expect(tables, `table ${t}`).toContain(t);
    }
  });

  it("ajoute les colonnes étendues sans casser l'existant", () => {
    const sqlite = freshMigratedDb();
    expect(columnNames(sqlite, "machines")).toEqual(
      expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
    );
    expect(columnNames(sqlite, "snapshots")).toEqual(
      expect.arrayContaining(["kind", "schema_version", "important_json"]),
    );
    expect(columnNames(sqlite, "executions")).toEqual(
      expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
    );
    // colonnes jalon 1 conservées
    expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
    expect(columnNames(sqlite, "machines")).toContain("enc_password");
  });
});
  • Step 4 : Lancer le test

Run: rtk pnpm vitest run server/db/schema.test.ts Expected: PASS (2 tests). Si une colonne attendue manque, corriger schema.ts et régénérer la migration (rtk pnpm db:generate) — ne pas modifier le test.

  • Step 5 : Vérifier la compilation + non-régression

Run: rtk pnpm check && rtk pnpm test Expected: 0 erreur TS ; toute la suite verte (les tests existants ne doivent pas casser).

  • Step 6 : (pas de commit — vérification seulement)

Vérifier git status : seuls server/db/schema.ts, server/db/migrations/0001_*.sql, server/db/schema.test.ts ajoutés à la liste des modifs. Ne PAS committer.


Task 2 : Service machineState (dérivation + upsert + events)

Files:

  • Create: server/services/machineState.ts, server/services/machineState.test.ts

  • Step 1 : Écrire le test (échec attendu)

// server/services/machineState.test.ts
import { describe, it, expect } from "vitest";
import { deriveAptState } from "./machineState.js";
import type { UpdateSnapshot } from "@shared/types.js";

const snap: UpdateSnapshot = {
  machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
  checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
  apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
};

describe("deriveAptState", () => {
  it("dérive le bloc APT de machine_state depuis un snapshot", () => {
    expect(deriveAptState(snap)).toEqual({
      status: "updates_available",
      aptStatus: "updates_available",
      aptUpdatesCount: 3,
      aptRebootRequired: 1,
      aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
    });
  });

  it("met rebootRequired à 0 quand absent", () => {
    const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
    expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
  });
});
  • Step 2 : Lancer (échec)

Run: rtk pnpm vitest run server/services/machineState.test.ts Expected: FAIL — module introuvable.

  • Step 3 : Implémenter server/services/machineState.ts
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";

export interface AptDerivedState {
  status: string;
  aptStatus: string;
  aptUpdatesCount: number;
  aptRebootRequired: number;
  aptLastAnalyzeAt: string;
}

/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
  return {
    status: snapshot.status,
    aptStatus: snapshot.status,
    aptUpdatesCount: snapshot.apt.count,
    aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
    aptLastAnalyzeAt: snapshot.checkedAt,
  };
}

type MachineStateInsert = typeof schema.machineState.$inferInsert;

/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
export function upsertMachineState(
  machineId: string,
  fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
): void {
  const now = new Date().toISOString();
  db.insert(schema.machineState)
    .values({ machineId, updatedAt: now, ...fields })
    .onConflictDoUpdate({
      target: schema.machineState.machineId,
      set: { ...fields, updatedAt: now },
    })
    .run();
}

/** Ajoute une ligne à la timeline machine_events. */
export function recordEvent(input: {
  machineId: string;
  eventType: string;
  severity: "info" | "warning" | "error";
  actorType?: string;
  snapshotId?: string;
  executionId?: string;
  message?: string;
}): void {
  db.insert(schema.machineEvents).values({
    id: randomUUID(),
    machineId: input.machineId,
    eventType: input.eventType,
    severity: input.severity,
    createdAt: new Date().toISOString(),
    actorType: input.actorType ?? "system",
    snapshotId: input.snapshotId,
    executionId: input.executionId,
    message: input.message,
  }).run();
}

/** Utilitaire interne réservé aux migrations/tests éventuels. */
export const _internal = { sql };

Note : _internal exporte sql pour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'import sql correspondant.

  • Step 4 : Lancer (succès)

Run: rtk pnpm vitest run server/services/machineState.test.ts Expected: PASS (2 tests). deriveAptState est pure et n'importe pas de DB au moment du test — mais le module importe ../db/client.js. Si l'import de db/client fait échouer le test en environnement node (chargement better-sqlite3), refactorer en isolant la fonction pure dans le même fichier sans exécuter de requête à l'import (c'est déjà le cas : aucune requête n'est lancée à l'import). Le test n'appelle que deriveAptState.

  • Step 5 : Vérifier

Run: rtk pnpm check Expected: 0 erreur.

  • Step 6 : (pas de commit)

Task 3 : Peupler l'état + la timeline dans refreshMachine

Files:

  • Modify: server/services/refresh.ts

  • Step 1 : Lire l'état actuel de refresh.ts

Run: rtk read server/services/refresh.ts Repère : la construction de snapshot, l'insert dans schema.snapshots, et l'update de machines.status.

  • Step 2 : Enrichir l'insertion du snapshot (kind/schema_version/important_json)

Dans refreshMachine, remplacer l'insertion actuelle du snapshot par :

  const snapshotId = randomUUID();
  db.insert(schema.snapshots).values({
    id: snapshotId,
    machineId,
    kind: "apt_update_analyze",
    schemaVersion: 1,
    checkedAt,
    status,
    payloadJson: JSON.stringify(snapshot),
    importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
  }).run();

(Le randomUUID est déjà importé dans refresh.ts.)

  • Step 3 : Mettre à jour machine_state + event après le snapshot

Ajouter, juste après l'update de machines (status/lastCheckedAt), et après avoir importé en tête de fichier import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js"; :

  upsertMachineState(machineId, deriveAptState(snapshot));
  recordEvent({
    machineId,
    eventType: "apt_refresh",
    severity: status === "error" ? "error" : "info",
    snapshotId,
    message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
  });
  • Step 4 : Vérifier compilation + tests

Run: rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts Expected: 0 erreur TS ; le test existant extractSection reste vert (il n'importe pas la DB grâce au mock en place).

  • Step 5 : (pas de commit)

Task 4 : Enrichir runAction (champs executions + state + event + reports + raw_artifacts)

Files:

  • Modify: server/services/execute.ts

  • Step 1 : Lire l'état actuel de execute.ts

Run: rtk read server/services/execute.ts Repère : l'insert initial dans executions, le bloc d'archivage (writeFileSync du log + rapport), l'update final de executions et de machines.

  • Step 2 : Importer les helpers

En tête de execute.ts, ajouter :

import { randomUUID } from "node:crypto"; // déjà présent — ne pas dupliquer
import { statSync } from "node:fs";
import { upsertMachineState, recordEvent } from "./machineState.js";

(Si randomUUID est déjà importé, n'ajouter que statSync et la ligne machineState.)

  • Step 3 : Mettre running_job_id/status dans machine_state au démarrage

Juste après l'update initial de machines en status: "running" et l'insert de l'exécution (status running), ajouter :

  upsertMachineState(machineId, { status: "running", runningJobId: executionId });
  • Step 4 : Enrichir l'update final de l'exécution

Remplacer l'update final de schema.executions par (ajout schemaVersion, importantJson, exitCode, errorKind, errorMessage, reportId) :

  const reportId = randomUUID();
  const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
  db.update(schema.executions).set({
    finishedAt,
    status,
    schemaVersion: 1,
    resultJson: JSON.stringify(result),
    importantJson: JSON.stringify(result.importantLogLines),
    reportPath,
    rawLogPath,
    reportId,
    exitCode: exitMatch ? Number(exitMatch[1]) : null,
    errorKind: status === "error" ? "execution_failed" : null,
    errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
  }).where(eq(schema.executions.id, executionId)).run();
  • Step 5 : Insérer reports + raw_artifacts + state + event

Juste après l'update final de machines, ajouter :

  db.insert(schema.reports).values({
    id: reportId,
    machineId,
    executionId,
    kind: "machine",
    title: `${m.name}${action}`,
    path: reportPath,
    createdAt: finishedAt,
  }).run();

  db.insert(schema.rawArtifacts).values({
    id: randomUUID(),
    machineId,
    kind: "raw_log",
    path: rawLogPath,
    bytes: statSync(rawLogPath).size,
    createdAt: finishedAt,
    retentionPolicy: status === "error" ? "failed" : "default",
  }).run();

  upsertMachineState(machineId, {
    status: status === "error" ? "error" : "unknown",
    runningJobId: null,
    lastErrorKind: status === "error" ? "execution_failed" : null,
    lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
  });

  recordEvent({
    machineId,
    eventType: `action_${action}`,
    severity: status === "error" ? "error" : status === "warning" ? "warning" : "info",
    executionId,
    message: `Action ${action} : ${status}`,
  });
  • Step 6 : Vérifier compilation + tests

Run: rtk pnpm check && rtk pnpm test Expected: 0 erreur TS ; suite complète verte.

  • Step 7 : (pas de commit)

Task 5 : Vérification finale Phase 1

Files: aucun (vérification).

  • Step 1 : Suite + build

Run: rtk pnpm check && rtk pnpm test && rtk pnpm build Expected: 0 erreur TS ; tests verts (jalon 1 + schema migration + machineState + helpers existants) ; dist/index.js + dist/client produits.

  • Step 2 : Démarrage runtime + migration appliquée

Run (clé jetable, DB jetable) :

export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/phase1-check.db SU_REPORTS_DIR=./data/phase1-reports
node dist/index.js > ./data/phase1.log 2>&1 &
sleep 3
curl -s localhost:8787/health
sqlite3 ./data/phase1-check.db ".tables" 2>/dev/null || echo "(sqlite3 absent — vérifier via le test de migration)"
kill %1 2>/dev/null
rm -rf ./data/phase1-check.db* ./data/phase1-reports ./data/phase1.log

Expected: {"ok":true} et la liste des tables inclut machine_state, machine_events, reports, raw_artifacts, etc. (migration appliquée au boot via runMigrations()).

  • Step 3 : Synthèse à l'utilisateur

Reporter : tables/colonnes ajoutées, machine_state/machine_events/reports/raw_artifacts peuplés lors des refresh/exécutions, non-régression confirmée. Ne pas committer (l'utilisateur gère les commits en fin de parcours).


Self-Review (couverture tache1.9 §14 Phase 1)

  • machine_state → Task 1 (table) + Task 2/3/4 (peuplement). ✓
  • machine_kind/virtualization/hardware_profile dans machines → Task 1. ✓
  • machine_hardware → Task 1 (table ; producteur = tâche 4, hors Phase 1). ✓
  • machine_metrics_latest → Task 1 (table ; producteur = tâche 4). ✓
  • machine_events → Task 1 + Task 3/4 (peuplement). ✓
  • important_messages → Task 1 (table ; peuplement fin = tâche 5/7, l'important_json du snapshot/exécution est déjà capturé). ✓
  • reports → Task 1 + Task 4 (peuplement depuis le rapport déjà écrit). ✓
  • raw_artifacts → Task 1 + Task 4 (peuplement depuis le log déjà écrit). ✓
  • snapshots.kind/schema_version/important_json → Task 1 + Task 3. ✓
  • executions.schema_version/important_json/error_kind/error_message → Task 1 + Task 4. ✓

Décision assumée (rétro-compat) : snapshots.checked_at conservé (non renommé en created_at) pour ne pas casser refresh.ts. Tables sans producteur en Phase 1 (machine_hardware, machine_metrics_latest, important_messages) créées vides, alimentées aux tâches 4/5/7 — conforme au principe « migration progressive ».

Pas de placeholder. Noms cohérents : deriveAptState/upsertMachineState/recordEvent définis Task 2 et utilisés Tasks 3-4 ; reportId défini Task 4 Step 4 et réutilisé Step 5.