package app import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) // Plan validates configuration and prints a read-only execution preview. func Plan(ctx context.Context, args []string, out io.Writer) error { request, err := parseBoundedRunRequest("plan", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } flags := request.Config loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("plan: %w", err) } defer func() { _ = loaded.Close() }() cfg := loaded.Config if err := config.Validate(cfg); err != nil { return fmt.Errorf("plan: %w", err) } effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts) if err != nil { return fmt.Errorf("plan: %w", err) } m, err := loadManifestIfPresent(ctx, cfg) if err != nil { return fmt.Errorf("plan: %w", err) } if err := validateBoundedPrerequisites(request.Plan, m); err != nil { return fmt.Errorf("plan: %w", err) } store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) model, err := cloneManifestForPlan(m, cfg) if err != nil { return fmt.Errorf("plan: clone session state: %w", err) } stages := request.Plan.Stages() stageEnv := &stage.Env{ Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...), EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force, } runCount := 0 skipCount := 0 if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s; %s\n", paths.Root, effectiveConfigSummary(cfg)); err != nil { return err } for _, selectedStage := range stages { semanticConfig, err := currentStageSemanticConfig(selectedStage, stageEnv) if err != nil { return fmt.Errorf("plan: fingerprint semantic configuration for stage %q: %w", selectedStage.Name(), err) } action := decideStageAction(selectedStage, model, request.Force) var validation *stage.ResumeValidation if action == stageActionSkip { checked, validationErr := evaluateStageResume(ctx, selectedStage, stageEnv, model, semanticConfig) if validationErr != nil { return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr) } validation = checked if checked != nil && !checked.Resumable { at := time.Now().UTC() model.MarkStageStale(selectedStage.Name(), at, checked.Reason) if _, invalidationErr := invalidateDependentSucceededStagesWithReason( model, selectedStage.Name(), at, staleReasonNotResumable, ); invalidationErr != nil { return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr) } action = stageActionRun } } if action == stageActionRun && selectedStage.Name() == "analyze" { if validator, ok := selectedStage.(stage.ResumeValidator); ok { checked, validationErr := validator.ValidateResume(ctx, stageEnv, model) if validationErr != nil { return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr) } checked = checked.Normalized() validation = &checked } } if action == stageActionRun { runCount++ } else { skipCount++ } if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil { return err } if validation != nil && validation.Analyze != nil { if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil { return err } } if action == stageActionRun { if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force, semanticConfig); err != nil { return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err) } } } if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil { return err } return nil } func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) { if source == nil { created := manifest.New(cfg.Session.SessionID, time.Now().UTC()) created.Campaign = cfg.Session.Campaign return created, nil } data, err := json.Marshal(source) if err != nil { return nil, err } var cloned manifest.Manifest if err := json.Unmarshal(data, &cloned); err != nil { return nil, err } return &cloned, nil } func modelPlannedStageRun( model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool, semanticConfig *manifest.SemanticConfigFingerprint, ) error { prior := capturePriorStageOutcome(model, selectedStage.Name()) at := time.Now().UTC() model.MarkStageRunning(selectedStage.Name(), at) if force { if _, err := invalidateDependentSucceededStagesWithReason( model, selectedStage.Name(), at, staleReasonForcedReplacement, ); err != nil { return err } } if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" { model.MarkStageSkipped(selectedStage.Name(), at, reason) setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig) if !prior.isSameSelfSkip(reason) { _, err := invalidateDependentSucceededStagesWithReason( model, selectedStage.Name(), at, staleReasonSelfSkip, ) return err } return nil } model.MarkStageSucceeded(selectedStage.Name(), at, nil) setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig) if !prior.exists || prior.status != manifest.StatusSucceeded { if _, err := invalidateDependentSucceededStagesWithReason( model, selectedStage.Name(), at, staleReasonChangedResult, ); err != nil { return err } } return nil } func plannedSelfSkipReason(stageName string, cfg *config.Config) string { if stageName == "extract" && cfg != nil && cfg.Pipeline != nil && (cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) { return "notarius_disabled" } return "" } func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error { if summary == nil { return nil } if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil { return err } if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil { return err } if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil { return err } _, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent)) return err } func planStringList(values []string) string { if len(values) == 0 { return "none" } return strings.Join(values, ", ") } func planArtifactList(values []stage.AnalyzeResumeArtifact) string { if len(values) == 0 { return "none" } parts := make([]string, 0, len(values)) for _, value := range values { detail := value.Role if value.Reason != "" { detail += ":" + value.Reason } if value.Forced { detail += ":forced" } parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail)) } return strings.Join(parts, ", ") }