Align internal publish terminology across stage, app, and artifacts

This commit is contained in:
2026-05-23 13:39:15 +00:00
parent 7d584ee6cd
commit 96b886e711
25 changed files with 306 additions and 306 deletions

View File

@@ -33,7 +33,7 @@ func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
} }
} }
func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) { func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
@@ -281,7 +281,7 @@ func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
} }
} }
func TestExecutePublishForceRunsArchive(t *testing.T) { func TestExecutePublishForceRunsPublish(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)

View File

@@ -82,7 +82,7 @@ func Session(ctx context.Context, args []string, out io.Writer) error {
} }
} }
// SessionLocks dispatches session-oriented archive lock list and mutation // SessionLocks dispatches session-oriented publish lock list and mutation
// helpers while preserving the existing lock implementations. // helpers while preserving the existing lock implementations.
func SessionLocks(ctx context.Context, args []string, out io.Writer) error { func SessionLocks(ctx context.Context, args []string, out io.Writer) error {
if len(args) > 0 && !isCLIFlagToken(args[0]) { if len(args) > 0 && !isCLIFlagToken(args[0]) {
@@ -179,7 +179,7 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
if lockErr != nil { if lockErr != nil {
findings = append(findings, errorFinding("locks", lockErr.Error())) findings = append(findings, errorFinding("locks", lockErr.Error()))
} else if len(locks.All) == 0 { } else if len(locks.All) == 0 {
findings = append(findings, okFinding("locks", "no effective archive locks")) findings = append(findings, okFinding("locks", "no effective publish locks"))
} else { } else {
for _, lock := range locks.All { for _, lock := range locks.All {
findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason)))) findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason))))
@@ -258,8 +258,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
catalogLocks := locks catalogLocks := locks
if err != nil { if err != nil {
catalogLocks = &effectiveLocks{ catalogLocks = &effectiveLocks{
Static: staticArchiveLocks(cfg), Static: staticPublishLocks(cfg),
All: staticArchiveLocks(cfg), All: staticPublishLocks(cfg),
} }
} }
publishedRemoteState := map[string]string{} publishedRemoteState := map[string]string{}
@@ -411,7 +411,7 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
var flags commonConfigFlags var flags commonConfigFlags
var remote bool var remote bool
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
fs.BoolVar(&remote, "remote", false, "inspect remote archive availability") fs.BoolVar(&remote, "remote", false, "inspect remote publish availability")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("artifacts list: invalid flags: %w", err) return fmt.Errorf("artifacts list: invalid flags: %w", err)
} }
@@ -446,7 +446,7 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
return nil return nil
} }
// Locks dispatches archive lock list and mutation helpers. // Locks dispatches publish lock list and mutation helpers.
func Locks(ctx context.Context, args []string, out io.Writer) error { func Locks(ctx context.Context, args []string, out io.Writer) error {
if len(args) > 0 && !strings.HasPrefix(args[0], "-") { if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
switch args[0] { switch args[0] {
@@ -461,7 +461,7 @@ func Locks(ctx context.Context, args []string, out io.Writer) error {
return LocksList(ctx, args, out) return LocksList(ctx, args, out)
} }
// LocksList lists effective archive locks. // LocksList lists effective publish locks.
func LocksList(ctx context.Context, args []string, out io.Writer) error { func LocksList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args) positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs := flag.NewFlagSet("locks", flag.ContinueOnError)
@@ -919,7 +919,7 @@ func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store s
func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding { func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding {
out := []finding{} out := []finding{}
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(prefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(prefix)
for _, key := range []string{runIDKey, manifestKey} { for _, key := range []string{runIDKey, manifestKey} {
exists, err := store.Exists(ctx, key) exists, err := store.Exists(ctx, key)
if err != nil { if err != nil {

View File

@@ -596,7 +596,7 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.final_trimmed") addStaticPublishLockToPipelineConfig(t, pipelinePath, "narratio.transcript.final_trimmed")
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
var storeInitCalls int var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
@@ -683,10 +683,10 @@ func addSessionTemplateToCampaign(t *testing.T, campaignPath, templateFile strin
} }
} }
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { func TestExecuteArtifactsListRemoteReportsPublishedAvailability(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addPublishOutputsToPipeline(t, pipelinePath, `
outputs: outputs:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
@@ -714,14 +714,14 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
if !strings.Contains(stdout.String(), "narratio.transcript.final_trimmed remote=published") { if !strings.Contains(stdout.String(), "narratio.transcript.final_trimmed remote=published") {
t.Fatalf("stdout = %q, want promoted remote availability", stdout.String()) t.Fatalf("stdout = %q, want published remote availability", stdout.String())
} }
} }
func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) { func TestExecuteArtifactsListRemoteUsesPublishOutputDestinations(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addPublishOutputsToPipeline(t, pipelinePath, `
outputs: outputs:
- source: narratio.transcript.final - source: narratio.transcript.final
dest: transcripts/full.json dest: transcripts/full.json
@@ -771,7 +771,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addPublishOutputsToPipeline(t, pipelinePath, `
outputs: outputs:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
@@ -782,7 +782,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
`) `)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json") trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json")
fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json") fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json")
lockKey := artifacts.S3SessionLocksKey(sessionPrefix) lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
@@ -828,7 +828,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testing.T) { func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addPublishOutputsToPipeline(t, pipelinePath, `
outputs: outputs:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
@@ -861,9 +861,9 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
} }
} }
func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) { func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidPublishRunConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")}) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
@@ -883,28 +883,28 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
promotedKey := artifacts.S3PublishedOutputKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.trimmed.json") publishedKey := artifacts.S3PublishedOutputKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.trimmed.json")
if _, ok := fake.Objects[promotedKey]; ok { if _, ok := fake.Objects[publishedKey]; ok {
t.Fatalf("locked promoted key %q was uploaded", promotedKey) t.Fatalf("locked published key %q was uploaded", publishedKey)
} }
} }
func addArchivePromotionsToPipeline(t *testing.T, pipelinePath, archiveYAML string) { func addPublishOutputsToPipeline(t *testing.T, pipelinePath, publishYAML string) {
t.Helper() t.Helper()
data, err := os.ReadFile(pipelinePath) data, err := os.ReadFile(pipelinePath)
if err != nil { if err != nil {
t.Fatalf("read pipeline: %v", err) t.Fatalf("read pipeline: %v", err)
} }
updated := strings.Replace(string(data), " upload_run: false\n", " upload_run: false\n"+archiveYAML, 1) updated := strings.Replace(string(data), " upload_run: false\n", " upload_run: false\n"+publishYAML, 1)
if updated == string(data) { if updated == string(data) {
t.Fatalf("pipeline %q did not contain archive upload_run marker", pipelinePath) t.Fatalf("pipeline %q did not contain publish upload_run marker", pipelinePath)
} }
if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil {
t.Fatalf("write pipeline: %v", err) t.Fatalf("write pipeline: %v", err)
} }
} }
func writeValidArchiveConfigFiles(t *testing.T, workspaceRoot string) (string, string, string) { func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string, string, string) {
t.Helper() t.Helper()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
data, err := os.ReadFile(pipelinePath) data, err := os.ReadFile(pipelinePath)
@@ -941,7 +941,7 @@ func writeValidArchiveConfigFiles(t *testing.T, workspaceRoot string) (string, s
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath
} }
func addStaticArchiveLockToPipelineConfig(t *testing.T, pipelinePath, source string) { func addStaticPublishLockToPipelineConfig(t *testing.T, pipelinePath, source string) {
t.Helper() t.Helper()
data, err := os.ReadFile(pipelinePath) data, err := os.ReadFile(pipelinePath)
if err != nil { if err != nil {
@@ -954,7 +954,7 @@ func addStaticArchiveLockToPipelineConfig(t *testing.T, pipelinePath, source str
1, 1,
) )
if updated == string(data) { if updated == string(data) {
t.Fatalf("archive section not found in pipeline config") t.Fatalf("publish section not found in pipeline config")
} }
if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(updated), 0o644); err != nil {
t.Fatalf("write pipeline: %v", err) t.Fatalf("write pipeline: %v", err)

View File

@@ -12,7 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m *manifest.Manifest, executed []string) error { func runPostPublishCleanup(ctx context.Context, env *Env, manifestPath string, m *manifest.Manifest, executed []string) error {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || m == nil { if env == nil || env.Config == nil || env.Config.Pipeline == nil || m == nil {
return nil return nil
} }
@@ -23,7 +23,7 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
return nil return nil
} }
sr := archiveStageRecordForCleanup(m, executed) sr := publishStageRecordForCleanup(m, executed)
if sr == nil { if sr == nil {
return nil return nil
} }
@@ -33,7 +33,7 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
sr.Metadata["spool_cleanup_requested"] = spoolRequested sr.Metadata["spool_cleanup_requested"] = spoolRequested
sr.Metadata["workdir_cleanup_requested"] = workRequested sr.Metadata["workdir_cleanup_requested"] = workRequested
eligible, reason := archiveCleanupEligible(env.Config, sr) eligible, reason := publishCleanupEligible(env.Config, sr)
if !eligible { if !eligible {
sr.Metadata["cleanup_skipped"] = true sr.Metadata["cleanup_skipped"] = true
sr.Metadata["cleanup_skipped_reason"] = reason sr.Metadata["cleanup_skipped_reason"] = reason
@@ -96,7 +96,7 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
return nil return nil
} }
func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *manifest.StageRecord { func publishStageRecordForCleanup(m *manifest.Manifest, executed []string) *manifest.StageRecord {
if m == nil { if m == nil {
return nil return nil
} }
@@ -117,7 +117,7 @@ func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *mani
return sr return sr
} }
func archiveCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) { func publishCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil {
return false, "publish configuration is missing" return false, "publish configuration is missing"
} }

View File

@@ -16,13 +16,13 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/stage" "gitea.maximumdirect.net/eric/narratio/internal/stage"
) )
type archiveSuccessStage struct { type publishSuccessStage struct {
metadata map[string]any metadata map[string]any
} }
func (archiveSuccessStage) Name() string { return "publish" } func (publishSuccessStage) Name() string { return "publish" }
func (archiveSuccessStage) Declares() stage.IODecl { return stage.IODecl{} } func (publishSuccessStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s archiveSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { func (s publishSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
md := map[string]any{ md := map[string]any{
"stage": "publish", "stage": "publish",
"uploaded": true, "uploaded": true,
@@ -43,12 +43,12 @@ func (notifyFailStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest
return nil, errors.New("notify failed") return nil, errors.New("notify failed")
} }
func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) { func TestPostPublishCleanupDisabledKeepsLocalDirs(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
cfg.Pipeline.Workspace.CleanupAfterPublish = false cfg.Pipeline.Workspace.CleanupAfterPublish = false
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -57,12 +57,12 @@ func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) {
assertExists(t, seed.localSourceAudio) assertExists(t, seed.localSourceAudio)
} }
func TestPostArchiveCleanupSpoolOnly(t *testing.T) { func TestPostPublishCleanupSpoolOnly(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = false cfg.Pipeline.Workspace.CleanupAfterPublish = false
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -71,12 +71,12 @@ func TestPostArchiveCleanupSpoolOnly(t *testing.T) {
assertExists(t, seed.localSourceAudio) assertExists(t, seed.localSourceAudio)
} }
func TestPostArchiveCleanupWorkdirOnly(t *testing.T) { func TestPostPublishCleanupWorkdirOnly(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -87,12 +87,12 @@ func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
assertExists(t, seed.spoolAudioDir) assertExists(t, seed.spoolAudioDir)
} }
func TestPostArchiveCleanupBothPolicies(t *testing.T) { func TestPostPublishCleanupBothPolicies(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -102,12 +102,12 @@ func TestPostArchiveCleanupBothPolicies(t *testing.T) {
assertExists(t, seed.previousCachePath) assertExists(t, seed.previousCachePath)
} }
func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) { func TestPostPublishCleanupNotRunWhenPublishFails(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
_, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "publish", err: errors.New("archive failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}) _, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "publish", err: errors.New("publish failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "stage \"publish\" failed") { if err == nil || !strings.Contains(err.Error(), "stage \"publish\" failed") {
t.Fatalf("executeStages() error = %v, want publish failure", err) t.Fatalf("executeStages() error = %v, want publish failure", err)
} }
@@ -116,12 +116,12 @@ func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) { func TestPostPublishCleanupNotRunWhenPublishSkipped(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"skipped": true}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"skipped": true}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -129,12 +129,12 @@ func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) { func TestPostPublishCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"current_pointer_written": false}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"current_pointer_written": false}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -142,13 +142,13 @@ func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) { func TestPostPublishCleanupNotRunWhenPublishUploadDisabled(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
cfg.Pipeline.Publish.UploadRun = boolPtr(false) cfg.Pipeline.Publish.UploadRun = boolPtr(false)
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil { if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
@@ -156,12 +156,12 @@ func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) { func TestPostPublishCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
_, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}, notifyFailStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}) _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}, notifyFailStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "stage \"notify\" failed") { if err == nil || !strings.Contains(err.Error(), "stage \"notify\" failed") {
t.Fatalf("executeStages() error = %v, want notify failure", err) t.Fatalf("executeStages() error = %v, want notify failure", err)
} }
@@ -170,7 +170,7 @@ func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) { func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
cfg, _ := cleanupFixtureConfig(t) cfg, _ := cleanupFixtureConfig(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = false cfg.Pipeline.Workspace.CleanupAfterPublish = false
@@ -186,25 +186,25 @@ func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) {
t.Fatalf("Save() error = %v", err) t.Fatalf("Save() error = %v", err)
} }
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}) _, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") { if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
t.Fatalf("executeStages() error = %v, want safe-path failure", err) t.Fatalf("executeStages() error = %v, want safe-path failure", err)
} }
} }
func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) { func TestPostPublishCleanupNotRunWhenOutputIsMissing(t *testing.T) {
cfg, seed, runID := archiveStageCleanupFixture(t) cfg, seed, runID := publishStageCleanupFixture(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
cfg.Pipeline.Publish.Outputs = []config.PublishOutputRule{ cfg.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)}, {Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
} }
archiveStageImpl, err := stage.Select("publish") publishStageImpl, err := stage.Select("publish")
if err != nil { if err != nil {
t.Fatalf("Select(publish) error = %v", err) t.Fatalf("Select(publish) error = %v", err)
} }
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}) _, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "required output source unavailable") { if err == nil || !strings.Contains(err.Error(), "required output source unavailable") {
t.Fatalf("executeStages() error = %v, want required output source unavailable failure", err) t.Fatalf("executeStages() error = %v, want required output source unavailable failure", err)
} }
@@ -215,17 +215,17 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
assertExists(t, artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)) assertExists(t, artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID))
} }
func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) { func TestPostPublishCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
cfg, seed, _ := archiveStageCleanupFixture(t) cfg, seed, _ := publishStageCleanupFixture(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
failKey := seed.sessionPrefix + "current/manifest.json" failKey := seed.sessionPrefix + "current/manifest.json"
archiveStageImpl, err := stage.Select("publish") publishStageImpl, err := stage.Select("publish")
if err != nil { if err != nil {
t.Fatalf("Select(publish) error = %v", err) t.Fatalf("Select(publish) error = %v", err)
} }
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{ _, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}}, Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
}) })
if err == nil || !strings.Contains(err.Error(), "current manifest") { if err == nil || !strings.Contains(err.Error(), "current manifest") {
@@ -236,17 +236,17 @@ func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
assertExists(t, seed.runWorkDir) assertExists(t, seed.runWorkDir)
} }
func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) { func TestPostPublishCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
cfg, seed, _ := archiveStageCleanupFixture(t) cfg, seed, _ := publishStageCleanupFixture(t)
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
cfg.Pipeline.Workspace.CleanupAfterPublish = true cfg.Pipeline.Workspace.CleanupAfterPublish = true
failKey := seed.sessionPrefix + "current/run_id.txt" failKey := seed.sessionPrefix + "current/run_id.txt"
archiveStageImpl, err := stage.Select("publish") publishStageImpl, err := stage.Select("publish")
if err != nil { if err != nil {
t.Fatalf("Select(publish) error = %v", err) t.Fatalf("Select(publish) error = %v", err)
} }
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{ _, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}}, Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
}) })
if err == nil || !strings.Contains(err.Error(), "current run pointer") { if err == nil || !strings.Contains(err.Error(), "current run pointer") {
@@ -320,7 +320,7 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
} }
} }
func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, string) { func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, string) {
t.Helper() t.Helper()
cfg, seed := cleanupFixtureConfig(t) cfg, seed := cleanupFixtureConfig(t)
@@ -344,7 +344,7 @@ func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
}, },
}, },
} }
writeArchiveFixtureRunFiles( writePublishFixtureRunFiles(
t, t,
seed.runWorkDir, seed.runWorkDir,
artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID), artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID),
@@ -367,7 +367,7 @@ func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
return cfg, seed, runID return cfg, seed, runID
} }
func writeArchiveFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) { func writePublishFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) {
t.Helper() t.Helper()
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n") mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n") mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")

