diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 0dea0f5..a1395e0 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -4,6 +4,7 @@ import "path/filepath" // SessionPaths contains canonical local paths for one session work directory. type SessionPaths struct { + WorkspaceRoot string Root string InputsDir string AudioDir string @@ -26,6 +27,7 @@ func buildSessionPaths(workspaceRoot, sessionID string) SessionPaths { root := SessionWorkDir(workspaceRoot, sessionID) transcripts := filepath.Join(root, "transcripts") return SessionPaths{ + WorkspaceRoot: workspaceRoot, Root: root, InputsDir: filepath.Join(root, "inputs"), AudioDir: filepath.Join(root, "audio"), diff --git a/internal/artifacts/resolve.go b/internal/artifacts/resolve.go new file mode 100644 index 0000000..8a471df --- /dev/null +++ b/internal/artifacts/resolve.go @@ -0,0 +1,58 @@ +package artifacts + +import ( + "os" + "path/filepath" + "strings" +) + +// ResolveSessionLocalPathForRead resolves manifest/local artifact paths for read use-cases. +// Invariants for stage consumers: +// - local paths may be absolute, workspace-root qualified, or session-workdir relative. +// - callers should resolve through this helper instead of manually joining session/workdir roots. +func ResolveSessionLocalPathForRead(paths SessionPaths, localPath string) string { + p := filepath.Clean(strings.TrimSpace(localPath)) + if p == "" { + return "" + } + if filepath.IsAbs(p) { + return p + } + + // Already qualified against known roots. + if underRoot(p, paths.WorkspaceRoot) || underRoot(p, paths.Root) { + return p + } + + candidates := []string{ + p, + filepath.Clean(filepath.Join(paths.WorkspaceRoot, p)), + filepath.Clean(filepath.Join(paths.Root, p)), + } + for _, c := range candidates { + if c == "" { + continue + } + if _, err := os.Stat(c); err == nil { + return c + } + } + + // Deterministic fallback for ambiguous relative values. + if strings.TrimSpace(paths.WorkspaceRoot) != "" { + return candidates[1] + } + return candidates[2] +} + +func underRoot(path, root string) bool { + p := filepath.Clean(strings.TrimSpace(path)) + r := filepath.Clean(strings.TrimSpace(root)) + if p == "" || r == "" { + return false + } + if p == r { + return true + } + return strings.HasPrefix(p, r+string(filepath.Separator)) +} diff --git a/internal/artifacts/resolve_test.go b/internal/artifacts/resolve_test.go new file mode 100644 index 0000000..71af272 --- /dev/null +++ b/internal/artifacts/resolve_test.go @@ -0,0 +1,58 @@ +package artifacts + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveSessionLocalPathForRead(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "s-1") + if err := os.MkdirAll(paths.TranscriptsRawDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := filepath.Join(paths.TranscriptsRawDir, "alice.json") + if err := os.WriteFile(target, []byte(`{"segments":[]}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + abs := target + got := ResolveSessionLocalPathForRead(paths, abs) + if got != abs { + t.Fatalf("absolute path resolution = %q, want %q", got, abs) + } + + sessionRelative := filepath.Join("transcripts", "raw", "alice.json") + got = ResolveSessionLocalPathForRead(paths, sessionRelative) + if got != target { + t.Fatalf("session-relative resolution = %q, want %q", got, target) + } +} + +func TestResolveSessionLocalPathForReadRelativeWorkspaceRootQualifiedPath(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + workspaceAbs := t.TempDir() + workspaceRel, err := filepath.Rel(cwd, workspaceAbs) + if err != nil { + t.Fatalf("Rel() error = %v", err) + } + + paths := buildSessionPaths(workspaceRel, "s-1") + target := filepath.Join(paths.TranscriptsRawDir, "alice.json") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(target, []byte(`{"segments":[]}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + manifestPath := filepath.Join(workspaceRel, "work", "s-1", "transcripts", "raw", "alice.json") + got := ResolveSessionLocalPathForRead(paths, manifestPath) + if got != filepath.Clean(manifestPath) { + t.Fatalf("resolution = %q, want %q", got, filepath.Clean(manifestPath)) + } +} diff --git a/internal/stage/merge.go b/internal/stage/merge.go index 005aad6..05d9032 100644 --- a/internal/stage/merge.go +++ b/internal/stage/merge.go @@ -190,9 +190,7 @@ func discoverRawTranscripts(m *manifest.Manifest, paths artifacts.SessionPaths) if p == "" { continue } - if !filepath.IsAbs(p) { - p = filepath.Join(paths.Root, p) - } + p = artifacts.ResolveSessionLocalPathForRead(paths, p) fromManifest = append(fromManifest, filepath.Clean(p)) } } diff --git a/internal/stage/merge_test.go b/internal/stage/merge_test.go index 5b3bb7e..8c7e647 100644 --- a/internal/stage/merge_test.go +++ b/internal/stage/merge_test.go @@ -2,6 +2,7 @@ package stage import ( "context" + "os" "path/filepath" "strings" "testing" @@ -153,9 +154,90 @@ func TestMergeStageFallsBackToRawDirectoryWhenTranscribeOutputsMissing(t *testin } } +func TestMergeStageResolvesWorkspaceQualifiedManifestOutputsWithoutDuplication(t *testing.T) { + env, m := setupMergeEnvWithRelativeWorkspaceRoot(t) + paths := env.ArtifactStore.SessionPaths(m.SessionID) + + rawPath := filepath.Join(paths.TranscriptsRawDir, "alice.json") + writeFile(t, rawPath, `{"segments":[]}`) + writeFile(t, filepath.Join(paths.InputsDir, "speakers.yml"), "match: []\n") + writeFile(t, filepath.Join(paths.InputsDir, "autocorrect.yml"), "rules: []\n") + + m.MarkStageSucceeded("transcribe", time.Now().UTC(), []manifest.ArtifactRecord{ + {Kind: "transcript_raw", LocalPath: filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.SessionID, "transcripts", "raw", "alice.json")}, + }) + + fake := &seriatim.FakeRunner{} + env.Seriatim = fake + + if _, err := (mergeStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("merge.Run() error = %v", err) + } + if len(fake.Requests) != 1 { + t.Fatalf("fake requests = %d, want 1", len(fake.Requests)) + } + got := fake.Requests[0].InputTranscriptPaths + if len(got) != 1 { + t.Fatalf("input transcript paths = %#v, want len 1", got) + } + if got[0] != filepath.Clean(rawPath) { + t.Fatalf("resolved transcript path = %q, want %q", got[0], filepath.Clean(rawPath)) + } +} + +func TestMergeStageResolvesSessionRelativeManifestOutputs(t *testing.T) { + env, m := setupMergeEnv(t) + paths := env.ArtifactStore.SessionPaths(m.SessionID) + + rawPath := filepath.Join(paths.TranscriptsRawDir, "alice.json") + writeFile(t, rawPath, `{"segments":[]}`) + writeFile(t, filepath.Join(paths.InputsDir, "speakers.yml"), "match: []\n") + writeFile(t, filepath.Join(paths.InputsDir, "autocorrect.yml"), "rules: []\n") + + m.MarkStageSucceeded("transcribe", time.Now().UTC(), []manifest.ArtifactRecord{ + {Kind: "transcript_raw", LocalPath: filepath.Join("transcripts", "raw", "alice.json")}, + }) + + fake := &seriatim.FakeRunner{} + env.Seriatim = fake + + if _, err := (mergeStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("merge.Run() error = %v", err) + } + if len(fake.Requests) != 1 { + t.Fatalf("fake requests = %d, want 1", len(fake.Requests)) + } + got := fake.Requests[0].InputTranscriptPaths + if len(got) != 1 { + t.Fatalf("input transcript paths = %#v, want len 1", got) + } + if got[0] != filepath.Clean(rawPath) { + t.Fatalf("resolved transcript path = %q, want %q", got[0], filepath.Clean(rawPath)) + } +} + func setupMergeEnv(t *testing.T) (*Env, *manifest.Manifest) { t.Helper() workspace := t.TempDir() + return setupMergeEnvWithWorkspace(t, workspace) +} + +func setupMergeEnvWithRelativeWorkspaceRoot(t *testing.T) (*Env, *manifest.Manifest) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + workspaceAbs := t.TempDir() + workspaceRel, err := filepath.Rel(cwd, workspaceAbs) + if err != nil { + t.Fatalf("Rel() error = %v", err) + } + return setupMergeEnvWithWorkspace(t, workspaceRel) +} + +func setupMergeEnvWithWorkspace(t *testing.T, workspace string) (*Env, *manifest.Manifest) { + t.Helper() cfgDir := t.TempDir() sessionPath := filepath.Join(cfgDir, "session.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")