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

660 lines
27 KiB
Markdown

# 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<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 :
```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.