View File

@@ -63,7 +63,7 @@ func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.
} }
func loadEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*effectiveLocks, error) { func loadEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*effectiveLocks, error) {
staticLocks := staticArchiveLocks(cfg) staticLocks := staticPublishLocks(cfg)
if store == nil { if store == nil {
return &effectiveLocks{ return &effectiveLocks{
Static: staticLocks, Static: staticLocks,
@@ -83,7 +83,7 @@ func loadEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.O
}, nil }, nil
} }
func staticArchiveLocks(cfg *config.Config) []config.PublishLockRule { func staticPublishLocks(cfg *config.Config) []config.PublishLockRule {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil {
return nil return nil
} }

View File

@@ -12,7 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
// RemoteCurrentState captures discovered committed remote archive state for one session. // RemoteCurrentState captures discovered committed remote published current state for one session.
type RemoteCurrentState struct { type RemoteCurrentState struct {
Bucket string Bucket string
SessionPrefix string SessionPrefix string
@@ -32,15 +32,15 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s
return nil, fmt.Errorf("remote object store is required") return nil, fmt.Errorf("remote object store is required")
} }
bucket := artifacts.ResolveArchiveBucket(cfg, nil) bucket := artifacts.ResolvePublishBucket(cfg, nil)
if strings.TrimSpace(bucket) == "" { if strings.TrimSpace(bucket) == "" {
return nil, fmt.Errorf("archive bucket is required") return nil, fmt.Errorf("publish bucket is required")
} }
sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(cfg, nil) sessionPrefix, err := artifacts.ResolvePublishSessionPrefix(cfg, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve archive session prefix: %w", err) return nil, fmt.Errorf("resolve publish session prefix: %w", err)
} }
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) currentManifestKey, currentRunIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
exists, err := store.Exists(ctx, currentRunIDKey) exists, err := store.Exists(ctx, currentRunIDKey)
if err != nil { if err != nil {

View File

@@ -208,7 +208,7 @@ func restoreDiscoveryConfig() *config.Config {
func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) { func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) {
sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey = artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey = artifacts.ResolveCurrentStateKeys(sessionPrefix)
return sessionPrefix, manifestKey, runIDKey return sessionPrefix, manifestKey, runIDKey
} }

View File

@@ -427,7 +427,7 @@ func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipeline
} }
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
@@ -465,7 +465,7 @@ func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *co
func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) { func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) {
t.Helper() t.Helper()
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix)
previousRunID := "20260426T010203Z-a1b2c3d4" previousRunID := "20260426T010203Z-a1b2c3d4"
seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n")) seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n"))

View File

@@ -325,7 +325,7 @@ func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool)
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
t.Helper() t.Helper()
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
return &RemoteCurrentState{ return &RemoteCurrentState{
Bucket: "test-bucket", Bucket: "test-bucket",
SessionPrefix: sessionPrefix, SessionPrefix: sessionPrefix,

View File

@@ -96,7 +96,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
if needsRemoteLocksForRun(env.Config, stages) { if needsRemoteLocksForRun(env.Config, stages) {
locks, err := loadEffectiveLocks(ctx, env.Config, env.ObjectStore) locks, err := loadEffectiveLocks(ctx, env.Config, env.ObjectStore)
if err != nil { if err != nil {
return nil, fmt.Errorf("load remote archive locks: %w", err) return nil, fmt.Errorf("load remote publish locks: %w", err)
} }
applyEffectiveLocks(env.Config, locks.All) applyEffectiveLocks(env.Config, locks.All)
} }
@@ -234,14 +234,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
env.Logger.Info("stage succeeded", "stage", s.Name()) env.Logger.Info("stage succeeded", "stage", s.Name())
} }
if err := runPostArchiveCleanup(ctx, env, manifestPath, m, executed); err != nil { if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
failedAt := nowUTC() failedAt := nowUTC()
runManifest.MarkFailed(failedAt, err.Error()) runManifest.MarkFailed(failedAt, err.Error())
syncRunManifestIdentityFromSession(m, runManifest) syncRunManifestIdentityFromSession(m, runManifest)
if saveErr := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); saveErr != nil { if saveErr := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); saveErr != nil {
return nil, fmt.Errorf("post-archive cleanup failed (%v) and run-manifest save failed (%v)", err, saveErr) return nil, fmt.Errorf("post-publish cleanup failed (%v) and run-manifest save failed (%v)", err, saveErr)
} }
return nil, fmt.Errorf("post-archive cleanup: %w", err) return nil, fmt.Errorf("post-publish cleanup: %w", err)
} }
completedAt := nowUTC() completedAt := nowUTC()
runManifest.MarkSucceeded(completedAt) runManifest.MarkSucceeded(completedAt)

View File

