package version import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" "uuid" "gitgud.io/mike/mpv-manager/pkg/config" ) const ( selfUpdateOutcomeSchema = 1 selfUpdateOutcomeName = "self-update-outcome.json" ) // SelfUpdateOutcome is the durable terminal result produced by the detached // helper. The next ordinary application start reconciles it into shared task // history, so a failed helper handoff is not silently represented as success. type SelfUpdateOutcome struct { SchemaVersion int `json:"schema_version"` TransactionID string `json:"transaction_id"` OriginJobID string `json:"origin_job_id,omitempty"` State UpdateTransactionState `json:"state"` Version string `json:"version"` Error string `json:"error,omitempty"` CompletedAt time.Time `json:"completed_at"` Targets []UpdateTarget `json:"targets"` } func selfUpdateOutcomePath() string { return filepath.Join(config.GetConfigDir(), selfUpdateOutcomeName) } func persistSelfUpdateOutcome(journal *UpdateJournal) error { if journal == nil { return errors.New("missing update journal") } outcome := SelfUpdateOutcome{ SchemaVersion: selfUpdateOutcomeSchema, TransactionID: journal.ID, OriginJobID: journal.OriginJobID, State: journal.State, Version: journal.Expected.Version, Error: journal.Error, CompletedAt: time.Now().UTC(), Targets: append([]UpdateTarget(nil), journal.Targets...), } if err := validateSelfUpdateOutcome(&outcome); err != nil { return err } if err := os.MkdirAll(config.GetConfigDir(), 0o755); err != nil { return fmt.Errorf("create self-update outcome directory: %w", err) } return writeJSONAtomicDurable(selfUpdateOutcomePath(), outcome, 0o600) } func recordSelfUpdateOutcome(journal *UpdateJournal) error { if err := persistSelfUpdateOutcome(journal); err != nil { // The executable transaction has already reached a terminal state. Do // not misrepresent that state or attempt a second rollback merely because // its UX reconciliation record could not be persisted. fmt.Fprintln(os.Stderr, "Could not persist self-update outcome:", err) return err } return nil } // ReadSelfUpdateOutcome loads the most recent detached-helper result. A // missing outcome is returned as (nil, nil). func ReadSelfUpdateOutcome() (*SelfUpdateOutcome, error) { data, err := os.ReadFile(selfUpdateOutcomePath()) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var outcome SelfUpdateOutcome decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&outcome); err != nil { return nil, fmt.Errorf("decode self-update outcome: %w", err) } if err := validateSelfUpdateOutcome(&outcome); err != nil { return nil, err } return &outcome, nil } // ClearSelfUpdateOutcome removes a reconciled outcome only if its transaction // ID still matches, preventing a late helper result from being deleted. func ClearSelfUpdateOutcome(transactionID string) error { outcome, err := ReadSelfUpdateOutcome() if err != nil || outcome == nil { return err } if outcome.TransactionID != transactionID { return fmt.Errorf("self-update outcome changed before acknowledgement") } return removeFileDurable(selfUpdateOutcomePath()) } func validateSelfUpdateOutcome(outcome *SelfUpdateOutcome) error { if outcome == nil || outcome.SchemaVersion != selfUpdateOutcomeSchema { return fmt.Errorf("unsupported self-update outcome schema") } transactionID, err := uuid.Parse(outcome.TransactionID) if err != nil || transactionID.String() != outcome.TransactionID { return fmt.Errorf("invalid self-update outcome transaction ID") } if outcome.OriginJobID != "" { jobID, err := uuid.Parse(outcome.OriginJobID) if err != nil || jobID.String() != outcome.OriginJobID { return fmt.Errorf("invalid self-update outcome origin job ID") } } if outcome.State != UpdateTransactionCommitted && outcome.State != UpdateTransactionRolledBack && outcome.State != UpdateTransactionFailed { return fmt.Errorf("self-update outcome is not terminal: %q", outcome.State) } if strings.TrimSpace(outcome.Version) == "" || outcome.CompletedAt.IsZero() || len(outcome.Targets) == 0 { return fmt.Errorf("self-update outcome is incomplete") } return nil }