Require manifest-backed local artifacts

This commit is contained in:
2026-09-02 14:09:32 +00:00
parent 62b1f9b0ae
commit 57719b7d81
28 changed files with 392 additions and 179 deletions

View File

@@ -20,8 +20,8 @@ The internal registry recognizes these stable built-in source IDs:
- `narratio.transcript.final_trimmed_markdown` - `narratio.transcript.final_trimmed_markdown`
- `narratio.bounds.session` - `narratio.bounds.session`
Registry entries bind each ID to its producer, output kind, canonical fallback, Registry entries bind each ID to its producer, output kind, and content
and content validator. The focused stage documents own their input/output flow; validator. The focused stage documents own their input/output flow;
[Configuration](../config.md) owns where operators may select these IDs. [Configuration](../config.md) owns where operators may select these IDs.
## Configured, Extraction, And Previous-Session Sources ## Configured, Extraction, And Previous-Session Sources
@@ -91,8 +91,13 @@ Current provenance values:
Built-ins: Built-ins:
1. manifest producer outputs (when present) - resolve only from the current manifest's succeeded registered producer;
2. canonical session-path fallback - require exactly one output with the registered source ID and output kind, a
safe session-rooted path, producer run ID, canonical checksum, and valid
content; and
- verify the registered checksum against the durable bytes. Incidental files,
canonical filenames, duplicate outputs, failed/stale producer records, and
malformed evidence are unavailable.
Configured sources (`narratio.artifact.*`): Configured sources (`narratio.artifact.*`):

View File

@@ -41,10 +41,9 @@ Exact remote placement and the operator workflow belong in
- resolves publish output sources through runtime artifact catalog and - resolves publish output sources through runtime artifact catalog and
manifest-aware resolution. Configured Scriptorium outputs are publishable manifest-aware resolution. Configured Scriptorium outputs are publishable
only from validated `current` per-artifact analyze evidence; an incidental only from validated `current` per-artifact analyze evidence; an incidental
canonical file, legacy aggregate output, stale/failed/unselected record, or canonical file, aggregate output, stale/failed/unselected record, or
mismatched path, size, or checksum remains unavailable. This does not change mismatched path, size, or checksum remains unavailable. Built-in and
the explicit compatibility policies owned by built-in, extraction, or extraction sources likewise require their own current manifest evidence.
previous-session sources.
- publishes extraction lanes only through explicit configured output rules; - publishes extraction lanes only through explicit configured output rules;
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale. neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
- selected artifact filter applies to configured artifact sources only. - selected artifact filter applies to configured artifact sources only.

View File

