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>
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.tscontientmachines,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 nommecreated_at, mais le code jalon 1refresh.tsutilisecheckedAt— 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_jsonrestent la vérité canonique ;machine_staten'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_idsont de simples colonnestextnullable. - 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 WIPcapabilities/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.tscontient déjà la tableapiClients(WIP api_clients, migration0001_api_clients.sql). Le contenu ci-dessous préserveapiClients(et l'importuniqueIndex). NE PAS supprimerapiClients. La migration générée à l'étape suivante sera donc0002_*(et non0001).
// 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). SiapiClientsy 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 :
_internalexportesqlpour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'importsqlcorrespondant.
- 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'
updatefinal 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_jsondu 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.