From efa295cca4f0e224a08202f2df10609d7b5d2c42 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 2 Jun 2026 12:43:01 +0800 Subject: [PATCH 1/2] feat(session): add session list + export commands for AI SRE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `flashduty session list` and `flashduty session export `, the CLI surface for the AI SRE session APIs (consumed by the /insight skill). - session list: curated command over Sessions.List. --app (default ai-sre), --limit (default 200), --scope, --status, --team-id, and a client-side --since window (the API has no time filter, so rows are filtered by updated_at after fetching). Output --format jsonl (default, one SessionItem per line for jq) / json / toon. - session export: streams the NDJSON transcript straight to stdout via the SDK's hand-written streaming method, line-by-line, so a large transcript never buffers in memory — redirect to a file. session/export is a streaming op (200 is application/x-ndjson), which the generated typed-response template cannot model. cligen now skips streaming ops (mirroring go-flashduty's own generator) and the curated `safari session-export` leaf keeps it reachable at its path-name alongside the generated safari session-get / session-list. The coverage tests account for streaming ops being curated-only. Pins go-flashduty to the commit that adds the Sessions service. --- go.mod | 2 +- go.sum | 4 +- internal/cli/coverage_test.go | 69 ++++- internal/cli/root.go | 8 + internal/cli/session.go | 238 +++++++++++++++++ internal/cli/session_test.go | 225 ++++++++++++++++ internal/cli/zz_generated_manifest.go | 2 + internal/cli/zz_generated_register.go | 1 + internal/cli/zz_generated_response_help.go | 2 + internal/cli/zz_generated_sessions.go | 287 +++++++++++++++++++++ internal/cmd/cligen/main.go | 28 +- 11 files changed, 851 insertions(+), 15 deletions(-) create mode 100644 internal/cli/session.go create mode 100644 internal/cli/session_test.go create mode 100644 internal/cli/zz_generated_sessions.go diff --git a/go.mod b/go.mod index f1508f0..7d6966e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2 + github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index aaf70ea..5bfb69c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2 h1:Tr563N4JAbclxnC9dWmwyPC39SCc/bifW0eVvCcnSyk= -github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 h1:ZW8Y7p6JYh+M+saQPq0ScVqRTsxFCrGV59K9TuLxHRA= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index c9dc212..0abce8c 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -11,10 +11,20 @@ import ( "github.com/spf13/cobra" ) -// loadSpecPaths reads every GET/POST operation from the openapi spec shipped in -// the linked go-flashduty module — the same spec cligen generates against — -// returning operationId -> path. -func loadSpecPaths(t *testing.T) map[string]string { +// specOpMeta is the slice of an operation the coverage tests reason about. +type specOpMeta struct { + id string + path string + streaming bool // 200 body is not application/json (e.g. application/x-ndjson) +} + +// loadSpecOps reads every public GET/POST operation from the openapi spec +// shipped in the linked go-flashduty module — the same spec cligen generates +// against — recording each op's id, path, and whether its 200 response is a +// non-JSON streaming body. Streaming ops are served by curated commands (the +// generated typed-response template cannot model an io.ReadCloser), so the +// generator-coverage check excludes them. +func loadSpecOps(t *testing.T) []specOpMeta { t.Helper() out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/flashcatcloud/go-flashduty").Output() if err != nil { @@ -29,12 +39,15 @@ func loadSpecPaths(t *testing.T) map[string]string { Paths map[string]map[string]struct { OperationID string `json:"operationId"` Tags []string `json:"tags"` + Responses map[string]struct { + Content map[string]json.RawMessage `json:"content"` + } `json:"responses"` } `json:"paths"` } if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("parse spec: %v", err) } - ids := map[string]string{} + var ops []specOpMeta for path, methods := range spec.Paths { for verb, op := range methods { v := strings.ToUpper(verb) @@ -44,9 +57,25 @@ func loadSpecPaths(t *testing.T) map[string]string { if op.OperationID == "" || len(op.Tags) == 0 { continue } - ids[op.OperationID] = path + streaming := false + if resp, ok := op.Responses["200"]; ok && len(resp.Content) > 0 { + if _, hasJSON := resp.Content["application/json"]; !hasJSON { + streaming = true + } + } + ops = append(ops, specOpMeta{id: op.OperationID, path: path, streaming: streaming}) } } + return ops +} + +// loadSpecPaths returns operationId -> path for every public GET/POST operation. +func loadSpecPaths(t *testing.T) map[string]string { + t.Helper() + ids := map[string]string{} + for _, op := range loadSpecOps(t) { + ids[op.id] = op.path + } return ids } @@ -110,20 +139,38 @@ func TestEveryOperationHasPathCommand(t *testing.T) { } // TestGeneratorTargetsFullSpec asserts the generator emitted a command for every -// spec operation (no gaps, no phantom manifest entries from a stale run). +// non-streaming spec operation (no gaps, no phantom manifest entries from a +// stale run). Streaming ops (200 body is not application/json) are deliberately +// excluded from generation — they cannot be modeled by the typed-response +// template and are served by curated commands instead — so the manifest must NOT +// contain them and they are not required to be generated. func TestGeneratorTargetsFullSpec(t *testing.T) { - specPaths := loadSpecPaths(t) + ops := loadSpecOps(t) + streaming := map[string]bool{} + wantGenerated := map[string]bool{} + for _, op := range ops { + if op.streaming { + streaming[op.id] = true + continue + } + wantGenerated[op.id] = true + } + gen := map[string]bool{} for _, id := range generatedOpIDs { gen[id] = true - if _, ok := specPaths[id]; !ok { + if streaming[id] { + t.Errorf("manifest op %q is streaming and must not be generated (curated only)", id) + } + if !wantGenerated[id] && !streaming[id] { t.Errorf("manifest op %q is not in the current spec (regenerate cligen)", id) } } - for id := range specPaths { + for id := range wantGenerated { if !gen[id] { t.Errorf("op %q has no generated command (regenerate cligen)", id) } } - t.Logf("generator targets %d/%d spec operations", len(gen), len(specPaths)) + t.Logf("generator targets %d/%d non-streaming spec operations (%d streaming, curated)", + len(gen), len(wantGenerated), len(streaming)) } diff --git a/internal/cli/root.go b/internal/cli/root.go index 260155d..eb97646 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -100,6 +100,9 @@ func init() { rootCmd.AddCommand(newWhoamiCmd()) rootCmd.AddCommand(newUpdateCmd()) + // AI agent sessions (list + transcript export). + rootCmd.AddCommand(newSessionCmd()) + // Diagnostics entry points (value-add over the raw API). rootCmd.AddCommand(newMonitQueryCmd()) rootCmd.AddCommand(newMonitAgentCmd()) @@ -107,6 +110,11 @@ func init() { // Generated commands (full OpenAPI coverage). Registered AFTER curated // commands so curated leaves win on any name conflict (see genAddLeaf). registerGenerated(rootCmd) + + // session/export is a streaming op excluded from the generated tree; attach + // its path-is-king leaf to the (now-existing) generated `safari` group so the + // operation stays reachable at safari session-export. + attachSafariSessionExport(rootCmd) } // Execute runs the root command. diff --git a/internal/cli/session.go b/internal/cli/session.go new file mode 100644 index 0000000..5c88cbe --- /dev/null +++ b/internal/cli/session.go @@ -0,0 +1,238 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/flashcatcloud/go-flashduty" + "github.com/spf13/cobra" + toon "github.com/toon-format/toon-go" + + "github.com/flashcatcloud/flashduty-cli/internal/timeutil" +) + +func newSessionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "session", + Short: "Inspect AI agent sessions", + Long: "Inspect AI agent sessions (AI SRE and other Flashduty agents).\n\n" + + "'session list' enumerates sessions visible to the caller; 'session export' streams\n" + + "one session's full event transcript as newline-delimited JSON for offline analysis.", + } + cmd.AddCommand(newSessionListCmd()) + cmd.AddCommand(newSessionExportCmd()) + return cmd +} + +// sessionListFormats are the output shapes 'session list' supports. jsonl (one +// SessionItem JSON object per line) is the default because the rows feed +// line-oriented downstream tooling (the /insight skill streams them through jq); +// json emits the whole SessionListResponse envelope; toon is the compact, +// fewer-tokens encoding. +const ( + sessionFormatJSONL = "jsonl" + sessionFormatJSON = "json" + sessionFormatTOON = "toon" +) + +func newSessionListCmd() *cobra.Command { + var ( + app string + scope string + status string + since string + format string + teamID int64 + limit int + page int + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List agent sessions", + Long: curatedLong( + "List agent sessions visible to the caller, newest first. Reads are scoped to the "+ + "person the app_key resolves to within its account.\n\n"+ + "--app selects the agent app (default ai-sre). The API has no time-window filter, so "+ + "--since (e.g. 30d, 24h, 2026-05-01) is applied CLIENT-SIDE against each session's "+ + "updated_at after fetching. --team-id restricts to one team (sets team_ids); --scope "+ + "chooses the visibility bucket (all = own + member-teams, the default). Output is "+ + "newline-delimited JSON (jsonl) by default so rows pipe straight into jq; use "+ + "--format json for the full envelope or --format toon for the compact encoding.", + "Sessions", "List"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + format = strings.ToLower(strings.TrimSpace(format)) + switch format { + case sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON: + default: + return fmt.Errorf("invalid --format %q (want jsonl, json, or toon)", format) + } + + var sinceUnix int64 + if since != "" { + ts, err := timeutil.Parse(since) + if err != nil { + return fmt.Errorf("invalid --since: %w", err) + } + sinceUnix = ts + } + + req := &flashduty.SessionListRequest{ + AppName: app, + Scope: scope, + Status: status, + Orderby: "updated_at", + } + req.Limit = limit + req.Page = page + if teamID > 0 { + req.TeamIDs = []int64{teamID} + } + + resp, _, err := ctx.Client.Sessions.List(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + + sessions := resp.Sessions + if sinceUnix > 0 { + sessions = filterSessionsSince(sessions, sinceUnix) + } + + return writeSessionList(ctx.Writer, format, sessions, resp.Total) + }) + }, + } + + cmd.Flags().StringVar(&app, "app", "ai-sre", "Agent app to list sessions for") + cmd.Flags().StringVar(&scope, "scope", "", "Visibility scope: all (own + member-teams, default), personal, or team") + registerEnumFlag(cmd, "scope", "all", "personal", "team") + cmd.Flags().StringVar(&status, "status", "", "Archive bucket: active (default), archived, or all") + registerEnumFlag(cmd, "status", "active", "archived", "all") + cmd.Flags().StringVar(&since, "since", "", "Keep only sessions updated within this window (client-side), e.g. 30d, 24h, 2026-05-01") + cmd.Flags().Int64Var(&teamID, "team-id", 0, "Restrict to one team ID") + cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch (server caps at 100/page)") + cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&format, "format", sessionFormatJSONL, "Output format: jsonl (default), json, or toon") + registerEnumFlag(cmd, "format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON) + + return cmd +} + +// filterSessionsSince keeps sessions whose updated_at is at or after sinceUnix +// (unix seconds). The API exposes no time-window filter, so this is the only +// place a --since window is honored. +func filterSessionsSince(sessions []flashduty.SessionItem, sinceUnix int64) []flashduty.SessionItem { + kept := make([]flashduty.SessionItem, 0, len(sessions)) + for _, s := range sessions { + if s.UpdatedAt.Time().Unix() >= sinceUnix { + kept = append(kept, s) + } + } + return kept +} + +// writeSessionList renders the session rows in the requested format. jsonl emits +// one SessionItem per line; json emits the whole SessionListResponse envelope; +// toon emits the compact encoding of that envelope. +func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionItem, total int64) error { + switch format { + case sessionFormatJSONL: + enc := json.NewEncoder(w) + for i := range sessions { + if err := enc.Encode(sessions[i]); err != nil { + return fmt.Errorf("failed to encode session: %w", err) + } + } + return nil + default: + envelope := flashduty.SessionListResponse{Sessions: sessions, Total: total} + var ( + out []byte + err error + ) + if format == sessionFormatTOON { + out, err = toon.Marshal(envelope) + } else { + out, err = json.MarshalIndent(envelope, "", " ") + } + if err != nil { + return fmt.Errorf("failed to marshal sessions: %w", err) + } + _, _ = fmt.Fprintln(w, string(out)) + return nil + } +} + +// newSessionExportCmd builds the friendly `session export ` command. +func newSessionExportCmd() *cobra.Command { + return buildSessionExportCmd("export ") +} + +// newSafariSessionExportCmd builds the path-is-king `safari session-export ` +// command. session/export is a streaming op, so it is excluded from the +// generated tree (which cannot model an io.ReadCloser response); this curated +// leaf keeps the operation reachable at its mechanical path-name alongside the +// generated safari session-get / session-list. +func newSafariSessionExportCmd() *cobra.Command { + return buildSessionExportCmd("session-export ") +} + +// buildSessionExportCmd constructs an export command with the given Use line. +// Both the friendly and path-is-king commands share this one implementation so +// the streaming behavior is defined once. +func buildSessionExportCmd(use string) *cobra.Command { + var includeSubagents bool + + cmd := &cobra.Command{ + Use: use, + Short: "Stream a session's full event transcript as NDJSON", + Long: "Stream one session's full event transcript as newline-delimited JSON (NDJSON) to stdout.\n\n" + + "The first line is always a session_meta envelope; each subsequent line is one event\n" + + "(user_message, llm_call, tool_call, subagent_dispatch, final_answer, agent_text, error).\n" + + "With --include-subagents, each subagent_dispatch line is followed by the child session's\n" + + "own stream. The transcript can be large, so redirect it to a file rather than reading it\n" + + "into a terminal:\n\n" + + " flashduty session export > session.ndjson\n", + Args: requireArgs("session_id"), + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + rc, _, err := ctx.Client.Sessions.Export(cmdContext(ctx.Cmd), &flashduty.SessionExportRequest{ + SessionID: ctx.Args[0], + IncludeSubagents: includeSubagents, + }) + if err != nil { + return err + } + defer func() { _ = rc.Close() }() + + // Stream the NDJSON straight through to the writer without + // buffering the whole transcript: copy line-by-line so a huge + // export never lands in memory or the agent's context. + sc := flashduty.NewExportScanner(rc) + for sc.Scan() { + if _, err := fmt.Fprintln(ctx.Writer, sc.Text()); err != nil { + return err + } + } + return sc.Err() + }) + }, + } + + cmd.Flags().BoolVar(&includeSubagents, "include-subagents", false, "Inline each dispatched subagent's own event stream") + + return cmd +} + +// attachSafariSessionExport adds the path-is-king `safari session-export` leaf to +// the generated `safari` group. It must run AFTER registerGenerated so the group +// exists; genGroup find-or-creates it and genAddLeaf is a no-op if a same-named +// command is already present. +func attachSafariSessionExport(root *cobra.Command) { + safari := genGroup(root, "safari", "AI SRE API") + genAddLeaf(safari, newSafariSessionExportCmd()) +} diff --git a/internal/cli/session_test.go b/internal/cli/session_test.go new file mode 100644 index 0000000..e3069b6 --- /dev/null +++ b/internal/cli/session_test.go @@ -0,0 +1,225 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/flashcatcloud/go-flashduty" +) + +// TestSessionListFlags asserts the curated flag surface: --app defaults to +// ai-sre, --limit to 200, and --format to jsonl. These defaults are what makes +// `session list` pipe cleanly into the /insight skill without extra flags. +func TestSessionListFlags(t *testing.T) { + cmd := newSessionListCmd() + flags := cmd.Flags() + + cases := []struct { + name string + def string + }{ + {"app", "ai-sre"}, + {"limit", "200"}, + {"format", sessionFormatJSONL}, + } + for _, c := range cases { + f := flags.Lookup(c.name) + if f == nil { + t.Fatalf("flag --%s not registered", c.name) + } + if f.DefValue != c.def { + t.Errorf("--%s default = %q, want %q", c.name, f.DefValue, c.def) + } + } + + if f := flags.Lookup("team-id"); f == nil || f.Value.Type() != "int64" { + t.Errorf("--team-id must be an int64 flag, got %v", f) + } +} + +// TestCommandSessionListJSONL drives `session list` against the stub: it must hit +// /safari/session/list, forward app_name + team_ids + scope, and emit one JSON +// object per session line (jsonl) so downstream tooling can stream the rows. +func TestCommandSessionListJSONL(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "sessions": []map[string]any{ + {"session_id": "sess-1", "session_name": "disk full", "app_name": "ai-sre", "updated_at": 1779432894000}, + {"session_id": "sess-2", "session_name": "oom kill", "app_name": "ai-sre", "updated_at": 1779432895000}, + }, + "total": 2, + } + + out, err := execCommand("session", "list", "--app", "ai-sre", "--team-id", "42", "--scope", "all", "--limit", "50") + if err != nil { + t.Fatalf("[session-list] unexpected error: %v", err) + } + if stub.lastPath != "/safari/session/list" { + t.Fatalf("[session-list] expected /safari/session/list, got %q", stub.lastPath) + } + if stub.lastBody["app_name"] != "ai-sre" { + t.Errorf("[session-list] app_name = %v, want ai-sre", stub.lastBody["app_name"]) + } + if stub.lastBody["scope"] != "all" { + t.Errorf("[session-list] scope = %v, want all", stub.lastBody["scope"]) + } + teamIDs, ok := stub.lastBody["team_ids"].([]any) + if !ok || len(teamIDs) != 1 || fmt.Sprintf("%v", teamIDs[0]) != "42" { + t.Errorf("[session-list] team_ids = %v, want [42]", stub.lastBody["team_ids"]) + } + + // jsonl: exactly one JSON object per session, no envelope. + lines := nonEmptyLines(out) + if len(lines) != 2 { + t.Fatalf("[session-list] expected 2 jsonl lines, got %d:\n%s", len(lines), out) + } + var first flashduty.SessionItem + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("[session-list] line 0 is not a SessionItem: %v", err) + } + if first.SessionID != "sess-1" { + t.Errorf("[session-list] first session = %q, want sess-1", first.SessionID) + } +} + +// TestCommandSessionListSinceFiltersClientSide proves --since drops rows older +// than the window using the response's updated_at (the API has no time filter). +func TestCommandSessionListSinceFiltersClientSide(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + recent := time.Now().Add(-1 * time.Hour).UnixMilli() + old := time.Now().Add(-72 * time.Hour).UnixMilli() + stub.data = map[string]any{ + "sessions": []map[string]any{ + {"session_id": "fresh", "app_name": "ai-sre", "updated_at": recent}, + {"session_id": "stale", "app_name": "ai-sre", "updated_at": old}, + }, + "total": 2, + } + + out, err := execCommand("session", "list", "--since", "24h") + if err != nil { + t.Fatalf("[session-since] unexpected error: %v", err) + } + lines := nonEmptyLines(out) + if len(lines) != 1 { + t.Fatalf("[session-since] expected 1 row after --since 24h, got %d:\n%s", len(lines), out) + } + if !strings.Contains(lines[0], "fresh") { + t.Errorf("[session-since] expected the fresh session, got: %s", lines[0]) + } +} + +// TestCommandSessionListRejectsBadFormat fails fast on an unknown --format. +func TestCommandSessionListRejectsBadFormat(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + + _, err := execCommand("session", "list", "--format", "yaml") + if err == nil || !strings.Contains(err.Error(), "invalid --format") { + t.Fatalf("expected an invalid --format error, got %v", err) + } +} + +// TestCommandSessionExportStreamsNDJSON drives `session export` against a stub +// that serves application/x-ndjson. The command must pass session_id + +// include_subagents and write the stream verbatim to stdout, line 1 being a +// session_meta envelope (jq-parseable). +func TestCommandSessionExportStreamsNDJSON(t *testing.T) { + saveAndResetGlobals(t) + + var gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Body != nil { + sc := bufio.NewScanner(r.Body) + if sc.Scan() { + gotBody = sc.Text() + } + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintln(w, `{"type":"session_meta","session_id":"sess-1","app_name":"ai-sre"}`) + _, _ = fmt.Fprintln(w, `{"type":"user_message","seq":1,"content":"disk is full"}`) + _, _ = fmt.Fprintln(w, `{"type":"tool_call","seq":2,"name":"bash","status":"ok"}`) + _, _ = fmt.Fprintln(w, `{"type":"final_answer","seq":3,"content":"freed 20G"}`) + })) + t.Cleanup(srv.Close) + + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(srv.URL)) + } + + out, err := execCommand("session", "export", "sess-1", "--include-subagents") + if err != nil { + t.Fatalf("[session-export] unexpected error: %v", err) + } + if gotPath != "/safari/session/export" { + t.Fatalf("[session-export] path = %q, want /safari/session/export", gotPath) + } + if !strings.Contains(gotBody, `"session_id":"sess-1"`) || !strings.Contains(gotBody, `"include_subagents":true`) { + t.Errorf("[session-export] request body = %s, want session_id + include_subagents", gotBody) + } + + lines := nonEmptyLines(out) + if len(lines) != 4 { + t.Fatalf("[session-export] expected 4 NDJSON lines, got %d:\n%s", len(lines), out) + } + // Line 1 must be a parseable session_meta envelope. + var meta struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal([]byte(lines[0]), &meta); err != nil { + t.Fatalf("[session-export] line 1 is not valid JSON: %v", err) + } + if meta.Type != "session_meta" || meta.SessionID != "sess-1" { + t.Errorf("[session-export] line 1 = %+v, want session_meta/sess-1", meta) + } +} + +// TestCommandSessionExportMapsErrorEnvelope confirms a non-2xx JSON error +// envelope on the streaming endpoint surfaces as a CLI error, not a partial +// stream. +func TestCommandSessionExportMapsErrorEnvelope(t *testing.T) { + saveAndResetGlobals(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "req-1", + "error": map[string]any{"code": "access_denied", "message": "not your session"}, + }) + })) + t.Cleanup(srv.Close) + + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(srv.URL)) + } + + out, err := execCommand("session", "export", "sess-x") + if err == nil { + t.Fatalf("[session-export-err] expected an error, got output: %s", out) + } + if !strings.Contains(err.Error(), "access_denied") && !strings.Contains(err.Error(), "not your session") { + t.Errorf("[session-export-err] error = %v, want the access_denied envelope", err) + } +} + +func nonEmptyLines(s string) []string { + var out []string + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} diff --git a/internal/cli/zz_generated_manifest.go b/internal/cli/zz_generated_manifest.go index 63534b1..384797f 100644 --- a/internal/cli/zz_generated_manifest.go +++ b/internal/cli/zz_generated_manifest.go @@ -214,6 +214,8 @@ var generatedOpIDs = []string{ "schedulePreview", "scheduleSelf", "scheduleUpdate", + "session-read-info", + "session-read-list", "skill-read-download", "skill-read-enable", "skill-read-get", diff --git a/internal/cli/zz_generated_register.go b/internal/cli/zz_generated_register.go index 29496e6..8fb8a7d 100644 --- a/internal/cli/zz_generated_register.go +++ b/internal/cli/zz_generated_register.go @@ -9,6 +9,7 @@ import "github.com/spf13/cobra" func registerGenerated(root *cobra.Command) { registerGeneratedA2aAgents(root) registerGeneratedMcpServers(root) + registerGeneratedSessions(root) registerGeneratedSkills(root) registerGeneratedAlertRules(root) registerGeneratedDataSources(root) diff --git a/internal/cli/zz_generated_response_help.go b/internal/cli/zz_generated_response_help.go index 549d75e..130bea0 100644 --- a/internal/cli/zz_generated_response_help.go +++ b/internal/cli/zz_generated_response_help.go @@ -134,6 +134,8 @@ var responseHelpBySDKMethod = map[string]string{ "Schedules.List": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - cur_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - description (any) (required) — Schedule description. null when returned from /schedule/preview.\n - disabled (any) (required) — Disabled flag (0 = enabled, 1 = disabled). Deprecated. null when returned from /schedule/preview.\n - end (integer) — Window end (Unix seconds).\n - field (string) — Field name used by the legacy update-field endpoint.\n - final_schedule (object) (required) — Computed schedule for a single layer.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - group_id (any) (required) — Legacy team/group ID. null when returned from /schedule/preview.\n - id (any) (required) — Schedule ID. null when returned from /schedule/preview.\n - layer_schedules (array) (required) — Alias of schedule_layers returned for compatibility.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - layers (array) (required) — Rotation layers defined on the schedule.\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - day_mask (object) (required) — Day-of-week mask for a rotation layer.\n - repeat (array) — Weekday numbers (0 = Sunday) included in the rotation.\n - enable_time (integer) (required) — When the layer becomes effective (Unix seconds).\n - expire_time (integer) (required) — When the layer expires (Unix seconds, 0 means never).\n - fair_rotation (boolean) (required) — Whether fair rotation is enabled.\n - groups (array) (required) — Oncall groups participating in the rotation.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - handoff_time (integer) (required) — Handoff time inside the rotation cycle (seconds).\n - hidden (integer) (required) — Whether the layer is hidden in the UI (0 = no, 1 = yes).\n - layer_end (any) — Layer end timestamp (Unix seconds). null means open-ended.\n - layer_name (string) — User-facing layer name.\n - layer_start (integer) — Layer start timestamp (Unix seconds).\n - mask_continuous_enabled (boolean) (required) — Whether continuous masking is enabled.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - restrict_end (integer) (required) — Legacy end offset inside the restriction window (seconds).\n - restrict_mode (integer) (required) — Restriction mode: 0 = none, 1 = day, 2 = week.\n - restrict_periods (array) (required) — Restriction windows inside each rotation cycle.\n - restrict_end (integer) (required) — End offset inside the rotation cycle.\n - restrict_start (integer) (required) — Start offset inside the rotation cycle.\n - restrict_start (integer) (required) — Legacy start offset inside the restriction window (seconds).\n - rotation_duration (integer) (required) — Rotation duration in seconds.\n - rotation_unit (string) (required) — Rotation unit. [hour, day, week, month]\n - rotation_value (integer) (required) — Rotation quantity (number of rotation_unit per cycle).\n - schedule_id (integer) (required) — Parent schedule ID.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n - weight (integer) (required) — Layer weight for ordering.\n - name (any) (required) — Schedule name (legacy field; mirrors schedule_name). null when returned from /schedule/preview.\n - next_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - notify (object) (required) — Notification configuration attached to a schedule.\n - advance_in_time (integer) — Advance notification lead time (seconds).\n - by (object) (required) — Per-recipient notification preference.\n - follow_preference (boolean) (required) — Whether to follow each responder's personal notification preference.\n - personal_channels (array) (required) — Personal notification channel keys.\n - fixed_time (object) (required) — Fixed-time notification config.\n - cycle (string) (required) — Notification cycle.\n - start (string) (required) — Notification start time within the cycle.\n - im (object) — Legacy IM-type to token map.\n - webhooks (array) (required) — IM webhook notification channels.\n - settings (object) (required) — Settings for an IM webhook notification channel.\n - type (string) (required) — IM provider type (for example feishu_app, dingtalk_app, wecom_app, teams_app, slack_app).\n - schedule_id (integer) (required) — Schedule ID.\n - schedule_layers (array) (required) — Computed layers for the requested window.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - schedule_name (any) (required) — Schedule display name. null when returned from /schedule/preview.\n - start (integer) — Window start (Unix seconds).\n - status (any) (required) — Legacy status flag. Deprecated. null when returned from /schedule/preview.\n - team_id (any) (required) — Owning team ID. null when returned from /schedule/preview.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n", "Schedules.Preview": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - cur_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - person_ids (array) (required) — Person IDs in this slot.\n - role_id (integer) (required) — Oncall role ID.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - description (any) (required) — Schedule description. null when returned from /schedule/preview.\n - disabled (any) (required) — Disabled flag (0 = enabled, 1 = disabled). Deprecated. null when returned from /schedule/preview.\n - end (integer) — Window end (Unix seconds).\n - field (string) — Field name used by the legacy update-field endpoint.\n - final_schedule (object) (required) — Computed schedule for a single layer.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - group_id (any) (required) — Legacy team/group ID. null when returned from /schedule/preview.\n - id (any) (required) — Schedule ID. null when returned from /schedule/preview.\n - layer_schedules (array) (required) — Alias of schedule_layers returned for compatibility.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - layers (array) (required) — Rotation layers defined on the schedule.\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - day_mask (object) (required) — Day-of-week mask for a rotation layer.\n - repeat (array) — Weekday numbers (0 = Sunday) included in the rotation.\n - enable_time (integer) (required) — When the layer becomes effective (Unix seconds).\n - expire_time (integer) (required) — When the layer expires (Unix seconds, 0 means never).\n - fair_rotation (boolean) (required) — Whether fair rotation is enabled.\n - groups (array) (required) — Oncall groups participating in the rotation.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - person_ids (array) (required) — Person IDs in this slot.\n - role_id (integer) (required) — Oncall role ID.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - handoff_time (integer) (required) — Handoff time inside the rotation cycle (seconds).\n - hidden (integer) (required) — Whether the layer is hidden in the UI (0 = no, 1 = yes).\n - layer_end (any) — Layer end timestamp (Unix seconds). null means open-ended.\n - layer_name (string) — User-facing layer name.\n - layer_start (integer) — Layer start timestamp (Unix seconds).\n - mask_continuous_enabled (boolean) (required) — Whether continuous masking is enabled.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - restrict_end (integer) (required) — Legacy end offset inside the restriction window (seconds).\n - restrict_mode (integer) (required) — Restriction mode: 0 = none, 1 = day, 2 = week.\n - restrict_periods (array) (required) — Restriction windows inside each rotation cycle.\n - restrict_end (integer) (required) — End offset inside the rotation cycle.\n - restrict_start (integer) (required) — Start offset inside the rotation cycle.\n - restrict_start (integer) (required) — Legacy start offset inside the restriction window (seconds).\n - rotation_duration (integer) (required) — Rotation duration in seconds.\n - rotation_unit (string) (required) — Rotation unit. [hour, day, week, month]\n - rotation_value (integer) (required) — Rotation quantity (number of rotation_unit per cycle).\n - schedule_id (integer) (required) — Parent schedule ID.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n - weight (integer) (required) — Layer weight for ordering.\n - name (any) (required) — Schedule name (legacy field; mirrors schedule_name). null when returned from /schedule/preview.\n - next_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - person_ids (array) (required) — Person IDs in this slot.\n - role_id (integer) (required) — Oncall role ID.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - notify (object) (required) — Notification configuration attached to a schedule.\n - advance_in_time (integer) — Advance notification lead time (seconds).\n - by (object) (required) — Per-recipient notification preference.\n - follow_preference (boolean) (required) — Whether to follow each responder's personal notification preference.\n - personal_channels (array) (required) — Personal notification channel keys.\n - fixed_time (object) (required) — Fixed-time notification config.\n - cycle (string) (required) — Notification cycle.\n - start (string) (required) — Notification start time within the cycle.\n - im (object) — Legacy IM-type to token map.\n - webhooks (array) (required) — IM webhook notification channels.\n - settings (object) (required) — Settings for an IM webhook notification channel.\n - alias (string) (required) — Channel alias.\n - chat_ids (array) (required) — Chat IDs.\n - data_source_id (integer) (required) — Data source ID.\n - sign_secret (string) (required) — Signature secret.\n - token (string) (required) — Webhook token.\n - verify_token (string) (required) — Verification token.\n - type (string) (required) — IM provider type (for example feishu_app, dingtalk_app, wecom_app, teams_app, slack_app).\n - schedule_id (integer) (required) — Schedule ID.\n - schedule_layers (array) (required) — Computed layers for the requested window.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - schedule_name (any) (required) — Schedule display name. null when returned from /schedule/preview.\n - start (integer) — Window start (Unix seconds).\n - status (any) (required) — Legacy status flag. Deprecated. null when returned from /schedule/preview.\n - team_id (any) (required) — Owning team ID. null when returned from /schedule/preview.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n", "Schedules.Self": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - cur_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - description (any) (required) — Schedule description. null when returned from /schedule/preview.\n - disabled (any) (required) — Disabled flag (0 = enabled, 1 = disabled). Deprecated. null when returned from /schedule/preview.\n - end (integer) — Window end (Unix seconds).\n - field (string) — Field name used by the legacy update-field endpoint.\n - final_schedule (object) (required) — Computed schedule for a single layer.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - group_id (any) (required) — Legacy team/group ID. null when returned from /schedule/preview.\n - id (any) (required) — Schedule ID. null when returned from /schedule/preview.\n - layer_schedules (array) (required) — Alias of schedule_layers returned for compatibility.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - layers (array) (required) — Rotation layers defined on the schedule.\n - account_id (integer) (required) — Account ID.\n - create_at (integer) (required) — Creation timestamp (Unix seconds).\n - create_by (integer) (required) — Creator person ID.\n - day_mask (object) (required) — Day-of-week mask for a rotation layer.\n - repeat (array) — Weekday numbers (0 = Sunday) included in the rotation.\n - enable_time (integer) (required) — When the layer becomes effective (Unix seconds).\n - expire_time (integer) (required) — When the layer expires (Unix seconds, 0 means never).\n - fair_rotation (boolean) (required) — Whether fair rotation is enabled.\n - groups (array) (required) — Oncall groups participating in the rotation.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - handoff_time (integer) (required) — Handoff time inside the rotation cycle (seconds).\n - hidden (integer) (required) — Whether the layer is hidden in the UI (0 = no, 1 = yes).\n - layer_end (any) — Layer end timestamp (Unix seconds). null means open-ended.\n - layer_name (string) — User-facing layer name.\n - layer_start (integer) — Layer start timestamp (Unix seconds).\n - mask_continuous_enabled (boolean) (required) — Whether continuous masking is enabled.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - restrict_end (integer) (required) — Legacy end offset inside the restriction window (seconds).\n - restrict_mode (integer) (required) — Restriction mode: 0 = none, 1 = day, 2 = week.\n - restrict_periods (array) (required) — Restriction windows inside each rotation cycle.\n - restrict_end (integer) (required) — End offset inside the rotation cycle.\n - restrict_start (integer) (required) — Start offset inside the rotation cycle.\n - restrict_start (integer) (required) — Legacy start offset inside the restriction window (seconds).\n - rotation_duration (integer) (required) — Rotation duration in seconds.\n - rotation_unit (string) (required) — Rotation unit. [hour, day, week, month]\n - rotation_value (integer) (required) — Rotation quantity (number of rotation_unit per cycle).\n - schedule_id (integer) (required) — Parent schedule ID.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n - weight (integer) (required) — Layer weight for ordering.\n - name (any) (required) — Schedule name (legacy field; mirrors schedule_name). null when returned from /schedule/preview.\n - next_oncall (object) (required) — Snapshot of the currently or next on-call group.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - end (integer) (required) — Group end timestamp (Unix seconds).\n - group_name (string) (required) — Group display name.\n - members (array) (required) — Members of this group.\n - name (string) (required) — Legacy group name.\n - start (integer) (required) — Group start timestamp (Unix seconds).\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - update_at (integer) (required) — Update timestamp (Unix seconds).\n - weight (integer) (required) — Layer weight the shift comes from.\n - notify (object) (required) — Notification configuration attached to a schedule.\n - advance_in_time (integer) — Advance notification lead time (seconds).\n - by (object) (required) — Per-recipient notification preference.\n - follow_preference (boolean) (required) — Whether to follow each responder's personal notification preference.\n - personal_channels (array) (required) — Personal notification channel keys.\n - fixed_time (object) (required) — Fixed-time notification config.\n - cycle (string) (required) — Notification cycle.\n - start (string) (required) — Notification start time within the cycle.\n - im (object) — Legacy IM-type to token map.\n - webhooks (array) (required) — IM webhook notification channels.\n - settings (object) (required) — Settings for an IM webhook notification channel.\n - type (string) (required) — IM provider type (for example feishu_app, dingtalk_app, wecom_app, teams_app, slack_app).\n - schedule_id (integer) (required) — Schedule ID.\n - schedule_layers (array) (required) — Computed layers for the requested window.\n - layer_name (string) (required) — Layer display name.\n - mode (integer) (required) — Layer mode: 0 = common rotation, 1 = override.\n - name (string) (required) — Layer internal name.\n - schedules (array) (required) — Computed shifts.\n - end (integer) (required) — Shift end timestamp (Unix seconds).\n - group (object) (required) — Oncall group definition within a rotation layer.\n - index (integer) (required) — Index inside the rotation.\n - start (integer) (required) — Shift start timestamp (Unix seconds).\n - schedule_name (any) (required) — Schedule display name. null when returned from /schedule/preview.\n - start (integer) — Window start (Unix seconds).\n - status (any) (required) — Legacy status flag. Deprecated. null when returned from /schedule/preview.\n - team_id (any) (required) — Owning team ID. null when returned from /schedule/preview.\n - update_at (integer) (required) — Last update timestamp (Unix seconds).\n - update_by (integer) (required) — Last updater person ID.\n", + "Sessions.Info": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - events (array) — Recent events, ascending by (created_at, event_id).\n - actions (object) — ADK actions envelope (state deltas, transfers, escalation).\n - author (string) — Event author (e.g. user, the agent name).\n - branch (string) — ADK branch path for nested agents.\n - content (object) — ADK content envelope {role, parts:[...]}.\n - created_at (integer) — Unix timestamp in milliseconds when the event was written.\n - error_code (string) — Error code when the event represents a failure.\n - error_message (string) — Human-readable error message, when present.\n - event_id (string) — Event identifier.\n - invocation_id (string) — ADK invocation id grouping a turn.\n - partial (boolean) — True for a streaming partial chunk.\n - session_id (string) — Owning session id.\n - status (string) — Event status. [normal, compressed]\n - turn_complete (boolean) — True on the terminal event of a turn.\n - usage_metadata (object) — Per-turn token usage metadata.\n - has_more_older (boolean) — True when older events remain beyond this page.\n - search_after_ctx (string) — Opaque keyset cursor; pass back as search_after_ctx to fetch the next older page. Omitted when has_more_older is false.\n - session (object) — One agent session row.\n - app_name (string) — Agent app that owns the session.\n - archived_at (integer) — Unix timestamp in milliseconds when archived; 0 means not archived.\n - bound_environment (object) — The runner or cloud sandbox the session is bound to. Null until the first message.\n - id (string) — Environment identifier.\n - kind (string) — Environment kind (e.g. runner, sandbox).\n - name (string) — Human-readable environment name.\n - status (string) — Binding status.\n - can_manage (boolean) — True when the caller may rename/archive/delete the session.\n - context_resolved (object) — Snapshot of the three-tier knowledge-pack resolution for this session.\n - account_pack_id (string) — Resolved account-scoped pack id.\n - incident_id (string) — Bound incident id, when war-room originated.\n - resolved_at_ms (integer) — Unix timestamp in milliseconds when the packs were resolved.\n - team_pack_id (string) — Resolved team-scoped pack id.\n - versions (object) — Per-pack resolved version map.\n - context_window (integer) — The bound model's max context size in tokens. 0 means unknown.\n - created_at (integer) — Unix timestamp in milliseconds when the session was created.\n - current_context_tokens (integer) — Size in tokens of the LLM context window as of the most recent turn. 0 means no turn has completed.\n - entry_kind (string) — Surface that created the session. [web, im, api, scheduled, subagent]\n - has_unread (boolean) — True when there is assistant output the caller has not yet viewed.\n - incognito (boolean) — True for incognito (non-persisted-memory) sessions.\n - is_mine (boolean) — True when the caller created this session.\n - is_running (boolean) — True when an agent turn is currently in flight for this session.\n - last_event_at (integer) — Unix timestamp in milliseconds of the most recent assistant-side event.\n - parent_session_id (string) — Parent session id for subagent (child) sessions; empty otherwise.\n - person_id (string) — Creator person id.\n - pinned_at (integer) — Caller's per-user pin timestamp in milliseconds; 0 means not pinned.\n - session_id (string) — Session identifier.\n - session_name (string) — Session title; may be empty for untitled sessions.\n - state (object) — Raw session-state bag (session-scoped keys). Omitted when empty.\n - status (string) — Lifecycle status. [enabled, deleted]\n - team_id (integer) — Owning team id; 0 means no team is bound. Immutable after create.\n - team_name (string) — Resolved team name; empty for unbound rows or deleted teams.\n - template_staging_round_id (string) — Current save→validate round id (template-assistant only); empty otherwise.\n - token_usage (object) — Cumulative session-level token rollup across all turns. The account-billing source of truth.\n - cached_tokens (integer) — Portion of input_tokens served from the prompt cache.\n - input_tokens (integer) — Total prompt (input) tokens, including the cached portion.\n - output_tokens (integer) — Total generated (output) tokens.\n - reasoning_tokens (integer) — Total reasoning/thinking tokens.\n - updated_at (integer) — Unix timestamp in milliseconds of the last session update.\n", + "Sessions.List": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - sessions (array) — The page of sessions.\n - app_name (string) — Agent app that owns the session.\n - archived_at (integer) — Unix timestamp in milliseconds when archived; 0 means not archived.\n - bound_environment (object) — The runner or cloud sandbox the session is bound to. Null until the first message.\n - id (string) — Environment identifier.\n - kind (string) — Environment kind (e.g. runner, sandbox).\n - name (string) — Human-readable environment name.\n - status (string) — Binding status.\n - can_manage (boolean) — True when the caller may rename/archive/delete the session.\n - context_resolved (object) — Snapshot of the three-tier knowledge-pack resolution for this session.\n - account_pack_id (string) — Resolved account-scoped pack id.\n - incident_id (string) — Bound incident id, when war-room originated.\n - resolved_at_ms (integer) — Unix timestamp in milliseconds when the packs were resolved.\n - team_pack_id (string) — Resolved team-scoped pack id.\n - versions (object) — Per-pack resolved version map.\n - context_window (integer) — The bound model's max context size in tokens. 0 means unknown.\n - created_at (integer) — Unix timestamp in milliseconds when the session was created.\n - current_context_tokens (integer) — Size in tokens of the LLM context window as of the most recent turn. 0 means no turn has completed.\n - entry_kind (string) — Surface that created the session. [web, im, api, scheduled, subagent]\n - has_unread (boolean) — True when there is assistant output the caller has not yet viewed.\n - incognito (boolean) — True for incognito (non-persisted-memory) sessions.\n - is_mine (boolean) — True when the caller created this session.\n - is_running (boolean) — True when an agent turn is currently in flight for this session.\n - last_event_at (integer) — Unix timestamp in milliseconds of the most recent assistant-side event.\n - parent_session_id (string) — Parent session id for subagent (child) sessions; empty otherwise.\n - person_id (string) — Creator person id.\n - pinned_at (integer) — Caller's per-user pin timestamp in milliseconds; 0 means not pinned.\n - session_id (string) — Session identifier.\n - session_name (string) — Session title; may be empty for untitled sessions.\n - state (object) — Raw session-state bag (session-scoped keys). Omitted when empty.\n - status (string) — Lifecycle status. [enabled, deleted]\n - team_id (integer) — Owning team id; 0 means no team is bound. Immutable after create.\n - team_name (string) — Resolved team name; empty for unbound rows or deleted teams.\n - template_staging_round_id (string) — Current save→validate round id (template-assistant only); empty otherwise.\n - token_usage (object) — Cumulative session-level token rollup across all turns. The account-billing source of truth.\n - cached_tokens (integer) — Portion of input_tokens served from the prompt cache.\n - input_tokens (integer) — Total prompt (input) tokens, including the cached portion.\n - output_tokens (integer) — Total generated (output) tokens.\n - reasoning_tokens (integer) — Total reasoning/thinking tokens.\n - updated_at (integer) — Unix timestamp in milliseconds of the last session update.\n - total (integer) — Total number of sessions matching the filter (ignoring pagination).\n", "Skills.ReadGet": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Owning account.\n - author (string) — Author declared in the skill frontmatter.\n - can_edit (boolean) (required) — Whether the calling member may edit or delete this resource.\n - checksum (string) — SHA-256 checksum of the skill's zip package.\n - content (string) — Full SKILL.md body; omitted in list responses.\n - created (boolean) — Install response only: true for a fresh install, false for an in-place upsert.\n - created_at (integer) (required) — Creation time as a Unix timestamp in milliseconds.\n - created_by (integer) (required) — Member who created this resource.\n - description (string) (required) — What the skill does and when the agent should use it.\n - is_modified (boolean) (required) — A marketplace-sourced skill has been edited locally; auto-update skips it.\n - license (string) — License declared in the skill frontmatter.\n - s3_key (string) — Object-storage key of the skill's zip package.\n - skill_id (string) (required) — Unique identifier of the skill.\n - skill_name (string) (required) — Name of the skill, parsed from its SKILL.md frontmatter.\n - source_template_name (string) — Marketplace template this skill was installed from, if any.\n - source_template_version (string) — Marketplace template version captured at install time.\n - status (string) (required) — Whether the skill is active and loadable by agents. [enabled, disabled]\n - tags (array) — Tags declared in the skill frontmatter.\n - team_id (integer) (required) — Owning team; 0 means account scope.\n - tools (array) — Tools the skill requires, declared in its frontmatter.\n - update_available (boolean) (required) — A newer marketplace template version exists for this skill.\n - updated_at (integer) (required) — Last-update time as a Unix timestamp in milliseconds.\n - version (string) — Skill version string from its frontmatter.\n", "Skills.ReadList": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - skills (array) (required) — Skills on the current page.\n - account_id (integer) (required) — Owning account.\n - author (string) — Author declared in the skill frontmatter.\n - can_edit (boolean) (required) — Whether the calling member may edit or delete this resource.\n - checksum (string) — SHA-256 checksum of the skill's zip package.\n - content (string) — Full SKILL.md body; omitted in list responses.\n - created (boolean) — Install response only: true for a fresh install, false for an in-place upsert.\n - created_at (integer) (required) — Creation time as a Unix timestamp in milliseconds.\n - created_by (integer) (required) — Member who created this resource.\n - description (string) (required) — What the skill does and when the agent should use it.\n - is_modified (boolean) (required) — A marketplace-sourced skill has been edited locally; auto-update skips it.\n - license (string) — License declared in the skill frontmatter.\n - s3_key (string) — Object-storage key of the skill's zip package.\n - skill_id (string) (required) — Unique identifier of the skill.\n - skill_name (string) (required) — Name of the skill, parsed from its SKILL.md frontmatter.\n - source_template_name (string) — Marketplace template this skill was installed from, if any.\n - source_template_version (string) — Marketplace template version captured at install time.\n - status (string) (required) — Whether the skill is active and loadable by agents. [enabled, disabled]\n - tags (array) — Tags declared in the skill frontmatter.\n - team_id (integer) (required) — Owning team; 0 means account scope.\n - tools (array) — Tools the skill requires, declared in its frontmatter.\n - update_available (boolean) (required) — A newer marketplace template version exists for this skill.\n - updated_at (integer) (required) — Last-update time as a Unix timestamp in milliseconds.\n - version (string) — Skill version string from its frontmatter.\n - total (integer) (required) — Total number of skills matching the filters.\n", "Skills.WriteUpdate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Owning account.\n - author (string) — Author declared in the skill frontmatter.\n - can_edit (boolean) (required) — Whether the calling member may edit or delete this resource.\n - checksum (string) — SHA-256 checksum of the skill's zip package.\n - content (string) — Full SKILL.md body; omitted in list responses.\n - created (boolean) — Install response only: true for a fresh install, false for an in-place upsert.\n - created_at (integer) (required) — Creation time as a Unix timestamp in milliseconds.\n - created_by (integer) (required) — Member who created this resource.\n - description (string) (required) — What the skill does and when the agent should use it.\n - is_modified (boolean) (required) — A marketplace-sourced skill has been edited locally; auto-update skips it.\n - license (string) — License declared in the skill frontmatter.\n - s3_key (string) — Object-storage key of the skill's zip package.\n - skill_id (string) (required) — Unique identifier of the skill.\n - skill_name (string) (required) — Name of the skill, parsed from its SKILL.md frontmatter.\n - source_template_name (string) — Marketplace template this skill was installed from, if any.\n - source_template_version (string) — Marketplace template version captured at install time.\n - status (string) (required) — Whether the skill is active and loadable by agents. [enabled, disabled]\n - tags (array) — Tags declared in the skill frontmatter.\n - team_id (integer) (required) — Owning team; 0 means account scope.\n - tools (array) — Tools the skill requires, declared in its frontmatter.\n - update_available (boolean) (required) — A newer marketplace template version exists for this skill.\n - updated_at (integer) (required) — Last-update time as a Unix timestamp in milliseconds.\n - version (string) — Skill version string from its frontmatter.\n", diff --git a/internal/cli/zz_generated_sessions.go b/internal/cli/zz_generated_sessions.go new file mode 100644 index 0000000..d6a457e --- /dev/null +++ b/internal/cli/zz_generated_sessions.go @@ -0,0 +1,287 @@ +// Code generated by internal/cmd/cligen; DO NOT EDIT. + +package cli + +import ( + "github.com/spf13/cobra" + + flashduty "github.com/flashcatcloud/go-flashduty" +) + +func genSessionsInfoCmd() *cobra.Command { + var dataJSON string + var fLimit int64 + var fNumRecentEvents int64 + var fSearchAfterCtx string + var fSessionID string + cmd := &cobra.Command{ + Use: "session-get", + Short: "Get session detail", + Long: `Get session detail. + +Fetch one session plus a backward-paged window of its most recent events. Use search_after_ctx to page through older history. + +API: POST /safari/session/get (session-read-info) + +Request fields: + --limit int — Alias for num_recent_events; takes precedence when both are set. (0-1000) + --num-recent-events int — Number of most-recent events to return; 0 uses the server default. (0-1000) + --search-after-ctx string — Opaque keyset cursor from a previous response's search_after_ctx, to page backward through older events. (≤4096 chars) + --session-id string (required) — Session identifier. (≥1 chars) + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - events (array) — Recent events, ascending by (created_at, event_id). + - actions (object) — ADK actions envelope (state deltas, transfers, escalation). + - author (string) — Event author (e.g. user, the agent name). + - branch (string) — ADK branch path for nested agents. + - content (object) — ADK content envelope {role, parts:[...]}. + - created_at (integer) — Unix timestamp in milliseconds when the event was written. + - error_code (string) — Error code when the event represents a failure. + - error_message (string) — Human-readable error message, when present. + - event_id (string) — Event identifier. + - invocation_id (string) — ADK invocation id grouping a turn. + - partial (boolean) — True for a streaming partial chunk. + - session_id (string) — Owning session id. + - status (string) — Event status. [normal, compressed] + - turn_complete (boolean) — True on the terminal event of a turn. + - usage_metadata (object) — Per-turn token usage metadata. + - has_more_older (boolean) — True when older events remain beyond this page. + - search_after_ctx (string) — Opaque keyset cursor; pass back as search_after_ctx to fetch the next older page. Omitted when has_more_older is false. + - session (object) — One agent session row. + - app_name (string) — Agent app that owns the session. + - archived_at (integer) — Unix timestamp in milliseconds when archived; 0 means not archived. + - bound_environment (object) — The runner or cloud sandbox the session is bound to. Null until the first message. + - id (string) — Environment identifier. + - kind (string) — Environment kind (e.g. runner, sandbox). + - name (string) — Human-readable environment name. + - status (string) — Binding status. + - can_manage (boolean) — True when the caller may rename/archive/delete the session. + - context_resolved (object) — Snapshot of the three-tier knowledge-pack resolution for this session. + - account_pack_id (string) — Resolved account-scoped pack id. + - incident_id (string) — Bound incident id, when war-room originated. + - resolved_at_ms (integer) — Unix timestamp in milliseconds when the packs were resolved. + - team_pack_id (string) — Resolved team-scoped pack id. + - versions (object) — Per-pack resolved version map. + - context_window (integer) — The bound model's max context size in tokens. 0 means unknown. + - created_at (integer) — Unix timestamp in milliseconds when the session was created. + - current_context_tokens (integer) — Size in tokens of the LLM context window as of the most recent turn. 0 means no turn has completed. + - entry_kind (string) — Surface that created the session. [web, im, api, scheduled, subagent] + - has_unread (boolean) — True when there is assistant output the caller has not yet viewed. + - incognito (boolean) — True for incognito (non-persisted-memory) sessions. + - is_mine (boolean) — True when the caller created this session. + - is_running (boolean) — True when an agent turn is currently in flight for this session. + - last_event_at (integer) — Unix timestamp in milliseconds of the most recent assistant-side event. + - parent_session_id (string) — Parent session id for subagent (child) sessions; empty otherwise. + - person_id (string) — Creator person id. + - pinned_at (integer) — Caller's per-user pin timestamp in milliseconds; 0 means not pinned. + - session_id (string) — Session identifier. + - session_name (string) — Session title; may be empty for untitled sessions. + - state (object) — Raw session-state bag (session-scoped keys). Omitted when empty. + - status (string) — Lifecycle status. [enabled, deleted] + - team_id (integer) — Owning team id; 0 means no team is bound. Immutable after create. + - team_name (string) — Resolved team name; empty for unbound rows or deleted teams. + - template_staging_round_id (string) — Current save→validate round id (template-assistant only); empty otherwise. + - token_usage (object) — Cumulative session-level token rollup across all turns. The account-billing source of truth. + - cached_tokens (integer) — Portion of input_tokens served from the prompt cache. + - input_tokens (integer) — Total prompt (input) tokens, including the cached portion. + - output_tokens (integer) — Total generated (output) tokens. + - reasoning_tokens (integer) — Total reasoning/thinking tokens. + - updated_at (integer) — Unix timestamp in milliseconds of the last session update. +`, + Example: ` flashduty safari session-get --data '{"num_recent_events":50,"session_id":"sess_f8oDvqiG64uur6sBNsTc4u"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) { + if cmd.Flags().Changed("limit") { + body["limit"] = fLimit + } + if cmd.Flags().Changed("num-recent-events") { + body["num_recent_events"] = fNumRecentEvents + } + if cmd.Flags().Changed("search-after-ctx") { + body["search_after_ctx"] = fSearchAfterCtx + } + if cmd.Flags().Changed("session-id") { + body["session_id"] = fSessionID + } + }) + if err != nil { + return err + } + req := new(flashduty.SessionGetRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Sessions.Info(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().Int64Var(&fLimit, "limit", 0, "Alias for num_recent_events; takes precedence when both are set. (0-1000)") + cmd.Flags().Int64Var(&fNumRecentEvents, "num-recent-events", 0, "Number of most-recent events to return; 0 uses the server default. (0-1000)") + cmd.Flags().StringVar(&fSearchAfterCtx, "search-after-ctx", "", "Opaque keyset cursor from a previous response's search_after_ctx, to page backward through older events. (≤4096 chars)") + cmd.Flags().StringVar(&fSessionID, "session-id", "", "Session identifier. (required) (≥1 chars)") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; typed flags override its fields") + return cmd +} + +func genSessionsListCmd() *cobra.Command { + var dataJSON string + var fP int64 + var fLimit int64 + var fSearchAfterCtx string + var fAppName string + var fAsc bool + var fEntryKinds []string + var fIncludeSubagentSessions bool + var fKeyword string + var fOrderby string + var fScope string + var fStatus string + var fTeamIDs []int + cmd := &cobra.Command{ + Use: "session-list", + Short: "List sessions", + Long: `List sessions. + +List agent sessions visible to the caller within the resolved account, filtered by app, entry surface, archive status, and team scope, with pagination. Reads are scoped to the person the app_key resolves to. + +API: POST /safari/session/list (session-read-list) + +Request fields: + --page int — 1-based page number; defaults to 1. + --limit int — Page size, 1..100; defaults to 20. (1-100) + --search-after-ctx string + --app-name string (required) — Agent app whose sessions to list. [ask-ai, support, support-website, support-flashcat, ai-sre, template-assistant] + --asc bool — Ascending sort when true; defaults to false (descending). Only honored when orderby is set. + --entry-kinds []string — Restrict to sessions produced by these entry surfaces. Empty returns every kind. [web, im, api, scheduled] + --include-subagent-sessions bool — Include subagent (child) sessions in the result; defaults to false. + --keyword string — Case-insensitive substring match against session name. (≤64 chars) + --orderby string — Sort column. [created_at, updated_at] + --scope string — Visibility scope: all (own + member-of-team rows, the default), personal (own only), or team (member teams only). [all, personal, team] + --status string — Archive bucket: active (default, not archived), archived, or all. [active, archived, all] + --team-ids []int — Optional explicit team filter; intersected with the caller's visible set / scope. + +Response fields ('data' envelope is unwrapped — these fields are at the top level): + - sessions (array) — The page of sessions. + - app_name (string) — Agent app that owns the session. + - archived_at (integer) — Unix timestamp in milliseconds when archived; 0 means not archived. + - bound_environment (object) — The runner or cloud sandbox the session is bound to. Null until the first message. + - id (string) — Environment identifier. + - kind (string) — Environment kind (e.g. runner, sandbox). + - name (string) — Human-readable environment name. + - status (string) — Binding status. + - can_manage (boolean) — True when the caller may rename/archive/delete the session. + - context_resolved (object) — Snapshot of the three-tier knowledge-pack resolution for this session. + - account_pack_id (string) — Resolved account-scoped pack id. + - incident_id (string) — Bound incident id, when war-room originated. + - resolved_at_ms (integer) — Unix timestamp in milliseconds when the packs were resolved. + - team_pack_id (string) — Resolved team-scoped pack id. + - versions (object) — Per-pack resolved version map. + - context_window (integer) — The bound model's max context size in tokens. 0 means unknown. + - created_at (integer) — Unix timestamp in milliseconds when the session was created. + - current_context_tokens (integer) — Size in tokens of the LLM context window as of the most recent turn. 0 means no turn has completed. + - entry_kind (string) — Surface that created the session. [web, im, api, scheduled, subagent] + - has_unread (boolean) — True when there is assistant output the caller has not yet viewed. + - incognito (boolean) — True for incognito (non-persisted-memory) sessions. + - is_mine (boolean) — True when the caller created this session. + - is_running (boolean) — True when an agent turn is currently in flight for this session. + - last_event_at (integer) — Unix timestamp in milliseconds of the most recent assistant-side event. + - parent_session_id (string) — Parent session id for subagent (child) sessions; empty otherwise. + - person_id (string) — Creator person id. + - pinned_at (integer) — Caller's per-user pin timestamp in milliseconds; 0 means not pinned. + - session_id (string) — Session identifier. + - session_name (string) — Session title; may be empty for untitled sessions. + - state (object) — Raw session-state bag (session-scoped keys). Omitted when empty. + - status (string) — Lifecycle status. [enabled, deleted] + - team_id (integer) — Owning team id; 0 means no team is bound. Immutable after create. + - team_name (string) — Resolved team name; empty for unbound rows or deleted teams. + - template_staging_round_id (string) — Current save→validate round id (template-assistant only); empty otherwise. + - token_usage (object) — Cumulative session-level token rollup across all turns. The account-billing source of truth. + - cached_tokens (integer) — Portion of input_tokens served from the prompt cache. + - input_tokens (integer) — Total prompt (input) tokens, including the cached portion. + - output_tokens (integer) — Total generated (output) tokens. + - reasoning_tokens (integer) — Total reasoning/thinking tokens. + - updated_at (integer) — Unix timestamp in milliseconds of the last session update. + - total (integer) — Total number of sessions matching the filter (ignoring pagination). +`, + Example: ` flashduty safari session-list --data '{"app_name":"ai-sre","limit":2,"orderby":"updated_at","scope":"all"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) { + if cmd.Flags().Changed("page") { + body["p"] = fP + } + if cmd.Flags().Changed("limit") { + body["limit"] = fLimit + } + if cmd.Flags().Changed("search-after-ctx") { + body["search_after_ctx"] = fSearchAfterCtx + } + if cmd.Flags().Changed("app-name") { + body["app_name"] = fAppName + } + if cmd.Flags().Changed("asc") { + body["asc"] = fAsc + } + if cmd.Flags().Changed("entry-kinds") { + body["entry_kinds"] = fEntryKinds + } + if cmd.Flags().Changed("include-subagent-sessions") { + body["include_subagent_sessions"] = fIncludeSubagentSessions + } + if cmd.Flags().Changed("keyword") { + body["keyword"] = fKeyword + } + if cmd.Flags().Changed("orderby") { + body["orderby"] = fOrderby + } + if cmd.Flags().Changed("scope") { + body["scope"] = fScope + } + if cmd.Flags().Changed("status") { + body["status"] = fStatus + } + if cmd.Flags().Changed("team-ids") { + body["team_ids"] = fTeamIDs + } + }) + if err != nil { + return err + } + req := new(flashduty.SessionListRequest) + if err := genBindBody(body, req); err != nil { + return err + } + out, _, err := ctx.Client.Sessions.List(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + return printGenericResult(ctx, out) + }) + }, + } + cmd.Flags().Int64Var(&fP, "page", 0, "1-based page number; defaults to 1.") + cmd.Flags().Int64Var(&fLimit, "limit", 0, "Page size, 1..100; defaults to 20. (1-100)") + cmd.Flags().StringVar(&fSearchAfterCtx, "search-after-ctx", "", "Request field ") + cmd.Flags().StringVar(&fAppName, "app-name", "", "Agent app whose sessions to list. (required) [ask-ai, support, support-website, support-flashcat, ai-sre, template-assistant]") + cmd.Flags().BoolVar(&fAsc, "asc", false, "Ascending sort when true; defaults to false (descending). Only honored when orderby is set.") + cmd.Flags().StringSliceVar(&fEntryKinds, "entry-kinds", nil, "Restrict to sessions produced by these entry surfaces. Empty returns every kind. [web, im, api, scheduled]") + cmd.Flags().BoolVar(&fIncludeSubagentSessions, "include-subagent-sessions", false, "Include subagent (child) sessions in the result; defaults to false.") + cmd.Flags().StringVar(&fKeyword, "keyword", "", "Case-insensitive substring match against session name. (≤64 chars)") + cmd.Flags().StringVar(&fOrderby, "orderby", "", "Sort column. [created_at, updated_at]") + cmd.Flags().StringVar(&fScope, "scope", "", "Visibility scope: all (own + member-of-team rows, the default), personal (own only), or team (member teams only). [all, personal, team]") + cmd.Flags().StringVar(&fStatus, "status", "", "Archive bucket: active (default, not archived), archived, or all. [active, archived, all]") + cmd.Flags().IntSliceVar(&fTeamIDs, "team-ids", nil, "Optional explicit team filter; intersected with the caller's visible set / scope.") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; typed flags override its fields") + return cmd +} + +func registerGeneratedSessions(root *cobra.Command) { + gSafari := genGroup(root, "safari", "AI SRE API") + genAddLeaf(gSafari, genSessionsInfoCmd()) + genAddLeaf(gSafari, genSessionsListCmd()) +} diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index 81a2436..7f0b468 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -188,6 +188,17 @@ func collectServices(paths, schemas map[string]any) []service { if len(tags) == 0 { continue } + // Streaming endpoints (200 body is not application/json, e.g. + // session/export's application/x-ndjson) cannot be modeled by the + // generated typed-response command: the SDK exposes them as a + // hand-written streaming method with a non-standard signature + // (io.ReadCloser, *Response, error), not the (out, *Response, error) + // the template expects. Skip them here — they are covered by a + // curated command at their path-name — mirroring go-flashduty's own + // generator, which skips the same ops. + if isStreamingOp(o) { + continue + } tag, _ := tags[0].(string) byTag[tag] = append(byTag[tag], struct { path, http string @@ -238,6 +249,21 @@ func collectServices(paths, schemas map[string]any) []service { return out } +// isStreamingOp reports whether an operation's 200 response is a non-JSON +// streaming body (its 200 content has no "application/json" key, e.g. +// session/export's application/x-ndjson). Such ops cannot be modeled by a +// generated typed-response command and are served by a curated command instead. +// This mirrors go-flashduty's generator, which excludes the same ops from the +// SDK's typed do() path in favor of a hand-written streaming method. +func isStreamingOp(o map[string]any) bool { + content := asMap(asMap(asMap(o["responses"])["200"])["content"]) + if len(content) == 0 { + return false // no body at all is not a streaming response + } + _, hasJSON := content["application/json"] + return !hasJSON +} + type specWalker struct{ schemas map[string]any } func (w *specWalker) deref(s map[string]any) map[string]any { @@ -1064,7 +1090,7 @@ func flagName(wire string) string { } return kebab(wire) } -func flagVar(wire string) string { return "f" + pascal(tokens(wire)) } +func flagVar(wire string) string { return "f" + pascal(tokens(wire)) } func goFlagType(kind string) string { switch kind { From d4d3fbdc4112d91a241a6e3add46b7d33939ca9e Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 2 Jun 2026 13:17:26 +0800 Subject: [PATCH 2/2] fix(session): paginate list beyond 100 rows; pin merged go-flashduty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /safari/session/list handler binds limit with "lte=100": a single request with limit>100 is a hard 400 bind failure, not a clamp. Honor --limit above 100 by paginating server-side (fetchSessionsPaged) — each page requests min(remaining, 100) and advances p until the limit is met or the server is exhausted (short page / accumulated >= total). Adds two regression guards: paginate-beyond-100 and stop-when-exhausted. Also bumps the go-flashduty pin to the squash-merged main commit (7583ebae, go-flashduty#8) so the dependency is reachable from main rather than the soon-stale PR-branch pseudo-version. --- go.mod | 2 +- go.sum | 4 +- internal/cli/session.go | 83 +++++++++++++++++++++-- internal/cli/session_test.go | 128 +++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 7d6966e..4b0c5c4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 + github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 5bfb69c..8ae88cd 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 h1:ZW8Y7p6JYh+M+saQPq0ScVqRTsxFCrGV59K9TuLxHRA= -github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07 h1:bi1rOjR2OY+TovBGabtVOTcEQWlgzU9RfEwlJxU+3n8= +github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/session.go b/internal/cli/session.go index 5c88cbe..e87cd8a 100644 --- a/internal/cli/session.go +++ b/internal/cli/session.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "fmt" "io" @@ -37,6 +38,13 @@ const ( sessionFormatTOON = "toon" ) +// sessionPageLimit is the largest per-page Limit the /safari/session/list +// handler accepts. The server validates limit with binding "lte=100": a +// limit > 100 is a hard 400 bind failure, NOT a clamp, so every page request +// must carry Limit <= 100. To honor a --limit above this, `session list` +// paginates server-side (see fetchSessionsPaged). +const sessionPageLimit = 100 + func newSessionListCmd() *cobra.Command { var ( app string @@ -86,23 +94,20 @@ func newSessionListCmd() *cobra.Command { Status: status, Orderby: "updated_at", } - req.Limit = limit - req.Page = page if teamID > 0 { req.TeamIDs = []int64{teamID} } - resp, _, err := ctx.Client.Sessions.List(cmdContext(ctx.Cmd), req) + sessions, total, err := fetchSessionsPaged(cmdContext(ctx.Cmd), ctx.Client, req, page, limit) if err != nil { return err } - sessions := resp.Sessions if sinceUnix > 0 { sessions = filterSessionsSince(sessions, sinceUnix) } - return writeSessionList(ctx.Writer, format, sessions, resp.Total) + return writeSessionList(ctx.Writer, format, sessions, total) }) }, } @@ -114,14 +119,78 @@ func newSessionListCmd() *cobra.Command { registerEnumFlag(cmd, "status", "active", "archived", "all") cmd.Flags().StringVar(&since, "since", "", "Keep only sessions updated within this window (client-side), e.g. 30d, 24h, 2026-05-01") cmd.Flags().Int64Var(&teamID, "team-id", 0, "Restrict to one team ID") - cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch (server caps at 100/page)") - cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch; fetched across multiple 100-row server pages as needed") + cmd.Flags().IntVar(&page, "page", 1, "1-based page to start paginating from") cmd.Flags().StringVar(&format, "format", sessionFormatJSONL, "Output format: jsonl (default), json, or toon") registerEnumFlag(cmd, "format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON) return cmd } +// fetchSessionsPaged collects up to `limit` sessions across as many server pages +// as needed, starting at page `startPage`. The /safari/session/list handler +// rejects any single request with Limit > 100 (binding "lte=100" → HTTP 400, not +// a clamp), so a --limit above 100 must be satisfied by paginating: each page +// requests min(remaining, 100) rows and advances the 1-based page number P. The +// loop stops once it has `limit` rows, the server reports it has returned every +// matching row (accumulated >= Total), or a page comes back short (fewer rows +// than requested means the server is exhausted). The Total from the last +// response is returned so the caller can report the full match count even when +// the rows were truncated to --limit. +func fetchSessionsPaged( + ctx context.Context, + client *flashduty.Client, + base *flashduty.SessionListRequest, + startPage, limit int, +) ([]flashduty.SessionItem, int64, error) { + if startPage < 1 { + startPage = 1 + } + if limit < 1 { + limit = 1 + } + + // Hint the slice at one page; it grows naturally across pages. Sizing it to + // `limit` would over-allocate when a huge --limit far exceeds what the server + // actually has (e.g. --limit 1000000 on an account with a few hundred rows). + capHint := limit + if capHint > sessionPageLimit { + capHint = sessionPageLimit + } + collected := make([]flashduty.SessionItem, 0, capHint) + var total int64 + for page := startPage; len(collected) < limit; page++ { + pageLimit := limit - len(collected) + if pageLimit > sessionPageLimit { + pageLimit = sessionPageLimit + } + + // Copy the filter so each page reuses the same scope/app/team but + // carries its own pagination cursor. + req := *base + req.Page = page + req.Limit = pageLimit + + resp, _, err := client.Sessions.List(ctx, &req) + if err != nil { + return nil, 0, err + } + total = resp.Total + collected = append(collected, resp.Sessions...) + + // Server exhausted: a short page (fewer rows than asked for) or we have + // already gathered every matching row. Either ends the loop. + if len(resp.Sessions) < pageLimit || int64(len(collected)) >= total { + break + } + } + + if len(collected) > limit { + collected = collected[:limit] + } + return collected, total, nil +} + // filterSessionsSince keeps sessions whose updated_at is at or after sinceUnix // (unix seconds). The API exposes no time-window filter, so this is the only // place a --since window is honored. diff --git a/internal/cli/session_test.go b/internal/cli/session_test.go index e3069b6..4726d20 100644 --- a/internal/cli/session_test.go +++ b/internal/cli/session_test.go @@ -89,6 +89,134 @@ func TestCommandSessionListJSONL(t *testing.T) { } } +// TestCommandSessionListPaginatesBeyond100 is the regression guard for the +// limit>100 bug: the /safari/session/list handler binds limit with "lte=100", +// so a single request with limit 200 is a hard 400 bind failure, not a clamp. +// `session list --limit 200` must therefore satisfy the request by paginating — +// issuing MULTIPLE page requests each with limit<=100 and advancing p — then +// concatenating the rows. This test serves 250 matching sessions in pages of at +// most 100 and asserts the command (a) never asks for more than 100 in any page, +// (b) advances p across pages, and (c) returns exactly the requested 200 rows. +func TestCommandSessionListPaginatesBeyond100(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + const totalAvailable = 250 + // Serve a page computed from the request's p/limit so we exercise the real + // loop: each page returns min(limit, remaining) sessions, never more than + // the server-accepted ceiling. + stub.dataFor = func(body map[string]any) any { + p := int(asFloat(body["p"])) + limit := int(asFloat(body["limit"])) + if p < 1 { + p = 1 + } + if limit > 100 { + // Mirror the real handler: limit>100 is a bind FAILURE, never a + // clamp. If the CLI ever sends this, the test must fail loudly. + t.Fatalf("page request used limit=%d (>100) — server would 400, CLI must paginate", limit) + } + offset := (p - 1) * limit + sessions := make([]map[string]any, 0, limit) + for i := offset; i < offset+limit && i < totalAvailable; i++ { + sessions = append(sessions, map[string]any{ + "session_id": fmt.Sprintf("sess-%03d", i), + "app_name": "ai-sre", + "updated_at": 1779432894000, + "session_name": fmt.Sprintf("row %d", i), + }) + } + return map[string]any{"sessions": sessions, "total": totalAvailable} + } + + out, err := execCommand("session", "list", "--app", "ai-sre", "--limit", "200", "--format", "jsonl") + if err != nil { + t.Fatalf("[session-paginate] unexpected error: %v", err) + } + + // (a) Multiple page requests were issued, and (b) p advanced across them. + if stub.requests < 2 { + t.Fatalf("[session-paginate] expected >=2 page requests for limit 200, got %d", stub.requests) + } + seenPages := make(map[int]bool) + for i, b := range stub.bodies { + limit := int(asFloat(b["limit"])) + if limit > 100 { + t.Errorf("[session-paginate] request %d used limit=%d, want <=100", i, limit) + } + seenPages[int(asFloat(b["p"]))] = true + } + if !seenPages[1] || !seenPages[2] { + t.Errorf("[session-paginate] expected requests for p=1 and p=2, saw pages %v", seenPages) + } + + // (c) Exactly 200 rows came back, concatenated and in order across pages. + lines := nonEmptyLines(out) + if len(lines) != 200 { + t.Fatalf("[session-paginate] expected 200 concatenated rows, got %d", len(lines)) + } + var first, last flashduty.SessionItem + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("[session-paginate] line 0 not a SessionItem: %v", err) + } + if err := json.Unmarshal([]byte(lines[199]), &last); err != nil { + t.Fatalf("[session-paginate] line 199 not a SessionItem: %v", err) + } + if first.SessionID != "sess-000" { + t.Errorf("[session-paginate] first row = %q, want sess-000", first.SessionID) + } + if last.SessionID != "sess-199" { + t.Errorf("[session-paginate] last row = %q, want sess-199", last.SessionID) + } +} + +// TestCommandSessionListStopsWhenServerExhausted proves the loop terminates when +// the server returns fewer rows than requested (a short page) even though +// --limit asks for more, rather than spinning forever. +func TestCommandSessionListStopsWhenServerExhausted(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + + const totalAvailable = 130 // exhausts mid-way through page 2 + stub.dataFor = func(body map[string]any) any { + p := int(asFloat(body["p"])) + limit := int(asFloat(body["limit"])) + if limit > 100 { + t.Fatalf("page request used limit=%d (>100)", limit) + } + offset := (p - 1) * limit + sessions := make([]map[string]any, 0, limit) + for i := offset; i < offset+limit && i < totalAvailable; i++ { + sessions = append(sessions, map[string]any{ + "session_id": fmt.Sprintf("sess-%03d", i), + "app_name": "ai-sre", + "updated_at": 1779432894000, + }) + } + return map[string]any{"sessions": sessions, "total": totalAvailable} + } + + out, err := execCommand("session", "list", "--app", "ai-sre", "--limit", "200", "--format", "jsonl") + if err != nil { + t.Fatalf("[session-exhaust] unexpected error: %v", err) + } + lines := nonEmptyLines(out) + if len(lines) != totalAvailable { + t.Fatalf("[session-exhaust] expected %d rows (server exhausted), got %d", totalAvailable, len(lines)) + } + // Page 1 (100) + page 2 (30, short) → exactly 2 requests, no extra spin. + if stub.requests != 2 { + t.Errorf("[session-exhaust] expected exactly 2 requests, got %d", stub.requests) + } +} + +// asFloat coerces a decoded JSON number (always float64) to float64, tolerating +// a missing key (returns 0). +func asFloat(v any) float64 { + f, _ := v.(float64) + return f +} + // TestCommandSessionListSinceFiltersClientSide proves --since drops rows older // than the window using the response's updated_at (the API has no time filter). func TestCommandSessionListSinceFiltersClientSide(t *testing.T) {