Make configured artifacts manifest authoritative
This commit is contained in:
@@ -357,7 +357,7 @@ func executeAnalyzeArtifact(
|
||||
default:
|
||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
|
||||
}
|
||||
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
|
||||
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
||||
"source_id": resolution.Artifact.ID,
|
||||
@@ -738,25 +738,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
|
||||
if notariusCfg != nil && notariusCfg.Enabled {
|
||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||
}
|
||||
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
if entry.Executable {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
|
||||
continue
|
||||
}
|
||||
resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
45
internal/stage/analyze_evidence_test_helpers_test.go
Normal file
45
internal/stage/analyze_evidence_test_helpers_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relativePath, absolutePath string) {
|
||||
t.Helper()
|
||||
checksum, err := artifacts.SHA256File(absolutePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC)
|
||||
record := m.Stages["analyze"]
|
||||
if record == nil {
|
||||
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
|
||||
m.Stages["analyze"] = record
|
||||
}
|
||||
if record.AnalyzeArtifacts == nil {
|
||||
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
|
||||
}
|
||||
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
|
||||
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||
Fingerprint: strings.Repeat("1", 64),
|
||||
Output: &manifest.ArtifactRecord{
|
||||
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
|
||||
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||
ProducerRunID: "run-1", Checksum: checksum,
|
||||
},
|
||||
OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,7 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
|
||||
Enabled: false,
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
}
|
||||
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||
|
||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
@@ -481,6 +482,25 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDoesNotResolveIncidentalConfiguredArtifactFile(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), "handout\n")
|
||||
|
||||
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{Source: "narratio.artifact.player_handout", Required: true}
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: false, OutputPath: "artifacts/player_handout.md",
|
||||
}
|
||||
|
||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `"narratio.artifact.player_handout" is unavailable`) {
|
||||
t.Fatalf("Run() error = %v, want incidental artifact unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
@@ -498,6 +518,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||
Enabled: false,
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
}
|
||||
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
@@ -545,8 +566,8 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||
if r0["path"] != playerHandoutPath {
|
||||
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
||||
}
|
||||
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
|
||||
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
|
||||
if r0["provenance"] != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceCurrentAnalyzeManifest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -839,51 +839,11 @@ func buildPublishRuntimeArtifactCatalog(
|
||||
if notariusCfg != nil && notariusCfg.Enabled {
|
||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||
}
|
||||
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
|
||||
continue
|
||||
}
|
||||
localPath, err := resolveConfiguredArtifactLocalPath(paths, entry.CanonicalRelPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Stat(localPath)
|
||||
if statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("stat configured artifact %q: %w", entry.SourceID, statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := catalog.MarkAvailableFromDisk(entry.SourceID, localPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured string) (string, error) {
|
||||
outputPath := strings.TrimSpace(configured)
|
||||
if outputPath == "" {
|
||||
return "", fmt.Errorf("configured artifact output path is required")
|
||||
}
|
||||
if filepath.IsAbs(outputPath) {
|
||||
return filepath.Clean(outputPath), nil
|
||||
}
|
||||
rel := filepath.Clean(outputPath)
|
||||
if rel == "." || rel == "" {
|
||||
return "", fmt.Errorf("relative output path is required")
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("relative output path escapes session root: %q", configured)
|
||||
}
|
||||
return filepath.Join(paths.Root, rel), nil
|
||||
}
|
||||
|
||||
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
|
||||
if runManifest == nil {
|
||||
return nil, fmt.Errorf("run manifest is required")
|
||||
|
||||
@@ -607,6 +607,54 @@ func TestPublishSelectedConfiguredOutputStillFailsWhenMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDoesNotSelectIncidentalConfiguredArtifactFile(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
m.Stages["analyze"].AnalyzeStateVersion = 0
|
||||
m.Stages["analyze"].AnalyzeArtifacts = nil
|
||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||
|
||||
_, err := publishStage{}.Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.artifact.session_recap"`) {
|
||||
t.Fatalf("Run() error = %v, want incidental configured artifact unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishOmitsStaleConfiguredArtifactAndPublishesUnrelatedCurrentArtifact(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
paths := publishSessionPaths(env, m)
|
||||
handoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||
writeStageTestFile(t, handoutPath, "# handout\n")
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, OutputPath: "artifacts/player_handout.md",
|
||||
}
|
||||
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", handoutPath)
|
||||
recap := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||
recap.Status = manifest.AnalyzeArtifactStale
|
||||
recap.Output = nil
|
||||
recap.OutputSize = 0
|
||||
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = recap
|
||||
env.SelectedArtifactKeys = []string{"session_recap", "player_handout"}
|
||||
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(false)},
|
||||
{Source: "narratio.artifact.player_handout", Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
|
||||
}
|
||||
|
||||
result, err := publishStage{}.Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")]; ok {
|
||||
t.Fatal("stale configured artifact was uploaded")
|
||||
}
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/player_handout.md")]; !ok {
|
||||
t.Fatal("unrelated current configured artifact was not uploaded")
|
||||
}
|
||||
if got := result.Metadata["skipped_optional_outputs"].([]string); !reflect.DeepEqual(got, []string{"artifacts/session_recap.md"}) {
|
||||
t.Fatalf("skipped_optional_outputs = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||
@@ -946,10 +994,12 @@ func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
|
||||
|
||||
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
writeStageTestFile(t, filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md"), "# other recap\n")
|
||||
otherPath := filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md")
|
||||
writeStageTestFile(t, otherPath, "# other recap\n")
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
|
||||
}
|
||||
setCurrentAnalyzeEvidence(t, m, "other_recap", "reports/session_recap.md", otherPath)
|
||||
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
|
||||
{Source: "narratio.artifact.other_recap", Dest: "published/second/session_recap.md", Required: boolPtr(true)},
|
||||
@@ -1024,6 +1074,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)
|
||||
}
|
||||
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md"))
|
||||
|
||||
env := &Env{
|
||||
Config: &config.Config{
|
||||
|
||||
Reference in New Issue
Block a user