package app import ( "context" "fmt" "log/slog" "os" "path/filepath" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/logging" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) type RunOptions struct { Force bool } type RunSummary struct { SessionID string ManifestPath string StageNames []string } func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) paths, err := store.EnsureLayout(cfg.Session.SessionID) if err != nil { return nil, fmt.Errorf("prepare workdir: %w", err) } lock, err := store.AcquireSessionLock(cfg.Session.SessionID) if err != nil { return nil, fmt.Errorf("acquire session lock: %w", err) } defer func() { _ = store.ReleaseSessionLock(lock) }() manifestStore := &manifest.LocalStore{} manifestPath := paths.ManifestPath m, err := loadOrCreateManifest(ctx, manifestStore, manifestPath, cfg.Session.SessionID) if err != nil { return nil, err } env := &Env{ Config: cfg, ArtifactStore: store, Logger: logging.NewLogger(os.Stderr, slog.LevelInfo), } stageEnv := toStageEnv(env) _ = opts // TODO: use --force behavior in future skip/stale logic. runNames := make([]string, 0, len(stages)) for _, s := range stages { runNames = append(runNames, s.Name()) now := nowUTC() m.MarkStageRunning(s.Name(), now) if err := manifestStore.Save(ctx, manifestPath, m); err != nil { return nil, fmt.Errorf("save manifest before stage %q: %w", s.Name(), err) } result, err := s.Run(ctx, stageEnv, m) if err != nil { m.MarkStageFailed(s.Name(), nowUTC(), err.Error()) if saveErr := manifestStore.Save(ctx, manifestPath, m); saveErr != nil { return nil, fmt.Errorf("stage %q failed (%v) and manifest save failed (%v)", s.Name(), err, saveErr) } return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err) } outputs := mapResultOutputs(result) m.MarkStageSucceeded(s.Name(), nowUTC(), outputs) applyStageResultToManifest(m, s.Name(), result) if err := manifestStore.Save(ctx, manifestPath, m); err != nil { return nil, fmt.Errorf("save manifest after stage %q: %w", s.Name(), err) } } return &RunSummary{ SessionID: cfg.Session.SessionID, ManifestPath: manifestPath, StageNames: runNames, }, nil } func loadOrCreateManifest(ctx context.Context, store *manifest.LocalStore, path, sessionID string) (*manifest.Manifest, error) { exists, err := fileExists(path) if err != nil { return nil, fmt.Errorf("check manifest existence %q: %w", path, err) } if exists { m, err := store.Load(ctx, path) if err != nil { return nil, fmt.Errorf("load manifest %q: %w", path, err) } return m, nil } m, err := store.Create(ctx, sessionID) if err != nil { return nil, fmt.Errorf("create manifest: %w", err) } if err := store.Save(ctx, path, m); err != nil { return nil, fmt.Errorf("save new manifest %q: %w", path, err) } return m, nil } func fileExists(path string) (bool, error) { _, err := os.Stat(path) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err } func mapResultOutputs(result *stage.StageResult) []manifest.ArtifactRecord { if result == nil || len(result.Outputs) == 0 { return nil } out := make([]manifest.ArtifactRecord, 0, len(result.Outputs)) for _, ref := range result.Outputs { localPath := ref.AbsolutePath if localPath == "" { localPath = ref.RelativePath } out = append(out, manifest.ArtifactRecord{ Kind: ref.Kind, LocalPath: localPath, RemoteKey: ref.RemoteKey, Checksum: ref.Checksum, }) } return out } func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) { if m == nil || result == nil { return } sr := m.Stages[stageName] if sr == nil { return } if len(result.Logs) > 0 { sr.Logs = append([]string(nil), result.Logs...) } if len(result.GeneratedConfigs) > 0 { sr.GeneratedConfigs = append([]string(nil), result.GeneratedConfigs...) } if len(result.Metadata) > 0 { sr.Metadata = result.Metadata } } func manifestPathFor(cfg *config.Config) string { return filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.SessionID, "manifest.json") }