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 } 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 checkContext(ctx context.Context) error { if ctx == nil { return nil } select { case <-ctx.Done(): return ctx.Err() default: return nil } }