package manifest import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) // Store persists manifests to and from durable storage. type Store interface { Create(ctx context.Context, sessionID string) (*Manifest, error) Load(ctx context.Context, path string) (*Manifest, error) Save(ctx context.Context, path string, m *Manifest) error } // LocalStore stores manifests as JSON on the local filesystem. type LocalStore struct{} // Create returns a new in-memory manifest for a session. func (s *LocalStore) Create(ctx context.Context, sessionID string) (*Manifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if strings.TrimSpace(sessionID) == "" { return nil, fmt.Errorf("create manifest: sessionID is required") } now := time.Now().UTC() return New(sessionID, now), nil } // Load reads and validates a local JSON manifest from path. func (s *LocalStore) Load(ctx context.Context, path string) (*Manifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if strings.TrimSpace(path) == "" { return nil, fmt.Errorf("load manifest: path is required") } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("load manifest %q: %w", path, err) } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("decode manifest %q: %w", path, err) } if err := validateLoadedManifest(&m); err != nil { return nil, fmt.Errorf("manifest %q invalid: %w", path, err) } normalizeManifest(&m) return &m, nil } // Save writes the manifest to path atomically via temp file + rename. func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error { if err := checkContext(ctx); err != nil { return err } if strings.TrimSpace(path) == "" { return fmt.Errorf("save manifest: path is required") } if m == nil { return fmt.Errorf("save manifest: manifest is nil") } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("save manifest: session_id is required") } if m.CreatedAt.IsZero() { return fmt.Errorf("save manifest: created_at is required") } m.UpdatedAt = time.Now().UTC() if m.Stages == nil { m.Stages = map[string]*StageRecord{} } data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("save manifest: marshal: %w", err) } data = append(data, '\n') dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("save manifest: create directory %q: %w", dir, err) } tmp, err := os.CreateTemp(dir, ".manifest.json.tmp-*") if err != nil { return fmt.Errorf("save manifest: create temp file: %w", err) } tmpName := tmp.Name() removeTmp := true defer func() { if removeTmp { _ = os.Remove(tmpName) } }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("save manifest: write temp file: %w", err) } if err := tmp.Sync(); err != nil { _ = tmp.Close() return fmt.Errorf("save manifest: sync temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("save manifest: close temp file: %w", err) } if err := checkContext(ctx); err != nil { return err } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("save manifest: rename temp file: %w", err) } removeTmp = false return nil } // CreateRun returns a new in-memory run manifest for one invocation. func (s *LocalStore) CreateRun( ctx context.Context, sessionID, campaign, runID string, force bool, requestedStages []string, ) (*RunManifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if strings.TrimSpace(sessionID) == "" { return nil, fmt.Errorf("create run manifest: session_id is required") } if strings.TrimSpace(runID) == "" { return nil, fmt.Errorf("create run manifest: run_id is required") } now := time.Now().UTC() return NewRun(sessionID, campaign, runID, force, requestedStages, now), nil } // LoadRun reads and validates a local JSON run manifest from path. func (s *LocalStore) LoadRun(ctx context.Context, path string) (*RunManifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if strings.TrimSpace(path) == "" { return nil, fmt.Errorf("load run manifest: path is required") } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("load run manifest %q: %w", path, err) } var m RunManifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("decode run manifest %q: %w", path, err) } if err := validateLoadedRunManifest(&m); err != nil { return nil, fmt.Errorf("run manifest %q invalid: %w", path, err) } normalizeRunManifest(&m) return &m, nil } // SaveRun writes the run manifest to path atomically via temp file + rename. func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) error { if err := checkContext(ctx); err != nil { return err } if strings.TrimSpace(path) == "" { return fmt.Errorf("save run manifest: path is required") } if m == nil { return fmt.Errorf("save run manifest: manifest is nil") } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("save run manifest: session_id is required") } if strings.TrimSpace(m.RunID) == "" { return fmt.Errorf("save run manifest: run_id is required") } if m.CreatedAt.IsZero() { return fmt.Errorf("save run manifest: created_at is required") } m.UpdatedAt = time.Now().UTC() if m.Stages == nil { m.Stages = map[string]*RunStageRecord{} } data, err := json.MarshalIndent(m, "", " ") if err != nil { return fmt.Errorf("save run manifest: marshal: %w", err) } data = append(data, '\n') return writeJSONAtomically(ctx, path, ".run-manifest.json.tmp-*", data) } func validateLoadedManifest(m *Manifest) error { if m == nil { return fmt.Errorf("manifest is nil") } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("session_id is required") } if m.CreatedAt.IsZero() { return fmt.Errorf("created_at is required") } if m.UpdatedAt.IsZero() { return fmt.Errorf("updated_at is required") } return nil } func normalizeManifest(m *Manifest) { if m.Stages == nil { m.Stages = map[string]*StageRecord{} } for name, stage := range m.Stages { if stage == nil { stage = &StageRecord{Name: name, Status: StatusPending, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt} m.Stages[name] = stage } if stage.Name == "" { stage.Name = name } } } func validateLoadedRunManifest(m *RunManifest) error { if m == nil { return fmt.Errorf("manifest is nil") } if strings.TrimSpace(m.SessionID) == "" { return fmt.Errorf("session_id is required") } if strings.TrimSpace(m.RunID) == "" { return fmt.Errorf("run_id is required") } if m.CreatedAt.IsZero() { return fmt.Errorf("created_at is required") } if m.UpdatedAt.IsZero() { return fmt.Errorf("updated_at is required") } return nil } func normalizeRunManifest(m *RunManifest) { if m.Stages == nil { m.Stages = map[string]*RunStageRecord{} } for name, stage := range m.Stages { if stage == nil { stage = &RunStageRecord{ Name: name, Action: RunStageActionRun, Status: StatusPending, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, } m.Stages[name] = stage } if stage.Name == "" { stage.Name = name } } } func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create directory %q: %w", dir, err) } tmp, err := os.CreateTemp(dir, tempPattern) if err != nil { return fmt.Errorf("create temp file: %w", err) } tmpName := tmp.Name() removeTmp := true defer func() { if removeTmp { _ = os.Remove(tmpName) } }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("write temp file: %w", err) } if err := tmp.Sync(); err != nil { _ = tmp.Close() return fmt.Errorf("sync temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) } if err := checkContext(ctx); err != nil { return err } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("rename temp file: %w", err) } removeTmp = false return nil } func checkContext(ctx context.Context) error { if ctx == nil { return nil } select { case <-ctx.Done(): return ctx.Err() default: return nil } }