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.bounds.session`
Registry entries bind each ID to its producer, output kind, canonical fallback,
and content validator. The focused stage documents own their input/output flow;
Registry entries bind each ID to its producer, output kind, and content
validator. The focused stage documents own their input/output flow;
[Configuration](../config.md) owns where operators may select these IDs.
## Configured, Extraction, And Previous-Session Sources
@@ -91,8 +91,13 @@ Current provenance values:
Built-ins:
1. manifest producer outputs (when present)
2. canonical session-path fallback
- resolve only from the current manifest's succeeded registered producer;
- 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.*`):

View File

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

View File

@@ -102,8 +102,7 @@ Canonical stage order:
Execution rules:
- succeeded stages are skipped unless `--force` is set; stages with semantic
configuration contracts additionally require matching versioned evidence,
and missing legacy evidence causes a safe one-time rerun;
configuration contracts additionally require matching versioned evidence;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- forcing a stage marks succeeded transitive dependents as `stale` before the
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
**Status: Pending**
**Status: Completed**
### Goal

View File

@@ -428,6 +428,25 @@ narratio analyze 2026-04-04 --artifacts session_recap
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
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
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"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -126,7 +127,9 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
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 stderr bytes.Buffer
@@ -247,7 +250,9 @@ inputs:
})
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")
var stdout bytes.Buffer

View File

