From 23c714da660acdbe62a145506f344ab5a304ceae Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 18:27:44 +0000 Subject: [PATCH] Add versioned analysis artifact state --- docs/internal/manifest.md | 37 +++ docs/roadmap/implementation.md | 2 + internal/manifest/analyze_state.go | 355 ++++++++++++++++++++++++ internal/manifest/analyze_state_test.go | 309 +++++++++++++++++++++ internal/manifest/manifest.go | 24 +- internal/manifest/run_manifest.go | 26 +- internal/manifest/store.go | 36 +++ 7 files changed, 766 insertions(+), 23 deletions(-) create mode 100644 internal/manifest/analyze_state.go create mode 100644 internal/manifest/analyze_state_test.go diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index 09a89eb..675f578 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -35,6 +35,37 @@ The model admits these stage states: - `stale` - `interrupted` +### Analyze-owned artifact state + +The `analyze` stage record may carry `analyze_state_version: 1` and an +`analyze_artifacts` map keyed by normalized configured artifact key. The +version is the authority marker: version 1 with no entries is a valid evaluated +empty set, while an absent version is legacy aggregate-only state and provides +no current configured-artifact evidence. + +Each analyze artifact record has one disposition: + +- `current`: the configured artifact is available and carries a versioned + fingerprint plus a complete output record and separate output size; +- `stale`: the recorded semantic identity is no longer current; +- `missing`: no validated current result exists; +- `failed`: the attempted work failed and carries a bounded diagnostic; or +- `unselected`: the artifact was intentionally outside the evaluated set. + +Records bind their normalized key and dependencies, fingerprint contract when +evaluated, canonical session-relative output identity when current, producing +Narratio run, update time, and bounded non-secret Scriptorium provenance and +diagnostic paths. A current output includes its configured source ID, contract, +checksum, and positive byte size. Non-current records cannot carry an output, +so an older file is not advertised through stale, missing, failed, or +unselected state. + +The session-stage collection is the reconciled authority across invocations. +The corresponding collection on an invocation's `analyze` stage record is an +audit of only the artifacts evaluated or attempted by that run. These records +remain analyze-owned data inside the fixed stage; they are not dynamic stages +or generic subtasks. + ## Run Manifest `manifest.RunManifest` is created for each invocation and records: @@ -99,6 +130,12 @@ those details because resume validation and diagnosis may still require them before execution begins. Invocation run manifests remain immutable audit records of their own outcomes. +Aggregate lifecycle clearing deliberately preserves the analyze-owned +per-artifact collection. This lets later reconciliation replace only evaluated +entries without erasing unrelated current results. Other stages retain their +existing aggregate-only lifecycle behavior and are forbidden from carrying the +analyze-specific fields. + A stage may explicitly return a skipped disposition and stable reason. The runner persists that outcome in both manifests, clears older outputs for the session-stage record along with older logs, generated configuration references, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4a54b1d..245fa9a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -305,6 +305,8 @@ path. ## Stage 6 — Versioned Analyze-Artifact Manifest State +**Status: Completed** + ### Goal Introduce a backward-compatible, analyze-specific session-manifest model that diff --git a/internal/manifest/analyze_state.go b/internal/manifest/analyze_state.go new file mode 100644 index 0000000..97e8458 --- /dev/null +++ b/internal/manifest/analyze_state.go @@ -0,0 +1,355 @@ +package manifest + +import ( + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" +) + +const ( + // AnalyzeStateContractVersion identifies the supported per-artifact state + // representation owned by the analyze stage. + AnalyzeStateContractVersion = 1 + // AnalyzeFingerprintContractVersion identifies the fingerprint representation + // stored by the supported analyze state contract. + AnalyzeFingerprintContractVersion = 1 +) + +const ( + maxAnalyzeArtifactErrorLength = 512 + maxAnalyzeArtifactTextLength = 4096 + maxAnalyzeArtifactListEntries = 128 +) + +// AnalyzeArtifactStatus describes whether one configured analysis artifact is +// currently available or why it is not. +type AnalyzeArtifactStatus string + +const ( + AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current" + AnalyzeArtifactStale AnalyzeArtifactStatus = "stale" + AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing" + AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed" + AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected" +) + +// AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation +// identity without making adapter diagnostics part of the generic artifact schema. +type AnalyzeArtifactProvenance struct { + PromptID string `json:"prompt_id,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + CommandMode string `json:"command_mode,omitempty"` +} + +// AnalyzeArtifactRecord is analyze-owned state for one configured artifact. +// Output is present only while the record is current. +type AnalyzeArtifactRecord struct { + Key string `json:"key"` + Status AnalyzeArtifactStatus `json:"status"` + FingerprintVersion int `json:"fingerprint_version,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Output *ArtifactRecord `json:"output,omitempty"` + OutputSize int64 `json:"output_size,omitempty"` + ProducerRunID string `json:"producer_run_id"` + UpdatedAt time.Time `json:"updated_at"` + Error string `json:"error,omitempty"` + Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"` + Logs []string `json:"logs,omitempty"` + GeneratedConfigs []string `json:"generated_configs,omitempty"` +} + +// HasVersionedAnalyzeState reports whether an analyze stage record carries the +// supported per-artifact authority. A legacy aggregate-only record returns false. +func (s *StageRecord) HasVersionedAnalyzeState() bool { + return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion +} + +// ValidateAnalyzeArtifactCollection validates one complete session or +// invocation collection independently of its containing manifest. +func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error { + if version != AnalyzeStateContractVersion { + return fmt.Errorf("unsupported analyze state version %d", version) + } + for key, record := range records { + if err := validateAnalyzeArtifactRecord(key, record); err != nil { + return fmt.Errorf("analyze artifact %q: %w", key, err) + } + } + return nil +} + +func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error { + if stageName != "analyze" { + if version != 0 || records != nil { + return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName) + } + return nil + } + if version == 0 { + if records != nil { + return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts") + } + return nil + } + return ValidateAnalyzeArtifactCollection(version, records) +} + +func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error { + if !isNormalizedAnalyzeArtifactKey(mapKey) { + return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization") + } + if record.Key != mapKey { + return fmt.Errorf("record key %q does not match map key", record.Key) + } + switch record.Status { + case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected: + default: + return fmt.Errorf("unsupported status %q", record.Status) + } + if err := validateAnalyzeDependencies(record.Dependencies); err != nil { + return err + } + if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil { + return err + } + if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil { + return fmt.Errorf("producer_run_id is invalid: %w", err) + } + if record.UpdatedAt.IsZero() { + return fmt.Errorf("updated_at is required") + } + if len(record.Error) > maxAnalyzeArtifactErrorLength { + return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength) + } + if strings.TrimSpace(record.Error) != record.Error { + return fmt.Errorf("error must be trimmed") + } + if record.Status == AnalyzeArtifactFailed { + if record.Error == "" { + return fmt.Errorf("failed status requires error") + } + } else if record.Error != "" { + return fmt.Errorf("status %q forbids error", record.Status) + } + if record.Status == AnalyzeArtifactCurrent { + if err := validateCurrentAnalyzeOutput(record); err != nil { + return err + } + } else if record.Output != nil || record.OutputSize != 0 { + return fmt.Errorf("status %q forbids output and output_size", record.Status) + } + if err := validateAnalyzeProvenance(record.Scriptorium); err != nil { + return err + } + if err := validateAnalyzeTextList("logs", record.Logs); err != nil { + return err + } + if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil { + return err + } + return nil +} + +func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error { + if record.Output == nil { + return fmt.Errorf("current status requires output") + } + if record.OutputSize <= 0 { + return fmt.Errorf("current status requires positive output_size") + } + output := record.Output + if strings.TrimSpace(output.Kind) == "" { + return fmt.Errorf("current output kind is required") + } + if strings.TrimSpace(output.Kind) != output.Kind { + return fmt.Errorf("current output kind must be trimmed") + } + wantSource := artifactpolicy.ConfiguredSourceID(record.Key) + if output.SourceID != wantSource { + return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource) + } + normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath) + if err != nil { + return fmt.Errorf("current output local_path is unsafe: %w", err) + } + if normalizedPath != output.LocalPath { + return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath) + } + if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" { + return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required") + } + for field, value := range map[string]string{ + "media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID, + "schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey, + } { + if strings.TrimSpace(value) != value { + return fmt.Errorf("current output contract %s must be trimmed", field) + } + } + if err := validateSHA256("current output checksum", output.Checksum); err != nil { + return err + } + if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID { + return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID) + } + for field, value := range map[string]string{ + "output kind": output.Kind, + "output source_id": output.SourceID, + "output local_path": output.LocalPath, + "output contract media_type": output.Contract.MediaType, + "output contract schema_id": output.Contract.SchemaID, + "output contract schema_version": output.Contract.SchemaVersion, + "output contract module_key": output.Contract.ModuleKey, + } { + if err := validateAnalyzeText(field, value); err != nil { + return err + } + } + if output.ExternalProvenance != nil { + if strings.TrimSpace(output.ExternalProvenance.System) == "" { + return fmt.Errorf("output provenance system is required when provenance is present") + } + for field, value := range map[string]string{ + "output provenance system": output.ExternalProvenance.System, + "output provenance run_id": output.ExternalProvenance.RunID, + "output provenance pipeline_id": output.ExternalProvenance.PipelineID, + "output provenance artifact_id": output.ExternalProvenance.ArtifactID, + } { + if err := validateAnalyzeText(field, value); err != nil { + return err + } + } + } + return nil +} + +func validateAnalyzeDependencies(dependencies []string) error { + seen := make(map[string]struct{}, len(dependencies)) + for index, dependency := range dependencies { + if !isNormalizedAnalyzeArtifactKey(dependency) { + return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index) + } + if _, duplicate := seen[dependency]; duplicate { + return fmt.Errorf("duplicate dependency %q", dependency) + } + seen[dependency] = struct{}{} + } + return nil +} + +func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error { + if version == 0 && fingerprint == "" { + if required { + return fmt.Errorf("current status requires fingerprint version and fingerprint") + } + return nil + } + if version != AnalyzeFingerprintContractVersion { + return fmt.Errorf("unsupported fingerprint version %d", version) + } + return validateSHA256("fingerprint", fingerprint) +} + +func validateSHA256(field, value string) error { + if len(value) != 64 || strings.ToLower(value) != value { + return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field) + } + decoded, err := hex.DecodeString(value) + if err != nil || len(decoded) != 32 { + return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field) + } + return nil +} + +func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error { + if provenance == nil { + return nil + } + if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" { + return fmt.Errorf("scriptorium provenance must contain at least one identifier") + } + for field, value := range map[string]string{ + "scriptorium prompt_id": provenance.PromptID, + "scriptorium profile_id": provenance.ProfileID, + "scriptorium command_mode": provenance.CommandMode, + } { + if err := validateAnalyzeText(field, value); err != nil { + return err + } + } + return nil +} + +func validateAnalyzeTextList(field string, values []string) error { + if len(values) > maxAnalyzeArtifactListEntries { + return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries) + } + for index, value := range values { + if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil { + return err + } + } + return nil +} + +func validateAnalyzeText(field, value string) error { + if len(value) > maxAnalyzeArtifactTextLength { + return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength) + } + if strings.ContainsRune(value, '\x00') { + return fmt.Errorf("%s contains a NUL byte", field) + } + return nil +} + +func isNormalizedAnalyzeArtifactKey(key string) bool { + return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key) +} + +func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) { + for key, record := range records { + if len(record.Dependencies) > 1 { + record.Dependencies = append([]string(nil), record.Dependencies...) + sort.Strings(record.Dependencies) + } + record.Logs = cloneStrings(record.Logs) + record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs) + record.Output = cloneArtifactRecord(record.Output) + record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium) + records[key] = record + } +} + +func cloneStrings(values []string) []string { + return append([]string(nil), values...) +} + +func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord { + if record == nil { + return nil + } + clone := *record + if record.Contract != nil { + contract := *record.Contract + clone.Contract = &contract + } + if record.ExternalProvenance != nil { + provenance := *record.ExternalProvenance + clone.ExternalProvenance = &provenance + } + return &clone +} + +func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance { + if provenance == nil { + return nil + } + clone := *provenance + return &clone +} diff --git a/internal/manifest/analyze_state_test.go b/internal/manifest/analyze_state_test.go new file mode 100644 index 0000000..8117b25 --- /dev/null +++ b/internal/manifest/analyze_state_test.go @@ -0,0 +1,309 @@ +package manifest + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" +) + +func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + records := map[string]AnalyzeArtifactRecord{ + "session_recap": currentAnalyzeArtifactRecord("session_recap", now), + "quest_log": { + Key: "quest_log", Status: AnalyzeArtifactStale, + FingerprintVersion: AnalyzeFingerprintContractVersion, + Fingerprint: strings.Repeat("2", 64), + Dependencies: []string{"session_recap"}, ProducerRunID: "run-stale", UpdatedAt: now, + }, + "player_handout": { + Key: "player_handout", Status: AnalyzeArtifactMissing, + Dependencies: []string{"session_recap"}, ProducerRunID: "run-missing", UpdatedAt: now, + }, + "npc_digest": { + Key: "npc_digest", Status: AnalyzeArtifactFailed, + ProducerRunID: "run-failed", UpdatedAt: now, Error: "scriptorium validation failed", + Logs: []string{"runs/run-failed/logs/npc-digest.stderr.log"}, + }, + "gm_notes": { + Key: "gm_notes", Status: AnalyzeArtifactUnselected, + ProducerRunID: "run-unselected", UpdatedAt: now, + }, + } + records["session_recap"] = func() AnalyzeArtifactRecord { + record := records["session_recap"] + record.Dependencies = []string{"quest_log", "gm_notes"} + return record + }() + + m := New("session", now) + m.Stages["analyze"] = &StageRecord{ + Name: "analyze", + Status: StatusSucceeded, + CreatedAt: now, + UpdatedAt: now, + AnalyzeStateVersion: AnalyzeStateContractVersion, + AnalyzeArtifacts: records, + } + path := filepath.Join(t.TempDir(), "manifest.json") + store := &LocalStore{} + if err := store.Save(context.Background(), path, m); err != nil { + t.Fatalf("Save() error = %v", err) + } + loaded, err := store.Load(context.Background(), path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + analyze := loaded.Stages["analyze"] + if !analyze.HasVersionedAnalyzeState() { + t.Fatal("round-tripped analyze record lacks versioned state") + } + for key, want := range records { + got, ok := analyze.AnalyzeArtifacts[key] + if !ok || got.Status != want.Status { + t.Fatalf("artifact %q = %#v, want status %q", key, got, want.Status) + } + } + if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) { + t.Fatalf("canonical dependencies = %#v", got) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + orderedKeys := []string{"gm_notes", "npc_digest", "player_handout", "quest_log", "session_recap"} + previous := -1 + for _, key := range orderedKeys { + position := bytes.Index(data, []byte(`"`+key+`": {`)) + if position <= previous { + t.Fatalf("map key %q position = %d after %d; JSON is not canonical:\n%s", key, position, previous, data) + } + previous = position + } +} + +func TestRunManifestAnalyzeArtifactStateRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + run := NewRun("session", "campaign", "run-123", true, []string{"analyze"}, now) + run.Stages["analyze"] = &RunStageRecord{ + Name: "analyze", + Action: RunStageActionRun, + Status: StatusSucceeded, + CreatedAt: now, + UpdatedAt: now, + AnalyzeStateVersion: AnalyzeStateContractVersion, + AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{ + "session_recap": currentAnalyzeArtifactRecord("session_recap", now), + }, + } + path := filepath.Join(t.TempDir(), "run.json") + store := &LocalStore{} + if err := store.SaveRun(context.Background(), path, run); err != nil { + t.Fatalf("SaveRun() error = %v", err) + } + loaded, err := store.LoadRun(context.Background(), path) + if err != nil { + t.Fatalf("LoadRun() error = %v", err) + } + got := loaded.Stages["analyze"] + if got.AnalyzeStateVersion != AnalyzeStateContractVersion || got.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent { + t.Fatalf("run analyze state = %#v", got) + } +} + +func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + valid := currentAnalyzeArtifactRecord("session_recap", now) + tests := []struct { + name string + key string + mutate func(*AnalyzeArtifactRecord) + want string + }{ + {name: "malformed map key", key: "Session Recap", want: "map key must match"}, + {name: "record key mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Key = "quest_log" }, want: "does not match map key"}, + {name: "unsupported status", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = "ready" }, want: "unsupported status"}, + {name: "unsupported fingerprint version", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.FingerprintVersion = 99 }, want: "unsupported fingerprint version"}, + {name: "bad fingerprint", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Fingerprint = "not-a-digest" }, want: "fingerprint must be"}, + {name: "duplicate dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"quest_log", "quest_log"} }, want: "duplicate dependency"}, + {name: "malformed dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"Quest Log"} }, want: "dependencies[0]"}, + {name: "missing current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output = nil }, want: "requires output"}, + {name: "bad current size", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.OutputSize = 0 }, want: "positive output_size"}, + {name: "bad current source", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.SourceID = "narratio.artifact.other" }, want: "source_id"}, + {name: "unsafe current path", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.LocalPath = "../recap.md" }, want: "local_path is unsafe"}, + {name: "missing current contract", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Contract = nil }, want: "output contract"}, + {name: "bad current checksum", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("G", 64) }, want: "checksum must be"}, + {name: "producer mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"}, + {name: "non-current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactStale }, want: "forbids output"}, + {name: "failed without error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed; r.Output = nil; r.OutputSize = 0 }, want: "requires error"}, + {name: "current with error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Error = "unexpected" }, want: "forbids error"}, + {name: "oversized error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { + r.Status = AnalyzeArtifactFailed + r.Output = nil + r.OutputSize = 0 + r.Error = strings.Repeat("x", maxAnalyzeArtifactErrorLength+1) + }, want: "error exceeds"}, + {name: "bad producer run", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id"}, + {name: "missing update time", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.UpdatedAt = time.Time{} }, want: "updated_at"}, + {name: "oversized log collection", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Logs = make([]string, maxAnalyzeArtifactListEntries+1) }, want: "logs exceeds"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := valid + record.Output = cloneArtifactRecord(valid.Output) + if test.mutate != nil { + test.mutate(&record) + } + err := ValidateAnalyzeArtifactCollection(AnalyzeStateContractVersion, map[string]AnalyzeArtifactRecord{test.key: record}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + if err := ValidateAnalyzeArtifactCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version") { + t.Fatalf("unsupported version error = %v", err) + } +} + +func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + store := &LocalStore{} + legacy := `{ + "session_id": "session", + "created_at": "2026-05-03T10:00:00Z", + "updated_at": "2026-05-03T10:01:00Z", + "stages": { + "analyze": { + "name": "analyze", + "status": "succeeded", + "created_at": "2026-05-03T10:00:00Z", + "updated_at": "2026-05-03T10:01:00Z", + "outputs": [{"kind":"session_recap","local_path":"artifacts/session_recap.md"}] + } + } +}` + loaded, err := store.LoadReader(context.Background(), strings.NewReader(legacy)) + if err != nil { + t.Fatalf("LoadReader(legacy) error = %v", err) + } + if loaded.Stages["analyze"].HasVersionedAnalyzeState() { + t.Fatal("legacy aggregate outputs became current per-artifact evidence") + } + path := filepath.Join(t.TempDir(), "legacy.json") + if err := store.Save(context.Background(), path, loaded); err != nil { + t.Fatalf("Save(legacy) error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(legacy) error = %v", err) + } + if bytes.Contains(data, []byte("analyze_state_version")) || bytes.Contains(data, []byte("analyze_artifacts")) || bytes.Contains(data, []byte("fingerprint")) { + t.Fatalf("legacy state gained fabricated evidence:\n%s", data) + } + + empty := New("session", now) + empty.Stages["analyze"] = &StageRecord{ + Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, + AnalyzeStateVersion: AnalyzeStateContractVersion, + } + emptyPath := filepath.Join(t.TempDir(), "empty.json") + if err := store.Save(context.Background(), emptyPath, empty); err != nil { + t.Fatalf("Save(versioned empty state) error = %v", err) + } + emptyLoaded, err := store.Load(context.Background(), emptyPath) + if err != nil { + t.Fatalf("Load(versioned empty state) error = %v", err) + } + if !emptyLoaded.Stages["analyze"].HasVersionedAnalyzeState() || len(emptyLoaded.Stages["analyze"].AnalyzeArtifacts) != 0 { + t.Fatalf("versioned empty state = %#v", emptyLoaded.Stages["analyze"]) + } + + m := New("session", now) + m.Stages["render"] = &StageRecord{ + Name: "render", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, + AnalyzeStateVersion: AnalyzeStateContractVersion, + } + if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "bad-owner.json"), m); err == nil || !strings.Contains(err.Error(), "cannot contain analyze-owned state") { + t.Fatalf("non-analyze ownership error = %v", err) + } + + m.Stages = map[string]*StageRecord{"analyze": { + Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, + AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{}, + }} + if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "without a state version") { + t.Fatalf("missing version error = %v", err) + } +} + +func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing(t *testing.T) { + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + for _, transition := range []struct { + name string + apply func(*Manifest) + }{ + {name: "running", apply: func(m *Manifest) { m.MarkStageRunning("analyze", now.Add(time.Minute)) }}, + {name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("analyze", now.Add(time.Minute), "aggregate failed") }}, + {name: "skipped", apply: func(m *Manifest) { m.MarkStageSkipped("analyze", now.Add(time.Minute), "disabled") }}, + } { + t.Run(transition.name, func(t *testing.T) { + m := New("session", now) + m.Stages["analyze"] = &StageRecord{ + Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, + Outputs: []ArtifactRecord{{Kind: "legacy", LocalPath: "artifacts/legacy.md"}}, + Logs: []string{"aggregate.log"}, GeneratedConfigs: []string{"aggregate.yml"}, Metadata: map[string]any{"aggregate": true}, + AnalyzeStateVersion: AnalyzeStateContractVersion, + AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{ + "session_recap": currentAnalyzeArtifactRecord("session_recap", now), + }, + } + transition.apply(m) + stage := m.Stages["analyze"] + if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 { + t.Fatalf("aggregate details survived %s: %#v", transition.name, stage) + } + if !stage.HasVersionedAnalyzeState() || stage.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent { + t.Fatalf("per-artifact state was cleared by %s: %#v", transition.name, stage) + } + }) + } +} + +func currentAnalyzeArtifactRecord(key string, now time.Time) AnalyzeArtifactRecord { + runID := "run-" + strings.ReplaceAll(key, "_", "-") + return AnalyzeArtifactRecord{ + Key: key, + Status: AnalyzeArtifactCurrent, + FingerprintVersion: AnalyzeFingerprintContractVersion, + Fingerprint: strings.Repeat("1", 64), + Output: &ArtifactRecord{ + Kind: key, + SourceID: "narratio.artifact." + key, + LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md", + ProducerRunID: runID, + Checksum: strings.Repeat("a", 64), + Contract: &artifactmodel.ContractMetadata{ + MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1", + }, + ExternalProvenance: &artifactmodel.ExternalProvenance{ + System: "scriptorium", PipelineID: "campaign", ArtifactID: key, + }, + }, + OutputSize: 42, + ProducerRunID: runID, + UpdatedAt: now, + Scriptorium: &AnalyzeArtifactProvenance{ + PromptID: key, ProfileID: "default", CommandMode: "artifact", + }, + Logs: []string{"runs/" + runID + "/logs/" + key + ".log"}, + GeneratedConfigs: []string{"runs/" + runID + "/config/" + key + ".yml"}, + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 1623a4b..a927a94 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -44,17 +44,19 @@ type ArtifactRecord struct { // StageRecord tracks lifecycle and provenance for one pipeline stage. type StageRecord struct { - Name string `json:"name"` - Status StageStatus `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Outputs []ArtifactRecord `json:"outputs,omitempty"` - Logs []string `json:"logs,omitempty"` - GeneratedConfigs []string `json:"generated_configs,omitempty"` - Error *ErrorRecord `json:"error,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + Name string `json:"name"` + Status StageStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Outputs []ArtifactRecord `json:"outputs,omitempty"` + Logs []string `json:"logs,omitempty"` + GeneratedConfigs []string `json:"generated_configs,omitempty"` + Error *ErrorRecord `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + AnalyzeStateVersion int `json:"analyze_state_version,omitempty"` + AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"` } // CleanupTarget records one root-confined local deletion requested by a diff --git a/internal/manifest/run_manifest.go b/internal/manifest/run_manifest.go index 8ca66b9..c0496e6 100644 --- a/internal/manifest/run_manifest.go +++ b/internal/manifest/run_manifest.go @@ -22,18 +22,20 @@ const ( // RunStageRecord tracks lifecycle and provenance for one stage within a single invocation. type RunStageRecord struct { - Name string `json:"name"` - Action RunStageAction `json:"action"` - Status StageStatus `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Outputs []ArtifactRecord `json:"outputs,omitempty"` - Logs []string `json:"logs,omitempty"` - GeneratedConfigs []string `json:"generated_configs,omitempty"` - Error *ErrorRecord `json:"error,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + Name string `json:"name"` + Action RunStageAction `json:"action"` + Status StageStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Outputs []ArtifactRecord `json:"outputs,omitempty"` + Logs []string `json:"logs,omitempty"` + GeneratedConfigs []string `json:"generated_configs,omitempty"` + Error *ErrorRecord `json:"error,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + AnalyzeStateVersion int `json:"analyze_state_version,omitempty"` + AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"` } // RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json. diff --git a/internal/manifest/store.go b/internal/manifest/store.go index bb287f4..48a3bca 100644 --- a/internal/manifest/store.go +++ b/internal/manifest/store.go @@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error { if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { return fmt.Errorf("save manifest: %w", err) } + for name, stage := range m.Stages { + if stage == nil { + continue + } + normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) + if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { + return fmt.Errorf("save manifest: stages.%s: %w", name, err) + } + } m.UpdatedAt = time.Now().UTC() if m.Stages == nil { @@ -216,6 +225,15 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return fmt.Errorf("save run manifest: %w", err) } + for name, stage := range m.Stages { + if stage == nil { + continue + } + normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) + if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { + return fmt.Errorf("save run manifest: stages.%s: %w", name, err) + } + } m.UpdatedAt = time.Now().UTC() if m.Stages == nil { @@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error { if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil { return err } + for name, stage := range m.Stages { + if stage == nil { + continue + } + if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { + return fmt.Errorf("stages.%s: %w", name, err) + } + } return nil } @@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) { if stage.Name == "" { stage.Name = name } + normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) } } @@ -309,6 +336,14 @@ func validateLoadedRunManifest(m *RunManifest) error { if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { return err } + for name, stage := range m.Stages { + if stage == nil { + continue + } + if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { + return fmt.Errorf("stages.%s: %w", name, err) + } + } return nil } @@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) { if stage.Name == "" { stage.Name = name } + normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) } }