# 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`). ```ts // 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`** ```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)** ```ts // 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`** ```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> & { 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 : ```ts 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";` : ```ts 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 : ```ts 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 : ```ts 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`) : ```ts 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 : ```ts 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) : ```bash 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.