@@ -156,7 +156,7 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
if err != nil {
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))
}
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)
}
rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
t.Fatalf("rerun executeStages() error = %v", err)
_, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
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 {
t.Fatalf("rerun summary = %#v requests=%d analyze=%d", rerun, len(runner.requests), analyzeRuns)
if len(runner.requests) != 1 || analyzeRuns != 1 {
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 {
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 {
@@ -738,9 +755,13 @@ func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *st
}
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
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{{
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 {
t.Fatalf("Save(seed) error = %v", err)

View File

@@ -1276,13 +1276,32 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
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.md"), "# final\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 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.Campaign = "sample-campaign"
trimmedChecksum, err := artifacts.SHA256File(trimmedPath)
if err != nil {
t.Fatal(err)
}
m.Stages["trim"] = &manifest.StageRecord{
Name: "trim", Status: manifest.StatusSucceeded,
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)

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"} {
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"))
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
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)
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"))
if err != nil {
t.Fatalf("read restored previous manifest: %v", err)

View File

@@ -10,6 +10,7 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -121,7 +122,9 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
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")
var out bytes.Buffer
@@ -172,11 +175,13 @@ func TestRunStageSkipAndForce(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
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")
store := &manifest.LocalStore{}
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)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish")
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)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
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{}
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"} {
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")
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
@@ -257,7 +265,9 @@ func TestRunStageTrimExecutes(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
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
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)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
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
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)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`)
finalPath := filepath.Join(workRoot, "transcripts", "final.json")
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
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
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
@@ -178,8 +180,8 @@ func PreviousSessionArtifactName(source string) (string, bool) {
return artifactpolicy.ParsePreviousSessionSource(source)
}
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
// Resolution order is manifest producer outputs first, then canonical session path fallback.
// ResolveSessionArtifact resolves a symbolic source from its unique current
// producer output declaration in the session manifest.
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
id, err := NormalizeSessionArtifactSource(source)
if err != nil {
@@ -187,43 +189,21 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
}
spec := artifactRegistry[id]
for _, candidate := range manifestArtifactCandidates(paths, m, spec) {
exists, isDir, statErr := pathExists(candidate.Path)
if statErr != nil {
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
candidate, err := manifestArtifactCandidate(paths, m, spec)
if err != nil {
return ResolvedSessionArtifact{}, err
}
fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath))
exists, isDir, statErr := pathExists(fallbackPath)
if statErr != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr)
if err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
}
if exists && !isDir {
if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %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
actualChecksum, err := SHA256File(candidate.Path)
if err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err)
}
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
if actualChecksum != candidate.Checksum {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", spec.ID)
}
return candidate, nil
}
// ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves
@@ -334,52 +314,74 @@ func ResolvePreviousSessionArtifactWithCatalog(
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact {
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" {
return nil
func manifestArtifactCandidate(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) (ResolvedSessionArtifact, error) {
if m == nil || spec.ProducerStage == "" || spec.OutputKind == "" {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
sr := m.Stages[spec.ProducerStage]
if sr == nil {
return nil
if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
}
candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs))
var selected manifest.ArtifactRecord
count := 0
for _, out := range sr.Outputs {
if strings.TrimSpace(out.Kind) != spec.OutputKind {
continue
if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID {
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 {
seen := map[string]struct{}{}
out := make([]ResolvedSessionArtifact, 0, len(values))
for _, value := range values {
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)
func requireSafeSessionRegularFile(root, path string) error {
relative, err := pathsafe.SlashRelativeFromRoot(root, path)
if err != nil {
return err
}
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 {

View File

@@ -187,7 +187,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
m := manifest.New("session", time.Now().UTC())
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)
@@ -205,7 +205,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
}
}
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
func TestResolveSessionArtifactRejectsIncidentalCanonicalPath(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
@@ -216,19 +216,13 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", 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)
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
if !errors.Is(err, ErrSessionArtifactNotFound) {
t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
}
}
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
func TestResolveSessionArtifactRejectsIncidentalCanonicalMarkdownPath(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
@@ -239,15 +233,9 @@ func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", 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)
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
if !errors.Is(err, ErrSessionArtifactNotFound) {
t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
}
}
@@ -275,7 +263,8 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
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 {
t.Fatal("expected error, got nil")
}
@@ -295,7 +284,8 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
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 {
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()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
@@ -315,18 +305,39 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T)
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 {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", 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)
if resolved.Provenance != "manifest.trim.outputs" {
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) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -93,6 +94,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
name string
state ExtractionEvidenceState
recomputeInputIdentity bool
wantInputUnavailable bool
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) {
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"}]}`)
}},
{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)
if test.recomputeInputIdentity {
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 {
t.Fatal(err)
}
@@ -271,7 +279,7 @@ func validExtractionCatalogFixture(t *testing.T) (SessionPaths, *manifest.Manife
m.Stages["trim"] = &manifest.StageRecord{
Name: "trim", Status: manifest.StatusSucceeded,
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)

View File

@@ -43,3 +43,15 @@ func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relative
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 {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, `{"segments":[]}`)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path)
return path
},
wantOutputKind: "transcript_final_trimmed",
@@ -47,6 +48,7 @@ func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) {
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
paths := sessionPathsForEnv(env, m.SessionID)
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"]
},
wantOutputKind: "notarius_lane",
@@ -177,6 +179,9 @@ func TestResolveAnalyzeInputIdentitiesRejectsUnsafePathsAndFileTypes(t *testing.
setup: func(t *testing.T, env *Env, m *manifest.Manifest) {
t.Helper()
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 {
t.Fatal(err)
}
@@ -213,6 +218,7 @@ func TestAnalyzeInputIdentityIsStableAcrossWorkspaceRelocationAndChangesWithByte
env, m, _ := setupAnalyzeEnv(t)
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, body)
setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, path)
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
}, newAnalyzeIdentityExecution(t, env, m))

View File

@@ -151,7 +151,6 @@ func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) {
analyzeReconciliationStale,
analyzeReconciliationMissing,
analyzeReconciliationFailed,
analyzeReconciliationLegacy,
analyzeReconciliationNonResumable,
}
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{
"target": {Enabled: true}, "legacy_unselected": {Enabled: true},
}
reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{
"target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy,
"target": analyzeReconciliationNonResumable, "legacy_unselected": analyzeReconciliationNonResumable,
}, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil {
@@ -258,7 +257,7 @@ func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
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"
analyzeReconciliationMissing analyzeReconciliationReason = "missing"
analyzeReconciliationFailed analyzeReconciliationReason = "failed"
analyzeReconciliationLegacy analyzeReconciliationReason = "legacy"
analyzeReconciliationRemoved analyzeReconciliationReason = "removed"
analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable"
)
@@ -109,8 +108,8 @@ func reconcileAnalyzeArtifact(
return result
}
if stageRecord.AnalyzeStateVersion == 0 {
result.Reason = analyzeReconciliationLegacy
result.Detail = "analyze manifest uses aggregate-only legacy state"
result.Reason = analyzeReconciliationNonResumable
result.Detail = "analyze manifest state contract is absent"
return result
}
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) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil

