Skip to content

Commit 7acc3f3

Browse files
authored
Merge pull request #99 from flashcatcloud/feat/ai-sre
chore: sync feat/ai-sre back into main
2 parents 923a4f8 + 512aefe commit 7acc3f3

13 files changed

Lines changed: 935 additions & 22 deletions

internal/cli/alert_event.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command {
2121
}
2222

2323
func newAlertEventListCmd() *cobra.Command {
24-
var severity, channel, integrationType, since, until string
24+
var severity, channel, integrationType, since, until, fields string
2525
var limit, page int
2626

2727
cmd := &cobra.Command{
2828
Use: "list",
2929
Short: "List alert events globally",
30-
Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"),
30+
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"),
3131
RunE: func(cmd *cobra.Command, args []string) error {
3232
return runCommand(cmd, args, func(ctx *RunContext) error {
33+
if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 {
34+
return fmt.Errorf("--fields must name at least one field")
35+
}
36+
3337
startTime, err := timeutil.Parse(since)
3438
if err != nil {
3539
return fmt.Errorf("invalid --since: %w", err)
@@ -77,6 +81,21 @@ func newAlertEventListCmd() *cobra.Command {
7781
{Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }},
7882
}
7983

84+
if ctx.Structured() {
85+
fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
86+
if fields != "" {
87+
fieldNames = parseStringSlice(fields)
88+
}
89+
proj, err := projectFields(result.Items, fieldNames)
90+
if err != nil {
91+
return err
92+
}
93+
if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil {
94+
return err
95+
}
96+
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
97+
}
98+
8099
return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
81100
})
82101
},
@@ -90,6 +109,7 @@ func newAlertEventListCmd() *cobra.Command {
90109
cmd.Flags().StringVar(&until, "until", "now", "End time")
91110
cmd.Flags().IntVar(&limit, "limit", 20, "Max results")
92111
cmd.Flags().IntVar(&page, "page", 1, "Page number")
112+
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.")
93113

94114
return cmd
95115
}

internal/cli/fieldproject.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import (
55
"reflect"
66
"sort"
77
"strings"
8+
"unicode/utf8"
9+
)
10+
11+
const (
12+
compactListOutputLimit = 16 * 1024
13+
compactDetailOutputLimit = 8 * 1024
814
)
915

1016
// 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) {
6369
return out, nil
6470
}
6571

72+
// boundProjectedOutput keeps the new agent-oriented projections below their
73+
// command budget without changing the selected keys. Short values remain byte
74+
// identical; when retained strings alone would overflow the actual JSON/TOON
75+
// encoding, they are shortened fairly and marked with "...". If keys and
76+
// non-string values alone exceed the budget, the command fails with a small
77+
// error instead of emitting an oversized payload.
78+
func boundProjectedOutput(data any, maxBytes int) error {
79+
var rows []map[string]any
80+
switch value := data.(type) {
81+
case map[string]any:
82+
rows = []map[string]any{value}
83+
case []map[string]any:
84+
rows = value
85+
default:
86+
return fmt.Errorf("internal error: unsupported projected output %T", data)
87+
}
88+
89+
encoded, err := marshalStructured(data)
90+
if err != nil {
91+
return err
92+
}
93+
if len(encoded)+1 < maxBytes {
94+
return nil
95+
}
96+
97+
stringCount := 0
98+
for _, row := range rows {
99+
for _, value := range row {
100+
if _, ok := value.(string); ok {
101+
stringCount++
102+
}
103+
}
104+
}
105+
if stringCount == 0 {
106+
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
107+
}
108+
109+
fieldLimit := maxBytes / stringCount
110+
for {
111+
for _, row := range rows {
112+
for key, value := range row {
113+
if text, ok := value.(string); ok {
114+
row[key] = truncateUTF8Bytes(text, fieldLimit)
115+
}
116+
}
117+
}
118+
119+
encoded, err = marshalStructured(data)
120+
if err != nil {
121+
return err
122+
}
123+
if len(encoded)+1 < maxBytes {
124+
return nil
125+
}
126+
if fieldLimit == 0 {
127+
return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes)
128+
}
129+
fieldLimit /= 2
130+
}
131+
}
132+
133+
func truncateUTF8Bytes(value string, maxBytes int) string {
134+
if len(value) <= maxBytes {
135+
return value
136+
}
137+
if maxBytes <= 0 {
138+
return ""
139+
}
140+
if maxBytes <= 3 {
141+
end := maxBytes
142+
for end > 0 && !utf8.ValidString(value[:end]) {
143+
end--
144+
}
145+
return value[:end]
146+
}
147+
148+
end := maxBytes - 3
149+
for end > 0 && !utf8.ValidString(value[:end]) {
150+
end--
151+
}
152+
return value[:end] + "..."
153+
}
154+
66155
// jsonTagIndex maps each exported field's json tag name (leading component, sans
67156
// `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged
68157
// fields, and embedded/anonymous fields are skipped — only declared, named,

0 commit comments

Comments
 (0)