package app import ( "encoding/json" "fmt" "io" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // RestoreReport is the durable restore diagnostic model. type RestoreReport struct { GeneratedAt string `json:"generated_at"` SessionID string `json:"session_id"` Campaign string `json:"campaign"` RunID string `json:"run_id"` DryRun bool `json:"dry_run"` Force bool `json:"force"` IncludeAudio bool `json:"include_audio"` Status string `json:"status"` Error string `json:"error,omitempty"` Plan RestorePlanSummary `json:"plan"` Execution RestoreExecutionStats `json:"execution"` Actions []RestoreReportAction `json:"actions"` reportPathRel string } type RestorePlanSummary struct { Actions int `json:"actions"` Download int `json:"download"` SkipSame int `json:"skip_same"` Conflicts int `json:"conflicts"` } type RestoreExecutionStats struct { Downloaded int `json:"downloaded"` Failed int `json:"failed"` } type RestoreReportAction struct { Kind string `json:"kind"` LocalRelativePath string `json:"local_relative_path"` RemoteKey string `json:"remote_key"` Reason string `json:"reason,omitempty"` Status string `json:"status"` Error string `json:"error,omitempty"` } func newRestoreReport(current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) (*RestoreReport, error) { if current == nil { return nil, fmt.Errorf("remote current state is required") } if plan == nil { return nil, fmt.Errorf("restore plan is required") } r := &RestoreReport{ GeneratedAt: nowUTC().Format("2006-01-02T15:04:05.999999999Z07:00"), SessionID: current.SessionID, Campaign: current.Campaign, RunID: current.RunID, DryRun: opts.DryRun, Force: opts.Force, IncludeAudio: opts.IncludeAudio, Status: "planned", Plan: RestorePlanSummary{ Actions: len(plan.Actions), Download: plan.DownloadCount, SkipSame: plan.SkipSameCount, Conflicts: plan.ConflictCount, }, Actions: make([]RestoreReportAction, 0, len(plan.Actions)), reportPathRel: filepath.ToSlash(filepath.Join(config.PathReportsDirSegment, "restore-latest.json")), } for _, action := range plan.Actions { r.Actions = append(r.Actions, RestoreReportAction{ Kind: string(action.Kind), LocalRelativePath: action.LocalRelativePath, RemoteKey: action.RemoteKey, Reason: action.Reason, Status: initialRestoreActionStatus(action.Kind), }) } return r, nil } func initialRestoreActionStatus(kind RestoreActionKind) string { switch kind { case RestoreActionDownload: return "planned_download" case RestoreActionSkipSame: return "skipped_same" case RestoreActionConflict: return "conflict" default: return "planned" } } func (r *RestoreReport) markDownloaded(action RestoreAction) { if r == nil { return } if idx := r.findAction(action); idx >= 0 { r.Actions[idx].Status = "downloaded" r.Actions[idx].Error = "" } r.Execution.Downloaded++ } func (r *RestoreReport) markFailed(action RestoreAction, err error) { if r == nil { return } if idx := r.findAction(action); idx >= 0 { r.Actions[idx].Status = "failed" if err != nil { r.Actions[idx].Error = err.Error() } } r.Execution.Failed++ } func (r *RestoreReport) setFailed(err error) { if r == nil { return } r.Status = "failed" if err != nil { r.Error = err.Error() } } func (r *RestoreReport) setSucceeded() { if r == nil { return } r.Status = "succeeded" r.Error = "" } func (r *RestoreReport) findAction(action RestoreAction) int { if r == nil { return -1 } for i := range r.Actions { if r.Actions[i].LocalRelativePath == action.LocalRelativePath && r.Actions[i].RemoteKey == action.RemoteKey { return i } } return -1 } func writeRestoreDryRunSummary(out io.Writer, report *RestoreReport) error { if out == nil { return fmt.Errorf("output writer is required") } if report == nil { return fmt.Errorf("restore report is required") } if _, err := fmt.Fprintf(out, "Restore plan for %s/%s\n", report.Campaign, report.SessionID); err != nil { return err } if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil { return err } if _, err := fmt.Fprintf(out, "Would download: %d\n", report.Plan.Download); err != nil { return err } if _, err := fmt.Fprintf(out, "Would skip unchanged: %d\n", report.Plan.SkipSame); err != nil { return err } if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil { return err } for _, action := range report.Actions { line := "" switch action.Status { case "planned_download": line = "Would download: " + action.LocalRelativePath case "skipped_same": line = "Would skip unchanged: " + action.LocalRelativePath case "conflict": line = "Conflict: " + action.LocalRelativePath default: line = strings.TrimSpace(action.Kind) + ": " + action.LocalRelativePath } if _, err := fmt.Fprintln(out, line); err != nil { return err } } return nil } func writeRestoreSuccessSummary(out io.Writer, report *RestoreReport) error { if out == nil { return fmt.Errorf("output writer is required") } if report == nil { return fmt.Errorf("restore report is required") } if _, err := fmt.Fprintf(out, "Restored session state for %s/%s\n", report.Campaign, report.SessionID); err != nil { return err } if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil { return err } if _, err := fmt.Fprintf(out, "Downloaded: %d\n", report.Execution.Downloaded); err != nil { return err } if _, err := fmt.Fprintf(out, "Skipped unchanged: %d\n", report.Plan.SkipSame); err != nil { return err } if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil { return err } return nil } func persistRestoreReport(store artifacts.Store, cfg *config.Config, report *RestoreReport) (string, error) { if store == nil { return "", fmt.Errorf("artifact store is required") } if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { return "", fmt.Errorf("resolved config with pipeline/session is required") } if report == nil { return "", fmt.Errorf("restore report is required") } sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) reportPath := filepath.Join(sessionRoot, filepath.FromSlash(report.reportPathRel)) payload, err := json.MarshalIndent(report, "", " ") if err != nil { return "", fmt.Errorf("marshal restore report: %w", err) } payload = append(payload, '\n') if err := store.WriteFileAtomic(reportPath, payload, 0o644); err != nil { return "", fmt.Errorf("write restore report %q: %w", reportPath, err) } return reportPath, nil }