@@ -248,7 +248,7 @@ func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
} }
} }
func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) { func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{ cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
Bucket: "my-dnd-archive", Bucket: "my-dnd-archive",
@@ -283,7 +283,7 @@ func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testi
t.Fatalf("Save manifest error = %v", err) t.Fatalf("Save manifest error = %v", err)
} }
archiveStageImpl, err := stage.Select("publish") publishStageImpl, err := stage.Select("publish")
if err != nil { if err != nil {
t.Fatalf("Select(publish) error = %v", err) t.Fatalf("Select(publish) error = %v", err)
} }
@@ -293,7 +293,7 @@ func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testi
cfg, cfg,
[]stage.Stage{ []stage.Stage{
selectedAnalyzeArtifactStage{expected: []string{"player_handout"}}, selectedAnalyzeArtifactStage{expected: []string{"player_handout"}},
archiveStageImpl, publishStageImpl,
}, },
RunOptions{ RunOptions{
SelectedArtifacts: []string{"player_handout"}, SelectedArtifacts: []string{"player_handout"},
@@ -304,7 +304,7 @@ func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testi
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
if len(summary.Executed) != 2 || summary.Executed[0] != "analyze" || summary.Executed[1] != "publish" { if len(summary.Executed) != 2 || summary.Executed[0] != "analyze" || summary.Executed[1] != "publish" {
t.Fatalf("executed = %#v, want analyze and archive", summary.Executed) t.Fatalf("executed = %#v, want analyze and publish", summary.Executed)
} }
loadedManifest, err := store.Load(context.Background(), summary.ManifestPath) loadedManifest, err := store.Load(context.Background(), summary.ManifestPath)
@@ -321,7 +321,7 @@ func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testi
t.Fatalf("skipped item = %#v, want object", skipped[0]) t.Fatalf("skipped item = %#v, want object", skipped[0])
} }
if item["source"] != "narratio.artifact.session_recap" || item["dest"] != "artifacts/session_recap.md" || item["required"] != true { if item["source"] != "narratio.artifact.session_recap" || item["dest"] != "artifacts/session_recap.md" || item["required"] != true {
t.Fatalf("skipped item = %#v, want required session_recap promotion", item) t.Fatalf("skipped item = %#v, want required session_recap published output", item)
} }
} }
@@ -427,10 +427,10 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
} }
if name == "publish" { if name == "publish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" { if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
t.Fatalf("archive metadata missing stage=publish: %#v", sr.Metadata) t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
} }
if sr.Metadata["skipped"] != true { if sr.Metadata["skipped"] != true {
t.Fatalf("archive metadata missing skipped=true for test config without archive section: %#v", sr.Metadata) t.Fatalf("publish metadata missing skipped=true for test config without publish section: %#v", sr.Metadata)
} }
continue continue
} }
@@ -729,7 +729,7 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
} }
} }
func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) { func TestExecuteStagesRunLocalArtifactsAndCanonicalSync(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
stages := []stage.Stage{ stages := []stage.Stage{
BuildFullPlan()[0], // prepare BuildFullPlan()[0], // prepare
@@ -778,7 +778,7 @@ func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) {
} }
for _, p := range canonicalChecks { for _, p := range canonicalChecks {
if _, statErr := os.Stat(p); statErr != nil { if _, statErr := os.Stat(p); statErr != nil {
t.Fatalf("canonical promoted artifact missing at %q: %v", p, statErr) t.Fatalf("canonical published artifact missing at %q: %v", p, statErr)
} }
} }
@@ -864,7 +864,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
{name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}}, {name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}},
{name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}}, {name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}},
{name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}}, {name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}},
{name: "publish", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("archive fail")}}}, {name: "publish", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("publish fail")}}},
{name: "notify", env: &Env{Notifier: &notify.FakeSender{Err: errors.New("notify fail")}}}, {name: "notify", env: &Env{Notifier: &notify.FakeSender{Err: errors.New("notify fail")}}},
} }
@@ -961,13 +961,13 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
runID := "20260516T010203Z-0a1b2c3d" runID := "20260516T010203Z-0a1b2c3d"
runWorkDir := filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.Campaign, cfg.Session.SessionID, runID) runWorkDir := filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.Campaign, cfg.Session.SessionID, runID)
if err := os.MkdirAll(filepath.Join(runWorkDir, "inputs"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(runWorkDir, "inputs"), 0o755); err != nil {
t.Fatalf("mkdir archive inputs dir: %v", err) t.Fatalf("mkdir publish inputs dir: %v", err)
} }
if err := os.WriteFile(filepath.Join(runWorkDir, "inputs", "session.yml"), []byte("session_id: 2026-05-03\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(runWorkDir, "inputs", "session.yml"), []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
t.Fatalf("write archive fixture session.yml: %v", err) t.Fatalf("write publish fixture session.yml: %v", err)
} }
if err := os.WriteFile(filepath.Join(runWorkDir, "manifest.json"), []byte("{}\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(runWorkDir, "manifest.json"), []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write archive fixture manifest.json: %v", err) t.Fatalf("write publish fixture manifest.json: %v", err)
} }
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC()) seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
@@ -981,7 +981,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil) seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
} }
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil { if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatalf("seed archive manifest: %v", err) t.Fatalf("seed publish manifest: %v", err)
} }
} }

View File

@@ -212,7 +212,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
var storeInitCalls int var storeInitCalls int

View File

@@ -75,9 +75,9 @@ func TestSessionPreviousPathsForCampaign(t *testing.T) {
t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath) t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath)
} }
archiveRelativeArtifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "artifacts/session_recap.md") previousRelativeArtifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "artifacts/session_recap.md")
if archiveRelativeArtifactPath != wantArtifactPath { if previousRelativeArtifactPath != wantArtifactPath {
t.Fatalf("SessionPreviousArtifactPathForCampaign(archive-relative) = %q, want %q", archiveRelativeArtifactPath, wantArtifactPath) t.Fatalf("SessionPreviousArtifactPathForCampaign(previous-relative) = %q, want %q", previousRelativeArtifactPath, wantArtifactPath)
} }
} }
@@ -102,7 +102,7 @@ func TestSessionPreviousPathsFromSessionPaths(t *testing.T) {
got = SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") got = SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
want = filepath.Join(paths.PreviousArtifactsDir, "session_recap.md") want = filepath.Join(paths.PreviousArtifactsDir, "session_recap.md")
if got != want { if got != want {
t.Fatalf("SessionPreviousArtifactPath(archive-relative) = %q, want %q", got, want) t.Fatalf("SessionPreviousArtifactPath(previous-relative) = %q, want %q", got, want)
} }
} }

View File

@@ -8,8 +8,8 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
// ResolveArchiveBucket resolves archive bucket identity with manifest-first precedence. // ResolvePublishBucket resolves publish bucket identity with manifest-first precedence.
func ResolveArchiveBucket(cfg *config.Config, m *manifest.Manifest) string { func ResolvePublishBucket(cfg *config.Config, m *manifest.Manifest) string {
if m != nil && strings.TrimSpace(m.S3Bucket) != "" { if m != nil && strings.TrimSpace(m.S3Bucket) != "" {
return strings.TrimSpace(m.S3Bucket) return strings.TrimSpace(m.S3Bucket)
} }
@@ -19,8 +19,8 @@ func ResolveArchiveBucket(cfg *config.Config, m *manifest.Manifest) string {
return strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) return strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket)
} }
// ResolveArchiveSessionPrefix resolves archive session prefix with manifest-first precedence. // ResolvePublishSessionPrefix resolves publish session prefix with manifest-first precedence.
func ResolveArchiveSessionPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) { func ResolvePublishSessionPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) {
if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" { if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" {
return strings.TrimSpace(m.S3SessionPrefix), nil return strings.TrimSpace(m.S3SessionPrefix), nil
} }
@@ -47,8 +47,8 @@ func ResolveArchiveSessionPrefix(cfg *config.Config, m *manifest.Manifest) (stri
return sessionPrefix, nil return sessionPrefix, nil
} }
// ResolveArchiveRunPrefix resolves archive run prefix with manifest-first precedence. // ResolvePublishRunPrefix resolves publish run prefix with manifest-first precedence.
func ResolveArchiveRunPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) { func ResolvePublishRunPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) {
if m != nil { if m != nil {
runPrefix := strings.TrimSpace(m.S3RunPrefix) runPrefix := strings.TrimSpace(m.S3RunPrefix)
if runPrefix != "" { if runPrefix != "" {
@@ -56,7 +56,7 @@ func ResolveArchiveRunPrefix(cfg *config.Config, m *manifest.Manifest) (string,
} }
} }
sessionPrefix, err := ResolveArchiveSessionPrefix(cfg, m) sessionPrefix, err := ResolvePublishSessionPrefix(cfg, m)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -71,7 +71,7 @@ func ResolveArchiveRunPrefix(cfg *config.Config, m *manifest.Manifest) (string,
return S3RunPrefix(sessionPrefix, runID), nil return S3RunPrefix(sessionPrefix, runID), nil
} }
// ResolveArchiveCurrentStateKeys returns current pointer keys for a session prefix. // ResolveCurrentStateKeys returns current pointer keys for a session prefix.
func ResolveArchiveCurrentStateKeys(sessionPrefix string) (manifestKey, runIDKey string) { func ResolveCurrentStateKeys(sessionPrefix string) (manifestKey, runIDKey string) {
return S3CurrentManifestKey(sessionPrefix), S3CurrentRunPointerKey(sessionPrefix) return S3CurrentManifestKey(sessionPrefix), S3CurrentRunPointerKey(sessionPrefix)
} }

View File

@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
func TestResolveArchiveBucketPrefersManifestThenConfig(t *testing.T) { func TestResolvePublishBucketPrefersManifestThenConfig(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{ Storage: config.StorageConfig{
@@ -17,15 +17,15 @@ func TestResolveArchiveBucketPrefersManifestThenConfig(t *testing.T) {
}, },
} }
if got := ResolveArchiveBucket(cfg, &manifest.Manifest{S3Bucket: "manifest-bucket"}); got != "manifest-bucket" { if got := ResolvePublishBucket(cfg, &manifest.Manifest{S3Bucket: "manifest-bucket"}); got != "manifest-bucket" {
t.Fatalf("bucket = %q, want manifest-bucket", got) t.Fatalf("bucket = %q, want manifest-bucket", got)
} }
if got := ResolveArchiveBucket(cfg, &manifest.Manifest{}); got != "cfg-bucket" { if got := ResolvePublishBucket(cfg, &manifest.Manifest{}); got != "cfg-bucket" {
t.Fatalf("bucket = %q, want cfg-bucket", got) t.Fatalf("bucket = %q, want cfg-bucket", got)
} }
} }
func TestResolveArchiveSessionPrefixPrefersManifestThenConfig(t *testing.T) { func TestResolvePublishSessionPrefixPrefersManifestThenConfig(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{ Storage: config.StorageConfig{
@@ -39,17 +39,17 @@ func TestResolveArchiveSessionPrefixPrefersManifestThenConfig(t *testing.T) {
} }
m := &manifest.Manifest{S3SessionPrefix: "manifest/session/prefix/"} m := &manifest.Manifest{S3SessionPrefix: "manifest/session/prefix/"}
got, err := ResolveArchiveSessionPrefix(cfg, m) got, err := ResolvePublishSessionPrefix(cfg, m)
if err != nil { if err != nil {
t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err) t.Fatalf("ResolvePublishSessionPrefix() error = %v", err)
} }
if got != "manifest/session/prefix/" { if got != "manifest/session/prefix/" {
t.Fatalf("session prefix = %q, want manifest/session/prefix/", got) t.Fatalf("session prefix = %q, want manifest/session/prefix/", got)
} }
got, err = ResolveArchiveSessionPrefix(cfg, &manifest.Manifest{}) got, err = ResolvePublishSessionPrefix(cfg, &manifest.Manifest{})
if err != nil { if err != nil {
t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err) t.Fatalf("ResolvePublishSessionPrefix() error = %v", err)
} }
want := "dnd/campaigns/forsaken/sessions/2026-04-19/" want := "dnd/campaigns/forsaken/sessions/2026-04-19/"
if got != want { if got != want {
@@ -57,7 +57,7 @@ func TestResolveArchiveSessionPrefixPrefersManifestThenConfig(t *testing.T) {
} }
} }
func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) { func TestResolvePublishRunPrefixPrefersManifestThenDerived(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{ Storage: config.StorageConfig{
@@ -74,9 +74,9 @@ func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) {
RunID: "20260516T010203Z-1a2b3c4d", RunID: "20260516T010203Z-1a2b3c4d",
S3RunPrefix: "manifest/run/prefix/", S3RunPrefix: "manifest/run/prefix/",
} }
got, err := ResolveArchiveRunPrefix(cfg, m) got, err := ResolvePublishRunPrefix(cfg, m)
if err != nil { if err != nil {
t.Fatalf("ResolveArchiveRunPrefix() error = %v", err) t.Fatalf("ResolvePublishRunPrefix() error = %v", err)
} }
if got != "manifest/run/prefix/" { if got != "manifest/run/prefix/" {
t.Fatalf("run prefix = %q, want manifest/run/prefix/", got) t.Fatalf("run prefix = %q, want manifest/run/prefix/", got)
@@ -85,9 +85,9 @@ func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) {
m = &manifest.Manifest{ m = &manifest.Manifest{
RunID: "20260516T010203Z-1a2b3c4d", RunID: "20260516T010203Z-1a2b3c4d",
} }
got, err = ResolveArchiveRunPrefix(cfg, m) got, err = ResolvePublishRunPrefix(cfg, m)
if err != nil { if err != nil {
t.Fatalf("ResolveArchiveRunPrefix() error = %v", err) t.Fatalf("ResolvePublishRunPrefix() error = %v", err)
} }
want := "dnd/campaigns/forsaken/sessions/2026-04-19/runs/20260516T010203Z-1a2b3c4d/" want := "dnd/campaigns/forsaken/sessions/2026-04-19/runs/20260516T010203Z-1a2b3c4d/"
if got != want { if got != want {
@@ -95,12 +95,12 @@ func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) {
} }
} }
func TestResolveArchiveIdentityErrorsAreDeterministic(t *testing.T) { func TestResolvePublishIdentityErrorsAreDeterministic(t *testing.T) {
cfgNoS3 := &config.Config{ cfgNoS3 := &config.Config{
Pipeline: &config.PipelineConfig{}, Pipeline: &config.PipelineConfig{},
Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"}, Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"},
} }
_, err := ResolveArchiveSessionPrefix(cfgNoS3, &manifest.Manifest{}) _, err := ResolvePublishSessionPrefix(cfgNoS3, &manifest.Manifest{})
if err == nil || !strings.Contains(err.Error(), "pipeline.storage.s3 configuration is required") { if err == nil || !strings.Contains(err.Error(), "pipeline.storage.s3 configuration is required") {
t.Fatalf("error = %v, want missing storage.s3", err) t.Fatalf("error = %v, want missing storage.s3", err)
} }
@@ -113,14 +113,14 @@ func TestResolveArchiveIdentityErrorsAreDeterministic(t *testing.T) {
}, },
Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"}, Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"},
} }
_, err = ResolveArchiveRunPrefix(cfg, &manifest.Manifest{}) _, err = ResolvePublishRunPrefix(cfg, &manifest.Manifest{})
if err == nil || !strings.Contains(err.Error(), "run id is required") { if err == nil || !strings.Contains(err.Error(), "run id is required") {
t.Fatalf("error = %v, want missing run id", err) t.Fatalf("error = %v, want missing run id", err)
} }
} }
func TestResolveArchiveCurrentStateKeys(t *testing.T) { func TestResolveCurrentStateKeys(t *testing.T) {
manifestKey, runIDKey := ResolveArchiveCurrentStateKeys("dnd/campaigns/forsaken/sessions/2026-04-19/") manifestKey, runIDKey := ResolveCurrentStateKeys("dnd/campaigns/forsaken/sessions/2026-04-19/")
if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" { if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" {
t.Fatalf("manifest key = %q", manifestKey) t.Fatalf("manifest key = %q", manifestKey)
} }

View File

@@ -43,9 +43,9 @@ func TestS3KeyConstruction(t *testing.T) {
t.Fatalf("manifest key = %q", manifestKey) t.Fatalf("manifest key = %q", manifestKey)
} }
promoted := S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json") publishedKey := S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json")
if promoted != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/final.trimmed.json" { if publishedKey != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/final.trimmed.json" {
t.Fatalf("promoted key = %q", promoted) t.Fatalf("published key = %q", publishedKey)
} }
runRelative := S3RunRelativeDestinationKey(runPrefix, `logs\whisperx.stdout.log`) runRelative := S3RunRelativeDestinationKey(runPrefix, `logs\whisperx.stdout.log`)

