package manifest import ( "context" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) // 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 } // RunStore persists invocation-scoped run manifests. type RunStore interface { CreateRun(ctx context.Context, sessionID, campaign, runID string, force bool, requestedStages []string) (*RunManifest, error) SaveRun(ctx context.Context, path string, m *RunManifest) 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") } file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("load manifest %q: %w", path, err) } defer file.Close() return s.LoadReader(ctx, file) } // LoadReader reads and validates a manifest from a caller-owned reader. func (s *LocalStore) LoadReader(ctx context.Context, source io.Reader) (*Manifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if source == nil { return nil, fmt.Errorf("load manifest: source is required") } data, err := io.ReadAll(source) if err != nil { return nil, fmt.Errorf("read manifest: %w", err) } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("decode manifest: %w", err) } if err := validateLoadedManifest(&m); err != nil { return nil, fmt.Errorf("manifest invalid: %w", err) } normalizeManifest(&m) return &m, nil } // Save writes the manifest to path through the durable file replacement primitive. 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") } if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return fmt.Errorf("save manifest: %w", err) } if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { return fmt.Errorf("save manifest: %w", err) } 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') if err := writeJSONAtomically(ctx, path, data); err != nil { return fmt.Errorf("save manifest: %w", err) } 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") } file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("load run manifest %q: %w", path, err) } defer file.Close() return s.LoadRunReader(ctx, file) } // LoadRunReader reads and validates a run manifest from a caller-owned reader. func (s *LocalStore) LoadRunReader(ctx context.Context, source io.Reader) (*RunManifest, error) { if err := checkContext(ctx); err != nil { return nil, err } if source == nil { return nil, fmt.Errorf("load run manifest: source is required") } data, err := io.ReadAll(source) if err != nil { return nil, fmt.Errorf("read run manifest: %w", err) } var m RunManifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("decode run manifest: %w", err) } if err := validateLoadedRunManifest(&m); err != nil { return nil, fmt.Errorf("run manifest invalid: %w", 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") } if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return fmt.Errorf("save run manifest: %w", err) } 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, 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") } if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return err } if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { return err } 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 validatePostPublishCleanup(cleanup *PostPublishCleanup) error { if cleanup == nil { return nil } if err := pathsafe.ValidateOpaqueSegment(cleanup.CommittedRunID); err != nil { return fmt.Errorf("post_publish_cleanup.committed_run_id is not a portable opaque identifier: %w", err) } if strings.TrimSpace(cleanup.RemoteCommitKey) == "" || strings.TrimSpace(cleanup.CurrentCommitPointerKey) == "" { return fmt.Errorf("post_publish_cleanup commit identity is required") } if len(cleanup.Targets) == 0 { return fmt.Errorf("post_publish_cleanup.targets is required") } for index, target := range cleanup.Targets { if strings.TrimSpace(target.Policy) == "" || strings.TrimSpace(target.Root) == "" || strings.TrimSpace(target.Path) == "" { return fmt.Errorf("post_publish_cleanup.targets[%d] policy, root, and path are required", index) } } return nil } 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") } if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return err } return nil } func validateManifestIdentities(sessionID, campaign, runID string) error { identities := []struct { field string value string required bool }{ {field: "session_id", value: sessionID, required: true}, {field: "campaign", value: campaign}, {field: "run_id", value: runID}, } for _, identity := range identities { value := strings.TrimSpace(identity.value) if value == "" && !identity.required { continue } if err := pathsafe.ValidateOpaqueSegment(identity.value); err != nil { return fmt.Errorf( "manifest %s %q is not a portable opaque identifier; migrate the legacy manifest before use: %w", identity.field, identity.value, err, ) } } 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 string, data []byte) error { dir := filepath.Dir(path) if err := fileops.EnsureWorkspaceDirectory(dir); err != nil { return fmt.Errorf("create directory %q: %w", dir, err) } return fileops.ReplaceFileAtomic(path, data, fileops.ReplaceFileOptions{ Mode: fileops.WorkspaceFileMode, BeforeRename: func() error { return checkContext(ctx) }, }) } func checkContext(ctx context.Context) error { if ctx == nil { return nil } select { case <-ctx.Done(): return ctx.Err() default: return nil } }