← 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
M internal/pipeline/minimal_state_test.goM internal/pipeline/paths.goM internal/pipeline/publish.goM internal/pipeline/publish_test.goM internal/pipeline/research.goM internal/pipeline/state.go
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 →