View File

@@ -153,7 +153,7 @@ storage:
} }
} }
func TestSpoolAndArchiveDefaults(t *testing.T) { func TestSpoolAndPublishDefaults(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML) pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath) cfg, err := Load(pipelinePath, sessionPath)
@@ -171,13 +171,13 @@ func TestSpoolAndArchiveDefaults(t *testing.T) {
t.Fatalf("workspace.cleanup_after_publish = true, want false") t.Fatalf("workspace.cleanup_after_publish = true, want false")
} }
if cfg.Pipeline.Publish == nil { if cfg.Pipeline.Publish == nil {
t.Fatal("archive should be initialized by defaults") t.Fatal("publish should be initialized by defaults")
} }
if cfg.Pipeline.Publish.Enabled == nil || !*cfg.Pipeline.Publish.Enabled { if cfg.Pipeline.Publish.Enabled == nil || !*cfg.Pipeline.Publish.Enabled {
t.Fatalf("archive.enabled = %#v, want true", cfg.Pipeline.Publish.Enabled) t.Fatalf("publish.enabled = %#v, want true", cfg.Pipeline.Publish.Enabled)
} }
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun { if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
t.Fatalf("archive.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun) t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
} }
if len(cfg.Pipeline.Publish.Outputs) != 1 { if len(cfg.Pipeline.Publish.Outputs) != 1 {
t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs)) t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs))
@@ -194,7 +194,7 @@ func TestSpoolAndArchiveDefaults(t *testing.T) {
} }
} }
func TestArchivePromotionValidation(t *testing.T) { func TestPublishOutputValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
ruleYML string ruleYML string
@@ -278,7 +278,7 @@ publish:
} }
} }
func TestArchivePromotionLegacyTranscriptSourcesRejected(t *testing.T) { func TestPublishOutputLegacyTranscriptSourcesRejected(t *testing.T) {
legacyTranscriptSources := []string{ legacyTranscriptSources := []string{
"narratio.transcript." + "merged", "narratio.transcript." + "merged",
"narratio.transcript." + "full", "narratio.transcript." + "full",
@@ -307,7 +307,7 @@ publish:
} }
} }
func TestArchivePromotionDerivesDestinationWhenOmitted(t *testing.T) { func TestPublishOutputDerivesDestinationWhenOmitted(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
pipelineYML string pipelineYML string
@@ -359,7 +359,7 @@ publish:
} }
} }
func TestArchiveLockValidation(t *testing.T) { func TestPublishLockValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
pipelineYML string pipelineYML string
@@ -439,7 +439,7 @@ publish:
} }
} }
func TestArchiveLockLegacyTranscriptSourcesRejected(t *testing.T) { func TestPublishLockLegacyTranscriptSourcesRejected(t *testing.T) {
legacyTranscriptSources := []string{ legacyTranscriptSources := []string{
"narratio.transcript." + "merged", "narratio.transcript." + "merged",
"narratio.transcript." + "full", "narratio.transcript." + "full",
@@ -467,7 +467,7 @@ publish:
} }
} }
func TestArchiveLockUnknownFieldFailsStrictDecode(t *testing.T) { func TestPublishLockUnknownFieldFailsStrictDecode(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + ` pipelineYAML := testPipelineBaseYAML + `
publish: publish:
locks: locks:
@@ -481,7 +481,7 @@ publish:
} }
} }
func TestArchiveLegacyFromToFailsStrictDecode(t *testing.T) { func TestPublishLegacyFromToFailsStrictDecode(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + ` pipelineYAML := testPipelineBaseYAML + `
publish: publish:
outputs: outputs:
@@ -495,7 +495,7 @@ publish:
} }
} }
func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) { func TestPublishLockStoreBytesStrictDecodeAndValidation(t *testing.T) {
store, err := LoadPublishLockStoreBytes("locks.yml", []byte(`locks: store, err := LoadPublishLockStoreBytes("locks.yml", []byte(`locks:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
reason: reviewed reason: reviewed
@@ -524,7 +524,7 @@ func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) {
} }
} }
func TestMergeArchiveLockRulesStaticWins(t *testing.T) { func TestMergePublishLockRulesStaticWins(t *testing.T) {
merged := MergePublishLockRules( merged := MergePublishLockRules(
[]PublishLockRule{{Source: "narratio.transcript.final_trimmed", Reason: "static"}}, []PublishLockRule{{Source: "narratio.transcript.final_trimmed", Reason: "static"}},
[]PublishLockRule{ []PublishLockRule{

View File

@@ -92,7 +92,7 @@ func BuildPlan(
campaign, campaign,
previousSessionID, previousSessionID,
) )
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix) currentManifestKey, currentRunIDKey := artifacts.ResolveCurrentStateKeys(previousSessionPrefix)
result := &Plan{} result := &Plan{}
@@ -190,7 +190,7 @@ func BuildPlan(
if len(candidates) == 0 { if len(candidates) == 0 {
if requirement.Required { if requirement.Required {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"required previous-session artifact %q is unavailable in previous-session manifest/archive", "required previous-session artifact %q is unavailable in previous-session manifest/published-state",
requirement.Name, requirement.Name,
) )
} }
@@ -216,7 +216,7 @@ func BuildPlan(
if selectedRel == "" { if selectedRel == "" {
if requirement.Required { if requirement.Required {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"required previous-session artifact %q object missing from archive candidate keys", "required previous-session artifact %q object missing from published candidate keys",
requirement.Name, requirement.Name,
) )
} }

View File

@@ -134,7 +134,7 @@ func previousCacheTestConfig(t *testing.T) (*config.Config, artifacts.SessionPat
func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) { func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) {
t.Helper() t.Helper()
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix) manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")})
data, err := marshalManifestForPreviousCacheTest(m) data, err := marshalManifestForPreviousCacheTest(m)
if err != nil { if err != nil {
@@ -143,7 +143,7 @@ func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.C
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data}) store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data})
} }
func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, promoted []string) *manifest.Manifest { func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, publishedPaths []string) *manifest.Manifest {
t.Helper() t.Helper()
m := manifest.New(cfg.Session.PreviousSessionID, time.Date(2026, 4, 26, 10, 0, 0, 0, time.UTC)) m := manifest.New(cfg.Session.PreviousSessionID, time.Date(2026, 4, 26, 10, 0, 0, 0, time.UTC))
m.Campaign = cfg.Session.Campaign m.Campaign = cfg.Session.Campaign
@@ -154,11 +154,11 @@ func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, pr
{SourceID: "narratio.artifact.session_recap", LocalPath: filepath.Join(filepath.Dir(filepath.Dir(m.LocalWorkDir)), filepath.FromSlash(rel))}, {SourceID: "narratio.artifact.session_recap", LocalPath: filepath.Join(filepath.Dir(filepath.Dir(m.LocalWorkDir)), filepath.FromSlash(rel))},
}) })
} }
if promoted != nil { if publishedPaths != nil {
if m.Stages["publish"] == nil { if m.Stages["publish"] == nil {
m.MarkStageSucceeded("publish", time.Date(2026, 4, 26, 10, 2, 0, 0, time.UTC), nil) m.MarkStageSucceeded("publish", time.Date(2026, 4, 26, 10, 2, 0, 0, time.UTC), nil)
} }
m.Stages["publish"].Metadata = map[string]any{"published_paths": promoted} m.Stages["publish"].Metadata = map[string]any{"published_paths": publishedPaths}
} }
return m return m
} }

