Skip to content

Commit 9ce1e4b

Browse files
authored
Merge pull request #177 from flashcatcloud/fix/list-output-bound
Bound oversized structured list output from generated list verbs and insight incidents
2 parents 1709ab2 + 3436149 commit 9ce1e4b

5 files changed

Lines changed: 558 additions & 7 deletions

File tree

‎internal/cli/gen_support.go‎

Lines changed: 167 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
67
"io"
@@ -11,6 +12,7 @@ import (
1112

1213
"github.com/spf13/cobra"
1314

15+
"github.com/flashcatcloud/flashduty-cli/internal/output"
1416
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1517
)
1618

@@ -320,16 +322,177 @@ func bindURLTagged(body map[string]any, rv reflect.Value) {
320322

321323
// printGenericResult renders a generated command's typed response. In
322324
// machine-readable mode (TOON/JSON) it marshals the whole value — which is what
323-
// the agent reads. In human (table) mode it derives an aligned table by
324-
// reflection (renderGenericTable), since generated commands carry no hand-written
325-
// column set; anything that isn't a list or object falls back to indented JSON.
325+
// the agent reads. A list-shaped response (a top-level array of objects, or an
326+
// items/docs/list page envelope whose other fields are scalar pagination
327+
// metadata) that overflows compactListOutputLimit is first bounded to the
328+
// leading rows that fit via boundProjectedList, with the reduction announced on
329+
// stderr; a payload that fits, and any detail-shaped single object, prints
330+
// untouched. In human (table) mode it derives an aligned table by reflection
331+
// (renderGenericTable), since generated commands carry no hand-written column
332+
// set; anything that isn't a list or object falls back to indented JSON.
326333
func printGenericResult(ctx *RunContext, data any) error {
327334
if ctx.Structured() {
328-
return ctx.Printer.Print(data, nil)
335+
return printBoundedGenericResult(ctx, data)
329336
}
330337
return renderGenericTable(ctx, data)
331338
}
332339

340+
// printBoundedGenericResult is printGenericResult's structured-mode half. The
341+
// under-cap fast path is the pre-bound behavior verbatim — same printer call,
342+
// byte-identical output. Only an over-cap list payload detours through the
343+
// bounding machinery, and only there does the output change (fewer rows; a
344+
// rebuilt envelope, so key order is no longer the struct's field order).
345+
func printBoundedGenericResult(ctx *RunContext, data any) error {
346+
encoded, err := marshalStructured(data)
347+
if err != nil || len(encoded)+1 < compactListOutputLimit {
348+
// Fits the budget (or cannot be measured, in which case the printer
349+
// surfaces the same marshal error): emit untouched.
350+
return ctx.Printer.Print(data, nil)
351+
}
352+
353+
generic, err := genericStructured(data)
354+
if err != nil {
355+
return ctx.Printer.Print(data, nil)
356+
}
357+
358+
switch value := generic.(type) {
359+
case []any:
360+
rows, ok := objectRows(value)
361+
if !ok {
362+
return ctx.Printer.Print(data, nil)
363+
}
364+
bounded, note, err := boundProjectedList(rows, compactListOutputLimit)
365+
if err != nil {
366+
return err
367+
}
368+
noteProjectionBound(ctx.Cmd.ErrOrStderr(), note)
369+
return ctx.Printer.Print(bounded, nil)
370+
case map[string]any:
371+
key, ok := listEnvelopeKey(value)
372+
if !ok {
373+
// Detail-shaped single object: never bounded, never errored — a
374+
// shortened id or status would pass for a real value.
375+
return ctx.Printer.Print(data, nil)
376+
}
377+
rows, ok := objectRows(value[key].([]any))
378+
if !ok {
379+
return ctx.Printer.Print(data, nil)
380+
}
381+
// boundProjectedList sizes the rows standalone, but printed inside the
382+
// envelope they share the budget with the pagination siblings (and, in
383+
// indented JSON, sit one indent level deeper). Fit against the full
384+
// limit, then re-fit with the observed envelope overhead subtracted
385+
// until the whole payload is under it.
386+
budget := compactListOutputLimit
387+
for {
388+
bounded, note, err := boundProjectedList(rows, budget)
389+
if err != nil {
390+
return err
391+
}
392+
value[key] = bounded
393+
out, err := marshalStructured(value)
394+
if err != nil {
395+
return err
396+
}
397+
if len(out)+1 < compactListOutputLimit {
398+
noteProjectionBound(ctx.Cmd.ErrOrStderr(), note)
399+
return ctx.Printer.Print(value, nil)
400+
}
401+
budget -= len(out) + 2 - compactListOutputLimit
402+
}
403+
default:
404+
return ctx.Printer.Print(data, nil)
405+
}
406+
}
407+
408+
// genericStructured decodes data through its JSON encoding into plain
409+
// maps/slices/scalars, so the list-bounding machinery can walk rows of any SDK
410+
// response type. Numbers decode as json.Number first (UseNumber) and are then
411+
// narrowed by narrowNumbers: decoding straight to float64 would round integer
412+
// IDs above 2^53 (channel_id, team_id, …) in the bounded output. Unset SDK
413+
// timestamps go in as null (NullUnsetInstants), matching what the printer
414+
// would have emitted for the unbounded payload.
415+
func genericStructured(data any) (any, error) {
416+
raw, err := json.Marshal(output.NullUnsetInstants(data))
417+
if err != nil {
418+
return nil, err
419+
}
420+
dec := json.NewDecoder(bytes.NewReader(raw))
421+
dec.UseNumber()
422+
var generic any
423+
if err := dec.Decode(&generic); err != nil {
424+
return nil, err
425+
}
426+
return narrowNumbers(generic), nil
427+
}
428+
429+
// narrowNumbers rewrites every json.Number in a decoded generic value to its
430+
// int64 form when the literal is an integer (exact for IDs beyond 2^53), else
431+
// float64 — both encoders (JSON and TOON) render those natively.
432+
func narrowNumbers(value any) any {
433+
switch v := value.(type) {
434+
case json.Number:
435+
if i, err := v.Int64(); err == nil {
436+
return i
437+
}
438+
if f, err := v.Float64(); err == nil {
439+
return f
440+
}
441+
return v.String()
442+
case map[string]any:
443+
for key, item := range v {
444+
v[key] = narrowNumbers(item)
445+
}
446+
return v
447+
case []any:
448+
for i, item := range v {
449+
v[i] = narrowNumbers(item)
450+
}
451+
return v
452+
default:
453+
return value
454+
}
455+
}
456+
457+
// listEnvelopeKey reports whether value is a paginated list envelope — exactly
458+
// one array field named items/docs/list with only scalar siblings (total,
459+
// has_next_page, search_after_ctx, …) — and returns the row array's key. It
460+
// mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the
461+
// same shape when generating these commands.
462+
func listEnvelopeKey(value map[string]any) (string, bool) {
463+
key := ""
464+
for name, field := range value {
465+
_, isArray := field.([]any)
466+
if isArray && (name == "items" || name == "docs" || name == "list") {
467+
if key != "" {
468+
return "", false // two candidate row arrays: not a flat list envelope
469+
}
470+
key = name
471+
continue
472+
}
473+
switch field.(type) {
474+
case map[string]any, []any:
475+
return "", false // non-scalar sibling: a richer response, not a flat list
476+
}
477+
}
478+
return key, key != ""
479+
}
480+
481+
// objectRows converts a decoded JSON array to rows for boundProjectedList. ok
482+
// is false when any element is not an object: an array of scalars has no row
483+
// fields to bound and prints unbounded instead.
484+
func objectRows(items []any) ([]map[string]any, bool) {
485+
rows := make([]map[string]any, len(items))
486+
for i, item := range items {
487+
row, ok := item.(map[string]any)
488+
if !ok {
489+
return nil, false
490+
}
491+
rows[i] = row
492+
}
493+
return rows, true
494+
}
495+
333496
// genParseTimeFlag parses a relative-or-absolute time flag into unix seconds,
334497
// mirroring the curated incident-list --since/--until handling: a Go duration
335498
// ("7d", "24h") is "now minus duration", "+7d" is the future, "now" is now, and

0 commit comments

Comments
 (0)