diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index f803b8c..cfa0709 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -47,22 +47,26 @@ kind and prepared filename vocabulary. - `planned`: source registered for run context; - `executable`: included in the effective analyze artifact set; -- `available`: local file exists and validates; +- `available`: the source's canonical evidence owner validates its current + manifest record and durable bytes; - `provenance`: availability source. Configured definitions are always registered. Without an explicit selection, the effective analyze set contains enabled definitions. With `--artifacts`, the exact named configured definitions become the effective set for that invocation, regardless of their `enabled` value; dependencies are not added implicitly. -Availability is separate from executability: a non-executable configured output -may be reused from a canonical non-empty file, while an executable definition -is generated by analyze. Extraction entries are registered from configuration -and become available only after compatible extraction evidence is hydrated. +Availability is separate from executability. Configured outputs, including +non-executable prerequisites, become available only when the versioned analyze +state identifies a current result whose source, contract, canonical configured +path, size, and checksum match a confined no-follow regular file. An incidental +canonical file and a legacy aggregate analyze output are unavailable. +Extraction entries are registered from configuration and become available only +after compatible extraction evidence is hydrated. Current provenance values: - `generated.current_analyze_run` -- `filesystem.disabled_artifact_output` +- `manifest.current_analyze_artifact` - `manifest.inputs.previous_cache` - `current_session.previous_cache` @@ -75,7 +79,17 @@ Built-ins: Configured sources (`narratio.artifact.*`): -- resolve only through runtime catalog availability. +- resolve only through runtime catalog availability; +- use the shared typed analyze-evidence inspection in + `analyze_evidence.go` for prior current-session results; +- require the supported analyze-state and fingerprint versions, a `current` + record for the exact configured key and source ID, a complete contract, the + configured canonical relative path, positive stored size, and stored + checksum matching bytes read from a confined no-follow regular file; and +- treat non-current statuses, legacy or malformed records, removed keys, + unsafe or missing files, and size/checksum mismatches as unavailable without + rewriting manifest state. Catalog construction iterates current + configuration, so removed or renamed records are not advertised. Prepared stable sources (`narratio.input.*`): diff --git a/docs/internal/stage-publish.md b/docs/internal/stage-publish.md index 3e86d23..38aae48 100644 --- a/docs/internal/stage-publish.md +++ b/docs/internal/stage-publish.md @@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in checks a declared checksum when present, then streams the opened descriptor. - derives the durable previous-cache archive from its validated manifest using the same confinement and regular-file checks. -- resolves publish output sources through runtime artifact catalog and manifest-aware resolution. +- 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. - 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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 449ae3c..c4c31d3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -409,6 +409,8 @@ history through the existing application-owned runner transaction. ## Stage 8 — Manifest-Authoritative Configured Artifact Evidence +**Status: Completed** + ### Goal Make configured analysis outputs available to analyze and publish only through diff --git a/internal/app/analyze_evidence_test_helpers_test.go b/internal/app/analyze_evidence_test_helpers_test.go new file mode 100644 index 0000000..94d8a49 --- /dev/null +++ b/internal/app/analyze_evidence_test_helpers_test.go @@ -0,0 +1,37 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func setAppAnalyzeEvidence(m *manifest.Manifest, key, relativePath string, body []byte) { + now := time.Date(2026, 5, 19, 23, 0, 0, 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{} + } + digest := sha256.Sum256(body) + 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: hex.EncodeToString(digest[:]), + }, + OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now, + } +} diff --git a/internal/app/operator_artifact_rendering.go b/internal/app/operator_artifact_rendering.go index c8db617..8d1104b 100644 --- a/internal/app/operator_artifact_rendering.go +++ b/internal/app/operator_artifact_rendering.go @@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled { catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions) } + catalog.HydrateAnalyzeArtifacts(paths, m, configured) return catalog, nil } @@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet) fmt.Fprintln(out, "Configured:") for _, entry := range catalog.ListConfigured() { - writeArtifactLine(out, entry.SourceID, lockSet) + state := "unavailable" + if entry.Available { + state = "available" + } + writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet) } fmt.Fprintln(out, "Extraction:") for _, entry := range catalog.ListExtraction() { diff --git a/internal/app/operator_artifact_rendering_test.go b/internal/app/operator_artifact_rendering_test.go new file mode 100644 index 0000000..f298598 --- /dev/null +++ b/internal/app/operator_artifact_rendering_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestBuildHelperArtifactCatalogUsesAnalyzeManifestEvidence(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{ + Pipeline: &config.PipelineConfig{ + Workspace: config.WorkspaceConfig{Root: root}, + Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }}, + }, + Session: &config.SessionConfig{Campaign: "campaign", SessionID: "session"}, + } + paths := artifacts.NewLocalStore(root).SessionPathsFor("campaign", "session") + outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + t.Fatal(err) + } + body := []byte("# recap\n") + if err := os.WriteFile(outputPath, body, 0o644); err != nil { + t.Fatal(err) + } + m := manifest.New("session", time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)) + + incidental, err := buildHelperArtifactCatalog(cfg, m) + if err != nil { + t.Fatal(err) + } + entry, _ := incidental.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap")) + if entry.Available { + t.Fatal("operator catalog advertised incidental configured artifact") + } + + setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", body) + current, err := buildHelperArtifactCatalog(cfg, m) + if err != nil { + t.Fatal(err) + } + entry, _ = current.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap")) + if !entry.Available || entry.Provenance != artifacts.ArtifactProvenanceCurrentAnalyzeManifest { + t.Fatalf("operator catalog entry = %#v", entry) + } +} diff --git a/internal/app/post_publish_cleanup_test.go b/internal/app/post_publish_cleanup_test.go index 4d1d04e..d24d955 100644 --- a/internal/app/post_publish_cleanup_test.go +++ b/internal/app/post_publish_cleanup_test.go @@ -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) } + 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) if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil { diff --git a/internal/app/restore_workflow_test.go b/internal/app/restore_workflow_test.go index f9cbe5d..784cd56 100644 --- a/internal/app/restore_workflow_test.go +++ b/internal/app/restore_workflow_test.go @@ -282,6 +282,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt for i, stageName := range stages { m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil) } + setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n")) path := filepath.Join(t.TempDir(), "manifest.json") if err := store.Save(context.Background(), path, m); err != nil { t.Fatalf("save workflow manifest fixture: %v", err) diff --git a/internal/artifacts/analyze_evidence.go b/internal/artifacts/analyze_evidence.go new file mode 100644 index 0000000..0af9ca1 --- /dev/null +++ b/internal/artifacts/analyze_evidence.go @@ -0,0 +1,134 @@ +package artifacts + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/fileops" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" +) + +// AnalyzeEvidenceState distinguishes a verified current configured artifact +// from every form of unavailable evidence. +type AnalyzeEvidenceState string + +const ( + AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current" + AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current" +) + +// AnalyzeEvidence is the read-only result of inspecting one configured +// artifact's manifest record and durable output. +type AnalyzeEvidence struct { + State AnalyzeEvidenceState + Reason string + SourceID string + Path string + ProducerRunID string +} + +// InspectAnalyzeEvidence verifies that one configured artifact has supported, +// current manifest evidence for the exact canonical bytes on disk. +func InspectAnalyzeEvidence( + paths SessionPaths, + m *manifest.Manifest, + key string, + configured ConfiguredArtifactDefinition, +) AnalyzeEvidence { + normalizedKey := strings.TrimSpace(key) + sourceID := ConfiguredArtifactSourceID(normalizedKey) + nonCurrent := func(reason string) AnalyzeEvidence { + return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID} + } + + if m == nil { + return nonCurrent("analyze manifest evidence is absent; regenerate the artifact") + } + stageRecord := m.Stages["analyze"] + if stageRecord == nil || stageRecord.Name != "analyze" { + return nonCurrent("analyze manifest evidence is absent; regenerate the artifact") + } + if !stageRecord.HasVersionedAnalyzeState() { + return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact") + } + record, ok := stageRecord.AnalyzeArtifacts[normalizedKey] + if !ok { + return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact") + } + if record.Status != manifest.AnalyzeArtifactCurrent { + return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status)) + } + if err := manifest.ValidateAnalyzeArtifactCollection( + stageRecord.AnalyzeStateVersion, + map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record}, + ); err != nil { + return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact") + } + + configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath)) + if err != nil { + return nonCurrent("configured artifact output path is unsafe; correct the configuration") + } + if record.Output.LocalPath != configuredPath { + return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact") + } + if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" { + return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact") + } + + file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath) + if err != nil { + return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact") + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact") + } + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return nonCurrent("configured artifact output could not be verified; regenerate the artifact") + } + if size != info.Size() || size != record.OutputSize { + return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact") + } + if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum { + return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact") + } + + return AnalyzeEvidence{ + State: AnalyzeEvidenceCurrent, + SourceID: sourceID, + Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)), + ProducerRunID: record.ProducerRunID, + } +} + +// HydrateAnalyzeArtifacts makes configured sources available only from +// validated current manifest evidence. It never mutates manifest state. +func (c *ArtifactCatalog) HydrateAnalyzeArtifacts( + paths SessionPaths, + m *manifest.Manifest, + configured map[string]ConfiguredArtifactDefinition, +) { + if c == nil || len(configured) == 0 { + return + } + for _, entry := range c.ListConfigured() { + definition, ok := configured[entry.ConfiguredKey] + if !ok { + continue + } + evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition) + if evidence.State != AnalyzeEvidenceCurrent { + continue + } + _ = c.markAvailableFromAnalyzeManifest(evidence.SourceID, evidence.Path, evidence.ProducerRunID) + } +} diff --git a/internal/artifacts/analyze_evidence_test.go b/internal/artifacts/analyze_evidence_test.go new file mode 100644 index 0000000..df4f3fb --- /dev/null +++ b/internal/artifacts/analyze_evidence_test.go @@ -0,0 +1,222 @@ +package artifacts + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) { + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + body := []byte("recap\n") + m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body) + + got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"}) + if got.State != AnalyzeEvidenceCurrent { + t.Fatalf("State = %q, reason = %q", got.State, got.Reason) + } + if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" { + t.Fatalf("evidence = %#v", got) + } +} + +func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) { + tests := []struct { + name string + mutate func(*manifest.Manifest) + config ConfiguredArtifactDefinition + reason string + }{ + {name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"}, + {name: "legacy", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeStateVersion = 0 + m.Stages["analyze"].AnalyzeArtifacts = nil + }, reason: "legacy"}, + {name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"}, + {name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"}, + {name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`}, + {name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`}, + {name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`}, + {name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`}, + {name: "fingerprint version", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ }) + }, reason: "malformed"}, + {name: "key mismatch", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" }) + }, reason: "malformed"}, + {name: "source mismatch", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") }) + }, reason: "malformed"}, + {name: "missing contract", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil }) + }, reason: "malformed"}, + {name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"}, + {name: "record path mismatch", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" }) + }, reason: "differs from current configuration"}, + {name: "size mismatch", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ }) + }, reason: "size differs"}, + {name: "checksum mismatch", mutate: func(m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) }) + }, reason: "checksum differs"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) + if test.mutate != nil { + test.mutate(m) + } + definition := test.config + if definition.OutputPath == "" { + definition.OutputPath = "artifacts/session_recap.md" + } + got := InspectAnalyzeEvidence(paths, m, "session_recap", definition) + if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) { + t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason) + } + }) + } +} + +func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) { + tests := []struct { + name string + alter func(t *testing.T, paths SessionPaths, outputPath string) + }{ + {name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) { + if err := os.Remove(outputPath); err != nil { + t.Fatal(err) + } + }}, + {name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) { + if err := os.Remove(outputPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(outputPath, 0o755); err != nil { + t.Fatal(err) + } + }}, + {name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) { + if err := os.Remove(outputPath); err != nil { + t.Fatal(err) + } + target := filepath.Join(paths.Root, "target.md") + if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, outputPath); err != nil { + t.Fatal(err) + } + }}, + {name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) { + if err := os.RemoveAll(paths.ArtifactsDir); err != nil { + t.Fatal(err) + } + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, paths.ArtifactsDir); err != nil { + t.Fatal(err) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) + outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") + test.alter(t, paths, outputPath) + got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"}) + if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") { + t.Fatalf("evidence = %#v", got) + } + }) + } +} + +func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) { + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n")) + m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n")) + configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}} + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil { + t.Fatal(err) + } + catalog.HydrateAnalyzeArtifacts(paths, m, configured) + entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap")) + if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" { + t.Fatalf("entry = %#v", entry) + } + if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok { + t.Fatal("removed manifest record was advertised in current catalog") + } +} + +func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest { + t.Helper() + record := analyzeEvidenceRecord(t, paths, key, relativePath, body) + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + m := manifest.New(paths.SessionID, now) + m.Stages["analyze"] = &manifest.StageRecord{ + Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now, + AnalyzeStateVersion: manifest.AnalyzeStateContractVersion, + AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record}, + } + return m +} + +func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord { + t.Helper() + outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(outputPath, body, 0o644); err != nil { + t.Fatal(err) + } + checksum, err := SHA256File(outputPath) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + return manifest.AnalyzeArtifactRecord{ + Key: key, Status: manifest.AnalyzeArtifactCurrent, + FingerprintVersion: manifest.AnalyzeFingerprintContractVersion, + Fingerprint: strings.Repeat("1", 64), + Output: &manifest.ArtifactRecord{ + Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath, + Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"}, + ProducerRunID: "run-1", Checksum: checksum, + }, + OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now, + } +} + +func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) { + return func(m *manifest.Manifest) { + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] + record.Status = status + record.Output = nil + record.OutputSize = 0 + if status == manifest.AnalyzeArtifactFailed { + record.Error = "generation failed" + } + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record + } +} + +func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord { + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] + mutate(&record) + return record +} diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index d2fd5d1..d2149a0 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin } } -func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) { +func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") @@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } sourceID := ConfiguredArtifactSourceID("player_handout") - if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil { - t.Fatalf("MarkAvailableFromDisk() error = %v", err) - } + m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n")) + catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{ + "player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"}, + }) - resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog) + resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog) if err != nil { t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) } - if resolved.Provenance != ArtifactProvenanceDisabledFromDisk { - t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk) + if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest { + t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest) } } diff --git a/internal/artifacts/catalog.go b/internal/artifacts/catalog.go index 9114094..c8cf41d 100644 --- a/internal/artifacts/catalog.go +++ b/internal/artifacts/catalog.go @@ -11,7 +11,7 @@ import ( const ( ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run" - ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output" + ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact" ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run" ) @@ -282,11 +282,6 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error { return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun) } -// MarkAvailableFromDisk marks one source as available from disabled artifact on disk. -func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error { - return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk) -} - func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error { if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil { return err @@ -297,6 +292,16 @@ func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, produ return nil } +func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(sourceID, path, producerRunID string) error { + if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil { + return err + } + entry := c.entries[strings.TrimSpace(sourceID)] + entry.ProducerRunID = strings.TrimSpace(producerRunID) + c.entries[entry.SourceID] = entry + return nil +} + func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error { if c == nil { return fmt.Errorf("artifact catalog is nil") diff --git a/internal/artifacts/catalog_test.go b/internal/artifacts/catalog_test.go index 84ff8d0..8254bd5 100644 --- a/internal/artifacts/catalog_test.go +++ b/internal/artifacts/catalog_test.go @@ -179,30 +179,6 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) { } } -func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) { - catalog := NewArtifactCatalog() - if err := catalog.RegisterConfiguredArtifacts( - map[string]ConfiguredArtifactDefinition{ - "session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"}, - }, - nil, - ); err != nil { - t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) - } - - sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap") - if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil { - t.Fatalf("MarkAvailableFromDisk() error = %v", err) - } - entry, _ := catalog.Lookup(sourceID) - if !entry.Available { - t.Fatalf("entry.Available = false, want true") - } - if entry.Provenance != ArtifactProvenanceDisabledFromDisk { - t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk) - } -} - func TestArtifactCatalogLookupPlannedButUnavailable(t *testing.T) { catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 1ae63d9..37bb083 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -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 } diff --git a/internal/stage/analyze_evidence_test_helpers_test.go b/internal/stage/analyze_evidence_test_helpers_test.go new file mode 100644 index 0000000..28725f5 --- /dev/null +++ b/internal/stage/analyze_evidence_test_helpers_test.go @@ -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, + } +} diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index aff412e..a56a006 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -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) } } diff --git a/internal/stage/publish.go b/internal/stage/publish.go index 72ffe58..8f554f3 100644 --- a/internal/stage/publish.go +++ b/internal/stage/publish.go @@ -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") diff --git a/internal/stage/publish_test.go b/internal/stage/publish_test.go index aac9b1a..45feeb3 100644 --- a/internal/stage/publish_test.go +++ b/internal/stage/publish_test.go @@ -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{