← back to Cli Printing Press
fix(cli): bypass response cache when polling async jobs (#1429)
acbb3dfe6938d4c820533d63bccfa271a30b1dce · 2026-05-15 00:13:33 -0700 · Trevin Chow
* fix(cli): bypass response cache when polling async jobs
The generated client's response cache is keyed by (path, params) with a
5-minute TTL. WaitForJob polls the same status endpoint repeatedly, so
the cached non-terminal response from the first poll was returned to
every subsequent poll for up to the TTL, locking the loop on the initial
status (Pending/Busy/Running) even after the server-side job completed.
Expose per-call GetNoCache and GetWithHeadersNoCache helpers on the
client so polling code can bypass the cache without mutating the global
c.NoCache toggle, and switch WaitForJob to use them.
Closes #1136
* fix(cli): refresh cache after no-cache GET; add async-job golden
Two follow-ups to Greptile P2 feedback on #1429:
- GetWithHeadersNoCache now writes the fresh response to cache on success.
Previously it skipped both read and write, leaving the on-disk cache
pinned to the first poll's non-terminal snapshot. A follow-up call
like `renders get <id>` run shortly after `--wait` returned would
still serve the stale Pending state for the cache TTL. Writing back
on success makes the helper "skip stale read, refresh cache" instead
of a full bypass, so subsequent c.Get calls see the terminal value.
- Add a dedicated generate-async-job-api golden case with a minimal
OpenAPI fixture that triggers DetectAsyncJobs (POST returning job_id
+ sibling GET status). Without async detection, no existing golden
exercises jobs.go emission; this locks the WaitForJob -> GetNoCache
wiring against future template drift (indentation, surrounding
comments) that the unit test's string-contains assertions cannot
catch.
Files touched
M internal/generator/generator_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/jobs.go.tmplA testdata/golden/cases/generate-async-job-api/artifacts.txtA testdata/golden/cases/generate-async-job-api/command.txtA testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.goA testdata/golden/expected/generate-async-job-api/exit.txtA testdata/golden/expected/generate-async-job-api/stderr.txtA testdata/golden/expected/generate-async-job-api/stdout.txtM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.goA testdata/golden/fixtures/async-job-api.yaml
Diff
commit acbb3dfe6938d4c820533d63bccfa271a30b1dce
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 15 00:13:33 2026 -0700
fix(cli): bypass response cache when polling async jobs (#1429)
* fix(cli): bypass response cache when polling async jobs
The generated client's response cache is keyed by (path, params) with a
5-minute TTL. WaitForJob polls the same status endpoint repeatedly, so
the cached non-terminal response from the first poll was returned to
every subsequent poll for up to the TTL, locking the loop on the initial
status (Pending/Busy/Running) even after the server-side job completed.
Expose per-call GetNoCache and GetWithHeadersNoCache helpers on the
client so polling code can bypass the cache without mutating the global
c.NoCache toggle, and switch WaitForJob to use them.
Closes #1136
* fix(cli): refresh cache after no-cache GET; add async-job golden
Two follow-ups to Greptile P2 feedback on #1429:
- GetWithHeadersNoCache now writes the fresh response to cache on success.
Previously it skipped both read and write, leaving the on-disk cache
pinned to the first poll's non-terminal snapshot. A follow-up call
like `renders get <id>` run shortly after `--wait` returned would
still serve the stale Pending state for the cache TTL. Writing back
on success makes the helper "skip stale read, refresh cache" instead
of a full bypass, so subsequent c.Get calls see the terminal value.
- Add a dedicated generate-async-job-api golden case with a minimal
OpenAPI fixture that triggers DetectAsyncJobs (POST returning job_id
+ sibling GET status). Without async detection, no existing golden
exercises jobs.go emission; this locks the WaitForJob -> GetNoCache
wiring against future template drift (indentation, surrounding
comments) that the unit test's string-contains assertions cannot
catch.
---
internal/generator/generator_test.go | 53 +++
internal/generator/templates/client.go.tmpl | 24 ++
internal/generator/templates/jobs.go.tmpl | 5 +-
.../cases/generate-async-job-api/artifacts.txt | 7 +
.../cases/generate-async-job-api/command.txt | 2 +
.../async-job-api/internal/cli/jobs.go | 357 +++++++++++++++++++++
.../expected/generate-async-job-api/exit.txt | 1 +
.../expected/generate-async-job-api/stderr.txt | 2 +
.../expected/generate-async-job-api/stdout.txt | 0
.../internal/client/client.go | 24 ++
.../internal/client/client.go | 24 ++
.../tier-routing-golden/internal/client/client.go | 24 ++
testdata/golden/fixtures/async-job-api.yaml | 56 ++++
13 files changed, 578 insertions(+), 1 deletion(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 66072459..7cd32483 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9287,6 +9287,59 @@ func assertNewMCPClientSkipsCache(t *testing.T, specSource string) {
"newMCPClient must disable the response cache so MCP-driven reads see fresh state across mutations")
}
+// TestGenerateWaitForJobBypassesResponseCache proves that the polling loop
+// in WaitForJob uses GetNoCache, not Get. The response cache is keyed by
+// (path, params), so a cached non-terminal status (Pending/Busy/Running)
+// from the first poll would be returned to every subsequent poll for the
+// cache TTL, locking the loop on the initial response even after the
+// server-side job has completed. The fix exposes per-call GetNoCache /
+// GetWithHeadersNoCache helpers on the generated client so polling code
+// can bypass the cache without mutating the global c.NoCache toggle.
+func TestGenerateWaitForJobBypassesResponseCache(t *testing.T) {
+ t.Parallel()
+
+ // Build a spec that triggers async-job detection (POST returning
+ // {job_id, status} + sibling GET status endpoint) so jobs.go is
+ // emitted; minimalSpec alone has no async surface.
+ apiSpec := minimalSpec("asyncpoll")
+ apiSpec.Types = map[string]spec.TypeDef{
+ "RenderJob": {Fields: []spec.TypeField{
+ {Name: "job_id", Type: "string"},
+ {Name: "status", Type: "string"},
+ }},
+ }
+ apiSpec.Resources = map[string]spec.Resource{
+ "renders": {
+ Description: "Async render jobs",
+ Endpoints: map[string]spec.Endpoint{
+ "submit": {Method: "POST", Path: "/renders", Description: "Submit a render", Response: spec.ResponseDef{Type: "object", Item: "RenderJob"}},
+ "get": {Method: "GET", Path: "/renders/{id}", Description: "Get a render", Response: spec.ResponseDef{Type: "object", Item: "RenderJob"}},
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientData, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ clientBody := string(clientData)
+
+ assert.Contains(t, clientBody, "func (c *Client) GetNoCache(",
+ "client.go must expose GetNoCache so polling callers can bypass cache without mutating c.NoCache")
+ assert.Contains(t, clientBody, "func (c *Client) GetWithHeadersNoCache(",
+ "client.go must expose GetWithHeadersNoCache for header-bearing polling fetches")
+
+ jobsData, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "jobs.go"))
+ require.NoError(t, err)
+ jobsBody := string(jobsData)
+
+ assert.Contains(t, jobsBody, "c.GetNoCache(path, nil)",
+ "WaitForJob must poll with GetNoCache to avoid locking on the cached initial response")
+ assert.NotContains(t, jobsBody, "c.Get(path, nil)",
+ "WaitForJob must not call c.Get(path, nil); cached non-terminal status would lock the poll")
+}
+
// TestGenerateMCPHandlerPreservesQueryPositionals proves the makeAPIHandler
// body in generated MCP tools.go distinguishes real URL path placeholders
// (e.g. /movie/{movieId}) from CLI positional args that map to query
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c0c304f4..8fc592ee 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -397,6 +397,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
return result, err
}
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
func (c *Client) ProbeGet(path string) (int, error) {
_, status, err := c.do("GET", path, nil, nil, nil)
return status, err
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
index de80b4d0..aa3b321f 100644
--- a/internal/generator/templates/jobs.go.tmpl
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -143,7 +143,10 @@ func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID
return nil, err
}
- resp, err := c.Get(path, nil)
+ // GetNoCache: the cache is keyed by (path, params), so a cached
+ // non-terminal status would lock the poll on the initial response
+ // for the cache TTL.
+ resp, err := c.GetNoCache(path, nil)
if err == nil {
var body map[string]any
if uerr := json.Unmarshal(resp, &body); uerr == nil {
diff --git a/testdata/golden/cases/generate-async-job-api/artifacts.txt b/testdata/golden/cases/generate-async-job-api/artifacts.txt
new file mode 100644
index 00000000..6f62bdd7
--- /dev/null
+++ b/testdata/golden/cases/generate-async-job-api/artifacts.txt
@@ -0,0 +1,7 @@
+# Locks the async-job emission contract: when DetectAsyncJobs fires
+# (POST returns a job_id-shaped response + sibling status GET), the
+# generator emits jobs.go with WaitForJob polling via GetNoCache.
+# Without async detection, none of these files are emitted; without
+# the cache-bypass fix from #1136, the WaitForJob body would call
+# c.Get instead of c.GetNoCache, locking polling on the initial response.
+async-job-api/internal/cli/jobs.go
diff --git a/testdata/golden/cases/generate-async-job-api/command.txt b/testdata/golden/cases/generate-async-job-api/command.txt
new file mode 100644
index 00000000..773b4790
--- /dev/null
+++ b/testdata/golden/cases/generate-async-job-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/async-job-api.yaml --output "$CASE_ACTUAL_DIR/async-job-api" --force --spec-url file://testdata/golden/fixtures/async-job-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go b/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go
new file mode 100644
index 00000000..c5d9a319
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/async-job-api/internal/cli/jobs.go
@@ -0,0 +1,357 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "async-job-pp-cli/internal/client"
+)
+
+// JobRow is one entry in the local jobs ledger. Rows are appended as NDJSON
+// to ~/.async-job-pp-cli/jobs.jsonl; the latest row for a given JobID wins
+// when listing. Pruning rewrites the file without old entries.
+type JobRow struct {
+ JobID string `json:"job_id"`
+ Resource string `json:"resource"`
+ Endpoint string `json:"endpoint"`
+ Status string `json:"status"`
+ SubmittedAt time.Time `json:"submitted_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ StatusResource string `json:"status_resource,omitempty"`
+ StatusEndpoint string `json:"status_endpoint,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func jobsFilePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".async-job-pp-cli")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating state dir: %w", err)
+ }
+ return filepath.Join(dir, "jobs.jsonl"), nil
+}
+
+// RecordJob appends a JobRow to the local ledger.
+func RecordJob(row JobRow) error {
+ p, err := jobsFilePath()
+ if err != nil {
+ return err
+ }
+ if row.SubmittedAt.IsZero() {
+ row.SubmittedAt = time.Now().UTC()
+ }
+ row.UpdatedAt = time.Now().UTC()
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening jobs ledger: %w", err)
+ }
+ defer f.Close()
+ return json.NewEncoder(f).Encode(row)
+}
+
+func readJobRows() ([]JobRow, error) {
+ p, err := jobsFilePath()
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("reading jobs ledger: %w", err)
+ }
+ var rows []JobRow
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var r JobRow
+ if err := json.Unmarshal([]byte(line), &r); err != nil {
+ continue // skip malformed rows rather than fail the whole list
+ }
+ rows = append(rows, r)
+ }
+ return rows, nil
+}
+
+// latestByJobID returns one row per JobID (the most recent by UpdatedAt).
+func latestByJobID(rows []JobRow) []JobRow {
+ byID := map[string]JobRow{}
+ for _, r := range rows {
+ existing, ok := byID[r.JobID]
+ if !ok || r.UpdatedAt.After(existing.UpdatedAt) {
+ byID[r.JobID] = r
+ }
+ }
+ out := make([]JobRow, 0, len(byID))
+ for _, r := range byID {
+ out = append(out, r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].SubmittedAt.After(out[j].SubmittedAt) })
+ return out
+}
+
+// WaitOptions configures WaitForJob polling.
+type WaitOptions struct {
+ Interval time.Duration // initial poll interval; grows with backoff up to 30s
+ Timeout time.Duration // overall wait budget; 0 means no timeout
+}
+
+// WaitForJob polls statusPath with exponential backoff + jitter until the
+// response reports a terminal status or the timeout elapses. Returns the
+// final response body as a parsed map, or an error.
+//
+// Terminal statuses recognised:
+//
+// done, complete, completed, success, succeeded, finished,
+// failed, errored, cancelled, canceled
+func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID string, opts WaitOptions) (map[string]any, error) {
+ interval := opts.Interval
+ if interval <= 0 {
+ interval = 2 * time.Second
+ }
+ deadline := time.Time{}
+ if opts.Timeout > 0 {
+ deadline = time.Now().Add(opts.Timeout)
+ }
+ maxInterval := 30 * time.Second
+
+ path := strings.ReplaceAll(statusPath, "{id}", jobID)
+ path = strings.ReplaceAll(path, "{job_id}", jobID)
+
+ for {
+ if !deadline.IsZero() && time.Now().After(deadline) {
+ return nil, fmt.Errorf("wait timed out after %s (job %s)", opts.Timeout, jobID)
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ // GetNoCache: the cache is keyed by (path, params), so a cached
+ // non-terminal status would lock the poll on the initial response
+ // for the cache TTL.
+ resp, err := c.GetNoCache(path, nil)
+ if err == nil {
+ var body map[string]any
+ if uerr := json.Unmarshal(resp, &body); uerr == nil {
+ if isJobTerminal(body) {
+ return body, nil
+ }
+ }
+ }
+
+ // Jittered sleep: interval + random up to 25%.
+ jitter := time.Duration(rand.Int63n(int64(interval)/4 + 1))
+ select {
+ case <-time.After(interval + jitter):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+
+ // Exponential-ish growth with cap.
+ interval = interval * 3 / 2
+ if interval > maxInterval {
+ interval = maxInterval
+ }
+ }
+}
+
+// ExtractJobID parses response bytes and returns the string value of the
+// named field (e.g. "job_id"). Returns "" if the response is not JSON or the
+// field is missing / not a string.
+func ExtractJobID(data []byte, field string) string {
+ if len(data) == 0 || field == "" {
+ return ""
+ }
+ var body map[string]any
+ if err := json.Unmarshal(data, &body); err != nil {
+ return ""
+ }
+ if v, ok := body[field].(string); ok {
+ return v
+ }
+ // Some APIs nest the job under "data" or "job".
+ for _, wrapper := range []string{"data", "job", "result"} {
+ if inner, ok := body[wrapper].(map[string]any); ok {
+ if v, ok := inner[field].(string); ok {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+func isJobTerminal(body map[string]any) bool {
+ status, _ := body["status"].(string)
+ if status == "" {
+ return false
+ }
+ switch strings.ToLower(status) {
+ case "done", "complete", "completed", "success", "succeeded", "finished",
+ "failed", "errored", "cancelled", "canceled":
+ return true
+ }
+ return false
+}
+
+// newJobsCmd registers the `jobs` parent command and its subcommands.
+func newJobsCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "jobs",
+ Short: "List and inspect async jobs tracked by this CLI",
+ Long: `Jobs tracked when you submit an async-capable endpoint land in
+~/.async-job-pp-cli/jobs.jsonl. This command lists, inspects, and prunes them.
+
+Submit an async endpoint with --wait to block until completion; submit
+without --wait to get the job ID back immediately and track it later.`,
+ RunE: parentNoSubcommandRunE(flags),
+ }
+ cmd.AddCommand(newJobsListCmd(flags))
+ cmd.AddCommand(newJobsGetCmd(flags))
+ cmd.AddCommand(newJobsPruneCmd(flags))
+ return cmd
+}
+
+func newJobsListCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ var all bool
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recent async jobs",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ if !all {
+ rows = latestByJobID(rows)
+ }
+ if limit > 0 && limit < len(rows) {
+ rows = rows[:limit]
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
+ tbl := make([][]string, 0, len(rows))
+ for _, r := range rows {
+ tbl = append(tbl, []string{
+ r.JobID,
+ r.Resource + "/" + r.Endpoint,
+ r.Status,
+ r.SubmittedAt.Format(time.RFC3339),
+ r.UpdatedAt.Format(time.RFC3339),
+ })
+ }
+ return flags.printTable(cmd, headers, tbl)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 50, "Maximum rows to list (0 for unlimited)")
+ cmd.Flags().BoolVar(&all, "all", false, "Show every state transition rather than the latest row per job")
+ return cmd
+}
+
+func newJobsGetCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "get <job-id>",
+ Short: "Show the latest state row for a job",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ rows = latestByJobID(rows)
+ for _, r := range rows {
+ if r.JobID == args[0] {
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), r, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "job_id: %s\nresource: %s/%s\nstatus: %s\nsubmitted: %s\nupdated: %s\n",
+ r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
+ if r.Error != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "error: %s\n", r.Error)
+ }
+ return nil
+ }
+ }
+ return fmt.Errorf("job %q not found in local ledger", args[0])
+ },
+ }
+}
+
+func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
+ var olderThan time.Duration
+ cmd := &cobra.Command{
+ Use: "prune",
+ Short: "Remove job rows older than --older-than",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ rows, err := readJobRows()
+ if err != nil {
+ return err
+ }
+ cutoff := time.Now().Add(-olderThan)
+ var kept []JobRow
+ pruned := 0
+ for _, r := range rows {
+ if r.UpdatedAt.Before(cutoff) {
+ pruned++
+ continue
+ }
+ kept = append(kept, r)
+ }
+
+ p, err := jobsFilePath()
+ if err != nil {
+ return err
+ }
+ tmp := p + ".tmp"
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening tmp ledger: %w", err)
+ }
+ enc := json.NewEncoder(f)
+ for _, r := range kept {
+ if err := enc.Encode(r); err != nil {
+ _ = f.Close()
+ _ = os.Remove(tmp)
+ return fmt.Errorf("writing tmp ledger: %w", err)
+ }
+ }
+ if err := f.Close(); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, p); err != nil {
+ return fmt.Errorf("replacing ledger: %w", err)
+ }
+
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"pruned": pruned, "kept": len(kept)}, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
+ return nil
+ },
+ }
+ cmd.Flags().DurationVar(&olderThan, "older-than", 7*24*time.Hour, "Prune rows whose latest update is older than this duration")
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-async-job-api/exit.txt b/testdata/golden/expected/generate-async-job-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-async-job-api/stderr.txt b/testdata/golden/expected/generate-async-job-api/stderr.txt
new file mode 100644
index 00000000..9c0d74cf
--- /dev/null
+++ b/testdata/golden/expected/generate-async-job-api/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated async-job at <ARTIFACT_DIR>/generate-async-job-api/async-job-api
diff --git a/testdata/golden/expected/generate-async-job-api/stdout.txt b/testdata/golden/expected/generate-async-job-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index 2ec802ce..278bf2e1 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -90,6 +90,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
return result, err
}
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
func (c *Client) ProbeGet(path string) (int, error) {
_, status, err := c.do("GET", path, nil, nil, nil)
return status, err
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 4ef66b85..12a6aba0 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -84,6 +84,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
return result, err
}
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
func (c *Client) ProbeGet(path string) (int, error) {
_, status, err := c.do("GET", path, nil, nil, nil)
return status, err
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index 98425284..585769f7 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -178,6 +178,30 @@ func (c *Client) GetWithHeaders(path string, params map[string]string, headers m
return result, err
}
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get <id>` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but
+// still writes the fresh response on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ result, _, err := c.do("GET", path, params, nil, headers)
+ if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
func (c *Client) ProbeGet(path string) (int, error) {
_, status, err := c.do("GET", path, nil, nil, nil)
return status, err
diff --git a/testdata/golden/fixtures/async-job-api.yaml b/testdata/golden/fixtures/async-job-api.yaml
new file mode 100644
index 00000000..cfb79190
--- /dev/null
+++ b/testdata/golden/fixtures/async-job-api.yaml
@@ -0,0 +1,56 @@
+openapi: "3.0.3"
+info:
+ title: Async Job API
+ version: "1.0.0"
+ description: |
+ Golden fixture exercising async-job detection: POST returns a job_id-shaped
+ response with a sibling GET status endpoint, triggering jobs.go emission.
+servers:
+ - url: https://api.example.com/v1
+security:
+ - ApiKeyAuth: []
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ schemas:
+ RenderJob:
+ type: object
+ properties:
+ job_id:
+ type: string
+ status:
+ type: string
+paths:
+ /renders:
+ post:
+ tags: [renders]
+ operationId: submitRender
+ summary: Submit a render job
+ responses:
+ "202":
+ description: Accepted
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RenderJob"
+ /renders/{id}:
+ get:
+ tags: [renders]
+ operationId: getRender
+ summary: Get a render job
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RenderJob"
← 95f03501 fix(cli): derive doctor auth.verify_path from me-shaped endp
·
back to Cli Printing Press
·
fix(cli): archive merged spec for multi-spec generate runs ( 44f1ff79 →