View File

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

View File

@@ -30,7 +30,7 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
paths := sessionPathsForEnv(env, sessionID)
input, err := artifacts.ResolveExtractionInputIdentity(paths, m)
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)
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.Campaign = campaign
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{{
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed,
LocalPath: inputPath,
LocalPath: inputPath, ProducerRunID: "trim-run", Checksum: inputChecksum,
}})
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")
writeFile(t, manifestProcessed, `{"segments":[{"id":10}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[{"id":99}]}`)
m.MarkStageSucceeded("polish", time.Now().UTC(), []manifest.ArtifactRecord{{
Kind: "transcript_polished",
LocalPath: manifestProcessed,
}})
setCurrentBuiltInEvidence(t, m, "polish", artifacts.TranscriptOutputKindPolished, artifacts.ArtifactTranscriptPolished, manifestProcessed)
result, err := (normalizeStage{}).Run(context.Background(), env, m)
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)
paths := sessionPathsForEnv(env, m.SessionID)
fallback := filepath.Join(paths.TranscriptsDir, "polished.json")
writeFile(t, fallback, `{"segments":[{"id":1}]}`)
_, err := (normalizeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("normalize.Run() error = %v", err)
if err == nil || !strings.Contains(err.Error(), "processed transcript input is required") {
t.Fatalf("normalize.Run() error = %v, want unavailable manifest input", err)
}
if len(ser.NormalizeRequests) != 1 {
t.Fatalf("normalize requests = %d, want 1", len(ser.NormalizeRequests))
}
if ser.NormalizeRequests[0].InputTranscriptPath != fallback {
t.Fatalf("input transcript = %q, want %q", ser.NormalizeRequests[0].InputTranscriptPath, fallback)
if len(ser.NormalizeRequests) != 0 {
t.Fatalf("normalize requests = %d, want 0", len(ser.NormalizeRequests))
}
}
@@ -74,6 +68,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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)
if err == nil {
@@ -88,6 +83,7 @@ func TestNormalizeStageFailsWhenProcessedTranscriptMissingSegments(t *testing.T)
env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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)
if err == nil {
@@ -102,6 +98,7 @@ func TestNormalizeStagePassesConfiguredOutputSchemaToAdapter(t *testing.T) {
env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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"
result, err := (normalizeStage{}).Run(context.Background(), env, m)
@@ -123,6 +120,7 @@ func TestNormalizeStageRecordsNormalizedTranscriptOutputKind(t *testing.T) {
env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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)
if err != nil {
@@ -140,6 +138,7 @@ func TestNormalizeStageRecordsReportLogAndGeneratedConfigRefs(t *testing.T) {
env, m, _ := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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
env.Config.Pipeline.Normalize.Report = &report
@@ -166,6 +165,7 @@ func TestNormalizeStageFailsWhenAdapterReturnsError(t *testing.T) {
env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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")
_, err := (normalizeStage{}).Run(context.Background(), env, m)
@@ -181,6 +181,7 @@ func TestNormalizeStageFailsWhenNormalizedOutputInvalid(t *testing.T) {
env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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")
writeFile(t, badOutput, "not-json")
ser.NormalizeResult = seriatim.NormalizeResult{OutputNormalizedPath: badOutput}
@@ -198,6 +199,7 @@ func TestNormalizeStageReportEnabledFailsWhenReportMissing(t *testing.T) {
env, m, ser := setupNormalizeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
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
env.Config.Pipeline.Normalize.Report = &report
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"
paths := sessionPathsForEnv(env, m.SessionID)
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)
if err != nil {

View File

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

View File

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

View File

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