Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/generator/templates/profile.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
)

// Profile is a named set of flag values saved for reuse across invocations.
// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses
// day after day with the same voice/format but different input each run.
// Use a named profile when a scheduled or recurring workflow reuses the same
// saved flags while providing different input each run.
type Profile struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Expand Down
2 changes: 1 addition & 1 deletion internal/generator/templates/skill.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ Unknown schemes are refused with a structured error naming the supported set. We

## Named Profiles

A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run.

```
{{.Name}}-pp-cli profile save briefing --json
Expand Down
32 changes: 32 additions & 0 deletions internal/openapi/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4398,6 +4398,7 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P
if schema != nil && schema.Default != nil {
param.Default = schema.Default
}
setParamMaximum(&param, schema)
if param.Positional {
param.Required = true
}
Expand All @@ -4412,6 +4413,36 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P
return params
}

// setParamMaximum records a numeric upper-bound constraint from the schema onto
// the param, normalizing the two OpenAPI encodings of an exclusive bound.
// OpenAPI 3.1 writes `exclusiveMaximum: N` as a number; OpenAPI 3.0 writes
// `maximum: N` plus `exclusiveMaximum: true`. Either way the largest legal value
// is strictly below N, so it lands in ExclusiveMaximum; a plain inclusive
// `maximum` lands in Maximum. Downstream (the sync profiler) turns whichever is
// set into an effective integer page-size cap.
func setParamMaximum(param *spec.Param, schema *openapi3.Schema) {
if param == nil || schema == nil {
return
}
// OpenAPI 3.0: `exclusiveMaximum: true` turns `maximum` into an exclusive
// bound — it is not also an inclusive one.
if schema.ExclusiveMax.IsTrue() {
if schema.Max != nil {
param.ExclusiveMaximum = schema.Max
}
return
}
// OpenAPI 3.1 (or no exclusive modifier): `maximum` is inclusive, and a
// numeric `exclusiveMaximum` is an independent assertion. Both may be
// present; capture each so the profiler can take the most restrictive.
if schema.Max != nil {
param.Maximum = schema.Max
}
if schema.ExclusiveMax.Value != nil {
param.ExclusiveMaximum = schema.ExclusiveMax.Value
}
}

func readParamURLNameOverrides(pathItem *openapi3.PathItem, op *openapi3.Operation) map[string]string {
var out map[string]string
add := func(extensions map[string]any, context string) {
Expand Down Expand Up @@ -4827,6 +4858,7 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
if paramSchema != nil && paramSchema.Default != nil {
param.Default = paramSchema.Default
}
setParamMaximum(&param, paramSchema)
// For array types, propagate item-level enum as a Fields entry
// so downstream consumers (profiler) can access it.
if paramSchema != nil && paramSchema.Type != nil && paramSchema.Type.Is(openapi3.TypeArray) &&
Expand Down
50 changes: 50 additions & 0 deletions internal/openapi/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11838,3 +11838,53 @@ paths:
t.Fatalf("TenantScopeColumn = %q, want %q", got, "workspace")
}
}

// setParamMaximum must normalize both OpenAPI encodings of an upper bound: a
// plain inclusive `maximum` into Maximum, and either exclusive form (3.1
// numeric `exclusiveMaximum`, or 3.0 `maximum` + `exclusiveMaximum: true`) into
// ExclusiveMaximum. Only one field is ever set. Guards the sync page-size clamp
// (mvanhorn/cli-printing-press#3440) at the parse boundary.
func TestSetParamMaximumNormalizesBounds(t *testing.T) {
f := func(v float64) *float64 { return &v }
tru := true

t.Run("inclusive maximum", func(t *testing.T) {
var p spec.Param
setParamMaximum(&p, &openapi3.Schema{Max: f(30)})
require.NotNil(t, p.Maximum)
assert.Equal(t, 30.0, *p.Maximum)
assert.Nil(t, p.ExclusiveMaximum)
})

t.Run("openapi 3.1 numeric exclusiveMaximum", func(t *testing.T) {
var p spec.Param
setParamMaximum(&p, &openapi3.Schema{ExclusiveMax: openapi3.ExclusiveBound{Value: f(30)}})
require.NotNil(t, p.ExclusiveMaximum)
assert.Equal(t, 30.0, *p.ExclusiveMaximum)
assert.Nil(t, p.Maximum)
})

t.Run("openapi 3.0 maximum plus exclusiveMaximum true", func(t *testing.T) {
var p spec.Param
setParamMaximum(&p, &openapi3.Schema{Max: f(30), ExclusiveMax: openapi3.ExclusiveBound{Bool: &tru}})
require.NotNil(t, p.ExclusiveMaximum)
assert.Equal(t, 30.0, *p.ExclusiveMaximum)
assert.Nil(t, p.Maximum, "an exclusive maximum must not also populate the inclusive field")
})

t.Run("openapi 3.1 both maximum and numeric exclusiveMaximum", func(t *testing.T) {
var p spec.Param
setParamMaximum(&p, &openapi3.Schema{Max: f(30), ExclusiveMax: openapi3.ExclusiveBound{Value: f(100)}})
require.NotNil(t, p.Maximum, "an inclusive maximum must survive alongside a numeric exclusiveMaximum")
assert.Equal(t, 30.0, *p.Maximum)
require.NotNil(t, p.ExclusiveMaximum)
assert.Equal(t, 100.0, *p.ExclusiveMaximum)
})

t.Run("no bound", func(t *testing.T) {
var p spec.Param
setParamMaximum(&p, &openapi3.Schema{})
assert.Nil(t, p.Maximum)
assert.Nil(t, p.ExclusiveMaximum)
})
}
50 changes: 50 additions & 0 deletions internal/profiler/profiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package profiler
import (
"fmt"
"maps"
"math"
"os"
"regexp"
"slices"
Expand Down Expand Up @@ -2394,6 +2395,12 @@ func detectIDWalkParams(endpoint spec.Endpoint) (string, string, int) {
if defaultSize, ok := paginationLimitDefault(endpoint, resolvedLimitParam); ok {
pageSize = defaultSize
}
// Clamp to the body limit param's declared maximum, same as the cursor/page
// sync path — an ID-walk POST search endpoint that caps its limit below 100
// would otherwise be rejected with a validation error on every page.
if maxSize, ok := paginationLimitMaximum(endpoint, resolvedLimitParam); ok && pageSize > maxSize {
pageSize = maxSize
}
return filterParam, resolvedLimitParam, pageSize
}

Expand Down Expand Up @@ -2429,6 +2436,42 @@ func paginationLimitDefault(endpoint spec.Endpoint, limitParam string) (int, boo
return 0, false
}

// paginationLimitMaximum returns the largest page size the pagination limit
// param permits, if it declares an upper bound. Sync uses it to clamp the
// requested page size below an API-enforced ceiling. An inclusive `maximum: N`
// yields floor(N); an exclusive bound (OpenAPI 3.1 `exclusiveMaximum: N`, or
// 3.0 `maximum: N` + `exclusiveMaximum: true`) yields ceil(N)-1 so the returned
// value is always the largest legal integer strictly below the bound.
func paginationLimitMaximum(endpoint spec.Endpoint, limitParam string) (int, bool) {
if strings.TrimSpace(limitParam) == "" {
return 0, false
}
limitName := strings.ToLower(limitParam)
params := append(append([]spec.Param{}, endpoint.Params...), endpoint.Body...)
for _, param := range params {
if strings.ToLower(param.Name) != limitName {
continue
}
// A param may declare both an inclusive `maximum` and an exclusive bound
// (independent assertions in OpenAPI 3.1). Take the most restrictive.
effMax, have := 0, false
if param.Maximum != nil {
if m := int(math.Floor(*param.Maximum)); m > 0 {
effMax, have = m, true
}
}
if param.ExclusiveMaximum != nil {
if m := int(math.Ceil(*param.ExclusiveMaximum)) - 1; m > 0 && (!have || m < effMax) {
effMax, have = m, true
}
}
if have {
return effMax, true
}
}
return 0, false
}

func syncPaginationDefaultsFromEndpoint(endpoint spec.Endpoint) (string, string, string, int) {
cursorParam := ""
cursorType := ""
Expand Down Expand Up @@ -2457,6 +2500,13 @@ func syncPaginationDefaultsFromEndpoint(endpoint spec.Endpoint) (string, string,
if defaultSize, ok := paginationLimitDefault(endpoint, limitParam); ok {
pageSize = defaultSize
}
// Clamp to the limit param's declared maximum so sync never requests a
// page size the API rejects with a validation error. An API-declared cap
// always wins over the default (e.g. Granola's public API caps page_size
// at 30, and a spec may declare a maximum without any default).
if maxSize, ok := paginationLimitMaximum(endpoint, limitParam); ok && pageSize > maxSize {
pageSize = maxSize
}
return cursorParam, cursorType, limitParam, pageSize
}

Expand Down
131 changes: 131 additions & 0 deletions internal/profiler/profiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3035,6 +3035,137 @@ func TestProfilePagination_InfersFromPlainParamsWhenNoExplicitBlock(t *testing.T
"inferred limit param must still read the spec-declared default")
}

// A limit param's declared `maximum` must cap the sync page size: the 100
// fallback (and any larger default) would otherwise trip an API validation
// error. Regression guard for the Granola public API, which caps page_size
// at 30 with no default. See mvanhorn/cli-printing-press#3440.
func TestProfilePagination_ClampsToParamMaximum(t *testing.T) {
max30 := 30.0
excl30 := 30.0
excl100 := 100.0
s := &spec.APISpec{
Name: "capped-pagination",
Resources: map[string]spec.Resource{
// maximum only, no default: must clamp the 100 fallback to 30.
"notes": {
Endpoints: map[string]spec.Endpoint{
"list": {
Method: "GET",
Path: "/notes",
Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Maximum: &max30}},
Response: spec.ResponseDef{Type: "array"},
},
},
},
// default above the maximum: the API-declared cap must win.
"folders": {
Endpoints: map[string]spec.Endpoint{
"list": {
Method: "GET",
Path: "/folders",
Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Default: 50, Maximum: &max30}},
Response: spec.ResponseDef{Type: "array"},
},
},
},
// default under the maximum: no clamp, the default stands.
"tags": {
Endpoints: map[string]spec.Endpoint{
"list": {
Method: "GET",
Path: "/tags",
Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Default: 20, Maximum: &max30}},
Response: spec.ResponseDef{Type: "array"},
},
},
},
// exclusive maximum: the largest legal value is 29, not 30.
"events": {
Endpoints: map[string]spec.Endpoint{
"list": {
Method: "GET",
Path: "/events",
Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", ExclusiveMaximum: &excl30}},
Response: spec.ResponseDef{Type: "array"},
},
},
},
// both bounds present (OpenAPI 3.1): the stricter inclusive maximum
// (30) wins over the looser exclusive bound (100 -> 99).
"webhooks": {
Endpoints: map[string]spec.Endpoint{
"list": {
Method: "GET",
Path: "/webhooks",
Params: []spec.Param{{Name: "cursor", Type: "string"}, {Name: "page_size", Type: "int", Maximum: &max30, ExclusiveMaximum: &excl100}},
Response: spec.ResponseDef{Type: "array"},
},
},
},
},
}

profile := Profile(s)
byName := map[string]SyncableResource{}
for _, resource := range profile.SyncableResources {
byName[resource.Name] = resource
}
require.Contains(t, byName, "notes")
require.Contains(t, byName, "folders")
require.Contains(t, byName, "tags")
require.Contains(t, byName, "events")
require.Contains(t, byName, "webhooks")
assert.Equal(t, 30, byName["notes"].PaginationPageSize,
"maximum must clamp the 100 fallback when no default is declared")
assert.Equal(t, 30, byName["folders"].PaginationPageSize,
"maximum must win over a larger declared default")
assert.Equal(t, 20, byName["tags"].PaginationPageSize,
"a default under the maximum must stand unchanged")
assert.Equal(t, 29, byName["events"].PaginationPageSize,
"an exclusive maximum must clamp to the largest value strictly below it")
assert.Equal(t, 30, byName["webhooks"].PaginationPageSize,
"the most restrictive of a co-declared inclusive and exclusive bound must win")
}

// The ID-walk sync path (pagination.type: id_walk over a POST search endpoint)
// resolves its page size independently via detectIDWalkParams, so the maximum
// clamp must apply there too. Regression guard for #3440.
func TestProfilePagination_IDWalkClampsToBodyParamMaximum(t *testing.T) {
max25 := 25.0
s := &spec.APISpec{
Name: "id-walk-capped",
Resources: map[string]spec.Resource{
"records": {
Endpoints: map[string]spec.Endpoint{
"search": {
Method: "POST",
Path: "/records/search",
IDField: "id",
Pagination: &spec.Pagination{
Type: spec.PaginationTypeIDWalk,
LimitParam: "limit",
},
Body: []spec.Param{
{Name: "filter", Type: "array"},
{Name: "limit", Type: "int", Maximum: &max25},
},
Response: spec.ResponseDef{Type: "array"},
},
},
},
},
}

profile := Profile(s)
byName := map[string]SyncableResource{}
for _, resource := range profile.SyncableResources {
byName[resource.Name] = resource
}
require.Contains(t, byName, "records")
assert.Equal(t, 25, byName["records"].IDWalkPageSize,
"ID-walk page size must clamp to the body limit param's maximum")
}

// Explicit pagination: blocks must continue to win over plain-param inference.
// Mixing the two on the same endpoint would otherwise double-count or let
// inference shadow the author's deliberate choice.
Expand Down
12 changes: 12 additions & 0 deletions internal/spec/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,18 @@ type Param struct {
Fields []Param `yaml:"fields" json:"fields"` // for nested objects
Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"` // enum constraints for the parameter
Format string `yaml:"format,omitempty" json:"format,omitempty"` // OpenAPI format hints (date-time, email, uri, etc.)
// Maximum captures an inclusive numeric `maximum` schema constraint on the
// parameter. Sync uses it to clamp the page size it requests so a generated
// CLI never sends a page size the API rejects (e.g. a page_size param with
// maximum: 30).
Maximum *float64 `yaml:"maximum,omitempty" json:"maximum,omitempty"`
// ExclusiveMaximum captures an *exclusive* upper bound: the largest legal
// value is strictly below it. It normalizes both OpenAPI styles —
// 3.1's numeric `exclusiveMaximum: N`, and 3.0's `maximum: N` +
// `exclusiveMaximum: true` (which makes `maximum` exclusive). In OpenAPI
// 3.1 `maximum` and `exclusiveMaximum` are independent assertions, so both
// this and Maximum can be set; consumers take the most restrictive.
ExclusiveMaximum *float64 `yaml:"exclusive_maximum,omitempty" json:"exclusive_maximum,omitempty"`
// DispatchParam marks a fixed discriminator such as type=domain_rank.
// Generated runnable examples keep its default instead of substituting
// synthetic dogfood values that would address a different upstream route.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Unknown schemes are refused with a structured error naming the supported set. We

## Named Profiles

A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run.

```
printing-press-oauth2-pp-cli profile save briefing --json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Unknown schemes are refused with a structured error naming the supported set. We

## Named Profiles

A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run.

```
printing-press-rich-pp-cli profile save briefing --json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Unknown schemes are refused with a structured error naming the supported set. We

## Named Profiles

A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
A profile is a saved set of flag values, reused across invocations. Use it when a scheduled or recurring agent reuses the same saved flags while providing different input each run.

```
printing-press-golden-pp-cli profile save briefing --json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
)

// Profile is a named set of flag values saved for reuse across invocations.
// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses
// day after day with the same voice/format but different input each run.
// Use a named profile when a scheduled or recurring workflow reuses the same
// saved flags while providing different input each run.
type Profile struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Expand Down
Loading
Loading