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"))