From 2cd40211c2b1a9af4426e42014537a1472199f9b Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 21 Jul 2026 21:52:54 -0700 Subject: [PATCH 1/3] fix(cli): compact incident audit outputs --- internal/cli/alert_event.go | 21 ++- internal/cli/fieldproject_test.go | 155 ++++++++++++++++++ internal/cli/incident.go | 35 +++- internal/cli/incident_summary_script_test.go | 45 +++++ .../incident_projection_guidance_test.go | 39 +++++ skills/flashduty/reference/alert.md | 2 + skills/flashduty/reference/incident.md | 22 +-- skills/flashduty/scripts/incident-summary.sh | 14 +- 8 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 internal/cli/incident_summary_script_test.go create mode 100644 internal/skilldoc/incident_projection_guidance_test.go diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index b8b0283..ee5e376 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command { } func newAlertEventListCmd() *cobra.Command { - var severity, channel, integrationType, since, until string + var severity, channel, integrationType, since, until, fields string var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List alert events globally", - Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"), + Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type. In json/toon mode, output defaults to compact event fields; use --fields to choose a different projection.", "Alerts", "EventReadList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -77,6 +81,18 @@ func newAlertEventListCmd() *cobra.Command { {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }}, } + if ctx.Structured() { + fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) + } + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, @@ -90,6 +106,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") 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.") return cmd } diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 215fdc0..100d95c 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -1,9 +1,12 @@ package cli import ( + "bytes" "encoding/json" "strings" "testing" + + "github.com/spf13/cobra" ) // incidentRow / alertRow are multi-field stub payloads with the nested blobs @@ -235,3 +238,155 @@ func TestFieldsUnknownFieldErrors(t *testing.T) { }) } } + +func TestIncidentSimilarStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 20) + for i := range items { + row := incidentRow() + row["incident_id"] = "similar-" + string(rune('a'+i)) + row["close_time"] = 1712000060 + row["ack_time"] = 1712000030 + row["alert_cnt"] = 3 + row["root_cause"] = "disk exhaustion" + row["score"] = 0.9 + row["description"] = strings.Repeat("large description ", 100) + row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + items[i] = row + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out)) + } + + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("parse compact similar json: %v\n%s", err, out) + } + want := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + if _, ok := row["description"]; ok { + t.Errorf("projected row includes description: %v", row) + } + } + +} + +func TestIncidentDetailFieldsProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := incidentRow() + row["description"] = strings.Repeat("large description ", 500) + row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + stub.data = row + + out, err := execCommand("incident", "detail", "inc-1", "--fields", "incident_id,title,root_cause", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 8*1024 { + t.Fatalf("projected detail output is %d bytes, want <8 KiB", len(out)) + } + var detail map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &detail); err != nil { + t.Fatalf("parse projected detail json: %v\n%s", err, out) + } + if len(detail) != 3 || detail["incident_id"] == nil || detail["title"] == nil || detail["root_cause"] == nil { + t.Fatalf("projected detail = %v, want exactly incident_id,title,root_cause", detail) + } + if _, ok := detail["description"]; ok { + t.Errorf("projected detail includes description: %v", detail) + } + +} + +func TestAlertEventListStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 30) + for i := range items { + items[i] = map[string]any{ + "event_id": "event-" + string(rune('a'+i)), + "alert_id": "alert-1", + "event_severity": "Warning", + "event_status": "Triggered", + "event_time": 1712000000, + "title": "CPU high", + "description": strings.Repeat("large description ", 100), + "labels": map[string]any{"host": "web-01"}, + "images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}, + } + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out)) + } + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("parse compact alert-event json: %v\n%s", err, out) + } + want := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + } + +} + +func TestStructuredFieldsEmptyErrors(t *testing.T) { + cases := []struct { + name string + cmd func() *cobra.Command + args []string + }{ + {"incident similar", newIncidentSimilarCmd, []string{"inc-1", "--fields", ""}}, + {"incident detail", newIncidentDetailCmd, []string{"inc-1", "--fields", ""}}, + {"alert-event list", newAlertEventListCmd, []string{"--fields", ""}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + flagOutputFormat = "json" + cmd := tc.cmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetErr(cmd.OutOrStderr()) + cmd.SetArgs(tc.args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--fields") { + t.Fatalf("empty --fields error = %v, want --fields validation", err) + } + }) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 948aa8a..0ff55fb 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -564,6 +564,7 @@ func newIncidentAlertsCmd() *cobra.Command { } func newIncidentSimilarCmd() *cobra.Command { + var fields string var limit int cmd := &cobra.Command{ @@ -573,6 +574,10 @@ func newIncidentSimilarCmd() *cobra.Command { Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{ IncidentID: ctx.Args[0], Limit: flashduty.Int64(int64(limit)), @@ -586,12 +591,25 @@ func newIncidentSimilarCmd() *cobra.Command { return nil } + if ctx.Structured() { + fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + return ctx.Printer.Print(proj, nil) + } + return ctx.Printer.Print(result.Items, pastIncidentColumns()) }) }, } 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.") return cmd } @@ -1301,13 +1319,19 @@ func resolveFeedOperators(rc *RunContext, items []flashduty.IncidentFeedItem) ma } func newIncidentDetailCmd() *cobra.Command { - return &cobra.Command{ + var fields string + + cmd := &cobra.Command{ Use: "detail ", Short: "View full incident detail with AI summary", Long: curatedLong("View full incident detail, including the AI summary, root cause, and resolution.", "Incidents", "Info"), Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + fullID, candidates, err := resolveIncidentArg(ctx, ctx.Args[0]) if err != nil { return err @@ -1324,6 +1348,13 @@ func newIncidentDetailCmd() *cobra.Command { } if ctx.Structured() { + if fields != "" { + proj, err := projectFields([]flashduty.IncidentInfo{*result}, parseStringSlice(fields)) + if err != nil { + return err + } + return ctx.Printer.Print(proj[0], nil) + } return ctx.Printer.Print(result, nil) } @@ -1332,6 +1363,8 @@ func newIncidentDetailCmd() *cobra.Command { }) }, } + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Omit for full detail.") + return cmd } func printIncidentFullDetail(w io.Writer, inc *flashduty.IncidentInfo) { diff --git a/internal/cli/incident_summary_script_test.go b/internal/cli/incident_summary_script_test.go new file mode 100644 index 0000000..dbf65df --- /dev/null +++ b/internal/cli/incident_summary_script_test.go @@ -0,0 +1,45 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestIncidentSummaryScriptCompactOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Bash fixture is unavailable on Windows") + } + + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh") + log := filepath.Join(t.TempDir(), "fduty.log") + bin := filepath.Join(t.TempDir(), "fduty") + if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil { + t.Fatalf("write fake fduty: %v", err) + } + t.Setenv("FDUTY_LOG", log) + t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH")) + + output, err := exec.Command("bash", script, "inc-1").CombinedOutput() + if err != nil { + t.Fatalf("run incident summary: %v\n%s", err, output) + } + invocations, err := os.ReadFile(log) + if err != nil { + t.Fatalf("read fake fduty log: %v", err) + } + lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' }) + if len(lines) != 6 { + t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations) + } + if strings.Contains(string(invocations), "--output-format toon") { + t.Fatalf("summary forces toon instead of each command's compact default:\n%s", invocations) + } +} diff --git a/internal/skilldoc/incident_projection_guidance_test.go b/internal/skilldoc/incident_projection_guidance_test.go new file mode 100644 index 0000000..fc04b09 --- /dev/null +++ b/internal/skilldoc/incident_projection_guidance_test.go @@ -0,0 +1,39 @@ +package skilldoc + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIncidentCardAvoidsUnboundedStructuredHotFlows(t *testing.T) { + card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "incident.md")) + if err != nil { + t.Fatal(err) + } + + body := string(card) + for description, command := range map[string]string{ + "triage list": "incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon", + "triage detail": "incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon", + "summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon`, + } { + if !strings.Contains(body, command) { + t.Errorf("incident %s must project structured output to the fields needed by the workflow", description) + } + } + + for _, command := range []string{ + "incident alerts --output-format toon", + `incident alerts "$ID" --output-format toon`, + `incident timeline "$ID" --output-format toon`, + "incident post-mortem-list --channel-ids --output-format toon", + "change list --since 24h --output-format toon", + "incident timeline --output-format toon", + } { + if strings.Contains(body, command) { + t.Errorf("incident hot flow must use the compact default instead of %q", command) + } + } +} diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index ef91058..93bc424 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -34,6 +34,8 @@ fduty alert get --output-format toon fduty alert events --output-format toon # 4. view state transitions (mute/severity changes/operator actions) fduty alert feed --output-format toon +# 5. for a time-window view across alerts, alert-event list is compact by default +fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` ## Hot flow — merge noisy alerts into an existing incident diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 4063413..903431e 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -42,13 +42,13 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders ```bash # 1. Find unacknowledged critical incidents (last 4h) -fduty incident list --severity Critical --progress Triggered --since 4h --output-format toon +fduty incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) -fduty incident detail --output-format toon +fduty incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon # 3. See contributing alerts -fduty incident alerts --output-format toon +fduty incident alerts # 4. Check for prior similar incidents (channel-backed only; see Gotchas) fduty incident similar --limit 5 --output-format toon @@ -77,12 +77,12 @@ If you fetch the pieces by hand instead, run **all six** — they are cheap read ```bash ID= # 24-char id from `incident list` -fduty incident detail "$ID" --output-format toon # ① 详情 + AI summary + alert counts + channel_id -fduty incident alerts "$ID" --output-format toon # ② contributing alerts (detail's embedded alerts are empty here) -fduty incident timeline "$ID" --output-format toon # ④ timeline (or `incident feed "$ID"` for the paginated view) -fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas) -fduty incident post-mortem-list --channel-ids --output-format toon # ⑥ post-mortems for this incident's channel -fduty change list --since 24h --output-format toon # ③ correlated changes — by shared labels + time; see reference/change.md +fduty incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon # ① 详情 + AI summary + alert counts + channel +fduty incident alerts "$ID" # ② contributing alerts (detail's embedded alerts are empty here) +fduty incident timeline "$ID" # ④ timeline (or `incident feed "$ID"` for the paginated view) +fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas; compact by default) +fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel +fduty change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md ``` > **Never report a result you didn't fetch.** Do not write "返回空" / "无" / a count for any aspect whose command is **absent from your tool-call history this turn** — write `未查询 — 可运行 ` instead. "Empty" is a claim only a command you actually ran can make; inventing it is the worst failure mode of a fault summary. @@ -100,7 +100,7 @@ fduty incident reset \ --resolution "Increased memory limit; deployed hot patch" # Review the event timeline -fduty incident timeline --output-format toon +fduty incident timeline ``` @@ -158,6 +158,7 @@ Execute custom action ### detail View full incident detail with AI summary +- `--fields` string ### disable-merge [...] Disable incident merge @@ -266,6 +267,7 @@ Add incident responder ### similar Find similar incidents +- `--fields` string - `--limit` int ### snooze [ ...] diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index 8288100..f9a52ac 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -7,8 +7,9 @@ # # usage: bash incident-summary.sh # -# To tie post-mortems to this incident specifically, re-run the last section with the -# channel_id from "incident detail": fduty incident post-mortem-list --channel-ids +# Section ⑥ lists recent post-mortems account-wide. To scope them to THIS incident's +# channel, read its channel_id (fduty incident info --incident-id --output-format +# toon | grep '^channel_id:') and re-run: fduty incident post-mortem-list --channel-ids # # Note: errexit (-e) is intentionally NOT set — every section must run even if one # command fails, so the summary stays as complete as possible. Each command's own @@ -21,9 +22,14 @@ if [ -z "$ID" ]; then exit 2 fi -run() { echo "===== fduty $* ====="; fduty "$@" --output-format toon 2>&1; echo; } +# Print each command's DEFAULT renderer (a curated table/summary that projects the +# summary-relevant fields), NOT --output-format toon: toon dumps the full raw objects +# — every empty field plus heavy blobs like a change's labels.steps — which overflowed +# the output cap and forced repeated paging. For these read verbs the lean default IS +# the field projection a fault summary needs (id/severity/status/title/channel/times/…). +run() { echo "===== fduty $* ====="; fduty "$@" 2>&1; echo; } -run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel_id +run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel run incident alerts "$ID" # ② contributing alerts run incident timeline "$ID" # ④ timeline run incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed) From 3b6f5b56e3424bf27611d2f2fd61473d5158da86 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 21 Jul 2026 21:56:58 -0700 Subject: [PATCH 2/3] fix(cli): bound incident summary detail --- internal/cli/fieldproject_test.go | 12 +++++++++--- internal/cli/incident_summary_script_test.go | 8 ++++++-- .../skilldoc/incident_projection_guidance_test.go | 4 ++-- skills/flashduty/reference/incident.md | 4 ++-- skills/flashduty/scripts/incident-summary.sh | 14 ++++++-------- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 100d95c..84277bc 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -297,7 +297,8 @@ func TestIncidentDetailFieldsProjection(t *testing.T) { row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} stub.data = row - out, err := execCommand("incident", "detail", "inc-1", "--fields", "incident_id,title,root_cause", "--output-format", "json") + fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"} + out, err := execCommand("incident", "detail", "inc-1", "--fields", strings.Join(fields, ","), "--output-format", "json") if err != nil { t.Fatalf("execCommand: %v", err) } @@ -308,8 +309,13 @@ func TestIncidentDetailFieldsProjection(t *testing.T) { if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &detail); err != nil { t.Fatalf("parse projected detail json: %v\n%s", err, out) } - if len(detail) != 3 || detail["incident_id"] == nil || detail["title"] == nil || detail["root_cause"] == nil { - t.Fatalf("projected detail = %v, want exactly incident_id,title,root_cause", detail) + if len(detail) != len(fields) { + t.Fatalf("projected detail keys = %v, want exactly %v", detail, fields) + } + for _, field := range fields { + if detail[field] == nil { + t.Errorf("projected detail missing %q: %v", field, detail) + } } if _, ok := detail["description"]; ok { t.Errorf("projected detail includes description: %v", detail) diff --git a/internal/cli/incident_summary_script_test.go b/internal/cli/incident_summary_script_test.go index dbf65df..2eeab13 100644 --- a/internal/cli/incident_summary_script_test.go +++ b/internal/cli/incident_summary_script_test.go @@ -39,7 +39,11 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) { if len(lines) != 6 { t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations) } - if strings.Contains(string(invocations), "--output-format toon") { - t.Fatalf("summary forces toon instead of each command's compact default:\n%s", invocations) + wantDetail := "incident detail inc-1 --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon" + if lines[0] != wantDetail { + t.Fatalf("detail call = %q, want compact projection %q", lines[0], wantDetail) + } + if strings.Contains(strings.Join(lines[1:], "\n"), "--output-format toon") { + t.Fatalf("non-detail reads force raw toon instead of their compact defaults:\n%s", invocations) } } diff --git a/internal/skilldoc/incident_projection_guidance_test.go b/internal/skilldoc/incident_projection_guidance_test.go index fc04b09..bb2867e 100644 --- a/internal/skilldoc/incident_projection_guidance_test.go +++ b/internal/skilldoc/incident_projection_guidance_test.go @@ -16,8 +16,8 @@ func TestIncidentCardAvoidsUnboundedStructuredHotFlows(t *testing.T) { body := string(card) for description, command := range map[string]string{ "triage list": "incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon", - "triage detail": "incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon", - "summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon`, + "triage detail": "incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon", + "summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon`, } { if !strings.Contains(body, command) { t.Errorf("incident %s must project structured output to the fields needed by the workflow", description) diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 903431e..d26c7ef 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -45,7 +45,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders fduty incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) -fduty incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon +fduty incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # 3. See contributing alerts fduty incident alerts @@ -77,7 +77,7 @@ If you fetch the pieces by hand instead, run **all six** — they are cheap read ```bash ID= # 24-char id from `incident list` -fduty incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time --output-format toon # ① 详情 + AI summary + alert counts + channel +fduty incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + AI summary + alert counts + channel fduty incident alerts "$ID" # ② contributing alerts (detail's embedded alerts are empty here) fduty incident timeline "$ID" # ④ timeline (or `incident feed "$ID"` for the paginated view) fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas; compact by default) diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index f9a52ac..ebcc937 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -8,8 +8,8 @@ # usage: bash incident-summary.sh # # Section ⑥ lists recent post-mortems account-wide. To scope them to THIS incident's -# channel, read its channel_id (fduty incident info --incident-id --output-format -# toon | grep '^channel_id:') and re-run: fduty incident post-mortem-list --channel-ids +# channel, read channel_id from the projected detail section and re-run: +# fduty incident post-mortem-list --channel-ids # # Note: errexit (-e) is intentionally NOT set — every section must run even if one # command fails, so the summary stays as complete as possible. Each command's own @@ -22,14 +22,12 @@ if [ -z "$ID" ]; then exit 2 fi -# Print each command's DEFAULT renderer (a curated table/summary that projects the -# summary-relevant fields), NOT --output-format toon: toon dumps the full raw objects -# — every empty field plus heavy blobs like a change's labels.steps — which overflowed -# the output cap and forced repeated paging. For these read verbs the lean default IS -# the field projection a fault summary needs (id/severity/status/title/channel/times/…). +# Project detail explicitly because its default table includes unbounded narrative +# fields. The other read verbs use their compact default renderers; raw toon dumps +# every empty field plus heavy blobs like a change's labels.steps. run() { echo "===== fduty $* ====="; fduty "$@" 2>&1; echo; } -run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel +run incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + AI summary + alert counts + channel run incident alerts "$ID" # ② contributing alerts run incident timeline "$ID" # ④ timeline run incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed) From 97aedf3ae097e4590d13ff4142440e7ebbb7447b Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 21 Jul 2026 22:12:22 -0700 Subject: [PATCH 3/3] fix(cli): cap compact structured output --- internal/cli/alert_event.go | 5 +- internal/cli/fieldproject.go | 89 ++++++++++++++++++++++++++ internal/cli/fieldproject_test.go | 51 ++++++++++++++- internal/cli/incident.go | 10 ++- skills/flashduty/reference/alert.md | 2 + skills/flashduty/reference/incident.md | 2 + 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index ee5e376..8d05b15 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -90,6 +90,9 @@ func newAlertEventListCmd() *cobra.Command { if err != nil { return err } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) } @@ -106,7 +109,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") 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.") + 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.") return cmd } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index e421397..7bcb12c 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -5,6 +5,12 @@ import ( "reflect" "sort" "strings" + "unicode/utf8" +) + +const ( + compactListOutputLimit = 16 * 1024 + compactDetailOutputLimit = 8 * 1024 ) // projectFields reduces each struct element of items to a map containing only @@ -63,6 +69,89 @@ func projectFields(items any, fields []string) ([]map[string]any, error) { return out, nil } +// boundProjectedOutput keeps the new agent-oriented projections below their +// command budget without changing the selected keys. Short values remain byte +// identical; when retained strings alone would overflow the actual JSON/TOON +// encoding, they are shortened fairly and marked with "...". If keys and +// non-string values alone exceed the budget, the command fails with a small +// error instead of emitting an oversized payload. +func boundProjectedOutput(data any, maxBytes int) error { + var rows []map[string]any + switch value := data.(type) { + case map[string]any: + rows = []map[string]any{value} + case []map[string]any: + rows = value + default: + return fmt.Errorf("internal error: unsupported projected output %T", data) + } + + encoded, err := marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + + stringCount := 0 + for _, row := range rows { + for _, value := range row { + if _, ok := value.(string); ok { + stringCount++ + } + } + } + if stringCount == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + + fieldLimit := maxBytes / stringCount + for { + for _, row := range rows { + for key, value := range row { + if text, ok := value.(string); ok { + row[key] = truncateUTF8Bytes(text, fieldLimit) + } + } + } + + encoded, err = marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + if fieldLimit == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + fieldLimit /= 2 + } +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + if maxBytes <= 0 { + return "" + } + if maxBytes <= 3 { + end := maxBytes + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + } + + end := maxBytes - 3 + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + "..." +} + // jsonTagIndex maps each exported field's json tag name (leading component, sans // `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged // fields, and embedded/anonymous fields are skipped — only declared, named, diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 84277bc..c9de19f 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" "testing" + "unicode/utf8" "github.com/spf13/cobra" ) @@ -28,6 +29,48 @@ func incidentRow() map[string]any { } } +func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = format + rows := []map[string]any{{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 2000), + }} + + if err := boundProjectedOutput(rows, 512); err != nil { + t.Fatalf("bound projected output: %v", err) + } + encoded, err := marshalStructured(rows) + if err != nil { + t.Fatalf("marshal bounded output: %v", err) + } + if len(encoded)+1 >= 512 { + t.Fatalf("bounded %s output is %d bytes, want <512", format, len(encoded)+1) + } + title := rows[0]["title"].(string) + if !utf8.ValidString(title) || !strings.HasSuffix(title, "...") { + t.Fatalf("truncated title = %q, want valid UTF-8 with marker", title) + } + }) + } +} + +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} + } + + err := boundProjectedOutput(rows, 512) + if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") { + t.Fatalf("irreducible output error = %v, want bounded guidance", err) + } +} + func alertRow() map[string]any { return map[string]any{ "alert_id": "al-1", @@ -249,7 +292,8 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) { row["close_time"] = 1712000060 row["ack_time"] = 1712000030 row["alert_cnt"] = 3 - row["root_cause"] = "disk exhaustion" + row["title"] = strings.Repeat("very long incident title ", 2000) + row["root_cause"] = strings.Repeat("disk exhaustion details ", 2000) row["score"] = 0.9 row["description"] = strings.Repeat("large description ", 100) row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} @@ -295,6 +339,9 @@ func TestIncidentDetailFieldsProjection(t *testing.T) { row := incidentRow() row["description"] = strings.Repeat("large description ", 500) row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + row["ai_summary"] = strings.Repeat("long AI summary ", 4000) + row["root_cause"] = strings.Repeat("long root cause ", 4000) + row["resolution"] = strings.Repeat("long resolution ", 4000) stub.data = row fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"} @@ -334,7 +381,7 @@ func TestAlertEventListStructuredProjection(t *testing.T) { "event_severity": "Warning", "event_status": "Triggered", "event_time": 1712000000, - "title": "CPU high", + "title": strings.Repeat("very long alert event title ", 2000), "description": strings.Repeat("large description ", 100), "labels": map[string]any{"host": "web-01"}, "images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}, diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 0ff55fb..c8985cb 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -600,6 +600,9 @@ func newIncidentSimilarCmd() *cobra.Command { if err != nil { return err } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } return ctx.Printer.Print(proj, nil) } @@ -609,7 +612,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.") + 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.") return cmd } @@ -1353,6 +1356,9 @@ func newIncidentDetailCmd() *cobra.Command { if err != nil { return err } + if err := boundProjectedOutput(proj[0], compactDetailOutputLimit); err != nil { + return err + } return ctx.Printer.Print(proj[0], nil) } return ctx.Printer.Print(result, nil) @@ -1363,7 +1369,7 @@ func newIncidentDetailCmd() *cobra.Command { }) }, } - cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Omit for full detail.") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Projected strings are truncated as needed to keep output below 8 KiB; omit --fields for full detail.") return cmd } diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 93bc424..fb9f68c 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -38,6 +38,8 @@ 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. + ## Hot flow — merge noisy alerts into an existing incident ```bash diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index d26c7ef..97d4b04 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -63,6 +63,8 @@ fduty incident comment --comment "Root cause identified: DB failov fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` +Projected `similar` lists stay below 16 KiB, and projected `detail --fields` output stays below 8 KiB. A trailing `...` means a long retained string was shortened; omit `--fields` only when the full unbounded detail is explicitly required. + ## Hot flow — full fault analysis (read-only summary) When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all six aspects: