// Package jobhistory persists terminal install/uninstall/update job records, // including explicit partial-success outcomes that require reconciliation, // to a JSON file in the mpv config directory. It is shared between the Web // UI job manager (pkg/web) and the TUI (pkg/tui) so both frontends read and // write the same history. package jobhistory import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" "uuid" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/internal/terminaltext" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" ) // FileName is the persisted job history file, kept in the mpv config // directory. const FileName = "job-history.json" // MaxRecords is the maximum number of terminal job records kept on disk. const MaxRecords = 50 // MaxOutputLines caps how many trailing output lines are persisted per job // (much smaller than the in-memory cap used while a job is running). const MaxOutputLines = 100 // Record is the on-disk representation of a terminal job. The field names // and JSON tags are part of the persisted format and must not change. type Record struct { ID string `json:"id"` Type string `json:"type"` MethodID string `json:"methodId"` AppName string `json:"appName"` Status string `json:"status"` Phase string `json:"phase,omitempty"` Progress int `json:"progress"` Message string `json:"message"` Output []string `json:"output"` Error string `json:"error,omitempty"` ErrorDetails string `json:"errorDetails,omitempty"` StartedAt time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt,omitempty"` } // NewID returns a cryptographically random RFC 9562 UUID for a job record. func NewID() string { return uuid.New().String() } // FilePath resolves the job history file in the mpv config dir (honoring // custom config paths). func FilePath() (string, error) { configDir, err := installer.GetMPVConfigDir() if err != nil { return "", err } return filepath.Join(configDir, FileName), nil } // Store persists terminal job records as a JSON array at path. Every append // rewrites the whole capped file atomically (temp file + rename) — at // MaxRecords records this is tiny. Safe for concurrent use. type Store struct { path string mu sync.Mutex } // NewStore creates a store that persists records at path. func NewStore(path string) *Store { return &Store{path: path} } // ReadRecords reads the store file, oldest first. A missing file yields // (nil, nil); a corrupt file yields an error. func (s *Store) ReadRecords() ([]Record, error) { s.mu.Lock() defer s.mu.Unlock() var records []Record err := fileops.WithLock(s.path, func() error { var err error records, err = s.readRecords() return err }) return records, err } // LoadRecords reads the store and returns the persisted records, newest // first. A missing file yields (nil, nil); a corrupt file yields an error. func (s *Store) LoadRecords() ([]Record, error) { records, err := s.ReadRecords() if err != nil { return nil, err } // Records are stored oldest-first; history views want newest first. for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { records[i], records[j] = records[j], records[i] } return records, nil } // Append records a terminal job, trimming the record's output to the last // MaxOutputLines lines and the store to MaxRecords (oldest dropped first). // A corrupt existing file is durably quarantined rather than silently // discarded or allowed to block future terminal records. func (s *Store) Append(rec Record) error { rec = sanitizeRecord(rec) s.mu.Lock() defer s.mu.Unlock() return fileops.WithLock(s.path, func() error { records, err := s.readRecords() if err != nil { quarantinePath, quarantineErr := s.quarantineCorrupt() if quarantineErr != nil { return errors.Join(err, fmt.Errorf("retain corrupt job history: %w", quarantineErr)) } log.Warn("Retained corrupt job history at " + quarantinePath + ": " + err.Error()) records = nil } output := rec.Output if len(output) > MaxOutputLines { output = output[len(output)-MaxOutputLines:] } rec.Output = append([]string(nil), output...) records = append(records, rec) if len(records) > MaxRecords { records = records[len(records)-MaxRecords:] } return s.writeRecords(records) }) } // Upsert inserts a terminal record or replaces the record with the same ID. // It is used when a detached self-update helper resolves a previously handed- // off task on the next application start. func (s *Store) Upsert(rec Record) error { rec = sanitizeRecord(rec) s.mu.Lock() defer s.mu.Unlock() return fileops.WithLock(s.path, func() error { records, err := s.readRecords() if err != nil { return err } for index := range records { if records[index].ID != rec.ID { continue } if rec.StartedAt.IsZero() { rec.StartedAt = records[index].StartedAt } rec.Output = append(append([]string(nil), records[index].Output...), rec.Output...) if len(rec.Output) > MaxOutputLines { rec.Output = rec.Output[len(rec.Output)-MaxOutputLines:] } records[index] = rec return s.writeRecords(records) } if rec.StartedAt.IsZero() { rec.StartedAt = time.Now() } if len(rec.Output) > MaxOutputLines { rec.Output = append([]string(nil), rec.Output[len(rec.Output)-MaxOutputLines:]...) } records = append(records, rec) if len(records) > MaxRecords { records = records[len(records)-MaxRecords:] } return s.writeRecords(records) }) } // readRecords reads the store file. A missing file yields (nil, nil); a // corrupt file yields an error. The caller must hold s.mu. func (s *Store) readRecords() ([]Record, error) { data, err := os.ReadFile(s.path) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var records []Record if err := json.Unmarshal(data, &records); err != nil { return nil, err } for index := range records { records[index] = sanitizeRecord(records[index]) } return records, nil } func sanitizeRecord(record Record) Record { record.Type = terminaltext.Sanitize(record.Type) record.MethodID = terminaltext.Sanitize(record.MethodID) record.AppName = terminaltext.Sanitize(record.AppName) record.Status = terminaltext.Sanitize(record.Status) record.Phase = terminaltext.Sanitize(record.Phase) record.Message = terminaltext.Sanitize(record.Message) record.Error = terminaltext.Sanitize(record.Error) record.ErrorDetails = terminaltext.Sanitize(record.ErrorDetails) output := make([]string, 0, len(record.Output)) for _, value := range record.Output { output = append(output, strings.Split(terminaltext.Sanitize(value), "\n")...) } record.Output = output return record } // writeRecords writes the records atomically via a temp file + rename. The // caller must hold s.mu. func (s *Store) writeRecords(records []Record) error { data, err := json.Marshal(records) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { return err } return fileops.AtomicWrite(s.path, data, 0644) } func (s *Store) quarantineCorrupt() (string, error) { quarantinePath := fmt.Sprintf("%s.corrupt-%s-%s", s.path, time.Now().Format("20060102-150405.000000000"), uuid.New().String()) if err := fileops.RenameDurable(s.path, quarantinePath); err != nil { return "", err } return quarantinePath, nil }