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 { return planWithDependencies(ctx, args, out, commandDependencies{}) } func planWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { deps = normalizeCommandDependencies(deps) request, err := parseBoundedRunRequest("plan", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } flags := request.Config loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps) 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) } selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts) if err != nil { return fmt.Errorf("plan: %w", err) } effective, err := resolveEffectiveArtifacts(cfg, 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), 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) } decision := decideStageExecution(recordedStageStatus(model, selectedStage.Name()), request.Force, nil) var validation *stage.ResumeValidation if decision.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 decision = decideStageExecution(recordedStageStatus(model, selectedStage.Name()), request.Force, checked) } if _, err := applyPreExecutionDecision(model, selectedStage.Name(), decision, time.Now().UTC()); err != nil { return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), err) } if decision.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 decision.action == stageActionRun { runCount++ } else { skipCount++ } if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), decision.action); err != nil { return err } if validation != nil && validation.Analyze != nil { if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil { return err } } if decision.action == stageActionRun { if err := modelPlannedStageRun(model, selectedStage, cfg, decision, 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, decision preExecutionDecision, semanticConfig *manifest.SemanticConfigFingerprint, ) error { prior := capturePriorStageOutcome(model, selectedStage.Name()) at := time.Now().UTC() if _, err := beginStageAttempt(model, selectedStage.Name(), decision, at); err != nil { return err } if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" { _, err := applyCompletedStageOutcome(model, selectedStage.Name(), &stage.StageResult{ Disposition: stage.StageDispositionSkipped, SkipReason: reason, }, nil, semanticConfig, prior, at) return err } _, err := applyCompletedStageOutcome(model, selectedStage.Name(), &stage.StageResult{}, nil, semanticConfig, prior, at) return err } 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" } identity := value.Key if value.Family != "" { identity += fmt.Sprintf("[family=%s character_id=%s]", value.Family, value.CharacterID) } parts = append(parts, fmt.Sprintf("%s(%s)", identity, detail)) } return strings.Join(parts, ", ") }