View File

@@ -75,7 +75,7 @@ func All() []Stage {
normalizeStage{}, normalizeStage{},
trimStage{}, trimStage{},
analyzeStage{}, analyzeStage{},
archiveStage{}, publishStage{},
placeholderStage{name: "notify"}, placeholderStage{name: "notify"},
} }
} }

View File

@@ -197,10 +197,10 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
} }
if s.Name() == "publish" { if s.Name() == "publish" {
if result.Metadata["stage"] != "publish" { if result.Metadata["stage"] != "publish" {
t.Fatalf("archive metadata = %#v, want stage=publish", result.Metadata) t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
} }
if result.Metadata["uploaded"] != true { if result.Metadata["uploaded"] != true {
t.Fatalf("archive metadata = %#v, want uploaded=true", result.Metadata) t.Fatalf("publish metadata = %#v, want uploaded=true", result.Metadata)
} }
continue continue
} }
@@ -222,10 +222,10 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
t.Fatalf("scriptorium run calls = %d, want 0 when scriptorium config is absent", len(sc.RunRequests)) t.Fatalf("scriptorium run calls = %d, want 0 when scriptorium config is absent", len(sc.RunRequests))
} }
if len(st.Requests) != 0 { if len(st.Requests) != 0 {
t.Fatalf("storage archive calls = %d, want 0", len(st.Requests)) t.Fatalf("storage publish calls = %d, want 0", len(st.Requests))
} }
if _, ok := st.Objects["dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/"+m.RunID+"/manifest.json"]; !ok { if _, ok := st.Objects["dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/"+m.RunID+"/manifest.json"]; !ok {
t.Fatalf("archive upload missing manifest key in fake object store") t.Fatalf("publish upload missing manifest key in fake object store")
} }
if len(nf.Requests) != 1 { if len(nf.Requests) != 1 {
t.Fatalf("notify calls = %d, want 1", len(nf.Requests)) t.Fatalf("notify calls = %d, want 1", len(nf.Requests))

View File

@@ -193,7 +193,7 @@ func TestHydratePreviousSessionArtifactsDoesNotUseLocalPreviousWorkspaceState(t
writeFile(t, filepath.Join(seed.PreviousSessionRoot, "artifacts", "session_recap.md"), "# local stale recap\n") writeFile(t, filepath.Join(seed.PreviousSessionRoot, "artifacts", "session_recap.md"), "# local stale recap\n")
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements) _, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "object missing from archive") { if err == nil || !strings.Contains(err.Error(), "object missing from published candidate keys") {
t.Fatalf("error = %v, want remote-object-missing failure", err) t.Fatalf("error = %v, want remote-object-missing failure", err)
} }
} }
@@ -273,7 +273,7 @@ func seedPreviousCurrentState(
campaign := strings.TrimSpace(env.Config.Session.Campaign) campaign := strings.TrimSpace(env.Config.Session.Campaign)
rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix) rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix)
previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID) previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID)
manifestKey, runPointerKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix) manifestKey, runPointerKey := artifacts.ResolveCurrentStateKeys(previousSessionPrefix)
previousRunID := "20260510T010203Z-a1b2c3d4" previousRunID := "20260510T010203Z-a1b2c3d4"
previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID) previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID)

View File

