Add restore report and operator summary
This commit is contained in:
@@ -85,14 +85,18 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restore: %w", err)
|
return fmt.Errorf("restore: %w", err)
|
||||||
}
|
}
|
||||||
if err := writeRestorePlan(out, current, plan, RestorePlanOptions{
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{
|
||||||
IncludeAudio: includeAudio,
|
IncludeAudio: includeAudio,
|
||||||
Force: force,
|
Force: force,
|
||||||
DryRun: dryRun,
|
DryRun: dryRun,
|
||||||
}); err != nil {
|
})
|
||||||
return fmt.Errorf("restore: write plan output: %w", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("restore: %w", err)
|
||||||
}
|
}
|
||||||
if dryRun {
|
if dryRun {
|
||||||
|
if err := writeRestoreDryRunSummary(out, report); err != nil {
|
||||||
|
return fmt.Errorf("restore: write plan output: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,28 +113,33 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if plan.ConflictCount > 0 && !force {
|
if plan.ConflictCount > 0 && !force {
|
||||||
|
report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount))
|
||||||
|
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
|
||||||
|
return fmt.Errorf("restore: report failure: %w", reportErr)
|
||||||
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"restore: plan has %d conflicting path(s); rerun with --force or resolve local conflicts",
|
"restore conflict: %d conflicting path(s); rerun with --force to overwrite (download=%d skip_same=%d conflicts=%d)",
|
||||||
|
plan.ConflictCount,
|
||||||
|
plan.DownloadCount,
|
||||||
|
plan.SkipSameCount,
|
||||||
plan.ConflictCount,
|
plan.ConflictCount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := executeRestorePlanFn(ctx, cfg, current, plan, objectStore)
|
result, err := executeRestorePlanFn(ctx, cfg, current, plan, report, objectStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
report.setFailed(err)
|
||||||
|
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
|
||||||
|
return fmt.Errorf("restore: execute plan failed (%v) and report write failed (%v)", err, reportErr)
|
||||||
|
}
|
||||||
return fmt.Errorf("restore: execute plan: %w", err)
|
return fmt.Errorf("restore: execute plan: %w", err)
|
||||||
}
|
}
|
||||||
|
report.Execution.Downloaded = result.DownloadedCount
|
||||||
_, err = fmt.Fprintf(
|
report.setSucceeded()
|
||||||
out,
|
if _, err := persistRestoreReport(artifactStore, cfg, report); err != nil {
|
||||||
"restore complete: session %s/%s run=%s downloaded=%d skipped_same=%d conflicts=%d\n",
|
return fmt.Errorf("restore: write report: %w", err)
|
||||||
current.Campaign,
|
}
|
||||||
current.SessionID,
|
if err := writeRestoreSuccessSummary(out, report); err != nil {
|
||||||
current.RunID,
|
|
||||||
result.DownloadedCount,
|
|
||||||
plan.SkipSameCount,
|
|
||||||
plan.ConflictCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("restore: write summary: %w", err)
|
return fmt.Errorf("restore: write summary: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func executeRestorePlan(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
current *RemoteCurrentState,
|
current *RemoteCurrentState,
|
||||||
plan *RestorePlan,
|
plan *RestorePlan,
|
||||||
|
report *RestoreReport,
|
||||||
store storage.ObjectStore,
|
store storage.ObjectStore,
|
||||||
) (*RestoreExecutionResult, error) {
|
) (*RestoreExecutionResult, error) {
|
||||||
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
@@ -61,8 +62,14 @@ func executeRestorePlan(
|
|||||||
result := &RestoreExecutionResult{}
|
result := &RestoreExecutionResult{}
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil {
|
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil {
|
||||||
|
if report != nil {
|
||||||
|
report.markFailed(action, err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("install %q from %q: %w", action.LocalRelativePath, action.RemoteKey, err)
|
return nil, fmt.Errorf("install %q from %q: %w", action.LocalRelativePath, action.RemoteKey, err)
|
||||||
}
|
}
|
||||||
|
if report != nil {
|
||||||
|
report.markDownloaded(action)
|
||||||
|
}
|
||||||
result.DownloadedCount++
|
result.DownloadedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -38,13 +39,24 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
|||||||
if stderr.Len() != 0 {
|
if stderr.Len() != 0 {
|
||||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "restore complete:") {
|
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
|
||||||
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`)
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`)
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
||||||
|
reportPath := filepath.Join(sessionRoot, "reports", "restore-latest.json")
|
||||||
|
report := mustReadRestoreReport(t, reportPath)
|
||||||
|
if report.Status != "succeeded" {
|
||||||
|
t.Fatalf("report status = %q, want succeeded", report.Status)
|
||||||
|
}
|
||||||
|
if report.Execution.Downloaded != 3 {
|
||||||
|
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
|
||||||
|
}
|
||||||
|
if len(report.Actions) == 0 {
|
||||||
|
t.Fatal("report actions is empty")
|
||||||
|
}
|
||||||
if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) {
|
||||||
t.Fatalf("audio should not be restored by default; stat err=%v", err)
|
t.Fatalf("audio should not be restored by default; stat err=%v", err)
|
||||||
}
|
}
|
||||||
@@ -69,6 +81,10 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
|||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
||||||
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
||||||
|
if !report.IncludeAudio {
|
||||||
|
t.Fatalf("report include_audio = %v, want true", report.IncludeAudio)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
||||||
@@ -94,6 +110,13 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
||||||
}
|
}
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
||||||
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
||||||
|
if report.Status != "failed" {
|
||||||
|
t.Fatalf("report status = %q, want failed", report.Status)
|
||||||
|
}
|
||||||
|
if report.Plan.Conflicts != 1 {
|
||||||
|
t.Fatalf("report plan.conflicts = %d, want 1", report.Plan.Conflicts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
||||||
@@ -116,6 +139,10 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
|||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
||||||
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
||||||
|
if !report.Force {
|
||||||
|
t.Fatalf("report force = %v, want true", report.Force)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
||||||
@@ -192,6 +219,13 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
|
|||||||
t.Fatalf("stderr = %q, want manifest validation failure", stderr.String())
|
t.Fatalf("stderr = %q, want manifest validation failure", stderr.String())
|
||||||
}
|
}
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
||||||
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
||||||
|
if report.Status != "failed" {
|
||||||
|
t.Fatalf("report status = %q, want failed", report.Status)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(report.Error) == "" {
|
||||||
|
t.Fatal("report error is empty, want failure context")
|
||||||
|
}
|
||||||
afterData, err := os.ReadFile(existingPath)
|
afterData, err := os.ReadFile(existingPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read local manifest after failure: %v", err)
|
t.Fatalf("read local manifest after failure: %v", err)
|
||||||
@@ -214,7 +248,11 @@ func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
|
|||||||
LocalPath: "/tmp/escape.txt",
|
LocalPath: "/tmp/escape.txt",
|
||||||
}}}
|
}}}
|
||||||
|
|
||||||
_, err := executeRestorePlan(context.Background(), cfg, current, plan, store)
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newRestoreReport() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -223,6 +261,19 @@ func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
var report RestoreReport
|
||||||
|
if err := json.Unmarshal(data, &report); err != nil {
|
||||||
|
t.Fatalf("Unmarshal restore report %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return &report
|
||||||
|
}
|
||||||
|
|
||||||
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
|
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
origStoreFn := newObjectStoreFromConfigFn
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
|||||||
243
internal/app/restore_report.go
Normal file
243
internal/app/restore_report.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
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 archive 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
|
||||||
|
}
|
||||||
@@ -94,10 +94,13 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
}
|
}
|
||||||
outText := stdout.String()
|
outText := stdout.String()
|
||||||
if !strings.Contains(outText, "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
if !strings.Contains(outText, "Restore plan for sample-campaign/2026-05-03") {
|
||||||
t.Fatalf("stdout = %q, want restore plan summary", outText)
|
t.Fatalf("stdout = %q, want restore plan summary", outText)
|
||||||
}
|
}
|
||||||
if !strings.Contains(outText, "download manifest.json <- dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json") {
|
if !strings.Contains(outText, "Would download: 1") {
|
||||||
|
t.Fatalf("stdout = %q, want plan count output", outText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outText, "Would download: manifest.json") {
|
||||||
t.Fatalf("stdout = %q, want action output", outText)
|
t.Fatalf("stdout = %q, want action output", outText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +108,10 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err)
|
t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err)
|
||||||
}
|
}
|
||||||
|
reportPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "reports", "restore-latest.json")
|
||||||
|
if _, err := os.Stat(reportPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("restore report should not be written during dry-run; stat err=%v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
||||||
@@ -210,10 +217,10 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
|||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
if stdout.Len() != 0 {
|
||||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "plan has 1 conflicting path(s)") {
|
if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s); rerun with --force to overwrite") {
|
||||||
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
||||||
@@ -250,7 +257,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
|||||||
DownloadCount: 1,
|
DownloadCount: 1,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, storage.ObjectStore) (*RestoreExecutionResult, error) {
|
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
|
||||||
return &RestoreExecutionResult{DownloadedCount: 1}, nil
|
return &RestoreExecutionResult{DownloadedCount: 1}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,10 +269,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
|||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
|
||||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(stdout.String(), "restore complete: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4 downloaded=1 skipped_same=0 conflicts=0") {
|
|
||||||
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
||||||
}
|
}
|
||||||
if stderr.Len() != 0 {
|
if stderr.Len() != 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user