diff --git a/internal/app/commands.go b/internal/app/commands.go index f0bb98a..a92043f 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -7,7 +7,7 @@ import ( "strings" ) -var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage"} +var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "restore"} // Execute dispatches CLI commands and returns a process exit code. func Execute(args []string, stdout, stderr io.Writer) int { @@ -32,6 +32,8 @@ func Execute(args []string, stdout, stderr io.Writer) int { err = Resume(ctx, cmdArgs, stdout) case "run-stage": err = RunStage(ctx, cmdArgs, stdout) + case "restore": + err = Restore(ctx, cmdArgs, stdout) default: fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd) printUsage(stderr) diff --git a/internal/app/restore.go b/internal/app/restore.go new file mode 100644 index 0000000..ebb0189 --- /dev/null +++ b/internal/app/restore.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +// Restore validates restore CLI/config inputs and storage preflight for future restore phases. +func Restore(ctx context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("restore", flag.ContinueOnError) + fs.SetOutput(out) + + var pipelinePath string + var sessionPath string + var sessionID string + var dryRun bool + var force bool + var includeAudio bool + fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") + fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") + fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") + fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") + fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") + fs.Usage = func() { + _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config ] [--session ] [--session-id ] [--dry-run] [--force] [--include-audio]") + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Flags:") + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return fmt.Errorf("restore: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("restore: unexpected positional arguments") + } + resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + SessionID: sessionID, + }) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("restore: %w", err) + } + + _, err = storage.NewObjectStoreFromConfig(ctx, cfg) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + + // Phase 2 boundary: command wiring and preflight only. + _ = dryRun + _ = force + _ = includeAudio + return fmt.Errorf("restore: not yet implemented (phase 3: remote current-state discovery)") +} diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go new file mode 100644 index 0000000..b133fb0 --- /dev/null +++ b/internal/app/restore_test.go @@ -0,0 +1,131 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" +) + +func TestExecuteRestoreHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Execute([]string{"restore", "--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "Usage: narratio restore") { + t.Fatalf("stdout = %q, want restore usage", out) + } + if !strings.Contains(out, "--include-audio") { + t.Fatalf("stdout = %q, want --include-audio flag", out) + } +} + +func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{ + "restore", + "--config", pipelinePath, + "--session", sessionPath, + "--session-id", "2026-05-03", + "--dry-run", + "--force", + "--include-audio", + }, + &stdout, + &stderr, + ) + if code == 0 { + t.Fatal("exit code = 0, want non-zero (phase 2 NYI)") + } + errText := stderr.String() + if !strings.Contains(errText, "restore: not yet implemented (phase 3: remote current-state discovery)") { + t.Fatalf("stderr = %q, want NYI error", errText) + } + if strings.Contains(errText, "unknown command") { + t.Fatalf("stderr = %q, restore should be recognized command", errText) + } + + manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") + if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { + t.Fatalf("manifest should not be created during phase-2 restore preflight; stat err=%v", err) + } +} + +func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "extra"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "restore: unexpected positional arguments") { + t.Fatalf("stderr = %q, want positional-args failure", stderr.String()) + } +} + +func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "no remote object store backend is configured") { + t.Fatalf("stderr = %q, want storage backend preflight failure", stderr.String()) + } +} + +func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) { + t.Helper() + + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + sessionPath := filepath.Join(dir, "session.yml") + + pipelineYAML := `workspace: + root: ` + workspaceRoot + ` +whisperx: + transcribe_url: https://example.com/transcribe +` + sessionYAML := `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline config: %v", err) + } + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session config: %v", err) + } + mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n") + mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") + + return pipelinePath, sessionPath +} diff --git a/internal/artifacts/archive_identity.go b/internal/artifacts/archive_identity.go new file mode 100644 index 0000000..1f6a6e1 --- /dev/null +++ b/internal/artifacts/archive_identity.go @@ -0,0 +1,77 @@ +package artifacts + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +// ResolveArchiveBucket resolves archive bucket identity with manifest-first precedence. +func ResolveArchiveBucket(cfg *config.Config, m *manifest.Manifest) string { + if m != nil && strings.TrimSpace(m.S3Bucket) != "" { + return strings.TrimSpace(m.S3Bucket) + } + if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Storage.S3 == nil { + return "" + } + return strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) +} + +// ResolveArchiveSessionPrefix resolves archive session prefix with manifest-first precedence. +func ResolveArchiveSessionPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) { + if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" { + return strings.TrimSpace(m.S3SessionPrefix), nil + } + if cfg == nil || cfg.Session == nil || cfg.Pipeline == nil { + return "", fmt.Errorf("resolved config is required") + } + + sessionID := strings.TrimSpace(cfg.Session.SessionID) + if sessionID == "" && m != nil { + sessionID = strings.TrimSpace(m.SessionID) + } + campaign := strings.TrimSpace(cfg.Session.Campaign) + if campaign == "" && m != nil { + campaign = strings.TrimSpace(m.Campaign) + } + if cfg.Pipeline.Storage.S3 == nil { + return "", fmt.Errorf("pipeline.storage.s3 configuration is required") + } + + sessionPrefix := S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, campaign, sessionID) + if strings.TrimSpace(sessionPrefix) == "" { + return "", fmt.Errorf("session prefix is required") + } + return sessionPrefix, nil +} + +// ResolveArchiveRunPrefix resolves archive run prefix with manifest-first precedence. +func ResolveArchiveRunPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) { + if m != nil { + runPrefix := strings.TrimSpace(m.S3RunPrefix) + if runPrefix != "" { + return runPrefix, nil + } + } + + sessionPrefix, err := ResolveArchiveSessionPrefix(cfg, m) + if err != nil { + return "", err + } + + runID := "" + if m != nil { + runID = strings.TrimSpace(m.RunID) + } + if runID == "" { + return "", fmt.Errorf("run id is required") + } + return S3RunPrefix(sessionPrefix, runID), nil +} + +// ResolveArchiveCurrentStateKeys returns current pointer keys for a session prefix. +func ResolveArchiveCurrentStateKeys(sessionPrefix string) (manifestKey, runIDKey string) { + return S3CurrentManifestKey(sessionPrefix), S3CurrentRunPointerKey(sessionPrefix) +} diff --git a/internal/artifacts/archive_identity_test.go b/internal/artifacts/archive_identity_test.go new file mode 100644 index 0000000..27ae8f1 --- /dev/null +++ b/internal/artifacts/archive_identity_test.go @@ -0,0 +1,130 @@ +package artifacts + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestResolveArchiveBucketPrefersManifestThenConfig(t *testing.T) { + cfg := &config.Config{ + Pipeline: &config.PipelineConfig{ + Storage: config.StorageConfig{ + S3: &config.StorageS3Config{Bucket: "cfg-bucket"}, + }, + }, + } + + if got := ResolveArchiveBucket(cfg, &manifest.Manifest{S3Bucket: "manifest-bucket"}); got != "manifest-bucket" { + t.Fatalf("bucket = %q, want manifest-bucket", got) + } + if got := ResolveArchiveBucket(cfg, &manifest.Manifest{}); got != "cfg-bucket" { + t.Fatalf("bucket = %q, want cfg-bucket", got) + } +} + +func TestResolveArchiveSessionPrefixPrefersManifestThenConfig(t *testing.T) { + cfg := &config.Config{ + Pipeline: &config.PipelineConfig{ + Storage: config.StorageConfig{ + S3: &config.StorageS3Config{RootPrefix: "dnd"}, + }, + }, + Session: &config.SessionConfig{ + SessionID: "2026-04-19", + Campaign: "forsaken", + }, + } + m := &manifest.Manifest{S3SessionPrefix: "manifest/session/prefix/"} + + got, err := ResolveArchiveSessionPrefix(cfg, m) + if err != nil { + t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err) + } + if got != "manifest/session/prefix/" { + t.Fatalf("session prefix = %q, want manifest/session/prefix/", got) + } + + got, err = ResolveArchiveSessionPrefix(cfg, &manifest.Manifest{}) + if err != nil { + t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err) + } + want := "dnd/campaigns/forsaken/sessions/2026-04-19/" + if got != want { + t.Fatalf("session prefix = %q, want %q", got, want) + } +} + +func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) { + cfg := &config.Config{ + Pipeline: &config.PipelineConfig{ + Storage: config.StorageConfig{ + S3: &config.StorageS3Config{RootPrefix: "dnd"}, + }, + }, + Session: &config.SessionConfig{ + SessionID: "2026-04-19", + Campaign: "forsaken", + }, + } + + m := &manifest.Manifest{ + RunID: "20260516T010203Z-1a2b3c4d", + S3RunPrefix: "manifest/run/prefix/", + } + got, err := ResolveArchiveRunPrefix(cfg, m) + if err != nil { + t.Fatalf("ResolveArchiveRunPrefix() error = %v", err) + } + if got != "manifest/run/prefix/" { + t.Fatalf("run prefix = %q, want manifest/run/prefix/", got) + } + + m = &manifest.Manifest{ + RunID: "20260516T010203Z-1a2b3c4d", + } + got, err = ResolveArchiveRunPrefix(cfg, m) + if err != nil { + t.Fatalf("ResolveArchiveRunPrefix() error = %v", err) + } + want := "dnd/campaigns/forsaken/sessions/2026-04-19/runs/20260516T010203Z-1a2b3c4d/" + if got != want { + t.Fatalf("run prefix = %q, want %q", got, want) + } +} + +func TestResolveArchiveIdentityErrorsAreDeterministic(t *testing.T) { + cfgNoS3 := &config.Config{ + Pipeline: &config.PipelineConfig{}, + Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"}, + } + _, err := ResolveArchiveSessionPrefix(cfgNoS3, &manifest.Manifest{}) + if err == nil || !strings.Contains(err.Error(), "pipeline.storage.s3 configuration is required") { + t.Fatalf("error = %v, want missing storage.s3", err) + } + + cfg := &config.Config{ + Pipeline: &config.PipelineConfig{ + Storage: config.StorageConfig{ + S3: &config.StorageS3Config{RootPrefix: "dnd"}, + }, + }, + Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"}, + } + _, err = ResolveArchiveRunPrefix(cfg, &manifest.Manifest{}) + if err == nil || !strings.Contains(err.Error(), "run id is required") { + t.Fatalf("error = %v, want missing run id", err) + } +} + +func TestResolveArchiveCurrentStateKeys(t *testing.T) { + manifestKey, runIDKey := ResolveArchiveCurrentStateKeys("dnd/campaigns/forsaken/sessions/2026-04-19/") + if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" { + t.Fatalf("manifest key = %q", manifestKey) + } + if runIDKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/run_id.txt" { + t.Fatalf("run id key = %q", runIDKey) + } +} diff --git a/internal/stage/archive.go b/internal/stage/archive.go index c91561a..3c4b9b0 100644 --- a/internal/stage/archive.go +++ b/internal/stage/archive.go @@ -91,15 +91,15 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("archive: run root %q is not a directory", runRoot) } - runPrefix, err := archiveRunPrefix(env, m) + runPrefix, err := artifacts.ResolveArchiveRunPrefix(env.Config, m) if err != nil { return nil, fmt.Errorf("archive: resolve s3 run prefix: %w", err) } - sessionPrefix, err := archiveSessionPrefix(env, m) + sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(env.Config, m) if err != nil { return nil, fmt.Errorf("archive: resolve s3 session prefix: %w", err) } - bucket := archiveBucket(env, m) + bucket := artifacts.ResolveArchiveBucket(env.Config, m) if bucket == "" { return nil, fmt.Errorf("archive: resolve s3 bucket: bucket is required") } @@ -144,7 +144,7 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S promotedUploaded = append(promotedUploaded, promotion.Dest) } - currentManifestKey := artifacts.S3CurrentManifestKey(sessionPrefix) + currentManifestKey, currentRunPointerKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestTempPath, err := writeCurrentManifestSnapshot(m, archiveMetadataPreview( bucket, runPrefix, @@ -165,7 +165,6 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("archive: upload current manifest to %q: %w", currentManifestKey, err) } - currentRunPointerKey := artifacts.S3CurrentRunPointerKey(sessionPrefix) runIDTempPath, err := writeCurrentRunIDPointer(runID) if err != nil { return nil, fmt.Errorf("archive: build current run id pointer: %w", err) @@ -299,56 +298,6 @@ func archiveSessionPaths(env *Env, m *manifest.Manifest) artifacts.SessionPaths return store.SessionPathsFor(campaign, sessionID) } -func archiveRunPrefix(env *Env, m *manifest.Manifest) (string, error) { - runPrefix := strings.TrimSpace(m.S3RunPrefix) - if runPrefix != "" { - return runPrefix, nil - } - - sessionPrefix, err := archiveSessionPrefix(env, m) - if err != nil { - return "", err - } - runID := strings.TrimSpace(m.RunID) - if runID == "" { - return "", fmt.Errorf("run id is required") - } - return artifacts.S3RunPrefix(sessionPrefix, runID), nil -} - -func archiveSessionPrefix(env *Env, m *manifest.Manifest) (string, error) { - if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" { - return strings.TrimSpace(m.S3SessionPrefix), nil - } - - sessionID := strings.TrimSpace(env.Config.Session.SessionID) - if sessionID == "" { - sessionID = strings.TrimSpace(m.SessionID) - } - campaign := strings.TrimSpace(env.Config.Session.Campaign) - if campaign == "" { - campaign = strings.TrimSpace(m.Campaign) - } - if env.Config.Pipeline.Storage.S3 == nil { - return "", fmt.Errorf("pipeline.storage.s3 configuration is required") - } - sessionPrefix := artifacts.S3SessionPrefix(env.Config.Pipeline.Storage.S3.RootPrefix, campaign, sessionID) - if strings.TrimSpace(sessionPrefix) == "" { - return "", fmt.Errorf("session prefix is required") - } - return sessionPrefix, nil -} - -func archiveBucket(env *Env, m *manifest.Manifest) string { - if m != nil && strings.TrimSpace(m.S3Bucket) != "" { - return strings.TrimSpace(m.S3Bucket) - } - if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Pipeline.Storage.S3 == nil { - return "" - } - return strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket) -} - func resolveArchivePromotions( paths artifacts.SessionPaths, m *manifest.Manifest,