Add artifact provenance and stage skip outcomes

This commit is contained in:
2026-08-09 23:20:32 +00:00
parent df58595d1e
commit 951383226c
12 changed files with 1565 additions and 305 deletions

View File

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

View File

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

View 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"`
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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