From a155af700ac7a144e8d5261e554da032a51da749 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 3 May 2026 22:01:07 -0500 Subject: [PATCH] Implement Seriatim subprocess adapter --- internal/adapters/seriatim/fake.go | 30 +- internal/adapters/seriatim/runner.go | 16 +- internal/adapters/seriatim/subprocess.go | 274 ++++++++++++ internal/adapters/seriatim/subprocess_test.go | 418 ++++++++++++++++++ 4 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 internal/adapters/seriatim/subprocess.go create mode 100644 internal/adapters/seriatim/subprocess_test.go diff --git a/internal/adapters/seriatim/fake.go b/internal/adapters/seriatim/fake.go index 2c6b5b4..b143b24 100644 --- a/internal/adapters/seriatim/fake.go +++ b/internal/adapters/seriatim/fake.go @@ -18,7 +18,15 @@ func (n *NoopRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, er if err := materializePlaceholders(req); err != nil { return MergeResult{}, err } - return MergeResult{MergedTranscriptPath: req.OutputMergedTranscriptPath, Metadata: map[string]any{"placeholder": true}}, nil + return MergeResult{ + MergedTranscriptPath: req.OutputMergedTranscriptPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + InvokedBinary: "noop", + Metadata: map[string]any{"placeholder": true}, + }, nil } // FakeRunner captures merge requests and returns deterministic responses. @@ -44,6 +52,21 @@ func (f *FakeRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, er if res.MergedTranscriptPath == "" { res.MergedTranscriptPath = req.OutputMergedTranscriptPath } + if res.ReportPath == "" { + res.ReportPath = req.ReportPath + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } + if res.InvokedBinary == "" { + res.InvokedBinary = "fake" + } if res.Metadata == nil { res.Metadata = map[string]any{"fake": true} } @@ -72,5 +95,10 @@ func materializePlaceholders(req MergeRequest) error { return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) } } + if req.ReportPath != "" { + if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), 0o644); err != nil { + return fmt.Errorf("write report %q: %w", req.ReportPath, err) + } + } return nil } diff --git a/internal/adapters/seriatim/runner.go b/internal/adapters/seriatim/runner.go index 8754f87..92d5b14 100644 --- a/internal/adapters/seriatim/runner.go +++ b/internal/adapters/seriatim/runner.go @@ -1,7 +1,10 @@ // Package seriatim declares the adapter contract for transcript merge execution. package seriatim -import "context" +import ( + "context" + "time" +) // TODO: implement a real Seriatim subprocess adapter. @@ -15,6 +18,9 @@ type MergeRequest struct { GeneratedConfigPath string InputTranscriptPaths []string OutputMergedTranscriptPath string + ReportPath string + SpeakersPath string + AutocorrectPath string StdoutLogPath string StderrLogPath string } @@ -22,5 +28,13 @@ type MergeRequest struct { // MergeResult describes a merge output. type MergeResult struct { MergedTranscriptPath string + ReportPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + ExitCode int + Duration time.Duration + InvokedBinary string + OutputSchema string Metadata map[string]any } diff --git a/internal/adapters/seriatim/subprocess.go b/internal/adapters/seriatim/subprocess.go new file mode 100644 index 0000000..3b9f8b1 --- /dev/null +++ b/internal/adapters/seriatim/subprocess.go @@ -0,0 +1,274 @@ +package seriatim + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" +) + +// EnvConfig defines optional Seriatim environment tuning values. +type EnvConfig struct { + OverlapWordRunGap *float64 + OverlapWordRunReorderWindow *float64 + BackchannelMaxDuration *float64 + FillerMaxDuration *float64 +} + +// SubprocessRunnerConfig defines deterministic settings for Seriatim CLI execution. +type SubprocessRunnerConfig struct { + Binary string + Timeout time.Duration + OutputSchema string + CoalesceGap *float64 + Report bool + Env EnvConfig +} + +// SubprocessRunner invokes Seriatim via subprocess. +type SubprocessRunner struct { + binary string + timeout time.Duration + outputSchema string + coalesceGap *float64 + report bool + env EnvConfig +} + +// NewSubprocessRunnerFromConfigValues parses config-derived values once. +func NewSubprocessRunnerFromConfigValues( + binary string, + timeout string, + outputSchema string, + coalesceGap *float64, + report bool, + env EnvConfig, +) (*SubprocessRunner, error) { + if strings.TrimSpace(timeout) == "" { + return nil, fmt.Errorf("seriatim timeout is required") + } + parsedTimeout, err := time.ParseDuration(timeout) + if err != nil { + return nil, fmt.Errorf("parse seriatim timeout %q: %w", timeout, err) + } + return NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: binary, + Timeout: parsedTimeout, + OutputSchema: outputSchema, + CoalesceGap: coalesceGap, + Report: report, + Env: env, + }) +} + +// NewSubprocessRunner constructs a validated Seriatim subprocess runner. +func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) { + if strings.TrimSpace(cfg.Binary) == "" { + return nil, fmt.Errorf("seriatim binary is required") + } + if cfg.Timeout <= 0 { + return nil, fmt.Errorf("seriatim timeout must be > 0") + } + if strings.TrimSpace(cfg.OutputSchema) == "" { + return nil, fmt.Errorf("seriatim output schema is required") + } + switch cfg.OutputSchema { + case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": + default: + return nil, fmt.Errorf("seriatim output schema %q is unsupported", cfg.OutputSchema) + } + if cfg.CoalesceGap != nil && *cfg.CoalesceGap < 0 { + return nil, fmt.Errorf("seriatim coalesce gap must be >= 0") + } + + return &SubprocessRunner{ + binary: cfg.Binary, + timeout: cfg.Timeout, + outputSchema: cfg.OutputSchema, + coalesceGap: cfg.CoalesceGap, + report: cfg.Report, + env: cfg.Env, + }, nil +} + +// Run executes Seriatim merge with deterministic flags and validates output artifacts. +func (r *SubprocessRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) { + if r == nil { + return MergeResult{}, fmt.Errorf("seriatim subprocess runner is nil") + } + if strings.TrimSpace(req.OutputMergedTranscriptPath) == "" { + return MergeResult{}, fmt.Errorf("seriatim merge output path is required") + } + if len(req.InputTranscriptPaths) == 0 { + return MergeResult{}, fmt.Errorf("seriatim merge requires at least one input transcript") + } + if r.report && strings.TrimSpace(req.ReportPath) == "" { + return MergeResult{}, fmt.Errorf("seriatim report is enabled but report path is missing") + } + + args := r.buildArgs(req) + env := r.buildEnvOverrides() + + if req.GeneratedConfigPath != "" { + if err := r.writeInvocationConfig(req, args); err != nil { + return MergeResult{}, fmt.Errorf("write seriatim invocation config %q: %w", req.GeneratedConfigPath, err) + } + } + + runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ + Executable: r.binary, + Args: args, + Timeout: r.timeout, + EnvOverrides: env, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + }) + if err != nil { + return MergeResult{ + MergedTranscriptPath: req.OutputMergedTranscriptPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + OutputSchema: r.outputSchema, + }, fmt.Errorf("run seriatim merge (binary=%q): %w", r.binary, err) + } + + if err := validateJSONFile(req.OutputMergedTranscriptPath); err != nil { + return MergeResult{ + MergedTranscriptPath: req.OutputMergedTranscriptPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + OutputSchema: r.outputSchema, + }, fmt.Errorf("validate seriatim merged output %q: %w", req.OutputMergedTranscriptPath, err) + } + + if r.report { + if err := validateJSONFile(req.ReportPath); err != nil { + return MergeResult{ + MergedTranscriptPath: req.OutputMergedTranscriptPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + OutputSchema: r.outputSchema, + }, fmt.Errorf("validate seriatim report output %q: %w", req.ReportPath, err) + } + } + + return MergeResult{ + MergedTranscriptPath: req.OutputMergedTranscriptPath, + ReportPath: req.ReportPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + OutputSchema: r.outputSchema, + Metadata: map[string]any{ + "adapter": "seriatim_subprocess", + }, + }, nil +} + +func (r *SubprocessRunner) buildArgs(req MergeRequest) []string { + args := []string{"merge"} + + for _, path := range req.InputTranscriptPaths { + args = append(args, "--input-file", path) + } + args = append(args, "--output-file", req.OutputMergedTranscriptPath) + + if r.report { + args = append(args, "--report-file", req.ReportPath) + } + if strings.TrimSpace(req.SpeakersPath) != "" { + args = append(args, "--speakers", req.SpeakersPath) + } + if strings.TrimSpace(req.AutocorrectPath) != "" { + args = append(args, "--autocorrect", req.AutocorrectPath) + } + + args = append(args, "--output-schema", r.outputSchema) + if r.coalesceGap != nil { + args = append(args, "--coalesce-gap", strconv.FormatFloat(*r.coalesceGap, 'f', -1, 64)) + } + + return args +} + +func (r *SubprocessRunner) buildEnvOverrides() map[string]string { + out := map[string]string{} + if r.env.OverlapWordRunGap != nil { + out["SERIATIM_OVERLAP_WORD_RUN_GAP"] = strconv.FormatFloat(*r.env.OverlapWordRunGap, 'f', -1, 64) + } + if r.env.OverlapWordRunReorderWindow != nil { + out["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] = strconv.FormatFloat(*r.env.OverlapWordRunReorderWindow, 'f', -1, 64) + } + if r.env.BackchannelMaxDuration != nil { + out["SERIATIM_BACKCHANNEL_MAX_DURATION"] = strconv.FormatFloat(*r.env.BackchannelMaxDuration, 'f', -1, 64) + } + if r.env.FillerMaxDuration != nil { + out["SERIATIM_FILLER_MAX_DURATION"] = strconv.FormatFloat(*r.env.FillerMaxDuration, 'f', -1, 64) + } + if len(out) == 0 { + return nil + } + return out +} + +func (r *SubprocessRunner) writeInvocationConfig(req MergeRequest, args []string) error { + payload := map[string]any{ + "schema": "seriatim.generated.v1", + "binary": r.binary, + "args": args, + "timeout": r.timeout.String(), + "output_schema": r.outputSchema, + "report_enabled": r.report, + "input_transcript_paths": req.InputTranscriptPaths, + "output_path": req.OutputMergedTranscriptPath, + } + if req.ReportPath != "" { + payload["report_path"] = req.ReportPath + } + if req.SpeakersPath != "" { + payload["speakers_path"] = req.SpeakersPath + } + if req.AutocorrectPath != "" { + payload["autocorrect_path"] = req.AutocorrectPath + } + if r.coalesceGap != nil { + payload["coalesce_gap"] = *r.coalesceGap + } + + return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) +} + +func validateJSONFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("parse json: %w", err) + } + return nil +} diff --git a/internal/adapters/seriatim/subprocess_test.go b/internal/adapters/seriatim/subprocess_test.go new file mode 100644 index 0000000..a082a59 --- /dev/null +++ b/internal/adapters/seriatim/subprocess_test.go @@ -0,0 +1,418 @@ +package seriatim + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "success") + + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath) + + wrapper := writeHelperWrapper(t) + coalesce := 3.0 + owg := 1.0 + owrw := 1.0 + bmd := 2.0 + fmd := 1.25 + runner, err := NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: wrapper, + Timeout: mustParseDuration(t, "2s"), + OutputSchema: "seriatim-intermediate", + CoalesceGap: &coalesce, + Report: true, + Env: EnvConfig{ + OverlapWordRunGap: &owg, + OverlapWordRunReorderWindow: &owrw, + BackchannelMaxDuration: &bmd, + FillerMaxDuration: &fmd, + }, + }) + if err != nil { + t.Fatalf("NewSubprocessRunner() error = %v", err) + } + + dir := t.TempDir() + req := MergeRequest{ + GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), + InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")}, + OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"), + ReportPath: filepath.Join(dir, "seriatim.report.json"), + SpeakersPath: filepath.Join(dir, "speakers.yml"), + AutocorrectPath: filepath.Join(dir, "autocorrect.yml"), + StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"), + StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"), + } + writeSeriatimFile(t, req.InputTranscriptPaths[0], `{"a":1}`) + writeSeriatimFile(t, req.InputTranscriptPaths[1], `{"b":1}`) + writeSeriatimFile(t, req.SpeakersPath, "speakers: []\n") + writeSeriatimFile(t, req.AutocorrectPath, "autocorrect: []\n") + + res, err := runner.Run(context.Background(), req) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if res.MergedTranscriptPath != req.OutputMergedTranscriptPath { + t.Fatalf("MergedTranscriptPath = %q, want %q", res.MergedTranscriptPath, req.OutputMergedTranscriptPath) + } + if res.ReportPath != req.ReportPath { + t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath) + } + if res.OutputSchema != "seriatim-intermediate" { + t.Fatalf("OutputSchema = %q, want seriatim-intermediate", res.OutputSchema) + } + if res.InvokedBinary != wrapper { + t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.Duration <= 0 { + t.Fatalf("Duration = %s, want >0", res.Duration) + } + if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" { + t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata) + } + + assertJSONFile(t, req.OutputMergedTranscriptPath) + assertJSONFile(t, req.ReportPath) + + if _, err := os.Stat(req.GeneratedConfigPath); err != nil { + t.Fatalf("generated config missing: %v", err) + } + if _, err := os.Stat(req.StdoutLogPath); err != nil { + t.Fatalf("stdout log missing: %v", err) + } + if _, err := os.Stat(req.StderrLogPath); err != nil { + t.Fatalf("stderr log missing: %v", err) + } + + rec := readHelperRecord(t, recordPath) + wantArgs := []string{ + "merge", + "--input-file", req.InputTranscriptPaths[0], + "--input-file", req.InputTranscriptPaths[1], + "--output-file", req.OutputMergedTranscriptPath, + "--report-file", req.ReportPath, + "--speakers", req.SpeakersPath, + "--autocorrect", req.AutocorrectPath, + "--output-schema", "seriatim-intermediate", + "--coalesce-gap", "3", + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } + if rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"] != "1" { + t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_GAP = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"]) + } + if rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] != "1" { + t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"]) + } + if rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"] != "2" { + t.Fatalf("SERIATIM_BACKCHANNEL_MAX_DURATION = %q, want 2", rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"]) + } + if rec.Env["SERIATIM_FILLER_MAX_DURATION"] != "1.25" { + t.Fatalf("SERIATIM_FILLER_MAX_DURATION = %q, want 1.25", rec.Env["SERIATIM_FILLER_MAX_DURATION"]) + } +} + +func TestSubprocessRunnerSubprocessFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "fail") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := mergeReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("Run() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "run seriatim merge") { + t.Fatalf("error = %q, want subprocess context", err.Error()) + } + if !strings.Contains(err.Error(), "exit code") { + t.Fatalf("error = %q, want exit code context", err.Error()) + } +} + +func TestSubprocessRunnerMissingOutputFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "missing_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := mergeReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("Run() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim merged output") { + t.Fatalf("error = %q, want output validation context", err.Error()) + } +} + +func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "invalid_output") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), false) + req := mergeReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("Run() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "parse json") { + t.Fatalf("error = %q, want parse json context", err.Error()) + } +} + +func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SERIATIM_HELPER", "1") + t.Setenv("SERIATIM_HELPER_MODE", "invalid_report") + t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := mustRunner(t, writeHelperWrapper(t), true) + req := mergeReqForTest(t, true) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("Run() error = nil, want non-nil") + } + if !strings.Contains(err.Error(), "validate seriatim report output") { + t.Fatalf("error = %q, want report validation context", err.Error()) + } +} + +func TestSubprocessRunnerConstructorValidation(t *testing.T) { + _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) + if err == nil { + t.Fatal("expected binary validation error") + } + _, err = NewSubprocessRunnerFromConfigValues("seriatim", "bad", "seriatim-intermediate", nil, true, EnvConfig{}) + if err == nil { + t.Fatal("expected timeout parse error") + } + _, err = NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: "seriatim", + Timeout: mustParseDuration(t, "1s"), + OutputSchema: "bad-schema", + }) + if err == nil { + t.Fatal("expected output schema validation error") + } +} + +type helperRecord struct { + Args []string `json:"args"` + Env map[string]string `json:"env"` +} + +func TestSeriatimSubprocessHelper(t *testing.T) { + if os.Getenv("GO_WANT_SERIATIM_HELPER") != "1" { + return + } + + args := os.Args + start := -1 + for i := range args { + if args[i] == "--" { + start = i + 1 + break + } + } + if start < 0 || start >= len(args) { + _, _ = os.Stderr.WriteString("missing -- args separator\n") + os.Exit(2) + } + mergeArgs := args[start:] + + outputPath := flagValue(mergeArgs, "--output-file") + reportPath := flagValue(mergeArgs, "--report-file") + recordPath := os.Getenv("SERIATIM_HELPER_RECORD_PATH") + if strings.TrimSpace(recordPath) != "" { + rec := helperRecord{ + Args: mergeArgs, + Env: map[string]string{ + "SERIATIM_OVERLAP_WORD_RUN_GAP": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_GAP"), + "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"), + "SERIATIM_BACKCHANNEL_MAX_DURATION": os.Getenv("SERIATIM_BACKCHANNEL_MAX_DURATION"), + "SERIATIM_FILLER_MAX_DURATION": os.Getenv("SERIATIM_FILLER_MAX_DURATION"), + }, + } + data, _ := json.Marshal(rec) + _ = os.MkdirAll(filepath.Dir(recordPath), 0o755) + _ = os.WriteFile(recordPath, data, 0o644) + } + + mode := os.Getenv("SERIATIM_HELPER_MODE") + switch mode { + case "success": + writeSeriatimHelperFile(outputPath, `{"merged":true}`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"report":true}`) + } + _, _ = os.Stdout.WriteString("seriatim helper success stdout\n") + _, _ = os.Stderr.WriteString("seriatim helper success stderr\n") + os.Exit(0) + case "fail": + _, _ = os.Stderr.WriteString("seriatim helper failure\n") + os.Exit(9) + case "missing_output": + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"report":true}`) + } + os.Exit(0) + case "invalid_output": + writeSeriatimHelperFile(outputPath, `not-json`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `{"report":true}`) + } + os.Exit(0) + case "invalid_report": + writeSeriatimHelperFile(outputPath, `{"merged":true}`) + if reportPath != "" { + writeSeriatimHelperFile(reportPath, `not-json`) + } + os.Exit(0) + default: + _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) + os.Exit(2) + } +} + +func writeHelperWrapper(t *testing.T) string { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable() error = %v", err) + } + path := filepath.Join(t.TempDir(), "seriatim-helper-wrapper.sh") + content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestSeriatimSubprocessHelper -- \"$@\"\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + return path +} + +func mergeReqForTest(t *testing.T, withReport bool) MergeRequest { + t.Helper() + dir := t.TempDir() + in1 := filepath.Join(dir, "a.json") + in2 := filepath.Join(dir, "b.json") + writeSeriatimFile(t, in1, `{"a":1}`) + writeSeriatimFile(t, in2, `{"b":1}`) + req := MergeRequest{ + GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), + InputTranscriptPaths: []string{in1, in2}, + OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"), + StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"), + StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"), + } + if withReport { + req.ReportPath = filepath.Join(dir, "seriatim.report.json") + } + return req +} + +func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner { + t.Helper() + coalesce := 3.0 + r, err := NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: binary, + Timeout: mustParseDuration(t, "2s"), + OutputSchema: "seriatim-intermediate", + CoalesceGap: &coalesce, + Report: report, + }) + if err != nil { + t.Fatalf("NewSubprocessRunner() error = %v", err) + } + return r +} + +func mustParseDuration(t *testing.T, v string) time.Duration { + t.Helper() + d, err := time.ParseDuration(v) + if err != nil { + t.Fatalf("time.ParseDuration(%q) error = %v", v, err) + } + return d +} + +func flagValue(args []string, name string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == name { + return args[i+1] + } + } + return "" +} + +func writeSeriatimFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", path, err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } +} + +func writeSeriatimHelperFile(path, contents string) { + if strings.TrimSpace(path) == "" { + return + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(contents), 0o644) +} + +func assertJSONFile(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("json unmarshal %q: %v", path, err) + } +} + +func readHelperRecord(t *testing.T, path string) helperRecord { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var rec helperRecord + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("json unmarshal helper record: %v", err) + } + return rec +}