@@ -102,8 +102,7 @@ Canonical stage order:
Execution rules: Execution rules:
- succeeded stages are skipped unless `--force` is set; stages with semantic - succeeded stages are skipped unless `--force` is set; stages with semantic
configuration contracts additionally require matching versioned evidence, configuration contracts additionally require matching versioned evidence;
and missing legacy evidence causes a safe one-time rerun;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages; - `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- forcing a stage marks succeeded transitive dependents as `stale` before the - forcing a stage marks succeeded transitive dependents as `stale` before the
replacement runs; render and extract are independent siblings; and replacement runs; render and extract are independent siblings; and

View File

@@ -317,7 +317,7 @@ path candidates or remote probing.
## Stage 6 — Make Local Artifact Evidence Manifest-Only ## Stage 6 — Make Local Artifact Evidence Manifest-Only
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -428,6 +428,25 @@ narratio analyze 2026-04-04 --artifacts session_recap
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection) Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
and [Artifact Internals](./internal/artifacts.md#resolution-rules). and [Artifact Internals](./internal/artifacts.md#resolution-rules).
## Built-in transcript or bounds source is unavailable
Symptom:
- analyze, extract, render, or publish says a built-in source is unavailable
even though a file exists at its usual session path.
Likely cause:
- the producing stage has not succeeded, its output lacks the exact registered
source, run ID, or checksum, its evidence is duplicated or stale, or the
durable bytes no longer match the manifest checksum.
Safe fix:
- do not copy or rename the file to make it discoverable. Rerun the producing
stage to regenerate both the bytes and current manifest evidence, then retry
the consumer.
## Legacy aggregate analysis requires regeneration ## Legacy aggregate analysis requires regeneration
Symptom: Symptom:

View File

@@ -0,0 +1,31 @@
package app
import (
"context"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func setCurrentBuiltInEvidence(t *testing.T, m *manifest.Manifest, stage, kind, sourceID, path string) {
t.Helper()
checksum, err := artifacts.SHA256File(path)
if err != nil {
t.Fatal(err)
}
m.MarkStageSucceeded(stage, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), []manifest.ArtifactRecord{{
Kind: kind, SourceID: sourceID, LocalPath: path, ProducerRunID: stage + "-run", Checksum: checksum,
}})
}
func seedCurrentBuiltInEvidence(t *testing.T, manifestPath, sessionID, campaign, stage, kind, sourceID, path string) {
t.Helper()
m := manifest.New(sessionID, time.Now().UTC())
m.Campaign = campaign
setCurrentBuiltInEvidence(t, m, stage, kind, sourceID, path)
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
t.Fatal(err)
}
}

View File

@@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -126,7 +127,9 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1}]}`) polishedPath := filepath.Join(workRoot, "transcripts", "polished.json")
mustWriteTestFile(t, polishedPath, `{"segments":[{"id":1}]}`)
seedCurrentBuiltInEvidence(t, filepath.Join(workRoot, "manifest.json"), "2026-05-03", "sample-campaign", "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, polishedPath)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -247,7 +250,9 @@ inputs:
}) })
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"schema":"seriatim-intermediate","segments":[]}`) basePath := filepath.Join(workRoot, "transcripts", "base.json")
mustWriteTestFile(t, basePath, `{"schema":"seriatim-intermediate","segments":[]}`)
seedCurrentBuiltInEvidence(t, filepath.Join(workRoot, "manifest.json"), sessionID, "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n")
var stdout bytes.Buffer var stdout bytes.Buffer

View File

@@ -156,7 +156,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
if len(summary.Executed) != 2 || len(runner.requests) != 1 { if len(summary.Executed) != 3 || len(runner.requests) != 1 {
t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests)) t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests))
} }
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID) paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
@@ -510,12 +510,12 @@ func TestExtractLifecycleRerunsAfterDirectTranscriptChange(t *testing.T) {
t.Fatalf("Save(mutated) error = %v", err) t.Fatalf("Save(mutated) error = %v", err)
} }
rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil { if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("rerun executeStages() error = %v", err) t.Fatalf("rerun executeStages() error = %v, want stale transcript evidence rejection", err)
} }
if len(rerun.Executed) != 2 || len(rerun.Skipped) != 0 || len(runner.requests) != 2 || analyzeRuns != 2 { if len(runner.requests) != 1 || analyzeRuns != 1 {
t.Fatalf("rerun summary = %#v requests=%d analyze=%d", rerun, len(runner.requests), analyzeRuns) t.Fatalf("stale transcript should prevent extraction, requests=%d analyze=%d", len(runner.requests), analyzeRuns)
} }
} }
@@ -645,7 +645,24 @@ func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage {
if err != nil { if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err) t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
} }
return append(prepare, extract...) return append(append(prepare, lifecycleTrimEvidenceStage{}), extract...)
}
type lifecycleTrimEvidenceStage struct{}
func (lifecycleTrimEvidenceStage) Name() string { return "trim" }
func (lifecycleTrimEvidenceStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
paths := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root).SessionPathsFor(m.Campaign, m.SessionID)
path := filepath.Join(paths.ArtifactsDir, "trimmed.from-manifest.json")
checksum, err := artifacts.SHA256File(path)
if err != nil {
return nil, err
}
return &stage.StageResult{Outputs: []artifacts.Ref{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed,
AbsolutePath: path, Checksum: checksum,
}}}, nil
} }
func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string { func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string {
@@ -738,9 +755,13 @@ func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *st
} }
m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign m.Campaign = cfg.Session.Campaign
inputChecksum, err := artifacts.SHA256File(inputPath)
if err != nil {
t.Fatalf("SHA256File(input) error = %v", err)
}
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed,
LocalPath: inputPath, LocalPath: inputPath, ProducerRunID: "trim-run", Checksum: inputChecksum,
}}) }})
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil { if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
t.Fatalf("Save(seed) error = %v", err) t.Fatalf("Save(seed) error = %v", err)

View File

@@ -1276,13 +1276,32 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
// The publish stage only checks the manifest statuses and source files.
_ = stageName
}
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n")
m := manifest.New("2026-05-03", time.Now().UTC())
m.Campaign = "sample-campaign"
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "extract", "analyze"} {
m.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
}
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(workRoot, "transcripts", "final.trimmed.json"))
finalMarkdown := filepath.Join(workRoot, "transcripts", "final.md")
trimmedMarkdown := filepath.Join(workRoot, "transcripts", "final.trimmed.md")
finalChecksum, err := artifacts.SHA256File(finalMarkdown)
if err != nil {
t.Fatal(err)
}
trimmedChecksum, err := artifacts.SHA256File(trimmedMarkdown)
if err != nil {
t.Fatal(err)
}
m.MarkStageSucceeded("render", time.Now().UTC(), []manifest.ArtifactRecord{
{Kind: artifacts.TranscriptOutputKindFinalMarkdown, SourceID: artifacts.ArtifactTranscriptFinalMarkdown, LocalPath: finalMarkdown, ProducerRunID: "render-run", Checksum: finalChecksum},
{Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, SourceID: artifacts.ArtifactTranscriptFinalTrimmedMarkdown, LocalPath: trimmedMarkdown, ProducerRunID: "render-run", Checksum: trimmedChecksum},
})
if err := (&manifest.LocalStore{}).Save(context.Background(), filepath.Join(workRoot, "manifest.json"), m); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -1357,10 +1376,14 @@ func writeOperatorExtractionManifest(t *testing.T, workspaceRoot string) string
} }
m := manifest.New("2026-05-03", time.Now().UTC()) m := manifest.New("2026-05-03", time.Now().UTC())
m.Campaign = "sample-campaign" m.Campaign = "sample-campaign"
trimmedChecksum, err := artifacts.SHA256File(trimmedPath)
if err != nil {
t.Fatal(err)
}
m.Stages["trim"] = &manifest.StageRecord{ m.Stages["trim"] = &manifest.StageRecord{
Name: "trim", Status: manifest.StatusSucceeded, Name: "trim", Status: manifest.StatusSucceeded,
Outputs: []manifest.ArtifactRecord{{ Outputs: []manifest.ArtifactRecord{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: trimmedChecksum,
}}, }},
} }
input, err := artifacts.ResolveExtractionInputIdentity(paths, m) input, err := artifacts.ResolveExtractionInputIdentity(paths, m)

View File

@@ -542,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil) seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
} }
setCurrentBuiltInEvidence(t, seedManifest, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID), "transcripts", "final.trimmed.json"))
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n")) setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID) seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)

View File

@@ -206,6 +206,14 @@ previous_session_id: 2026-04-26
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`+"\n") mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`+"\n")
localManifest, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(sessionRoot, "manifest.json"))
if err != nil {
t.Fatalf("load restored manifest: %v", err)
}
setCurrentBuiltInEvidence(t, localManifest, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"))
if err := (&manifest.LocalStore{}).Save(context.Background(), filepath.Join(sessionRoot, "manifest.json"), localManifest); err != nil {
t.Fatalf("save manifest evidence: %v", err)
}
previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json")) previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
if err != nil { if err != nil {
t.Fatalf("read restored previous manifest: %v", err) t.Fatalf("read restored previous manifest: %v", err)

View File

@@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -121,7 +122,9 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) basePath := filepath.Join(workRoot, "transcripts", "base.json")
mustWriteTestFile(t, basePath, `{"segments":[]}`)
seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer var out bytes.Buffer
@@ -172,11 +175,13 @@ func TestRunStageSkipAndForce(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) basePath := filepath.Join(workRoot, "transcripts", "base.json")
mustWriteTestFile(t, basePath, `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish") seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish")
if err := store.Save(context.Background(), manifestPath, m); err != nil { if err := store.Save(context.Background(), manifestPath, m); err != nil {
@@ -207,7 +212,9 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`) basePath := filepath.Join(workRoot, "transcripts", "base.json")
mustWriteTestFile(t, basePath, `{"segments":[]}`)
seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath) materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
@@ -218,6 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
} }
setCurrentBuiltInEvidence(t, seed, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge") seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
if err := store.Save(context.Background(), manifestPath, seed); err != nil { if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err) t.Fatalf("save manifest: %v", err)
@@ -257,7 +265,9 @@ func TestRunStageTrimExecutes(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`) finalPath := filepath.Join(workRoot, "transcripts", "final.json")
mustWriteTestFile(t, finalPath, `{"segments":[{"id":1},{"id":2}]}`)
seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, finalPath)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
@@ -286,7 +296,9 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`) polishedPath := filepath.Join(workRoot, "transcripts", "polished.json")
mustWriteTestFile(t, polishedPath, `{"segments":[{"id":1},{"id":2}]}`)
seedCurrentBuiltInEvidence(t, manifestPath, "2026-05-03", "sample-campaign", "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, polishedPath)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
@@ -312,8 +324,17 @@ func TestRunStageRenderExecutes(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`) finalPath := filepath.Join(workRoot, "transcripts", "final.json")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`) trimmedPath := filepath.Join(workRoot, "transcripts", "final.trimmed.json")
mustWriteTestFile(t, finalPath, `{"segments":[{"id":1}]}`)
mustWriteTestFile(t, trimmedPath, `{"segments":[{"id":2}]}`)
seed := manifest.New("2026-05-03", time.Now().UTC())
seed.Campaign = "sample-campaign"
setCurrentBuiltInEvidence(t, seed, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, finalPath)
setCurrentBuiltInEvidence(t, seed, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, trimmedPath)
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, seed); err != nil {
t.Fatal(err)
}
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)

View File

@@ -1,6 +1,7 @@
package artifacts package artifacts
import ( import (
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -12,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
) )
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation. // MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
@@ -178,8 +180,8 @@ func PreviousSessionArtifactName(source string) (string, bool) {
return artifactpolicy.ParsePreviousSessionSource(source) return artifactpolicy.ParsePreviousSessionSource(source)
} }
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. // ResolveSessionArtifact resolves a symbolic source from its unique current
// Resolution order is manifest producer outputs first, then canonical session path fallback. // producer output declaration in the session manifest.
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) { func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
id, err := NormalizeSessionArtifactSource(source) id, err := NormalizeSessionArtifactSource(source)
if err != nil { if err != nil {
@@ -187,43 +189,21 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
} }
spec := artifactRegistry[id] spec := artifactRegistry[id]
for _, candidate := range manifestArtifactCandidates(paths, m, spec) { candidate, err := manifestArtifactCandidate(paths, m, spec)
exists, isDir, statErr := pathExists(candidate.Path) if err != nil {
if statErr != nil { return ResolvedSessionArtifact{}, err
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate.Path, statErr)
}
if !exists || isDir {
continue
}
resolved := candidate
resolved.ID = spec.ID
resolved.ProducerStage = spec.ProducerStage
resolved.OutputKind = spec.OutputKind
if err := validateResolvedContent(resolved.Path, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", resolved.ID, err)
}
return resolved, nil
} }
if err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil {
fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath)) return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
exists, isDir, statErr := pathExists(fallbackPath)
if statErr != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr)
} }
if exists && !isDir { actualChecksum, err := SHA256File(candidate.Path)
if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil { if err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err) return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err)
}
return ResolvedSessionArtifact{
ID: spec.ID,
Path: filepath.Clean(fallbackPath),
ProducerStage: spec.ProducerStage,
OutputKind: spec.OutputKind,
Provenance: "fallback.canonical_path",
}, nil
} }
if actualChecksum != candidate.Checksum {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", spec.ID)
}
return candidate, nil
} }
// ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves // ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves
@@ -334,52 +314,74 @@ func ResolvePreviousSessionArtifactWithCatalog(
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
} }
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact { func manifestArtifactCandidate(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) (ResolvedSessionArtifact, error) {
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" { if m == nil || spec.ProducerStage == "" || spec.OutputKind == "" {
return nil return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
} }
sr := m.Stages[spec.ProducerStage] sr := m.Stages[spec.ProducerStage]
if sr == nil { if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded {
return nil return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
} }
var selected manifest.ArtifactRecord
candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs)) count := 0
for _, out := range sr.Outputs { for _, out := range sr.Outputs {
if strings.TrimSpace(out.Kind) != spec.OutputKind { if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID {
continue selected, count = out, count+1
} }
if strings.TrimSpace(out.LocalPath) == "" {
continue
}
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, out.LocalPath))
if resolved == "" {
continue
}
candidates = append(candidates, ResolvedSessionArtifact{
Path: resolved,
ProducerRunID: strings.TrimSpace(out.ProducerRunID),
Provenance: "manifest." + spec.ProducerStage + ".outputs",
})
} }
return dedupeResolvedArtifacts(candidates) if count == 0 {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
if count != 1 {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q is ambiguous", spec.ProducerStage, spec.ID)
}
if err := ValidateRunIdentity(strings.TrimSpace(selected.ProducerRunID)); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid producer run ID: %w", spec.ProducerStage, spec.ID, err)
}
checksum := strings.TrimSpace(selected.Checksum)
if len(checksum) != 64 || strings.ToLower(checksum) != checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
}
if _, err := hex.DecodeString(checksum); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
}
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, selected.LocalPath))
if resolved == "" {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has an empty path", spec.ProducerStage, spec.ID)
}
if _, err := pathsafe.SlashRelativeFromRoot(paths.Root, resolved); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
}
if err := requireSafeSessionRegularFile(paths.Root, resolved); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
}
return ResolvedSessionArtifact{ID: spec.ID, Path: resolved, ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, ProducerRunID: selected.ProducerRunID, Provenance: "manifest." + spec.ProducerStage + ".outputs", Checksum: checksum}, nil
} }
func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSessionArtifact { func requireSafeSessionRegularFile(root, path string) error {
seen := map[string]struct{}{} relative, err := pathsafe.SlashRelativeFromRoot(root, path)
out := make([]ResolvedSessionArtifact, 0, len(values)) if err != nil {
for _, value := range values { return err
key := filepath.Clean(strings.TrimSpace(value.Path))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
value.Path = key
out = append(out, value)
} }
return out current := filepath.Clean(root)
for _, component := range strings.Split(relative, "/") {
current = filepath.Join(current, filepath.FromSlash(component))
info, err := os.Lstat(current)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("path contains symlink %q", current)
}
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("path is not a regular file")
}
return nil
} }
func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string { func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string {

View File

@@ -187,7 +187,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
m := manifest.New("session", time.Now().UTC()) m := manifest.New("session", time.Now().UTC())
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
{Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"}, {Kind: "transcript_final", SourceID: ArtifactTranscriptFinal, LocalPath: manifestPath, ProducerRunID: "run-123", Checksum: resolverTestChecksum(t, manifestPath)},
}) })
resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal) resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal)
@@ -205,7 +205,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
} }
} }
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { func TestResolveSessionArtifactRejectsIncidentalCanonicalPath(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
@@ -216,19 +216,13 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed) _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
if err != nil { if !errors.Is(err, ErrSessionArtifactNotFound) {
t.Fatalf("ResolveSessionArtifact() error = %v", err) t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
}
if resolved.Path != canonicalPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
}
if resolved.Provenance != "fallback.canonical_path" {
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
} }
} }
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) { func TestResolveSessionArtifactRejectsIncidentalCanonicalMarkdownPath(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
@@ -239,15 +233,9 @@ func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown) _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
if err != nil { if !errors.Is(err, ErrSessionArtifactNotFound) {
t.Fatalf("ResolveSessionArtifact() error = %v", err) t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
}
if resolved.Path != canonicalPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
}
if resolved.Provenance != "fallback.canonical_path" {
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
} }
} }
@@ -275,7 +263,8 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptPolished) m := resolverTestManifest(t, paths, ArtifactTranscriptPolished, canonicalPath)
_, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptPolished)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -295,7 +284,8 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown) m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmedMarkdown, canonicalPath)
_, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinalTrimmedMarkdown)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -304,7 +294,7 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
} }
} }
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { func TestResolveSessionArtifactWithCatalogBuiltInUsesManifestEvidence(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
@@ -315,18 +305,39 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T)
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog()) m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmed, canonicalPath)
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog())
if err != nil { if err != nil {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
} }
if resolved.Path != canonicalPath { if resolved.Path != canonicalPath {
t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath) t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath)
} }
if resolved.Provenance != "fallback.canonical_path" { if resolved.Provenance != "manifest.trim.outputs" {
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) t.Fatalf("provenance = %q, want manifest.trim.outputs", resolved.Provenance)
} }
} }
func resolverTestManifest(t *testing.T, paths SessionPaths, source, localPath string) *manifest.Manifest {
t.Helper()
spec := artifactRegistry[source]
m := manifest.New(paths.SessionID, time.Now().UTC())
m.MarkStageSucceeded(spec.ProducerStage, time.Now().UTC(), []manifest.ArtifactRecord{{
Kind: spec.OutputKind, SourceID: source, LocalPath: localPath,
ProducerRunID: "resolver-run", Checksum: resolverTestChecksum(t, localPath),
}})
return m
}
func resolverTestChecksum(t *testing.T, path string) string {
t.Helper()
checksum, err := SHA256File(path)
if err != nil {
t.Fatal(err)
}
return checksum
}
func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) { func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"time" "time"
@@ -93,6 +94,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
name string name string
state ExtractionEvidenceState state ExtractionEvidenceState
recomputeInputIdentity bool recomputeInputIdentity bool
wantInputUnavailable bool
mutate func(*testing.T, *SessionPaths, *manifest.Manifest) mutate func(*testing.T, *SessionPaths, *manifest.Manifest)
}{ }{
{name: "valid", state: ExtractionEvidenceValid, mutate: func(_ *testing.T, _ *SessionPaths, _ *manifest.Manifest) {}}, {name: "valid", state: ExtractionEvidenceValid, mutate: func(_ *testing.T, _ *SessionPaths, _ *manifest.Manifest) {}},
@@ -102,7 +104,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
{name: "obsolete version", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) { {name: "obsolete version", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
m.Stages["extract"].Outputs[0].Contract.SchemaVersion = "99" m.Stages["extract"].Outputs[0].Contract.SchemaVersion = "99"
}}, }},
{name: "changed direct transcript", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) { {name: "changed direct transcript", wantInputUnavailable: true, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) {
writeExtractionFixtureFile(t, m.Stages["trim"].Outputs[0].LocalPath, `{"segments":[{"id":"changed"}]}`) writeExtractionFixtureFile(t, m.Stages["trim"].Outputs[0].LocalPath, `{"segments":[{"id":"changed"}]}`)
}}, }},
{name: "changed direct producer", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) { {name: "changed direct producer", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
@@ -140,6 +142,12 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
test.mutate(t, &paths, currentManifest) test.mutate(t, &paths, currentManifest)
if test.recomputeInputIdentity { if test.recomputeInputIdentity {
input, err = ResolveExtractionInputIdentity(paths, currentManifest) input, err = ResolveExtractionInputIdentity(paths, currentManifest)
if test.wantInputUnavailable {
if !errors.Is(err, ErrSessionArtifactNotFound) && (err == nil || !strings.Contains(err.Error(), "checksum mismatch")) {
t.Fatalf("ResolveExtractionInputIdentity() error = %v, want unavailable", err)
}
return
}
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -271,7 +279,7 @@ func validExtractionCatalogFixture(t *testing.T) (SessionPaths, *manifest.Manife
m.Stages["trim"] = &manifest.StageRecord{ m.Stages["trim"] = &manifest.StageRecord{
Name: "trim", Status: manifest.StatusSucceeded, Name: "trim", Status: manifest.StatusSucceeded,
Outputs: []manifest.ArtifactRecord{{ Outputs: []manifest.ArtifactRecord{{
Kind: TranscriptOutputKindFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Kind: TranscriptOutputKindFinalTrimmed, SourceID: ArtifactTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: extractionFixtureChecksum(t, trimmedPath),
}}, }},
} }
input, err := ResolveExtractionInputIdentity(paths, m) input, err := ResolveExtractionInputIdentity(paths, m)

View File

@@ -43,3 +43,15 @@ func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relative
OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now, OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now,
} }
} }
func setCurrentBuiltInEvidence(t *testing.T, m *manifest.Manifest, stage, kind, sourceID, absolutePath string) {
t.Helper()
checksum, err := artifacts.SHA256File(absolutePath)
if err != nil {
t.Fatal(err)
}
m.MarkStageSucceeded(stage, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), []manifest.ArtifactRecord{{
Kind: kind, SourceID: sourceID, LocalPath: absolutePath,
ProducerRunID: stage + "-run", Checksum: checksum,
}})
}

View File

@@ -29,6 +29,7 @@ func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) {
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string { setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, `{"segments":[]}`) writeAnalyzeFile(t, path, `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path)
return path return path
}, },
wantOutputKind: "transcript_final_trimmed", wantOutputKind: "transcript_final_trimmed",
@@ -47,6 +48,7 @@ func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) {
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string { setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"))
return configureAnalyzeExtractionFixture(t, env, m)["encounters"] return configureAnalyzeExtractionFixture(t, env, m)["encounters"]
}, },
wantOutputKind: "notarius_lane", wantOutputKind: "notarius_lane",
@@ -177,6 +179,9 @@ func TestResolveAnalyzeInputIdentitiesRejectsUnsafePathsAndFileTypes(t *testing.
setup: func(t *testing.T, env *Env, m *manifest.Manifest) { setup: func(t *testing.T, env *Env, m *manifest.Manifest) {
t.Helper() t.Helper()
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(path, 0o755); err != nil { if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -213,6 +218,7 @@ func TestAnalyzeInputIdentityIsStableAcrossWorkspaceRelocationAndChangesWithByte
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json") path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, body) writeAnalyzeFile(t, path, body)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path)
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{ resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.final_trimmed", Required: true}, "transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
}, newAnalyzeIdentityExecution(t, env, m)) }, newAnalyzeIdentityExecution(t, env, m))

View File

@@ -151,7 +151,6 @@ func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) {
analyzeReconciliationStale, analyzeReconciliationStale,
analyzeReconciliationMissing, analyzeReconciliationMissing,
analyzeReconciliationFailed, analyzeReconciliationFailed,
analyzeReconciliationLegacy,
analyzeReconciliationNonResumable, analyzeReconciliationNonResumable,
} }
for _, reason := range reasons { for _, reason := range reasons {
@@ -245,12 +244,12 @@ func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testi
} }
} }
func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) { func TestPlanAnalyzeWorkNonResumablePartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{ configured := map[string]config.ScriptoriumArtifactConfig{
"target": {Enabled: true}, "legacy_unselected": {Enabled: true}, "target": {Enabled: true}, "legacy_unselected": {Enabled: true},
} }
reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{ reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{
"target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy, "target": analyzeReconciliationNonResumable, "legacy_unselected": analyzeReconciliationNonResumable,
}, nil) }, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation) plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil { if err != nil {
@@ -258,7 +257,7 @@ func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *
} }
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"}) assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
if len(plan.ProjectedRecords) != 0 { if len(plan.ProjectedRecords) != 0 {
t.Fatalf("legacy projection = %#v, want no current records", plan.ProjectedRecords) t.Fatalf("non-resumable projection = %#v, want no current records", plan.ProjectedRecords)
} }
} }

