package store import ( "context" "database/sql" "fmt" "time" "vetting/internal/model" ) // SubSteps is the persistence layer for the sub_steps table. Sub-steps // are authored by the agent (one per disk in SMART, one per disk-operation // in Storage, CPU+Memory passes in CPUStress, etc.) and surfaced in the // detail page as a collapsible list under each stage. type SubSteps struct { DB *sql.DB } // Upsert inserts-or-replaces a sub-step row keyed by (run_id, stage_name, // ordinal). Replace semantics let the agent push a row twice — once when // the sub-step starts (state=running, completed_at=nil) and again when // it finishes (state=passed|failed, completed_at set) — without the // caller needing to track whether the row already exists. func (s *SubSteps) Upsert(ctx context.Context, ss model.SubStep) error { // Preserve the original started_at if the caller didn't supply one // (a "complete" call that only knows the end time still needs to // leave the start time alone). ON CONFLICT DO UPDATE with COALESCE // handles the merge in one round-trip. _, err := s.DB.ExecContext(ctx, ` INSERT INTO sub_steps(run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(run_id, stage_name, ordinal) DO UPDATE SET name = excluded.name, state = excluded.state, started_at = COALESCE(excluded.started_at, sub_steps.started_at), completed_at = COALESCE(excluded.completed_at, sub_steps.completed_at), summary_json = CASE WHEN excluded.summary_json = '{}' THEN sub_steps.summary_json ELSE excluded.summary_json END `, ss.RunID, ss.StageName, ss.Ordinal, ss.Name, string(ss.State), nullTime(ss.StartedAt), nullTime(ss.CompletedAt), jsonOrDefault(ss.SummaryJSON)) if err != nil { return fmt.Errorf("upsert sub-step: %w", err) } return nil } // Get returns a single sub-step by its natural key. Returns ErrNotFound // if none exists. func (s *SubSteps) Get(ctx context.Context, runID int64, stageName string, ordinal int) (*model.SubStep, error) { row := s.DB.QueryRowContext(ctx, ` SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json FROM sub_steps WHERE run_id = ? AND stage_name = ? AND ordinal = ? `, runID, stageName, ordinal) ss, err := scanSubStep(row) if err == sql.ErrNoRows { return nil, ErrNotFound } return ss, err } // ListForRun returns every sub-step for a run, ordered by (stage_name, // ordinal). The caller groups by StageName to build the UI. func (s *SubSteps) ListForRun(ctx context.Context, runID int64) ([]model.SubStep, error) { rows, err := s.DB.QueryContext(ctx, ` SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json FROM sub_steps WHERE run_id = ? ORDER BY stage_name, ordinal `, runID) if err != nil { return nil, err } defer rows.Close() var out []model.SubStep for rows.Next() { ss, err := scanSubStep(rows) if err != nil { return nil, err } out = append(out, *ss) } return out, rows.Err() } // ListForStage returns every sub-step for one stage of a run, in ordinal // order. Convenience over filtering ListForRun client-side. func (s *SubSteps) ListForStage(ctx context.Context, runID int64, stageName string) ([]model.SubStep, error) { rows, err := s.DB.QueryContext(ctx, ` SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json FROM sub_steps WHERE run_id = ? AND stage_name = ? ORDER BY ordinal `, runID, stageName) if err != nil { return nil, err } defer rows.Close() var out []model.SubStep for rows.Next() { ss, err := scanSubStep(rows) if err != nil { return nil, err } out = append(out, *ss) } return out, rows.Err() } type scanner interface { Scan(dest ...any) error } func scanSubStep(r scanner) (*model.SubStep, error) { var ss model.SubStep var started, completed sql.NullTime if err := r.Scan(&ss.ID, &ss.RunID, &ss.StageName, &ss.Ordinal, &ss.Name, &ss.State, &started, &completed, &ss.SummaryJSON); err != nil { return nil, err } if started.Valid { t := started.Time ss.StartedAt = &t } if completed.Valid { t := completed.Time ss.CompletedAt = &t } return &ss, nil } func nullTime(t *time.Time) any { if t == nil || t.IsZero() { return nil } return t.UTC() } func jsonOrDefault(s string) string { if s == "" { return "{}" } return s }