Files
narratio/internal/manifest/store.go

338 lines
8.6 KiB
Go

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
}
// 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)
}
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")
}
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")
}
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
}
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")
}
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
}
}