package app import ( "context" "flag" "fmt" "io" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // SessionValidate performs a read-only session preflight. func SessionValidate(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("session validate", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags addCommonConfigFlags(fs, &flags) if err := parseSessionAwareFlags("session validate", fs, args, &flags.sessionID); err != nil { return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("session validate: session_id is required") } findings := []finding{} cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { findings = append(findings, errorFinding("config", err.Error())) return renderFindings(out, "", "", findings) } if err := config.Validate(cfg); err != nil { findings = append(findings, errorFinding("config", err.Error())) } else { findings = append(findings, okFinding("config", "resolved pipeline, campaign, and session config")) } findings = append(findings, okFinding("session", fmt.Sprintf("session source: %s", sessionSourceSummary(cfg)))) paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) findings = append(findings, validateStableInputFindings(cfg)...) findings = append(findings, validateLocalAudioFindings(cfg)...) store, storeErr := objectStoreIfConfigured(ctx, cfg) if storeErr != nil { findings = append(findings, errorFinding("storage", storeErr.Error())) } if cfg.Session.Inputs.AudioS3 != nil { if storeErr != nil { findings = append(findings, errorFinding("audio", "remote audio cannot be checked because storage is unavailable")) } else { findings = append(findings, validateRemoteAudioFinding(ctx, cfg, store)) } } requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) previous := inspectPreviousArtifactReadiness(ctx, cfg, store, requirements) if len(previous.Requirements) == 0 { findings = append(findings, okFinding("previous", "no previous-session artifacts required")) } else if previous.Err != nil { findings = append(findings, errorFinding("previous", previous.Err.Error())) } else { for _, req := range previous.Requirements { if !req.Required && previousRequirementSkipped(previous.SkippedMissing, req.Name) { findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=false unavailable", req.Name))) continue } findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required))) } } locks := inspectEffectiveLocks(ctx, cfg, store) if locks.Err != nil { findings = append(findings, errorFinding("locks", locks.Err.Error())) } else if len(locks.Locks.All) == 0 { findings = append(findings, okFinding("locks", "no effective publish locks")) } else { for _, lock := range locks.Locks.All { findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason)))) } } if paths.ManifestPath != "" { findings = append(findings, infoFinding("workspace", "manifest path: "+paths.ManifestPath)) } return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings) } func previousRequirementSkipped(values []string, name string) bool { for _, value := range values { if value == name { return true } } return false }