[object Object]

← back to Cli Printing Press

fix(cli): delegate workflow archive to syncResource (#1090)

3d515c457de657b470035e71bda8b5d8808f45e2 · 2026-05-11 10:53:29 -0700 · Trevin Chow

* fix(cli): delegate workflow archive to syncResource so printed CLIs inherit pagination, envelope unwrap, and spec paths

Issue #1077: the workflow archive template re-implemented the sync loop
with three independent bugs that ship into every printed CLI:

1. Hardcoded `/<resource>` path missing the API version prefix → 404s
   on most modern APIs (67/68 in the public library).
2. Bare-array `json.Unmarshal` failing on envelope responses → stores
   the whole envelope under one synthetic `<resource>-singleton` key
   while `workflow status` reports count=1.
3. Hardcoded `?after=<cursor>` pagination → silently truncates after
   page 1 for APIs paginated by page number, starting_after, etc.

Refactor the template to delegate per-resource sync to syncResource
(already emitted in sync.go.tmpl / graphql_sync.go.tmpl), so workflow
archive inherits spec-driven resource paths via syncResourcePath,
envelope unwrap via extractPageItems, cursor + page-size param
detection via determinePaginationDefaults, plus sync_anomaly
telemetry, sticky-cursor detection, and per-resource since-param
handling.

Gate archive emission on VisionSet.Sync since syncResource only
exists when sync.go.tmpl or graphql_sync.go.tmpl is rendered.
SelectVisionTemplates already enforces Store=>Sync in production
(vision_templates.go:146); the gate covers test-fixture isolation
where Store=true is set without Sync.

Add a focused test that pins the syncResource call and the absence
of the three buggy patterns, plus a SyncEnabled=false test that
verifies archive is dropped when sync is unavailable. Add a comment
at syncResource's definition flagging the cross-template variadic
coupling.

* fix(cli): update sync-walker golden fixture for syncResource comment

Catches the same 2-line comment added in the parent commit to
sync.go.tmpl flagging the cross-template signature coupling with
channel_workflow.go.tmpl. The sync-walker fixture landed on origin/main
in #1060 after the parent commit was written.

Files touched

Diff

commit 3d515c457de657b470035e71bda8b5d8808f45e2
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 10:53:29 2026 -0700

    fix(cli): delegate workflow archive to syncResource (#1090)
    
    * fix(cli): delegate workflow archive to syncResource so printed CLIs inherit pagination, envelope unwrap, and spec paths
    
    Issue #1077: the workflow archive template re-implemented the sync loop
    with three independent bugs that ship into every printed CLI:
    
    1. Hardcoded `/<resource>` path missing the API version prefix → 404s
       on most modern APIs (67/68 in the public library).
    2. Bare-array `json.Unmarshal` failing on envelope responses → stores
       the whole envelope under one synthetic `<resource>-singleton` key
       while `workflow status` reports count=1.
    3. Hardcoded `?after=<cursor>` pagination → silently truncates after
       page 1 for APIs paginated by page number, starting_after, etc.
    
    Refactor the template to delegate per-resource sync to syncResource
    (already emitted in sync.go.tmpl / graphql_sync.go.tmpl), so workflow
    archive inherits spec-driven resource paths via syncResourcePath,
    envelope unwrap via extractPageItems, cursor + page-size param
    detection via determinePaginationDefaults, plus sync_anomaly
    telemetry, sticky-cursor detection, and per-resource since-param
    handling.
    
    Gate archive emission on VisionSet.Sync since syncResource only
    exists when sync.go.tmpl or graphql_sync.go.tmpl is rendered.
    SelectVisionTemplates already enforces Store=>Sync in production
    (vision_templates.go:146); the gate covers test-fixture isolation
    where Store=true is set without Sync.
    
    Add a focused test that pins the syncResource call and the absence
    of the three buggy patterns, plus a SyncEnabled=false test that
    verifies archive is dropped when sync is unavailable. Add a comment
    at syncResource's definition flagging the cross-template variadic
    coupling.
    
    * fix(cli): update sync-walker golden fixture for syncResource comment
    
    Catches the same 2-line comment added in the parent commit to
    sync.go.tmpl flagging the cross-template signature coupling with
    channel_workflow.go.tmpl. The sync-walker fixture landed on origin/main
    in #1060 after the parent commit was written.
---
 internal/generator/generator.go                    |  4 ++
 internal/generator/generator_test.go               | 56 ++++++++++++++++
 .../generator/templates/channel_workflow.go.tmpl   | 78 +++++++---------------
 internal/generator/templates/sync.go.tmpl          |  2 +
 .../printing-press-golden/internal/cli/sync.go     |  2 +
 .../sync-walker-golden/internal/cli/sync.go        |  2 +
 .../tier-routing-golden/internal/cli/sync.go       |  2 +
 7 files changed, 91 insertions(+), 55 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index dbf7c8e7..684f5421 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2174,12 +2174,16 @@ func (g *Generator) renderWorkflowFiles(visionData visionRenderData) ([]string,
 			*spec.APISpec
 			SyncableResources  []profiler.SyncableResource
 			SearchableFields   map[string][]string
+			Pagination         profiler.PaginationProfile
 			AgentMoneyWorkflow AgentMoneyWorkflow
+			SyncEnabled        bool
 		}{
 			APISpec:            g.Spec,
 			SyncableResources:  g.profile.SyncableResources,
 			SearchableFields:   g.profile.SearchableFields,
+			Pagination:         g.profile.Pagination,
 			AgentMoneyWorkflow: visionData.AgentMoneyWorkflow,
+			SyncEnabled:        g.VisionSet.Sync,
 		}
 		if err := g.renderTemplate("channel_workflow.go.tmpl", filepath.Join("internal", "cli", "channel_workflow.go"), workflowData); err != nil {
 			return nil, fmt.Errorf("rendering workflow: %w", err)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 727ae218..8a66957a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4505,6 +4505,62 @@ func TestGeneratedOutput_AgentMoneyWorkflowPaymentPlan(t *testing.T) {
 	assert.Contains(t, string(out), "--purpose is required for domesticWire payment plans")
 }
 
+// TestGeneratedOutput_WorkflowArchiveDelegatesToSyncResource pins issue
+// #1077's fix: workflow archive must delegate to syncResource (so it
+// inherits the spec-driven path, envelope unwrap, and pagination params),
+// and the archive subcommand must only emit when Sync is enabled.
+// A regression that brings back the inline c.Get("/"+resource), bare
+// json.Unmarshal into []json.RawMessage, or hardcoded params["after"]
+// would silently re-introduce three P1 bugs across the printed library.
+func TestGeneratedOutput_WorkflowArchiveDelegatesToSyncResource(t *testing.T) {
+	t.Parallel()
+
+	t.Run("sync_enabled emits archive delegating to syncResource", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := minimalSpec("archive-sync-on")
+		outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+		gen := New(apiSpec, outputDir)
+		gen.VisionSet = VisionTemplateSet{Store: true, Sync: true}
+		require.NoError(t, gen.Generate())
+
+		workflowGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "channel_workflow.go"))
+		require.NoError(t, err)
+		src := string(workflowGo)
+
+		assert.Contains(t, src, "func newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command",
+			"archive command must be emitted when Sync is enabled")
+		assert.Contains(t, src, "syncResource(",
+			"archive must delegate to syncResource so it inherits the hardened sync loop")
+
+		assert.NotContains(t, src, `c.Get("/"+resource`,
+			"archive must not hardcode `/<resource>` paths (#1077 Bug 1)")
+		assert.NotContains(t, src, `json.Unmarshal(data, &items)`,
+			"archive must not bare-unmarshal arrays; use extractPageItems via syncResource (#1077 Bug 2)")
+		assert.NotContains(t, src, `params["after"] = cursor`,
+			"archive must not hardcode `?after=` pagination; use determinePaginationDefaults via syncResource (#1077 Bug 3)")
+	})
+
+	t.Run("sync_disabled drops archive subcommand", func(t *testing.T) {
+		t.Parallel()
+		apiSpec := minimalSpec("archive-sync-off")
+		outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+		gen := New(apiSpec, outputDir)
+		gen.VisionSet = VisionTemplateSet{Store: true}
+		require.NoError(t, gen.Generate())
+
+		workflowGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "channel_workflow.go"))
+		require.NoError(t, err)
+		src := string(workflowGo)
+
+		assert.NotContains(t, src, "func newWorkflowArchiveCmd",
+			"archive must not be emitted when Sync is disabled (syncResource is absent)")
+		assert.NotContains(t, src, "newWorkflowArchiveCmd(flags)",
+			"archive must not be registered when Sync is disabled")
+		assert.Contains(t, src, "func newWorkflowStatusCmd",
+			"status subcommand must still be emitted")
+	})
+}
+
 func TestGeneratedOutput_PromotedCommandCompiles(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/channel_workflow.go.tmpl b/internal/generator/templates/channel_workflow.go.tmpl
index 98ea000f..b732062d 100644
--- a/internal/generator/templates/channel_workflow.go.tmpl
+++ b/internal/generator/templates/channel_workflow.go.tmpl
@@ -13,7 +13,9 @@ import (
 {{- if .AgentMoneyWorkflow.Enabled}}
 	"strings"
 {{- end}}
+{{- if or .SyncEnabled .AgentMoneyWorkflow.Enabled}}
 	"time"
+{{- end}}
 
 	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
@@ -25,7 +27,9 @@ func newWorkflowCmd(flags *rootFlags) *cobra.Command {
 		Short: "Compound workflows that combine multiple API operations",
 	}
 
+{{- if .SyncEnabled}}
 	cmd.AddCommand(newWorkflowArchiveCmd(flags))
+{{- end}}
 	cmd.AddCommand(newWorkflowStatusCmd(flags))
 {{- if .AgentMoneyWorkflow.Enabled}}
 	cmd.AddCommand(newWorkflowPaymentPlanCmd(flags))
@@ -239,6 +243,7 @@ func workflowAmountArg(amount float64, integer bool) (string, error) {
 }
 {{- end}}
 
+{{- if .SyncEnabled}}
 func newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command {
 	var dbPath string
 	var full bool
@@ -273,65 +278,27 @@ and full resync. After archiving, use 'search' for instant full-text search.`,
 			resources := []string{ {{- range .SyncableResources}}"{{.Name}}", {{end}} }
 			totalSynced := 0
 
-			for _, resource := range resources {
-				cursor := ""
-				if !full {
-					existing, _, _, err := s.GetSyncState(resource)
-					if err == nil && existing != "" {
-						cursor = existing
-					}
-				}
-
-				fmt.Fprintf(cmd.ErrOrStderr(), "Syncing %s...\n", resource)
-
-				params := map[string]string{"limit": "100"}
-				if cursor != "" {
-					params["after"] = cursor
+			// --full clears the cursor here because syncResource reads
+			// existingCursor unconditionally; its full param only gates the
+			// since filter, not cursor reset. Mirrors newSyncCmd's pattern.
+			if full {
+				for _, resource := range resources {
+					_ = s.SaveSyncState(resource, "", 0)
 				}
+			}
 
-				count := 0
-				for {
-					data, fetchErr := c.Get("/"+resource, params)
-					if fetchErr != nil {
-						fmt.Fprintf(cmd.ErrOrStderr(), "  warning: %s: %v\n", resource, fetchErr)
-						break
-					}
-					var items []json.RawMessage
-					if err := json.Unmarshal(data, &items); err != nil {
-						// Might be a single object, not array
-						if err := s.Upsert(resource, resource+"-singleton", data); err != nil {
-							fmt.Fprintf(cmd.ErrOrStderr(), "  warning: store %s: %v\n", resource, err)
-						}
-						count++
-						break
-					}
-					if len(items) == 0 {
-						break
-					}
-					for _, item := range items {
-						var obj struct{ ID string `json:"id"` }
-						json.Unmarshal(item, &obj)
-						id := obj.ID
-						if id == "" {
-							id = fmt.Sprintf("%s-%d", resource, count)
-						}
-						if err := s.Upsert(resource, id, item); err != nil {
-							fmt.Fprintf(cmd.ErrOrStderr(), "  warning: store %s/%s: %v\n", resource, id, err)
-						}
-						cursor = id
-						count++
-					}
-					if len(items) < 100 {
-						break
-					}
-					params["after"] = cursor
+			for _, resource := range resources {
+				res := syncResource({{if .HasTierRouting}}syncClientForResource(c, resource){{else}}c{{end}}, s, resource, "", full, 100{{if .Pagination.DateRangeParam}}, ""{{end}})
+				if res.Err != nil {
+					fmt.Fprintf(cmd.ErrOrStderr(), "  %s: error: %v\n", resource, res.Err)
+					continue
 				}
-
-				if count > 0 {
-					s.SaveSyncState(resource, cursor, count)
+				if res.Warn != nil {
+					fmt.Fprintf(cmd.ErrOrStderr(), "  %s: warning: %v\n", resource, res.Warn)
+					continue
 				}
-				totalSynced += count
-				fmt.Fprintf(cmd.ErrOrStderr(), "  %s: %d items\n", resource, count)
+				totalSynced += res.Count
+				fmt.Fprintf(cmd.ErrOrStderr(), "  %s: %d synced\n", resource, res.Count)
 			}
 
 			if flags.asJSON {
@@ -355,6 +322,7 @@ and full resync. After archiving, use 'search' for instant full-text search.`,
 
 	return cmd
 }
+{{- end}}
 
 func newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {
 	var dbPath string
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 0bee4ab4..e6a6fd16 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -323,6 +323,8 @@ Exit codes & warnings:
 
 // syncResource handles the full paginated sync of a single resource.
 // It resumes from the last cursor unless sinceTS or full mode overrides it.
+// channel_workflow.go.tmpl mirrors the trailing dates arg conditional;
+// keep both call sites in sync if this signature changes.
 func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 	RateLimit() float64
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index d77225d6..0118d655 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -298,6 +298,8 @@ Exit codes & warnings:
 
 // syncResource handles the full paginated sync of a single resource.
 // It resumes from the last cursor unless sinceTS or full mode overrides it.
+// channel_workflow.go.tmpl mirrors the trailing dates arg conditional;
+// keep both call sites in sync if this signature changes.
 func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 	RateLimit() float64
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index b722397c..2e2aa4f8 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -298,6 +298,8 @@ Exit codes & warnings:
 
 // syncResource handles the full paginated sync of a single resource.
 // It resumes from the last cursor unless sinceTS or full mode overrides it.
+// channel_workflow.go.tmpl mirrors the trailing dates arg conditional;
+// keep both call sites in sync if this signature changes.
 func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 	RateLimit() float64
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index a24be51e..9c0abc9e 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -272,6 +272,8 @@ Exit codes & warnings:
 
 // syncResource handles the full paginated sync of a single resource.
 // It resumes from the last cursor unless sinceTS or full mode overrides it.
+// channel_workflow.go.tmpl mirrors the trailing dates arg conditional;
+// keep both call sites in sync if this signature changes.
 func syncResource(c interface {
 	Get(string, map[string]string) (json.RawMessage, error)
 	RateLimit() float64

← 705bad2d fix(cli): dispatch typed-table upserts on spec resource key,  ·  back to Cli Printing Press  ·  fix(cli): stage runstate manuscripts during lock promote (#1 17d19307 →