← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/channel_workflow.go.tmplM internal/generator/templates/sync.go.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
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 →