[object Object]

← back to Cli Printing Press

fix(cli): hydrate promote state across scopes (#738)

dfdf183139a2ae36b2c2750f9f7b0e80f15326c5 · 2026-05-08 13:00:33 -0700 · Trevin Chow

Files touched

Diff

commit dfdf183139a2ae36b2c2750f9f7b0e80f15326c5
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 13:00:33 2026 -0700

    fix(cli): hydrate promote state across scopes (#738)
---
 internal/pipeline/minimal_state_test.go | 19 ++++++++
 internal/pipeline/paths.go              | 27 +++++++++--
 internal/pipeline/publish.go            | 84 +++++++++++++++++++++++++++++----
 internal/pipeline/publish_test.go       | 40 ++++++++++++++++
 internal/pipeline/research.go           |  2 +-
 internal/pipeline/state.go              | 63 ++++++++++++++++++-------
 6 files changed, 206 insertions(+), 29 deletions(-)

diff --git a/internal/pipeline/minimal_state_test.go b/internal/pipeline/minimal_state_test.go
index 88f2103e..0841b93a 100644
--- a/internal/pipeline/minimal_state_test.go
+++ b/internal/pipeline/minimal_state_test.go
@@ -3,6 +3,7 @@
 package pipeline
 
 import (
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -104,3 +105,21 @@ func TestNewMinimalState_PreservesScopeFromRecoveredState(t *testing.T) {
 	state := NewMinimalState("scope-test-pp-cli", "/tmp/different")
 	assert.Equal(t, "test-scope", state.Scope, "Scope must be borrowed from the recovered state")
 }
+
+func TestFindStateByWorkingDirFindsRunFromDifferentCurrentScope(t *testing.T) {
+	home := setPressTestEnv(t)
+	workingDir := filepath.Join(home, "work", "cross-scope-pp-cli")
+	prior := NewState("cross-scope", workingDir)
+	require.NoError(t, prior.Save())
+
+	t.Setenv("PRINTING_PRESS_SCOPE", "fresh-publish-scope")
+
+	state, err := FindStateByWorkingDir(workingDir)
+	require.NoError(t, err)
+	assert.Equal(t, prior.RunID, state.RunID)
+	assert.Equal(t, "test-scope", state.Scope)
+	assert.Equal(t,
+		filepath.Join(home, ".runstate", "test-scope", "runs", prior.RunID, "pipeline"),
+		state.PipelineDir(),
+		"state path helpers should honor the recovered state's scope, not the current shell scope")
+}
diff --git a/internal/pipeline/paths.go b/internal/pipeline/paths.go
index 85001fa5..02bdeca2 100644
--- a/internal/pipeline/paths.go
+++ b/internal/pipeline/paths.go
@@ -46,19 +46,38 @@ func RunstateRoot() string {
 }
 
 func ScopedRunstateRoot() string {
-	return filepath.Join(RunstateRoot(), WorkspaceScope())
+	return scopedRunstateRoot(WorkspaceScope())
+}
+
+func scopedRunstateRoot(scope string) string {
+	if scope == "" {
+		scope = WorkspaceScope()
+	}
+	return filepath.Join(RunstateRoot(), scope)
+}
+
+func currentRunDirForScope(scope string) string {
+	return filepath.Join(scopedRunstateRoot(scope), "current")
+}
+
+func currentRunPointerPathForScope(scope, apiName string) string {
+	return filepath.Join(currentRunDirForScope(scope), apiName+".json")
+}
+
+func runRootForScope(scope, runID string) string {
+	return filepath.Join(scopedRunstateRoot(scope), "runs", runID)
 }
 
 func CurrentRunDir() string {
-	return filepath.Join(ScopedRunstateRoot(), "current")
+	return currentRunDirForScope(WorkspaceScope())
 }
 
 func CurrentRunPointerPath(apiName string) string {
-	return filepath.Join(CurrentRunDir(), apiName+".json")
+	return currentRunPointerPathForScope(WorkspaceScope(), apiName)
 }
 
 func RunRoot(runID string) string {
-	return filepath.Join(ScopedRunstateRoot(), "runs", runID)
+	return runRootForScope(WorkspaceScope(), runID)
 }
 
 func RunStatePath(runID string) string {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 41dd04fc..0d0a98b5 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -211,6 +211,28 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 	if existingData, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename)); err == nil {
 		var existing CLIManifest
 		if json.Unmarshal(existingData, &existing) == nil {
+			if state.RunID == "" && existing.RunID != "" {
+				state.RunID = existing.RunID
+				m.RunID = existing.RunID
+			}
+			if existing.DisplayName != "" {
+				m.DisplayName = existing.DisplayName
+			}
+			if existing.Owner != "" {
+				m.Owner = existing.Owner
+			}
+			if existing.CatalogEntry != "" {
+				m.CatalogEntry = existing.CatalogEntry
+			}
+			if existing.Category != "" {
+				m.Category = existing.Category
+			}
+			if existing.Description != "" {
+				m.Description = existing.Description
+			}
+			if existing.APIVersion != "" {
+				m.APIVersion = existing.APIVersion
+			}
 			m.MCPBinary = existing.MCPBinary
 			m.MCPToolCount = existing.MCPToolCount
 			m.MCPPublicToolCount = existing.MCPPublicToolCount
@@ -218,6 +240,11 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 			m.AuthType = existing.AuthType
 			m.AuthEnvVars = existing.AuthEnvVars
 			m.AuthEnvVarSpecs = existing.AuthEnvVarSpecs
+			m.EndpointTemplateVars = existing.EndpointTemplateVars
+			m.AuthKeyURL = existing.AuthKeyURL
+			m.AuthTitle = existing.AuthTitle
+			m.AuthDescription = existing.AuthDescription
+			m.AuthOptional = existing.AuthOptional
 			m.NovelFeatures = existing.NovelFeatures
 		}
 	}
@@ -304,7 +331,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		if len(nfs) > 0 {
 			m.NovelFeatures = novelFeaturesToManifest(nfs)
 		}
-		if len(m.NovelFeatures) > 0 && source != "" && source != state.PipelineDir() && source != RunRoot(state.RunID) {
+		if len(m.NovelFeatures) > 0 && source != "" && source != state.PipelineDir() && source != state.RunRoot() {
 			// Visibility for non-canonical sources — a one-line stderr
 			// note keeps promote silent on the happy path but tells the
 			// user when novel_features came from the glob fallback.
@@ -320,7 +347,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		fmt.Fprintf(os.Stderr,
 			"debug: research.json not found at %s or %s; skipping novel_features enrichment "+
 				"(state.RunID=%q)\n",
-			filepath.Join(RunRoot(state.RunID), "research.json"),
+			filepath.Join(state.RunRoot(), "research.json"),
 			filepath.Join(state.PipelineDir(), "research.json"),
 			state.RunID)
 	}
@@ -349,8 +376,8 @@ func loadResearchForPromote(state *PipelineState) (*ResearchResult, string) {
 		// caller's "is this a non-canonical source?" check compares
 		// against state.PipelineDir() and RunRoot(state.RunID).
 		if state.RunID != "" {
-			if _, statErr := os.Stat(filepath.Join(RunRoot(state.RunID), "research.json")); statErr == nil {
-				return r, RunRoot(state.RunID)
+			if _, statErr := os.Stat(filepath.Join(state.RunRoot(), "research.json")); statErr == nil {
+				return r, state.RunRoot()
 			}
 			return r, state.PipelineDir()
 		}
@@ -358,9 +385,7 @@ func loadResearchForPromote(state *PipelineState) (*ResearchResult, string) {
 	}
 
 	if state.RunID != "" {
-		// loadResearchForState already covered both canonical paths
-		// for a populated state — no further fallback to try.
-		return nil, ""
+		return loadMatchingResearch(globResearchCandidatesForRunID(state.RunID), state.APIName)
 	}
 
 	// Minimal-state fallback: empty RunID and the canonical loader
@@ -373,12 +398,16 @@ func loadResearchForPromote(state *PipelineState) (*ResearchResult, string) {
 	sort.SliceStable(candidates, func(i, j int) bool {
 		return candidates[i].mtime.After(candidates[j].mtime)
 	})
+	return loadMatchingResearch(candidates, state.APIName)
+}
+
+func loadMatchingResearch(candidates []researchCandidate, apiName string) (*ResearchResult, string) {
 	for _, c := range candidates {
 		r, err := LoadResearch(filepath.Dir(c.path))
 		if err != nil {
 			continue
 		}
-		if state.APIName != "" && r.APIName != "" && r.APIName != state.APIName {
+		if apiName != "" && r.APIName != "" && r.APIName != apiName {
 			continue
 		}
 		return r, c.path
@@ -386,6 +415,45 @@ func loadResearchForPromote(state *PipelineState) (*ResearchResult, string) {
 	return nil, ""
 }
 
+func globResearchCandidatesForRunID(runID string) []researchCandidate {
+	if runID == "" {
+		return nil
+	}
+	var out []researchCandidate
+	seen := make(map[string]bool)
+	add := func(path string) {
+		if seen[path] {
+			return
+		}
+		seen[path] = true
+		info, err := os.Stat(path)
+		if err != nil {
+			return
+		}
+		out = append(out, researchCandidate{path: path, mtime: info.ModTime()})
+	}
+
+	add(filepath.Join(RunRoot(runID), "research.json"))
+	add(filepath.Join(RunRoot(runID), "pipeline", "research.json"))
+
+	scopeEntries, err := os.ReadDir(RunstateRoot())
+	if err != nil {
+		return out
+	}
+	for _, entry := range scopeEntries {
+		if !entry.IsDir() {
+			continue
+		}
+		runRoot := runRootForScope(entry.Name(), runID)
+		add(filepath.Join(runRoot, "research.json"))
+		add(filepath.Join(runRoot, "pipeline", "research.json"))
+	}
+	sort.SliceStable(out, func(i, j int) bool {
+		return out[i].mtime.After(out[j].mtime)
+	})
+	return out
+}
+
 // researchCandidate is a path + mtime for sorting glob results.
 type researchCandidate struct {
 	path  string
diff --git a/internal/pipeline/publish_test.go b/internal/pipeline/publish_test.go
index 6baf6d6b..0df2b584 100644
--- a/internal/pipeline/publish_test.go
+++ b/internal/pipeline/publish_test.go
@@ -219,6 +219,46 @@ func TestWriteCLIManifestForPublish_NovelFeaturesFromPrintFlowResearch(t *testin
 	assert.Equal(t, "conflicts", m.NovelFeatures[0].Command)
 }
 
+// TestWriteCLIManifestForPublish_HydratesFromManifestRunIDAcrossScopes covers
+// the deferred-publish flow: generate wrote a manifest with run_id under one
+// PRESS_SCOPE, then promote runs from a different shell/cwd with a different
+// scope. The source manifest's run_id must still let promote find research.json.
+func TestWriteCLIManifestForPublish_HydratesFromManifestRunIDAcrossScopes(t *testing.T) {
+	tmp := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", tmp)
+	t.Setenv("PRINTING_PRESS_SCOPE", "publish-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+	runID := "20260508-070627"
+	workingDir := filepath.Join(tmp, "working", "test-api-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, WriteCLIManifest(workingDir, CLIManifest{
+		SchemaVersion: 1,
+		APIName:       "test-api",
+		CLIName:       "test-api-pp-cli",
+		RunID:         runID,
+	}))
+
+	built := []NovelFeature{
+		{Name: "Cross-scope feature", Command: "feature", Description: "Loaded from the original run scope."},
+	}
+	originalRunRoot := filepath.Join(RunstateRoot(), "generate-scope", "runs", runID)
+	writeResearchAt(t, originalRunRoot, &ResearchResult{
+		APIName:            "test-api",
+		NovelFeaturesBuilt: &built,
+	})
+
+	state := NewMinimalState("test-api-pp-cli", workingDir)
+	require.Empty(t, state.RunID, "current scope has no registry entry")
+
+	require.NoError(t, writeCLIManifestForPublish(state, workingDir))
+
+	m := readPublishedManifest(t, workingDir)
+	assert.Equal(t, runID, m.RunID)
+	require.Len(t, m.NovelFeatures, 1)
+	assert.Equal(t, "feature", m.NovelFeatures[0].Command)
+}
+
 // TestWriteCLIManifestForPublish_NovelFeaturesPreservedFromCarryForward covers
 // the defense-in-depth path: research.json missing (deleted, not yet written),
 // but the existing manifest in the staging dir already has novel_features from
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index 99cb275f..7edabe5a 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -279,7 +279,7 @@ func LoadResearch(pipelineDir string) (*ResearchResult, error) {
 // Without this fallback the publish-time read silently misses skill-flow runs
 // and ships manifests with empty novel_features (cal-com retro #334 F2).
 func loadResearchForState(state *PipelineState) (*ResearchResult, error) {
-	if r, err := LoadResearch(RunRoot(state.RunID)); err == nil {
+	if r, err := LoadResearch(state.RunRoot()); err == nil {
 		return r, nil
 	}
 	return LoadResearch(state.PipelineDir())
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index f8d86838..4b31f54c 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -179,7 +179,7 @@ func writeCurrentRunPointer(state *PipelineState) error {
 		UpdatedAt:  time.Now(),
 	}
 
-	if err := os.MkdirAll(CurrentRunDir(), 0o755); err != nil {
+	if err := os.MkdirAll(currentRunDirForScope(state.Scope), 0o755); err != nil {
 		return fmt.Errorf("creating current run dir: %w", err)
 	}
 
@@ -188,7 +188,7 @@ func writeCurrentRunPointer(state *PipelineState) error {
 		return fmt.Errorf("marshaling current run pointer: %w", err)
 	}
 
-	return os.WriteFile(CurrentRunPointerPath(state.APIName), data, 0o644)
+	return os.WriteFile(currentRunPointerPathForScope(state.Scope, state.APIName), data, 0o644)
 }
 
 func findRunstateStatePath(apiName string) (string, bool) {
@@ -272,13 +272,7 @@ func FindStateByWorkingDir(dir string) (*PipelineState, error) {
 		return nil, fmt.Errorf("resolving working dir: %w", err)
 	}
 
-	pattern := filepath.Join(ScopedRunstateRoot(), "runs", "*", "state.json")
-	matches, err := filepath.Glob(pattern)
-	if err != nil {
-		return nil, fmt.Errorf("scanning runstate: %w", err)
-	}
-
-	for _, candidate := range matches {
+	for _, candidate := range runstateStatePathCandidates() {
 		data, err := os.ReadFile(candidate)
 		if err != nil {
 			continue
@@ -293,7 +287,7 @@ func FindStateByWorkingDir(dir string) (*PipelineState, error) {
 				state.RunID = filepath.Base(filepath.Dir(candidate))
 			}
 			if state.Scope == "" {
-				state.Scope = WorkspaceScope()
+				state.Scope = scopeFromStatePath(candidate)
 			}
 			return &state, nil
 		}
@@ -302,6 +296,39 @@ func FindStateByWorkingDir(dir string) (*PipelineState, error) {
 	return nil, fmt.Errorf("no runstate entry for working dir %s", absDir)
 }
 
+func runstateStatePathCandidates() []string {
+	seen := make(map[string]bool)
+	var out []string
+	addMatches := func(pattern string) {
+		matches, err := filepath.Glob(pattern)
+		if err != nil {
+			return
+		}
+		for _, match := range matches {
+			if !seen[match] {
+				seen[match] = true
+				out = append(out, match)
+			}
+		}
+	}
+	addMatches(filepath.Join(ScopedRunstateRoot(), "runs", "*", "state.json"))
+	addMatches(filepath.Join(RunstateRoot(), "*", "runs", "*", "state.json"))
+	return out
+}
+
+func scopeFromStatePath(path string) string {
+	runsDir := filepath.Dir(filepath.Dir(path))
+	scopeDir := filepath.Dir(runsDir)
+	if filepath.Base(runsDir) != "runs" {
+		return WorkspaceScope()
+	}
+	scope := filepath.Base(scopeDir)
+	if scope == "." || scope == string(filepath.Separator) || scope == "" {
+		return WorkspaceScope()
+	}
+	return scope
+}
+
 // NewMinimalState creates a lightweight state for CLIs that skipped the
 // generate pipeline (e.g. plan-driven CLIs) or whose working directory
 // doesn't match any current runstate via FindStateByWorkingDir.
@@ -597,27 +624,31 @@ func (s *PipelineState) EffectiveWorkingDir() string {
 }
 
 func (s *PipelineState) StatePath() string {
-	return RunStatePath(s.RunID)
+	return filepath.Join(s.RunRoot(), "state.json")
 }
 
 func (s *PipelineState) PipelineDir() string {
-	return RunPipelineDir(s.RunID)
+	return filepath.Join(s.RunRoot(), "pipeline")
 }
 
 func (s *PipelineState) ResearchDir() string {
-	return RunResearchDir(s.RunID)
+	return filepath.Join(s.RunRoot(), "research")
 }
 
 func (s *PipelineState) ProofsDir() string {
-	return RunProofsDir(s.RunID)
+	return filepath.Join(s.RunRoot(), "proofs")
 }
 
 func (s *PipelineState) DiscoveryDir() string {
-	return RunDiscoveryDir(s.RunID)
+	return filepath.Join(s.RunRoot(), "discovery")
 }
 
 func (s *PipelineState) ManifestPath() string {
-	return RunManifestPath(s.RunID)
+	return filepath.Join(s.RunRoot(), "manifest.json")
+}
+
+func (s *PipelineState) RunRoot() string {
+	return runRootForScope(s.Scope, s.RunID)
 }
 
 // NextPhase returns the name of the next incomplete phase, or "".

← 8721df4b fix(cli): preserve browser-sniffed request defaults (#737)  ·  back to Cli Printing Press  ·  Parallelize Main CI full suite jobs (#743) 6044e7ff →