diff --git a/cmd/flashduty/main_test.go b/cmd/flashduty/main_test.go index 1143742..4f52b74 100644 --- a/cmd/flashduty/main_test.go +++ b/cmd/flashduty/main_test.go @@ -112,24 +112,23 @@ func TestSetVersionInfoBeforeExecute(t *testing.T) { } } -// Test 79: When a compact list projection overflows its byte budget, the -// binary exits non-zero, writes nothing to stdout, and reports the error on -// stderr — a pipeline reading stdout must see a failed call, never an empty -// page masquerading as "no data". +// Test 79: When a compact list projection cannot fit its byte budget at +// all — a single row that overflows on its own and carries nothing +// shortenable — the binary exits non-zero, writes nothing to stdout, and +// reports the error on stderr: a pipeline reading stdout must see a failed +// call, never an empty page masquerading as "no data". (A multi-row page +// that overflows is instead reduced to the leading rows that fit, announced +// on stderr.) func TestProjectionOverflowFailsHard(t *testing.T) { binPath := buildTestBinary(t, "") - // Stub the alert-event list endpoint with a page whose projection stays - // over the 16 KiB budget even after value shortening. + // Stub the alert-event list endpoint with one row whose projected labels + // blob alone exceeds the 16 KiB budget; a labels map is not a string, so + // no value shortening can rescue it. var body strings.Builder - body.WriteString(`{"request_id":"r","error":{"code":"OK","message":""},"data":{"total":100,"items":[`) - for i := 0; i < 100; i++ { - if i > 0 { - body.WriteByte(',') - } - fmt.Fprintf(&body, `{"event_id":"%024x","alert_id":"%024x","event_severity":"Warning","event_status":"Triggered","event_time":1712000000,"title":%q}`, - i, i+1_000_000, strings.Repeat("x", 200)) - } + body.WriteString(`{"request_id":"r","error":{"code":"OK","message":""},"data":{"total":1,"items":[`) + fmt.Fprintf(&body, `{"event_id":"%024x","alert_id":"%024x","event_severity":"Warning","event_status":"Triggered","event_time":1712000000,"title":"disk full","labels":{"payload":%q}}`, + 1, 1_000_001, strings.Repeat("x", 20000)) body.WriteString(`]}}`) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -138,7 +137,7 @@ func TestProjectionOverflowFailsHard(t *testing.T) { })) defer srv.Close() - run := exec.Command(binPath, "alert-event", "list", "--limit", "100", + run := exec.Command(binPath, "alert-event", "list", "--fields", "event_id,labels", "--output-format", "json", "--app-key", "test-key", "--base-url", srv.URL) // Isolate HOME so the test never reads the developer's real CLI config. run.Env = append(os.Environ(), "HOME="+t.TempDir()) diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index 42c0d52..a707ba5 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -97,12 +97,17 @@ func newAlertEventListCmd() *cobra.Command { if err != nil { return err } - note, err := boundProjectedOutput(proj, compactListOutputLimit) + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) if err != nil { return err } - noteProjectionShortening(cmd.ErrOrStderr(), note) - return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total)) + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) + effectiveLimit := limit + if len(proj) < len(result.Items) { + effectiveLimit = len(proj) + } + return ctx.PrintList(proj, nil, len(proj), page, effectiveLimit, int(result.Total)) } return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total)) @@ -119,7 +124,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") - cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. Long strings are truncated as needed to keep structured output below 16 KiB.") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. If the page would exceed 16 KiB, only the leading rows that fit are emitted, with every value intact (announced on stderr).") return cmd } diff --git a/internal/cli/channel.go b/internal/cli/channel.go index a2472f6..6d56e08 100644 --- a/internal/cli/channel.go +++ b/internal/cli/channel.go @@ -195,11 +195,12 @@ func newChannelEscalateRuleListCmd() *cobra.Command { if err != nil { return err } - note, err := boundProjectedOutput(proj, compactListOutputLimit) + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) if err != nil { return err } - noteProjectionShortening(cmd.ErrOrStderr(), note) + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) return ctx.PrintTotal(proj, nil, len(proj)) } diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index baea71c..e65232e 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -1705,24 +1705,22 @@ func TestCommandAlertListStructuredAnnouncesTruncation(t *testing.T) { // --------------------------------------------------------------------------- // TestCommandListProjectionOverflowFails pins that a compact list projection -// which cannot fit the byte budget fails the command instead of emitting -// anything: Execute returns the error and stdout stays empty, so a pipeline -// reading stdout sees a failed call, never an empty page masquerading as -// "no data". +// which cannot fit the byte budget at all — a single row that overflows on +// its own and carries nothing shortenable — fails the command instead of +// emitting anything: Execute returns the error and stdout stays empty, so a +// pipeline reading stdout sees a failed call, never an empty page +// masquerading as "no data". (A multi-row page that overflows is instead +// reduced to the leading rows that fit — see +// TestAlertEventListAutoReducesPageAtLargeLimit.) func TestCommandListProjectionOverflowFails(t *testing.T) { t.Run("incident list", func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) - items := make([]any, 100) - for i := range items { - row := incidentRow() - row["incident_id"] = fmt.Sprintf("inc-%024d", i) - row["title"] = strings.Repeat("x", 200) - items[i] = row - } - stub.data = map[string]any{"items": items, "total": len(items)} + row := incidentRow() + row["labels"] = map[string]any{"payload": strings.Repeat("x", 20000)} + stub.data = map[string]any{"items": []any{row}, "total": 1} - out, stderrText, err := execCommandSplit("incident", "list", "--limit", "100", "--output-format", "json") + out, stderrText, err := execCommandSplit("incident", "list", "--fields", "incident_id,labels", "--output-format", "json") if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") { t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err) } @@ -1737,20 +1735,18 @@ func TestCommandListProjectionOverflowFails(t *testing.T) { t.Run("alert-event list", func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) - items := make([]any, 100) - for i := range items { - items[i] = map[string]any{ - "event_id": fmt.Sprintf("%024x", i), - "alert_id": fmt.Sprintf("%024x", i+1_000_000), - "event_severity": "Warning", - "event_status": "Triggered", - "event_time": 1712000000 + i, - "title": strings.Repeat("x", 200), - } + row := map[string]any{ + "event_id": fmt.Sprintf("%024x", 1), + "alert_id": fmt.Sprintf("%024x", 1_000_001), + "event_severity": "Warning", + "event_status": "Triggered", + "event_time": 1712000000, + "title": "disk full", + "labels": map[string]any{"payload": strings.Repeat("x", 20000)}, } - stub.data = map[string]any{"items": items, "total": len(items)} + stub.data = map[string]any{"items": []any{row}, "total": 1} - out, _, err := execCommandSplit("alert-event", "list", "--limit", "100", "--output-format", "json") + out, _, err := execCommandSplit("alert-event", "list", "--fields", "event_id,labels", "--output-format", "json") if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") { t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err) } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index 835f7ed..44a86e7 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -80,12 +80,12 @@ func noteDefaultProjection(w io.Writer, fields []string) { strings.Join(fields, ",")) } -// noteProjectionShortening tells the caller, on stderr, that some values came -// back clipped. Without it a shortened value is only visible to a reader, not -// to the jq filter or exact match a --json consumer runs over it, so a query -// that silently matches nothing looks like an empty result rather than a -// truncated one. -func noteProjectionShortening(w io.Writer, note string) { +// noteProjectionBound relays a boundProjectedOutput note to the caller on +// stderr. Without it a reduced page or a shortened value is only visible to +// a reader, not to the jq filter or exact match a --json consumer runs over +// it, so a query that silently matches nothing looks like an empty result +// rather than a bounded one. +func noteProjectionBound(w io.Writer, note string) { if note == "" { return } @@ -93,26 +93,29 @@ func noteProjectionShortening(w io.Writer, note string) { } // boundProjectedOutput keeps the new agent-oriented projections below their -// command budget without changing the selected keys. List rows (many small -// records) are shortened fairly when they overflow the budget, with -// shortened values marked with "...". A single-object detail projection is -// never modified: a truncated id or status string is indistinguishable from -// a genuinely short value, so silently shortening it would hand the caller -// wrong data instead of a compact one. If a detail projection doesn't fit, -// the command fails with an error instead. +// command budget without changing the selected keys. A list projection that +// overflows the budget is first reduced to the leading rows that fit with +// every value intact; only a single row that overflows the budget on its own +// is shortened fairly, with shortened values marked with "...". A +// single-object detail projection is never modified: a truncated id or +// status string is indistinguishable from a genuinely short value, so +// silently shortening it would hand the caller wrong data instead of a +// compact one. If a detail projection doesn't fit, the command fails with an +// error instead. // -// It returns a caller-printable note (empty when nothing was shortened) that -// names the clipped fields, so the caller can announce the loss on stderr — -// the "..." marker is only visible to something that reads the value, never -// to the filter a --json consumer runs over it. -func boundProjectedOutput(data any, maxBytes int) (string, error) { +// It returns the bounded data with the same type it was given, plus a +// caller-printable note (empty when nothing was reduced or shortened), so +// the caller can announce the loss on stderr — the "..." marker is only +// visible to something that reads the value, never to the filter a --json +// consumer runs over it. +func boundProjectedOutput(data any, maxBytes int) (any, string, error) { switch value := data.(type) { case map[string]any: - return "", boundProjectedDetail(value, maxBytes) + return value, "", boundProjectedDetail(value, maxBytes) case []map[string]any: return boundProjectedList(value, maxBytes) default: - return "", fmt.Errorf("internal error: unsupported projected output %T", data) + return nil, "", fmt.Errorf("internal error: unsupported projected output %T", data) } } @@ -190,10 +193,14 @@ func isIdentifierField(key string) bool { return strings.HasSuffix(key, "_id") || strings.HasSuffix(key, "_key") } -// boundProjectedList shortens a list projection's string values fairly when -// the compact rows themselves overflow the budget: it finds the largest -// per-field byte cap that still makes everything fit, then applies that one -// cap to every shortenable string value across every row. A field already +// boundProjectedList keeps a list projection below the budget. A page that +// overflows is first reduced to the largest leading prefix of rows that +// fits, with every value intact: a --json consumer filters and matches on +// the values, so a partial page of intact rows serves it, while a full page +// of "..."-clipped rows silently defeats the filter. Only when one row alone +// overflows the budget does it shorten that row's string values fairly: it +// finds the largest per-field byte cap that still makes the row fit, then +// applies that one cap to every shortenable string value. A field already // shorter than the cap is left completely untouched — only the field(s) // actually responsible for the overflow (typically a long title) get // shortened, each marked with "...". Identifier fields (keys ending in _id @@ -202,27 +209,37 @@ func isIdentifierField(key string) bool { // marker itself disappear, so a shortened value is always distinguishable // from a genuinely short one; if no cap at or above that floor fits, the // command fails with a small error instead of emitting values that look -// real but aren't. Whatever it clips, it reports back in the returned note. -func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) { +// real but aren't. Whatever it reduces or clips, it reports back in the +// returned note. +func boundProjectedList(rows []map[string]any, maxBytes int) ([]map[string]any, string, error) { encoded, err := marshalStructured(rows) if err != nil { - return "", err + return nil, "", err } if len(encoded)+1 < maxBytes { - return "", nil + return rows, "", nil } // The overflow error names the fields responsible, exactly as the detail // path does, so the request can be narrowed in one pass. - tooBig := func() (string, error) { + tooBig := func() ([]map[string]any, string, error) { largest, err := largestProjectedFields(rows) if err != nil { - return "", err + return nil, "", err } - return "", fmt.Errorf("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields", + return nil, "", fmt.Errorf("projected list is %d bytes across %d rows, exceeds the %d-byte limit; largest fields: %s; request fewer rows (--limit) or fewer --fields", len(encoded), len(rows), maxBytes, largest) } + kept, err := largestFittingPrefix(rows, maxBytes) + if err != nil { + return nil, "", err + } + if kept > 0 { + return rows[:kept], fmt.Sprintf("note: emitted %d of %d projected rows (every value intact) to stay below the %d-byte structured-output limit; narrow --fields or lower --limit to fit more rows per page — the rows past the first %d were not emitted", + kept, len(rows), maxBytes, kept), nil + } + maxLen := 0 for _, row := range rows { for key, value := range row { @@ -268,7 +285,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) { return tooBig() } if ok, err := fits(minMarkedTruncationCap); err != nil { - return "", err + return nil, "", err } else if !ok { return tooBig() } @@ -282,7 +299,7 @@ func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) { mid := lo + (hi-lo+1)/2 ok, err := fits(mid) if err != nil { - return "", err + return nil, "", err } if ok { lo = mid @@ -312,17 +329,50 @@ func boundProjectedList(rows []map[string]any, maxBytes int) (string, error) { } } if shortened == 0 { - return "", nil + return rows, "", nil } names := make([]string, 0, len(fields)) for name := range fields { names = append(names, name) } sort.Strings(names) - return fmt.Sprintf("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \"...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values", + return rows, fmt.Sprintf("note: %d of %d string values were shortened to fit the %d-byte limit and now end with \"...\" (fields: %s); matching or filtering on those fields will miss — narrow --fields or --limit for untruncated values", shortened, total, maxBytes, strings.Join(names, ", ")), nil } +// largestFittingPrefix returns the largest n < len(rows) whose encoded prefix +// rows[:n] fits the budget, or 0 when even one row overflows it. Prefix size +// is monotone — appending a row never shrinks the encoding — so the boundary +// is found by binary search between lo=1 (known fitting, checked first) and +// hi=len(rows) (known not to fit: the caller only reaches here on overflow). +func largestFittingPrefix(rows []map[string]any, maxBytes int) (int, error) { + fits := func(n int) (bool, error) { + encoded, err := marshalStructured(rows[:n]) + if err != nil { + return false, err + } + return len(encoded)+1 < maxBytes, nil + } + ok, err := fits(1) + if err != nil || !ok { + return 0, err + } + lo, hi := 1, len(rows) // fits(lo) holds, fits(hi) does not + for hi-lo > 1 { + mid := lo + (hi-lo)/2 + ok, err := fits(mid) + if err != nil { + return 0, err + } + if ok { + lo = mid + } else { + hi = mid + } + } + return lo, nil +} + func truncateUTF8Bytes(value string, maxBytes int) string { if len(value) <= maxBytes { return value diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 9124f2f..610ca70 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "github.com/spf13/cobra" + toon "github.com/toon-format/toon-go" ) // incidentRow / alertRow are multi-field stub payloads with the nested blobs @@ -42,7 +43,7 @@ func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { "title": strings.Repeat("数据库故障", 2000), }} - if _, err := boundProjectedOutput(rows, 512); err != nil { + if _, _, err := boundProjectedOutput(rows, 512); err != nil { t.Fatalf("bound projected output: %v", err) } encoded, err := marshalStructured(rows) @@ -63,12 +64,12 @@ func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) { saveAndResetGlobals(t) flagOutputFormat = "json" - rows := make([]map[string]any, 200) - for i := range rows { - rows[i] = map[string]any{"count": i} - } + // One row carrying a large non-string field: too big to emit as a + // single-row page and with no string value to shorten, so the only + // honest answer is the narrowing error. + rows := []map[string]any{{"counts": make([]int, 500)}} - _, err := boundProjectedOutput(rows, 512) + _, _, err := boundProjectedOutput(rows, 512) if err == nil || !strings.Contains(err.Error(), "request fewer rows") { t.Fatalf("irreducible output error = %v, want bounded guidance", err) } @@ -92,7 +93,7 @@ func TestBoundProjectedOutputDetailWithinBudgetLeavesValuesUnchanged(t *testing. "progress": "Triggered", } - if _, err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil { + if _, _, err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil { t.Fatalf("bound projected output: %v", err) } if !reflect.DeepEqual(row, want) { @@ -119,7 +120,7 @@ func TestBoundProjectedOutputDetailOversizedErrorsWithoutMutating(t *testing.T) "root_cause": strings.Repeat("disk exhaustion details ", 3000), } - _, err := boundProjectedOutput(row, 512) + _, _, err := boundProjectedOutput(row, 512) if err == nil { t.Fatal("expected an error for an oversized detail projection, got nil") } @@ -151,7 +152,7 @@ func TestBoundProjectedOutputDetailErrorIsDeterministic(t *testing.T) { "delta": strings.Repeat("d", 400), "echo": strings.Repeat("e", 400), } - _, err := boundProjectedOutput(row, 512) + _, _, err := boundProjectedOutput(row, 512) if err == nil { t.Fatal("expected an error for an oversized detail projection, got nil") } @@ -667,8 +668,8 @@ func TestAlertEventListStructuredProjection(t *testing.T) { // multi-word/CJK title (the field actually responsible for a page overflowing // the compact-list budget), the rest carry short, realistic titles. It // returns the row list alongside the exact ids and titles a correct -// projection must preserve or shorten. -func alertEventOutlierFixture(n, outliers int) (items []any, ids, shortTitles []string) { +// projection must preserve. +func alertEventOutlierFixture(n, outliers int) (items []any, ids, titles []string) { longTitle := strings.Repeat("K8S pod tcp 接收队列大于2000 / cluster-prod-a / node-17 / namespace kube-system / pod coredns-7db6d8ff4d-abcde ", 40) normalTitles := []string{ "ERROR Detected / VMLogs-Prod", @@ -679,17 +680,17 @@ func alertEventOutlierFixture(n, outliers int) (items []any, ids, shortTitles [] items = make([]any, n) ids = make([]string, 0, n*2) + titles = make([]string, 0, n) for i := range items { eventID := fmt.Sprintf("%024x", i) alertID := fmt.Sprintf("%024x", i+1_000_000) ids = append(ids, eventID, alertID) title := normalTitles[i%len(normalTitles)] - if i >= outliers { - shortTitles = append(shortTitles, title) - } else { + if i < outliers { title = fmt.Sprintf("%s (row %d)", longTitle, i) } + titles = append(titles, title) items[i] = map[string]any{ "event_id": eventID, @@ -700,21 +701,21 @@ func alertEventOutlierFixture(n, outliers int) (items []any, ids, shortTitles [] "title": title, } } - return items, ids, shortTitles + return items, ids, titles } // TestAlertEventListDefaultProjectionPreservesShortFields is the regression // guard for the original defect: a minority of pathologically long titles // pushing a page over the 16 KiB compact-list budget must never mangle the -// other rows' long (Mongo ObjectID-shaped) ids, or the short title rows on -// the same page — the shortening must land entirely on the field(s) actually -// responsible for the overflow. +// rows — the command reduces the page to the leading rows that fit with every +// value (long ids, short and outlier titles alike) byte-identical to the +// fixture, and says on stderr how many rows it emitted. func TestAlertEventListDefaultProjectionPreservesShortFields(t *testing.T) { for _, format := range []string{"json", "toon"} { t.Run(format, func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) - items, ids, shortTitles := alertEventOutlierFixture(30, 3) + items, ids, titles := alertEventOutlierFixture(30, 3) stub.data = map[string]any{"items": items, "total": len(items)} out, stderrText, err := execCommandSplit("alert-event", "list", "--output-format", format) @@ -727,24 +728,160 @@ func TestAlertEventListDefaultProjectionPreservesShortFields(t *testing.T) { if !strings.Contains(stderrText, "note: rows projected to default compact fields") { t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) } + if strings.Contains(out, "...") { + t.Errorf("a reduced page must never emit a shortened value, got:\n%s", out) + } - for _, id := range ids { - if !strings.Contains(out, id) { - t.Errorf("id %q was shortened; only the oversized outlier titles should shrink, got:\n%s", id, out) + // The emitted rows are the longest leading prefix that fits; each + // must be byte-identical to the fixture, and the stderr note must + // name the emitted count. + emitted := 0 + for i := range items { + if !strings.Contains(out, ids[2*i]) { + for j := i + 1; j < len(items); j++ { + if strings.Contains(out, ids[2*j]) { + t.Fatalf("row %d emitted but earlier row %d missing: emitted rows must be the leading prefix", j, i) + } + } + break } + if !strings.Contains(out, ids[2*i+1]) { + t.Errorf("row %d alert_id was altered though a reduced page keeps every value intact", i) + } + if !strings.Contains(out, titles[i]) { + t.Errorf("row %d title was altered though a reduced page keeps every value intact", i) + } + emitted++ + } + if emitted < 1 || emitted >= len(items) { + t.Fatalf("emitted %d rows, want a reduced page in [1, %d)", emitted, len(items)) } - for _, title := range shortTitles { - if !strings.Contains(out, title) { - t.Errorf("short title %q was shortened even though it never exceeded the budget on its own, got:\n%s", title, out) + wantNote := fmt.Sprintf("emitted %d of %d", emitted, len(items)) + if !strings.Contains(stderrText, wantNote) { + t.Errorf("stderr should name the emitted count (%q), got:\n%s", wantNote, stderrText) + } + }) + } +} + +// TestAlertEventListAutoReducesPageAtLargeLimit pins the end-to-end contract +// for a large --limit: a page whose projected rows overflow the 16 KiB budget +// comes back as the longest fitting leading prefix — exit 0, parseable array, +// every value byte-identical, no "..." marker — with the emitted count +// announced on stderr. +func TestAlertEventListAutoReducesPageAtLargeLimit(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + const total = 100 + fixture := make([]map[string]any, total) + items := make([]any, total) + for i := range items { + row := map[string]any{ + "event_id": fmt.Sprintf("%024x", i), + "alert_id": fmt.Sprintf("%024x", i+1_000_000), + "event_severity": "Warning", + "event_status": "Triggered", + "event_time": 1712000000 + i, + "title": strings.Repeat("K8S pod tcp 接收队列大于2000 / cluster-prod-a / node-17 ", 10) + fmt.Sprintf("(row %d)", i), } + fixture[i] = row + items[i] = row } - if !strings.Contains(out, "...") { - t.Errorf("expected the outlier titles to be visibly marked with \"...\", got:\n%s", out) + stub.data = map[string]any{"items": items, "total": total} + + out, stderrText, err := execCommandSplit("alert-event", "list", "--limit", "100", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + assertAutoReducedPage(t, out, stderrText, format, fixture, []string{"event_id", "alert_id", "event_severity", "event_status", "title"}) + }) + } +} + +// TestIncidentListAutoReducesPageAtLargeLimit mirrors +// TestAlertEventListAutoReducesPageAtLargeLimit for incident list. +func TestIncidentListAutoReducesPageAtLargeLimit(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + const total = 100 + fixture := make([]map[string]any, total) + items := make([]any, total) + for i := range items { + row := map[string]any{ + "incident_id": fmt.Sprintf("%024x", i), + "title": strings.Repeat("数据库主库磁盘空间不足告警 / db-01 / 磁盘使用率超过95% ", 10) + fmt.Sprintf("(row %d)", i), + "incident_severity": "Critical", + "progress": "Triggered", + "start_time": 1712000000 + i, + "channel_id": 4201, + } + fixture[i] = row + items[i] = row + } + stub.data = map[string]any{"items": items, "total": total} + + out, stderrText, err := execCommandSplit("incident", "list", "--limit", "100", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) } + assertAutoReducedPage(t, out, stderrText, format, fixture, []string{"incident_id", "title", "incident_severity", "progress"}) }) } } +// assertAutoReducedPage pins the auto-reduced-page contract for a structured +// list command whose full page overflows the compact budget: stdout parses as +// an array holding the longest fitting leading prefix of the fixture — every +// value byte-identical, no "..." marker anywhere, output under the budget — +// and stderr names the emitted count. +func assertAutoReducedPage(t *testing.T, out, stderrText, format string, fixture []map[string]any, stringFields []string) { + t.Helper() + + var decoded []map[string]any + switch format { + case "json": + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decoded); err != nil { + t.Fatalf("parse %s output as an array: %v\n%s", format, err, out) + } + case "toon": + if err := toon.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("parse %s output as an array: %v\n%s", format, err, out) + } + } + + if len(decoded) < 1 || len(decoded) >= len(fixture) { + t.Fatalf("emitted %d rows, want a reduced page in [1, %d)", len(decoded), len(fixture)) + } + if strings.Contains(out, "...") { + t.Errorf("a reduced page must never emit a shortened value, got:\n%s", out) + } + for i, row := range decoded { + for _, field := range stringFields { + got, ok := row[field].(string) + if !ok { + t.Fatalf("row %d field %q = %#v, want a string", i, field, row[field]) + } + if want := fixture[i][field].(string); got != want { + t.Errorf("row %d field %q was altered: got %q, want byte-identical %q", i, field, got, want) + } + } + } + if len(out) >= compactListOutputLimit { + t.Errorf("reduced page is %d bytes, want <%d", len(out), compactListOutputLimit) + } + wantNote := fmt.Sprintf("note: emitted %d of %d projected rows (every value intact) to stay below the %d-byte structured-output limit; narrow --fields or lower --limit to fit more rows per page — the rows past the first %d were not emitted", + len(decoded), len(fixture), compactListOutputLimit, len(decoded)) + if !strings.Contains(stderrText, wantNote) { + t.Errorf("stderr should announce the reduced page (%q), got:\n%s", wantNote, stderrText) + } +} + // TestAlertEventListFieldsProjectionUnchanged is the conductor constraint for // alert-event list's --fields path: it must keep selecting exactly the named // fields, unaffected by the default-projection truncation logic. @@ -776,16 +913,11 @@ func TestAlertEventListFieldsProjectionUnchanged(t *testing.T) { } // TestBoundProjectedListNeverEmitsUnmarkedTruncation is the regression guard -// for the original defect's silent-corruption half: the old algorithm -// repeatedly halved a single shared per-field byte cap, and once that cap -// dropped to 3 bytes or below, truncateUTF8Bytes's no-room-for-a-marker -// fallback returned raw, unmarked bytes indistinguishable from a genuinely -// short value. Even under extreme row/field pressure that forces every -// shortenable string field to shrink, every shortened value must carry the -// "..." marker. (Row count is sized so the exempt _id fields and the JSON -// envelope still fit at the truncation floor — more rows would tip the page -// into the identifier-overflow error pinned by -// TestBoundProjectedListIdentifierOnlyOverflowErrors.) +// for the original defect's silent-corruption half: a page that overflows the +// budget is reduced to the leading rows that fit, so no value is ever +// shortened — every emitted row is byte-identical to the fixture, no "..." +// marker appears anywhere, the note names the emitted count, and the encoded +// output stays under the budget. func TestBoundProjectedListNeverEmitsUnmarkedTruncation(t *testing.T) { saveAndResetGlobals(t) flagOutputFormat = "json" @@ -809,10 +941,21 @@ func TestBoundProjectedListNeverEmitsUnmarkedTruncation(t *testing.T) { originals[i] = clone } - if _, err := boundProjectedOutput(rows, compactListOutputLimit); err != nil { + bounded, note, err := boundProjectedOutput(rows, compactListOutputLimit) + if err != nil { t.Fatalf("bound: %v", err) } + kept, ok := bounded.([]map[string]any) + if !ok { + t.Fatalf("bounded output type = %T, want []map[string]any", bounded) + } + if len(kept) < 1 || len(kept) >= len(rows) { + t.Fatalf("emitted %d rows, want a reduced page in [1, %d)", len(kept), len(rows)) + } + // Reduction returns a prefix of the original slice without mutating any + // row, so the whole fixture — emitted and dropped rows alike — must come + // back byte-identical. for i, row := range rows { for key, value := range row { text, ok := value.(string) @@ -820,14 +963,27 @@ func TestBoundProjectedListNeverEmitsUnmarkedTruncation(t *testing.T) { continue } original := originals[i][key].(string) - if text == original { - continue + if text != original { + t.Fatalf("row %d field %q was altered: page reduction must never touch a value (got %d bytes, want %d)", i, key, len(text), len(original)) } - if !strings.HasSuffix(text, "...") { - t.Fatalf("row %d field %q was shortened to %q without the \"...\" marker (original was %d bytes)", i, key, text, len(original)) + if strings.Contains(text, "...") { + t.Fatalf("row %d field %q carries a \"...\" marker: page reduction must never shorten a value", i, key) } } } + + encoded, err := marshalStructured(kept) + if err != nil { + t.Fatalf("marshal bounded output: %v", err) + } + if len(encoded)+1 >= compactListOutputLimit { + t.Fatalf("bounded output is %d bytes, want <%d", len(encoded)+1, compactListOutputLimit) + } + wantNote := fmt.Sprintf("note: emitted %d of %d projected rows (every value intact) to stay below the %d-byte structured-output limit; narrow --fields or lower --limit to fit more rows per page — the rows past the first %d were not emitted", + len(kept), len(rows), compactListOutputLimit, len(kept)) + if note != wantNote { + t.Fatalf("note = %q, want %q", note, wantNote) + } } func TestStructuredFieldsEmptyErrors(t *testing.T) { @@ -862,7 +1018,9 @@ func TestStructuredFieldsEmptyErrors(t *testing.T) { // only visible to something that READS the value; a --json consumer runs a jq // filter or an exact match over it, where a clipped string produces an empty // result that is indistinguishable from "nothing matched" — the expensive -// failure this note exists to prevent. +// failure this note exists to prevent. The fixture is a single oversized row +// so the run lands on the shortening fallback (a multi-row page would be +// reduced, not shortened). func TestBoundProjectedListAnnouncesShortening(t *testing.T) { for _, format := range []string{"json", "toon"} { t.Run(format, func(t *testing.T) { @@ -873,7 +1031,7 @@ func TestBoundProjectedListAnnouncesShortening(t *testing.T) { "title": strings.Repeat("payment-gateway timeout ", 200), }} - note, err := boundProjectedOutput(rows, 512) + _, note, err := boundProjectedOutput(rows, 512) if err != nil { t.Fatalf("bound projected output: %v", err) } @@ -894,7 +1052,7 @@ func TestBoundProjectedListNoNoteWhenNothingShortened(t *testing.T) { flagOutputFormat = "json" rows := []map[string]any{{"incident_id": "inc-1", "title": "disk full"}} - note, err := boundProjectedOutput(rows, 512) + _, note, err := boundProjectedOutput(rows, 512) if err != nil { t.Fatalf("bound projected output: %v", err) } @@ -906,20 +1064,18 @@ func TestBoundProjectedListNoNoteWhenNothingShortened(t *testing.T) { // TestBoundProjectedListErrorNamesLargestFields pins that a list projection // which cannot fit at all says WHICH fields are responsible, exactly as the // detail path already does. Without it the only way to find the oversized -// field is to re-run the query once per field. +// field is to re-run the query once per field. The fixture is a single row +// with a large non-string field: no page to reduce, nothing to shorten. func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) { saveAndResetGlobals(t) flagOutputFormat = "json" - rows := make([]map[string]any, 200) - for i := range rows { - rows[i] = map[string]any{"count": i, "score": i * 2} - } + rows := []map[string]any{{"counts": make([]int, 500)}} - _, err := boundProjectedOutput(rows, 512) + _, _, err := boundProjectedOutput(rows, 512) if err == nil { t.Fatalf("irreducible projection = nil error, want refusal") } - if !strings.Contains(err.Error(), "largest fields:") { + if !strings.Contains(err.Error(), "largest fields:") || !strings.Contains(err.Error(), "counts") { t.Fatalf("list overflow error = %q, want it to name the largest fields", err) } } @@ -1107,44 +1263,43 @@ func TestChannelEscalateRuleListStructuredProjection(t *testing.T) { // exemption: keys ending in _id/_key carry values a consumer matches, // filters, or passes back verbatim (a jq exact-match over --json output, a // follow-up detail call), so shortening one silently defeats that consumer. -// Even when a page overflows badly enough that the fair cap lands below an -// identifier's own length, identifiers must come back byte-identical and -// only free-text fields shorten; the note must name only the clipped fields. +// The fixture is a single row whose oversized title overflows the budget on +// its own, landing the run on the shortening fallback: the identifiers must +// come back byte-identical, only the free-text title shortens, and the note +// must name only the clipped field. func TestBoundProjectedListNeverShortensIdentifierFields(t *testing.T) { for _, format := range []string{"json", "toon"} { t.Run(format, func(t *testing.T) { saveAndResetGlobals(t) flagOutputFormat = format - rows := make([]map[string]any, 10) - wantIDs := make([]map[string]string, len(rows)) - for i := range rows { - eventID := fmt.Sprintf("%024x", i) - alertKey := fmt.Sprintf("%032x", i) - rows[i] = map[string]any{ - "event_id": eventID, - "alert_key": alertKey, - "title": strings.Repeat("a", 500), - } - wantIDs[i] = map[string]string{"event_id": eventID, "alert_key": alertKey} - } + eventID := fmt.Sprintf("%024x", 1) + alertKey := fmt.Sprintf("%032x", 1) + rows := []map[string]any{{ + "event_id": eventID, + "alert_key": alertKey, + "title": strings.Repeat("a", 5000), + }} const budget = 1400 - note, err := boundProjectedOutput(rows, budget) + bounded, note, err := boundProjectedOutput(rows, budget) if err != nil { t.Fatalf("bound projected output: %v", err) } + kept, ok := bounded.([]map[string]any) + if !ok || len(kept) != 1 { + t.Fatalf("bounded output = %v, want the single shortened row", bounded) + } - for i, row := range rows { - for _, field := range []string{"event_id", "alert_key"} { - if got := row[field].(string); got != wantIDs[i][field] { - t.Errorf("row %d %s was shortened: got %q, want byte-identical %q", i, field, got, wantIDs[i][field]) - } - } - if title := row["title"].(string); !strings.HasSuffix(title, "...") { - t.Errorf("row %d title should be shortened with the \"...\" marker, got %q", i, title) + row := kept[0] + for field, want := range map[string]string{"event_id": eventID, "alert_key": alertKey} { + if got := row[field].(string); got != want { + t.Errorf("%s was shortened: got %q, want byte-identical %q", field, got, want) } } + if title := row["title"].(string); !strings.HasSuffix(title, "...") { + t.Errorf("title should be shortened with the \"...\" marker, got %q", title) + } if note == "" { t.Fatal("shortened projection returned no note; caller cannot tell values were clipped") @@ -1156,7 +1311,7 @@ func TestBoundProjectedListNeverShortensIdentifierFields(t *testing.T) { t.Errorf("note = %q, want it to name only shortened fields, never exempt identifiers", note) } - encoded, err := marshalStructured(rows) + encoded, err := marshalStructured(kept) if err != nil { t.Fatalf("marshal bounded output: %v", err) } @@ -1167,18 +1322,15 @@ func TestBoundProjectedListNeverShortensIdentifierFields(t *testing.T) { } } -// TestBoundProjectedListIdentifierOnlyOverflowErrors pins the other half of -// the identifier exemption: when a page carries nothing shortenable and its -// identifier content alone overflows the budget, the command must fail with -// the narrowing error instead of clipping identifiers to fit — and the rows -// must come back untouched. -func TestBoundProjectedListIdentifierOnlyOverflowErrors(t *testing.T) { +// TestBoundProjectedListIdentifierOnlyOverflowReducesPage pins the other half +// of the identifier exemption: a page carrying nothing shortenable (only +// identifier content) that overflows the budget is reduced to the leading +// rows that fit — identifiers stay byte-identical and the note names the +// emitted count — instead of clipping identifiers or erroring out. +func TestBoundProjectedListIdentifierOnlyOverflowReducesPage(t *testing.T) { saveAndResetGlobals(t) flagOutputFormat = "json" - // Sized so the full rows overflow 512 bytes while rows with ids clipped - // to the truncation floor would still fit: the old fair cap "succeeded" - // by shipping mangled ids, the exemption must instead refuse. rows := make([]map[string]any, 12) for i := range rows { rows[i] = map[string]any{"incident_id": fmt.Sprintf("%024x", i)} @@ -1188,13 +1340,31 @@ func TestBoundProjectedListIdentifierOnlyOverflowErrors(t *testing.T) { originals[i] = row["incident_id"].(string) } - _, err := boundProjectedOutput(rows, 512) - if err == nil || !strings.Contains(err.Error(), "request fewer rows") { - t.Fatalf("identifier-only overflow error = %v, want bounded guidance", err) + bounded, note, err := boundProjectedOutput(rows, 512) + if err != nil { + t.Fatalf("identifier-only overflow should reduce the page, not error: %v", err) + } + kept, ok := bounded.([]map[string]any) + if !ok { + t.Fatalf("bounded output type = %T, want []map[string]any", bounded) + } + if len(kept) < 1 || len(kept) >= len(rows) { + t.Fatalf("emitted %d rows, want a reduced page in [1, %d)", len(kept), len(rows)) } for i, row := range rows { if got := row["incident_id"].(string); got != originals[i] { - t.Errorf("row %d incident_id was mutated despite the error: got %q, want %q", i, got, originals[i]) + t.Errorf("row %d incident_id was mutated: got %q, want byte-identical %q", i, got, originals[i]) } } + wantNote := fmt.Sprintf("emitted %d of %d", len(kept), len(rows)) + if !strings.Contains(note, wantNote) { + t.Fatalf("note = %q, want it to name the emitted count (%q)", note, wantNote) + } + encoded, err := marshalStructured(kept) + if err != nil { + t.Fatalf("marshal bounded output: %v", err) + } + if len(encoded)+1 >= 512 { + t.Fatalf("bounded output is %d bytes, want <512", len(encoded)+1) + } } diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 99bfef1..a4dceb3 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -137,12 +137,17 @@ func newIncidentListCmd() *cobra.Command { if err != nil { return err } - note, err := boundProjectedOutput(proj, compactListOutputLimit) + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) if err != nil { return err } - noteProjectionShortening(cmd.ErrOrStderr(), note) - return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total)) + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) + effectiveLimit := limit + if len(proj) < len(result.Items) { + effectiveLimit = len(proj) + } + return ctx.PrintList(proj, nil, len(proj), page, effectiveLimit, int(result.Total)) } return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, limit, int(result.Total)) @@ -633,11 +638,12 @@ func newIncidentSimilarCmd() *cobra.Command { if err != nil { return err } - note, err := boundProjectedOutput(proj, compactListOutputLimit) + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) if err != nil { return err } - noteProjectionShortening(cmd.ErrOrStderr(), note) + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) return ctx.Printer.Print(proj, nil) } @@ -647,7 +653,7 @@ func newIncidentSimilarCmd() *cobra.Command { } cmd.Flags().IntVar(&limit, "limit", 5, "Max results") - cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary. Long strings are truncated as needed to keep structured output below 16 KiB.") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary. If the page would exceed 16 KiB, only the leading rows that fit are emitted, with every value intact (announced on stderr).") return cmd } @@ -1607,11 +1613,11 @@ func newIncidentDetailCmd() *cobra.Command { if err != nil { return err } - note, err := boundProjectedOutput(proj[0], compactDetailOutputLimit) + _, note, err := boundProjectedOutput(proj[0], compactDetailOutputLimit) if err != nil { return err } - noteProjectionShortening(cmd.ErrOrStderr(), note) + noteProjectionBound(cmd.ErrOrStderr(), note) return ctx.Printer.Print(proj[0], nil) } return ctx.Printer.Print(result, nil) diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 7663a56..f617951 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -38,7 +38,7 @@ fduty alert feed --output-format toon fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` -Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened, and a stderr note names the clipped fields — heed it before matching on those values, because the clipped text is what a `jq` filter sees. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. +Structured `alert-event list` output stays below 16 KiB: when the requested page would overflow, only the leading rows that fit are emitted — every value intact — and a stderr note says how many of the rows were emitted, so heed it before assuming the page is complete (narrow `--fields` or lower `--limit` to fit more rows per page). A trailing `...` on a value, with a stderr note naming the clipped fields, appears only when one row alone exceeds the budget — heed it before matching on that value, because the clipped text is what a `jq` filter sees. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. ## Hot flow — merge noisy alerts into an existing incident diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 459a49e..e454ec4 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -75,11 +75,11 @@ fduty incident comment "$ID" --comment-file "$COMMENT_FILE" fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` -Projected `similar` lists stay below 16 KiB; a trailing `...` in a list row means a long retained string was shortened, and a stderr note names the fields that were clipped. `detail --fields` is different: it never shortens values — the projection must fit within 8 KiB as requested or the command fails and names the largest fields, so drop some fields (or drop `--fields` for the full unbounded detail) and retry. +Projected `similar` lists stay below 16 KiB: when the page would overflow, only the leading rows that fit are emitted — every value intact — and a stderr note says how many rows were emitted. A trailing `...` in a list row, with a stderr note naming the clipped fields, appears only when one row alone exceeds the budget. `detail --fields` is different: it never shortens values — the projection must fit within 8 KiB as requested or the command fails and names the largest fields, so drop some fields (or drop `--fields` for the full unbounded detail) and retry. `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound; the command then errors and names the largest fields by aggregate size, so lower `--limit`, drop the field it names, or use `insight` aggregates for distributions instead of dumping labels row by row. Before it errors it tries to fit the rows by shortening long string values — when it does, a stderr note says how many values were clipped and in which fields. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound; when that happens the command emits only the leading rows that fit — every value intact — and a stderr note says how many rows were emitted, so lower `--limit` or narrow `--fields` to fit more rows per page. Only when one row alone exceeds the bound does it shorten long string values (a stderr note says how many values were clipped and in which fields); if the row cannot be shortened to fit, the command errors and names the largest fields by aggregate size, so drop the field it names, or use `insight` aggregates for distributions instead of dumping labels row by row. ## Hot flow — full fault analysis (read-only summary) @@ -496,7 +496,7 @@ Update a work item - **`similar` only works on channel-backed incidents** (those with a real `channel_id`). Manually created incidents with no channel return HTTP 400 "Channel not found" — this is expected, not transient. Fall back to `incident list --query ""` for text search. - **`update` vs `reset`**: `update ` edits title/description/severity/custom fields. `reset ` additionally supports `--impact`, `--root-cause`, `--resolution` (the AI narrative fields). Use `reset` for post-incident write-back. - **If `list` returns a `total`, use it instead of page-walking.** For "how many incidents are Triggered / Processing / Closed", run one filtered `incident list --progress ...` per bucket and read the returned `total`. Do not fetch page 1/2/3 just to derive counts the server already computed. -- **Search with `--query`, don't substring-match `title` from list output.** A `--fields` list projection may come back with long values clipped to fit its byte budget (a stderr note names the fields when it happens), so a local `jq test()` / `contains()` over `title` can miss rows that really do match, and an empty result is indistinguishable from a genuine non-match. `--query` is a server-side full-text search over title/labels/content — correct regardless of projection, and cheaper than pulling pages to filter locally. (It also resolves a 24-char `incident_id` or 6-char `num` to a direct lookup.) +- **Search with `--query`, don't substring-match `title` from list output.** A structured list page that exceeds its byte budget comes back reduced to the leading rows that fit (a stderr note names the emitted count), so a local `jq test()` / `contains()` over `title` only sees the emitted prefix and can miss rows that really do match, and an empty result is indistinguishable from a genuine non-match. `--query` is a server-side full-text search over title/labels/content — correct regardless of projection, and cheaper than pulling pages to filter locally. (It also resolves a 24-char `incident_id` or 6-char `num` to a direct lookup.) - **Use `--fields` to keep list scans compact.** When the goal is to identify matching incidents or collect IDs/numbers/titles, project only the needed columns first, then fetch one target incident with `detail` / `alerts` / `timeline`. - **`list` window cap**: `--since`/`--until` window must be < 31 days; `--limit` max 100. Empty result is authoritative — do not widen filters or retry. - **`get` has no time-window flags**: `get [...]` takes one or more incident IDs, not a window — it has no `--since`, `--until`, `--start-time`, or `--end-time` at all, so passing one errors as an unknown flag rather than filtering; use `list` for time-range filtering.