From c0fbc14f2d59e2f7f5ac84107bd4328f5dcb308f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 3 Sep 2026 04:16:36 +0000 Subject: [PATCH] Delete run manifest domain model --- docs/internal/manifest.md | 6 +- docs/internal/overview.md | 6 +- docs/roadmap/implementation.md | 2 +- internal/artifacts/paths.go | 5 - internal/artifacts/paths_model_test.go | 10 - internal/artifacts/remote_commit.go | 3 +- internal/artifacts/remote_commit_test.go | 12 ++ internal/manifest/analyze_state_test.go | 29 --- internal/manifest/manifest.go | 3 - internal/manifest/run_manifest.go | 175 ----------------- internal/manifest/run_manifest_test.go | 61 ------ .../manifest/semantic_fingerprint_test.go | 15 -- internal/manifest/store.go | 184 ------------------ internal/manifest/store_test.go | 103 ---------- internal/stage/publish_test.go | 6 +- 15 files changed, 25 insertions(+), 595 deletions(-) delete mode 100644 internal/manifest/run_manifest.go delete mode 100644 internal/manifest/run_manifest_test.go diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index 0cb362a..dc9c465 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -2,8 +2,8 @@ ## Purpose -Explain the session-progress and invocation-audit models implemented by -`internal/manifest`. Physical manifest placement belongs in +Explain the session-progress model implemented by `internal/manifest`. Physical +manifest placement belongs in [Operations](../operations.md#local-state-layout). ## Session Manifest @@ -143,7 +143,7 @@ establish a current immutable snapshot. `manifest.LocalStore`: -- accepts only the exact supported schema version for each manifest kind; +- accepts only the exact supported session-manifest schema version; - validates stage-map identity without synthesizing missing maps, records, or names; - retains only representation-preserving collection normalization; diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 8a037e0..3ff8a10 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -31,7 +31,7 @@ progress and artifact services resolve durable inputs and outputs. | Configuration | [`internal/config`](configuration.md) | Presence-aware root/import/profile composition, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. | | Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. | | External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. | -| Manifests | `internal/manifest` | Durable session progress, invocation audit state, stage transitions, validation, and atomic persistence. | +| Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. | | Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. | | Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. | | Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. | @@ -91,8 +91,8 @@ trimmed transcript state: neither invalidates the other, while either can stale failure behavior, and test surfaces. - [Artifact Internals](artifacts.md): source identities, runtime catalog, resolution, previous requirements, and current-state helpers. -- [Manifest Internals](manifest.md): session and run records, persistence, and - execution transitions. +- [Manifest Internals](manifest.md): session records, persistence, and execution + transitions. - [Storage Internals](storage.md): object-store interface and S3 behavior. - [Workspace Internals](workspace.md): local layout, locking, and cleanup guardrails. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 34f7a48..e365f2b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -285,7 +285,7 @@ prematurely implementing Workstream 6 checkpoint semantics. ## Stage 4 — Delete The Run-Manifest Domain Model -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 1ef79d3..0a8f6c9 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -88,11 +88,6 @@ func SessionRunRootForCampaign(rootDir, campaign, sessionID, runID string) strin return filepath.Join(SessionRunsDirForCampaign(rootDir, campaign, sessionID), runID) } -// SessionRunManifestPathForCampaign returns the canonical run manifest path under runs/{run_id}/manifest.json. -func SessionRunManifestPathForCampaign(rootDir, campaign, sessionID, runID string) string { - return filepath.Join(SessionRunRootForCampaign(rootDir, campaign, sessionID, runID), config.PathManifestFile) -} - // SessionRunStageDirForCampaign returns the canonical stage directory under runs/{run_id}/{stage}. func SessionRunStageDirForCampaign(rootDir, campaign, sessionID, runID, stageName string) string { return filepath.Join(SessionRunRootForCampaign(rootDir, campaign, sessionID, runID), stageName) diff --git a/internal/artifacts/paths_model_test.go b/internal/artifacts/paths_model_test.go index d54f398..582b910 100644 --- a/internal/artifacts/paths_model_test.go +++ b/internal/artifacts/paths_model_test.go @@ -39,16 +39,6 @@ func TestSessionRunRootAndStageDirForCampaign(t *testing.T) { } } -func TestSessionRunManifestPathForCampaign(t *testing.T) { - root := "/tmp/workspace" - runID := "20260515T031522Z-a1b2c3d4" - got := SessionRunManifestPathForCampaign(root, "forsaken", "2026-04-19", runID) - want := filepath.Join(root, "work", "forsaken", "2026-04-19", "runs", runID, "manifest.json") - if got != want { - t.Fatalf("SessionRunManifestPathForCampaign() = %q, want %q", got, want) - } -} - func TestSessionNotariusPathsForCampaign(t *testing.T) { root := "/tmp/workspace" campaign := "forsaken" diff --git a/internal/artifacts/remote_commit.go b/internal/artifacts/remote_commit.go index f614e88..f515474 100644 --- a/internal/artifacts/remote_commit.go +++ b/internal/artifacts/remote_commit.go @@ -17,7 +17,8 @@ const RemoteCommitFormatVersion = 1 type RemoteArtifactType string const ( - RemoteArtifactTypeSessionManifest RemoteArtifactType = "session_manifest" + RemoteArtifactTypeSessionManifest RemoteArtifactType = "session_manifest" + // RemoteArtifactTypeRunManifest is retained solely to decode immutable existing commits; Narratio no longer emits or interprets it. RemoteArtifactTypeRunManifest RemoteArtifactType = "run_manifest" RemoteArtifactTypeRunFile RemoteArtifactType = "run_file" RemoteArtifactTypePublishedOutput RemoteArtifactType = "published_output" diff --git a/internal/artifacts/remote_commit_test.go b/internal/artifacts/remote_commit_test.go index 4e2784a..f67c25b 100644 --- a/internal/artifacts/remote_commit_test.go +++ b/internal/artifacts/remote_commit_test.go @@ -46,6 +46,18 @@ func TestRemoteCommitDecodersRejectUnknownFields(t *testing.T) { } } +func TestRemoteCommitDecoderAcceptsHistoricalRunManifestArtifact(t *testing.T) { + data := []byte(`{"format_version":1,"campaign":"sample-campaign","session_id":"2026-05-03","run_id":"20260519T010203Z-a1b2c3d4","artifacts":[{"type":"run_manifest","source":"run:manifest.json","destination_key":"sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4/manifest.json","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","size":1,"generation":"historical"}]}`) + + commit, err := DecodeRemoteCommitManifest(data) + if err != nil { + t.Fatalf("DecodeRemoteCommitManifest() error = %v", err) + } + if got := commit.Artifacts[0].Type; got != RemoteArtifactTypeRunManifest { + t.Fatalf("artifact type = %q, want %q", got, RemoteArtifactTypeRunManifest) + } +} + func TestLoadCurrentStateReadsVerifiedRemoteCommit(t *testing.T) { store := &storage.FakeBackend{} commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4") diff --git a/internal/manifest/analyze_state_test.go b/internal/manifest/analyze_state_test.go index 1fd2d72..f88a68c 100644 --- a/internal/manifest/analyze_state_test.go +++ b/internal/manifest/analyze_state_test.go @@ -95,35 +95,6 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T } } -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) diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 9949575..dad2033 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -11,9 +11,6 @@ const ( // SessionManifestSchemaVersion identifies the only supported durable session // manifest representation. SessionManifestSchemaVersion = "narratio.session-manifest.v1" - // RunManifestSchemaVersion identifies the only supported invocation manifest - // representation. - RunManifestSchemaVersion = "narratio.run-manifest.v1" ) // ErrorRecord captures structured error metadata at run or stage scope. diff --git a/internal/manifest/run_manifest.go b/internal/manifest/run_manifest.go deleted file mode 100644 index 0d4fb98..0000000 --- a/internal/manifest/run_manifest.go +++ /dev/null @@ -1,175 +0,0 @@ -package manifest - -import ( - "strings" - "time" -) - -type RunManifestStatus string - -const ( - RunManifestStatusRunning RunManifestStatus = "running" - RunManifestStatusSucceeded RunManifestStatus = "succeeded" - RunManifestStatusFailed RunManifestStatus = "failed" -) - -type RunStageAction string - -const ( - RunStageActionRun RunStageAction = "run" - RunStageActionSkip RunStageAction = "skip" -) - -// 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"` - SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,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. -type RunManifest struct { - SchemaVersion string `json:"schema_version"` - SessionID string `json:"session_id"` - Campaign string `json:"campaign,omitempty"` - RunID string `json:"run_id"` - Force bool `json:"force"` - RequestedStages []string `json:"requested_stages,omitempty"` - SessionManifestPath string `json:"session_manifest_path,omitempty"` - LocalWorkDir string `json:"local_workdir,omitempty"` - LocalSpoolDir string `json:"local_spool_dir,omitempty"` - S3Bucket string `json:"s3_bucket,omitempty"` - S3SessionPrefix string `json:"s3_session_prefix,omitempty"` - S3RunPrefix string `json:"s3_run_prefix,omitempty"` - EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"` - 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"` - Status RunManifestStatus `json:"status"` - LastError *ErrorRecord `json:"last_error,omitempty"` - Stages map[string]*RunStageRecord `json:"stages"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -// NewRun constructs a new run manifest with deterministic timestamps. -func NewRun(sessionID, campaign, runID string, force bool, requestedStages []string, now time.Time) *RunManifest { - return &RunManifest{ - SchemaVersion: RunManifestSchemaVersion, - SessionID: strings.TrimSpace(sessionID), - Campaign: strings.TrimSpace(campaign), - RunID: strings.TrimSpace(runID), - Force: force, - RequestedStages: append([]string(nil), requestedStages...), - CreatedAt: now, - UpdatedAt: now, - StartedAt: timePtr(now), - Status: RunManifestStatusRunning, - Stages: map[string]*RunStageRecord{}, - } -} - -func (m *RunManifest) SetStageAction(name string, action RunStageAction, at time.Time) { - s := m.ensureStage(name, at) - s.Action = action - s.UpdatedAt = at - m.UpdatedAt = at -} - -func (m *RunManifest) MarkStageRunning(name string, at time.Time) { - s := m.ensureStage(name, at) - s.Status = StatusRunning - s.StartedAt = timePtr(at) - s.CompletedAt = nil - s.Error = nil - s.UpdatedAt = at - m.UpdatedAt = at -} - -func (m *RunManifest) MarkStageSucceeded(name string, at time.Time, outputs []ArtifactRecord) { - s := m.ensureStage(name, at) - s.Status = StatusSucceeded - s.CompletedAt = timePtr(at) - s.Error = nil - s.Outputs = append([]ArtifactRecord(nil), outputs...) - s.UpdatedAt = at - m.UpdatedAt = at -} - -func (m *RunManifest) MarkStageFailed(name string, at time.Time, message string) { - s := m.ensureStage(name, at) - s.Status = StatusFailed - s.CompletedAt = timePtr(at) - s.Error = &ErrorRecord{Message: strings.TrimSpace(message), At: timePtr(at)} - s.UpdatedAt = at - m.LastError = &ErrorRecord{Message: strings.TrimSpace(message), At: timePtr(at)} - m.UpdatedAt = at - m.Status = RunManifestStatusFailed - m.CompletedAt = timePtr(at) -} - -func (m *RunManifest) MarkStageSkipped(name string, at time.Time, reason string) { - s := m.ensureStage(name, at) - s.Status = StatusSkipped - s.CompletedAt = timePtr(at) - s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)} - s.Outputs = nil - s.UpdatedAt = at - m.UpdatedAt = at -} - -func (m *RunManifest) MarkSucceeded(at time.Time) { - m.Status = RunManifestStatusSucceeded - m.CompletedAt = timePtr(at) - m.UpdatedAt = at -} - -func (m *RunManifest) MarkFailed(at time.Time, message string) { - m.Status = RunManifestStatusFailed - m.CompletedAt = timePtr(at) - m.LastError = &ErrorRecord{Message: strings.TrimSpace(message), At: timePtr(at)} - m.UpdatedAt = at -} - -func (m *RunManifest) ensureStage(name string, at time.Time) *RunStageRecord { - if m.Stages == nil { - m.Stages = map[string]*RunStageRecord{} - } - - stageName := strings.TrimSpace(name) - s, ok := m.Stages[stageName] - if !ok || s == nil { - s = &RunStageRecord{ - Name: stageName, - Action: RunStageActionRun, - Status: StatusPending, - CreatedAt: at, - UpdatedAt: at, - } - if stageName == "analyze" { - s.AnalyzeStateVersion = AnalyzeStateContractVersion - s.AnalyzeArtifacts = map[string]AnalyzeArtifactRecord{} - } - m.Stages[stageName] = s - } - if s.Name == "" { - s.Name = stageName - } - if s.CreatedAt.IsZero() { - s.CreatedAt = at - } - - return s -} diff --git a/internal/manifest/run_manifest_test.go b/internal/manifest/run_manifest_test.go deleted file mode 100644 index f923ca4..0000000 --- a/internal/manifest/run_manifest_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package manifest - -import ( - "testing" - "time" -) - -func TestRunManifestStageMarkHelpers(t *testing.T) { - rm := NewRun("2026-05-03", "forsaken", "20260517T000000Z-abcdef12", false, []string{"prepare"}, time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - if rm.Status != RunManifestStatusRunning { - t.Fatalf("status = %q, want %q", rm.Status, RunManifestStatusRunning) - } - - runningAt := time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC) - rm.SetStageAction("prepare", RunStageActionRun, runningAt) - rm.MarkStageRunning("prepare", runningAt) - rm.MarkStageSucceeded("prepare", runningAt.Add(30*time.Second), []ArtifactRecord{ - {Kind: "input", LocalPath: "inputs/session.yml"}, - }) - - stage := rm.Stages["prepare"] - if stage == nil { - t.Fatal("prepare stage missing") - } - if stage.Action != RunStageActionRun { - t.Fatalf("action = %q, want %q", stage.Action, RunStageActionRun) - } - if stage.Status != StatusSucceeded { - t.Fatalf("status = %q, want %q", stage.Status, StatusSucceeded) - } - - skippedAt := runningAt.Add(1 * time.Minute) - rm.SetStageAction("notify", RunStageActionSkip, skippedAt) - rm.MarkStageSkipped("notify", skippedAt, "already_succeeded") - skipped := rm.Stages["notify"] - if skipped == nil { - t.Fatal("notify stage missing") - } - if skipped.Action != RunStageActionSkip { - t.Fatalf("action = %q, want %q", skipped.Action, RunStageActionSkip) - } - if skipped.Status != StatusSkipped { - t.Fatalf("status = %q, want %q", skipped.Status, StatusSkipped) - } - - rm.MarkSucceeded(skippedAt.Add(10 * time.Second)) - if rm.Status != RunManifestStatusSucceeded { - t.Fatalf("status = %q, want %q", rm.Status, RunManifestStatusSucceeded) - } -} - -func TestRunEnsureAnalyzeStageStartsWithCurrentAuthority(t *testing.T) { - now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) - run := NewRun("2026-05-03", "forsaken", "run-123", false, nil, now) - run.MarkStageRunning("analyze", now) - - stage := run.Stages["analyze"] - if stage == nil || stage.AnalyzeStateVersion != AnalyzeStateContractVersion || stage.AnalyzeArtifacts == nil { - t.Fatalf("analyze stage = %#v, want initialized current authority", stage) - } -} diff --git a/internal/manifest/semantic_fingerprint_test.go b/internal/manifest/semantic_fingerprint_test.go index a50b718..348631f 100644 --- a/internal/manifest/semantic_fingerprint_test.go +++ b/internal/manifest/semantic_fingerprint_test.go @@ -32,21 +32,6 @@ func TestSemanticConfigFingerprintRoundTripsAndRemainsOptional(t *testing.T) { t.Fatalf("session fingerprint = %#v", loadedSession.Stages["prepare"].SemanticConfig) } - run := NewRun("session", "campaign", "run", false, []string{"prepare"}, time.Now().UTC()) - run.MarkStageSucceeded("prepare", time.Now().UTC(), nil) - run.Stages["prepare"].SemanticConfig = fingerprint - runPath := filepath.Join(dir, "run.json") - if err := store.SaveRun(ctx, runPath, run); err != nil { - t.Fatal(err) - } - loadedRun, err := store.LoadRun(ctx, runPath) - if err != nil { - t.Fatal(err) - } - if loadedRun.Stages["prepare"].SemanticConfig == nil || !loadedRun.Stages["prepare"].SemanticConfig.Equal(*fingerprint) { - t.Fatalf("run fingerprint = %#v", loadedRun.Stages["prepare"].SemanticConfig) - } - path := filepath.Join(dir, "without-fingerprint.json") data := `{"schema_version":"narratio.session-manifest.v1","session_id":"session","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","stages":{"prepare":{"name":"prepare","status":"succeeded","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}}` if err := os.WriteFile(path, []byte(data), 0o644); err != nil { diff --git a/internal/manifest/store.go b/internal/manifest/store.go index 7f54986..85e4077 100644 --- a/internal/manifest/store.go +++ b/internal/manifest/store.go @@ -21,12 +21,6 @@ type Store interface { Save(ctx context.Context, path string, m *Manifest) error } -// RunStore persists invocation-scoped run manifests. -type RunStore interface { - CreateRun(ctx context.Context, sessionID, campaign, runID string, force bool, requestedStages []string) (*RunManifest, error) - SaveRun(ctx context.Context, path string, m *RunManifest) error -} - // LocalStore stores manifests as JSON on the local filesystem. type LocalStore struct{} @@ -136,117 +130,6 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error { return nil } -// CreateRun returns a new in-memory run manifest for one invocation. -func (s *LocalStore) CreateRun( - ctx context.Context, - sessionID, campaign, runID string, - force bool, - requestedStages []string, -) (*RunManifest, error) { - if err := checkContext(ctx); err != nil { - return nil, err - } - if strings.TrimSpace(sessionID) == "" { - return nil, fmt.Errorf("create run manifest: session_id is required") - } - if strings.TrimSpace(runID) == "" { - return nil, fmt.Errorf("create run manifest: run_id is required") - } - - now := time.Now().UTC() - return NewRun(sessionID, campaign, runID, force, requestedStages, now), nil -} - -// LoadRun reads and validates a local JSON run manifest from path. -func (s *LocalStore) LoadRun(ctx context.Context, path string) (*RunManifest, error) { - if err := checkContext(ctx); err != nil { - return nil, err - } - if strings.TrimSpace(path) == "" { - return nil, fmt.Errorf("load run manifest: path is required") - } - - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("load run manifest %q: %w", path, err) - } - defer file.Close() - return s.LoadRunReader(ctx, file) -} - -// LoadRunReader reads and validates a run manifest from a caller-owned reader. -func (s *LocalStore) LoadRunReader(ctx context.Context, source io.Reader) (*RunManifest, error) { - if err := checkContext(ctx); err != nil { - return nil, err - } - if source == nil { - return nil, fmt.Errorf("load run manifest: source is required") - } - - data, err := io.ReadAll(source) - if err != nil { - return nil, fmt.Errorf("read run manifest: %w", err) - } - - var m RunManifest - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("decode run manifest: %w", err) - } - - if err := validateLoadedRunManifest(&m); err != nil { - return nil, fmt.Errorf("run manifest invalid: %w", err) - } - normalizeRunManifest(&m) - - return &m, nil -} - -// SaveRun writes the run manifest to path atomically via temp file + rename. -func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) error { - if err := checkContext(ctx); err != nil { - return err - } - if strings.TrimSpace(path) == "" { - return fmt.Errorf("save run manifest: path is required") - } - if m == nil { - return fmt.Errorf("save run manifest: manifest is nil") - } - if err := validateRunManifestSchemaVersion(m.SchemaVersion); err != nil { - return fmt.Errorf("save run manifest: %w", err) - } - if strings.TrimSpace(m.SessionID) == "" { - return fmt.Errorf("save run manifest: session_id is required") - } - if strings.TrimSpace(m.RunID) == "" { - return fmt.Errorf("save run manifest: run_id is required") - } - if m.CreatedAt.IsZero() { - return fmt.Errorf("save run manifest: created_at is required") - } - if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { - return fmt.Errorf("save run manifest: %w", err) - } - if err := validateRunStages(m.Stages); err != nil { - return fmt.Errorf("save run manifest: %w", err) - } - for name, stage := range m.Stages { - 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() - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return fmt.Errorf("save run manifest: marshal: %w", err) - } - data = append(data, '\n') - - return writeJSONAtomically(ctx, path, data) -} - func validateLoadedManifest(m *Manifest) error { if m == nil { return fmt.Errorf("manifest is nil") @@ -313,45 +196,6 @@ func validatePostPublishCleanup(cleanup *PostPublishCleanup) error { return nil } -func validateLoadedRunManifest(m *RunManifest) error { - if m == nil { - return fmt.Errorf("manifest is nil") - } - if err := validateRunManifestSchemaVersion(m.SchemaVersion); err != nil { - return err - } - if strings.TrimSpace(m.SessionID) == "" { - return fmt.Errorf("session_id is required") - } - if strings.TrimSpace(m.RunID) == "" { - return fmt.Errorf("run_id is required") - } - if m.CreatedAt.IsZero() { - return fmt.Errorf("created_at is required") - } - if m.UpdatedAt.IsZero() { - return fmt.Errorf("updated_at is required") - } - if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil { - return err - } - if err := validateRunStages(m.Stages); err != nil { - return err - } - for name, stage := range m.Stages { - if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil { - return fmt.Errorf("stages.%s: %w", name, err) - } - if stage.SemanticConfig != nil { - if err := stage.SemanticConfig.Validate(); err != nil { - return fmt.Errorf("stages.%s.semantic_config: %w", name, err) - } - } - } - - return nil -} - func validateManifestIdentities(sessionID, campaign, runID string) error { identities := []struct { field string @@ -379,20 +223,10 @@ func validateManifestIdentities(sessionID, campaign, runID string) error { return nil } -func normalizeRunManifest(m *RunManifest) { - for _, stage := range m.Stages { - normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts) - } -} - func validateSessionManifestSchemaVersion(version string) error { return validateManifestSchemaVersion("session manifest", version, SessionManifestSchemaVersion) } -func validateRunManifestSchemaVersion(version string) error { - return validateManifestSchemaVersion("run manifest", version, RunManifestSchemaVersion) -} - func validateManifestSchemaVersion(kind, version, supported string) error { if version == supported { return nil @@ -421,24 +255,6 @@ func validateSessionStages(stages map[string]*StageRecord) error { return nil } -func validateRunStages(stages map[string]*RunStageRecord) error { - if stages == nil { - return fmt.Errorf("stages is required") - } - for name, stage := range stages { - if stage == nil { - return fmt.Errorf("stages.%s is nil", name) - } - if stage.Name == "" { - return fmt.Errorf("stages.%s.name is required", name) - } - if stage.Name != name { - return fmt.Errorf("stages.%s.name %q does not match map key", name, stage.Name) - } - } - return nil -} - func writeJSONAtomically(ctx context.Context, path string, data []byte) error { dir := filepath.Dir(path) if err := fileops.EnsureWorkspaceDirectory(dir); err != nil { diff --git a/internal/manifest/store_test.go b/internal/manifest/store_test.go index 88b7e49..1d6a0ec 100644 --- a/internal/manifest/store_test.go +++ b/internal/manifest/store_test.go @@ -256,83 +256,6 @@ func TestLoadRejectsInvalidManifest(t *testing.T) { } } -func TestLocalStoreCreateSaveLoadRunManifestRoundTrip(t *testing.T) { - store := &LocalStore{} - ctx := context.Background() - - run, err := store.CreateRun( - ctx, - "2026-05-03", - "forsaken", - "20260517T000000Z-abcdef12", - true, - []string{"prepare", "transcribe"}, - ) - if err != nil { - t.Fatalf("CreateRun() error = %v", err) - } - run.SessionManifestPath = "/var/lib/narratio/work/forsaken/2026-05-03/manifest.json" - run.EffectiveConfig = &EffectiveConfigProvenance{ - SelectedProfile: &SelectedProfileProvenance{Name: "testing", Source: "cli"}, - EffectiveConfigDigest: "aabbcc", - } - run.MarkStageRunning("prepare", time.Date(2026, 5, 3, 12, 1, 0, 0, time.UTC)) - run.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 12, 2, 0, 0, time.UTC), []ArtifactRecord{ - {Kind: "input", LocalPath: "inputs/session.yml"}, - }) - run.MarkSucceeded(time.Date(2026, 5, 3, 12, 3, 0, 0, time.UTC)) - - path := filepath.Join(t.TempDir(), "run-manifest.json") - if err := store.SaveRun(ctx, path, run); err != nil { - t.Fatalf("SaveRun() error = %v", err) - } - - loaded, err := store.LoadRun(ctx, path) - if err != nil { - t.Fatalf("LoadRun() error = %v", err) - } - - if loaded.SessionID != "2026-05-03" { - t.Fatalf("SessionID = %q, want %q", loaded.SessionID, "2026-05-03") - } - if loaded.Campaign != "forsaken" { - t.Fatalf("Campaign = %q, want %q", loaded.Campaign, "forsaken") - } - if loaded.RunID != "20260517T000000Z-abcdef12" { - t.Fatalf("RunID = %q, want %q", loaded.RunID, "20260517T000000Z-abcdef12") - } - if loaded.Status != RunManifestStatusSucceeded { - t.Fatalf("Status = %q, want %q", loaded.Status, RunManifestStatusSucceeded) - } - if loaded.Stages["prepare"] == nil || loaded.Stages["prepare"].Status != StatusSucceeded { - t.Fatalf("prepare stage = %#v, want succeeded", loaded.Stages["prepare"]) - } - if loaded.Stages["prepare"].Action != RunStageActionRun { - t.Fatalf("prepare action = %q, want %q", loaded.Stages["prepare"].Action, RunStageActionRun) - } - if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedProfile == nil || loaded.EffectiveConfig.SelectedProfile.Name != "testing" || loaded.EffectiveConfig.EffectiveConfigDigest != "aabbcc" { - t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig) - } -} - -func TestLoadRunRejectsInvalidManifest(t *testing.T) { - store := &LocalStore{} - ctx := context.Background() - - path := filepath.Join(t.TempDir(), "run-manifest.json") - if err := os.WriteFile(path, []byte(`{"schema_version":"narratio.run-manifest.v1","session_id":"2026-05-03","stages":{}}`), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - _, err := store.LoadRun(ctx, path) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "run_id is required") { - t.Fatalf("error = %q, want run_id validation", err.Error()) - } -} - func TestLocalStoreRejectsUnsupportedSchemaVersions(t *testing.T) { store := &LocalStore{} now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) @@ -370,32 +293,6 @@ func TestLocalStoreRejectsUnsupportedSchemaVersions(t *testing.T) { }, want: "unsupported session manifest schema_version", }, - { - name: "missing run schema", - schema: `{"session_id":"session","run_id":"run","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, - load: func(source *strings.Reader) error { - _, err := store.LoadRunReader(context.Background(), source) - return err - }, - save: func() error { - return store.SaveRun(context.Background(), filepath.Join(t.TempDir(), "run.json"), &RunManifest{SessionID: "session", RunID: "run", CreatedAt: now, UpdatedAt: now, Stages: map[string]*RunStageRecord{}}) - }, - want: "run manifest schema_version is required", - }, - { - name: "unknown run schema", - schema: `{"schema_version":"narratio.run-manifest.v99","session_id":"session","run_id":"run","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","stages":{}}`, - load: func(source *strings.Reader) error { - _, err := store.LoadRunReader(context.Background(), source) - return err - }, - save: func() error { - m := NewRun("session", "", "run", false, nil, now) - m.SchemaVersion = "narratio.run-manifest.v99" - return store.SaveRun(context.Background(), filepath.Join(t.TempDir(), "run.json"), m) - }, - want: "unsupported run manifest schema_version", - }, } { t.Run(test.name, func(t *testing.T) { if err := test.load(strings.NewReader(test.schema)); err == nil || !strings.Contains(err.Error(), test.want) { diff --git a/internal/stage/publish_test.go b/internal/stage/publish_test.go index 6b5556e..5c544c6 100644 --- a/internal/stage/publish_test.go +++ b/internal/stage/publish_test.go @@ -388,8 +388,10 @@ func TestPublishAllowsEmptyCurrentRunDiagnostics(t *testing.T) { t.Fatalf("DecodeRemoteCommitManifest() error = %v", err) } for _, artifact := range commit.Artifacts { - if artifact.Type == artifacts.RemoteArtifactTypeRunManifest { - t.Fatal("publish emitted a run manifest artifact") + switch artifact.Type { + case artifacts.RemoteArtifactTypeSessionManifest, artifacts.RemoteArtifactTypePublishedOutput, artifacts.RemoteArtifactTypePreviousArtifact: + default: + t.Fatalf("empty diagnostics published unexpected artifact type %q", artifact.Type) } } }