@@ -19,13 +19,13 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
type archiveStage struct{} type publishStage struct{}
type archiveUploadFile struct { type publishUploadFile struct {
RelativePath string RelativePath string
LocalPath string LocalPath string
} }
var archivePrerequisiteStages = []string{ var publishPrerequisiteStages = []string{
"prepare", "prepare",
"transcribe", "transcribe",
"merge", "merge",
@@ -35,9 +35,9 @@ var archivePrerequisiteStages = []string{
"analyze", "analyze",
} }
func (archiveStage) Name() string { return "publish" } func (publishStage) Name() string { return "publish" }
func (archiveStage) Declares() IODecl { func (publishStage) Declares() IODecl {
return IODecl{ return IODecl{
Inputs: []artifacts.Ref{ Inputs: []artifacts.Ref{
{Kind: "manifest", Category: "input", RelativePath: "manifest.json"}, {Kind: "manifest", Category: "input", RelativePath: "manifest.json"},
@@ -45,23 +45,23 @@ func (archiveStage) Declares() IODecl {
} }
} }
func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil { if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return nil, fmt.Errorf("publish: resolved config must include pipeline and session") return nil, fmt.Errorf("publish: resolved config must include pipeline and session")
} }
if archiveDisabled(env) { if publishDisabled(env) {
return &StageResult{ return &StageResult{
Metadata: map[string]any{ Metadata: map[string]any{
"stage": "publish", "stage": "publish",
"skipped": true, "skipped": true,
"archive_enabled": false, "publish_enabled": false,
"audio_upload_skipped": true, "audio_upload_skipped": true,
"current_pointer_written": false, "current_pointer_written": false,
}, },
}, nil }, nil
} }
if archiveRunUploadDisabled(env) { if publishRunUploadDisabled(env) {
return &StageResult{ return &StageResult{
Metadata: map[string]any{ Metadata: map[string]any{
"stage": "publish", "stage": "publish",
@@ -80,7 +80,7 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("publish: remote object store backend is required when publish run upload is enabled") return nil, fmt.Errorf("publish: remote object store backend is required when publish run upload is enabled")
} }
runRoot, err := resolveArchiveRunRoot(env, m) runRoot, err := resolvePublishRunRoot(env, m)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: resolve run root: %w", err) return nil, fmt.Errorf("publish: resolve run root: %w", err)
} }
@@ -92,15 +92,15 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("publish: run root %q is not a directory", runRoot) return nil, fmt.Errorf("publish: run root %q is not a directory", runRoot)
} }
runPrefix, err := artifacts.ResolveArchiveRunPrefix(env.Config, m) runPrefix, err := artifacts.ResolvePublishRunPrefix(env.Config, m)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: resolve s3 run prefix: %w", err) return nil, fmt.Errorf("publish: resolve s3 run prefix: %w", err)
} }
sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(env.Config, m) sessionPrefix, err := artifacts.ResolvePublishSessionPrefix(env.Config, m)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: resolve s3 session prefix: %w", err) return nil, fmt.Errorf("publish: resolve s3 session prefix: %w", err)
} }
bucket := artifacts.ResolveArchiveBucket(env.Config, m) bucket := artifacts.ResolvePublishBucket(env.Config, m)
if bucket == "" { if bucket == "" {
return nil, fmt.Errorf("publish: resolve s3 bucket: bucket is required") return nil, fmt.Errorf("publish: resolve s3 bucket: bucket is required")
} }
@@ -109,21 +109,21 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("publish: run id is required") return nil, fmt.Errorf("publish: run id is required")
} }
manifestSource, err := resolveArchiveRunManifestSource(runRoot) manifestSource, err := resolvePublishRunManifestSource(runRoot)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: resolve run manifest source: %w", err) return nil, fmt.Errorf("publish: resolve run manifest source: %w", err)
} }
runFiles, err := collectArchiveRunFiles(runRoot, manifestSource) runFiles, err := collectPublishRunFiles(runRoot, manifestSource)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: collect run files: %w", err) return nil, fmt.Errorf("publish: collect run files: %w", err)
} }
sessionPaths := archiveSessionPaths(env, m) sessionPaths := publishSessionPaths(env, m)
previousFiles, err := collectArchivePreviousFiles(sessionPaths.PreviousDir) previousFiles, err := collectPublishPreviousFiles(sessionPaths.PreviousDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: collect previous files: %w", err) return nil, fmt.Errorf("publish: collect previous files: %w", err)
} }
runtimeCatalog, err := buildArchiveRuntimeArtifactCatalog(sessionPaths, env.Config.Pipeline.Scriptorium) runtimeCatalog, err := buildPublishRuntimeArtifactCatalog(sessionPaths, env.Config.Pipeline.Scriptorium)
if err != nil { if err != nil {
return nil, fmt.Errorf("publish: build runtime artifact catalog: %w", err) return nil, fmt.Errorf("publish: build runtime artifact catalog: %w", err)
} }
@@ -149,12 +149,12 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
} }
publishedUploaded := make([]string, 0, len(publishOutputs)) publishedUploaded := make([]string, 0, len(publishOutputs))
for _, promotion := range publishOutputs { for _, publishedOutput := range publishOutputs {
key := artifacts.S3PublishedOutputKey(sessionPrefix, promotion.Dest) key := artifacts.S3PublishedOutputKey(sessionPrefix, publishedOutput.Dest)
if _, err := env.ObjectStore.Upload(ctx, promotion.LocalPath, key, storage.UploadOptions{}); err != nil { if _, err := env.ObjectStore.Upload(ctx, publishedOutput.LocalPath, key, storage.UploadOptions{}); err != nil {
return nil, fmt.Errorf("publish: upload published output source %q to %q: %w", promotion.Source, key, err) return nil, fmt.Errorf("publish: upload published output source %q to %q: %w", publishedOutput.Source, key, err)
} }
publishedUploaded = append(publishedUploaded, promotion.Dest) publishedUploaded = append(publishedUploaded, publishedOutput.Dest)
} }
previousUploaded := make([]string, 0, len(previousFiles)) previousUploaded := make([]string, 0, len(previousFiles))
@@ -166,8 +166,8 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
previousUploaded = append(previousUploaded, file.RelativePath) previousUploaded = append(previousUploaded, file.RelativePath)
} }
currentManifestKey, currentRunPointerKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) currentManifestKey, currentRunPointerKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
manifestTempPath, err := writeCurrentManifestSnapshot(m, archiveMetadataPreview( manifestTempPath, err := writeCurrentManifestSnapshot(m, publishMetadataPreview(
bucket, bucket,
runPrefix, runPrefix,
sessionPrefix, sessionPrefix,
@@ -250,7 +250,7 @@ type publishSkippedUnselectedOutput struct {
Required bool Required bool
} }
func archiveDisabled(env *Env) bool { func publishDisabled(env *Env) bool {
cfg := env.Config.Pipeline.Publish cfg := env.Config.Pipeline.Publish
if cfg == nil { if cfg == nil {
return true return true
@@ -258,7 +258,7 @@ func archiveDisabled(env *Env) bool {
return cfg.Enabled != nil && !*cfg.Enabled return cfg.Enabled != nil && !*cfg.Enabled
} }
func archiveRunUploadDisabled(env *Env) bool { func publishRunUploadDisabled(env *Env) bool {
cfg := env.Config.Pipeline.Publish cfg := env.Config.Pipeline.Publish
if cfg == nil { if cfg == nil {
return true return true
@@ -270,7 +270,7 @@ func validatePublishPrerequisites(m *manifest.Manifest) error {
if m == nil { if m == nil {
return fmt.Errorf("manifest is required") return fmt.Errorf("manifest is required")
} }
for _, stageName := range archivePrerequisiteStages { for _, stageName := range publishPrerequisiteStages {
sr := m.Stages[stageName] sr := m.Stages[stageName]
if sr == nil { if sr == nil {
return fmt.Errorf("prerequisite stage %q has not succeeded", stageName) return fmt.Errorf("prerequisite stage %q has not succeeded", stageName)
@@ -282,7 +282,7 @@ func validatePublishPrerequisites(m *manifest.Manifest) error {
return nil return nil
} }
func resolveArchiveRunRoot(env *Env, m *manifest.Manifest) (string, error) { func resolvePublishRunRoot(env *Env, m *manifest.Manifest) (string, error) {
sessionID := strings.TrimSpace(env.Config.Session.SessionID) sessionID := strings.TrimSpace(env.Config.Session.SessionID)
if sessionID == "" && m != nil { if sessionID == "" && m != nil {
sessionID = strings.TrimSpace(m.SessionID) sessionID = strings.TrimSpace(m.SessionID)
@@ -313,7 +313,7 @@ func resolveArchiveRunRoot(env *Env, m *manifest.Manifest) (string, error) {
return canonical, nil return canonical, nil
} }
func resolveArchiveSessionRoot(env *Env, m *manifest.Manifest) (string, error) { func resolvePublishSessionRoot(env *Env, m *manifest.Manifest) (string, error) {
sessionID := strings.TrimSpace(env.Config.Session.SessionID) sessionID := strings.TrimSpace(env.Config.Session.SessionID)
if sessionID == "" && m != nil { if sessionID == "" && m != nil {
sessionID = strings.TrimSpace(m.SessionID) sessionID = strings.TrimSpace(m.SessionID)
@@ -331,7 +331,7 @@ func resolveArchiveSessionRoot(env *Env, m *manifest.Manifest) (string, error) {
return filepath.Clean(artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID)), nil return filepath.Clean(artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID)), nil
} }
func archiveSessionPaths(env *Env, m *manifest.Manifest) artifacts.SessionPaths { func publishSessionPaths(env *Env, m *manifest.Manifest) artifacts.SessionPaths {
sessionID := strings.TrimSpace(env.Config.Session.SessionID) sessionID := strings.TrimSpace(env.Config.Session.SessionID)
if sessionID == "" && m != nil { if sessionID == "" && m != nil {
sessionID = strings.TrimSpace(m.SessionID) sessionID = strings.TrimSpace(m.SessionID)
@@ -357,8 +357,8 @@ func resolvePublishOutputs(
skippedOptionalOutputs := make([]string, 0) skippedOptionalOutputs := make([]string, 0)
skippedUnselectedOutputs := make([]publishSkippedUnselectedOutput, 0) skippedUnselectedOutputs := make([]publishSkippedUnselectedOutput, 0)
lockedOutputs := make([]publishLockedOutput, 0) lockedOutputs := make([]publishLockedOutput, 0)
lockSet := archiveLockSet(locks) lockSet := publishLockSet(locks)
selectedSet := archiveSelectedArtifactSet(selectedArtifactKeys) selectedSet := publishSelectedArtifactSet(selectedArtifactKeys)
configuredOutputs := configuredOutputPathMapFromCatalog(catalog) configuredOutputs := configuredOutputPathMapFromCatalog(catalog)
for _, rule := range rules { for _, rule := range rules {
source := strings.TrimSpace(rule.Source) source := strings.TrimSpace(rule.Source)
@@ -424,7 +424,7 @@ func resolvePublishOutputs(
return out, skippedOptionalOutputs, skippedUnselectedOutputs, lockedOutputs, nil return out, skippedOptionalOutputs, skippedUnselectedOutputs, lockedOutputs, nil
} }
func archiveSelectedArtifactSet(selected []string) map[string]struct{} { func publishSelectedArtifactSet(selected []string) map[string]struct{} {
if len(selected) == 0 { if len(selected) == 0 {
return nil return nil
} }
@@ -439,7 +439,7 @@ func archiveSelectedArtifactSet(selected []string) map[string]struct{} {
return out return out
} }
func archiveLockSet(locks []config.PublishLockRule) map[string]config.PublishLockRule { func publishLockSet(locks []config.PublishLockRule) map[string]config.PublishLockRule {
out := make(map[string]config.PublishLockRule, len(locks)) out := make(map[string]config.PublishLockRule, len(locks))
for _, lock := range locks { for _, lock := range locks {
source := strings.TrimSpace(lock.Source) source := strings.TrimSpace(lock.Source)
@@ -471,7 +471,7 @@ func configuredOutputPathMapFromCatalog(catalog *artifacts.ArtifactCatalog) map[
return out return out
} }
func buildArchiveRuntimeArtifactCatalog( func buildPublishRuntimeArtifactCatalog(
paths artifacts.SessionPaths, paths artifacts.SessionPaths,
scriptoriumCfg *config.ScriptoriumConfig, scriptoriumCfg *config.ScriptoriumConfig,
) (*artifacts.ArtifactCatalog, error) { ) (*artifacts.ArtifactCatalog, error) {
@@ -538,8 +538,8 @@ func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured
return filepath.Join(paths.Root, rel), nil return filepath.Join(paths.Root, rel), nil
} }
func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile, error) { func collectPublishRunFiles(runRoot, manifestPath string) ([]publishUploadFile, error) {
files := make([]archiveUploadFile, 0, 64) files := make([]publishUploadFile, 0, 64)
err := filepath.WalkDir(runRoot, func(path string, d fs.DirEntry, walkErr error) error { err := filepath.WalkDir(runRoot, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
@@ -553,7 +553,7 @@ func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile,
return fmt.Errorf("relative dir from %q to %q: %w", runRoot, path, err) return fmt.Errorf("relative dir from %q to %q: %w", runRoot, path, err)
} }
relDir = filepath.ToSlash(relDir) relDir = filepath.ToSlash(relDir)
// Preserve existing behavior: audio is not uploaded in archive run record. // Preserve existing behavior: audio is not uploaded in publish run record.
if relDir == "audio" || strings.HasPrefix(relDir, "audio/") { if relDir == "audio" || strings.HasPrefix(relDir, "audio/") {
return filepath.SkipDir return filepath.SkipDir
} }
@@ -564,7 +564,7 @@ func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile,
return fmt.Errorf("relative path from %q to %q: %w", runRoot, path, err) return fmt.Errorf("relative path from %q to %q: %w", runRoot, path, err)
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
files = append(files, archiveUploadFile{ files = append(files, publishUploadFile{
RelativePath: rel, RelativePath: rel,
LocalPath: path, LocalPath: path,
}) })
@@ -584,11 +584,11 @@ func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile,
if manifestInfo.IsDir() { if manifestInfo.IsDir() {
return nil, fmt.Errorf("manifest path %q is a directory", manifestPath) return nil, fmt.Errorf("manifest path %q is a directory", manifestPath)
} }
files = append(files, archiveUploadFile{ files = append(files, publishUploadFile{
RelativePath: "manifest.json", RelativePath: "manifest.json",
LocalPath: manifestPath, LocalPath: manifestPath,
}) })
seen := map[string]archiveUploadFile{} seen := map[string]publishUploadFile{}
for _, file := range files { for _, file := range files {
seen[file.RelativePath] = file seen[file.RelativePath] = file
} }
@@ -603,7 +603,7 @@ func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile,
return files, nil return files, nil
} }
func collectArchivePreviousFiles(previousDir string) ([]archiveUploadFile, error) { func collectPublishPreviousFiles(previousDir string) ([]publishUploadFile, error) {
previousDir = filepath.Clean(strings.TrimSpace(previousDir)) previousDir = filepath.Clean(strings.TrimSpace(previousDir))
if previousDir == "" { if previousDir == "" {
return nil, fmt.Errorf("previous directory is required") return nil, fmt.Errorf("previous directory is required")
@@ -619,7 +619,7 @@ func collectArchivePreviousFiles(previousDir string) ([]archiveUploadFile, error
return nil, fmt.Errorf("previous path %q is not a directory", previousDir) return nil, fmt.Errorf("previous path %q is not a directory", previousDir)
} }
files := make([]archiveUploadFile, 0, 16) files := make([]publishUploadFile, 0, 16)
err = filepath.WalkDir(previousDir, func(path string, d fs.DirEntry, walkErr error) error { err = filepath.WalkDir(previousDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
@@ -632,7 +632,7 @@ func collectArchivePreviousFiles(previousDir string) ([]archiveUploadFile, error
return fmt.Errorf("relative path from %q to %q: %w", previousDir, path, err) return fmt.Errorf("relative path from %q to %q: %w", previousDir, path, err)
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
files = append(files, archiveUploadFile{ files = append(files, publishUploadFile{
RelativePath: filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, rel)), RelativePath: filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, rel)),
LocalPath: path, LocalPath: path,
}) })
@@ -648,7 +648,7 @@ func collectArchivePreviousFiles(previousDir string) ([]archiveUploadFile, error
return files, nil return files, nil
} }
func resolveArchiveRunManifestSource(runRoot string) (string, error) { func resolvePublishRunManifestSource(runRoot string) (string, error) {
path := filepath.Join(filepath.Clean(runRoot), "manifest.json") path := filepath.Join(filepath.Clean(runRoot), "manifest.json")
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@@ -674,7 +674,7 @@ func directoryExists(path string) (bool, error) {
return false, err return false, err
} }
func writeCurrentManifestSnapshot(m *manifest.Manifest, archiveMetadata map[string]any) (string, error) { func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[string]any) (string, error) {
if m == nil { if m == nil {
return "", fmt.Errorf("manifest is required") return "", fmt.Errorf("manifest is required")
} }
@@ -707,7 +707,7 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, archiveMetadata map[stri
now := time.Now().UTC() now := time.Now().UTC()
clone.MarkStageSucceeded("publish", now, nil) clone.MarkStageSucceeded("publish", now, nil)
if sr := clone.Stages["publish"]; sr != nil { if sr := clone.Stages["publish"]; sr != nil {
sr.Metadata = archiveMetadata sr.Metadata = publishMetadata
} }
data, err := json.MarshalIndent(&clone, "", " ") data, err := json.MarshalIndent(&clone, "", " ")
@@ -747,7 +747,7 @@ func writeCurrentRunIDPointer(runID string) (string, error) {
return path, nil return path, nil
} }
func archiveMetadataPreview( func publishMetadataPreview(
bucket, runPrefix, sessionPrefix string, bucket, runPrefix, sessionPrefix string,
runUploaded []string, runUploaded []string,
publishedUploaded []string, publishedUploaded []string,

View File

@@ -17,11 +17,11 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
func TestArchiveSkipsWhenDisabled(t *testing.T) { func TestPublishSkipsWhenDisabled(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Enabled = boolPtr(false) env.Config.Pipeline.Publish.Enabled = boolPtr(false)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -29,15 +29,15 @@ func TestArchiveSkipsWhenDisabled(t *testing.T) {
t.Fatalf("metadata = %#v, want skipped=true", result.Metadata) t.Fatalf("metadata = %#v, want skipped=true", result.Metadata)
} }
if len(env.ObjectStore.(*storage.FakeBackend).Uploads) != 0 { if len(env.ObjectStore.(*storage.FakeBackend).Uploads) != 0 {
t.Fatalf("unexpected uploads when archive disabled") t.Fatalf("unexpected uploads when publish disabled")
} }
} }
func TestArchiveSkipsRunUploadWhenDisabled(t *testing.T) { func TestPublishSkipsRunUploadWhenDisabled(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.UploadRun = boolPtr(false) env.Config.Pipeline.Publish.UploadRun = boolPtr(false)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -49,11 +49,11 @@ func TestArchiveSkipsRunUploadWhenDisabled(t *testing.T) {
} }
} }
func TestArchiveFailsWhenPrerequisiteNotSucceeded(t *testing.T) { func TestPublishFailsWhenPrerequisiteNotSucceeded(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
m.Stages["trim"].Status = manifest.StatusFailed m.Stages["trim"].Status = manifest.StatusFailed
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `prerequisite stage "trim"`) { if err == nil || !strings.Contains(err.Error(), `prerequisite stage "trim"`) {
t.Fatalf("Run() error = %v, want prerequisite failure", err) t.Fatalf("Run() error = %v, want prerequisite failure", err)
} }
@@ -62,11 +62,11 @@ func TestArchiveFailsWhenPrerequisiteNotSucceeded(t *testing.T) {
} }
} }
func TestArchiveUploadsRunRecordPromotionsAndCurrentPointer(t *testing.T) { func TestPublishUploadsRunRecordPublishedOutputsAndCurrentPointer(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -94,10 +94,10 @@ func TestArchiveUploadsRunRecordPromotionsAndCurrentPointer(t *testing.T) {
trimmedKey := sessionPrefix + "transcripts/final.trimmed.json" trimmedKey := sessionPrefix + "transcripts/final.trimmed.json"
recapKey := sessionPrefix + "artifacts/session_recap.md" recapKey := sessionPrefix + "artifacts/session_recap.md"
if _, ok := fake.Objects[trimmedKey]; !ok { if _, ok := fake.Objects[trimmedKey]; !ok {
t.Fatalf("missing promoted key %q", trimmedKey) t.Fatalf("missing published output key %q", trimmedKey)
} }
if _, ok := fake.Objects[recapKey]; !ok { if _, ok := fake.Objects[recapKey]; !ok {
t.Fatalf("missing promoted key %q", recapKey) t.Fatalf("missing published output key %q", recapKey)
} }
currentManifestKey := sessionPrefix + "current/manifest.json" currentManifestKey := sessionPrefix + "current/manifest.json"
@@ -136,8 +136,8 @@ func TestArchiveUploadsRunRecordPromotionsAndCurrentPointer(t *testing.T) {
} }
} }
func TestArchiveUploadsPreviousCacheWhenPresent(t *testing.T) { func TestPublishUploadsPreviousCacheWhenPresent(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
sessionRoot := artifacts.SessionWorkDirForCampaign( sessionRoot := artifacts.SessionWorkDirForCampaign(
env.Config.Pipeline.Workspace.Root, env.Config.Pipeline.Workspace.Root,
@@ -147,7 +147,7 @@ func TestArchiveUploadsPreviousCacheWhenPresent(t *testing.T) {
writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "manifest.json"), "{\"session_id\":\"2026-04-12\"}\n") writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "manifest.json"), "{\"session_id\":\"2026-04-12\"}\n")
writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n") writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -155,20 +155,20 @@ func TestArchiveUploadsPreviousCacheWhenPresent(t *testing.T) {
previousManifestKey := m.S3SessionPrefix + "previous/manifest.json" previousManifestKey := m.S3SessionPrefix + "previous/manifest.json"
previousRecapKey := m.S3SessionPrefix + "previous/artifacts/session_recap.md" previousRecapKey := m.S3SessionPrefix + "previous/artifacts/session_recap.md"
if _, ok := fake.Objects[previousManifestKey]; !ok { if _, ok := fake.Objects[previousManifestKey]; !ok {
t.Fatalf("missing archived previous manifest key %q", previousManifestKey) t.Fatalf("missing published previous manifest key %q", previousManifestKey)
} }
if _, ok := fake.Objects[previousRecapKey]; !ok { if _, ok := fake.Objects[previousRecapKey]; !ok {
t.Fatalf("missing archived previous artifact key %q", previousRecapKey) t.Fatalf("missing published previous artifact key %q", previousRecapKey)
} }
if result.Metadata["previous_files_uploaded"] != 2 { if result.Metadata["previous_files_uploaded"] != 2 {
t.Fatalf("metadata previous_files_uploaded = %#v, want 2", result.Metadata["previous_files_uploaded"]) t.Fatalf("metadata previous_files_uploaded = %#v, want 2", result.Metadata["previous_files_uploaded"])
} }
} }
func TestArchiveToleratesMissingPreviousCache(t *testing.T) { func TestPublishToleratesMissingPreviousCache(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -177,35 +177,35 @@ func TestArchiveToleratesMissingPreviousCache(t *testing.T) {
} }
} }
func TestArchiveUsesCustomPromotionRules(t *testing.T) { func TestPublishUsesCustomOutputRules(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{ env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.transcript.final_trimmed", Dest: "published/trimmed.json", Required: boolPtr(true)}, {Source: "narratio.transcript.final_trimmed", Dest: "published/trimmed.json", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "published/recap.md", Required: boolPtr(true)}, {Source: "narratio.artifact.session_recap", Dest: "published/recap.md", Required: boolPtr(true)},
} }
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
if _, ok := fake.Objects[m.S3SessionPrefix+"published/trimmed.json"]; !ok { if _, ok := fake.Objects[m.S3SessionPrefix+"published/trimmed.json"]; !ok {
t.Fatalf("missing custom promoted trimmed key") t.Fatalf("missing custom published trimmed key")
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"published/recap.md"]; !ok { if _, ok := fake.Objects[m.S3SessionPrefix+"published/recap.md"]; !ok {
t.Fatalf("missing custom promoted recap key") t.Fatalf("missing custom published recap key")
} }
} }
func TestArchiveSkipsOptionalMissingPromotion(t *testing.T) { func TestPublishSkipsOptionalMissingOutput(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{ env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPtr(true)}, {Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPtr(true)},
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(false)}, {Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(false)},
} }
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -216,8 +216,8 @@ func TestArchiveSkipsOptionalMissingPromotion(t *testing.T) {
} }
} }
func TestArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) { func TestPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"player_handout"} env.SelectedArtifactKeys = []string{"player_handout"}
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -226,15 +226,15 @@ func TestArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) {
} }
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"transcripts/final.trimmed.json"]; !ok { if _, ok := fake.Objects[m.S3SessionPrefix+"transcripts/final.trimmed.json"]; !ok {
t.Fatalf("missing built-in promoted trimmed key") t.Fatalf("missing built-in published trimmed key")
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok { if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok {
t.Fatalf("unexpected unselected recap promotion upload") t.Fatalf("unexpected unselected recap published output upload")
} }
skipped := result.Metadata["skipped_unselected_outputs"].([]map[string]any) skipped := result.Metadata["skipped_unselected_outputs"].([]map[string]any)
if len(skipped) != 1 { if len(skipped) != 1 {
@@ -251,8 +251,8 @@ func TestArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) {
} }
} }
func TestArchiveSelectedConfiguredPromotionStillFailsWhenMissing(t *testing.T) { func TestPublishSelectedConfiguredOutputStillFailsWhenMissing(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"session_recap"} env.SelectedArtifactKeys = []string{"session_recap"}
sessionRoot := artifacts.SessionWorkDirForCampaign( sessionRoot := artifacts.SessionWorkDirForCampaign(
env.Config.Pipeline.Workspace.Root, env.Config.Pipeline.Workspace.Root,
@@ -263,26 +263,26 @@ func TestArchiveSelectedConfiguredPromotionStillFailsWhenMissing(t *testing.T) {
t.Fatalf("remove recap: %v", err) t.Fatalf("remove recap: %v", err)
} }
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.artifact.session_recap"`) { if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.artifact.session_recap"`) {
t.Fatalf("Run() error = %v, want required selected output failure", err) t.Fatalf("Run() error = %v, want required selected output failure", err)
} }
} }
func TestArchiveLockedSelectedPromotionSkipsAsLocked(t *testing.T) { func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"session_recap"} env.SelectedArtifactKeys = []string{"session_recap"}
env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{ env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{
{Source: "narratio.artifact.session_recap", Reason: "reviewed"}, {Source: "narratio.artifact.session_recap", Reason: "reviewed"},
} }
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok { if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok {
t.Fatalf("unexpected locked recap promotion upload") t.Fatalf("unexpected locked recap published output upload")
} }
if result.Metadata["locked_output_count"] != 1 { if result.Metadata["locked_output_count"] != 1 {
t.Fatalf("locked_output_count = %#v, want 1", result.Metadata["locked_output_count"]) t.Fatalf("locked_output_count = %#v, want 1", result.Metadata["locked_output_count"])
@@ -293,8 +293,8 @@ func TestArchiveLockedSelectedPromotionSkipsAsLocked(t *testing.T) {
} }
} }
func TestArchiveLockedUnselectedConfiguredPromotionSkipsAsUnselected(t *testing.T) { func TestPublishLockedUnselectedConfiguredOutputSkipsAsUnselected(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"player_handout"} env.SelectedArtifactKeys = []string{"player_handout"}
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -305,7 +305,7 @@ func TestArchiveLockedUnselectedConfiguredPromotionSkipsAsUnselected(t *testing.
{Source: "narratio.artifact.session_recap", Reason: "reviewed"}, {Source: "narratio.artifact.session_recap", Reason: "reviewed"},
} }
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -318,25 +318,25 @@ func TestArchiveLockedUnselectedConfiguredPromotionSkipsAsUnselected(t *testing.
} }
} }
func TestArchiveSkipsLockedRequiredPromotionAndCommits(t *testing.T) { func TestPublishSkipsLockedRequiredOutputAndCommits(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{ env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{
{Source: "narratio.transcript.final_trimmed", Reason: "human reviewed"}, {Source: "narratio.transcript.final_trimmed", Reason: "human reviewed"},
} }
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json" trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
if _, ok := fake.Objects[trimmedKey]; ok { if _, ok := fake.Objects[trimmedKey]; ok {
t.Fatalf("locked promotion key %q should not be uploaded", trimmedKey) t.Fatalf("locked published output key %q should not be uploaded", trimmedKey)
} }
recapKey := m.S3SessionPrefix + "artifacts/session_recap.md" recapKey := m.S3SessionPrefix + "artifacts/session_recap.md"
if _, ok := fake.Objects[recapKey]; !ok { if _, ok := fake.Objects[recapKey]; !ok {
t.Fatalf("unlocked promotion key %q should be uploaded", recapKey) t.Fatalf("unlocked published output key %q should be uploaded", recapKey)
} }
runTrimmedKey := m.S3RunPrefix + "trim/outputs/transcripts/final.trimmed.json" runTrimmedKey := m.S3RunPrefix + "trim/outputs/transcripts/final.trimmed.json"
if _, ok := fake.Objects[runTrimmedKey]; !ok { if _, ok := fake.Objects[runTrimmedKey]; !ok {
@@ -365,7 +365,7 @@ func TestArchiveSkipsLockedRequiredPromotionAndCommits(t *testing.T) {
locked[0]["required"] != true || locked[0]["required"] != true ||
locked[0]["local_path"] == "" || locked[0]["local_path"] == "" ||
locked[0]["provenance"] == "" { locked[0]["provenance"] == "" {
t.Fatalf("locked promotion metadata = %#v", locked[0]) t.Fatalf("locked published output metadata = %#v", locked[0])
} }
currentManifestKey := m.S3SessionPrefix + "current/manifest.json" currentManifestKey := m.S3SessionPrefix + "current/manifest.json"
@@ -374,8 +374,8 @@ func TestArchiveSkipsLockedRequiredPromotionAndCommits(t *testing.T) {
t.Fatalf("unmarshal current manifest: %v", err) t.Fatalf("unmarshal current manifest: %v", err)
} }
stages := current["stages"].(map[string]any) stages := current["stages"].(map[string]any)
archive := stages["publish"].(map[string]any) publishRecord := stages["publish"].(map[string]any)
meta := archive["metadata"].(map[string]any) meta := publishRecord["metadata"].(map[string]any)
if meta["locked_output_count"] != float64(1) { if meta["locked_output_count"] != float64(1) {
t.Fatalf("current manifest locked_output_count = %#v, want 1", meta["locked_output_count"]) t.Fatalf("current manifest locked_output_count = %#v, want 1", meta["locked_output_count"])
} }
@@ -385,8 +385,8 @@ func TestArchiveSkipsLockedRequiredPromotionAndCommits(t *testing.T) {
} }
} }
func TestArchiveLockedRequiredMissingPromotionSucceeds(t *testing.T) { func TestPublishLockedRequiredMissingOutputSucceeds(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{ env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)}, {Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
} }
@@ -394,7 +394,7 @@ func TestArchiveLockedRequiredMissingPromotionSucceeds(t *testing.T) {
{Source: "narratio.transcript.base", Reason: "manual merge is locked"}, {Source: "narratio.transcript.base", Reason: "manual merge is locked"},
} }
result, err := archiveStage{}.Run(context.Background(), env, m) result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
@@ -402,22 +402,22 @@ func TestArchiveLockedRequiredMissingPromotionSucceeds(t *testing.T) {
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
mergedKey := m.S3SessionPrefix + "transcripts/base.json" mergedKey := m.S3SessionPrefix + "transcripts/base.json"
if _, ok := fake.Objects[mergedKey]; ok { if _, ok := fake.Objects[mergedKey]; ok {
t.Fatalf("locked missing promotion key %q should not be uploaded", mergedKey) t.Fatalf("locked missing published output key %q should not be uploaded", mergedKey)
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; !ok { if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; !ok {
t.Fatalf("current run pointer should be written for locked missing promotion") t.Fatalf("current run pointer should be written for locked missing output")
} }
locked := result.Metadata["locked_outputs"].([]map[string]any) locked := result.Metadata["locked_outputs"].([]map[string]any)
if len(locked) != 1 { if len(locked) != 1 {
t.Fatalf("locked_outputs = %#v, want one item", locked) t.Fatalf("locked_outputs = %#v, want one item", locked)
} }
if locked[0]["local_path"] != "" || locked[0]["provenance"] != "" { if locked[0]["local_path"] != "" || locked[0]["provenance"] != "" {
t.Fatalf("locked missing promotion metadata = %#v, want empty local path/provenance", locked[0]) t.Fatalf("locked missing published output metadata = %#v, want empty local path/provenance", locked[0])
} }
} }
func TestArchiveLockDoesNotOverwriteExistingPromotion(t *testing.T) { func TestPublishLockDoesNotOverwriteExistingOutput(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{ env.Config.Pipeline.Publish.Locks = []config.PublishLockRule{
{Source: "narratio.transcript.final_trimmed", Reason: "already published"}, {Source: "narratio.transcript.final_trimmed", Reason: "already published"},
} }
@@ -425,40 +425,40 @@ func TestArchiveLockDoesNotOverwriteExistingPromotion(t *testing.T) {
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json" trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte("previously published\n")}) fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte("previously published\n")})
if _, err := (archiveStage{}).Run(context.Background(), env, m); err != nil { if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
got := string(fake.Objects[trimmedKey].Data) got := string(fake.Objects[trimmedKey].Data)
if got != "previously published\n" { if got != "previously published\n" {
t.Fatalf("locked promotion object contents = %q, want existing object preserved", got) t.Fatalf("locked published output object contents = %q, want existing object preserved", got)
} }
for _, upload := range fake.Uploads { for _, upload := range fake.Uploads {
if upload.Key == trimmedKey { if upload.Key == trimmedKey {
t.Fatalf("locked promotion key %q was uploaded", trimmedKey) t.Fatalf("locked published output key %q was uploaded", trimmedKey)
} }
} }
} }
func TestArchiveFailsWhenRequiredPromotionMissing(t *testing.T) { func TestPublishFailsWhenRequiredOutputMissing(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{ env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)}, {Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
} }
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "required output source unavailable") { if err == nil || !strings.Contains(err.Error(), "required output source unavailable") {
t.Fatalf("Run() error = %v, want required output source unavailable failure", err) t.Fatalf("Run() error = %v, want required output source unavailable failure", err)
} }
} }
func TestArchiveFailsWhenCanonicalRunRootMissing(t *testing.T) { func TestPublishFailsWhenCanonicalRunRootMissing(t *testing.T) {
env, m, runRoot := archiveFixture(t) env, m, runRoot := publishFixture(t)
if err := os.RemoveAll(runRoot); err != nil { if err := os.RemoveAll(runRoot); err != nil {
t.Fatalf("remove run root: %v", err) t.Fatalf("remove run root: %v", err)
} }
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil { if err == nil {
t.Fatal("expected missing run-root error, got nil") t.Fatal("expected missing run-root error, got nil")
} }
@@ -467,8 +467,8 @@ func TestArchiveFailsWhenCanonicalRunRootMissing(t *testing.T) {
} }
} }
func TestArchiveDoesNotWriteCurrentPointerWhenPromotionUploadFails(t *testing.T) { func TestPublishDoesNotWriteCurrentPointerWhenOutputUploadFails(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json" trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
@@ -480,26 +480,26 @@ func TestArchiveDoesNotWriteCurrentPointerWhenPromotionUploadFails(t *testing.T)
originalUpload := fake.Upload originalUpload := fake.Upload
_ = originalUpload _ = originalUpload
// Use UploadErr toggle by checking call sequence in postcondition. // Use UploadErr toggle by checking call sequence in postcondition.
// First failure point is promotion upload; simulate by setting error immediately before promotion key write. // First failure point is published output upload; simulate by setting error immediately before published output key write.
// We cannot hook FakeBackend per-key without changing public behavior; use dedicated backend wrapper instead. // We cannot hook FakeBackend per-key without changing public behavior; use dedicated backend wrapper instead.
env.ObjectStore = &promotionFailingStore{delegate: fake, failKey: failingKey} env.ObjectStore = &publishedOutputFailingStore{delegate: fake, failKey: failingKey}
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "upload published output source") { if err == nil || !strings.Contains(err.Error(), "upload published output source") {
t.Fatalf("Run() error = %v, want published output upload failure", err) t.Fatalf("Run() error = %v, want published output upload failure", err)
} }
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; ok { if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; ok {
t.Fatalf("unexpected current pointer write on promotion failure") t.Fatalf("unexpected current pointer write on published output failure")
} }
fake.UploadErr = origUploadErr fake.UploadErr = origUploadErr
} }
func TestArchiveDoesNotWriteCurrentPointerWhenCurrentManifestUploadFails(t *testing.T) { func TestPublishDoesNotWriteCurrentPointerWhenCurrentManifestUploadFails(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
fake := env.ObjectStore.(*storage.FakeBackend) fake := env.ObjectStore.(*storage.FakeBackend)
env.ObjectStore = &promotionFailingStore{delegate: fake, failKey: m.S3SessionPrefix + "current/manifest.json"} env.ObjectStore = &publishedOutputFailingStore{delegate: fake, failKey: m.S3SessionPrefix + "current/manifest.json"}
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "current manifest") { if err == nil || !strings.Contains(err.Error(), "current manifest") {
t.Fatalf("Run() error = %v, want current manifest upload failure", err) t.Fatalf("Run() error = %v, want current manifest upload failure", err)
} }
@@ -508,17 +508,17 @@ func TestArchiveDoesNotWriteCurrentPointerWhenCurrentManifestUploadFails(t *test
} }
} }
func TestArchiveFailsWithoutObjectStore(t *testing.T) { func TestPublishFailsWithoutObjectStore(t *testing.T) {
env, m, _ := archiveFixture(t) env, m, _ := publishFixture(t)
env.ObjectStore = nil env.ObjectStore = nil
_, err := archiveStage{}.Run(context.Background(), env, m) _, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "object store") { if err == nil || !strings.Contains(err.Error(), "object store") {
t.Fatalf("Run() error = %v, want object store backend failure", err) t.Fatalf("Run() error = %v, want object store backend failure", err)
} }
} }
func archiveFixture(t *testing.T) (*Env, *manifest.Manifest, string) { func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
@@ -549,7 +549,7 @@ func archiveFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
m.S3Bucket = "my-dnd-archive" m.S3Bucket = "my-dnd-archive"
m.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", campaign, sessionID) m.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", campaign, sessionID)
m.S3RunPrefix = artifacts.S3RunPrefix(m.S3SessionPrefix, runID) m.S3RunPrefix = artifacts.S3RunPrefix(m.S3SessionPrefix, runID)
for _, name := range archivePrerequisiteStages { for _, name := range publishPrerequisiteStages {
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil) m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
} }
@@ -591,27 +591,27 @@ func archiveFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
return env, m, runRoot return env, m, runRoot
} }
type promotionFailingStore struct { type publishedOutputFailingStore struct {
delegate *storage.FakeBackend delegate *storage.FakeBackend
failKey string failKey string
} }
func (s *promotionFailingStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { func (s *publishedOutputFailingStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
return s.delegate.List(ctx, prefix) return s.delegate.List(ctx, prefix)
} }
func (s *promotionFailingStore) Download(ctx context.Context, key, localPath string) error { func (s *publishedOutputFailingStore) Download(ctx context.Context, key, localPath string) error {
return s.delegate.Download(ctx, key, localPath) return s.delegate.Download(ctx, key, localPath)
} }
func (s *promotionFailingStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) { func (s *publishedOutputFailingStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
if strings.TrimSpace(key) == strings.TrimSpace(s.failKey) { if strings.TrimSpace(key) == strings.TrimSpace(s.failKey) {
return storage.ObjectInfo{}, errors.New("forced upload failure") return storage.ObjectInfo{}, errors.New("forced upload failure")
} }
return s.delegate.Upload(ctx, localPath, key, opts) return s.delegate.Upload(ctx, localPath, key, opts)
} }
func (s *promotionFailingStore) Exists(ctx context.Context, key string) (bool, error) { func (s *publishedOutputFailingStore) Exists(ctx context.Context, key string) (bool, error) {
return s.delegate.Exists(ctx, key) return s.delegate.Exists(ctx, key)
} }