diff --git a/backend/internal/adapters/agent/agy/auth.go b/backend/internal/adapters/agent/agy/auth.go new file mode 100644 index 00000000..8ef67dd0 --- /dev/null +++ b/backend/internal/adapters/agent/agy/auth.go @@ -0,0 +1,18 @@ +package agy + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/aider/auth.go b/backend/internal/adapters/agent/aider/auth.go new file mode 100644 index 00000000..9fb43ac9 --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth.go @@ -0,0 +1,18 @@ +package aider + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/amp/auth.go b/backend/internal/adapters/agent/amp/auth.go new file mode 100644 index 00000000..f44bb2bf --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth.go @@ -0,0 +1,18 @@ +package amp + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/auggie/auth.go b/backend/internal/adapters/agent/auggie/auth.go new file mode 100644 index 00000000..0d196cd1 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth.go @@ -0,0 +1,18 @@ +package auggie + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe.go b/backend/internal/adapters/agent/authprobe/authprobe.go new file mode 100644 index 00000000..ec7f18f6 --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe.go @@ -0,0 +1,96 @@ +package authprobe + +import ( + "context" + "os/exec" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// DefaultCommands are cheap local auth/status probes common across agent CLIs. +// Unsupported commands usually exit quickly with help text and are treated as +// unknown rather than unauthorized. +var DefaultCommands = [][]string{ + {"auth", "status"}, + {"login", "status"}, + {"providers", "list"}, +} + +// CLIStatus runs bounded local CLI probes and classifies their output. +func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + if len(commands) == 0 { + commands = DefaultCommands + } + for _, args := range commands { + status, err := commandStatus(ctx, binary, args) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + } + return ports.AgentAuthStatusUnknown, nil +} + +func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, args...).CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if hasAny(text, + "not logged in", + "logged out", + "not authenticated", + "unauthenticated", + "authentication required", + "not authorized", + "unauthorized", + "login required", + "no credentials", + "0 credentials", + "no api key", + "no token", + `"loggedin": false`, + `"loggedin":false`, + ) { + return ports.AgentAuthStatusUnauthorized, nil + } + if hasAny(text, + "logged in", + "authenticated", + "authorized", + "token valid", + "api key found", + "credentials found", + `"loggedin": true`, + `"loggedin":true`, + ) { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func hasAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/autohand/auth.go b/backend/internal/adapters/agent/autohand/auth.go new file mode 100644 index 00000000..9fd1aee7 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth.go @@ -0,0 +1,18 @@ +package autohand + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index bc3b5437..5fe0f01b 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -26,6 +26,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/google/uuid" @@ -60,6 +61,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -268,6 +270,35 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Claude Code's local authentication state without starting a +// session. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.claudeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + var status struct { + LoggedIn bool `json:"loggedIn"` + } + if json.Unmarshal(out, &status) == nil { + if status.LoggedIn { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnauthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // claudeSessionUUID maps an AO session id onto a stable Claude Code // session UUID via UUIDv5 over a fixed namespace, so the same AO session // always resolves to the same Claude session. diff --git a/backend/internal/adapters/agent/cline/auth.go b/backend/internal/adapters/agent/cline/auth.go new file mode 100644 index 00000000..a70d7874 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth.go @@ -0,0 +1,18 @@ +package cline + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 68b33d7d..a6e50b69 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -13,8 +13,10 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -34,6 +36,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -144,10 +147,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Codex's local login state without making a model call. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.codexBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "logged in") { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // ResolveCodexBinary returns the path to the codex binary on this machine, // searching PATH then a handful of well-known install locations -// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback -// so callers see a clear "command not found" rather than an empty argv. +// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch +// fallback so callers see a clear "command not found" rather than an empty +// argv. func ResolveCodexBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -199,6 +229,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { filepath.Join(home, ".cargo", "bin", "codex"), filepath.Join(home, ".npm", "bin", "codex"), ) + candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...) } for _, candidate := range candidates { @@ -213,6 +244,15 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound) } +func nvmNodeBinCandidates(home, binary string) []string { + matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary)) + if err != nil || len(matches) == 0 { + return nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches +} + func (p *Plugin) codexBinary(ctx context.Context) (string, error) { p.binaryMu.Lock() defer p.binaryMu.Unlock() diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index dba7e2e2..f5fcbfb7 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -87,6 +87,28 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) { } } +func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) { + home := t.TempDir() + binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "codex") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + + got, err := ResolveCodexBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveCodexBinary: %v", err) + } + if got != want { + t.Fatalf("ResolveCodexBinary = %q, want %q", got, want) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/adapters/agent/continueagent/auth.go b/backend/internal/adapters/agent/continueagent/auth.go new file mode 100644 index 00000000..0d3a25af --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth.go @@ -0,0 +1,18 @@ +package continueagent + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/copilot/auth.go b/backend/internal/adapters/agent/copilot/auth.go new file mode 100644 index 00000000..4e4f98ea --- /dev/null +++ b/backend/internal/adapters/agent/copilot/auth.go @@ -0,0 +1,18 @@ +package copilot + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/crush/auth.go b/backend/internal/adapters/agent/crush/auth.go new file mode 100644 index 00000000..ff39bdc1 --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth.go @@ -0,0 +1,18 @@ +package crush + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/cursor/auth.go b/backend/internal/adapters/agent/cursor/auth.go new file mode 100644 index 00000000..43c330c6 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth.go @@ -0,0 +1,18 @@ +package cursor + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/devin/auth.go b/backend/internal/adapters/agent/devin/auth.go new file mode 100644 index 00000000..bdea20df --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth.go @@ -0,0 +1,18 @@ +package devin + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/droid/auth.go b/backend/internal/adapters/agent/droid/auth.go new file mode 100644 index 00000000..1b6fb752 --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth.go @@ -0,0 +1,18 @@ +package droid + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/goose/auth.go b/backend/internal/adapters/agent/goose/auth.go new file mode 100644 index 00000000..ba46324a --- /dev/null +++ b/backend/internal/adapters/agent/goose/auth.go @@ -0,0 +1,18 @@ +package goose + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/grok/auth.go b/backend/internal/adapters/agent/grok/auth.go new file mode 100644 index 00000000..17275332 --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth.go @@ -0,0 +1,18 @@ +package grok + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/kilocode/auth.go b/backend/internal/adapters/agent/kilocode/auth.go new file mode 100644 index 00000000..f073cb6b --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth.go @@ -0,0 +1,18 @@ +package kilocode + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/kimi/auth.go b/backend/internal/adapters/agent/kimi/auth.go new file mode 100644 index 00000000..8753fe0c --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth.go @@ -0,0 +1,18 @@ +package kimi + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/kiro/auth.go b/backend/internal/adapters/agent/kiro/auth.go new file mode 100644 index 00000000..5ebccd43 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/auth.go @@ -0,0 +1,18 @@ +package kiro + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 377f1bde..02e59766 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -23,6 +23,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -55,6 +56,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -158,6 +160,33 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks whether opencode has at least one configured provider +// credential. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.opencodeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "providers", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "0 credentials") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "credential") && err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // appendPermissionFlags maps AO's permission modes onto opencode's single // approval flag. opencode exposes only --dangerously-skip-permissions (no // graduated accept-edits/auto modes), so: diff --git a/backend/internal/adapters/agent/pi/auth.go b/backend/internal/adapters/agent/pi/auth.go new file mode 100644 index 00000000..e78281ff --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth.go @@ -0,0 +1,18 @@ +package pi + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/qwen/auth.go b/backend/internal/adapters/agent/qwen/auth.go new file mode 100644 index 00000000..4f3f7cc8 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth.go @@ -0,0 +1,18 @@ +package qwen + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 77f9b526..91141e9d 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -83,8 +83,9 @@ func Build() (*adapters.Registry, error) { // harness is the adapter's manifest id, which is also the domain.AgentHarness // value a session carries and the `--harness` flag users pass. type HarnessAgent struct { - Harness domain.AgentHarness - Agent ports.Agent + Harness domain.AgentHarness + Manifest adapters.Manifest + Agent ports.Agent } // Harnessed returns every shipped adapter that drives an agent, paired with its @@ -99,8 +100,9 @@ func Harnessed() []HarnessAgent { continue } out = append(out, HarnessAgent{ - Harness: domain.AgentHarness(a.Manifest().ID), - Agent: agent, + Harness: domain.AgentHarness(a.Manifest().ID), + Manifest: a.Manifest(), + Agent: agent, }) } return out diff --git a/backend/internal/adapters/agent/registry/registry_test.go b/backend/internal/adapters/agent/registry/registry_test.go index 269abced..2c2f56c9 100644 --- a/backend/internal/adapters/agent/registry/registry_test.go +++ b/backend/internal/adapters/agent/registry/registry_test.go @@ -52,6 +52,14 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { } } +func TestEveryHarnessReportsAuthStatus(t *testing.T) { + for _, ha := range Harnessed() { + if _, ok := ha.Agent.(ports.AgentAuthChecker); !ok { + t.Errorf("%s does not implement ports.AgentAuthChecker", ha.Harness) + } + } +} + // workspaceFiles returns every regular file under root, relative to root. func workspaceFiles(t *testing.T, root string) []string { t.Helper() diff --git a/backend/internal/adapters/agent/vibe/auth.go b/backend/internal/adapters/agent/vibe/auth.go new file mode 100644 index 00000000..99a8653d --- /dev/null +++ b/backend/internal/adapters/agent/vibe/auth.go @@ -0,0 +1,18 @@ +package vibe + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + cmd, err := p.GetLaunchCommand(ctx, ports.LaunchConfig{}) + if err != nil || len(cmd) == 0 { + return ports.AgentAuthStatusUnknown, err + } + return authprobe.CLIStatus(ctx, cmd[0], nil) +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 747f5251..664712a8 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -16,6 +16,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -108,7 +109,8 @@ func Run() error { } srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ - Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc}), + Agents: agentsvc.New(), + Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent)}), Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 40b65d8a..cecf7d0c 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -18,6 +18,7 @@ import ( // APIDeps bundles every service the API layer's controllers depend on. type APIDeps struct { + Agents controllers.AgentCatalog Projects projectsvc.Manager Sessions controllers.SessionService Activity controllers.ActivityRecorder @@ -33,6 +34,7 @@ type APIDeps struct { // router invokes to mount the /api/v1 surface. type API struct { cfg config.Config + agents *controllers.AgentsController projects *controllers.ProjectsController sessions *controllers.SessionsController prs *controllers.PRsController @@ -47,6 +49,9 @@ type API struct { func NewAPI(cfg config.Config, deps APIDeps) *API { return &API{ cfg: cfg, + agents: &controllers.AgentsController{ + Catalog: deps.Agents, + }, projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, @@ -75,6 +80,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) + a.agents.Register(r) a.projects.Register(r) a.sessions.Register(r) a.prs.Register(r) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a279d460..d78d63a0 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -8,6 +8,31 @@ servers: - description: Local daemon (loopback only) url: http://127.0.0.1:3001 paths: + /api/v1/agents: + get: + operationId: listAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "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: List supported and locally installed agent adapters + tags: + - agents /api/v1/events: get: operationId: streamEvents @@ -1195,6 +1220,22 @@ components: permissions: type: string type: object + AgentInfo: + properties: + authStatus: + enum: + - authorized + - unauthorized + - unknown + type: string + id: + type: string + label: + type: string + required: + - id + - label + type: object ClaimPRRequest: properties: allowTakeover: @@ -1349,6 +1390,25 @@ components: - ok - sessionId type: object + ListAgentsResponse: + properties: + authorized: + items: + $ref: '#/components/schemas/AgentInfo' + type: array + installed: + items: + $ref: '#/components/schemas/AgentInfo' + type: array + supported: + items: + $ref: '#/components/schemas/AgentInfo' + type: array + required: + - supported + - installed + - authorized + type: object ListNotificationsResponse: properties: notifications: @@ -1895,6 +1955,8 @@ components: - repo type: object tags: +- description: Supported and locally runnable agent adapters + name: agents - description: Project registry, configuration, and lifecycle administration name: projects - description: Agent session lifecycle and messaging diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 2aeca734..2f7ba202 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -55,6 +55,8 @@ func Build() ([]byte, error) { *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), } r.Spec.Tags = []openapi31.Tag{ + *(&openapi31.Tag{Name: "agents"}).WithDescription( + "Supported and locally runnable agent adapters"), *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( @@ -157,6 +159,8 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + "AgentInventory": "ListAgentsResponse", + "AgentInfo": "AgentInfo", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationTarget": "NotificationTarget", @@ -254,6 +258,7 @@ type operation struct { func operations() []operation { ops := append([]operation{}, eventOperations()...) + ops = append(ops, agentOperations()...) ops = append(ops, projectOperations()...) ops = append(ops, sessionOperations()...) ops = append(ops, prOperations()...) @@ -262,6 +267,20 @@ func operations() []operation { return ops } +func agentOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/agents", id: "listAgents", tag: "agents", + summary: "List supported and locally installed agent adapters", + resps: []respUnit{ + {http.StatusOK, controllers.ListAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} + func notificationOperations() []operation { return []operation{ { diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go new file mode 100644 index 00000000..f829c628 --- /dev/null +++ b/backend/internal/httpd/controllers/agents.go @@ -0,0 +1,40 @@ +package controllers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +// AgentCatalog is the controller-facing contract for local agent inventory. +type AgentCatalog interface { + List(ctx context.Context) (agentsvc.Inventory, error) +} + +// AgentsController owns the /agents routes. +type AgentsController struct { + Catalog AgentCatalog +} + +// Register mounts the agent inventory routes on the supplied router. +func (c *AgentsController) Register(r chi.Router) { + r.Get("/agents", c.list) +} + +func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/agents") + return + } + inventory, err := c.Catalog.List(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ListAgentsResponse(inventory)) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go new file mode 100644 index 00000000..f159e8e5 --- /dev/null +++ b/backend/internal/httpd/controllers/agents_test.go @@ -0,0 +1,49 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +type fakeAgentCatalog struct { + inventory agentsvc.Inventory + err error +} + +func (f fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + return f.inventory, f.err +} + +func TestListAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: fakeAgentCatalog{inventory: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "claude-code", Label: "Claude Code"}, {ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }}, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/agents", "") + if status != http.StatusOK { + t.Fatalf("GET /agents = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if strings.Contains(string(body), `"counts"`) { + t.Fatalf("body includes removed counts field: %s", body) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 2da2fe91..df58baaa 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -6,6 +6,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) @@ -271,6 +272,12 @@ type OrchestratorResponse struct { ProjectName string `json:"projectName,omitempty"` } +// ListAgentsResponse is the body of GET /api/v1/agents. +type ListAgentsResponse = agentsvc.Inventory + +// AgentInfo is one supported or installed agent entry. +type AgentInfo = agentsvc.Info + // ListNotificationsQuery is the query string accepted by GET /api/v1/notifications. type ListNotificationsQuery struct { Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."` diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 93c534b2..a4fbbf80 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -14,6 +14,16 @@ import ( // for a live session. var ErrAgentBinaryNotFound = errors.New("agent: binary not found on PATH") +// AgentAuthStatus describes whether an installed agent is ready to make +// authenticated model calls. +type AgentAuthStatus string + +const ( + AgentAuthStatusAuthorized AgentAuthStatus = "authorized" + AgentAuthStatusUnauthorized AgentAuthStatus = "unauthorized" + AgentAuthStatusUnknown AgentAuthStatus = "unknown" +) + // Agent is the contract every CLI coding agent adapter (claude-code, codex, …) // must satisfy. It supplies the argv and process configuration the Session // Manager needs to launch, restore, and read back a native agent session. @@ -42,6 +52,12 @@ type Agent interface { SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error) } +// AgentAuthChecker is the optional capability for adapters whose native CLI has +// a cheap local authentication status probe. +type AgentAuthChecker interface { + AuthStatus(ctx context.Context) (AgentAuthStatus, error) +} + // AgentResolver maps a session's harness onto the Agent adapter that drives it, // so the Session Manager can spawn (and restore) a different agent per session // without depending on the concrete adapter registry. ok=false means no adapter diff --git a/backend/internal/service/agent/catalog.go b/backend/internal/service/agent/catalog.go new file mode 100644 index 00000000..b6a72303 --- /dev/null +++ b/backend/internal/service/agent/catalog.go @@ -0,0 +1,106 @@ +package agent + +import ( + "context" + "errors" + "sort" + + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Info is the user-facing identity for an agent adapter. +type Info struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus ports.AgentAuthStatus `json:"authStatus,omitempty" enum:"authorized,unauthorized,unknown"` +} + +// Inventory describes all daemon-supported agents and which are runnable here. +type Inventory struct { + Supported []Info `json:"supported"` + Installed []Info `json:"installed"` + Authorized []Info `json:"authorized"` +} + +// Service reports supported and locally runnable agent adapters. +type Service struct { + agents []agentregistry.HarnessAgent +} + +// New returns an agent inventory service backed by the daemon's shipped +// adapter registry. +func New() *Service { + return &Service{agents: agentregistry.Harnessed()} +} + +// NewWithAgents returns an inventory service over a caller-provided adapter +// slice. It is used by focused tests. +func NewWithAgents(agents []agentregistry.HarnessAgent) *Service { + return &Service{agents: agents} +} + +// List returns every supported agent plus the subset whose binary can be +// resolved on this machine. Detector errors are intentionally isolated to the +// affected agent; one broken adapter should not hide the rest of the catalog. +func (s *Service) List(ctx context.Context) (Inventory, error) { + supported := make([]Info, 0, len(s.agents)) + installed := make([]Info, 0, len(s.agents)) + authorized := make([]Info, 0, len(s.agents)) + for _, item := range s.agents { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + supported = append(supported, info) + if _, err := item.Agent.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil { + info.AuthStatus = authStatus(ctx, item.Agent) + installed = append(installed, info) + if info.AuthStatus == ports.AgentAuthStatusAuthorized { + authorized = append(authorized, info) + } + } else if errors.Is(err, ports.ErrAgentBinaryNotFound) { + continue + } else { + continue + } + } + + sortInfos(supported) + sortInfos(installed) + sortInfos(authorized) + return Inventory{ + Supported: supported, + Installed: installed, + Authorized: authorized, + }, nil +} + +func authStatus(ctx context.Context, a ports.Agent) ports.AgentAuthStatus { + checker, ok := a.(ports.AgentAuthChecker) + if !ok { + return ports.AgentAuthStatusUnknown + } + status, err := checker.AuthStatus(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return ports.AgentAuthStatusUnknown + } + return ports.AgentAuthStatusUnknown + } + switch status { + case ports.AgentAuthStatusAuthorized, ports.AgentAuthStatusUnauthorized: + return status + default: + return ports.AgentAuthStatusUnknown + } +} + +func sortInfos(infos []Info) { + sort.Slice(infos, func(i, j int) bool { + return infos[i].ID < infos[j].ID + }) +} diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go new file mode 100644 index 00000000..2dd88d6b --- /dev/null +++ b/backend/internal/service/agent/catalog_test.go @@ -0,0 +1,131 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeAgent struct { + err error +} + +type fakeAuthAgent struct { + fakeAgent + status ports.AgentAuthStatus + authErr error +} + +func (f fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} + +func (f fakeAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) { + if f.err != nil { + return nil, f.err + } + return []string{"agent"}, nil +} + +func (f fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} + +func (f fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { + return nil +} + +func (f fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} + +func (f fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func (f fakeAuthAgent) AuthStatus(context.Context) (ports.AgentAuthStatus, error) { + return f.status, f.authErr +} + +func TestListReportsInstalledAgentsAndIgnoresDetectorErrors(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + harnessAgent("broken", "Broken", errors.New("unexpected detector failure")), + }) + + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got.Supported) != 3 { + t.Fatalf("supported = %#v, want 3 agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestListReportsAuthorizedInstalledAgents(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAuthAgent("codex", "Codex", ports.AgentAuthStatusAuthorized, nil), + harnessAuthAgent("claude-code", "Claude Code", ports.AgentAuthStatusUnauthorized, nil), + harnessAgent("opencode", "OpenCode", nil), + harnessAuthAgent("broken-auth", "Broken Auth", ports.AgentAuthStatusAuthorized, errors.New("probe failed")), + }) + + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got.Supported) != 4 || len(got.Installed) != 4 { + t.Fatalf("inventory = %#v, want supported=4 installed=4", got) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "codex" { + t.Fatalf("authorized = %#v, want only codex", got.Authorized) + } + + byID := map[string]Info{} + for _, info := range got.Installed { + byID[info.ID] = info + } + if byID["codex"].AuthStatus != ports.AgentAuthStatusAuthorized { + t.Fatalf("codex authStatus = %q", byID["codex"].AuthStatus) + } + if byID["claude-code"].AuthStatus != ports.AgentAuthStatusUnauthorized { + t.Fatalf("claude-code authStatus = %q", byID["claude-code"].AuthStatus) + } + if byID["opencode"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("opencode authStatus = %q", byID["opencode"].AuthStatus) + } + if byID["broken-auth"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("broken-auth authStatus = %q", byID["broken-auth"].AuthStatus) + } +} + +func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAgent{err: err}, + } +} + +func harnessAuthAgent(id, label string, status ports.AgentAuthStatus, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAuthAgent{fakeAgent: fakeAgent{}, status: status, authErr: err}, + } +} diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 3e32f73b..b744eb82 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" ) @@ -46,6 +47,10 @@ type SessionTeardowner interface { type Service struct { store Store sessions SessionTeardowner + // defaultHarness is the daemon's configured default agent. Project detail + // responses expose it so clients can compare an explicit empty override + // against the real effective default. + defaultHarness domain.AgentHarness // addMu serialises the whole body of Add. Workspace registration performs // filesystem mutations (git init, .gitignore writes, commits) that are not // covered by the store's own writeMu, so path/id conflict checks plus the @@ -59,6 +64,9 @@ var _ Manager = (*Service)(nil) type Deps struct { Store Store Sessions SessionTeardowner + // DefaultHarness is the daemon's configured default agent (AO_AGENT). + // When empty, the service falls back to config.DefaultAgent. + DefaultHarness domain.AgentHarness } // New returns a project service backed by the given durable store. @@ -68,7 +76,11 @@ func New(store Store) *Service { // NewWithDeps returns a project service with optional teardown dependencies. func NewWithDeps(d Deps) *Service { - return &Service{store: d.Store, sessions: d.Sessions} + defaultHarness := d.DefaultHarness + if defaultHarness == "" { + defaultHarness = domain.AgentHarness(config.DefaultAgent) + } + return &Service{store: d.Store, sessions: d.Sessions, defaultHarness: defaultHarness} } // List returns every active registered project. @@ -102,7 +114,7 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } - p := projectFromRow(row) + p := m.projectFromRow(row) if row.Kind.WithDefault() == domain.ProjectKindWorkspace { repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) if err != nil { @@ -187,7 +199,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { if err := m.store.UpsertWorkspaceProject(ctx, row, repos); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project") } - p := projectFromRow(row) + p := m.projectFromRow(row) p.WorkspaceRepos = workspaceReposFromRecords(repos) return p, nil } @@ -208,7 +220,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } - return projectFromRow(row), nil + return m.projectFromRow(row), nil } // SetConfig replaces the project's stored config. The typed config is validated @@ -231,7 +243,7 @@ func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConf if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") } - return projectFromRow(row), nil + return m.projectFromRow(row), nil } // resolveGitOriginURL returns the project's `origin` remote URL via @@ -297,7 +309,7 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row domain.ProjectRecord) Project { +func (m *Service) projectFromRow(row domain.ProjectRecord) Project { p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -305,6 +317,7 @@ func projectFromRow(row domain.ProjectRecord) Project { Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: row.Config.WithDefaults().DefaultBranch, + Agent: string(m.defaultHarness), } if !row.Config.IsZero() { cfg := row.Config diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 057afc84..ed4cbe06 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -187,6 +187,9 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { if got.Project.DefaultBranch != domain.DefaultBranchName { t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName) } + if got.Project.Agent != "claude-code" { + t.Fatalf("default agent = %q, want claude-code", got.Project.Agent) + } if got.Project.Config != nil { t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config) } @@ -200,6 +203,32 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } } +func TestManager_GetUsesConfiguredDefaultHarness(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + m := project.NewWithDeps(project.Deps{Store: store, DefaultHarness: domain.HarnessCodex}) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil { + t.Fatalf("Get returned no project: %#v", got) + } + if got.Project.Agent != "codex" { + t.Fatalf("default agent = %q, want codex", got.Project.Agent) + } +} + func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/session/claim_pr.go b/backend/internal/service/session/claim_pr.go index 6d6cd92b..509ca2b0 100644 --- a/backend/internal/service/session/claim_pr.go +++ b/backend/internal/service/session/claim_pr.go @@ -47,9 +47,11 @@ type ClaimPRResult struct { // ListPRs returns all PRs currently owned by a session, ordered for display. func (s *Service) ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) { - if _, ok, err := s.store.GetSession(ctx, id); err != nil { + _, ok, err := s.store.GetSession(ctx, id) + if err != nil { return nil, fmt.Errorf("get %s: %w", id, err) - } else if !ok { + } + if !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.listPRFacts(ctx, id) diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 26a39fa0..1f6fb417 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -40,6 +40,7 @@ type commander interface { Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + RetireOrchestrator(ctx context.Context, id domain.SessionID) error Send(ctx context.Context, id domain.SessionID, message string) error Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error) @@ -155,26 +156,40 @@ func (s *Service) requireProject(ctx context.Context, id domain.ProjectID) error } // SpawnOrchestrator spawns an orchestrator session for a project. When clean is -// true it first tears down any active orchestrator(s) for that project so the new -// one is the only live coordinator — a business rule that belongs here, not in the -// HTTP controller. +// true it performs a replacement cutover: start the new orchestrator first, +// then retire any older active orchestrators for that project so a failed +// replacement never causes downtime. This business rule belongs here, not in +// the HTTP controller. func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { if err := s.requireProject(ctx, projectID); err != nil { return domain.Session{}, err } + var existing []domain.Session if clean { active := true - existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) + var err error + existing, err = s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) if err != nil { return domain.Session{}, err } - for _, orch := range existing { - if _, err := s.Kill(ctx, orch.ID); err != nil { - return domain.Session{}, err - } + } + sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + if err != nil || !clean { + return sess, err + } + for _, orch := range existing { + if orch.ID == sess.ID { + continue + } + if err := s.manager.RetireOrchestrator(ctx, orch.ID); err != nil { + return domain.Session{}, apierr.Conflict( + "ORCHESTRATOR_REPLACEMENT_INCOMPLETE", + fmt.Sprintf("Replacement orchestrator started, but previous orchestrator %s could not be retired", orch.ID), + map[string]any{"oldOrchestratorId": orch.ID}, + ) } } - return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + return sess, nil } // Restore relaunches a terminated session and returns the API-facing read model. diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index d9c2ec0b..8745551c 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -136,16 +137,24 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { // clean-orchestrator ordering without wiring a real session engine. type fakeCommander struct { killed []domain.SessionID + retired []domain.SessionID cleanupProjects []domain.ProjectID killErr error + retireErr error cleanupErr error spawned bool killsAtSpawn int + retiredAtSpawn int + spawnErr error } func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) { + if f.spawnErr != nil { + return domain.SessionRecord{}, f.spawnErr + } f.spawned = true f.killsAtSpawn = len(f.killed) + f.retiredAtSpawn = len(f.retired) return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind}, nil } func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { @@ -158,6 +167,13 @@ func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, erro f.killed = append(f.killed, id) return true, nil } +func (f *fakeCommander) RetireOrchestrator(_ context.Context, id domain.SessionID) error { + if f.retireErr != nil { + return f.retireErr + } + f.retired = append(f.retired, id) + return nil +} func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) { f.cleanupProjects = append(f.cleanupProjects, project) @@ -224,7 +240,7 @@ func TestTeardownProjectStopsOnKillError(t *testing.T) { } } -func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { +func TestSpawnOrchestratorCleanSpawnsBeforeRetiringActiveOrchestrators(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer"} // Two active orchestrators plus an unrelated worker and a terminated @@ -241,11 +257,43 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) t.Fatalf("SpawnOrchestrator: %v", err) } - if len(fc.killed) != 2 { - t.Fatalf("killed = %v, want the two active orchestrators", fc.killed) + if len(fc.retired) != 2 { + t.Fatalf("retired = %v, want the two active orchestrators", fc.retired) + } + if !fc.spawned || fc.retiredAtSpawn != 0 { + t.Fatalf("spawn must run before old orchestrators are retired: spawned=%v retiredAtSpawn=%d", fc.spawned, fc.retiredAtSpawn) + } +} + +func TestSpawnOrchestratorCleanSpawnFailureKeepsExistingOrchestrators(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer"} + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + fc := &fakeCommander{spawnErr: errors.New("boom")} + svc := &Service{manager: fc, store: st} + + err := fc.spawnErr + if _, got := svc.SpawnOrchestrator(context.Background(), "mer", true); !errors.Is(got, err) { + t.Fatalf("SpawnOrchestrator err = %v, want %v", got, err) + } + if len(fc.retired) != 0 { + t.Fatalf("retired = %v, want none when replacement spawn fails", fc.retired) + } +} + +func TestSpawnOrchestratorCleanRetireFailureReturnsCutoverError(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer"} + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + fc := &fakeCommander{retireErr: errors.New("cannot retire")} + svc := &Service{manager: fc, store: st} + + _, err := svc.SpawnOrchestrator(context.Background(), "mer", true) + if err == nil || !strings.Contains(err.Error(), "Replacement orchestrator started") { + t.Fatalf("err = %v, want cutover failure message", err) } - if !fc.spawned || fc.killsAtSpawn != 2 { - t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + if !fc.spawned || len(fc.retired) != 0 { + t.Fatalf("spawned=%v retired=%v, want replacement spawned and retire attempted but failed", fc.spawned, fc.retired) } } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 59454e6b..c75a36e3 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -32,6 +32,9 @@ var ( // adapter. The API maps it to a 400 so a typo'd `--harness` is a validation // error, not an opaque 500. ErrUnknownHarness = errors.New("session: unknown agent harness") + // ErrSessionStillAlive means teardown could not prove the runtime is gone + // even after destroy was attempted. + ErrSessionStillAlive = errors.New("session: runtime still alive after destroy") ) // Env vars a spawned process reads to learn who it is. @@ -41,6 +44,9 @@ const ( EnvIssueID = "AO_ISSUE_ID" // EnvDataDir tells a spawned agent's AO hook commands where the store lives. EnvDataDir = "AO_DATA_DIR" + // Replacement cutover gets one retry after the initial destroy attempt + // before the old orchestrator retirement fails hard. + orchestratorRetireAttempts = 2 ) // hookBinaryName is the executable name the workspace hook commands invoke: @@ -57,6 +63,7 @@ type lifecycleRecorder interface { type runtimeController interface { Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) Destroy(ctx context.Context, handle ports.RuntimeHandle) error + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) } // Store is the persistence surface needed by the internal session Manager. @@ -437,6 +444,54 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return true, nil } +// RetireOrchestrator tears down an old orchestrator during replacement cutover. +// It retries destroy and requires a clean liveness probe before marking the old +// orchestrator terminated. +func (m *Manager) RetireOrchestrator(ctx context.Context, id domain.SessionID) error { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil { + return fmt.Errorf("retire %s: %w", id, err) + } + if !ok || rec.IsTerminated { + return nil + } + handle := runtimeHandle(rec.Metadata) + ws := workspaceInfo(rec) + if handle.ID == "" || ws.Path == "" { + return fmt.Errorf("retire %s: %w", id, ErrIncompleteHandle) + } + for attempt := 0; attempt < orchestratorRetireAttempts; attempt++ { + if err := m.runtime.Destroy(ctx, handle); err != nil { + if attempt == orchestratorRetireAttempts-1 { + return fmt.Errorf("retire %s: destroy: %w", id, err) + } + continue + } + alive, err := m.runtime.IsAlive(ctx, handle) + if err != nil { + if attempt == orchestratorRetireAttempts-1 { + return fmt.Errorf("retire %s: probe: %w", id, err) + } + continue + } + if alive { + if attempt == orchestratorRetireAttempts-1 { + return fmt.Errorf("retire %s: %w", id, ErrSessionStillAlive) + } + continue + } + if err := m.workspace.Destroy(ctx, ws); err != nil && !errors.Is(err, ports.ErrWorkspaceDirty) { + m.logger.Warn("session manager: old orchestrator workspace destroy failed after runtime exit", + "session", id, "err", err) + } + if err := m.lcm.MarkTerminated(ctx, id); err != nil { + return fmt.Errorf("retire %s: %w", id, err) + } + return nil + } + return fmt.Errorf("retire %s: %w", id, ErrSessionStillAlive) +} + // Restore relaunches a torn-down session in its workspace. The fallible I/O runs // before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index b4a114fe..6c3215fd 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -111,6 +111,8 @@ type fakeRuntime struct { createErr error created, destroyed int lastCfg ports.RuntimeConfig + alive bool + probeErr error } func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { @@ -122,6 +124,9 @@ func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports. return ports.RuntimeHandle{ID: "h1"}, nil } func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil } +func (r *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return r.alive, r.probeErr +} type fakeAgent struct{} @@ -451,6 +456,44 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { t.Fatalf("kill err = %v, want workspace error surfaced", err) } } + +func TestRetireOrchestrator_TerminatesOldOrchestratorWhenDestroySucceeds(t *testing.T) { + m, st, rt, ws := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + if err := m.RetireOrchestrator(ctx, "mer-1"); err != nil { + t.Fatalf("RetireOrchestrator: %v", err) + } + if rt.destroyed != 1 || ws.destroyed != 1 { + t.Fatalf("destroyed runtime/workspace = %d/%d, want 1/1", rt.destroyed, ws.destroyed) + } + if !st.sessions["mer-1"].IsTerminated { + t.Fatalf("retired session = %+v, want terminated", st.sessions["mer-1"]) + } +} + +func TestRetireOrchestrator_ReturnsErrorWhenRuntimeStaysAlive(t *testing.T) { + m, st, rt, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + rt.alive = true + if err := m.RetireOrchestrator(ctx, "mer-1"); !errors.Is(err, ErrSessionStillAlive) { + t.Fatalf("RetireOrchestrator err = %v, want ErrSessionStillAlive", err) + } + if rt.destroyed != orchestratorRetireAttempts { + t.Fatalf("destroy attempts = %d, want %d", rt.destroyed, orchestratorRetireAttempts) + } + if got := st.sessions["mer-1"]; got.IsTerminated { + t.Fatalf("retired session = %+v, want still live after failed retirement", got) + } +} + +func TestRetireOrchestrator_ReturnsIncompleteHandleWhenHandleMissing(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}} + if err := m.RetireOrchestrator(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) { + t.Fatalf("RetireOrchestrator err = %v, want ErrIncompleteHandle", err) + } +} + func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 0bb8bd4a..4704ad2e 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -4,6 +4,23 @@ */ export interface paths { + "/api/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List supported and locally installed agent adapters */ + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/events": { parameters: { query?: never; @@ -424,6 +441,12 @@ export interface components { model?: string; permissions?: string; }; + AgentInfo: { + /** @enum {string} */ + authStatus?: "authorized" | "unauthorized" | "unknown"; + id: string; + label: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -481,6 +504,11 @@ export interface components { ok: boolean; sessionId: string; }; + ListAgentsResponse: { + authorized: components["schemas"]["AgentInfo"][]; + installed: components["schemas"]["AgentInfo"][]; + supported: components["schemas"]["AgentInfo"][]; + }; ListNotificationsResponse: { notifications: components["schemas"]["NotificationResponse"][]; }; @@ -695,6 +723,44 @@ export interface components { } export type $defs = Record; export interface operations { + listAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; streamEvents: { parameters: { query?: { diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 4e387c98..a07cbd2d 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -3,14 +3,16 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getMock, putMock } = vi.hoisted(() => ({ +const { getMock, postMock, putMock } = vi.hoisted(() => ({ getMock: vi.fn(), + postMock: vi.fn(), putMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { GET: getMock, + POST: postMock, PUT: putMock, }, apiErrorMessage: (error: unknown) => { @@ -44,49 +46,79 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { await userEvent.click(await screen.findByRole("option", { name: optionName })); } +let projectResponse: unknown; +let agentsResponse: unknown; +let orchestratorsResponse: unknown; +let projectsListResponse: unknown; +let sessionsListResponse: unknown; + beforeEach(() => { getMock.mockReset(); + postMock.mockReset(); putMock.mockReset(); + postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orchestrator", projectId: "proj-1" } }, error: undefined }); putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); + projectResponse = undefined; + agentsResponse = undefined; + orchestratorsResponse = { data: { sessions: [] }, error: undefined }; + projectsListResponse = { data: { projects: [] }, error: undefined }; + sessionsListResponse = { data: { sessions: [] }, error: undefined }; }); describe("ProjectSettingsForm", () => { it("loads the current project settings and saves the exposed fields without dropping hidden config", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "git@github.com:acme/project-one.git", - defaultBranch: "main", - config: { - defaultBranch: "develop", - sessionPrefix: "po", - env: { FOO: "bar" }, - symlinks: [".env"], - postCreate: ["npm install"], - worker: { - agent: "codex", - agentConfig: { model: "worker-model" }, - }, - orchestrator: { agent: "claude-code" }, - agentConfig: { - model: "claude-opus-4-5", - permissions: "auto", - }, - reviewers: [{ harness: "claude-code" }], - }, + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + defaultBranch: "develop", + sessionPrefix: "po", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["npm install"], + worker: { + agent: "codex", + agentConfig: { model: "worker-model" }, }, + orchestrator: { agent: "claude-code" }, + agentConfig: { + model: "claude-opus-4-5", + permissions: "auto", + }, + reviewers: [{ harness: "claude-code" }], }, - error: undefined, }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + { id: "goose", label: "Goose" }, + { id: "opencode", label: "OpenCode" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + { id: "goose", label: "Goose" }, + { id: "opencode", label: "OpenCode" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + { id: "goose", label: "Goose", authStatus: "authorized" }, + { id: "opencode", label: "OpenCode", authStatus: "authorized" }, + ], + }); + mockGetResponses(); renderSettings(); expect(await screen.findByText("git@github.com:acme/project-one.git")).toBeInTheDocument(); + expect(screen.queryByText(/supported agents installed on this machine/)).not.toBeInTheDocument(); + expect(screen.queryByText(/installed agents authorized/)).not.toBeInTheDocument(); expect(screen.getByLabelText("Default branch")).toHaveValue("develop"); expect(screen.getByLabelText("Session prefix")).toHaveValue("po"); expect(screen.getByLabelText("Model override")).toHaveValue("claude-opus-4-5"); @@ -94,9 +126,9 @@ describe("ProjectSettingsForm", () => { const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" }); const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); const permissionMode = screen.getByRole("combobox", { name: "Permission mode" }); + expect(workerAgent).toHaveTextContent("Codex"); + expect(orchestratorAgent).toHaveTextContent("Claude Code"); const reviewerAgent = screen.getByRole("combobox", { name: "Default reviewer agent" }); - expect(workerAgent).toHaveTextContent("codex"); - expect(orchestratorAgent).toHaveTextContent("claude-code"); expect(permissionMode).toHaveTextContent("Auto"); expect(reviewerAgent).toHaveTextContent("claude-code"); @@ -106,13 +138,17 @@ describe("ProjectSettingsForm", () => { await userEvent.type(screen.getByLabelText("Session prefix"), "rel"); await userEvent.clear(screen.getByLabelText("Model override")); await userEvent.type(screen.getByLabelText("Model override"), "gpt-5-codex"); - await chooseOption(workerAgent, "opencode"); - await chooseOption(orchestratorAgent, "goose"); + await chooseOption(workerAgent, "OpenCode"); + await chooseOption(orchestratorAgent, "Goose"); await chooseOption(permissionMode, "Bypass permissions"); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + expect(getMock).toHaveBeenCalledWith("/api/v1/orchestrators"); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", { params: { path: { id: "proj-1" } }, body: { @@ -135,24 +171,565 @@ describe("ProjectSettingsForm", () => { }, }, }); + expect(await screen.findByText("Saved. Orchestrator restarted.")).toBeInTheDocument(); + }); + + it("reuses the cached agent catalog across project settings switches", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [{ id: "codex", label: "Codex" }], + installed: [{ id: "codex", label: "Codex" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const view = render( + + + , + ); + + await waitFor(() => expect(screen.getAllByText("/repo/project-one").length).toBeGreaterThan(0)); + expect(agentRequestCount()).toBe(1); + + mockProject({ + id: "proj-2", + name: "Project Two", + kind: "single_repo", + path: "/repo/project-two", + repo: "", + defaultBranch: "main", + }); + + view.rerender( + + + , + ); + + await waitFor(() => expect(screen.getAllByText("/repo/project-two").length).toBeGreaterThan(0)); + expect(agentRequestCount()).toBe(1); + }); + + it("lets project settings manually reload the shared agent catalog", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [{ id: "codex", label: "Codex" }], + installed: [{ id: "codex", label: "Codex" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByRole("combobox", { name: "Default worker agent" })).toBeInTheDocument(); + expect(agentRequestCount()).toBe(1); + + await userEvent.click(screen.getByRole("button", { name: "Reload agents" })); + + await waitFor(() => expect(agentRequestCount()).toBe(2)); + }); + + it("does not restart the orchestrator when unrelated settings change", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + defaultBranch: "main", + orchestrator: { agent: "codex" }, + }, + }); + mockAgents({ + supported: [{ id: "codex", label: "Codex" }], + installed: [{ id: "codex", label: "Codex" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); + + renderSettings(); + + await userEvent.clear(await screen.findByLabelText("Default branch")); + await userEvent.type(screen.getByLabelText("Default branch"), "release"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + expect(postMock).not.toHaveBeenCalled(); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }, 10_000); - it("shows the daemon validation message when save fails", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "", - defaultBranch: "main", + it("blocks orchestrator agent changes while the project orchestrator is active", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + orchestrator: { agent: "claude-code" }, + }, + }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + mockOrchestrators([ + { + id: "proj-1-orchestrator", + projectId: "proj-1", + kind: "orchestrator", + status: "working", + isTerminated: false, + }, + ]); + mockGetResponses(); + + renderSettings(); + + await chooseOption(await screen.findByRole("combobox", { name: "Default orchestrator agent" }), "Codex"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText("Orchestrator is currently active. Wait until it is idle before switching agents."), + ).toBeInTheDocument(); + expect(putMock).not.toHaveBeenCalled(); + expect(postMock).not.toHaveBeenCalled(); + }); + + it("allows orchestrator agent changes when the current orchestrator is idle", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + orchestrator: { agent: "claude-code" }, + }, + }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + mockOrchestrators([ + { + id: "proj-1-orchestrator", + projectId: "proj-1", + kind: "orchestrator", + status: "idle", + isTerminated: false, + }, + ]); + mockGetResponses(); + + renderSettings(); + + await chooseOption(await screen.findByRole("combobox", { name: "Default orchestrator agent" }), "Codex"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); + expect(await screen.findByText("Saved. Orchestrator restarted.")).toBeInTheDocument(); + }); + + it("shows a persistent retry action when config saves but orchestrator replacement fails", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + orchestrator: { agent: "claude-code" }, + }, + }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + mockOrchestrators([ + { + id: "proj-1-orchestrator", + projectId: "proj-1", + kind: "orchestrator", + harness: "claude-code", + status: "idle", + isTerminated: false, + }, + ]); + mockGetResponses(); + putMock.mockImplementation(async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + orchestrator: { agent: "codex" }, }, + }); + return { data: { project: {} }, error: undefined }; + }); + postMock + .mockResolvedValueOnce({ data: undefined, error: { message: "agent binary missing" } }) + .mockResolvedValueOnce({ data: { orchestrator: { id: "proj-2-orchestrator", projectId: "proj-1" } }, error: undefined }); + + renderSettings(); + + await chooseOption(await screen.findByRole("combobox", { name: "Default orchestrator agent" }), "Codex"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText( + "Saved config. New orchestrator failed to start, and the previous orchestrator is still running: agent binary missing", + ), + ).toBeInTheDocument(); + expect(await screen.findByText("Orchestrator replacement pending")).toBeInTheDocument(); + expect( + screen.getByText((_, node) => node?.textContent === "Saved orchestrator agent is Codex, but the running orchestrator is still Claude Code."), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Retry orchestrator replacement" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(2)); + expect(postMock).toHaveBeenNthCalledWith(2, "/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); + expect(putMock).toHaveBeenCalledTimes(1); + }); + + it("disables spawn retry until the old orchestrator becomes idle", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + orchestrator: { agent: "codex" }, }, - error: undefined, }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + mockOrchestrators([ + { + id: "proj-1-orchestrator", + projectId: "proj-1", + kind: "orchestrator", + harness: "claude-code", + status: "working", + isTerminated: false, + }, + ]); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByText("Orchestrator replacement pending")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry when idle" })).toBeDisabled(); + expect(screen.getByText("Current orchestrator must be idle before retrying.")).toBeInTheDocument(); + }); + + it("keeps the retry card after reload when daemon default is the desired orchestrator agent", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + agent: "codex", + config: {}, + }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + mockOrchestrators([ + { + id: "proj-1-orchestrator", + projectId: "proj-1", + kind: "orchestrator", + harness: "claude-code", + status: "idle", + isTerminated: false, + }, + ]); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByText("Orchestrator replacement pending")).toBeInTheDocument(); + expect( + screen.getByText( + (_, node) => + node?.textContent === + "Saved orchestrator agent is Codex (daemon default), but the running orchestrator is still Claude Code.", + ), + ).toBeInTheDocument(); + }); + + it("keeps a configured but missing agent visible with a warning", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "aider" }, + orchestrator: { agent: "codex" }, + }, + }); + mockAgents({ + supported: [ + { id: "aider", label: "Aider" }, + { id: "codex", label: "Codex" }, + ], + installed: [{ id: "codex", label: "Codex" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByText("Aider is configured but was not detected on this machine.")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Aider"); + await userEvent.click(screen.getByRole("combobox", { name: "Default orchestrator agent" })); + expect(screen.getByRole("option", { name: /Aider.*Needs install/i })).toHaveAttribute("aria-disabled", "true"); + }); + + it("shows unavailable agents instead of disabling the dropdowns", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [{ id: "codex", label: "Codex" }], + installed: [], + authorized: [], + }); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByRole("combobox", { name: "Default worker agent" })).toBeInTheDocument(); + const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" }); + const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); + expect(workerAgent).not.toBeDisabled(); + expect(orchestratorAgent).not.toBeDisabled(); + await userEvent.click(workerAgent); + expect(screen.getByRole("option", { name: /Codex.*Needs install/i })).toHaveAttribute("aria-disabled", "true"); + }); + + it("keeps a configured but unauthorized agent visible with a warning", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "claude-code" }, + orchestrator: { agent: "codex" }, + }, + }); + mockAgents({ + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "unauthorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByText("Claude Code is configured but is not authorized on this machine.")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Claude Code"); + await userEvent.click(screen.getByRole("combobox", { name: "Default orchestrator agent" })); + expect(screen.getByRole("option", { name: /Claude Code.*Needs auth/i })).toHaveAttribute("aria-disabled", "true"); + }); + + it("sorts agent options by authorized, installed, then not installed", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [ + { id: "z-missing", label: "Z Missing" }, + { id: "b-auth", label: "B Authorized", authStatus: "authorized" }, + { id: "a-auth", label: "A Authorized", authStatus: "authorized" }, + { id: "installed", label: "Installed Only", authStatus: "unauthorized" }, + ], + installed: [ + { id: "b-auth", label: "B Authorized", authStatus: "authorized" }, + { id: "a-auth", label: "A Authorized", authStatus: "authorized" }, + { id: "installed", label: "Installed Only", authStatus: "unauthorized" }, + ], + authorized: [ + { id: "b-auth", label: "B Authorized", authStatus: "authorized" }, + { id: "a-auth", label: "A Authorized", authStatus: "authorized" }, + ], + }); + mockGetResponses(); + + renderSettings(); + + await userEvent.click(await screen.findByRole("combobox", { name: "Default worker agent" })); + const options = screen.getAllByRole("option").map((option) => option.textContent); + expect(options).toEqual([ + "Daemon default", + "A Authorized", + "B Authorized", + "Installed OnlyNeeds auth", + "Z MissingNeeds install", + ]); + }); + + it("prompts for login when installed agents have no authorized status", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [ + { id: "codex", label: "Codex" }, + { id: "aider", label: "Aider" }, + ], + installed: [{ id: "codex", label: "Codex" }], + }); + mockGetResponses(); + + renderSettings(); + + expect(await screen.findByRole("dialog", { name: "Agent login needed" })).toBeInTheDocument(); + expect(screen.getByText(/Log in to one of/)).toHaveTextContent("Log in to one of Codex, then reload settings."); + await userEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + const workerAgent = await screen.findByRole("combobox", { name: "Default worker agent" }); + await userEvent.click(workerAgent); + expect(screen.getByRole("option", { name: /Codex.*Needs auth/i })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: /Aider.*Needs install/i })).toHaveAttribute("aria-disabled", "true"); + }); + + it("shows the daemon validation message when save fails", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + }); + mockAgents({ + supported: [{ id: "codex", label: "Codex" }], + installed: [{ id: "codex", label: "Codex" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }); + mockGetResponses(); putMock.mockResolvedValue({ data: undefined, error: { message: "invalid permissions" }, @@ -166,3 +743,60 @@ describe("ProjectSettingsForm", () => { expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); }); }); + +function mockProject(project: unknown) { + projectResponse = { + data: { + status: "ok", + project, + }, + error: undefined, + }; + if (project && typeof project === "object") { + const summary = project as { id?: unknown; name?: unknown; path?: unknown }; + projectsListResponse = { + data: { + projects: [ + { + id: summary.id, + name: summary.name, + path: summary.path, + }, + ], + }, + error: undefined, + }; + } +} + +function mockAgents(agents: unknown) { + agentsResponse = { + data: agents, + error: undefined, + }; +} + +function mockOrchestrators(sessions: unknown[]) { + orchestratorsResponse = { + data: { sessions }, + error: undefined, + }; + sessionsListResponse = { + data: { sessions }, + error: undefined, + }; +} + +function mockGetResponses() { + getMock.mockImplementation((path: string) => { + if (path === "/api/v1/agents") return Promise.resolve(agentsResponse); + if (path === "/api/v1/projects") return Promise.resolve(projectsListResponse); + if (path === "/api/v1/orchestrators") return Promise.resolve(orchestratorsResponse); + if (path === "/api/v1/sessions") return Promise.resolve(sessionsListResponse); + return Promise.resolve(projectResponse); + }); +} + +function agentRequestCount() { + return getMock.mock.calls.filter(([path]) => path === "/api/v1/agents").length; +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 673204e1..47754b13 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; -import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { agentsQueryKey, type AgentCatalog, useAgentsQuery } from "../hooks/useAgentsQuery"; +import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { findProjectOrchestrator } from "../types/workspace"; import { DashboardSubhead } from "./DashboardSubhead"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; @@ -11,9 +13,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". type Project = components["schemas"]["Project"]; type ProjectConfig = components["schemas"]["ProjectConfig"]; - -// Agents the daemon registers. Empty = "use the daemon default". -const AGENT_OPTIONS = ["claude-code", "codex", "opencode", "amp", "goose", "kiro"] as const; +type AgentInfo = components["schemas"]["AgentInfo"]; +type AgentCatalogWithAuth = AgentCatalog & { + authorized?: AgentInfo[]; +}; +type Session = components["schemas"]["ControllersSessionView"]; const PERMISSION_MODE_OPTIONS = [ { value: "default", label: "Default" }, @@ -40,8 +44,9 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { return data.project as Project; }, }); + const agentsQuery = useAgentsQuery(); - if (query.isLoading) { + if (query.isLoading || agentsQuery.isLoading) { return Loading project settings…; } if (query.isError || !query.data) { @@ -49,6 +54,13 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { {query.error instanceof Error ? query.error.message : "Could not load project."} ); } + if (agentsQuery.isError || !agentsQuery.data) { + return ( + + {agentsQuery.error instanceof Error ? agentsQuery.error.message : "Could not load agent catalog."} + + ); + } return (
@@ -57,6 +69,9 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { queryClient.invalidateQueries({ queryKey: agentsQueryKey })} onSaved={() => queryClient.invalidateQueries({ queryKey: workspaceQueryKey })} projectId={projectId} /> @@ -65,9 +80,39 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { ); } -function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { +function SettingsBody({ + project, + projectId, + agents, + isReloadingAgents, + onReloadAgents, + onSaved, +}: { + project: Project; + projectId: string; + agents: AgentCatalog; + isReloadingAgents: boolean; + onReloadAgents: () => void; + onSaved: () => void; +}) { const queryClient = useQueryClient(); + const workspaces = useWorkspaceQuery().data ?? []; const config = project.config ?? {}; + const agentCatalog = agents as AgentCatalogWithAuth; + const installedAgents = agents.installed ?? []; + const agentOptions = agentCatalog.authorized ?? []; + const supportedAgents = agents.supported ?? []; + const agentLabels = new Map( + [...supportedAgents, ...installedAgents, ...agentOptions].map((agent) => [agent.id, agent.label] as const), + ); + const authStatusUnavailable = agentCatalog.authorized === undefined && installedAgents.length > 0; + const liveOrchestrator = findProjectOrchestrator(workspaces, projectId); + const savedOrchestratorAgent = effectiveDesiredOrchestratorAgent(project); + const runningOrchestratorAgent = liveOrchestrator?.provider ?? ""; + const spawnFailurePending = liveOrchestrator !== undefined && savedOrchestratorAgent !== runningOrchestratorAgent; + const replacementNeeded = spawnFailurePending; + const retryRequiresIdle = spawnFailurePending; + const retryBlockedUntilIdle = retryRequiresIdle && liveOrchestrator !== undefined && workspaceOrchestratorRestartBlocked(liveOrchestrator); const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", @@ -78,9 +123,25 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje reviewerHarness: config.reviewers?.[0]?.harness ?? "", }); const [savedAt, setSavedAt] = useState(null); + const [restartedAt, setRestartedAt] = useState(null); + const [showAuthPrompt, setShowAuthPrompt] = useState(authStatusUnavailable); + const [replacementNotice, setReplacementNotice] = useState(null); const mutation = useMutation({ mutationFn: async () => { + const currentEffectiveOrchestratorAgent = effectiveOrchestratorAgentValue( + config.orchestrator?.agent, + project.agent, + ); + const nextEffectiveOrchestratorAgent = effectiveOrchestratorAgentValue( + form.orchestratorAgent || undefined, + project.agent, + ); + const orchestratorAgentChanged = + currentEffectiveOrchestratorAgent !== nextEffectiveOrchestratorAgent; + if (orchestratorAgentChanged) { + await assertOrchestratorCanRestart(projectId); + } // PUT replaces the whole config; merge the edited fields over what loaded // so we don't drop env/symlinks/postCreate the form doesn't expose. const next: ProjectConfig = { @@ -101,22 +162,127 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje body: { config: next }, }); if (error) throw new Error(apiErrorMessage(error)); + if (orchestratorAgentChanged) { + try { + return { + restarted: true, + replacement: await restartOrchestrator(projectId), + }; + } catch (error) { + await invalidateReplacementQueries(queryClient, projectId); + throw new Error( + `Saved config. New orchestrator failed to start, and the previous orchestrator is still running: ${errorMessage( + error, + )}`, + ); + } + } + return { restarted: false, replacement: { incomplete: false, notice: null } }; }, - onSuccess: () => { + onSuccess: async ({ restarted, replacement }) => { setSavedAt(Date.now()); - void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); + setRestartedAt(restarted ? Date.now() : null); + setReplacementNotice(replacement.notice); + await invalidateReplacementQueries(queryClient, projectId); onSaved(); }, + onError: () => { + setReplacementNotice(null); + }, }); + const retryReplacementMutation = useMutation({ + mutationFn: async () => { + await assertOrchestratorCanRestart(projectId); + return restartOrchestrator(projectId); + }, + onSuccess: async (replacement) => { + setSavedAt(Date.now()); + setRestartedAt(Date.now()); + setReplacementNotice(replacement.notice); + await invalidateReplacementQueries(queryClient, projectId); + onSaved(); + }, + onError: () => { + setReplacementNotice(null); + }, + }); + const runningAgentLabel = agentName(runningOrchestratorAgent, agentLabels); + const savedAgentLabel = desiredAgentLabel(project, agentLabels); return ( -
{ - event.preventDefault(); - mutation.mutate(); - }} - > + <> + {showAuthPrompt && authStatusUnavailable && ( +
+
+

+ Agent login needed +

+

+ AO found installed agents, but none are verified as authorized yet. Log in to one of{" "} + {formatAgentList(installedAgents)}, then reload settings. +

+
+ +
+
+
+ )} + { + event.preventDefault(); + mutation.mutate(); + }} + > + {replacementNeeded && ( + + + Orchestrator replacement pending + + +
+ Saved orchestrator agent is {savedAgentLabel}, but the + running orchestrator is still {runningAgentLabel}. +
+
+ {retryBlockedUntilIdle + ? "The previous orchestrator was kept alive because the replacement failed to start. It must become idle before retry can run." + : "The previous orchestrator was kept alive because the replacement failed to start."} +
+
+ + {retryBlockedUntilIdle && ( + Current orchestrator must be idle before retrying. + )} + {retryReplacementMutation.isError && ( + + {retryReplacementMutation.error instanceof Error + ? retryReplacementMutation.error.message + : "Retry failed"} + + )} +
+
+
+ )} Identity @@ -156,13 +322,21 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje - Agents +
+ Agents + +
setForm((f) => ({ ...f, workerAgent: v }))} /> @@ -170,6 +344,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje setForm((f) => ({ ...f, orchestratorAgent: v }))} /> @@ -208,7 +385,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
- {mutation.isError && ( @@ -216,14 +393,104 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje {mutation.error instanceof Error ? mutation.error.message : "Save failed"} )} - {savedAt && !mutation.isPending && !mutation.isError && ( - Saved. + {replacementNotice && !mutation.isPending && !mutation.isError && !retryReplacementMutation.isPending && ( + {replacementNotice} + )} + {savedAt && + !mutation.isPending && + !mutation.isError && + !replacementNotice && + !retryReplacementMutation.isPending && + !retryReplacementMutation.isError && ( + + {restartedAt ? "Saved. Orchestrator restarted." : "Saved."} + )}
- + + ); } +async function assertOrchestratorCanRestart(projectId: string) { + const { data, error } = await apiClient.GET("/api/v1/orchestrators"); + if (error) throw new Error(`Could not check orchestrator state: ${apiErrorMessage(error)}`); + const busy = (data?.sessions ?? []).find( + (session) => + session.projectId === projectId && + session.kind === "orchestrator" && + !session.isTerminated && + orchestratorRestartBlocked(session), + ); + if (busy) { + throw new Error("Orchestrator is currently active. Wait until it is idle before switching agents."); + } +} + +function orchestratorRestartBlocked(session: Session) { + if (session.status === "idle" || session.status === "terminated") return false; + return true; +} + +function workspaceOrchestratorRestartBlocked(session: { status: string }) { + return session.status !== "idle" && session.status !== "terminated"; +} + +async function restartOrchestrator(projectId: string) { + const { error } = await apiClient.POST("/api/v1/orchestrators", { + body: { projectId, clean: true }, + }); + if (!error) { + return { incomplete: false, notice: null as string | null }; + } + if (apiErrorCode(error) === "ORCHESTRATOR_REPLACEMENT_INCOMPLETE") { + return { + incomplete: true, + notice: "Saved. New orchestrator started, but the previous orchestrator could not be retired yet.", + }; + } + throw new Error(apiErrorMessage(error)); +} + +async function invalidateReplacementQueries(queryClient: ReturnType, projectId: string) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["project", projectId] }), + queryClient.invalidateQueries({ queryKey: workspaceQueryKey }), + ]); +} + +function agentName(agentID: string, labels: Map) { + if (agentID === "") return "daemon default"; + return labels.get(agentID) ?? agentID; +} + +function effectiveDesiredOrchestratorAgent(project: Project) { + return effectiveOrchestratorAgentValue(project.config?.orchestrator?.agent, project.agent); +} + +function effectiveOrchestratorAgentValue(explicitAgent: string | undefined, defaultAgent: string | undefined) { + return explicitAgent ?? defaultAgent ?? ""; +} + +function desiredAgentLabel(project: Project, labels: Map) { + const explicit = project.config?.orchestrator?.agent ?? ""; + if (explicit !== "") return agentName(explicit, labels); + if (project.agent) return `${agentName(project.agent, labels)} (daemon default)`; + return "daemon default"; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : apiErrorMessage(error); +} + +function apiErrorCode(error: unknown) { + if (typeof error === "object" && error !== null && "code" in error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "string" && code !== "") return code; + } + return ""; +} + function PermissionModeSelect({ id, value, @@ -250,22 +517,76 @@ function PermissionModeSelect({ ); } -function AgentSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) { +function AgentSelect({ + id, + value, + authorized, + installed, + supported, + onChange, +}: { + id: string; + value: string; + authorized: AgentInfo[]; + installed: AgentInfo[]; + supported: AgentInfo[]; + onChange: (value: string) => void; +}) { // "" sentinel → daemon default; Select can't hold an empty value, so map it. + const authorizedIds = new Set(authorized.map((agent) => agent.id)); + const installedById = new Map(installed.map((agent) => [agent.id, agent])); + const supportedById = new Map(supported.map((agent) => [agent.id, agent])); + const configuredUnavailable = value !== "" && !authorizedIds.has(value); + const needsFallbackOption = value !== "" && !supportedById.has(value); + const current = supportedById.get(value); + const currentInstalled = installedById.get(value); + const options = supported + .map((agent) => { + const installedAgent = installedById.get(agent.id); + const isAuthorized = authorizedIds.has(agent.id); + const rank = isAuthorized ? 0 : installedAgent ? 1 : 2; + return { + ...agent, + disabled: !isAuthorized, + rank, + reason: !installedAgent ? "Needs install" : !isAuthorized ? "Needs auth" : "", + }; + }) + .sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); + const currentWarning = currentInstalled + ? `${current?.label ?? value} is configured but is not authorized on this machine.` + : `${current?.label ?? value} is configured but was not detected on this machine.`; return ( - +
+ + {configuredUnavailable && {currentWarning}} +
); } @@ -315,6 +636,12 @@ function CenteredNote({ children }: { children: React.ReactNode }) { ); } +function formatAgentList(agents: AgentInfo[]) { + const labels = agents.map((agent) => agent.label || agent.id).sort((a, b) => a.localeCompare(b)); + if (labels.length <= 2) return labels.join(" or "); + return `${labels.slice(0, -1).join(", ")}, or ${labels[labels.length - 1]}`; +} + // Drop an object whose every value is undefined so we send `undefined` (omit) // rather than an empty {} the daemon would persist. function blankToUndefined(obj: T): T | undefined { diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 00000000..fb1493ff --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateMock, workspaceQueryMock, agentsQueryMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + workspaceQueryMock: vi.fn(), + agentsQueryMock: vi.fn(), +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../hooks/useWorkspaceQuery", () => ({ + useWorkspaceQuery: workspaceQueryMock, +})); + +vi.mock("../hooks/useAgentsQuery", () => ({ + agentsQueryKey: ["agents"], + useAgentsQuery: agentsQueryMock, +})); + +import { SessionsBoard } from "./SessionsBoard"; + +function renderBoard() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + render(); + return { invalidateSpy }; +} + +beforeEach(() => { + navigateMock.mockReset(); + workspaceQueryMock.mockReset().mockReturnValue({ data: [], isError: false }); + agentsQueryMock.mockReset().mockReturnValue({ + data: { supported: [], installed: [], authorized: [] }, + isFetching: false, + isLoading: false, + }); +}); + +describe("SessionsBoard", () => { + it("shows an agent setup warning when no agents are authorized", async () => { + const { invalidateSpy } = renderBoard(); + + expect(screen.getByText("Install and log in to a supported agent, then reload agents.")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Reload agents" })); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["agents"] }); + }); + + it("names installed agents that need login", () => { + agentsQueryMock.mockReturnValue({ + data: { + supported: [ + { id: "cursor", label: "Cursor" }, + { id: "opencode", label: "opencode" }, + { id: "copilot", label: "GitHub Copilot" }, + ], + installed: [ + { id: "cursor", label: "Cursor", authStatus: "unknown" }, + { id: "opencode", label: "opencode", authStatus: "unauthorized" }, + { id: "copilot", label: "GitHub Copilot", authStatus: "unknown" }, + ], + authorized: [], + }, + isFetching: false, + isLoading: false, + }); + + renderBoard(); + + expect(screen.getByText("Log in to Cursor, GitHub Copilot, and opencode, then reload agents.")).toBeInTheDocument(); + }); + + it("hides the agent setup warning when an agent is authorized", () => { + agentsQueryMock.mockReturnValue({ + data: { + supported: [{ id: "codex", label: "Codex" }], + installed: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + authorized: [{ id: "codex", label: "Codex", authStatus: "authorized" }], + }, + isFetching: false, + isLoading: false, + }); + + renderBoard(); + + expect(screen.queryByText("Install and log in to a supported agent, then reload agents.")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6649e1d5..d42240c0 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { type AttentionZone, type WorkerDisplayStatus, @@ -9,7 +10,9 @@ import { workerSessions, } from "../types/workspace"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; +import { agentsQueryKey, useAgentsQuery } from "../hooks/useAgentsQuery"; import { DashboardSubhead } from "./DashboardSubhead"; +import { Button } from "./ui/button"; import { cn } from "../lib/utils"; type SessionsBoardProps = { @@ -73,10 +76,15 @@ const BADGE: Record = export function SessionsBoard({ projectId }: SessionsBoardProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); const workspaceQuery = useWorkspaceQuery(); + const agentsQuery = useAgentsQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); + const authorizedAgentIds = new Set((agentsQuery.data?.authorized ?? []).map((agent) => agent.id)); + const loginNeededAgents = (agentsQuery.data?.installed ?? []).filter((agent) => !authorizedAgentIds.has(agent.id)); + const showAgentSetupWarning = !agentsQuery.isLoading && authorizedAgentIds.size === 0; const byZone = new Map(); for (const session of sessions) { @@ -98,6 +106,23 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
+ {showAgentSetupWarning && ( +
+
+ {agentSetupMessage(loginNeededAgents)} + +
+
+ )} +
{workspaceQuery.isError ? (

Could not load sessions.

@@ -158,6 +183,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { ); } +function agentSetupMessage(loginNeededAgents: { id: string; label?: string }[]) { + if (loginNeededAgents.length === 0) return "Install and log in to a supported agent, then reload agents."; + return `Log in to ${formatAgentList(loginNeededAgents)}, then reload agents.`; +} + +function formatAgentList(agents: { id: string; label?: string }[]) { + const labels = agents.map((agent) => agent.label || agent.id).sort((a, b) => a.localeCompare(b)); + if (labels.length <= 2) return labels.join(" and "); + return `${labels.slice(0, -1).join(", ")}, and ${labels.at(-1)}`; +} + function ZoneColumn({ col, sessions, diff --git a/frontend/src/renderer/hooks/useAgentsQuery.ts b/frontend/src/renderer/hooks/useAgentsQuery.ts new file mode 100644 index 00000000..867dfa2a --- /dev/null +++ b/frontend/src/renderer/hooks/useAgentsQuery.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type AgentCatalog = components["schemas"]["ListAgentsResponse"]; + +export const agentsQueryKey = ["agents"] as const; + +async function fetchAgents(): Promise { + const { data, error } = await apiClient.GET("/api/v1/agents"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export const agentsQueryOptions = { + queryKey: agentsQueryKey, + queryFn: fetchAgents, + retry: 1, + staleTime: 5 * 60 * 1000, +}; + +export function useAgentsQuery() { + return useQuery(agentsQueryOptions); +} diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index fc276ee4..3bd96b91 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -1,10 +1,11 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; -import { type CSSProperties, useCallback, useEffect } from "react"; +import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; +import { agentsQueryKey, agentsQueryOptions } from "../hooks/useAgentsQuery"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; @@ -38,6 +39,7 @@ function ShellLayout() { const workspaceQuery = useWorkspaceQuery(); const workspaces = workspaceQuery.data ?? []; const daemonStatus = useDaemonStatus(queryClient); + const agentCatalogPortRef = useRef(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); const updateWorkspaces = useCallback( @@ -82,6 +84,15 @@ function ShellLayout() { document.documentElement.style.colorScheme = theme; }, [theme]); + useEffect(() => { + if (daemonStatus.state !== "ready" || !daemonStatus.port) return; + if (agentCatalogPortRef.current === daemonStatus.port) return; + + agentCatalogPortRef.current = daemonStatus.port; + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); + void queryClient.ensureQueryData(agentsQueryOptions); + }, [daemonStatus.port, daemonStatus.state, queryClient]); + // Follow OS appearance only until the user picks a theme explicitly. useEffect(() => { if (readStoredTheme()) return; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 6845c37c..87ac62e1 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -94,6 +94,12 @@ describe("findProjectOrchestrator", () => { expect(findProjectOrchestrator([workspaceWith([dead, live, worker])], "skills")).toBe(live); }); + it("prefers the newest live orchestrator when multiple replacements overlap", () => { + const older = sessionWith({ id: "skills-4", kind: "orchestrator", status: "idle", provider: "claude-code" }); + const newer = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working", provider: "codex" }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + it("returns undefined when every orchestrator is terminated", () => { const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" }); expect(findProjectOrchestrator([workspaceWith([dead])], "skills")).toBeUndefined(); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index d401f48b..eada7725 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -136,7 +136,14 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - return workspace?.sessions.find((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (!workspace) return undefined; + for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { + const session = workspace.sessions[i]; + if (isOrchestratorSession(session) && sessionIsActive(session)) { + return session; + } + } + return undefined; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] {