feat(cli): context-aware agent spawn with agent catalog and auth preflight (#2411)
* feat(cli): add agent command for listing and refreshing agent catalog - Implemented `ao agent ls` command to list supported agents and their installation/auth readiness. - Added `--refresh` flag to refresh local install/auth probes before listing agents. - Introduced JSON output option with `--json` for raw agent catalog response. - Updated tests to cover new agent command functionality, including cached catalog usage and refresh behavior. - Enhanced error handling and output formatting for better user experience. * docs: add CLI Guide link to README for direct usage instructions * docs: clarify direct CLI usage * fix: tighten spawn agent preflight * feat(cli): implement agent readiness probe and update API specifications
This commit is contained in:
parent
2c08597ee6
commit
821cda64a8
|
|
@ -108,6 +108,9 @@ The result is a local control layer for agentic coding: agents still do the codi
|
||||||
|
|
||||||
Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code.
|
Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code.
|
||||||
|
|
||||||
|
For direct CLI usage, including agent readiness checks and context-aware session
|
||||||
|
spawns, see the [CLI Guide](docs/cli/README.md).
|
||||||
|
|
||||||
**If it runs in a terminal, it runs on Agent Orchestrator.**
|
**If it runs in a terminal, it runs on Agent Orchestrator.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -172,6 +175,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc
|
||||||
| -------------------------------------------------------- | ------------------------------------------------------- |
|
| -------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
|
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
|
||||||
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
||||||
|
| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests |
|
||||||
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type agentListOptions struct {
|
||||||
|
refresh bool
|
||||||
|
json bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentInfo mirrors the daemon's agent Info body for the CLI client.
|
||||||
|
type agentInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
AuthStatus string `json:"authStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentInventory mirrors GET /api/v1/agents and POST /api/v1/agents/refresh.
|
||||||
|
type agentInventory struct {
|
||||||
|
Supported []agentInfo `json:"supported"`
|
||||||
|
Installed []agentInfo `json:"installed"`
|
||||||
|
Authorized []agentInfo `json:"authorized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgentCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "agent",
|
||||||
|
Short: "Inspect agent catalog readiness",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newAgentListCommand(ctx))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgentListCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
var opts agentListOptions
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List supported agents and local auth readiness",
|
||||||
|
Args: noArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
inv, err := ctx.fetchAgentInventory(cmd.Context(), opts.refresh)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.json {
|
||||||
|
return writeJSON(cmd.OutOrStdout(), inv)
|
||||||
|
}
|
||||||
|
return writeAgentList(cmd, inv)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.refresh, "refresh", false, "Refresh local install/auth probes before listing")
|
||||||
|
cmd.Flags().BoolVar(&opts.json, "json", false, "Output raw agent catalog JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAgentList(cmd *cobra.Command, inv agentInventory) error {
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
if len(inv.Supported) == 0 {
|
||||||
|
_, err := fmt.Fprintln(out, "No agents supported by this daemon.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(inv.Supported, func(i, j int) bool {
|
||||||
|
return inv.Supported[i].ID < inv.Supported[j].ID
|
||||||
|
})
|
||||||
|
installed := agentInfoByID(inv.Installed)
|
||||||
|
authorized := agentInfoByID(inv.Authorized)
|
||||||
|
|
||||||
|
tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||||
|
if _, err := fmt.Fprintln(tw, "ID\tLABEL\tINSTALL\tAUTH"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, info := range inv.Supported {
|
||||||
|
installLabel := "needs install"
|
||||||
|
authLabel := "auth unknown"
|
||||||
|
if installedInfo, ok := installed[info.ID]; ok {
|
||||||
|
installLabel = "installed"
|
||||||
|
switch installedInfo.AuthStatus {
|
||||||
|
case "authorized":
|
||||||
|
authLabel = "authorized"
|
||||||
|
case "unauthorized":
|
||||||
|
authLabel = "needs auth"
|
||||||
|
default:
|
||||||
|
authLabel = "auth unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := authorized[info.ID]; ok {
|
||||||
|
installLabel = "installed"
|
||||||
|
authLabel = "authorized"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, info.Label, installLabel, authLabel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentInfoByID(infos []agentInfo) map[string]agentInfo {
|
||||||
|
out := make(map[string]agentInfo, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
out[info.ID] = info
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentListUsesCachedCatalogByDefault(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agent ls failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "codex") || !strings.Contains(out, "needs install") {
|
||||||
|
t.Fatalf("output missing table labels:\n%s", out)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/agents"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentListRefreshAndStatuses(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh" {
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"aider","label":"Aider"},{"id":"codex","label":"Codex"},{"id":"goose","label":"Goose"},{"id":"opencode","label":"OpenCode"}],"installed":[{"id":"aider","label":"Aider","authStatus":"unauthorized"},{"id":"codex","label":"Codex","authStatus":"authorized"},{"id":"goose","label":"Goose","authStatus":"unknown"}],"authorized":[{"id":"codex","label":"Codex","authStatus":"authorized"}]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--refresh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agent ls --refresh failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"codex", "authorized", "aider", "needs auth", "goose", "auth unknown", "opencode", "needs install"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"POST /api/v1/agents/refresh"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentListJSONEmitsRawCatalog(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agent ls --json failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
var inv agentInventory
|
||||||
|
if err := json.Unmarshal([]byte(out), &inv); err != nil {
|
||||||
|
t.Fatalf("json output did not decode: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if len(inv.Supported) != 1 || len(inv.Installed) != 1 || len(inv.Authorized) != 1 {
|
||||||
|
t.Fatalf("inventory = %#v", inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,18 @@ type apiError struct {
|
||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiResponseError struct {
|
||||||
|
StatusCode int
|
||||||
|
ErrorBody apiError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e apiResponseError) Error() string {
|
||||||
|
if e.ErrorBody.Message == "" {
|
||||||
|
return fmt.Sprintf("daemon returned HTTP %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
return e.ErrorBody.String()
|
||||||
|
}
|
||||||
|
|
||||||
// String renders the envelope for the user: "<message> (<code>) [request <id>]",
|
// String renders the envelope for the user: "<message> (<code>) [request <id>]",
|
||||||
// omitting whichever parts the daemon left empty.
|
// omitting whichever parts the daemon left empty.
|
||||||
func (e apiError) String() string {
|
func (e apiError) String() string {
|
||||||
|
|
@ -128,10 +140,7 @@ func (c *commandContext) doJSONPath(ctx context.Context, method, path string, bo
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
var e apiError
|
var e apiError
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
if e.Message == "" {
|
return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e}
|
||||||
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s", e.String())
|
|
||||||
}
|
}
|
||||||
if out != nil {
|
if out != nil {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||||
|
agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent"
|
||||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||||
)
|
)
|
||||||
|
|
@ -106,6 +107,32 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string,
|
||||||
return sessionsvc.ClaimPRResult{}, nil
|
return sessionsvc.ClaimPRResult{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeAgentCatalog struct{}
|
||||||
|
|
||||||
|
var _ controllers.AgentCatalog = (*fakeAgentCatalog)(nil)
|
||||||
|
|
||||||
|
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||||
|
return authorizedCodexInventory(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) {
|
||||||
|
return authorizedCodexInventory(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||||
|
info := agentsvc.Info{ID: agentID, Label: agentID, AuthStatus: "authorized"}
|
||||||
|
return agentsvc.ProbeResult{Agent: info, Supported: true, Installed: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizedCodexInventory() agentsvc.Inventory {
|
||||||
|
info := agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"}
|
||||||
|
return agentsvc.Inventory{
|
||||||
|
Supported: []agentsvc.Info{info},
|
||||||
|
Installed: []agentsvc.Info{info},
|
||||||
|
Authorized: []agentsvc.Info{info},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fakeProjectManager captures the project.AddInput the controller decodes from
|
// fakeProjectManager captures the project.AddInput the controller decodes from
|
||||||
// the CLI's request body. Every other method is a no-op so it satisfies the
|
// the CLI's request body. Every other method is a no-op so it satisfies the
|
||||||
// projectsvc.Manager interface.
|
// projectsvc.Manager interface.
|
||||||
|
|
@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) {
|
||||||
return projectsvc.GetResult{}, nil
|
project := projectsvc.Project{ID: id, Path: "/repo/" + string(id)}
|
||||||
|
return projectsvc.GetResult{Status: "ok", Project: &project}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) {
|
func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) {
|
||||||
|
|
@ -150,6 +178,7 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||||
|
Agents: &fakeAgentCatalog{},
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
}, httpd.ControlDeps{})
|
}, httpd.ControlDeps{})
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
||||||
root.AddCommand(newStopCommand(ctx))
|
root.AddCommand(newStopCommand(ctx))
|
||||||
root.AddCommand(newStatusCommand(ctx))
|
root.AddCommand(newStatusCommand(ctx))
|
||||||
root.AddCommand(newDoctorCommand(ctx))
|
root.AddCommand(newDoctorCommand(ctx))
|
||||||
|
root.AddCommand(newAgentCommand(ctx))
|
||||||
root.AddCommand(newSpawnCommand(ctx))
|
root.AddCommand(newSpawnCommand(ctx))
|
||||||
root.AddCommand(newSendCommand(ctx))
|
root.AddCommand(newSendCommand(ctx))
|
||||||
root.AddCommand(newPreviewCommand(ctx))
|
root.AddCommand(newPreviewCommand(ctx))
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
|
@ -27,6 +32,7 @@ type spawnOptions struct {
|
||||||
name string
|
name string
|
||||||
claimPR string
|
claimPR string
|
||||||
noTakeover bool
|
noTakeover bool
|
||||||
|
skipAgentCheck bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
||||||
|
|
@ -37,7 +43,7 @@ type spawnRequest struct {
|
||||||
Harness string `json:"harness,omitempty"`
|
Harness string `json:"harness,omitempty"`
|
||||||
Branch string `json:"branch,omitempty"`
|
Branch string `json:"branch,omitempty"`
|
||||||
Prompt string `json:"prompt,omitempty"`
|
Prompt string `json:"prompt,omitempty"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type spawnResult struct {
|
type spawnResult struct {
|
||||||
|
|
@ -47,6 +53,12 @@ type spawnResult struct {
|
||||||
} `json:"session"`
|
} `json:"session"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type agentProbeResult struct {
|
||||||
|
Agent agentInfo `json:"agent"`
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
}
|
||||||
|
|
||||||
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
var opts spawnOptions
|
var opts spawnOptions
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
"fresh git worktree. Register the project first with `ao project add`.",
|
"fresh git worktree. Register the project first with `ao project add`.",
|
||||||
Args: noArgs,
|
Args: noArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if opts.project == "" {
|
|
||||||
return usageError{fmt.Errorf("--project is required")}
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(opts.name)
|
|
||||||
if name == "" {
|
|
||||||
return usageError{fmt.Errorf("--name is required")}
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(name) > maxDisplayNameLen {
|
|
||||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
|
||||||
}
|
|
||||||
if opts.noTakeover && opts.claimPR == "" {
|
if opts.noTakeover && opts.claimPR == "" {
|
||||||
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
||||||
}
|
}
|
||||||
claimRef := ""
|
if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen {
|
||||||
if opts.claimPR != "" {
|
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||||
project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project)
|
}
|
||||||
|
|
||||||
|
project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
opts.project = project.ID
|
||||||
|
|
||||||
|
harness, err := resolveSpawnHarness(opts.harness, project)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.harness = harness
|
||||||
|
|
||||||
|
name := resolveSpawnDisplayName(opts.name, opts.prompt)
|
||||||
|
if !opts.skipAgentCheck {
|
||||||
|
if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claimRef := ""
|
||||||
|
if opts.claimPR != "" {
|
||||||
claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
|
claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
} else {
|
} else {
|
||||||
attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)"
|
attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)"
|
||||||
}
|
}
|
||||||
_, err := fmt.Fprintf(out, "attach with: %s\n", attach)
|
_, err = fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
}
|
}
|
||||||
return pflag.NormalizedName(name)
|
return pflag.NormalizedName(name)
|
||||||
})
|
})
|
||||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (default: AO_PROJECT_ID or current registered repo)")
|
||||||
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
|
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
|
||||||
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
||||||
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
|
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)")
|
||||||
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
||||||
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
||||||
|
f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) fetchAgentInventory(ctx context.Context, refresh bool) (agentInventory, error) {
|
||||||
|
var inv agentInventory
|
||||||
|
if refresh {
|
||||||
|
if err := c.postJSON(ctx, "agents/refresh", struct{}{}, &inv); err != nil {
|
||||||
|
return agentInventory{}, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "agents", &inv); err != nil {
|
||||||
|
return agentInventory{}, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit string) (projectDetails, error) {
|
||||||
|
if id := strings.TrimSpace(explicit); id != "" {
|
||||||
|
return c.fetchProjectDetails(ctx, id)
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(os.Getenv("AO_PROJECT_ID")); id != "" {
|
||||||
|
return c.fetchProjectDetails(ctx, id)
|
||||||
|
}
|
||||||
|
if sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sessionID != "" {
|
||||||
|
project, err := c.resolveProjectFromSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, err
|
||||||
|
}
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
project, ok, err := c.resolveProjectFromCWD(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) {
|
||||||
|
sess, err := c.fetchScopedSession(ctx, sessionID, "")
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sess.ProjectID) == "" {
|
||||||
|
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||||
|
}
|
||||||
|
return c.fetchProjectDetails(ctx, sess.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDetails, bool, error) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, false, err
|
||||||
|
}
|
||||||
|
cwd, err = normalizeProjectMatchPath(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var list projectListResult
|
||||||
|
if err := c.getJSON(ctx, "projects", &list); err != nil {
|
||||||
|
return projectDetails{}, false, err
|
||||||
|
}
|
||||||
|
sort.Slice(list.Projects, func(i, j int) bool {
|
||||||
|
return list.Projects[i].ID < list.Projects[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
var best projectDetails
|
||||||
|
bestLen := -1
|
||||||
|
ambiguous := false
|
||||||
|
for _, summary := range list.Projects {
|
||||||
|
project, err := c.fetchProjectDetails(ctx, summary.ID)
|
||||||
|
if err != nil {
|
||||||
|
return projectDetails{}, false, err
|
||||||
|
}
|
||||||
|
if project.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projectPath, err := normalizeProjectMatchPath(project.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !pathContains(projectPath, cwd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathLen := len(projectPath)
|
||||||
|
switch {
|
||||||
|
case pathLen > bestLen:
|
||||||
|
best = project
|
||||||
|
bestLen = pathLen
|
||||||
|
ambiguous = false
|
||||||
|
case pathLen == bestLen:
|
||||||
|
ambiguous = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestLen == -1 {
|
||||||
|
return projectDetails{}, false, nil
|
||||||
|
}
|
||||||
|
if ambiguous {
|
||||||
|
return projectDetails{}, false, usageError{fmt.Errorf("current directory matches multiple registered projects; pass --project")}
|
||||||
|
}
|
||||||
|
return best, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProjectMatchPath(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if realPath, err := filepath.EvalSymlinks(abs); err == nil {
|
||||||
|
abs = realPath
|
||||||
|
}
|
||||||
|
return filepath.Clean(abs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathContains(root, child string) bool {
|
||||||
|
if root == child {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, child)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSpawnHarness(explicit string, project projectDetails) (string, error) {
|
||||||
|
if harness := strings.TrimSpace(explicit); harness != "" {
|
||||||
|
return harness, nil
|
||||||
|
}
|
||||||
|
if project.Config != nil {
|
||||||
|
if harness := strings.TrimSpace(project.Config.Worker.Agent); harness != "" {
|
||||||
|
return harness, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSpawnDisplayName(explicit, prompt string) string {
|
||||||
|
if name := strings.TrimSpace(explicit); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return deriveDisplayNameFromPrompt(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveDisplayNameFromPrompt(prompt string) string {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(prompt))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, field := range fields {
|
||||||
|
next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'")
|
||||||
|
if next == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
next = " " + next
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(next)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error {
|
||||||
|
inv, err := c.fetchAgentInventory(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state := agentCatalogStateFor(inv, agentID)
|
||||||
|
if !state.supported {
|
||||||
|
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||||
|
}
|
||||||
|
if !state.installed || state.authStatus == "unauthorized" {
|
||||||
|
fresh, err := c.probeSpawnAgent(ctx, agentID)
|
||||||
|
if err != nil {
|
||||||
|
if agentProbeUnavailable(err) {
|
||||||
|
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q fresh readiness probe is unavailable; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fresh.Supported {
|
||||||
|
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||||
|
}
|
||||||
|
if !fresh.Installed {
|
||||||
|
return fmt.Errorf("agent %q needs install; install the agent CLI or pass --skip-agent-check to let spawn validate it", agentID)
|
||||||
|
}
|
||||||
|
state.installed = true
|
||||||
|
state.authorized = fresh.Agent.AuthStatus == "authorized"
|
||||||
|
state.authStatus = fresh.Agent.AuthStatus
|
||||||
|
}
|
||||||
|
if state.authorized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if state.authStatus == "unauthorized" {
|
||||||
|
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q may need auth according to a fresh local probe; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q auth status is unknown; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *commandContext) probeSpawnAgent(ctx context.Context, agentID string) (agentProbeResult, error) {
|
||||||
|
var result agentProbeResult
|
||||||
|
if err := c.postJSON(ctx, "agents/"+url.PathEscape(agentID)+"/probe", struct{}{}, &result); err != nil {
|
||||||
|
return agentProbeResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentProbeUnavailable(err error) bool {
|
||||||
|
var apiErr apiResponseError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentCatalogState struct {
|
||||||
|
supported bool
|
||||||
|
installed bool
|
||||||
|
authorized bool
|
||||||
|
authStatus string
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentCatalogStateFor(inv agentInventory, agentID string) agentCatalogState {
|
||||||
|
state := agentCatalogState{}
|
||||||
|
for _, info := range inv.Supported {
|
||||||
|
if info.ID == agentID {
|
||||||
|
state.supported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, info := range inv.Authorized {
|
||||||
|
if info.ID == agentID {
|
||||||
|
state.installed = true
|
||||||
|
state.authorized = true
|
||||||
|
state.authStatus = "authorized"
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, info := range inv.Installed {
|
||||||
|
if info.ID == agentID {
|
||||||
|
state.installed = true
|
||||||
|
state.authorized = info.AuthStatus == "authorized"
|
||||||
|
state.authStatus = info.AuthStatus
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
// rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up
|
// rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up
|
||||||
// (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes
|
// (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes
|
||||||
// the seed-state row outright instead of marking it terminated — so the user
|
// the seed-state row outright instead of marking it terminated — so the user
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,44 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing
|
func authorizedAgentsJSON(agent string) string {
|
||||||
// --project before touching the network, so it fails fast without a daemon.
|
info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}`
|
||||||
func TestSpawnCommand_RequiresProject(t *testing.T) {
|
return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
|
||||||
var out, errb bytes.Buffer
|
|
||||||
root := NewRootCommand(Deps{Out: &out, Err: &errb})
|
|
||||||
root.SetArgs([]string{"spawn"})
|
|
||||||
err := root.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected an error when --project is missing")
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "--project is required") {
|
|
||||||
t.Fatalf("error = %v, want it to mention --project is required", err)
|
// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
|
||||||
|
// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
|
||||||
|
func TestSpawnCommand_MissingProjectContext(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" {
|
||||||
|
_, _ = io.WriteString(w, `{"projects":[]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when project context is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <agent>") {
|
||||||
|
t.Fatalf("error = %v, want project add hint", err)
|
||||||
|
}
|
||||||
|
if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +71,8 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`)
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`)
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim":
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim":
|
||||||
|
|
@ -66,14 +89,14 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
writeRunFileFor(t, cfg, srv)
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
|
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") {
|
if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") {
|
||||||
t.Fatalf("output missing claimed label: %s", out)
|
t.Fatalf("output missing claimed label: %s", out)
|
||||||
}
|
}
|
||||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
||||||
if !reflect.DeepEqual(requests, want) {
|
if !reflect.DeepEqual(requests, want) {
|
||||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
sessions["demo-10"] = true
|
sessions["demo-10"] = true
|
||||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`)
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`)
|
||||||
|
|
@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
writeRunFileFor(t, cfg, srv)
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142")
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected spawn claim failure")
|
t.Fatal("expected spawn claim failure")
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
||||||
if sessions["demo-10"] {
|
if sessions["demo-10"] {
|
||||||
t.Fatalf("spawned session still present after claim rollback: %#v", sessions)
|
t.Fatalf("spawned session still present after claim rollback: %#v", sessions)
|
||||||
}
|
}
|
||||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
||||||
if !reflect.DeepEqual(requests, want) {
|
if !reflect.DeepEqual(requests, want) {
|
||||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
}
|
}
|
||||||
|
|
@ -132,15 +157,6 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name
|
|
||||||
// before touching the network, mirroring the --project guard.
|
|
||||||
func TestSpawnCommand_RequiresName(t *testing.T) {
|
|
||||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo")
|
|
||||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") {
|
|
||||||
t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
|
// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
|
||||||
// longer than 20 characters without contacting the daemon.
|
// longer than 20 characters without contacting the daemon.
|
||||||
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
||||||
|
|
@ -149,3 +165,529 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
||||||
t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err))
|
t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
t.Setenv("AO_PROJECT_ID", "demo")
|
||||||
|
|
||||||
|
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "spawned session demo-11") {
|
||||||
|
t.Fatalf("output missing spawn: %s", out)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
|
||||||
|
_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-15","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
t.Setenv("AO_SESSION_ID", "demo-1")
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/sessions/demo-1", "GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/missing":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Session not found"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
t.Setenv("AO_SESSION_ID", "missing")
|
||||||
|
|
||||||
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) {
|
||||||
|
t.Fatalf("err=%v, want AO_SESSION_ID project error", err)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/sessions/missing"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnResolvesProjectFromCWD(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
repo := filepath.Join(t.TempDir(), "repo")
|
||||||
|
subdir := filepath.Join(repo, "pkg")
|
||||||
|
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(subdir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||||
|
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects":
|
||||||
|
_, _ = io.WriteString(w, `{"projects":[{"id":"demo","name":"Demo"}]}`)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":`+jsonQuote(repo)+`,"config":{"worker":{"agent":"codex"}}}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"authorized"},"supported":true,"installed":true}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if errOut != "" {
|
||||||
|
t.Fatalf("stderr = %q, want no warning after fresh authorized probe", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unauthorized"},"supported":true,"installed":true}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "may need auth according to a fresh local probe") {
|
||||||
|
t.Fatalf("stderr missing warning: %s", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
_, _ = io.WriteString(w, `{"message":"not implemented","code":"NOT_IMPLEMENTED"}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||||
|
t.Fatalf("stderr missing warning: %s", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") {
|
||||||
|
t.Fatalf("err=%v, want unsupported", err)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex"},"supported":true,"installed":false}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") {
|
||||||
|
t.Fatalf("err=%v, want needs install", err)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unknown"},"supported":true,"installed":true}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "auth status is unknown") {
|
||||||
|
t.Fatalf("stderr missing warning: %s", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = io.WriteString(w, `{"message":"not found","code":"NOT_FOUND"}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||||
|
t.Fatalf("stderr missing warning: %s", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = io.WriteString(w, `{"message":"probe failed","code":"PROBE_FAILED","requestId":"req-1"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") {
|
||||||
|
t.Fatalf("err=%v, want probe server error", err)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var requests []string
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
appendPrimaryRequest(&requests, r)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-14","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "unsupported" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions"}
|
||||||
|
if !reflect.DeepEqual(requests, want) {
|
||||||
|
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
|
||||||
|
cfg := setConfigEnv(t)
|
||||||
|
var req spawnRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||||
|
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unknown"}],"authorized":[]}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"session":{"id":"demo-13","status":"idle"}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
writeRunFileFor(t, cfg, srv)
|
||||||
|
|
||||||
|
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "auth status is unknown") {
|
||||||
|
t.Fatalf("stderr missing warning: %s", errOut)
|
||||||
|
}
|
||||||
|
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||||
|
t.Fatalf("spawn request = %#v", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,10 +132,16 @@ func Run() error {
|
||||||
}
|
}
|
||||||
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
|
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
|
||||||
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
|
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
|
||||||
|
agentSvc := agentsvc.New()
|
||||||
|
go func() {
|
||||||
|
if _, err := agentSvc.Refresh(ctx); err != nil {
|
||||||
|
log.Warn("initial agent catalog refresh failed", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
||||||
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
|
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
|
||||||
Agents: agentsvc.New(),
|
Agents: agentSvc,
|
||||||
Sessions: sessionSvc,
|
Sessions: sessionSvc,
|
||||||
Reviews: reviewSvc,
|
Reviews: reviewSvc,
|
||||||
Notifications: notifier,
|
Notifications: notifier,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,45 @@ paths:
|
||||||
summary: Return cached supported and locally installed agent adapters
|
summary: Return cached supported and locally installed agent adapters
|
||||||
tags:
|
tags:
|
||||||
- agents
|
- agents
|
||||||
|
/api/v1/agents/{agent}/probe:
|
||||||
|
post:
|
||||||
|
operationId: probeAgent
|
||||||
|
parameters:
|
||||||
|
- description: Agent adapter identifier.
|
||||||
|
in: path
|
||||||
|
name: agent
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: Agent adapter identifier.
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProbeAgentResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/APIError'
|
||||||
|
description: Bad Request
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/APIError'
|
||||||
|
description: Internal Server Error
|
||||||
|
"501":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/APIError'
|
||||||
|
description: Not Implemented
|
||||||
|
summary: Run a fresh local readiness probe for one agent adapter
|
||||||
|
tags:
|
||||||
|
- agents
|
||||||
/api/v1/agents/refresh:
|
/api/v1/agents/refresh:
|
||||||
post:
|
post:
|
||||||
operationId: refreshAgents
|
operationId: refreshAgents
|
||||||
|
|
@ -1975,6 +2014,19 @@ components:
|
||||||
- targetSha
|
- targetSha
|
||||||
- status
|
- status
|
||||||
type: object
|
type: object
|
||||||
|
ProbeAgentResponse:
|
||||||
|
properties:
|
||||||
|
agent:
|
||||||
|
$ref: '#/components/schemas/AgentInfo'
|
||||||
|
installed:
|
||||||
|
type: boolean
|
||||||
|
supported:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- agent
|
||||||
|
- supported
|
||||||
|
- installed
|
||||||
|
type: object
|
||||||
Project:
|
Project:
|
||||||
properties:
|
properties:
|
||||||
agent:
|
agent:
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ var schemaNames = map[string]string{
|
||||||
// httpd/controllers (wire envelopes)
|
// httpd/controllers (wire envelopes)
|
||||||
"ControllersListProjectsResponse": "ListProjectsResponse",
|
"ControllersListProjectsResponse": "ListProjectsResponse",
|
||||||
"ControllersProjectResponse": "ProjectResponse",
|
"ControllersProjectResponse": "ProjectResponse",
|
||||||
|
"ControllersAgentIDParam": "AgentIDParam",
|
||||||
"ControllersGetProjectResponse": "ProjectGetResponse",
|
"ControllersGetProjectResponse": "ProjectGetResponse",
|
||||||
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
||||||
"ControllersListSessionsQuery": "ListSessionsQuery",
|
"ControllersListSessionsQuery": "ListSessionsQuery",
|
||||||
|
|
@ -174,6 +175,7 @@ var schemaNames = map[string]string{
|
||||||
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
||||||
"AgentInventory": "ListAgentsResponse",
|
"AgentInventory": "ListAgentsResponse",
|
||||||
"AgentInfo": "AgentInfo",
|
"AgentInfo": "AgentInfo",
|
||||||
|
"AgentProbeResult": "ProbeAgentResponse",
|
||||||
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
||||||
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
||||||
"ControllersNotificationIDParam": "NotificationIDParam",
|
"ControllersNotificationIDParam": "NotificationIDParam",
|
||||||
|
|
@ -313,6 +315,17 @@ func agentOperations() []operation {
|
||||||
{http.StatusNotImplemented, envelope.APIError{}},
|
{http.StatusNotImplemented, envelope.APIError{}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: http.MethodPost, path: "/api/v1/agents/{agent}/probe", id: "probeAgent", tag: "agents",
|
||||||
|
summary: "Run a fresh local readiness probe for one agent adapter",
|
||||||
|
pathParams: []any{controllers.AgentIDParam{}},
|
||||||
|
resps: []respUnit{
|
||||||
|
{http.StatusOK, controllers.ProbeAgentResponse{}},
|
||||||
|
{http.StatusBadRequest, envelope.APIError{}},
|
||||||
|
{http.StatusInternalServerError, envelope.APIError{}},
|
||||||
|
{http.StatusNotImplemented, envelope.APIError{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package controllers
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ import (
|
||||||
type AgentCatalog interface {
|
type AgentCatalog interface {
|
||||||
List(ctx context.Context) (agentsvc.Inventory, error)
|
List(ctx context.Context) (agentsvc.Inventory, error)
|
||||||
Refresh(ctx context.Context) (agentsvc.Inventory, error)
|
Refresh(ctx context.Context) (agentsvc.Inventory, error)
|
||||||
|
Probe(ctx context.Context, agentID string) (agentsvc.ProbeResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentsController owns the /agents routes.
|
// AgentsController owns the /agents routes.
|
||||||
|
|
@ -26,6 +28,7 @@ type AgentsController struct {
|
||||||
func (c *AgentsController) Register(r chi.Router) {
|
func (c *AgentsController) Register(r chi.Router) {
|
||||||
r.Get("/agents", c.list)
|
r.Get("/agents", c.list)
|
||||||
r.Post("/agents/refresh", c.refresh)
|
r.Post("/agents/refresh", c.refresh)
|
||||||
|
r.Post("/agents/{agent}/probe", c.probe)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) {
|
func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -53,3 +56,21 @@ func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
envelope.WriteJSON(w, http.StatusOK, inventory)
|
envelope.WriteJSON(w, http.StatusOK, inventory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AgentsController) probe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c.Catalog == nil {
|
||||||
|
apispec.NotImplemented(w, r, "POST", "/api/v1/agents/{agent}/probe")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agentID := strings.TrimSpace(chi.URLParam(r, "agent"))
|
||||||
|
if agentID == "" {
|
||||||
|
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "AGENT_REQUIRED", "agent is required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := c.Catalog.Probe(r.Context(), agentID)
|
||||||
|
if err != nil {
|
||||||
|
envelope.WriteError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
envelope.WriteJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,12 @@ import (
|
||||||
type fakeAgentCatalog struct {
|
type fakeAgentCatalog struct {
|
||||||
inventory agentsvc.Inventory
|
inventory agentsvc.Inventory
|
||||||
refreshed agentsvc.Inventory
|
refreshed agentsvc.Inventory
|
||||||
|
probed agentsvc.ProbeResult
|
||||||
err error
|
err error
|
||||||
listCalls int
|
listCalls int
|
||||||
refreshCalls int
|
refreshCalls int
|
||||||
|
probeCalls int
|
||||||
|
probeAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||||
|
|
@ -35,6 +38,12 @@ func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error)
|
||||||
return f.inventory, f.err
|
return f.inventory, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||||
|
f.probeCalls++
|
||||||
|
f.probeAgent = agentID
|
||||||
|
return f.probed, f.err
|
||||||
|
}
|
||||||
|
|
||||||
func TestListAgents(t *testing.T) {
|
func TestListAgents(t *testing.T) {
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{
|
catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{
|
||||||
|
|
@ -92,3 +101,31 @@ func TestRefreshAgents(t *testing.T) {
|
||||||
t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls)
|
t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProbeAgent(t *testing.T) {
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
catalog := &fakeAgentCatalog{
|
||||||
|
probed: agentsvc.ProbeResult{
|
||||||
|
Agent: agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"},
|
||||||
|
Supported: true,
|
||||||
|
Installed: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||||
|
Agents: catalog,
|
||||||
|
}, httpd.ControlDeps{}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/codex/probe", "")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("POST /agents/codex/probe = %d, body=%s", status, body)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"supported":true`, `"installed":true`, `"id":"codex"`, `"authStatus":"authorized"`} {
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Fatalf("body missing %s: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if catalog.probeCalls != 1 || catalog.probeAgent != "codex" {
|
||||||
|
t.Fatalf("probe calls=%d agent=%q, want one codex probe", catalog.probeCalls, catalog.probeAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ type ProjectIDParam struct {
|
||||||
ID string `path:"id" description:"Project identifier (registry key)."`
|
ID string `path:"id" description:"Project identifier (registry key)."`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentIDParam is the {agent} path parameter for one-agent catalog probes.
|
||||||
|
type AgentIDParam struct {
|
||||||
|
Agent string `path:"agent" description:"Agent adapter identifier."`
|
||||||
|
}
|
||||||
|
|
||||||
// ListProjectsResponse is the body of GET /api/v1/projects.
|
// ListProjectsResponse is the body of GET /api/v1/projects.
|
||||||
type ListProjectsResponse struct {
|
type ListProjectsResponse struct {
|
||||||
Projects []projectsvc.Summary `json:"projects"`
|
Projects []projectsvc.Summary `json:"projects"`
|
||||||
|
|
@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory
|
||||||
// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh.
|
// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh.
|
||||||
type RefreshAgentsResponse = agentsvc.Inventory
|
type RefreshAgentsResponse = agentsvc.Inventory
|
||||||
|
|
||||||
|
// ProbeAgentResponse is the body of POST /api/v1/agents/{agent}/probe.
|
||||||
|
type ProbeAgentResponse = agentsvc.ProbeResult
|
||||||
|
|
||||||
// AgentInfo is one supported or installed agent entry.
|
// AgentInfo is one supported or installed agent entry.
|
||||||
type AgentInfo = agentsvc.Info
|
type AgentInfo = agentsvc.Info
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,61 @@ func TestRefreshIsRateLimited(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProbeBypassesRefreshRateLimitForOneAgent(t *testing.T) {
|
||||||
|
previous := agentRefreshMinInterval
|
||||||
|
agentRefreshMinInterval = time.Hour
|
||||||
|
t.Cleanup(func() { agentRefreshMinInterval = previous })
|
||||||
|
|
||||||
|
probes := 0
|
||||||
|
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||||
|
{
|
||||||
|
Harness: domain.AgentHarness("codex"),
|
||||||
|
Manifest: adapters.Manifest{
|
||||||
|
ID: "codex",
|
||||||
|
Name: "Codex",
|
||||||
|
},
|
||||||
|
Agent: probeTrackingAgent{fakeAgent: fakeAgent{}, onProbe: func() { probes++ }},
|
||||||
|
},
|
||||||
|
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := svc.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
got, err := svc.Probe(context.Background(), "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Supported || !got.Installed || got.Agent.ID != "codex" {
|
||||||
|
t.Fatalf("Probe = %#v, want supported installed codex", got)
|
||||||
|
}
|
||||||
|
if probes != 2 {
|
||||||
|
t.Fatalf("probes = %d, want refresh plus fresh probe", probes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeReportsUnsupportedAndMissingAgent(t *testing.T) {
|
||||||
|
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||||
|
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||||
|
})
|
||||||
|
|
||||||
|
missing, err := svc.Probe(context.Background(), "missing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe missing: %v", err)
|
||||||
|
}
|
||||||
|
if !missing.Supported || missing.Installed {
|
||||||
|
t.Fatalf("Probe missing = %#v, want supported but not installed", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsupported, err := svc.Probe(context.Background(), "unknown")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Probe unknown: %v", err)
|
||||||
|
}
|
||||||
|
if unsupported.Supported || unsupported.Installed || unsupported.Agent.ID != "unknown" {
|
||||||
|
t.Fatalf("Probe unknown = %#v, want unsupported unknown", unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func harnessAgent(id, label string, err error) agentregistry.HarnessAgent {
|
func harnessAgent(id, label string, err error) agentregistry.HarnessAgent {
|
||||||
return agentregistry.HarnessAgent{
|
return agentregistry.HarnessAgent{
|
||||||
Harness: domain.AgentHarness(id),
|
Harness: domain.AgentHarness(id),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ type probeResult struct {
|
||||||
authorized bool
|
authorized bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProbeResult describes a fresh readiness probe for one supported agent.
|
||||||
|
type ProbeResult struct {
|
||||||
|
Agent Info `json:"agent"`
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
}
|
||||||
|
|
||||||
// Info is the user-facing identity for an agent adapter.
|
// Info is the user-facing identity for an agent adapter.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) {
|
||||||
return next, nil
|
return next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Probe runs a fresh bounded binary/auth probe for one agent, bypassing the
|
||||||
|
// catalog refresh rate limit. It is intended for user-initiated preflight paths
|
||||||
|
// where a cached negative catalog result may be stale.
|
||||||
|
func (s *Service) Probe(ctx context.Context, agentID string) (ProbeResult, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
for _, item := range s.agents {
|
||||||
|
info := Info{ID: string(item.Harness), Label: item.Manifest.Name}
|
||||||
|
if info.Label == "" {
|
||||||
|
info.Label = info.ID
|
||||||
|
}
|
||||||
|
if info.ID != agentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res := probeAgent(ctx, item)
|
||||||
|
return ProbeResult{
|
||||||
|
Agent: res.info,
|
||||||
|
Supported: true,
|
||||||
|
Installed: res.installed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return ProbeResult{Agent: Info{ID: agentID}, Supported: false, Installed: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func supportedInfos(agents []agentregistry.HarnessAgent) []Info {
|
func supportedInfos(agents []agentregistry.HarnessAgent) []Info {
|
||||||
supported := make([]Info, 0, len(agents))
|
supported := make([]Info, 0, len(agents))
|
||||||
for _, item := range agents {
|
for _, item := range agents {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,18 @@ It starts, discovers, inspects, and stops the daemon through the loopback HTTP
|
||||||
surface and the `running.json` handshake. It must not open SQLite directly or
|
surface and the `running.json` handshake. It must not open SQLite directly or
|
||||||
call runtime, workspace, tracker, or agent adapters in-process.
|
call runtime, workspace, tracker, or agent adapters in-process.
|
||||||
|
|
||||||
|
When using the CLI directly from a shell, make sure the daemon is running first
|
||||||
|
with `ao start` or by opening the desktop app. Product commands such as
|
||||||
|
`ao agent ls` and `ao spawn` call the loopback daemon and will fail with a
|
||||||
|
"daemon is not running" error if no `running.json` points at a live process. From
|
||||||
|
a source checkout, build and run the local binary explicitly, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build -o ./bin/ao ./cmd/ao
|
||||||
|
./bin/ao agent ls
|
||||||
|
```
|
||||||
|
|
||||||
## Current commands
|
## Current commands
|
||||||
|
|
||||||
Every product command resolves to a daemon HTTP route. Run `ao <command>
|
Every product command resolves to a daemon HTTP route. Run `ao <command>
|
||||||
|
|
@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
||||||
| `ao project get <id>` | `GET /api/v1/projects/{id}` |
|
| `ao project get <id>` | `GET /api/v1/projects/{id}` |
|
||||||
| `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` |
|
| `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` |
|
||||||
| `ao project rm <id>` | `DELETE /api/v1/projects/{id}` |
|
| `ao project rm <id>` | `DELETE /api/v1/projects/{id}` |
|
||||||
|
| `ao agent ls` | `GET /api/v1/agents` |
|
||||||
|
| `ao agent ls --refresh` | `POST /api/v1/agents/refresh` |
|
||||||
| `ao spawn` | `POST /api/v1/sessions` |
|
| `ao spawn` | `POST /api/v1/sessions` |
|
||||||
| `ao session ls` | `GET /api/v1/sessions` |
|
| `ao session ls` | `GET /api/v1/sessions` |
|
||||||
| `ao session get <id>` | `GET /api/v1/sessions/{id}` |
|
| `ao session get <id>` | `GET /api/v1/sessions/{id}` |
|
||||||
|
|
@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
||||||
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
|
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
|
||||||
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
|
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
|
||||||
|
|
||||||
|
`ao agent ls` prints the daemon-supported agent catalog with local install/auth
|
||||||
|
readiness. Use `--refresh` to rerun the bounded local probes and `--json` to
|
||||||
|
print the raw inventory response.
|
||||||
|
|
||||||
|
`ao spawn` resolves project context in this order: explicit `--project`,
|
||||||
|
`AO_PROJECT_ID`, `AO_SESSION_ID` (by fetching the current session from the
|
||||||
|
daemon), then the current working directory matched against registered project
|
||||||
|
paths. If `AO_SESSION_ID` is set but the session cannot be fetched, pass
|
||||||
|
`--project` explicitly.
|
||||||
|
|
||||||
|
If `--agent` / `--harness` is omitted, `ao spawn` uses the resolved project's
|
||||||
|
`worker.agent` config. Before spawning, the CLI refreshes the advisory agent
|
||||||
|
catalog and fails early when the selected agent is unsupported, not installed,
|
||||||
|
or unauthorized. It warns-but-continues when auth remains unknown because daemon
|
||||||
|
spawn remains the authoritative runtime validation point. Use
|
||||||
|
`--skip-agent-check` to bypass only this CLI-side preflight.
|
||||||
|
|
||||||
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
|
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
|
||||||
(it is meant to run inside a session), not a flag. With no argument it
|
(it is meant to run inside a session), not a flag. With no argument it
|
||||||
autodetects an `index.html` in the session workspace; with a URL argument it
|
autodetects an `index.html` in the session workspace; with a URL argument it
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/agents/{agent}/probe": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Run a fresh local readiness probe for one agent adapter */
|
||||||
|
post: operations["probeAgent"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/agents/refresh": {
|
"/api/v1/agents/refresh": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -708,6 +725,11 @@ export interface components {
|
||||||
targetSha: string;
|
targetSha: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
ProbeAgentResponse: {
|
||||||
|
agent: components["schemas"]["AgentInfo"];
|
||||||
|
installed: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
};
|
||||||
Project: {
|
Project: {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
config?: components["schemas"]["ProjectConfig"];
|
config?: components["schemas"]["ProjectConfig"];
|
||||||
|
|
@ -1027,6 +1049,56 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
probeAgent: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Agent adapter identifier. */
|
||||||
|
agent: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProbeAgentResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["APIError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal Server Error */
|
||||||
|
500: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["APIError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not Implemented */
|
||||||
|
501: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["APIError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
refreshAgents: {
|
refreshAgents: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue