Add artifact provenance and stage skip outcomes
This commit is contained in:
@@ -195,6 +195,9 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
||||
|
||||
result, err := s.Run(ctx, stageEnv, m)
|
||||
if err == nil {
|
||||
err = validateStageResult(result)
|
||||
}
|
||||
if err != nil {
|
||||
failedAt := nowUTC()
|
||||
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||
@@ -209,6 +212,25 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
||||
return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err)
|
||||
}
|
||||
if result != nil && result.Disposition == stage.StageDispositionSkipped {
|
||||
skipped = append(skipped, s.Name())
|
||||
skippedAt := nowUTC()
|
||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||
clearStageResultDetails(m.Stages[s.Name()])
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return nil, fmt.Errorf("save manifest after self-skip %q: %w", s.Name(), err)
|
||||
}
|
||||
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||
syncRunManifestIdentityFromSession(m, runManifest)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
return nil, fmt.Errorf("save run manifest after self-skip %q: %w", s.Name(), err)
|
||||
}
|
||||
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "skipped", "path", manifestPath)
|
||||
env.Logger.Info("stage skipped", "stage", s.Name(), "reason", result.SkipReason)
|
||||
continue
|
||||
}
|
||||
|
||||
outputs := mapResultOutputs(s.Name(), result, runID)
|
||||
succeededAt := nowUTC()
|
||||
@@ -407,26 +429,69 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
||||
localPath = ref.RelativePath
|
||||
}
|
||||
kind := ref.Kind
|
||||
sourceID := ""
|
||||
if stageName == "analyze" {
|
||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||
kind = "scriptorium_artifact"
|
||||
} else {
|
||||
sourceID = sourceIDForOutputKind(kind)
|
||||
sourceID := strings.TrimSpace(ref.SourceID)
|
||||
if sourceID == "" {
|
||||
if stageName == "analyze" {
|
||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||
kind = "scriptorium_artifact"
|
||||
} else {
|
||||
sourceID = sourceIDForOutputKind(kind)
|
||||
}
|
||||
}
|
||||
out = append(out, manifest.ArtifactRecord{
|
||||
Kind: kind,
|
||||
SourceID: sourceID,
|
||||
LocalPath: localPath,
|
||||
ProducerRunID: runID,
|
||||
RemoteKey: ref.RemoteKey,
|
||||
Checksum: ref.Checksum,
|
||||
Kind: kind,
|
||||
SourceID: sourceID,
|
||||
LocalPath: localPath,
|
||||
Contract: cloneContractMetadata(ref.Contract),
|
||||
ExternalProvenance: cloneExternalProvenance(ref.ExternalProvenance),
|
||||
ProducerRunID: runID,
|
||||
RemoteKey: ref.RemoteKey,
|
||||
Checksum: ref.Checksum,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func validateStageResult(result *stage.StageResult) error {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
switch result.Disposition {
|
||||
case stage.StageDispositionSucceeded:
|
||||
if strings.TrimSpace(result.SkipReason) != "" {
|
||||
return fmt.Errorf("successful result contains a skip reason")
|
||||
}
|
||||
return nil
|
||||
case stage.StageDispositionSkipped:
|
||||
if strings.TrimSpace(result.SkipReason) == "" {
|
||||
return fmt.Errorf("skipped result requires a skip reason")
|
||||
}
|
||||
if len(result.Outputs) != 0 {
|
||||
return fmt.Errorf("skipped result contains %d output(s)", len(result.Outputs))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported stage result disposition %q", result.Disposition)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneContractMetadata(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneExternalProvenance(value *artifactmodel.ExternalProvenance) *artifactmodel.ExternalProvenance {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func sourceIDForOutputKind(kind string) string {
|
||||
trimmed := strings.TrimSpace(kind)
|
||||
if trimmed == "" {
|
||||
@@ -462,6 +527,15 @@ func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *
|
||||
}
|
||||
}
|
||||
|
||||
func clearStageResultDetails(sr *manifest.StageRecord) {
|
||||
if sr == nil {
|
||||
return
|
||||
}
|
||||
sr.Logs = nil
|
||||
sr.GeneratedConfigs = nil
|
||||
sr.Metadata = nil
|
||||
}
|
||||
|
||||
func ensureManifestIdentity(cfg *config.Config, m *manifest.Manifest, runID string) (bool, error) {
|
||||
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil || m == nil {
|
||||
return false, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
@@ -38,6 +39,25 @@ type countingStage struct {
|
||||
runs *int
|
||||
}
|
||||
|
||||
type resultStage struct {
|
||||
name string
|
||||
result *stage.StageResult
|
||||
runs *int
|
||||
order *[]string
|
||||
}
|
||||
|
||||
func (s resultStage) Name() string { return s.name }
|
||||
func (s resultStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s resultStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
if s.runs != nil {
|
||||
*s.runs = *s.runs + 1
|
||||
}
|
||||
if s.order != nil {
|
||||
*s.order = append(*s.order, s.name)
|
||||
}
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
func (s countingStage) Name() string { return s.name }
|
||||
func (s countingStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s countingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
@@ -199,6 +219,61 @@ func TestExecuteStagesAnalyzeOutputsPersistAsScriptoriumArtifacts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapResultOutputsPrefersExplicitSourceAndCopiesMetadata(t *testing.T) {
|
||||
contract := &artifactmodel.ContractMetadata{
|
||||
MediaType: "application/json",
|
||||
SchemaID: "notarius.dnd.npc_registry",
|
||||
SchemaVersion: "v1",
|
||||
}
|
||||
provenance := &artifactmodel.ExternalProvenance{
|
||||
System: "notarius",
|
||||
RunID: "external-run",
|
||||
PipelineID: "dnd-session",
|
||||
ArtifactID: "npc-registry",
|
||||
}
|
||||
result := &stage.StageResult{Outputs: []artifacts.Ref{{
|
||||
Kind: "structured_data",
|
||||
SourceID: "narratio.example.npcs",
|
||||
RelativePath: "artifacts/npcs.json",
|
||||
Contract: contract,
|
||||
ExternalProvenance: provenance,
|
||||
}}}
|
||||
|
||||
got := mapResultOutputs("analyze", result, "narratio-run")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("outputs len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].SourceID != "narratio.example.npcs" {
|
||||
t.Fatalf("source_id = %q, want explicit source", got[0].SourceID)
|
||||
}
|
||||
if got[0].Kind != "structured_data" {
|
||||
t.Fatalf("kind = %q, want explicit output kind preserved", got[0].Kind)
|
||||
}
|
||||
if got[0].Contract == nil || *got[0].Contract != *contract {
|
||||
t.Fatalf("contract = %#v, want %#v", got[0].Contract, contract)
|
||||
}
|
||||
if got[0].ExternalProvenance == nil || *got[0].ExternalProvenance != *provenance {
|
||||
t.Fatalf("external provenance = %#v, want %#v", got[0].ExternalProvenance, provenance)
|
||||
}
|
||||
if got[0].Contract == contract || got[0].ExternalProvenance == provenance {
|
||||
t.Fatal("mapped metadata should not alias the stage result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapResultOutputsRetainsFallbackInference(t *testing.T) {
|
||||
transcript := mapResultOutputs("trim", &stage.StageResult{Outputs: []artifacts.Ref{{
|
||||
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed,
|
||||
}}}, "run-id")
|
||||
if len(transcript) != 1 || transcript[0].SourceID != artifacts.ArtifactTranscriptFinalTrimmed {
|
||||
t.Fatalf("transcript fallback = %#v, want final-trimmed source", transcript)
|
||||
}
|
||||
|
||||
analyze := mapResultOutputs("analyze", &stage.StageResult{Outputs: []artifacts.Ref{{Kind: "session_recap"}}}, "run-id")
|
||||
if len(analyze) != 1 || analyze[0].SourceID != "narratio.artifact.session_recap" || analyze[0].Kind != "scriptorium_artifact" {
|
||||
t.Fatalf("analyze fallback = %#v, want configured artifact inference", analyze)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -738,6 +813,126 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||
Kind: "old_output",
|
||||
SourceID: "narratio.example.old",
|
||||
LocalPath: "artifacts/old.json",
|
||||
}})
|
||||
seed.Stages["optional"].Logs = []string{"old.log"}
|
||||
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
|
||||
seed.Stages["optional"].Metadata = map[string]any{"old": true}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
t.Fatalf("Save() seed manifest error = %v", err)
|
||||
}
|
||||
|
||||
order := []string{}
|
||||
optionalRuns := 0
|
||||
stages := []stage.Stage{
|
||||
resultStage{
|
||||
name: "optional",
|
||||
runs: &optionalRuns,
|
||||
order: &order,
|
||||
result: &stage.StageResult{
|
||||
Disposition: stage.StageDispositionSkipped,
|
||||
SkipReason: "integration_disabled",
|
||||
Logs: []string{"runs/current/optional.log"},
|
||||
GeneratedConfigs: []string{"runs/current/optional.yml"},
|
||||
Metadata: map[string]any{"enabled": false},
|
||||
},
|
||||
},
|
||||
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
|
||||
}
|
||||
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if strings.Join(order, ",") != "optional,later" {
|
||||
t.Fatalf("execution order = %v, want optional then later", order)
|
||||
}
|
||||
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
|
||||
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
||||
}
|
||||
|
||||
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() session manifest error = %v", err)
|
||||
}
|
||||
selfSkipped := sessionManifest.Stages["optional"]
|
||||
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
||||
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
||||
}
|
||||
if len(selfSkipped.Outputs) != 0 {
|
||||
t.Fatalf("optional outputs = %#v, want old outputs cleared", selfSkipped.Outputs)
|
||||
}
|
||||
if selfSkipped.Error == nil || selfSkipped.Error.Message != "integration_disabled" {
|
||||
t.Fatalf("optional skip reason = %#v, want integration_disabled", selfSkipped.Error)
|
||||
}
|
||||
if len(selfSkipped.Logs) != 1 || selfSkipped.Logs[0] != "runs/current/optional.log" ||
|
||||
len(selfSkipped.GeneratedConfigs) != 1 || selfSkipped.GeneratedConfigs[0] != "runs/current/optional.yml" ||
|
||||
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
||||
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
||||
}
|
||||
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("later stage = %#v, want succeeded", later)
|
||||
}
|
||||
|
||||
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRun() error = %v", err)
|
||||
}
|
||||
runStage := runManifest.Stages["optional"]
|
||||
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
||||
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
||||
}
|
||||
if len(runStage.Logs) != 1 || runStage.Metadata["enabled"] != false {
|
||||
t.Fatalf("run optional stage details = %#v, want result diagnostics and metadata", runStage)
|
||||
}
|
||||
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{stages[0]}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("second executeStages() error = %v", err)
|
||||
}
|
||||
if optionalRuns != 2 {
|
||||
t.Fatalf("optional runs = %d, want self-skipped stage reconsidered", optionalRuns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
invalid := resultStage{name: "optional", result: &stage.StageResult{
|
||||
Disposition: stage.StageDispositionSkipped,
|
||||
SkipReason: "integration_disabled",
|
||||
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
||||
}}
|
||||
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{invalid}, RunOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("executeStages() error = nil, want invalid skipped result failure")
|
||||
}
|
||||
if summary != nil {
|
||||
t.Fatalf("summary = %#v, want nil", summary)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "skipped result contains 1 output") {
|
||||
t.Fatalf("error = %q, want skipped-output validation", err)
|
||||
}
|
||||
|
||||
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Load() session manifest error = %v", loadErr)
|
||||
}
|
||||
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
|
||||
t.Fatalf("optional stage = %#v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRunLocalArtifactsAndCanonicalSync(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
stages := []stage.Stage{
|
||||
|
||||
17
internal/artifactmodel/metadata.go
Normal file
17
internal/artifactmodel/metadata.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package artifactmodel
|
||||
|
||||
// ContractMetadata identifies the data contract implemented by an artifact.
|
||||
type ContractMetadata struct {
|
||||
MediaType string `json:"media_type"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ModuleKey string `json:"module_key,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalProvenance identifies an artifact produced by an external system.
|
||||
type ExternalProvenance struct {
|
||||
System string `json:"system"`
|
||||
RunID string `json:"run_id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
}
|
||||
56
internal/artifactmodel/metadata_test.go
Normal file
56
internal/artifactmodel/metadata_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package artifactmodel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactMetadataJSON(t *testing.T) {
|
||||
type envelope struct {
|
||||
Contract *ContractMetadata `json:"contract,omitempty"`
|
||||
ExternalProvenance *ExternalProvenance `json:"external_provenance,omitempty"`
|
||||
}
|
||||
|
||||
complete, err := json.Marshal(envelope{
|
||||
Contract: &ContractMetadata{
|
||||
MediaType: "application/json",
|
||||
SchemaID: "notarius.dnd.npc_registry",
|
||||
SchemaVersion: "v1",
|
||||
ModuleKey: "dnd/npc-registry",
|
||||
},
|
||||
ExternalProvenance: &ExternalProvenance{
|
||||
System: "notarius",
|
||||
RunID: "run-123",
|
||||
PipelineID: "dnd-session",
|
||||
ArtifactID: "npc-registry",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() complete metadata error = %v", err)
|
||||
}
|
||||
wantComplete := `{"contract":{"media_type":"application/json","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","module_key":"dnd/npc-registry"},"external_provenance":{"system":"notarius","run_id":"run-123","pipeline_id":"dnd-session","artifact_id":"npc-registry"}}`
|
||||
if string(complete) != wantComplete {
|
||||
t.Fatalf("complete metadata JSON = %s, want %s", complete, wantComplete)
|
||||
}
|
||||
|
||||
omitted, err := json.Marshal(envelope{})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() omitted metadata error = %v", err)
|
||||
}
|
||||
if string(omitted) != `{}` {
|
||||
t.Fatalf("omitted metadata JSON = %s, want {}", omitted)
|
||||
}
|
||||
|
||||
withoutModule, err := json.Marshal(envelope{Contract: &ContractMetadata{
|
||||
MediaType: "application/json",
|
||||
SchemaID: "notarius.dnd.npc_registry",
|
||||
SchemaVersion: "v1",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() contract without module key error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(withoutModule), "module_key") {
|
||||
t.Fatalf("contract JSON unexpectedly contains omitted module_key: %s", withoutModule)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
package artifacts
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
)
|
||||
|
||||
// Ref identifies a pipeline artifact and its local/remote coordinates.
|
||||
type Ref struct {
|
||||
Kind string
|
||||
Category string
|
||||
SessionID string
|
||||
RelativePath string
|
||||
AbsolutePath string
|
||||
RemoteKey string
|
||||
Checksum string
|
||||
Kind string
|
||||
SourceID string
|
||||
Category string
|
||||
SessionID string
|
||||
RelativePath string
|
||||
AbsolutePath string
|
||||
RemoteKey string
|
||||
Checksum string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
ExternalProvenance *artifactmodel.ExternalProvenance
|
||||
}
|
||||
|
||||
// Store is the local artifact/workdir abstraction used by orchestration code.
|
||||
|
||||
@@ -3,6 +3,8 @@ package manifest
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
)
|
||||
|
||||
// ErrorRecord captures structured error metadata at run or stage scope.
|
||||
@@ -28,9 +30,11 @@ type InputRecord struct {
|
||||
|
||||
// ArtifactRecord captures one produced artifact and optional remote metadata.
|
||||
type ArtifactRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
LocalPath string `json:"local_path"`
|
||||
Kind string `json:"kind"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
LocalPath string `json:"local_path"`
|
||||
Contract *artifactmodel.ContractMetadata `json:"contract,omitempty"`
|
||||
ExternalProvenance *artifactmodel.ExternalProvenance `json:"external_provenance,omitempty"`
|
||||
// ProducerRunID identifies the run that produced this durable artifact.
|
||||
ProducerRunID string `json:"producer_run_id,omitempty"`
|
||||
RemoteKey string `json:"remote_key,omitempty"`
|
||||
@@ -120,6 +124,7 @@ func (m *Manifest) MarkStageSkipped(name string, at time.Time, reason string) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,3 +110,23 @@ func TestMarkStageSucceededClearsError(t *testing.T) {
|
||||
t.Fatalf("error = %#v, want nil on success", stage.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkStageSkippedClearsEarlierOutputs(t *testing.T) {
|
||||
m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), []ArtifactRecord{
|
||||
{Kind: "structured_data", SourceID: "narratio.example.characters", LocalPath: "artifacts/characters.json"},
|
||||
})
|
||||
|
||||
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), "integration_disabled")
|
||||
|
||||
stage := m.Stages["extract"]
|
||||
if stage == nil {
|
||||
t.Fatal("missing stage record")
|
||||
}
|
||||
if stage.Status != StatusSkipped {
|
||||
t.Fatalf("status = %q, want %q", stage.Status, StatusSkipped)
|
||||
}
|
||||
if len(stage.Outputs) != 0 {
|
||||
t.Fatalf("outputs = %#v, want cleared", stage.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ func (m *RunManifest) MarkStageSkipped(name string, at time.Time, reason string)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
)
|
||||
|
||||
func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
||||
@@ -64,6 +66,76 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStoreArtifactMetadataCompatibility(t *testing.T) {
|
||||
store := &LocalStore{}
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "old-manifest.json")
|
||||
oldJSON := `{
|
||||
"session_id": "2026-05-03",
|
||||
"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":"scriptorium_artifact","local_path":"artifacts/recap.md"}]
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(oldPath, []byte(oldJSON), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() old manifest error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load(ctx, oldPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() old manifest error = %v", err)
|
||||
}
|
||||
oldOutput := loaded.Stages["analyze"].Outputs[0]
|
||||
if oldOutput.Contract != nil || oldOutput.ExternalProvenance != nil {
|
||||
t.Fatalf("old output metadata = %#v, %#v; want nil", oldOutput.Contract, oldOutput.ExternalProvenance)
|
||||
}
|
||||
if err := store.Save(ctx, oldPath, loaded); err != nil {
|
||||
t.Fatalf("Save() old manifest error = %v", err)
|
||||
}
|
||||
roundTripped, err := os.ReadFile(oldPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() round-tripped old manifest error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(roundTripped), `"contract"`) || strings.Contains(string(roundTripped), `"external_provenance"`) {
|
||||
t.Fatalf("old manifest gained fabricated metadata:\n%s", roundTripped)
|
||||
}
|
||||
|
||||
loaded.Stages["analyze"].Outputs[0].Contract = &artifactmodel.ContractMetadata{
|
||||
MediaType: "application/json",
|
||||
SchemaID: "example.recap",
|
||||
SchemaVersion: "v1",
|
||||
}
|
||||
loaded.Stages["analyze"].Outputs[0].ExternalProvenance = &artifactmodel.ExternalProvenance{
|
||||
System: "example",
|
||||
RunID: "external-run",
|
||||
PipelineID: "pipeline",
|
||||
ArtifactID: "recap",
|
||||
}
|
||||
metadataPath := filepath.Join(dir, "metadata-manifest.json")
|
||||
if err := store.Save(ctx, metadataPath, loaded); err != nil {
|
||||
t.Fatalf("Save() metadata manifest error = %v", err)
|
||||
}
|
||||
withMetadata, err := store.Load(ctx, metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() metadata manifest error = %v", err)
|
||||
}
|
||||
got := withMetadata.Stages["analyze"].Outputs[0]
|
||||
if got.Contract == nil || got.Contract.SchemaID != "example.recap" {
|
||||
t.Fatalf("contract = %#v, want persisted contract", got.Contract)
|
||||
}
|
||||
if got.ExternalProvenance == nil || got.ExternalProvenance.RunID != "external-run" {
|
||||
t.Fatalf("external provenance = %#v, want persisted provenance", got.ExternalProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStoreSaveUpdatesTimestamp(t *testing.T) {
|
||||
store := &LocalStore{}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -44,8 +44,19 @@ type Stage interface {
|
||||
Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error)
|
||||
}
|
||||
|
||||
// StageDisposition describes the outcome of a stage that returned without an error.
|
||||
type StageDisposition string
|
||||
|
||||
const (
|
||||
// StageDispositionSucceeded is the zero value so existing stages remain successful.
|
||||
StageDispositionSucceeded StageDisposition = ""
|
||||
StageDispositionSkipped StageDisposition = "skipped"
|
||||
)
|
||||
|
||||
// StageResult is the declared output of a stage execution.
|
||||
type StageResult struct {
|
||||
Disposition StageDisposition
|
||||
SkipReason string
|
||||
Outputs []artifacts.Ref
|
||||
Logs []string
|
||||
GeneratedConfigs []string
|
||||
|
||||
Reference in New Issue
Block a user