// schedule_runs.go — derived "next run" / "last run" helpers for the // dashboard host row + schedules tab (P2R-14). // // Both are derived data: NextRun is computed from the cron expression // at request time; LatestJobBySchedule reads the most recent job that // fired against this schedule. Neither is persisted — the cost of the // query is small relative to a page render. package store import ( "context" "database/sql" "errors" "fmt" "time" ) // LatestJobBySchedule returns the most recent job fired by this // schedule (actor_kind='schedule' AND scheduled_id=schedID), or // (nil, ErrNotFound) when the schedule has never fired. Includes // queued/running rows because the operator wants to see "running // now" too. func (s *Store) LatestJobBySchedule(ctx context.Context, hostID, schedID string) (*Job, error) { row := s.db.QueryRowContext(ctx, `SELECT id, host_id, kind, status, scheduled_id, actor_kind, actor_id, started_at, finished_at, exit_code, stats, error, created_at FROM jobs WHERE host_id = ? AND scheduled_id = ? AND actor_kind = 'schedule' ORDER BY created_at DESC LIMIT 1`, hostID, schedID) return scanJobRow(row) } // scanJobRow is the shared scan used by LatestJobBySchedule. Mirrors // the columns LatestJobByKind reads. Kept in this file (vs jobs.go) // to avoid disturbing the stable API surface exported there. func scanJobRow(row *sql.Row) (*Job, error) { var ( j Job schedID sql.NullString actorID sql.NullString startedAt sql.NullString finishedAt sql.NullString exitCode sql.NullInt64 stats sql.NullString errMsg sql.NullString createdAt string ) if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, &j.ActorKind, &actorID, &startedAt, &finishedAt, &exitCode, &stats, &errMsg, &createdAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("store: scan job: %w", err) } if schedID.Valid { v := schedID.String j.ScheduledID = &v } if actorID.Valid { v := actorID.String j.ActorID = &v } if startedAt.Valid { t, _ := time.Parse(time.RFC3339Nano, startedAt.String) j.StartedAt = &t } if finishedAt.Valid { t, _ := time.Parse(time.RFC3339Nano, finishedAt.String) j.FinishedAt = &t } if exitCode.Valid { i := int(exitCode.Int64) j.ExitCode = &i } if stats.Valid && stats.String != "" { j.Stats = []byte(stats.String) } if errMsg.Valid { v := errMsg.String j.Error = &v } if t, err := time.Parse(time.RFC3339Nano, createdAt); err == nil { j.CreatedAt = t } return &j, nil }