Fixed a path-duplication bug and added regression coverage
This commit is contained in:
@@ -4,6 +4,7 @@ import "path/filepath"
|
|||||||
|
|
||||||
// SessionPaths contains canonical local paths for one session work directory.
|
// SessionPaths contains canonical local paths for one session work directory.
|
||||||
type SessionPaths struct {
|
type SessionPaths struct {
|
||||||
|
WorkspaceRoot string
|
||||||
Root string
|
Root string
|
||||||
InputsDir string
|
InputsDir string
|
||||||
AudioDir string
|
AudioDir string
|
||||||
@@ -26,6 +27,7 @@ func buildSessionPaths(workspaceRoot, sessionID string) SessionPaths {
|
|||||||
root := SessionWorkDir(workspaceRoot, sessionID)
|
root := SessionWorkDir(workspaceRoot, sessionID)
|
||||||
transcripts := filepath.Join(root, "transcripts")
|
transcripts := filepath.Join(root, "transcripts")
|
||||||
return SessionPaths{
|
return SessionPaths{
|
||||||
|
WorkspaceRoot: workspaceRoot,
|
||||||
Root: root,
|
Root: root,
|
||||||
InputsDir: filepath.Join(root, "inputs"),
|
InputsDir: filepath.Join(root, "inputs"),
|
||||||
AudioDir: filepath.Join(root, "audio"),
|
AudioDir: filepath.Join(root, "audio"),
|
||||||
|
|||||||
58
internal/artifacts/resolve.go
Normal file
58
internal/artifacts/resolve.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
58
internal/artifacts/resolve_test.go
Normal file
58
internal/artifacts/resolve_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,9 +190,7 @@ func discoverRawTranscripts(m *manifest.Manifest, paths artifacts.SessionPaths)
|
|||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(p) {
|
p = artifacts.ResolveSessionLocalPathForRead(paths, p)
|
||||||
p = filepath.Join(paths.Root, p)
|
|
||||||
}
|
|
||||||
fromManifest = append(fromManifest, filepath.Clean(p))
|
fromManifest = append(fromManifest, filepath.Clean(p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package stage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func setupMergeEnv(t *testing.T) (*Env, *manifest.Manifest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
workspace := t.TempDir()
|
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()
|
cfgDir := t.TempDir()
|
||||||
sessionPath := filepath.Join(cfgDir, "session.yml")
|
sessionPath := filepath.Join(cfgDir, "session.yml")
|
||||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
|||||||
Reference in New Issue
Block a user