From e68a41064cda448c5031a341a48382790a254a92 Mon Sep 17 00:00:00 2001 From: Wu Sheng Date: Sat, 5 Sep 2026 10:45:25 +0800 Subject: [PATCH] Add the ai-agent commands for the conversations the AI Sessionizer lands The OAP (11.1.0) stores the conversations of long-lived AI agents that the AI Sessionizer pushes under the AI_AGENT layer, and serves them through two GraphQL queries and one streamed HTTP route. swctl gains: ai-agent list one row per conversation of a service, newest first ai-agent files the raw files as stored, or --export DIR to rebuild a storage root that asz verify and asz view read ai-agent view the whole conversation as one asz.view document, from GET /ai-agent/conversations/{id}/v1/view on the --base-url host, streamed to stdout or --output, JSON or --yaml, with the route's problem document turned into the error The view route lives on the query host beside /graphql, not on the admin host, and its body can be tens of megabytes, so it has its own small streaming client rather than the admin REST client. goapi is bumped to the regeneration that carries the conversation types. That regeneration also dropped MenuItem, since the protocol retired getMenuItems; the menu command keeps the shape locally for the backends before 11.0.0. The e2e builds three sessions with the Sessionizer's scenario tool, pushes them to an OAP, and checks every command, the export included. --- CHANGES.md | 1 + .../aiagent/ConversationRawFiles.graphql | 34 +++++ .../aiagent/ListConversations.graphql | 36 +++++ cmd/swctl/main.go | 2 + dist/LICENSE | 2 +- go.mod | 2 +- go.sum | 4 +- internal/commands/aiagent/aiagent.go | 40 +++++ internal/commands/aiagent/files.go | 141 ++++++++++++++++++ internal/commands/aiagent/list.go | 93 ++++++++++++ internal/commands/aiagent/view.go | 81 ++++++++++ pkg/aiagent/view/view.go | 132 ++++++++++++++++ pkg/aiagent/view/view_test.go | 130 ++++++++++++++++ pkg/graphql/aiagent/conversation.go | 58 +++++++ pkg/graphql/menu/menu.go | 20 ++- 15 files changed, 768 insertions(+), 8 deletions(-) create mode 100644 assets/graphqls/aiagent/ConversationRawFiles.graphql create mode 100644 assets/graphqls/aiagent/ListConversations.graphql create mode 100644 internal/commands/aiagent/aiagent.go create mode 100644 internal/commands/aiagent/files.go create mode 100644 internal/commands/aiagent/list.go create mode 100644 internal/commands/aiagent/view.go create mode 100644 pkg/aiagent/view/view.go create mode 100644 pkg/aiagent/view/view_test.go create mode 100644 pkg/graphql/aiagent/conversation.go diff --git a/CHANGES.md b/CHANGES.md index 3e7a3a89..5e1abc26 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ Release Notes. ### Features +* Add the `ai-agent` commands, `list`, `files` and `view`, for the AI agent conversations the AI Sessionizer lands in the OAP (11.1.0+); `view` reads the whole conversation as one `asz.view` document from the OAP's streamed route on the GraphQL host by @wu-sheng in https://github.com/apache/skywalking-cli/pull/234 * Add the sub-command `profiling async` for async-profiler query API by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203 * Support the owner in MQE response by using [10.2 MQE query protocol](https://github.com/apache/skywalking-query-protocol/pull/141) by @zhengziyi0117 in https://github.com/apache/skywalking-cli/pull/203 * Add the sub-command `alarm autocomplete-keys` and `alarm auto-complete-values` for alarm query API by @mrproliu in https://github.com/apache/skywalking-cli/pull/210 diff --git a/assets/graphqls/aiagent/ConversationRawFiles.graphql b/assets/graphqls/aiagent/ConversationRawFiles.graphql new file mode 100644 index 00000000..824e2c88 --- /dev/null +++ b/assets/graphqls/aiagent/ConversationRawFiles.graphql @@ -0,0 +1,34 @@ +# Licensed to Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Apache Software Foundation (ASF) licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# The body is read from storage only when selected; $body is true on the export path. +query ($condition: ConversationCondition!, $files: [ID!], $body: Boolean!) { + result: getConversationRawFiles(condition: $condition, files: $files) { + errorReason + files { + id + format + session + seq + round + digest + bytes + timestamp + body @include(if: $body) + } + } +} diff --git a/assets/graphqls/aiagent/ListConversations.graphql b/assets/graphqls/aiagent/ListConversations.graphql new file mode 100644 index 00000000..b7243d79 --- /dev/null +++ b/assets/graphqls/aiagent/ListConversations.graphql @@ -0,0 +1,36 @@ +# Licensed to Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Apache Software Foundation (ASF) licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +query ($condition: ConversationListCondition!, $duration: Duration!) { + result: listConversations(condition: $condition, duration: $duration) { + errorReason + conversations { + conversation + serviceInstanceId + serviceInstanceName + title + round + talks + steps + streams + segments + unresolved + from + to + } + } +} diff --git a/cmd/swctl/main.go b/cmd/swctl/main.go index 01ee2c93..f35fff1c 100644 --- a/cmd/swctl/main.go +++ b/cmd/swctl/main.go @@ -23,6 +23,7 @@ import ( "runtime" "github.com/apache/skywalking-cli/internal/commands/admin" + "github.com/apache/skywalking-cli/internal/commands/aiagent" "github.com/apache/skywalking-cli/internal/commands/alarm" "github.com/apache/skywalking-cli/internal/commands/browser" "github.com/apache/skywalking-cli/internal/commands/completion" @@ -117,6 +118,7 @@ services, service instances, etc.` menu.Command, hierarchy.Command, admin.Command, + aiagent.Command, } app.Before = interceptor.BeforeChain( diff --git a/dist/LICENSE b/dist/LICENSE index fad46a84..c63e40c5 100644 --- a/dist/LICENSE +++ b/dist/LICENSE @@ -213,7 +213,7 @@ The text of each license is also included at licenses/license-[project].txt. sigs.k8s.io/controller-runtime v0.20.4 Apache-2.0 sigs.k8s.io/randfill v1.0.0 Apache-2.0 sigs.k8s.io/structured-merge-diff/v4 v4.7.0 Apache-2.0 - skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 Apache-2.0 + skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 Apache-2.0 ======================================================================== Apache-2.0 and BSD-3-Clause licenses diff --git a/go.mod b/go.mod index b45a2f09..d9f78723 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 k8s.io/apimachinery v0.33.1 sigs.k8s.io/controller-runtime v0.20.4 - skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 + skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 ) require ( diff --git a/go.sum b/go.sum index d664728e..ef1ad5c5 100644 --- a/go.sum +++ b/go.sum @@ -543,5 +543,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxg sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= -skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031 h1:iMd6gzltWrWOtV3COm0mWydeXhpy7r1vkNlTsm7Co0g= -skywalking.apache.org/repo/goapi v0.0.0-20251011100214-efff910f2031/go.mod h1:Vj9vINJYsTQASPsbQ1i81YgH8nFC/Xds4GjcXvmRYwM= +skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302 h1:XZ27v0cI1QaSyQK48fJfcvpGyhKUv22ULp2cS3l7snI= +skywalking.apache.org/repo/goapi v0.0.0-20260905021802-699be54ca302/go.mod h1:tsTLCXFg0zZ1lqr5+7tDMsZvZA+YFUCQe0lRctv23yc= diff --git a/internal/commands/aiagent/aiagent.go b/internal/commands/aiagent/aiagent.go new file mode 100644 index 00000000..6eec3df7 --- /dev/null +++ b/internal/commands/aiagent/aiagent.go @@ -0,0 +1,40 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package aiagent holds the commands for the conversations of long-lived AI agents +// that the AI Sessionizer (apache/skywalking-ai-sessionizer) lands in the OAP under +// the AI_AGENT layer: the list page, the raw-file export, and the conversation itself +// as one asz.view document. +package aiagent + +import ( + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "ai-agent", + Usage: "AI agent conversations landed by the AI Sessionizer", + UsageText: `The AI Sessionizer collects an agent runtime's transcripts and pushes them to the OAP +under the AI_AGENT layer. "list" and "files" are GraphQL queries on the "--base-url" +endpoint; "view" reads the whole conversation as one asz.view document from the OAP's +streamed route on the same host, GET /ai-agent/conversations/{conversation}/v1/view.`, + Subcommands: []*cli.Command{ + listCommand, + filesCommand, + viewCommand, + }, +} diff --git a/internal/commands/aiagent/files.go b/internal/commands/aiagent/files.go new file mode 100644 index 00000000..f3cb2933 --- /dev/null +++ b/internal/commands/aiagent/files.go @@ -0,0 +1,141 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package aiagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + api "skywalking.apache.org/repo/goapi/query" + + "github.com/urfave/cli/v2" + + "github.com/apache/skywalking-cli/internal/commands/interceptor" + "github.com/apache/skywalking-cli/internal/flags" + "github.com/apache/skywalking-cli/pkg/display" + "github.com/apache/skywalking-cli/pkg/display/displayable" + "github.com/apache/skywalking-cli/pkg/graphql/aiagent" +) + +var filesCommand = &cli.Command{ + Name: "files", + Usage: "List or export the raw files of a conversation, as the OAP stores them", + UsageText: `List every landed file and round of a conversation with its digest and size, or +export them: "--export DIR" reads each body and writes it to its id path under DIR, +which gives a storage root that "asz verify" and "asz view" read like the original. + +Examples: +1. The files of a conversation: +$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 + +2. Export them all: +$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --export ./root + +3. Export two named files: +$ swctl ai-agent files --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 \ + --files 7a3c882e-0dc0-46a0-b814-6613d24b7ac2/streams/main/transcript-20260904T152815.774957000Z-000408.sd \ + --export ./root`, + Flags: flags.Flags( + flags.ServiceFlags, + flags.InstanceFlags, + []cli.Flag{ + &cli.StringFlag{ + Name: "conversation", + Usage: "`id` of the conversation", + Required: true, + }, + &cli.StringFlag{ + Name: "files", + Usage: "only these file `ids`, comma separated; without it, every file of the conversation", + }, + &cli.StringFlag{ + Name: "export", + Usage: "write each file's body to its id path under this `directory`", + }, + }, + ), + Before: interceptor.BeforeChain( + interceptor.ParseService(true), + interceptor.ParseInstance(false), + ), + Action: func(ctx *cli.Context) error { + condition := &api.ConversationCondition{ + Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")}, + Conversation: ctx.String("conversation"), + Instance: instanceCondition(ctx), + } + var files []string + if arg := strings.TrimSpace(ctx.String("files")); arg != "" { + files = strings.Split(arg, ",") + } + exportDir := ctx.String("export") + + raw, err := aiagent.RawFiles(ctx.Context, condition, files, exportDir != "") + if err != nil { + return err + } + if raw.ErrorReason != nil && *raw.ErrorReason != "" { + return fmt.Errorf("%s", *raw.ErrorReason) + } + if exportDir == "" { + return display.Display(ctx.Context, &displayable.Displayable{Data: raw, Condition: condition}) + } + + written, err := export(exportDir, raw.Files) + if err != nil { + return err + } + return display.Display(ctx.Context, &displayable.Displayable{Data: written, Condition: condition}) + }, +} + +// Exported is one file written by "--export": its id path and size, the body left out. +type Exported struct { + ID string `json:"id"` + Path string `json:"path"` + Bytes int `json:"bytes"` +} + +// export writes each body to its id path under dir. An id is a relative path inside the +// Sessionizer's storage root; one that would leave dir is refused. +func export(dir string, files []*api.ConversationRawFile) ([]Exported, error) { + root, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + out := make([]Exported, 0, len(files)) + for _, f := range files { + if f.Body == nil { + return nil, fmt.Errorf("the OAP returned no body for %s", f.ID) + } + path := filepath.Join(root, filepath.FromSlash(f.ID)) + if !strings.HasPrefix(path, root+string(filepath.Separator)) { + return nil, fmt.Errorf("refusing to write %s outside %s", f.ID, root) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(path, []byte(*f.Body), 0o644); err != nil { // #nosec G306 -- a landed file is readable by design + return nil, err + } + out = append(out, Exported{ID: f.ID, Path: path, Bytes: len(*f.Body)}) + } + return out, nil +} diff --git a/internal/commands/aiagent/list.go b/internal/commands/aiagent/list.go new file mode 100644 index 00000000..9523c3c7 --- /dev/null +++ b/internal/commands/aiagent/list.go @@ -0,0 +1,93 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package aiagent + +import ( + api "skywalking.apache.org/repo/goapi/query" + + "github.com/urfave/cli/v2" + + "github.com/apache/skywalking-cli/internal/commands/interceptor" + "github.com/apache/skywalking-cli/internal/flags" + "github.com/apache/skywalking-cli/internal/model" + "github.com/apache/skywalking-cli/pkg/display" + "github.com/apache/skywalking-cli/pkg/display/displayable" + "github.com/apache/skywalking-cli/pkg/graphql/aiagent" +) + +var listCommand = &cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List the conversations of an AI agent service", + UsageText: `List the conversations of an AI agent service active in the duration, newest first, +one row per conversation from its newest round. + +Examples: +1. The conversations of service "Claude Code" in the last 30 minutes: +$ swctl ai-agent list --service-name "Claude Code" + +2. Only those pushed by one Sessionizer, in a day: +$ swctl ai-agent list --service-name "Claude Code" --instance-name laptop --start 2026-09-01 --end 2026-09-02`, + Flags: flags.Flags( + flags.DurationFlags, + flags.ServiceFlags, + flags.InstanceFlags, + []cli.Flag{ + &cli.IntFlag{ + Name: "limit", + Usage: "at most this many rounds are read, newest first, before folding to one row per conversation; 0 for the OAP's default", + Value: 0, + }, + }, + ), + Before: interceptor.BeforeChain( + interceptor.DurationInterceptor, + interceptor.ParseService(true), + interceptor.ParseInstance(false), + ), + Action: func(ctx *cli.Context) error { + duration := api.Duration{ + Start: ctx.String("start"), + End: ctx.String("end"), + Step: ctx.Generic("step").(*model.StepEnumValue).Selected, + } + condition := &api.ConversationListCondition{ + Service: &api.ServiceCondition{ServiceName: ctx.String("service-name")}, + Instance: instanceCondition(ctx), + } + if limit := ctx.Int("limit"); limit > 0 { + condition.Limit = &limit + } + + list, err := aiagent.ListConversations(ctx.Context, condition, duration) + if err != nil { + return err + } + return display.Display(ctx.Context, &displayable.Displayable{Data: list, Condition: condition, Duration: duration}) + }, +} + +// instanceCondition names the sender when "--instance-name" (or "--instance-id", +// resolved to the name by the interceptor) was given. +func instanceCondition(ctx *cli.Context) *api.InstanceCondition { + name := ctx.String("instance-name") + if name == "" { + return nil + } + return &api.InstanceCondition{ServiceName: ctx.String("service-name"), InstanceName: name} +} diff --git a/internal/commands/aiagent/view.go b/internal/commands/aiagent/view.go new file mode 100644 index 00000000..2bbb3852 --- /dev/null +++ b/internal/commands/aiagent/view.go @@ -0,0 +1,81 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package aiagent + +import ( + "io" + "os" + + "github.com/urfave/cli/v2" + + "github.com/apache/skywalking-cli/internal/commands/interceptor" + "github.com/apache/skywalking-cli/internal/flags" + "github.com/apache/skywalking-cli/pkg/aiagent/view" +) + +var viewCommand = &cli.Command{ + Name: "view", + Usage: "Read a whole conversation as one asz.view document", + UsageText: `Read the whole conversation, once, as one asz.view 1.0 document from the OAP's route +GET /ai-agent/conversations/{conversation}/v1/view, on the "--base-url" host. The body +is streamed to stdout, or to "--output", as it arrives: JSON, or YAML with "--yaml". +The "--display" option does not apply; the document is printed as the OAP sends it. + +Examples: +1. A conversation as JSON, into a file: +$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --output conversation.json + +2. As YAML, on the terminal: +$ swctl ai-agent view --service-name "Claude Code" --conversation 7a3c882e-0dc0-46a0-b814-6613d24b7ac2 --yaml`, + Flags: flags.Flags( + flags.ServiceFlags, + flags.InstanceFlags, + []cli.Flag{ + &cli.StringFlag{ + Name: "conversation", + Usage: "`id` of the conversation", + Required: true, + }, + &cli.BoolFlag{ + Name: "yaml", + Usage: "ask for the document as YAML instead of JSON", + }, + &cli.StringFlag{ + Name: "output", + Usage: "write the document to this `file` instead of stdout", + }, + }, + ), + Before: interceptor.BeforeChain( + interceptor.ParseService(true), + interceptor.ParseInstance(false), + ), + Action: func(ctx *cli.Context) error { + var out io.Writer = os.Stdout + if path := ctx.String("output"); path != "" { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + out = f + } + _, err := view.Fetch(ctx.Context, ctx.String("conversation"), ctx.String("service-name"), ctx.String("instance-name"), ctx.Bool("yaml"), out) + return err + }, +} diff --git a/pkg/aiagent/view/view.go b/pkg/aiagent/view/view.go new file mode 100644 index 00000000..94546c62 --- /dev/null +++ b/pkg/aiagent/view/view.go @@ -0,0 +1,132 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package view fetches the asz.view document of an AI agent conversation from the OAP's +// GET /ai-agent/conversations/{conversation}/v1/view route. The route lives on the query +// host beside /graphql, not on the admin host, because the document is what the UI reads; +// it is streamed, since a long conversation renders to tens of megabytes, so the body is +// copied through and never held whole. +package view + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/apache/skywalking-cli/pkg/contextkey" + "github.com/apache/skywalking-cli/pkg/transport" +) + +const ( + // MediaTypeJSON names the document, its version a parameter: the route's default body. + MediaTypeJSON = "application/vnd.skywalking.asz.view+json" + // MediaTypeYAML is the same document as YAML, chosen by Accept. + MediaTypeYAML = "application/vnd.skywalking.asz.view+yaml" + // problemType is the route's error body, RFC 9457. + problemType = "application/problem+json" + + defaultBaseURL = "http://127.0.0.1:12800/graphql" +) + +// Problem is the route's error: an RFC 9457 problem document carrying the status. +type Problem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` + URL string `json:"-"` +} + +func (p *Problem) Error() string { + if p.Detail != "" { + return fmt.Sprintf("%d %s: %s (%s)", p.Status, p.Title, p.Detail, p.URL) + } + return fmt.Sprintf("%d %s (%s)", p.Status, p.Title, p.URL) +} + +// CoreURL is the root of the query host the route lives on, derived from the GraphQL +// base URL by dropping its path: http://host:12800/graphql becomes http://host:12800. +// A base URL that does not parse is returned trimmed, so the error surfaces on the call. +func CoreURL(baseURL string) string { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + u, err := url.Parse(trimmed) + if err != nil || u.Host == "" { + return trimmed + } + return u.Scheme + "://" + u.Host +} + +// Path is the route of one conversation. +func Path(conversation string) string { + return "/ai-agent/conversations/" + url.PathEscape(conversation) + "/v1/view" +} + +// Fetch streams the document of the conversation to out and returns the Content-Type +// it came with. serviceName is required; instanceName narrows the read to one sender. +// A non-2xx answer is returned as a *Problem when the OAP sent one. +func Fetch(ctx context.Context, conversation, serviceName, instanceName string, yaml bool, out io.Writer) (string, error) { + query := url.Values{"service": {serviceName}} + if instanceName != "" { + query.Set("instance", instanceName) + } + full := CoreURL(transport.GetValue(ctx, contextkey.BaseURL{}, defaultBaseURL)) + Path(conversation) + "?" + query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, http.NoBody) + if err != nil { + return "", err + } + if yaml { + req.Header.Set("Accept", MediaTypeYAML) + } else { + req.Header.Set("Accept", MediaTypeJSON) + } + if authorization := transport.AuthHeader(ctx); authorization != "" { + req.Header.Set("Authorization", authorization) + } + + resp, err := transport.HTTPClient(ctx).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return contentType, readError(resp, full) + } + _, err = io.Copy(out, resp.Body) + return contentType, err +} + +// readError turns a non-2xx response into an error: the problem document when the OAP +// sent one, otherwise the status and whatever the body says. +func readError(resp *http.Response, full string) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if mediaType == problemType { + problem := &Problem{URL: full} + if json.Unmarshal(body, problem) == nil && problem.Status != 0 { + return problem + } + } + return fmt.Errorf("%s: %s: %s", full, resp.Status, strings.TrimSpace(string(body))) +} diff --git a/pkg/aiagent/view/view_test.go b/pkg/aiagent/view/view_test.go new file mode 100644 index 00000000..dadf6168 --- /dev/null +++ b/pkg/aiagent/view/view_test.go @@ -0,0 +1,130 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package view + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/apache/skywalking-cli/pkg/contextkey" +) + +func TestCoreURL(t *testing.T) { + cases := map[string]string{ + "http://127.0.0.1:12800/graphql": "http://127.0.0.1:12800", + "https://oap.example.com/graphql/": "https://oap.example.com", + "http://[::1]:12800/graphql": "http://[::1]:12800", + "http://oap:12800": "http://oap:12800", + "not a url": "not a url", + } + for in, want := range cases { + if got := CoreURL(in); got != want { + t.Errorf("CoreURL(%q) = %q, want %q", in, got, want) + } + } +} + +// server answers the route the way the OAP does: the document as JSON in several flushes or +// as YAML by Accept, a problem document for anything else, and 418 for a wrong request. +func server(t *testing.T, document []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ai-agent/conversations/c 1/v1/view" || + r.URL.Query().Get("service") != "agent" || r.URL.Query().Get("instance") != "sender" { + w.WriteHeader(http.StatusTeapot) + return + } + if r.Header.Get("Authorization") != "Basic dTpw" { + w.WriteHeader(http.StatusUnauthorized) + return + } + switch r.Header.Get("Accept") { + case MediaTypeYAML: + w.Header().Set("Content-Type", MediaTypeYAML+"; version=1.0") + _, _ = w.Write([]byte("format: asz.view\n")) + case MediaTypeJSON: + w.Header().Set("Content-Type", MediaTypeJSON+"; version=1.0") + for i := 0; i < len(document); i += 8192 { + end := i + 8192 + if end > len(document) { + end = len(document) + } + _, _ = w.Write(document[i:end]) + w.(http.Flusher).Flush() + } + default: + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"no round"}`)) + } + })) +} + +func testContext(serverURL string) context.Context { + ctx := context.WithValue(context.Background(), contextkey.BaseURL{}, serverURL+"/graphql") + ctx = context.WithValue(ctx, contextkey.Username{}, "u") + return context.WithValue(ctx, contextkey.Password{}, "p") +} + +func TestFetchStreamsTheDocument(t *testing.T) { + document := bytes.Repeat([]byte("{\"format\":\"asz.view\"}\n"), 4096) + srv := server(t, document) + defer srv.Close() + + var out bytes.Buffer + contentType, err := Fetch(testContext(srv.URL), "c 1", "agent", "sender", false, &out) + if err != nil { + t.Fatal(err) + } + if contentType != MediaTypeJSON+"; version=1.0" || !bytes.Equal(out.Bytes(), document) { + t.Fatalf("content type %q, %d bytes", contentType, out.Len()) + } +} + +func TestFetchAsksForYAML(t *testing.T) { + srv := server(t, nil) + defer srv.Close() + + var out bytes.Buffer + contentType, err := Fetch(testContext(srv.URL), "c 1", "agent", "sender", true, &out) + if err != nil || contentType != MediaTypeYAML+"; version=1.0" || out.String() != "format: asz.view\n" { + t.Fatalf("%v, content type %q, body %q", err, contentType, out.String()) + } +} + +func TestAProblemDocumentIsTheError(t *testing.T) { + srv := server(t, nil) + defer srv.Close() + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+Path("c 1")+"?service=agent&instance=sender", http.NoBody) + req.Header.Set("Authorization", "Basic dTpw") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var problem *Problem + if err := readError(resp, req.URL.String()); !errors.As(err, &problem) || + problem.Status != 404 || problem.Detail != "no round" || problem.Title != "Not Found" { + t.Fatalf("problem: %v", err) + } +} diff --git a/pkg/graphql/aiagent/conversation.go b/pkg/graphql/aiagent/conversation.go new file mode 100644 index 00000000..fce0246f --- /dev/null +++ b/pkg/graphql/aiagent/conversation.go @@ -0,0 +1,58 @@ +// Licensed to Apache Software Foundation (ASF) under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Apache Software Foundation (ASF) licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package aiagent wraps the GraphQL queries of ai-agent-conversation.graphqls: the +// list page and the raw-file export of the conversations the AI Sessionizer lands. +// The conversation document itself is not a GraphQL query; see pkg/aiagent/view. +package aiagent + +import ( + "context" + + "github.com/machinebox/graphql" + api "skywalking.apache.org/repo/goapi/query" + + "github.com/apache/skywalking-cli/assets" + "github.com/apache/skywalking-cli/pkg/graphql/client" +) + +// ListConversations lists one row per conversation of a service active in the duration, +// newest first, from the newest round's attributes. +func ListConversations(ctx context.Context, condition *api.ConversationListCondition, duration api.Duration) (api.ConversationList, error) { + var response map[string]api.ConversationList + + request := graphql.NewRequest(assets.Read("graphqls/aiagent/ListConversations.graphql")) + request.Var("condition", condition) + request.Var("duration", duration) + + err := client.ExecuteQuery(ctx, request, &response) + return response["result"], err +} + +// RawFiles lists every landed file and round of a conversation as stored, or only the +// named ones; with body, each file comes verbatim, which is the export path. +func RawFiles(ctx context.Context, condition *api.ConversationCondition, files []string, body bool) (api.ConversationRawFiles, error) { + var response map[string]api.ConversationRawFiles + + request := graphql.NewRequest(assets.Read("graphqls/aiagent/ConversationRawFiles.graphql")) + request.Var("condition", condition) + request.Var("files", files) + request.Var("body", body) + + err := client.ExecuteQuery(ctx, request, &response) + return response["result"], err +} diff --git a/pkg/graphql/menu/menu.go b/pkg/graphql/menu/menu.go index b46815cd..41b5943a 100644 --- a/pkg/graphql/menu/menu.go +++ b/pkg/graphql/menu/menu.go @@ -24,12 +24,24 @@ import ( "github.com/apache/skywalking-cli/pkg/graphql/client" "github.com/machinebox/graphql" - - api "skywalking.apache.org/repo/goapi/query" ) -func GetItems(ctx context.Context) ([]*api.MenuItem, error) { - var response map[string][]*api.MenuItem +// Item is one entry of the UI menu the OAP served before 11.0.0. The query protocol +// retired getItems, so goapi no longer generates the type; the command stays for the +// older backends and carries the shape itself. +type Item struct { + Title string `json:"title"` + Icon *string `json:"icon,omitempty"` + Layer string `json:"layer"` + Activate bool `json:"activate"` + SubItems []*Item `json:"subItems"` + Description *string `json:"description,omitempty"` + DocumentLink *string `json:"documentLink,omitempty"` + I18nKey *string `json:"i18nKey,omitempty"` +} + +func GetItems(ctx context.Context) ([]*Item, error) { + var response map[string][]*Item request := graphql.NewRequest(assets.Read("graphqls/menu/GetItems.graphql"))