From c366912586753400848e033dfd998f2a2aba46a2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 23 May 2026 16:00:31 +0000 Subject: [PATCH] Extract shared read-only session inspection checks --- internal/app/operator_findings.go | 101 ++------ internal/app/operator_helpers_test.go | 25 ++ internal/app/operator_inspection.go | 274 ++++++++++++++++++++++ internal/app/operator_session_validate.go | 23 +- internal/app/operator_status.go | 96 +++++++- 5 files changed, 418 insertions(+), 101 deletions(-) create mode 100644 internal/app/operator_inspection.go diff --git a/internal/app/operator_findings.go b/internal/app/operator_findings.go index cf89a0b..2462aa9 100644 --- a/internal/app/operator_findings.go +++ b/internal/app/operator_findings.go @@ -10,7 +10,6 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" - "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -64,26 +63,18 @@ func sessionSourceSummary(cfg *config.Config) string { } func validateStableInputFindings(cfg *config.Config) []finding { - items := []struct { - name string - in config.ResolvedInputFile - }{ - {"speakers", cfg.StableInputs.SpeakersFile}, - {"autocorrect", cfg.StableInputs.AutocorrectFile}, - {"glossary", cfg.StableInputs.GlossaryFile}, - } - out := make([]finding, 0, len(items)) - for _, item := range items { - path, err := resolveHelperConfigRelativePath(item.in) - if err != nil { - out = append(out, errorFinding("inputs", item.name+": "+err.Error())) + checks := inspectStableInputs(cfg) + out := make([]finding, 0, len(checks)) + for _, check := range checks { + if check.Err != nil { + msg := check.Name + ": " + check.Err.Error() + if strings.TrimSpace(check.Path) != "" { + msg = fmt.Sprintf("%s missing: %v", check.Name, check.Err) + } + out = append(out, errorFinding("inputs", msg)) continue } - if _, err := os.Stat(path); err != nil { - out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err))) - } else { - out = append(out, okFinding("inputs", item.name+": "+path)) - } + out = append(out, okFinding("inputs", check.Name+": "+check.Path)) } return out } @@ -103,76 +94,22 @@ func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, er } func validateLocalAudioFindings(cfg *config.Config) []finding { - if cfg.Session.Inputs.AudioS3 != nil { + check := inspectLocalAudioPresence(cfg) + if !check.Checked { return nil } - audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir) - if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 { - return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")} + if check.Err != nil { + return []finding{errorFinding("audio", check.Err.Error())} } - base := filepath.Dir(cfg.SessionPath) - paths := []string{} - if audioDir != "" { - dir := audioDir - if !filepath.IsAbs(dir) { - dir = filepath.Join(base, dir) - } - matches, err := filepath.Glob(filepath.Join(dir, "*.flac")) - if err != nil || len(matches) == 0 { - return []finding{errorFinding("audio", "no .flac files found in "+dir)} - } - paths = append(paths, matches...) - } - for _, file := range cfg.Session.Inputs.AudioFiles { - p := file - if !filepath.IsAbs(p) { - p = filepath.Join(base, p) - } - paths = append(paths, p) - } - for _, p := range paths { - if _, err := os.Stat(p); err != nil { - return []finding{errorFinding("audio", fmt.Sprintf("audio file missing: %v", err))} - } - } - return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(paths)))} + return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(check.Paths)))} } func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding { - sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) - audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix) - objects, err := store.List(ctx, audioPrefix) - if err != nil { - return errorFinding("audio", err.Error()) + check := inspectRemoteAudioPresence(ctx, cfg, store) + if check.Err != nil { + return errorFinding("audio", check.Err.Error()) } - count := 0 - for _, obj := range objects { - if strings.HasSuffix(strings.ToLower(obj.Key), ".flac") { - count++ - } - } - if count == 0 { - return errorFinding("audio", "no remote .flac objects found under "+audioPrefix) - } - return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", count)) -} - -func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding { - out := []finding{} - prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) - _, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{ - ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID), - ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign), - ValidateRunID: true, - }) - if err != nil { - out = append(out, errorFinding("previous", fmt.Sprintf("remote %v", err))) - return out - } - for _, req := range requirements { - out = append(out, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required))) - } - return out + return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", len(check.Keys))) } func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) { diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 6dd254e..760e82c 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -884,6 +884,31 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing. } } +func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + replaceInFileOrFatal(t, pipelinePath, "source: narratio.artifact.session_recap", "source: narratio.previous_session.artifact.session_recap") + replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n") + fake := &storage.FakeBackend{} + var storeInitCalls int + restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{ + "session", "status", "2026-05-03", + "--config", pipelinePath, + "--campaign-file", campaignPath, + "--session", sessionPath, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Previous-session artifacts: unavailable: remote current run pointer missing") { + t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String()) + } +} + func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go new file mode 100644 index 0000000..fdc66d3 --- /dev/null +++ b/internal/app/operator_inspection.go @@ -0,0 +1,274 @@ +package app + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +type stableInputCheck struct { + Name string + Path string + Err error +} + +type localAudioCheck struct { + Checked bool + Paths []string + Err error +} + +type remoteAudioCheck struct { + Checked bool + Prefix string + Keys []string + Err error +} + +type previousArtifactReadiness struct { + Requirements []artifacts.PreviousArtifactRequirement + MissingID bool + Err error +} + +type remoteCurrentStateCheck struct { + State *RemoteCurrentState + Err error +} + +type effectiveLocksCheck struct { + Locks *effectiveLocks + Err error +} + +func inspectStableInputs(cfg *config.Config) []stableInputCheck { + items := []struct { + name string + in config.ResolvedInputFile + }{ + {name: "speakers", in: cfg.StableInputs.SpeakersFile}, + {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, + {name: "glossary", in: cfg.StableInputs.GlossaryFile}, + } + out := make([]stableInputCheck, 0, len(items)) + for _, item := range items { + path, err := resolveHelperConfigRelativePath(item.in) + if err != nil { + out = append(out, stableInputCheck{Name: item.name, Err: err}) + continue + } + if _, err := os.Stat(path); err != nil { + out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err}) + continue + } + out = append(out, stableInputCheck{Name: item.name, Path: path}) + } + return out +} + +func inspectLocalAudioPresence(cfg *config.Config) localAudioCheck { + if cfg.Session.Inputs.AudioS3 != nil { + return localAudioCheck{} + } + + sessionDir := filepath.Dir(cfg.SessionPath) + resolved, err := resolveLocalInspectionAudioPaths(sessionDir, cfg.Session.Inputs) + if err != nil { + return localAudioCheck{Checked: true, Err: err} + } + return localAudioCheck{ + Checked: true, + Paths: resolved, + } +} + +func inspectRemoteAudioPresence(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteAudioCheck { + if cfg.Session.Inputs.AudioS3 == nil { + return remoteAudioCheck{} + } + if store == nil { + return remoteAudioCheck{Checked: true, Err: fmt.Errorf("storage backend is required for remote audio checks")} + } + + sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) + audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix) + objects, err := store.List(ctx, audioPrefix) + if err != nil { + return remoteAudioCheck{Checked: true, Prefix: audioPrefix, Err: err} + } + + keys := make([]string, 0, len(objects)) + seenBase := map[string]string{} + for _, obj := range objects { + key := strings.TrimSpace(obj.Key) + if key == "" || strings.HasSuffix(key, "/") || !isInspectionFlacPath(key) { + continue + } + base := path.Base(key) + if prev, exists := seenBase[base]; exists && prev != key { + return remoteAudioCheck{ + Checked: true, + Prefix: audioPrefix, + Err: fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, prev, key), + } + } + seenBase[base] = key + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + return remoteAudioCheck{ + Checked: true, + Prefix: audioPrefix, + Err: fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix), + } + } + return remoteAudioCheck{ + Checked: true, + Prefix: audioPrefix, + Keys: keys, + } +} + +func inspectPreviousArtifactReadiness( + ctx context.Context, + cfg *config.Config, + store storage.ObjectStore, + requirements []artifacts.PreviousArtifactRequirement, +) previousArtifactReadiness { + out := previousArtifactReadiness{ + Requirements: append([]artifacts.PreviousArtifactRequirement(nil), requirements...), + } + if len(requirements) == 0 { + return out + } + if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" { + out.MissingID = true + return out + } + if store == nil { + out.Err = fmt.Errorf("previous-session artifacts cannot be checked because storage is unavailable") + return out + } + + prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) + if _, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{ + ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID), + ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign), + ValidateRunID: true, + }); err != nil { + out.Err = fmt.Errorf("remote %v", err) + } + return out +} + +func inspectRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteCurrentStateCheck { + if store == nil { + return remoteCurrentStateCheck{} + } + current, err := discoverRemoteCurrentStateFn(ctx, cfg, store) + if err != nil { + return remoteCurrentStateCheck{Err: err} + } + return remoteCurrentStateCheck{State: current} +} + +func inspectEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.ObjectStore) effectiveLocksCheck { + locks, err := loadEffectiveLocks(ctx, cfg, store) + if err != nil { + return effectiveLocksCheck{Err: err} + } + return effectiveLocksCheck{Locks: locks} +} + +func resolveLocalInspectionAudioPaths(sessionDir string, inputs config.SessionInputsConfig) ([]string, error) { + if len(inputs.AudioFiles) > 0 { + out := make([]string, 0, len(inputs.AudioFiles)) + seenBase := map[string]string{} + for _, item := range inputs.AudioFiles { + resolved, err := resolveInspectionPath(sessionDir, item) + if err != nil { + return nil, err + } + if !isInspectionFlacPath(resolved) { + return nil, fmt.Errorf("audio file %q must have .flac extension", resolved) + } + if err := requireInspectionFile(resolved, "audio file"); err != nil { + return nil, err + } + base := filepath.Base(resolved) + if prev, exists := seenBase[base]; exists && prev != resolved { + return nil, fmt.Errorf("duplicate audio basename %q from %q and %q", base, prev, resolved) + } + seenBase[base] = resolved + out = append(out, resolved) + } + sort.Strings(out) + return out, nil + } + + audioDir, err := resolveInspectionPath(sessionDir, inputs.AudioDir) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(audioDir) + if err != nil { + return nil, fmt.Errorf("read audio directory %q: %w", audioDir, err) + } + out := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + full := filepath.Join(audioDir, entry.Name()) + if !isInspectionFlacPath(full) { + continue + } + if err := requireInspectionFile(full, "audio file"); err != nil { + return nil, err + } + out = append(out, full) + } + if len(out) == 0 { + return nil, fmt.Errorf("no .flac files found in audio directory %q", audioDir) + } + sort.Strings(out) + return out, nil +} + +func resolveInspectionPath(baseDir, inputPath string) (string, error) { + pathValue := strings.TrimSpace(inputPath) + if pathValue == "" { + return "", fmt.Errorf("path is required") + } + if filepath.IsAbs(pathValue) { + return filepath.Clean(pathValue), nil + } + return filepath.Clean(filepath.Join(baseDir, pathValue)), nil +} + +func requireInspectionFile(path, label string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s %q does not exist", label, path) + } + return fmt.Errorf("stat %s %q: %w", label, path, err) + } + if info.IsDir() { + return fmt.Errorf("%s %q is a directory", label, path) + } + return nil +} + +func isInspectionFlacPath(path string) bool { + return strings.EqualFold(filepath.Ext(strings.TrimSpace(path)), ".flac") +} diff --git a/internal/app/operator_session_validate.go b/internal/app/operator_session_validate.go index cb9b7ec..abfcd43 100644 --- a/internal/app/operator_session_validate.go +++ b/internal/app/operator_session_validate.go @@ -54,23 +54,26 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error { } requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) - if len(requirements) == 0 { + previous := inspectPreviousArtifactReadiness(ctx, cfg, store, requirements) + if len(previous.Requirements) == 0 { findings = append(findings, okFinding("previous", "no previous-session artifacts required")) - } else if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" { + } else if previous.MissingID { findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts")) - } else if storeErr != nil { - findings = append(findings, errorFinding("previous", "previous-session artifacts cannot be checked because storage is unavailable")) + } else if previous.Err != nil { + findings = append(findings, errorFinding("previous", previous.Err.Error())) } else { - findings = append(findings, validatePreviousArtifactFindings(ctx, cfg, store, requirements)...) + for _, req := range previous.Requirements { + findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required))) + } } - locks, lockErr := loadEffectiveLocks(ctx, cfg, store) - if lockErr != nil { - findings = append(findings, errorFinding("locks", lockErr.Error())) - } else if len(locks.All) == 0 { + 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.All { + for _, lock := range locks.Locks.All { findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason)))) } } diff --git a/internal/app/operator_status.go b/internal/app/operator_status.go index 2c7c611..eb9ce06 100644 --- a/internal/app/operator_status.go +++ b/internal/app/operator_status.go @@ -5,8 +5,10 @@ import ( "flag" "fmt" "io" + "sort" "strings" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" ) @@ -35,6 +37,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error { fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign) fmt.Fprintf(out, "Workspace: %s\n", paths.Root) fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg)) + writeStatusStableInputs(out, inspectStableInputs(cfg)) + writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg)) if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil { fmt.Fprintf(out, "Local manifest: error: %v\n", err) @@ -49,21 +53,30 @@ func Status(ctx context.Context, args []string, out io.Writer) error { if storeErr != nil { fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr) } else if store != nil { - current, err := discoverRemoteCurrentStateFn(ctx, cfg, store) - if err != nil { - fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err) + current := inspectRemoteCurrentState(ctx, cfg, store) + if current.Err != nil { + fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err) } else { - fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID) - fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey) + fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID) + fmt.Fprintf(out, "Remote manifest: %s\n", current.State.CurrentManifestKey) } } + writeStatusRemoteAudio(ctx, out, cfg, store, storeErr) + writeStatusPreviousArtifacts(out, inspectPreviousArtifactReadiness( + ctx, + cfg, + store, + artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)), + )) - locks, err := loadEffectiveLocks(ctx, cfg, store) + lockChecks := inspectEffectiveLocks(ctx, cfg, store) + locks := lockChecks.Locks + lockErr := lockChecks.Err if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil { fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr) } else if storeErr == nil { catalogLocks := locks - if err != nil { + if lockErr != nil { catalogLocks = &effectiveLocks{ Static: staticPublishLocks(cfg), All: staticPublishLocks(cfg), @@ -76,8 +89,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error { fmt.Fprintln(out, "Remote outputs:") writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState) } - if err != nil { - fmt.Fprintf(out, "Publish locks: error: %v\n", err) + if lockErr != nil { + fmt.Fprintf(out, "Publish locks: error: %v\n", lockErr) } else { writeLocks(out, cfg, locks) } @@ -86,3 +99,68 @@ func Status(ctx context.Context, args []string, out io.Writer) error { fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID) return nil } + +func writeStatusStableInputs(out io.Writer, checks []stableInputCheck) { + if len(checks) == 0 { + return + } + for _, check := range checks { + if check.Err != nil { + if strings.TrimSpace(check.Path) != "" { + fmt.Fprintf(out, "Stable input %s: unavailable: %v\n", check.Name, check.Err) + } else { + fmt.Fprintf(out, "Stable input %s: unavailable: %s\n", check.Name, check.Err.Error()) + } + continue + } + fmt.Fprintf(out, "Stable input %s: %s\n", check.Name, check.Path) + } +} + +func writeStatusLocalAudio(out io.Writer, check localAudioCheck) { + if !check.Checked { + return + } + if check.Err != nil { + fmt.Fprintf(out, "Local audio: unavailable: %v\n", check.Err) + return + } + fmt.Fprintf(out, "Local audio: %d file(s)\n", len(check.Paths)) +} + +func writeStatusRemoteAudio(ctx context.Context, out io.Writer, cfg *config.Config, store storage.ObjectStore, storeErr error) { + if cfg.Session.Inputs.AudioS3 == nil { + return + } + if storeErr != nil { + fmt.Fprintf(out, "Remote audio: unavailable: %v\n", storeErr) + return + } + check := inspectRemoteAudioPresence(ctx, cfg, store) + if check.Err != nil { + fmt.Fprintf(out, "Remote audio: unavailable: %v\n", check.Err) + return + } + fmt.Fprintf(out, "Remote audio: %d .flac object(s)\n", len(check.Keys)) +} + +func writeStatusPreviousArtifacts(out io.Writer, readiness previousArtifactReadiness) { + if len(readiness.Requirements) == 0 { + fmt.Fprintln(out, "Previous-session artifacts: not required") + return + } + if readiness.MissingID { + fmt.Fprintln(out, "Previous-session artifacts: unavailable: previous_session_id is required by configured previous-session artifacts") + return + } + if readiness.Err != nil { + fmt.Fprintf(out, "Previous-session artifacts: unavailable: %v\n", readiness.Err) + return + } + names := make([]string, 0, len(readiness.Requirements)) + for _, req := range readiness.Requirements { + names = append(names, fmt.Sprintf("%s(required=%t)", req.Name, req.Required)) + } + sort.Strings(names) + fmt.Fprintf(out, "Previous-session artifacts: ready: %s\n", strings.Join(names, ", ")) +}