diff --git a/internal/adapters/analyzer/fake.go b/internal/adapters/analyzer/fake.go new file mode 100644 index 0000000..828b8e9 --- /dev/null +++ b/internal/adapters/analyzer/fake.go @@ -0,0 +1,40 @@ +package analyzer + +import "context" + +// NoopRunner is a deterministic no-op analyzer adapter. +type NoopRunner struct{} + +// Run returns the requested output path with placeholder metadata. +func (n *NoopRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) { + if err := ctx.Err(); err != nil { + return AnalyzeResult{}, err + } + return AnalyzeResult{ArtifactPath: req.OutputPath, Metadata: map[string]any{"placeholder": true}}, nil +} + +// FakeRunner captures analyze requests and returns deterministic responses. +type FakeRunner struct { + Requests []AnalyzeRequest + Err error + Result AnalyzeResult +} + +// Run records request and returns configured response. +func (f *FakeRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) { + if err := ctx.Err(); err != nil { + return AnalyzeResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return AnalyzeResult{}, f.Err + } + res := f.Result + if res.ArtifactPath == "" { + res.ArtifactPath = req.OutputPath + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/analyzer/fake_test.go b/internal/adapters/analyzer/fake_test.go new file mode 100644 index 0000000..56b9e94 --- /dev/null +++ b/internal/adapters/analyzer/fake_test.go @@ -0,0 +1,31 @@ +package analyzer + +import ( + "context" + "errors" + "testing" +) + +func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeRunner{} + req := AnalyzeRequest{ArtifactType: "session-log", OutputPath: "artifacts/session-log.md"} + + res, err := fake.Run(context.Background(), req) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].ArtifactType != "session-log" { + t.Fatalf("requests = %#v, want captured request", fake.Requests) + } + if res.ArtifactPath != req.OutputPath { + t.Fatalf("artifact path = %q, want %q", res.ArtifactPath, req.OutputPath) + } +} + +func TestFakeRunnerError(t *testing.T) { + fake := &FakeRunner{Err: errors.New("boom")} + _, err := fake.Run(context.Background(), AnalyzeRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/analyzer/runner.go b/internal/adapters/analyzer/runner.go index a8a1fc7..f5ac1c2 100644 --- a/internal/adapters/analyzer/runner.go +++ b/internal/adapters/analyzer/runner.go @@ -3,18 +3,24 @@ package analyzer import "context" -// Runner is a placeholder adapter interface for session artifact generation. +// Runner is the adapter boundary for analyzer invocations. type Runner interface { - Run(ctx context.Context, req AnalysisRequest) (AnalysisResult, error) + Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) } -// AnalysisRequest is a placeholder analyzer input. -type AnalysisRequest struct { +// AnalyzeRequest describes one analyzer artifact generation request. +type AnalyzeRequest struct { + ArtifactType string ProcessedTranscriptPath string - OutputDir string + ContextReferences []string + OutputPath string + GeneratedConfigPath string + StdoutLogPath string + StderrLogPath string } -// AnalysisResult is a placeholder analyzer output. -type AnalysisResult struct { - ArtifactPaths []string +// AnalyzeResult describes analyzer output. +type AnalyzeResult struct { + ArtifactPath string + Metadata map[string]any } diff --git a/internal/adapters/audita/fake.go b/internal/adapters/audita/fake.go new file mode 100644 index 0000000..6324b43 --- /dev/null +++ b/internal/adapters/audita/fake.go @@ -0,0 +1,40 @@ +package audita + +import "context" + +// NoopRunner is a deterministic no-op audita adapter. +type NoopRunner struct{} + +// Run returns requested output path with placeholder metadata. +func (n *NoopRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { + if err := ctx.Err(); err != nil { + return PolishResult{}, err + } + return PolishResult{ProcessedTranscriptPath: req.OutputProcessedPath, Metadata: map[string]any{"placeholder": true}}, nil +} + +// FakeRunner captures polish requests and returns deterministic responses. +type FakeRunner struct { + Requests []PolishRequest + Err error + Result PolishResult +} + +// Run records request and returns configured response. +func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { + if err := ctx.Err(); err != nil { + return PolishResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return PolishResult{}, f.Err + } + res := f.Result + if res.ProcessedTranscriptPath == "" { + res.ProcessedTranscriptPath = req.OutputProcessedPath + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/audita/fake_test.go b/internal/adapters/audita/fake_test.go new file mode 100644 index 0000000..6d67211 --- /dev/null +++ b/internal/adapters/audita/fake_test.go @@ -0,0 +1,31 @@ +package audita + +import ( + "context" + "errors" + "testing" +) + +func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeRunner{} + req := PolishRequest{GeneratedConfigPath: "config/audita.yml", OutputProcessedPath: "transcripts/processed.json"} + + res, err := fake.Run(context.Background(), req) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].GeneratedConfigPath == "" { + t.Fatalf("requests = %#v, want captured request", fake.Requests) + } + if res.ProcessedTranscriptPath != req.OutputProcessedPath { + t.Fatalf("processed path = %q, want %q", res.ProcessedTranscriptPath, req.OutputProcessedPath) + } +} + +func TestFakeRunnerError(t *testing.T) { + fake := &FakeRunner{Err: errors.New("boom")} + _, err := fake.Run(context.Background(), PolishRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/audita/runner.go b/internal/adapters/audita/runner.go index e6e3f97..bd94533 100644 --- a/internal/adapters/audita/runner.go +++ b/internal/adapters/audita/runner.go @@ -3,18 +3,22 @@ package audita import "context" -// Runner is a placeholder adapter interface for audita invocation. +// Runner is the adapter boundary for audita polish invocations. type Runner interface { Run(ctx context.Context, req PolishRequest) (PolishResult, error) } -// PolishRequest is a placeholder polish input. +// PolishRequest describes an audita invocation. type PolishRequest struct { + GeneratedConfigPath string MergedTranscriptPath string - OutputPath string + OutputProcessedPath string + StdoutLogPath string + StderrLogPath string } -// PolishResult is a placeholder polish output. +// PolishResult describes a polish output. type PolishResult struct { - ProcessedPath string + ProcessedTranscriptPath string + Metadata map[string]any } diff --git a/internal/adapters/notify/fake.go b/internal/adapters/notify/fake.go new file mode 100644 index 0000000..3a76141 --- /dev/null +++ b/internal/adapters/notify/fake.go @@ -0,0 +1,40 @@ +package notify + +import "context" + +// NoopSender is a deterministic no-op notifier. +type NoopSender struct{} + +// Send returns a placeholder successful result. +func (n *NoopSender) Send(ctx context.Context, _ SendRequest) (SendResult, error) { + if err := ctx.Err(); err != nil { + return SendResult{}, err + } + return SendResult{ProviderMessageID: "noop", Metadata: map[string]any{"placeholder": true}}, nil +} + +// FakeSender captures send requests and returns deterministic responses. +type FakeSender struct { + Requests []SendRequest + Err error + Result SendResult +} + +// Send records request and returns configured response. +func (f *FakeSender) Send(ctx context.Context, req SendRequest) (SendResult, error) { + if err := ctx.Err(); err != nil { + return SendResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return SendResult{}, f.Err + } + res := f.Result + if res.ProviderMessageID == "" { + res.ProviderMessageID = "fake" + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/notify/fake_test.go b/internal/adapters/notify/fake_test.go new file mode 100644 index 0000000..27bb208 --- /dev/null +++ b/internal/adapters/notify/fake_test.go @@ -0,0 +1,31 @@ +package notify + +import ( + "context" + "errors" + "testing" +) + +func TestFakeSenderCapturesRequestAndReturnsResult(t *testing.T) { + fake := &FakeSender{} + req := SendRequest{Subject: "done", Body: "body"} + + res, err := fake.Send(context.Background(), req) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].Subject != "done" { + t.Fatalf("requests = %#v, want captured request", fake.Requests) + } + if res.ProviderMessageID == "" { + t.Fatal("provider message id should be set") + } +} + +func TestFakeSenderError(t *testing.T) { + fake := &FakeSender{Err: errors.New("boom")} + _, err := fake.Send(context.Background(), SendRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/notify/sender.go b/internal/adapters/notify/sender.go index 89d28e2..44e9902 100644 --- a/internal/adapters/notify/sender.go +++ b/internal/adapters/notify/sender.go @@ -3,13 +3,20 @@ package notify import "context" -// Sender is a placeholder adapter interface for notifications. +// Sender is the adapter boundary for notifications. type Sender interface { - Send(ctx context.Context, msg Message) error + Send(ctx context.Context, req SendRequest) (SendResult, error) } -// Message is a placeholder notification payload. -type Message struct { - Subject string - Body string +// SendRequest describes one notification request. +type SendRequest struct { + Subject string + Body string + Metadata map[string]string +} + +// SendResult describes notification send outcome metadata. +type SendResult struct { + ProviderMessageID string + Metadata map[string]any } diff --git a/internal/adapters/seriatim/fake.go b/internal/adapters/seriatim/fake.go new file mode 100644 index 0000000..9b611a2 --- /dev/null +++ b/internal/adapters/seriatim/fake.go @@ -0,0 +1,40 @@ +package seriatim + +import "context" + +// NoopRunner is a deterministic no-op seriatim adapter. +type NoopRunner struct{} + +// Run returns the requested output path with placeholder metadata. +func (n *NoopRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) { + if err := ctx.Err(); err != nil { + return MergeResult{}, err + } + return MergeResult{MergedTranscriptPath: req.OutputMergedTranscriptPath, Metadata: map[string]any{"placeholder": true}}, nil +} + +// FakeRunner captures merge requests and returns deterministic responses. +type FakeRunner struct { + Requests []MergeRequest + Err error + Result MergeResult +} + +// Run records request and returns configured response. +func (f *FakeRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) { + if err := ctx.Err(); err != nil { + return MergeResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return MergeResult{}, f.Err + } + res := f.Result + if res.MergedTranscriptPath == "" { + res.MergedTranscriptPath = req.OutputMergedTranscriptPath + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/seriatim/fake_test.go b/internal/adapters/seriatim/fake_test.go new file mode 100644 index 0000000..77a2f29 --- /dev/null +++ b/internal/adapters/seriatim/fake_test.go @@ -0,0 +1,31 @@ +package seriatim + +import ( + "context" + "errors" + "testing" +) + +func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeRunner{} + req := MergeRequest{GeneratedConfigPath: "config/seriatim.yml", OutputMergedTranscriptPath: "transcripts/merged.json"} + + res, err := fake.Run(context.Background(), req) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].GeneratedConfigPath == "" { + t.Fatalf("requests = %#v, want captured request", fake.Requests) + } + if res.MergedTranscriptPath != req.OutputMergedTranscriptPath { + t.Fatalf("merged path = %q, want %q", res.MergedTranscriptPath, req.OutputMergedTranscriptPath) + } +} + +func TestFakeRunnerError(t *testing.T) { + fake := &FakeRunner{Err: errors.New("boom")} + _, err := fake.Run(context.Background(), MergeRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/seriatim/runner.go b/internal/adapters/seriatim/runner.go index 92883a5..1115b16 100644 --- a/internal/adapters/seriatim/runner.go +++ b/internal/adapters/seriatim/runner.go @@ -3,18 +3,22 @@ package seriatim import "context" -// Runner is a placeholder adapter interface for seriatim invocation. +// Runner is the adapter boundary for seriatim merge invocations. type Runner interface { Run(ctx context.Context, req MergeRequest) (MergeResult, error) } -// MergeRequest is a placeholder merge input. +// MergeRequest describes a seriatim merge invocation. type MergeRequest struct { - TranscriptPaths []string - OutputPath string + GeneratedConfigPath string + InputTranscriptPaths []string + OutputMergedTranscriptPath string + StdoutLogPath string + StderrLogPath string } -// MergeResult is a placeholder merge output. +// MergeResult describes a merge output. type MergeResult struct { - MergedPath string + MergedTranscriptPath string + Metadata map[string]any } diff --git a/internal/adapters/storage/archive.go b/internal/adapters/storage/archive.go new file mode 100644 index 0000000..e466fa5 --- /dev/null +++ b/internal/adapters/storage/archive.go @@ -0,0 +1,29 @@ +// Package storage declares archive/storage backend adapter boundaries. +package storage + +import "context" + +// Backend is the adapter boundary for archive/storage operations. +type Backend interface { + Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error) +} + +// ArchiveItem describes one item to archive. +type ArchiveItem struct { + Kind string + LocalPath string + RemoteKey string +} + +// ArchiveRequest describes one archive operation. +type ArchiveRequest struct { + SessionID string + ManifestPath string + Items []ArchiveItem +} + +// ArchiveResult describes archive operation output. +type ArchiveResult struct { + Archived []ArchiveItem + Metadata map[string]any +} diff --git a/internal/adapters/storage/fake.go b/internal/adapters/storage/fake.go new file mode 100644 index 0000000..be11724 --- /dev/null +++ b/internal/adapters/storage/fake.go @@ -0,0 +1,40 @@ +package storage + +import "context" + +// NoopBackend is a deterministic no-op archive/storage adapter. +type NoopBackend struct{} + +// Archive returns the requested items as archived with placeholder metadata. +func (n *NoopBackend) Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error) { + if err := ctx.Err(); err != nil { + return ArchiveResult{}, err + } + return ArchiveResult{Archived: append([]ArchiveItem(nil), req.Items...), Metadata: map[string]any{"placeholder": true}}, nil +} + +// FakeBackend captures archive requests and returns deterministic responses. +type FakeBackend struct { + Requests []ArchiveRequest + Err error + Result ArchiveResult +} + +// Archive records request and returns configured response. +func (f *FakeBackend) Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error) { + if err := ctx.Err(); err != nil { + return ArchiveResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return ArchiveResult{}, f.Err + } + res := f.Result + if res.Archived == nil { + res.Archived = append([]ArchiveItem(nil), req.Items...) + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/storage/fake_test.go b/internal/adapters/storage/fake_test.go new file mode 100644 index 0000000..eee58df --- /dev/null +++ b/internal/adapters/storage/fake_test.go @@ -0,0 +1,31 @@ +package storage + +import ( + "context" + "errors" + "testing" +) + +func TestFakeBackendCapturesRequestAndReturnsItems(t *testing.T) { + fake := &FakeBackend{} + req := ArchiveRequest{SessionID: "s1", Items: []ArchiveItem{{Kind: "artifact", LocalPath: "artifacts/log.md"}}} + + res, err := fake.Archive(context.Background(), req) + if err != nil { + t.Fatalf("Archive() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].SessionID != "s1" { + t.Fatalf("requests = %#v, want captured request", fake.Requests) + } + if len(res.Archived) != 1 { + t.Fatalf("archived len = %d, want 1", len(res.Archived)) + } +} + +func TestFakeBackendError(t *testing.T) { + fake := &FakeBackend{Err: errors.New("boom")} + _, err := fake.Archive(context.Background(), ArchiveRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/adapters/whisperx/client.go b/internal/adapters/whisperx/client.go index 8916911..c835314 100644 --- a/internal/adapters/whisperx/client.go +++ b/internal/adapters/whisperx/client.go @@ -3,18 +3,20 @@ package whisperx import "context" -// Client is a placeholder adapter interface for WhisperX interactions. +// Client is the adapter boundary for WhisperX transcription jobs. type Client interface { - Transcribe(ctx context.Context, req TranscriptionRequest) (TranscriptionResult, error) + Transcribe(ctx context.Context, req TranscribeRequest) (TranscribeResult, error) } -// TranscriptionRequest is a placeholder transcription input. -type TranscriptionRequest struct { - Speaker string - AudioPath string +// TranscribeRequest describes one speaker audio transcription request. +type TranscribeRequest struct { + SpeakerID string + AudioPath string + OutputRawTranscriptPath string } -// TranscriptionResult is a placeholder transcription output. -type TranscriptionResult struct { - TranscriptPath string +// TranscribeResult describes the transcript output and adapter metadata. +type TranscribeResult struct { + OutputRawTranscriptPath string + Metadata map[string]any } diff --git a/internal/adapters/whisperx/fake.go b/internal/adapters/whisperx/fake.go new file mode 100644 index 0000000..885b9f9 --- /dev/null +++ b/internal/adapters/whisperx/fake.go @@ -0,0 +1,45 @@ +package whisperx + +import "context" + +// NoopClient is a deterministic no-op WhisperX adapter. +type NoopClient struct{} + +// Transcribe returns the requested output path with placeholder metadata. +func (n *NoopClient) Transcribe(ctx context.Context, req TranscribeRequest) (TranscribeResult, error) { + if err := ctx.Err(); err != nil { + return TranscribeResult{}, err + } + return TranscribeResult{ + OutputRawTranscriptPath: req.OutputRawTranscriptPath, + Metadata: map[string]any{ + "placeholder": true, + }, + }, nil +} + +// FakeClient captures requests and returns deterministic responses for tests. +type FakeClient struct { + Requests []TranscribeRequest + Err error + Result TranscribeResult +} + +// Transcribe records the request and returns either configured error or result. +func (f *FakeClient) Transcribe(ctx context.Context, req TranscribeRequest) (TranscribeResult, error) { + if err := ctx.Err(); err != nil { + return TranscribeResult{}, err + } + f.Requests = append(f.Requests, req) + if f.Err != nil { + return TranscribeResult{}, f.Err + } + res := f.Result + if res.OutputRawTranscriptPath == "" { + res.OutputRawTranscriptPath = req.OutputRawTranscriptPath + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} diff --git a/internal/adapters/whisperx/fake_test.go b/internal/adapters/whisperx/fake_test.go new file mode 100644 index 0000000..e7feb05 --- /dev/null +++ b/internal/adapters/whisperx/fake_test.go @@ -0,0 +1,31 @@ +package whisperx + +import ( + "context" + "errors" + "testing" +) + +func TestFakeClientCapturesRequestAndReturnsPath(t *testing.T) { + fake := &FakeClient{} + req := TranscribeRequest{SpeakerID: "alice", AudioPath: "audio/alice.flac", OutputRawTranscriptPath: "transcripts/raw/alice.json"} + + res, err := fake.Transcribe(context.Background(), req) + if err != nil { + t.Fatalf("Transcribe() error = %v", err) + } + if len(fake.Requests) != 1 || fake.Requests[0].SpeakerID != "alice" { + t.Fatalf("requests = %#v, want one alice request", fake.Requests) + } + if res.OutputRawTranscriptPath != req.OutputRawTranscriptPath { + t.Fatalf("output path = %q, want %q", res.OutputRawTranscriptPath, req.OutputRawTranscriptPath) + } +} + +func TestFakeClientError(t *testing.T) { + fake := &FakeClient{Err: errors.New("boom")} + _, err := fake.Transcribe(context.Background(), TranscribeRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 281a871..b71ee9e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,9 +7,11 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "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" ) @@ -17,12 +19,14 @@ import ( type Env struct { Config *config.Config ArtifactStore artifacts.Store + ManifestStore manifest.Store Logger *slog.Logger WhisperX whisperx.Client Seriatim seriatim.Runner Audita audita.Runner Analyzer analyzer.Runner + Storage storage.Backend Notifier notify.Sender } @@ -34,11 +38,13 @@ func toStageEnv(env *Env) *stage.Env { return &stage.Env{ Config: env.Config, ArtifactStore: env.ArtifactStore, + ManifestStore: env.ManifestStore, Logger: env.Logger, WhisperX: env.WhisperX, Seriatim: env.Seriatim, Audita: env.Audita, Analyzer: env.Analyzer, + Storage: env.Storage, Notifier: env.Notifier, } } diff --git a/internal/app/runner.go b/internal/app/runner.go index 3615030..11526af 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -7,6 +7,12 @@ import ( "os" "path/filepath" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/logging" @@ -16,6 +22,7 @@ import ( type RunOptions struct { Force bool + Env *Env } type RunSummary struct { @@ -25,36 +32,64 @@ type RunSummary struct { } func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { - store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) - paths, err := store.EnsureLayout(cfg.Session.SessionID) + env := opts.Env + if env == nil { + env = &Env{} + } + if env.Config == nil { + env.Config = cfg + } + if env.ArtifactStore == nil { + env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) + } + if env.ManifestStore == nil { + env.ManifestStore = &manifest.LocalStore{} + } + if env.Logger == nil { + env.Logger = logging.NewLogger(os.Stderr, slog.LevelInfo) + } + if env.WhisperX == nil { + env.WhisperX = &whisperx.NoopClient{} + } + if env.Seriatim == nil { + env.Seriatim = &seriatim.NoopRunner{} + } + if env.Audita == nil { + env.Audita = &audita.NoopRunner{} + } + if env.Analyzer == nil { + env.Analyzer = &analyzer.NoopRunner{} + } + if env.Storage == nil { + env.Storage = &storage.NoopBackend{} + } + if env.Notifier == nil { + env.Notifier = ¬ify.NoopSender{} + } + + artifactStore := env.ArtifactStore + paths, err := artifactStore.EnsureLayout(cfg.Session.SessionID) if err != nil { return nil, fmt.Errorf("prepare workdir: %w", err) } - lock, err := store.AcquireSessionLock(cfg.Session.SessionID) + lock, err := artifactStore.AcquireSessionLock(cfg.Session.SessionID) if err != nil { return nil, fmt.Errorf("acquire session lock: %w", err) } defer func() { - _ = store.ReleaseSessionLock(lock) + _ = artifactStore.ReleaseSessionLock(lock) }() - manifestStore := &manifest.LocalStore{} manifestPath := paths.ManifestPath - - m, err := loadOrCreateManifest(ctx, manifestStore, manifestPath, cfg.Session.SessionID) + m, err := loadOrCreateManifest(ctx, env.ManifestStore, manifestPath, cfg.Session.SessionID) if err != nil { return nil, err } - env := &Env{ - Config: cfg, - ArtifactStore: store, - Logger: logging.NewLogger(os.Stderr, slog.LevelInfo), - } stageEnv := toStageEnv(env) - _ = opts // TODO: use --force behavior in future skip/stale logic. + _ = opts.Force // TODO: use --force behavior in future skip/stale logic. runNames := make([]string, 0, len(stages)) for _, s := range stages { @@ -62,14 +97,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage now := nowUTC() m.MarkStageRunning(s.Name(), now) - if err := manifestStore.Save(ctx, manifestPath, m); err != nil { + if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { return nil, fmt.Errorf("save manifest before stage %q: %w", s.Name(), err) } result, err := s.Run(ctx, stageEnv, m) if err != nil { m.MarkStageFailed(s.Name(), nowUTC(), err.Error()) - if saveErr := manifestStore.Save(ctx, manifestPath, m); saveErr != nil { + if saveErr := env.ManifestStore.Save(ctx, manifestPath, m); saveErr != nil { return nil, fmt.Errorf("stage %q failed (%v) and manifest save failed (%v)", s.Name(), err, saveErr) } return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err) @@ -79,7 +114,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage m.MarkStageSucceeded(s.Name(), nowUTC(), outputs) applyStageResultToManifest(m, s.Name(), result) - if err := manifestStore.Save(ctx, manifestPath, m); err != nil { + if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { return nil, fmt.Errorf("save manifest after stage %q: %w", s.Name(), err) } } @@ -91,7 +126,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage }, nil } -func loadOrCreateManifest(ctx context.Context, store *manifest.LocalStore, path, sessionID string) (*manifest.Manifest, error) { +func loadOrCreateManifest(ctx context.Context, store manifest.Store, path, sessionID string) (*manifest.Manifest, error) { exists, err := fileExists(path) if err != nil { return nil, fmt.Errorf("check manifest existence %q: %w", path, err) diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index c923ffa..28b0025 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -9,6 +9,13 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" + "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" @@ -98,24 +105,6 @@ func TestExecuteStagesFailureUpdatesManifest(t *testing.T) { } } -func testConfig(t *testing.T) *config.Config { - t.Helper() - - workspace := t.TempDir() - return &config.Config{ - Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, - Session: &config.SessionConfig{ - SessionID: "2026-05-03", - Inputs: config.SessionInputsConfig{ - AudioDir: "./audio", - SpeakersFile: "./speakers.yml", - AutocorrectFile: "./autocorrect.yml", - GlossaryFile: "./glossary.yml", - }, - }, - } -} - func TestExecuteStagesLoadsExistingManifest(t *testing.T) { cfg := testConfig(t) manifestPath := manifestPathFor(cfg) @@ -146,3 +135,67 @@ func TestExecuteStagesLoadsExistingManifest(t *testing.T) { t.Fatalf("transcribe should be succeeded after run") } } + +func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) { + cases := []struct { + name string + env *Env + }{ + {name: "transcribe", env: &Env{WhisperX: &whisperx.FakeClient{Err: errors.New("transcribe fail")}}}, + {name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}}, + {name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}}, + {name: "analyze", env: &Env{Analyzer: &analyzer.FakeRunner{Err: errors.New("analyze fail")}}}, + {name: "archive", env: &Env{Storage: &storage.FakeBackend{Err: errors.New("archive fail")}}}, + {name: "notify", env: &Env{Notifier: ¬ify.FakeSender{Err: errors.New("notify fail")}}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := testConfig(t) + selected, err := stage.Select(tc.name) + if err != nil { + t.Fatalf("Select() error = %v", err) + } + + artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) + tc.env.Config = cfg + tc.env.ArtifactStore = artifactStore + tc.env.ManifestStore = &manifest.LocalStore{} + + _, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: tc.env}) + if runErr == nil { + t.Fatal("expected error, got nil") + } + + m, loadErr := tc.env.ManifestStore.Load(context.Background(), manifestPathFor(cfg)) + if loadErr != nil { + t.Fatalf("load manifest error = %v", loadErr) + } + sr := m.Stages[tc.name] + if sr == nil { + t.Fatalf("missing stage record %q", tc.name) + } + if sr.Status != manifest.StatusFailed { + t.Fatalf("status = %q, want %q", sr.Status, manifest.StatusFailed) + } + }) + } +} + +func testConfig(t *testing.T) *config.Config { + t.Helper() + + workspace := t.TempDir() + return &config.Config{ + Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, + Session: &config.SessionConfig{ + SessionID: "2026-05-03", + Inputs: config.SessionInputsConfig{ + AudioDir: "./audio", + SpeakersFile: "./speakers.yml", + AutocorrectFile: "./autocorrect.yml", + GlossaryFile: "./glossary.yml", + }, + }, + } +} diff --git a/internal/stage/placeholders.go b/internal/stage/placeholders.go index 1aa9e74..d07ee77 100644 --- a/internal/stage/placeholders.go +++ b/internal/stage/placeholders.go @@ -3,7 +3,14 @@ package stage import ( "context" "fmt" + "path/filepath" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -23,14 +30,134 @@ func (s placeholderStage) Declares() IODecl { } } -func (s placeholderStage) Run(_ context.Context, _ *Env, _ *manifest.Manifest) (*StageResult, error) { - return &StageResult{ +func (s placeholderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { + result := &StageResult{ Metadata: map[string]any{ "placeholder": true, "stage": s.name, "message": placeholderMessage, }, - }, nil + } + + if env == nil || env.Config == nil || env.ArtifactStore == nil { + return result, nil + } + + sessionID := m.SessionID + if sessionID == "" && env.Config.Session != nil { + sessionID = env.Config.Session.SessionID + } + if sessionID == "" { + return result, nil + } + + paths := env.ArtifactStore.SessionPaths(sessionID) + + switch s.name { + case "transcribe": + if env.WhisperX != nil { + req := whisperx.TranscribeRequest{ + SpeakerID: "placeholder-speaker", + AudioPath: filepath.Join(paths.AudioDir, "placeholder-speaker.flac"), + OutputRawTranscriptPath: filepath.Join(paths.TranscriptsRawDir, "placeholder-speaker.json"), + } + resp, err := env.WhisperX.Transcribe(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder transcribe adapter call failed: %w", err) + } + result.Outputs = append(result.Outputs, artifacts.Ref{ + Kind: "transcript_raw", + Category: "transcripts", + SessionID: sessionID, + AbsolutePath: resp.OutputRawTranscriptPath, + }) + } + case "merge": + if env.Seriatim != nil { + req := seriatim.MergeRequest{ + GeneratedConfigPath: filepath.Join(paths.ConfigDir, "seriatim.generated.yml"), + InputTranscriptPaths: []string{filepath.Join(paths.TranscriptsNormalizedDir, "placeholder-speaker.json")}, + OutputMergedTranscriptPath: filepath.Join(paths.TranscriptsDir, "merged.json"), + StdoutLogPath: filepath.Join(paths.LogsDir, "seriatim.stdout.log"), + StderrLogPath: filepath.Join(paths.LogsDir, "seriatim.stderr.log"), + } + resp, err := env.Seriatim.Run(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder merge adapter call failed: %w", err) + } + result.Outputs = append(result.Outputs, artifacts.Ref{Kind: "transcript_merged", Category: "transcripts", SessionID: sessionID, AbsolutePath: resp.MergedTranscriptPath}) + result.Logs = append(result.Logs, req.StdoutLogPath, req.StderrLogPath) + result.GeneratedConfigs = append(result.GeneratedConfigs, req.GeneratedConfigPath) + } + case "polish": + if env.Audita != nil { + req := audita.PolishRequest{ + GeneratedConfigPath: filepath.Join(paths.ConfigDir, "audita.generated.yml"), + MergedTranscriptPath: filepath.Join(paths.TranscriptsDir, "merged.json"), + OutputProcessedPath: filepath.Join(paths.TranscriptsDir, "processed.json"), + StdoutLogPath: filepath.Join(paths.LogsDir, "audita.stdout.log"), + StderrLogPath: filepath.Join(paths.LogsDir, "audita.stderr.log"), + } + resp, err := env.Audita.Run(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder polish adapter call failed: %w", err) + } + result.Outputs = append(result.Outputs, artifacts.Ref{Kind: "transcript_processed", Category: "transcripts", SessionID: sessionID, AbsolutePath: resp.ProcessedTranscriptPath}) + result.Logs = append(result.Logs, req.StdoutLogPath, req.StderrLogPath) + result.GeneratedConfigs = append(result.GeneratedConfigs, req.GeneratedConfigPath) + } + case "analyze": + if env.Analyzer != nil { + req := analyzer.AnalyzeRequest{ + ArtifactType: "session-log", + ProcessedTranscriptPath: filepath.Join(paths.TranscriptsDir, "processed.json"), + ContextReferences: []string{"previous-session"}, + OutputPath: filepath.Join(paths.ArtifactsDir, "session-log.md"), + GeneratedConfigPath: filepath.Join(paths.ConfigDir, "analyzer.session-log.generated.yml"), + StdoutLogPath: filepath.Join(paths.LogsDir, "analyzer.session-log.stdout.log"), + StderrLogPath: filepath.Join(paths.LogsDir, "analyzer.session-log.stderr.log"), + } + resp, err := env.Analyzer.Run(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder analyze adapter call failed: %w", err) + } + result.Outputs = append(result.Outputs, artifacts.Ref{Kind: "artifact", Category: "artifacts", SessionID: sessionID, AbsolutePath: resp.ArtifactPath}) + result.Logs = append(result.Logs, req.StdoutLogPath, req.StderrLogPath) + result.GeneratedConfigs = append(result.GeneratedConfigs, req.GeneratedConfigPath) + } + case "archive": + if env.Storage != nil { + req := storage.ArchiveRequest{ + SessionID: sessionID, + ManifestPath: paths.ManifestPath, + Items: []storage.ArchiveItem{{ + Kind: "artifact", + LocalPath: filepath.Join(paths.ArtifactsDir, "session-log.md"), + RemoteKey: "sessions/" + sessionID + "/artifacts/session-log.md", + }}, + } + _, err := env.Storage.Archive(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder archive adapter call failed: %w", err) + } + } + case "notify": + if env.Notifier != nil { + req := notify.SendRequest{ + Subject: "narratio placeholder run complete", + Body: "placeholder stage execution finished", + Metadata: map[string]string{ + "session_id": sessionID, + }, + } + _, err := env.Notifier.Send(ctx, req) + if err != nil { + return nil, fmt.Errorf("placeholder notify adapter call failed: %w", err) + } + } + } + + return result, nil } // All returns the canonical ordered stage list for full pipeline execution. diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index 317cfea..3fa50c8 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -2,9 +2,19 @@ package stage import ( "context" + "errors" + "strings" "testing" "time" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -14,9 +24,34 @@ func TestPlaceholderStagesReturnSuccessMetadata(t *testing.T) { t.Fatal("expected non-empty stage list") } + root := t.TempDir() + store := artifacts.NewLocalStore(root) + _, err := store.EnsureLayout("2026-05-03") + if err != nil { + t.Fatalf("EnsureLayout() error = %v", err) + } + + wf := &whisperx.FakeClient{} + sf := &seriatim.FakeRunner{} + af := &audita.FakeRunner{} + anz := &analyzer.FakeRunner{} + st := &storage.FakeBackend{} + nf := ¬ify.FakeSender{} + + env := &Env{ + Config: &config.Config{Session: &config.SessionConfig{SessionID: "2026-05-03"}}, + ArtifactStore: store, + WhisperX: wf, + Seriatim: sf, + Audita: af, + Analyzer: anz, + Storage: st, + Notifier: nf, + } + m := manifest.New("2026-05-03", time.Now().UTC()) for _, s := range stages { - result, err := s.Run(context.Background(), nil, m) + result, err := s.Run(context.Background(), env, m) if err != nil { t.Fatalf("stage %q returned unexpected error: %v", s.Name(), err) } @@ -27,4 +62,65 @@ func TestPlaceholderStagesReturnSuccessMetadata(t *testing.T) { t.Fatalf("stage %q missing placeholder metadata", s.Name()) } } + + if len(wf.Requests) != 1 { + t.Fatalf("whisperx calls = %d, want 1", len(wf.Requests)) + } + if len(sf.Requests) != 1 { + t.Fatalf("seriatim calls = %d, want 1", len(sf.Requests)) + } + if len(af.Requests) != 1 { + t.Fatalf("audita calls = %d, want 1", len(af.Requests)) + } + if len(anz.Requests) != 1 { + t.Fatalf("analyzer calls = %d, want 1", len(anz.Requests)) + } + if len(st.Requests) != 1 { + t.Fatalf("storage calls = %d, want 1", len(st.Requests)) + } + if len(nf.Requests) != 1 { + t.Fatalf("notify calls = %d, want 1", len(nf.Requests)) + } +} + +func TestPlaceholderAdapterErrorPropagation(t *testing.T) { + cases := []struct { + stageName string + env *Env + wantErr string + }{ + {stageName: "transcribe", env: &Env{WhisperX: &whisperx.FakeClient{Err: errors.New("werr")}}, wantErr: "transcribe"}, + {stageName: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("serr")}}, wantErr: "merge"}, + {stageName: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("aerr")}}, wantErr: "polish"}, + {stageName: "analyze", env: &Env{Analyzer: &analyzer.FakeRunner{Err: errors.New("anerr")}}, wantErr: "analyze"}, + {stageName: "archive", env: &Env{Storage: &storage.FakeBackend{Err: errors.New("sterr")}}, wantErr: "archive"}, + {stageName: "notify", env: &Env{Notifier: ¬ify.FakeSender{Err: errors.New("nerr")}}, wantErr: "notify"}, + } + + root := t.TempDir() + store := artifacts.NewLocalStore(root) + _, err := store.EnsureLayout("2026-05-03") + if err != nil { + t.Fatalf("EnsureLayout() error = %v", err) + } + + for _, tc := range cases { + t.Run(tc.stageName, func(t *testing.T) { + s, selErr := Select(tc.stageName) + if selErr != nil { + t.Fatalf("Select() error = %v", selErr) + } + + tc.env.Config = &config.Config{Session: &config.SessionConfig{SessionID: "2026-05-03"}} + tc.env.ArtifactStore = store + + _, runErr := s.Run(context.Background(), tc.env, manifest.New("2026-05-03", time.Now().UTC())) + if runErr == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(runErr.Error(), tc.wantErr) { + t.Fatalf("error = %q, want to contain %q", runErr.Error(), tc.wantErr) + } + }) + } } diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 10bf585..7a6bbc0 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -8,6 +8,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -18,12 +19,14 @@ import ( type Env struct { Config *config.Config ArtifactStore artifacts.Store + ManifestStore manifest.Store Logger *slog.Logger WhisperX whisperx.Client Seriatim seriatim.Runner Audita audita.Runner Analyzer analyzer.Runner + Storage storage.Backend Notifier notify.Sender }