Model publish output outcomes
This commit is contained in:
@@ -33,7 +33,8 @@ and optional rules, destination resolution, and collision inputs live in
|
|||||||
`publish_outputs.go`. Copying, hashing, immutable upload, reuse checks, and
|
`publish_outputs.go`. Copying, hashing, immutable upload, reuse checks, and
|
||||||
remote verification live in `publish_transfer.go`; committed-manifest,
|
remote verification live in `publish_transfer.go`; committed-manifest,
|
||||||
immutable-commit, and current-pointer encoding and writes live in
|
immutable-commit, and current-pointer encoding and writes live in
|
||||||
`publish_commit.go`.
|
`publish_commit.go`. `publish_output_plan.go` owns the deterministic, I/O-free
|
||||||
|
five-outcome model and its secret-free metadata projection.
|
||||||
|
|
||||||
- when publishing or run upload is disabled, completes successfully with no
|
- when publishing or run upload is disabled, completes successfully with no
|
||||||
outputs and records explanatory metadata. This is not an explicit self-skip:
|
outputs and records explanatory metadata. This is not an explicit self-skip:
|
||||||
@@ -125,6 +126,7 @@ The commit boundary and cleanup gate are normative architecture invariants; see
|
|||||||
helpers.
|
helpers.
|
||||||
- Implementation and tests: `internal/stage/publish.go`,
|
- Implementation and tests: `internal/stage/publish.go`,
|
||||||
`internal/stage/publish_sources.go`, `internal/stage/publish_outputs.go`,
|
`internal/stage/publish_sources.go`, `internal/stage/publish_outputs.go`,
|
||||||
|
`internal/stage/publish_output_plan.go`,
|
||||||
`internal/stage/publish_transfer.go`, `internal/stage/publish_commit.go`,
|
`internal/stage/publish_transfer.go`, `internal/stage/publish_commit.go`,
|
||||||
`internal/stage/publish_test.go`,
|
`internal/stage/publish_test.go`,
|
||||||
`internal/stage/semantic_contracts_delivery.go`,
|
`internal/stage/semantic_contracts_delivery.go`,
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ partial-failure tests. Run `go test ./internal/stage -run Publish`,
|
|||||||
|
|
||||||
## Stage 6 — Model Locked Output Carry-Forward
|
## Stage 6 — Model Locked Output Carry-Forward
|
||||||
|
|
||||||
**Status:** Pending
|
**Status:** Completed
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
257
internal/stage/publish_output_plan.go
Normal file
257
internal/stage/publish_output_plan.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type publishOutputStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
publishOutputPublished publishOutputStatus = "published"
|
||||||
|
publishOutputCarriedLocked publishOutputStatus = "carried_locked"
|
||||||
|
publishOutputLockedUnpublished publishOutputStatus = "locked_unpublished"
|
||||||
|
publishOutputOptionalMissing publishOutputStatus = "optional_missing"
|
||||||
|
publishOutputUnselected publishOutputStatus = "unselected"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolvedPublishRule struct {
|
||||||
|
Source string
|
||||||
|
Destination string
|
||||||
|
Required bool
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type discoveredPublishOutput struct {
|
||||||
|
Source string
|
||||||
|
LocalPath string
|
||||||
|
Provenance string
|
||||||
|
}
|
||||||
|
|
||||||
|
type publishOutputOutcome struct {
|
||||||
|
Source string
|
||||||
|
Status publishOutputStatus
|
||||||
|
Destination string
|
||||||
|
DeclaredKey string
|
||||||
|
OriginRun string
|
||||||
|
Required bool
|
||||||
|
LocalPath string
|
||||||
|
Provenance string
|
||||||
|
Declaration artifacts.RemoteArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
type publishOutputPlan struct {
|
||||||
|
Outcomes []publishOutputOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPublishOutputPlan(
|
||||||
|
sessionPrefix string,
|
||||||
|
runPrefix string,
|
||||||
|
rules []resolvedPublishRule,
|
||||||
|
discovered []discoveredPublishOutput,
|
||||||
|
locks []artifacts.RemoteLock,
|
||||||
|
prior *artifacts.RemoteCommitManifest,
|
||||||
|
) (*publishOutputPlan, error) {
|
||||||
|
probeIdentity, err := artifacts.ParseImmutableRunObjectKey(sessionPrefix, publishOutputRemoteKey(runPrefix, "validation/object"))
|
||||||
|
if err != nil || probeIdentity.RelativePath != "published/validation/object" {
|
||||||
|
return nil, fmt.Errorf("publish run prefix %q is not canonical for session %q", runPrefix, sessionPrefix)
|
||||||
|
}
|
||||||
|
sources, err := indexDiscoveredPublishOutputs(discovered)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lockSet, err := indexPublishOutputLocks(locks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priorBySource, err := indexPriorPublishedOutputs(sessionPrefix, prior)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedRules := append([]resolvedPublishRule(nil), rules...)
|
||||||
|
sort.Slice(orderedRules, func(i, j int) bool { return orderedRules[i].Source < orderedRules[j].Source })
|
||||||
|
seenSources := make(map[string]struct{}, len(orderedRules))
|
||||||
|
configuredDestinations := make(map[string]string, len(orderedRules))
|
||||||
|
outcomes := make([]publishOutputOutcome, 0, len(orderedRules))
|
||||||
|
for _, rule := range orderedRules {
|
||||||
|
if err := validateResolvedPublishRule(rule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, duplicate := seenSources[rule.Source]; duplicate {
|
||||||
|
return nil, fmt.Errorf("publish output source %q is configured more than once", rule.Source)
|
||||||
|
}
|
||||||
|
seenSources[rule.Source] = struct{}{}
|
||||||
|
if other, collision := configuredDestinations[rule.Destination]; collision {
|
||||||
|
return nil, fmt.Errorf("publish output destination %q is configured for both %q and %q", rule.Destination, other, rule.Source)
|
||||||
|
}
|
||||||
|
configuredDestinations[rule.Destination] = rule.Source
|
||||||
|
|
||||||
|
outcome := publishOutputOutcome{
|
||||||
|
Source: rule.Source, Destination: rule.Destination, Required: rule.Required,
|
||||||
|
}
|
||||||
|
if !rule.Selected {
|
||||||
|
outcome.Status = publishOutputUnselected
|
||||||
|
outcomes = append(outcomes, outcome)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, locked := lockSet[rule.Source]; locked {
|
||||||
|
if declaration, found := priorBySource[rule.Source]; found {
|
||||||
|
identity, err := artifacts.ParseImmutableRunObjectKey(sessionPrefix, declaration.DestinationKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("carried publish output %q: %w", rule.Source, err)
|
||||||
|
}
|
||||||
|
outcome.Status = publishOutputCarriedLocked
|
||||||
|
outcome.Destination = strings.TrimPrefix(identity.RelativePath, "published/")
|
||||||
|
outcome.DeclaredKey = declaration.DestinationKey
|
||||||
|
outcome.OriginRun = identity.RunID
|
||||||
|
outcome.Declaration = declaration
|
||||||
|
} else {
|
||||||
|
outcome.Status = publishOutputLockedUnpublished
|
||||||
|
}
|
||||||
|
outcomes = append(outcomes, outcome)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discoveredSource, found := sources[rule.Source]
|
||||||
|
if !found {
|
||||||
|
if rule.Required {
|
||||||
|
return nil, fmt.Errorf("required output source unavailable: %q", rule.Source)
|
||||||
|
}
|
||||||
|
outcome.Status = publishOutputOptionalMissing
|
||||||
|
outcomes = append(outcomes, outcome)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
declaredKey := publishOutputRemoteKey(runPrefix, rule.Destination)
|
||||||
|
identity, err := artifacts.ParseImmutableRunObjectKey(sessionPrefix, declaredKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("publish output %q destination: %w", rule.Source, err)
|
||||||
|
}
|
||||||
|
outcome.Status = publishOutputPublished
|
||||||
|
outcome.DeclaredKey = declaredKey
|
||||||
|
outcome.OriginRun = identity.RunID
|
||||||
|
outcome.LocalPath = discoveredSource.LocalPath
|
||||||
|
outcome.Provenance = discoveredSource.Provenance
|
||||||
|
outcome.Declaration = artifacts.RemoteArtifact{
|
||||||
|
Type: artifacts.RemoteArtifactTypePublishedOutput, Source: rule.Source, DestinationKey: declaredKey,
|
||||||
|
}
|
||||||
|
outcomes = append(outcomes, outcome)
|
||||||
|
}
|
||||||
|
if err := validateEffectivePublishDestinations(outcomes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &publishOutputPlan{Outcomes: outcomes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResolvedPublishRule(rule resolvedPublishRule) error {
|
||||||
|
if strings.TrimSpace(rule.Source) != rule.Source || rule.Source == "" {
|
||||||
|
return fmt.Errorf("publish output source %q is not canonical", rule.Source)
|
||||||
|
}
|
||||||
|
if _, err := artifactpolicy.ClassifySource(rule.Source); err != nil {
|
||||||
|
return fmt.Errorf("publish output source %q: %w", rule.Source, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.Destination) != rule.Destination || rule.Destination == "" {
|
||||||
|
return fmt.Errorf("publish output %q destination is required and canonical", rule.Source)
|
||||||
|
}
|
||||||
|
normalized, err := pathsafe.NormalizeRelativeDestination(rule.Destination)
|
||||||
|
if err != nil || normalized != rule.Destination {
|
||||||
|
return fmt.Errorf("publish output %q destination %q is not canonical", rule.Source, rule.Destination)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexDiscoveredPublishOutputs(discovered []discoveredPublishOutput) (map[string]discoveredPublishOutput, error) {
|
||||||
|
out := make(map[string]discoveredPublishOutput, len(discovered))
|
||||||
|
for _, source := range discovered {
|
||||||
|
if strings.TrimSpace(source.Source) != source.Source || source.Source == "" {
|
||||||
|
return nil, fmt.Errorf("discovered publish source %q is not canonical", source.Source)
|
||||||
|
}
|
||||||
|
if _, err := artifactpolicy.ClassifySource(source.Source); err != nil {
|
||||||
|
return nil, fmt.Errorf("discovered publish source %q: %w", source.Source, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.LocalPath) == "" {
|
||||||
|
return nil, fmt.Errorf("discovered publish source %q has no local path", source.Source)
|
||||||
|
}
|
||||||
|
if _, duplicate := out[source.Source]; duplicate {
|
||||||
|
return nil, fmt.Errorf("discovered publish source %q is ambiguous", source.Source)
|
||||||
|
}
|
||||||
|
out[source.Source] = source
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexPublishOutputLocks(locks []artifacts.RemoteLock) (map[string]artifacts.RemoteLock, error) {
|
||||||
|
out := make(map[string]artifacts.RemoteLock, len(locks))
|
||||||
|
for _, lock := range locks {
|
||||||
|
if err := lock.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("publish output lock: %w", err)
|
||||||
|
}
|
||||||
|
if _, duplicate := out[lock.Source]; duplicate {
|
||||||
|
return nil, fmt.Errorf("publish output lock source %q is duplicated", lock.Source)
|
||||||
|
}
|
||||||
|
out[lock.Source] = lock
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexPriorPublishedOutputs(sessionPrefix string, prior *artifacts.RemoteCommitManifest) (map[string]artifacts.RemoteArtifact, error) {
|
||||||
|
out := map[string]artifacts.RemoteArtifact{}
|
||||||
|
if prior == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := prior.ValidateForSessionPrefix(sessionPrefix); err != nil {
|
||||||
|
return nil, fmt.Errorf("prior remote commit: %w", err)
|
||||||
|
}
|
||||||
|
for _, declaration := range prior.Artifacts {
|
||||||
|
if declaration.Type != artifacts.RemoteArtifactTypePublishedOutput {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, duplicate := out[declaration.Source]; duplicate {
|
||||||
|
return nil, fmt.Errorf("prior remote commit publishes source %q more than once", declaration.Source)
|
||||||
|
}
|
||||||
|
out[declaration.Source] = declaration
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEffectivePublishDestinations(outcomes []publishOutputOutcome) error {
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, outcome := range outcomes {
|
||||||
|
if outcome.Status != publishOutputPublished && outcome.Status != publishOutputCarriedLocked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if other, collision := seen[outcome.Destination]; collision {
|
||||||
|
return fmt.Errorf("effective publish destination %q is declared by both %q and %q", outcome.Destination, other, outcome.Source)
|
||||||
|
}
|
||||||
|
seen[outcome.Destination] = outcome.Source
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishOutputOutcomeMetadata(plan *publishOutputPlan) []map[string]any {
|
||||||
|
if plan == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(plan.Outcomes))
|
||||||
|
for _, outcome := range plan.Outcomes {
|
||||||
|
item := map[string]any{
|
||||||
|
"source": outcome.Source,
|
||||||
|
"status": string(outcome.Status),
|
||||||
|
}
|
||||||
|
if outcome.Destination != "" {
|
||||||
|
item["destination"] = outcome.Destination
|
||||||
|
}
|
||||||
|
if outcome.DeclaredKey != "" {
|
||||||
|
item["declared_key"] = outcome.DeclaredKey
|
||||||
|
}
|
||||||
|
if outcome.OriginRun != "" {
|
||||||
|
item["origin_run"] = outcome.OriginRun
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
197
internal/stage/publish_output_plan_test.go
Normal file
197
internal/stage/publish_output_plan_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPublishOutputPlanModelsEveryOutcome(t *testing.T) {
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
|
currentRun := "20260519T010203Z-a1b2c3d4"
|
||||||
|
runPrefix := artifacts.S3RunPrefix(sessionPrefix, currentRun)
|
||||||
|
carried := publishPlanDeclaration(sessionPrefix, "20260512T010203Z-11223344", "narratio.artifact.beta", "artifacts/beta-old.md")
|
||||||
|
prior := publishPlanCommit(carried)
|
||||||
|
locks := []artifacts.RemoteLock{
|
||||||
|
publishPlanLock(t, "narratio.artifact.beta"),
|
||||||
|
publishPlanLock(t, "narratio.artifact.gamma"),
|
||||||
|
}
|
||||||
|
rules := []resolvedPublishRule{
|
||||||
|
{Source: "narratio.artifact.gamma", Destination: "artifacts/gamma.md", Required: true, Selected: true},
|
||||||
|
{Source: "narratio.artifact.alpha", Destination: "artifacts/alpha.md", Required: true, Selected: true},
|
||||||
|
{Source: "narratio.artifact.epsilon", Destination: "artifacts/epsilon.md", Required: true, Selected: false},
|
||||||
|
{Source: "narratio.artifact.beta", Destination: "artifacts/beta-new.md", Required: true, Selected: true},
|
||||||
|
{Source: "narratio.artifact.delta", Destination: "artifacts/delta.md", Required: false, Selected: true},
|
||||||
|
}
|
||||||
|
discovered := []discoveredPublishOutput{
|
||||||
|
{Source: "narratio.artifact.alpha", LocalPath: "/workspace/alpha.md", Provenance: "current"},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := buildPublishOutputPlan(sessionPrefix, runPrefix, rules, discovered, locks, prior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildPublishOutputPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
wantSources := []string{
|
||||||
|
"narratio.artifact.alpha",
|
||||||
|
"narratio.artifact.beta",
|
||||||
|
"narratio.artifact.delta",
|
||||||
|
"narratio.artifact.epsilon",
|
||||||
|
"narratio.artifact.gamma",
|
||||||
|
}
|
||||||
|
wantStatuses := []publishOutputStatus{
|
||||||
|
publishOutputPublished,
|
||||||
|
publishOutputCarriedLocked,
|
||||||
|
publishOutputOptionalMissing,
|
||||||
|
publishOutputUnselected,
|
||||||
|
publishOutputLockedUnpublished,
|
||||||
|
}
|
||||||
|
if len(plan.Outcomes) != len(wantSources) {
|
||||||
|
t.Fatalf("outcomes = %#v", plan.Outcomes)
|
||||||
|
}
|
||||||
|
for index := range wantSources {
|
||||||
|
if plan.Outcomes[index].Source != wantSources[index] || plan.Outcomes[index].Status != wantStatuses[index] {
|
||||||
|
t.Fatalf("outcomes[%d] = %#v, want source %q status %q", index, plan.Outcomes[index], wantSources[index], wantStatuses[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(plan.Outcomes[1].Declaration, carried) {
|
||||||
|
t.Fatalf("carried declaration = %#v, want exact %#v", plan.Outcomes[1].Declaration, carried)
|
||||||
|
}
|
||||||
|
if plan.Outcomes[1].Destination != "artifacts/beta-old.md" || plan.Outcomes[1].OriginRun != "20260512T010203Z-11223344" {
|
||||||
|
t.Fatalf("carried outcome = %#v", plan.Outcomes[1])
|
||||||
|
}
|
||||||
|
if plan.Outcomes[4].Declaration != (artifacts.RemoteArtifact{}) {
|
||||||
|
t.Fatalf("locked unpublished declaration = %#v, want none", plan.Outcomes[4].Declaration)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := publishOutputOutcomeMetadata(plan)
|
||||||
|
if len(metadata) != len(plan.Outcomes) {
|
||||||
|
t.Fatalf("metadata = %#v", metadata)
|
||||||
|
}
|
||||||
|
if metadata[1]["status"] != "carried_locked" || metadata[1]["declared_key"] != carried.DestinationKey || metadata[1]["origin_run"] != "20260512T010203Z-11223344" {
|
||||||
|
t.Fatalf("carried metadata = %#v", metadata[1])
|
||||||
|
}
|
||||||
|
for _, item := range metadata {
|
||||||
|
if _, exposed := item["local_path"]; exposed {
|
||||||
|
t.Fatalf("metadata exposes local path: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPublishOutputPlanRejectsAmbiguities(t *testing.T) {
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
|
runPrefix := artifacts.S3RunPrefix(sessionPrefix, "20260519T010203Z-a1b2c3d4")
|
||||||
|
available := []discoveredPublishOutput{{Source: "narratio.artifact.alpha", LocalPath: "/workspace/alpha.md"}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rules []resolvedPublishRule
|
||||||
|
discovered []discoveredPublishOutput
|
||||||
|
locks []artifacts.RemoteLock
|
||||||
|
prior *artifacts.RemoteCommitManifest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "duplicate configured source",
|
||||||
|
rules: []resolvedPublishRule{
|
||||||
|
{Source: "narratio.artifact.alpha", Destination: "artifacts/a.md", Selected: true},
|
||||||
|
{Source: "narratio.artifact.alpha", Destination: "artifacts/b.md", Selected: true},
|
||||||
|
},
|
||||||
|
discovered: available,
|
||||||
|
want: "configured more than once",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configured destination collision",
|
||||||
|
rules: []resolvedPublishRule{
|
||||||
|
{Source: "narratio.artifact.alpha", Destination: "artifacts/shared.md", Selected: true},
|
||||||
|
{Source: "narratio.artifact.beta", Destination: "artifacts/shared.md", Selected: false},
|
||||||
|
},
|
||||||
|
discovered: available,
|
||||||
|
want: "configured for both",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ambiguous discovery",
|
||||||
|
rules: []resolvedPublishRule{{Source: "narratio.artifact.alpha", Destination: "artifacts/a.md", Selected: true}},
|
||||||
|
discovered: []discoveredPublishOutput{
|
||||||
|
{Source: "narratio.artifact.alpha", LocalPath: "/workspace/a.md"},
|
||||||
|
{Source: "narratio.artifact.alpha", LocalPath: "/workspace/other.md"},
|
||||||
|
},
|
||||||
|
want: "ambiguous",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate prior source",
|
||||||
|
rules: []resolvedPublishRule{{Source: "narratio.artifact.alpha", Destination: "artifacts/a.md", Selected: true}},
|
||||||
|
locks: []artifacts.RemoteLock{publishPlanLock(t, "narratio.artifact.alpha")},
|
||||||
|
prior: publishPlanCommit(
|
||||||
|
publishPlanDeclaration(sessionPrefix, "20260512T010203Z-11223344", "narratio.artifact.alpha", "artifacts/a.md"),
|
||||||
|
publishPlanDeclaration(sessionPrefix, "20260512T010203Z-11223344", "narratio.artifact.alpha", "artifacts/other.md"),
|
||||||
|
),
|
||||||
|
want: "ambiguous published_output source",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := buildPublishOutputPlan(sessionPrefix, runPrefix, test.rules, test.discovered, test.locks, test.prior)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("buildPublishOutputPlan() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPublishOutputPlanRejectsInvalidCarryAndEffectiveCollision(t *testing.T) {
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
|
runPrefix := artifacts.S3RunPrefix(sessionPrefix, "20260519T010203Z-a1b2c3d4")
|
||||||
|
locked := publishPlanLock(t, "narratio.artifact.beta")
|
||||||
|
rules := []resolvedPublishRule{
|
||||||
|
{Source: "narratio.artifact.alpha", Destination: "artifacts/shared.md", Required: true, Selected: true},
|
||||||
|
{Source: "narratio.artifact.beta", Destination: "artifacts/new-beta.md", Required: true, Selected: true},
|
||||||
|
}
|
||||||
|
discovered := []discoveredPublishOutput{{Source: "narratio.artifact.alpha", LocalPath: "/workspace/alpha.md"}}
|
||||||
|
|
||||||
|
crossSession := publishPlanDeclaration(
|
||||||
|
artifacts.S3SessionPrefix("dnd", "other-campaign", "2026-05-03"),
|
||||||
|
"20260512T010203Z-11223344",
|
||||||
|
"narratio.artifact.beta",
|
||||||
|
"artifacts/shared.md",
|
||||||
|
)
|
||||||
|
if _, err := buildPublishOutputPlan(sessionPrefix, runPrefix, rules, discovered, []artifacts.RemoteLock{locked}, publishPlanCommit(crossSession)); err == nil || !strings.Contains(err.Error(), "outside session run prefix") {
|
||||||
|
t.Fatalf("cross-session carry error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
carried := publishPlanDeclaration(sessionPrefix, "20260512T010203Z-11223344", "narratio.artifact.beta", "artifacts/shared.md")
|
||||||
|
if _, err := buildPublishOutputPlan(sessionPrefix, runPrefix, rules, discovered, []artifacts.RemoteLock{locked}, publishPlanCommit(carried)); err == nil || !strings.Contains(err.Error(), "effective publish destination") {
|
||||||
|
t.Fatalf("effective destination collision error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishPlanLock(t *testing.T, source string) artifacts.RemoteLock {
|
||||||
|
t.Helper()
|
||||||
|
lock, err := artifacts.NewRemoteLock(source, "review", time.Date(2026, 5, 18, 1, 2, 3, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRemoteLock() error = %v", err)
|
||||||
|
}
|
||||||
|
return lock
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishPlanDeclaration(sessionPrefix, runID, source, destination string) artifacts.RemoteArtifact {
|
||||||
|
return artifacts.RemoteArtifact{
|
||||||
|
Type: artifacts.RemoteArtifactTypePublishedOutput,
|
||||||
|
Source: source,
|
||||||
|
DestinationKey: publishOutputRemoteKey(artifacts.S3RunPrefix(sessionPrefix, runID), destination),
|
||||||
|
SHA256: strings.Repeat("a", 64),
|
||||||
|
Size: 17,
|
||||||
|
Generation: "generation-" + source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishPlanCommit(declarations ...artifacts.RemoteArtifact) *artifacts.RemoteCommitManifest {
|
||||||
|
return &artifacts.RemoteCommitManifest{
|
||||||
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
RunID: "20260512T010203Z-11223344",
|
||||||
|
Artifacts: declarations,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user