Add versioned analysis artifact state
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
355
internal/manifest/analyze_state.go
Normal file
355
internal/manifest/analyze_state.go
Normal file
@@ -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
|
||||
}
|
||||
309
internal/manifest/analyze_state_test.go
Normal file
309
internal/manifest/analyze_state_test.go
Normal file
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,8 @@ type StageRecord struct {
|
||||
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
|
||||
|
||||
@@ -34,6 +34,8 @@ type RunStageRecord struct {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user