0fbca06d3d
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>
660 lines
27 KiB
Markdown
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.
|