Make configured artifacts manifest authoritative
This commit is contained in:
@@ -47,22 +47,26 @@ kind and prepared filename vocabulary.
|
|||||||
|
|
||||||
- `planned`: source registered for run context;
|
- `planned`: source registered for run context;
|
||||||
- `executable`: included in the effective analyze artifact set;
|
- `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.
|
- `provenance`: availability source.
|
||||||
|
|
||||||
Configured definitions are always registered. Without an explicit selection,
|
Configured definitions are always registered. Without an explicit selection,
|
||||||
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
||||||
exact named configured definitions become the effective set for that invocation,
|
exact named configured definitions become the effective set for that invocation,
|
||||||
regardless of their `enabled` value; dependencies are not added implicitly.
|
regardless of their `enabled` value; dependencies are not added implicitly.
|
||||||
Availability is separate from executability: a non-executable configured output
|
Availability is separate from executability. Configured outputs, including
|
||||||
may be reused from a canonical non-empty file, while an executable definition
|
non-executable prerequisites, become available only when the versioned analyze
|
||||||
is generated by analyze. Extraction entries are registered from configuration
|
state identifies a current result whose source, contract, canonical configured
|
||||||
and become available only after compatible extraction evidence is hydrated.
|
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:
|
Current provenance values:
|
||||||
|
|
||||||
- `generated.current_analyze_run`
|
- `generated.current_analyze_run`
|
||||||
- `filesystem.disabled_artifact_output`
|
- `manifest.current_analyze_artifact`
|
||||||
- `manifest.inputs.previous_cache`
|
- `manifest.inputs.previous_cache`
|
||||||
- `current_session.previous_cache`
|
- `current_session.previous_cache`
|
||||||
|
|
||||||
@@ -75,7 +79,17 @@ Built-ins:
|
|||||||
|
|
||||||
Configured sources (`narratio.artifact.*`):
|
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.*`):
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
|
|||||||
checks a declared checksum when present, then streams the opened descriptor.
|
checks a declared checksum when present, then streams the opened descriptor.
|
||||||
- derives the durable previous-cache archive from its validated manifest using
|
- derives the durable previous-cache archive from its validated manifest using
|
||||||
the same confinement and regular-file checks.
|
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;
|
- publishes extraction lanes only through explicit configured output rules;
|
||||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||||
- selected artifact filter applies to configured artifact sources only.
|
- selected artifact filter applies to configured artifact sources only.
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ history through the existing application-owned runner transaction.
|
|||||||
|
|
||||||
## Stage 8 — Manifest-Authoritative Configured Artifact Evidence
|
## Stage 8 — Manifest-Authoritative Configured Artifact Evidence
|
||||||
|
|
||||||
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
Make configured analysis outputs available to analyze and publish only through
|
Make configured analysis outputs available to analyze and publish only through
|
||||||
|
|||||||
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
|
|||||||
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
for _, entry := range catalog.ListConfigured() {
|
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:")
|
fmt.Fprintln(out, "Extraction:")
|
||||||
for _, entry := range catalog.ListExtraction() {
|
for _, entry := range catalog.ListExtraction() {
|
||||||
|
|||||||
54
internal/app/operator_artifact_rendering_test.go
Normal file
54
internal/app/operator_artifact_rendering_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -542,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
|
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
|
||||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
|
|||||||
for i, stageName := range stages {
|
for i, stageName := range stages {
|
||||||
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
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")
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
if err := store.Save(context.Background(), path, m); err != nil {
|
if err := store.Save(context.Background(), path, m); err != nil {
|
||||||
t.Fatalf("save workflow manifest fixture: %v", err)
|
t.Fatalf("save workflow manifest fixture: %v", err)
|
||||||
|
|||||||
134
internal/artifacts/analyze_evidence.go
Normal file
134
internal/artifacts/analyze_evidence.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/artifacts/analyze_evidence_test.go
Normal file
222
internal/artifacts/analyze_evidence_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||||
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
|
|||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
}
|
}
|
||||||
sourceID := ConfiguredArtifactSourceID("player_handout")
|
sourceID := ConfiguredArtifactSourceID("player_handout")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
|
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
}
|
}
|
||||||
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
|
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
||||||
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
|
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
|
||||||
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,11 +282,6 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
|||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
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 {
|
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
|
||||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -297,6 +292,16 @@ func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, produ
|
|||||||
return nil
|
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 {
|
func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("artifact catalog is nil")
|
return fmt.Errorf("artifact catalog is nil")
|
||||||
|
|||||||
@@ -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) {
|
func TestArtifactCatalogLookupPlannedButUnavailable(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
if err := catalog.RegisterConfiguredArtifacts(
|
if err := catalog.RegisterConfiguredArtifacts(
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ func executeAnalyzeArtifact(
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
|
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{
|
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||||
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
||||||
"source_id": resolution.Artifact.ID,
|
"source_id": resolution.Artifact.ID,
|
||||||
@@ -738,25 +738,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
|
|||||||
if notariusCfg != nil && notariusCfg.Enabled {
|
if notariusCfg != nil && notariusCfg.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return catalog, nil
|
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,
|
Enabled: false,
|
||||||
OutputPath: "artifacts/player_handout.md",
|
OutputPath: "artifacts/player_handout.md",
|
||||||
}
|
}
|
||||||
|
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||||
|
|
||||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
if err != nil {
|
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) {
|
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||||
env, m, _ := setupAnalyzeEnv(t)
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
@@ -498,6 +518,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
OutputPath: "artifacts/player_handout.md",
|
OutputPath: "artifacts/player_handout.md",
|
||||||
}
|
}
|
||||||
|
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||||
|
|
||||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -545,8 +566,8 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
|||||||
if r0["path"] != playerHandoutPath {
|
if r0["path"] != playerHandoutPath {
|
||||||
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
||||||
}
|
}
|
||||||
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
|
if r0["provenance"] != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
|
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceCurrentAnalyzeManifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -839,51 +839,11 @@ func buildPublishRuntimeArtifactCatalog(
|
|||||||
if notariusCfg != nil && notariusCfg.Enabled {
|
if notariusCfg != nil && notariusCfg.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return catalog, nil
|
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) {
|
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
|
||||||
if runManifest == nil {
|
if runManifest == nil {
|
||||||
return nil, fmt.Errorf("run manifest is required")
|
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) {
|
func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
|
||||||
env, m, _ := publishFixture(t)
|
env, m, _ := publishFixture(t)
|
||||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||||
@@ -946,10 +994,12 @@ func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
|
|||||||
|
|
||||||
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
||||||
env, m, _ := publishFixture(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{
|
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
|
||||||
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
|
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{
|
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||||
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
|
{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)},
|
{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 {
|
for _, name := range publishPrerequisiteStages {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
|
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md"))
|
||||||
|
|
||||||
env := &Env{
|
env := &Env{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
|
|||||||
Reference in New Issue
Block a user