Delete run manifest domain model

This commit is contained in:
2026-09-03 04:16:36 +00:00
parent 2d20900cbf
commit c0fbc14f2d
15 changed files with 25 additions and 595 deletions

View File

@@ -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;

View File

@@ -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.

View File

@@ -285,7 +285,7 @@ prematurely implementing Workstream 6 checkpoint semantics.
## Stage 4 — Delete The Run-Manifest Domain Model
**Status: Pending**
**Status: Completed**
### Goal

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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")

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
}
}
}