View File

@@ -18,7 +18,6 @@ const (
analyzeReconciliationStale analyzeReconciliationReason = "stale" analyzeReconciliationStale analyzeReconciliationReason = "stale"
analyzeReconciliationMissing analyzeReconciliationReason = "missing" analyzeReconciliationMissing analyzeReconciliationReason = "missing"
analyzeReconciliationFailed analyzeReconciliationReason = "failed" analyzeReconciliationFailed analyzeReconciliationReason = "failed"
analyzeReconciliationLegacy analyzeReconciliationReason = "legacy"
analyzeReconciliationRemoved analyzeReconciliationReason = "removed" analyzeReconciliationRemoved analyzeReconciliationReason = "removed"
analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable" analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable"
) )
@@ -109,8 +108,8 @@ func reconcileAnalyzeArtifact(
return result return result
} }
if stageRecord.AnalyzeStateVersion == 0 { if stageRecord.AnalyzeStateVersion == 0 {
result.Reason = analyzeReconciliationLegacy result.Reason = analyzeReconciliationNonResumable
result.Detail = "analyze manifest uses aggregate-only legacy state" result.Detail = "analyze manifest state contract is absent"
return result return result
} }
if stageRecord.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion { if stageRecord.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion {

View File

@@ -32,7 +32,7 @@ func TestReconcileAnalyzeArtifactsClassifiesStoredState(t *testing.T) {
}, },
}, },
{ {
name: "legacy record", want: analyzeReconciliationLegacy, name: "missing state contract", want: analyzeReconciliationNonResumable,
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) { mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0 m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil m.Stages["analyze"].AnalyzeArtifacts = nil

View File

@@ -264,6 +264,7 @@ func TestAnalyzeOmitsOptionalCanonicalPreviousRecapWhenUnavailable(t *testing.T)
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -303,6 +304,7 @@ func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
m.RunID = "20260518T010203Z-abcdef12" m.RunID = "20260518T010203Z-abcdef12"
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"))
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -386,6 +388,7 @@ func TestAnalyzeIncludesCanonicalPreviousRecapWhenPreparedCacheExists(t *testing
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
previousRecapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md") previousRecapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousRecapPath, "previous recap\n") writeAnalyzeFile(t, previousRecapPath, "previous recap\n")
@@ -938,6 +941,7 @@ func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) {
func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) { func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
delete(m.Stages, "trim")
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
@@ -964,6 +968,9 @@ func TestAnalyzeMissingOptionalBuiltInInputsAreOmitted(t *testing.T) {
"optional": {Source: source, Required: false}, "optional": {Source: source, Required: false},
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
if source == artifacts.ArtifactTranscriptFinalTrimmed {
delete(m.Stages, "trim")
}
if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil { if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
@@ -1000,6 +1007,9 @@ func TestAnalyzeMissingRequiredBuiltInInputsProvideExecutableGuidance(t *testing
"required": {Source: tt.source, Required: true}, "required": {Source: tt.source, Required: true},
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
if tt.source == artifacts.ArtifactTranscriptFinalTrimmed {
delete(m.Stages, "trim")
}
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -1020,6 +1030,7 @@ func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -1069,6 +1080,7 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
normalizedPath := filepath.Join(paths.TranscriptsDir, "final.json") normalizedPath := filepath.Join(paths.TranscriptsDir, "final.json")
writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`) writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalizedPath)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -1094,6 +1106,7 @@ func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testin
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
markdownPath := filepath.Join(paths.TranscriptsDir, "final.md") markdownPath := filepath.Join(paths.TranscriptsDir, "final.md")
writeAnalyzeFile(t, markdownPath, "# Session Transcript\n") writeAnalyzeFile(t, markdownPath, "# Session Transcript\n")
setCurrentBuiltInEvidence(t, m, "render", artifacts.TranscriptOutputKindFinalMarkdown, artifacts.ArtifactTranscriptFinalMarkdown, markdownPath)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -1243,9 +1256,7 @@ func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t
manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json")
writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`)
writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestPath)
{Kind: "transcript_final", LocalPath: manifestPath},
})
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -1273,9 +1284,7 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceFromManifestOutput(t *testing.
manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json")
writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`)
writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestPath)
{Kind: "transcript_final", LocalPath: manifestPath},
})
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -1689,6 +1698,17 @@ func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeR
Scriptorium: fake, Scriptorium: fake,
} }
m := manifest.New("2026-05-03", time.Now().UTC()) m := manifest.New("2026-05-03", time.Now().UTC())
paths := store.SessionPathsFor("icewind-dale", "2026-05-03")
trimmedPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, trimmedPath, `{"segments":[]}`)
checksum, err := artifacts.SHA256File(trimmedPath)
if err != nil {
t.Fatalf("SHA256File(%q): %v", trimmedPath, err)
}
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{
Kind: artifacts.TranscriptOutputKindFinalTrimmed, SourceID: artifacts.ArtifactTranscriptFinalTrimmed,
LocalPath: trimmedPath, ProducerRunID: "trim-run", Checksum: checksum,
}})
return env, m, fake return env, m, fake
} }

View File

@@ -30,7 +30,7 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
paths := sessionPathsForEnv(env, sessionID) paths := sessionPathsForEnv(env, sessionID)
input, err := artifacts.ResolveExtractionInputIdentity(paths, m) input, err := artifacts.ResolveExtractionInputIdentity(paths, m)
if err != nil { if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err) return NonResumable("final-trimmed transcript evidence is unavailable or changed"), nil
} }
references, err := resolveExtractReferences(paths, m, cfg, sessionID) references, err := resolveExtractReferences(paths, m, cfg, sessionID)
if err != nil { if err != nil {

View File

@@ -1012,9 +1012,13 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
m := manifest.New(sessionID, time.Now().UTC()) m := manifest.New(sessionID, time.Now().UTC())
m.Campaign = campaign m.Campaign = campaign
m.RunID = runID m.RunID = runID
inputChecksum, err := artifacts.SHA256File(inputPath)
if err != nil {
t.Fatalf("SHA256File(input) error = %v", err)
}
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed,
LocalPath: inputPath, LocalPath: inputPath, ProducerRunID: "trim-run", Checksum: inputChecksum,
}}) }})
outputRoot := artifacts.SessionRunNotariusOutputRootForCampaign(workspace, campaign, sessionID, runID) outputRoot := artifacts.SessionRunNotariusOutputRootForCampaign(workspace, campaign, sessionID, runID)

View File

@@ -20,10 +20,7 @@ func TestNormalizeStageConsumesProcessedTranscriptFromManifest(t *testing.T) {
manifestProcessed := filepath.Join(paths.ArtifactsDir, "processed.from-manifest.json") manifestProcessed := filepath.Join(paths.ArtifactsDir, "processed.from-manifest.json")
writeFile(t, manifestProcessed, `{"segments":[{"id":10}]}`) writeFile(t, manifestProcessed, `{"segments":[{"id":10}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":99}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":99}]}`)
m.MarkStageSucceeded("polish", time.Now().UTC(), []manifest.ArtifactRecord{{ setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, manifestProcessed)
Kind: "transcript_polished",
LocalPath: manifestProcessed,
}})
result, err := (normalizeStage{}).Run(context.Background(), env, m) result, err := (normalizeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -41,21 +38,18 @@ func TestNormalizeStageConsumesProcessedTranscriptFromManifest(t *testing.T) {
} }
} }
func TestNormalizeStageFallsBackToProcessedTranscriptPath(t *testing.T) { func TestNormalizeStageRejectsIncidentalProcessedTranscriptPath(t *testing.T) {
env, m, ser := setupNormalizeEnv(t) env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
fallback := filepath.Join(paths.TranscriptsDir, "polished.json") fallback := filepath.Join(paths.TranscriptsDir, "polished.json")
writeFile(t, fallback, `{"segments":[{"id":1}]}`) writeFile(t, fallback, `{"segments":[{"id":1}]}`)
_, err := (normalizeStage{}).Run(context.Background(), env, m) _, err := (normalizeStage{}).Run(context.Background(), env, m)
if err != nil { if err == nil || !strings.Contains(err.Error(), "processed transcript input is required") {
t.Fatalf("normalize.Run() error = %v", err) t.Fatalf("normalize.Run() error = %v, want unavailable manifest input", err)
} }
if len(ser.NormalizeRequests) != 1 { if len(ser.NormalizeRequests) != 0 {
t.Fatalf("normalize requests = %d, want 1", len(ser.NormalizeRequests)) t.Fatalf("normalize requests = %d, want 0", len(ser.NormalizeRequests))
}
if ser.NormalizeRequests[0].InputTranscriptPath != fallback {
t.Fatalf("input transcript = %q, want %q", ser.NormalizeRequests[0].InputTranscriptPath, fallback)
} }
} }
@@ -74,6 +68,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
env, m, _ := setupNormalizeEnv(t) env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), "not-json") writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), "not-json")
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
_, err := (normalizeStage{}).Run(context.Background(), env, m) _, err := (normalizeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -88,6 +83,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptMissingSegments(t *testing.T)
env, m, _ := setupNormalizeEnv(t) env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"schema":"audita.processed.v1"}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"schema":"audita.processed.v1"}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
_, err := (normalizeStage{}).Run(context.Background(), env, m) _, err := (normalizeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -102,6 +98,7 @@ func TestNormalizeStagePassesConfiguredOutputSchemaToAdapter(t *testing.T) {
env, m, ser := setupNormalizeEnv(t) env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
env.Config.Pipeline.Normalize.OutputSchema = "seriatim-full" env.Config.Pipeline.Normalize.OutputSchema = "seriatim-full"
result, err := (normalizeStage{}).Run(context.Background(), env, m) result, err := (normalizeStage{}).Run(context.Background(), env, m)
@@ -123,6 +120,7 @@ func TestNormalizeStageRecordsNormalizedTranscriptOutputKind(t *testing.T) {
env, m, _ := setupNormalizeEnv(t) env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
result, err := (normalizeStage{}).Run(context.Background(), env, m) result, err := (normalizeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -140,6 +138,7 @@ func TestNormalizeStageRecordsReportLogAndGeneratedConfigRefs(t *testing.T) {
env, m, _ := setupNormalizeEnv(t) env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
report := true report := true
env.Config.Pipeline.Normalize.Report = &report env.Config.Pipeline.Normalize.Report = &report
@@ -166,6 +165,7 @@ func TestNormalizeStageFailsWhenAdapterReturnsError(t *testing.T) {
env, m, ser := setupNormalizeEnv(t) env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
ser.NormalizeErr = errors.New("normalize failed") ser.NormalizeErr = errors.New("normalize failed")
_, err := (normalizeStage{}).Run(context.Background(), env, m) _, err := (normalizeStage{}).Run(context.Background(), env, m)
@@ -181,6 +181,7 @@ func TestNormalizeStageFailsWhenNormalizedOutputInvalid(t *testing.T) {
env, m, ser := setupNormalizeEnv(t) env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
badOutput := filepath.Join(paths.TranscriptsDir, "normalized.bad.json") badOutput := filepath.Join(paths.TranscriptsDir, "normalized.bad.json")
writeFile(t, badOutput, "not-json") writeFile(t, badOutput, "not-json")
ser.NormalizeResult = seriatim.NormalizeResult{OutputNormalizedPath: badOutput} ser.NormalizeResult = seriatim.NormalizeResult{OutputNormalizedPath: badOutput}
@@ -198,6 +199,7 @@ func TestNormalizeStageReportEnabledFailsWhenReportMissing(t *testing.T) {
env, m, ser := setupNormalizeEnv(t) env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
report := true report := true
env.Config.Pipeline.Normalize.Report = &report env.Config.Pipeline.Normalize.Report = &report
ser.NormalizeResult = seriatim.NormalizeResult{ReportPath: filepath.Join(paths.ArtifactsDir, "missing-report.json")} ser.NormalizeResult = seriatim.NormalizeResult{ReportPath: filepath.Join(paths.ArtifactsDir, "missing-report.json")}
@@ -218,6 +220,7 @@ func TestNormalizeStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
m.RunID = "20260518T010203Z-abcdef12" m.RunID = "20260518T010203Z-abcdef12"
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, filepath.Join(paths.TranscriptsDir, "polished.json"))
result, err := (normalizeStage{}).Run(context.Background(), env, m) result, err := (normalizeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {

View File

@@ -21,9 +21,7 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) {
mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") mergedPath := filepath.Join(paths.TranscriptsDir, "base.json")
writeFile(t, mergedPath, `{"segments":[]}`) writeFile(t, mergedPath, `{"segments":[]}`)
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
m.MarkStageSucceeded("merge", time.Now().UTC(), []manifest.ArtifactRecord{ setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, mergedPath)
{Kind: "transcript_base", LocalPath: mergedPath},
})
fake := &audita.FakeRunner{} fake := &audita.FakeRunner{}
env.Audita = fake env.Audita = fake
@@ -84,7 +82,7 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) {
} }
} }
func TestPolishStageFallsBackToMergedTranscriptPath(t *testing.T) { func TestPolishStageRejectsIncidentalMergedTranscriptPath(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") mergedPath := filepath.Join(paths.TranscriptsDir, "base.json")
@@ -95,14 +93,11 @@ func TestPolishStageFallsBackToMergedTranscriptPath(t *testing.T) {
env.Audita = fake env.Audita = fake
_, err := (polishStage{}).Run(context.Background(), env, m) _, err := (polishStage{}).Run(context.Background(), env, m)
if err != nil { if err == nil || !strings.Contains(err.Error(), "merged transcript input is required") {
t.Fatalf("polish.Run() error = %v", err) t.Fatalf("polish.Run() error = %v, want unavailable manifest input", err)
} }
if len(fake.Requests) != 1 { if len(fake.Requests) != 0 {
t.Fatalf("requests = %d, want 1", len(fake.Requests)) t.Fatalf("requests = %d, want 0", len(fake.Requests))
}
if fake.Requests[0].MergedTranscriptPath != mergedPath {
t.Fatalf("merged path = %q, want %q", fake.Requests[0].MergedTranscriptPath, mergedPath)
} }
} }
@@ -125,6 +120,7 @@ func TestPolishStageFailsWhenMergedTranscriptInvalidJSON(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), "not-json") writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), "not-json")
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
env.Audita = &audita.FakeRunner{} env.Audita = &audita.FakeRunner{}
@@ -141,6 +137,7 @@ func TestPolishStageFailsWhenGlossaryMissing(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
env.Audita = &audita.FakeRunner{} env.Audita = &audita.FakeRunner{}
_, err := (polishStage{}).Run(context.Background(), env, m) _, err := (polishStage{}).Run(context.Background(), env, m)
@@ -156,6 +153,7 @@ func TestPolishStageFailsWhenAdapterFails(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
env.Audita = &audita.FakeRunner{Err: errors.New("audita failed")} env.Audita = &audita.FakeRunner{Err: errors.New("audita failed")}
@@ -172,6 +170,7 @@ func TestPolishStageFailsWhenProcessedOutputInvalid(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
badPath := filepath.Join(paths.TranscriptsDir, "processed.invalid.json") badPath := filepath.Join(paths.TranscriptsDir, "processed.invalid.json")
writeFile(t, badPath, `{"schema":"audita.processed.v1","segments":"wrong-type"}`) writeFile(t, badPath, `{"schema":"audita.processed.v1","segments":"wrong-type"}`)
@@ -190,6 +189,7 @@ func TestPolishStageFailsWhenReportInvalid(t *testing.T) {
env, m := setupPolishEnv(t) env, m := setupPolishEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
badReport := filepath.Join(paths.ArtifactsDir, "bad.report.json") badReport := filepath.Join(paths.ArtifactsDir, "bad.report.json")
writeFile(t, badReport, "not-json") writeFile(t, badReport, "not-json")
@@ -211,6 +211,7 @@ func TestPolishStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
m.RunID = "20260518T010203Z-abcdef12" m.RunID = "20260518T010203Z-abcdef12"
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "base.json"), `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, filepath.Join(paths.TranscriptsDir, "base.json"))
writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n")
fake := &audita.FakeRunner{} fake := &audita.FakeRunner{}

View File

@@ -1071,6 +1071,7 @@ func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
for _, name := range publishPrerequisiteStages { for _, name := range publishPrerequisiteStages {
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil) m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
} }
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"))
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md")) setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md"))
env := &Env{ env := &Env{

View File

@@ -44,8 +44,8 @@ func TestRenderStageResolvesManifestInputsFirst(t *testing.T) {
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}}) setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, manifestFinal)
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}}) setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, manifestTrimmed)
result, err := (renderStage{}).Run(context.Background(), env, m) result, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -82,6 +82,8 @@ func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"))
env.Config.Pipeline.Render.Title = "" env.Config.Pipeline.Render.Title = ""
env.Config.Session.Title = "Session Title" env.Config.Session.Title = "Session Title"
@@ -112,6 +114,7 @@ func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) {
env, m, _ := setupRenderEnv(t) env, m, _ := setupRenderEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
_, err := (renderStage{}).Run(context.Background(), env, m) _, err := (renderStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -130,6 +133,8 @@ func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"))
result, err := (renderStage{}).Run(context.Background(), env, m) result, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {

View File

@@ -21,9 +21,7 @@ func TestTrimStageConsumesNormalizedAndProducesTrimmedTranscript(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
normalized := filepath.Join(paths.TranscriptsDir, "final.json") normalized := filepath.Join(paths.TranscriptsDir, "final.json")
writeFile(t, normalized, `{"segments":[{"id":10},{"id":868}]}`) writeFile(t, normalized, `{"segments":[{"id":10},{"id":868}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized)
{Kind: "transcript_final", LocalPath: normalized},
})
scr.BoundsBody = `{"confidence":"high","trim_action":"trim","start_segment_id":10,"end_segment_id":868,"warnings":[]}` scr.BoundsBody = `{"confidence":"high","trim_action":"trim","start_segment_id":10,"end_segment_id":868,"warnings":[]}`
result, err := (trimStage{}).Run(context.Background(), env, m) result, err := (trimStage{}).Run(context.Background(), env, m)
@@ -76,6 +74,7 @@ func TestTrimStageUsesConfiguredScriptoriumInputName(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
normalized := filepath.Join(paths.TranscriptsDir, "final.json") normalized := filepath.Join(paths.TranscriptsDir, "final.json")
writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`) writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized)
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11}`
cfg := env.Config.Pipeline.Trim cfg := env.Config.Pipeline.Trim
@@ -99,6 +98,7 @@ func TestTrimStageRecordsLogAndGeneratedConfigRefs(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}`
result, err := (trimStage{}).Run(context.Background(), env, m) result, err := (trimStage{}).Run(context.Background(), env, m)
@@ -119,6 +119,7 @@ func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}`
cfg := *env.Config.Pipeline.Trim cfg := *env.Config.Pipeline.Trim
@@ -183,6 +184,7 @@ func TestTrimStageFailsWhenNormalizedTranscriptInvalidJSON(t *testing.T) {
env, m, _, _ := setupTrimEnv(t) env, m, _, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), "not-json") writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), "not-json")
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
@@ -196,6 +198,7 @@ func TestTrimStageFailsWhenNormalizedTranscriptMissingSegmentsArray(t *testing.T
env, m, _, _ := setupTrimEnv(t) env, m, _, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"schema":"audita.processed.v1"}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"schema":"audita.processed.v1"}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
@@ -209,6 +212,7 @@ func TestTrimStageFailsWhenBoundsOutputInvalidJSON(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = "not-json" scr.BoundsBody = "not-json"
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -223,6 +227,7 @@ func TestTrimStageFailsWhenBoundsRangeIsDescending(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":2,"end_segment_id":1}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":2,"end_segment_id":1}`
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -237,6 +242,7 @@ func TestTrimStageFailsWhenBoundsIDsMissingFromTranscript(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":20},{"id":21}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":20},{"id":21}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":21}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":21}`
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -251,6 +257,7 @@ func TestTrimStageFailsWhenScriptoriumAdapterFails(t *testing.T) {
env, m, scr, _ := setupTrimEnv(t) env, m, scr, _ := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.RunErr = errors.New("bounds failed") scr.RunErr = errors.New("bounds failed")
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -265,6 +272,7 @@ func TestTrimStageFailsWhenSeriatimTrimAdapterFails(t *testing.T) {
env, m, scr, ser := setupTrimEnv(t) env, m, scr, ser := setupTrimEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`) writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, filepath.Join(paths.TranscriptsDir, "final.json"))
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":1,"end_segment_id":2}`
ser.TrimErr = errors.New("trim failed") ser.TrimErr = errors.New("trim failed")
_, err := (trimStage{}).Run(context.Background(), env, m) _, err := (trimStage{}).Run(context.Background(), env, m)
@@ -282,6 +290,7 @@ func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) {
normalized := filepath.Join(paths.TranscriptsDir, "final.json") normalized := filepath.Join(paths.TranscriptsDir, "final.json")
normalizedBody := `{"segments":[{"id":1,"text":"alpha"},{"id":2,"text":"beta"}]}` normalizedBody := `{"segments":[{"id":1,"text":"alpha"},{"id":2,"text":"beta"}]}`
writeFile(t, normalized, normalizedBody) writeFile(t, normalized, normalizedBody)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized)
disabled := *env.Config.Pipeline.Trim disabled := *env.Config.Pipeline.Trim
enabled := false enabled := false
@@ -316,6 +325,7 @@ func TestTrimStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
normalized := filepath.Join(paths.TranscriptsDir, "final.json") normalized := filepath.Join(paths.TranscriptsDir, "final.json")
writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`) writeFile(t, normalized, `{"segments":[{"id":10},{"id":11}]}`)
setCurrentBuiltInEvidence(t, m, "normalize", artifacts.TranscriptOutputKindFinal, artifacts.ArtifactTranscriptFinal, normalized)
scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11,"warnings":[]}` scr.BoundsBody = `{"trim_action":"trim","start_segment_id":10,"end_segment_id":11,"warnings":[]}`
result, err := (trimStage{}).Run(context.Background(), env, m) result, err := (trimStage{}).Run(context.Background(), env, m)