feat: require explicit project agents (#355)
* feat: require explicit project agents * chore: format with prettier [skip ci] * test: configure integration project agents * fix: center project agent selection dialog * fix: keep agent selects neutral before validation --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
5e8c8defbd
commit
72b757b203
10
README.md
10
README.md
|
|
@ -21,7 +21,8 @@ progress (what's shipped vs. in flight) see [`docs/STATUS.md`](docs/STATUS.md).
|
||||||
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
|
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
|
||||||
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
|
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
|
||||||
and more), registered through a shared registry with common
|
and more), registered through a shared registry with common
|
||||||
activity-dispatch / hook utilities. The default is set by `AO_AGENT`.
|
activity-dispatch / hook utilities. Worker and orchestrator defaults are set
|
||||||
|
per project.
|
||||||
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
|
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
|
||||||
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
|
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
|
||||||
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
|
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
|
||||||
|
|
@ -92,9 +93,10 @@ go build -o /tmp/ao ./cmd/ao
|
||||||
|
|
||||||
# Register a local git repo as a project. The id defaults to the lowercased
|
# Register a local git repo as a project. The id defaults to the lowercased
|
||||||
# base of --path; pass --id explicitly when the directory name doesn't match.
|
# base of --path; pass --id explicitly when the directory name doesn't match.
|
||||||
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo
|
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo \
|
||||||
|
--worker-agent codex --orchestrator-agent codex
|
||||||
|
|
||||||
# Spawn a worker session running the default agent.
|
# Spawn a worker session running the project's worker agent.
|
||||||
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
|
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
|
||||||
|
|
||||||
# Inspect what's running.
|
# Inspect what's running.
|
||||||
|
|
@ -167,7 +169,7 @@ exposing it beyond loopback would be a security regression.
|
||||||
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
|
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
|
||||||
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
|
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
|
||||||
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
|
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
|
||||||
| `AO_AGENT` | `claude-code` | Default agent adapter id used by `ao spawn`. |
|
| `AO_AGENT` | `claude-code` | Compatibility agent adapter id validated at daemon startup. |
|
||||||
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
|
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
|
||||||
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |
|
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,8 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
||||||
"--path", "/repo/mer",
|
"--path", "/repo/mer",
|
||||||
"--id", "demo",
|
"--id", "demo",
|
||||||
"--name", "Demo",
|
"--name", "Demo",
|
||||||
|
"--worker-agent", "codex",
|
||||||
|
"--orchestrator-agent", "claude-code",
|
||||||
"--as-workspace",
|
"--as-workspace",
|
||||||
})
|
})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
|
|
@ -238,6 +240,15 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
||||||
if got.Name == nil || *got.Name != "Demo" {
|
if got.Name == nil || *got.Name != "Demo" {
|
||||||
t.Errorf("Name = %v, want %q", got.Name, "Demo")
|
t.Errorf("Name = %v, want %q", got.Name, "Demo")
|
||||||
}
|
}
|
||||||
|
if got.Config == nil {
|
||||||
|
t.Fatal("Config = nil, want role agent config")
|
||||||
|
}
|
||||||
|
if got.Config.Worker.Harness != domain.HarnessCodex {
|
||||||
|
t.Errorf("Config.Worker.Harness = %q, want codex", got.Config.Worker.Harness)
|
||||||
|
}
|
||||||
|
if got.Config.Orchestrator.Harness != domain.HarnessClaudeCode {
|
||||||
|
t.Errorf("Config.Orchestrator.Harness = %q, want claude-code", got.Config.Orchestrator.Harness)
|
||||||
|
}
|
||||||
if !got.AsWorkspace {
|
if !got.AsWorkspace {
|
||||||
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
|
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type projectAddOptions struct {
|
type projectAddOptions struct {
|
||||||
path string
|
path string
|
||||||
id string
|
id string
|
||||||
name string
|
name string
|
||||||
asWorkspace bool
|
workerAgent string
|
||||||
|
orchestratorAgent string
|
||||||
|
asWorkspace bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type projectListOptions struct {
|
type projectListOptions struct {
|
||||||
|
|
@ -37,10 +39,11 @@ type projectRemoveOptions struct {
|
||||||
// addProjectRequest mirrors the daemon's project AddInput body for
|
// addProjectRequest mirrors the daemon's project AddInput body for
|
||||||
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
|
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
|
||||||
type addProjectRequest struct {
|
type addProjectRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
ProjectID *string `json:"projectId,omitempty"`
|
ProjectID *string `json:"projectId,omitempty"`
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
AsWorkspace bool `json:"asWorkspace,omitempty"`
|
Config *projectConfig `json:"config,omitempty"`
|
||||||
|
AsWorkspace bool `json:"asWorkspace,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type projectSummary struct {
|
type projectSummary struct {
|
||||||
|
|
@ -58,7 +61,7 @@ type projectDetails struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
DefaultBranch string `json:"defaultBranch"`
|
DefaultBranch string `json:"defaultBranch"`
|
||||||
DefaultHarness string `json:"agent,omitempty"`
|
Agent string `json:"agent,omitempty"`
|
||||||
Config *projectConfig `json:"config,omitempty"`
|
Config *projectConfig `json:"config,omitempty"`
|
||||||
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
|
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
|
||||||
ResolveError string `json:"resolveError,omitempty"`
|
ResolveError string `json:"resolveError,omitempty"`
|
||||||
|
|
@ -226,6 +229,12 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
||||||
if opts.name != "" {
|
if opts.name != "" {
|
||||||
req.Name = &opts.name
|
req.Name = &opts.name
|
||||||
}
|
}
|
||||||
|
if opts.workerAgent != "" || opts.orchestratorAgent != "" {
|
||||||
|
req.Config = &projectConfig{
|
||||||
|
Worker: roleOverride{Agent: opts.workerAgent},
|
||||||
|
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
|
||||||
|
}
|
||||||
|
}
|
||||||
var res projectResult
|
var res projectResult
|
||||||
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
|
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -238,6 +247,8 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
||||||
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
|
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
|
||||||
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
|
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
|
||||||
f.StringVar(&opts.name, "name", "", "Display name")
|
f.StringVar(&opts.name, "name", "", "Display name")
|
||||||
|
f.StringVar(&opts.workerAgent, "worker-agent", "", "Default worker session agent")
|
||||||
|
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Default orchestrator session agent")
|
||||||
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
|
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
@ -443,7 +454,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
|
||||||
{label: "path", value: p.Path},
|
{label: "path", value: p.Path},
|
||||||
{label: "repo", value: p.Repo},
|
{label: "repo", value: p.Repo},
|
||||||
{label: "default branch", value: p.DefaultBranch},
|
{label: "default branch", value: p.DefaultBranch},
|
||||||
{label: "default harness", value: p.DefaultHarness},
|
{label: "agent", value: p.Agent},
|
||||||
{label: "config", value: formatProjectConfig(p.Config)},
|
{label: "config", value: formatProjectConfig(p.Config)},
|
||||||
{label: "resolve error", value: p.ResolveError},
|
{label: "resolve error", value: p.ResolveError},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ func TestProjectGet_Success(t *testing.T) {
|
||||||
if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" {
|
if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" {
|
||||||
t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path)
|
t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} {
|
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "agent: codex"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("output missing %q:\n%s", want, out)
|
t.Fatalf("output missing %q:\n%s", want, out)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
Use: "spawn",
|
Use: "spawn",
|
||||||
Short: "Spawn a worker agent session in a registered project",
|
Short: "Spawn a worker agent session in a registered project",
|
||||||
Long: "Spawn a worker agent session in a registered project.\n\n" +
|
Long: "Spawn a worker agent session in a registered project.\n\n" +
|
||||||
"The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" +
|
"The session runs the chosen agent in a\n" +
|
||||||
"fresh git worktree. Register the project first with `ao project add`.",
|
"fresh git worktree. Register the project first with `ao project add`.",
|
||||||
Args: noArgs,
|
Args: noArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -120,7 +120,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
return pflag.NormalizedName(name)
|
return pflag.NormalizedName(name)
|
||||||
})
|
})
|
||||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
||||||
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: the daemon's AO_AGENT)")
|
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
|
||||||
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
||||||
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ const (
|
||||||
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
|
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
|
||||||
// the process exits even if connections are still draining.
|
// the process exits even if connections are still draining.
|
||||||
DefaultShutdownTimeout = 10 * time.Second
|
DefaultShutdownTimeout = 10 * time.Second
|
||||||
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
|
// DefaultAgent is the compatibility value used when AO_AGENT is unset. The
|
||||||
// unset. It matches the claude-code adapter's manifest id.
|
// daemon validates it at startup, but worker/orchestrator spawns resolve from
|
||||||
|
// explicit requests or project role config instead of falling back to it.
|
||||||
DefaultAgent = "claude-code"
|
DefaultAgent = "claude-code"
|
||||||
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
|
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
|
||||||
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
|
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
|
||||||
|
|
@ -85,9 +86,8 @@ type Config struct {
|
||||||
// DataDir is the directory holding durable SQLite state: DB and WAL files.
|
// DataDir is the directory holding durable SQLite state: DB and WAL files.
|
||||||
// It is created on first use by the storage layer.
|
// It is created on first use by the storage layer.
|
||||||
DataDir string
|
DataDir string
|
||||||
// Agent is the id of the agent adapter the daemon wires into the Session
|
// Agent is the compatibility agent adapter id selected by AO_AGENT;
|
||||||
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
|
// startSession fails fast if no adapter with this id is registered.
|
||||||
// if no adapter with this id is registered.
|
|
||||||
Agent string
|
Agent string
|
||||||
// AllowedOrigins are the browser origins granted CORS read access (see
|
// AllowedOrigins are the browser origins granted CORS read access (see
|
||||||
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
|
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
|
||||||
|
|
@ -113,7 +113,7 @@ func (c Config) Addr() string {
|
||||||
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
|
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
|
||||||
// AO_RUN_FILE running.json path (default ~/.ao/running.json)
|
// AO_RUN_FILE running.json path (default ~/.ao/running.json)
|
||||||
// AO_DATA_DIR durable state dir (default ~/.ao/data)
|
// AO_DATA_DIR durable state dir (default ~/.ao/data)
|
||||||
// AO_AGENT agent adapter id (default claude-code)
|
// AO_AGENT compatibility agent id (default claude-code)
|
||||||
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
|
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
|
||||||
// AO_TELEMETRY_EVENTS local event capture off|on (default off)
|
// AO_TELEMETRY_EVENTS local event capture off|on (default off)
|
||||||
// AO_TELEMETRY_METRICS local metric capture off|on (default off)
|
// AO_TELEMETRY_METRICS local metric capture off|on (default off)
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ func Run() error {
|
||||||
|
|
||||||
// Wire the controller-facing session service over the same store + LCM, the
|
// Wire the controller-facing session service over the same store + LCM, the
|
||||||
// zellij runtime, a gitworktree workspace, the per-session agent resolver
|
// zellij runtime, a gitworktree workspace, the per-session agent resolver
|
||||||
// (AO_AGENT default, validated here), and the agent messenger, then mount it
|
// (AO_AGENT validated here for compatibility), and the agent messenger, then mount it
|
||||||
// on the API.
|
// on the API.
|
||||||
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
|
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,9 @@ func (l *lifecycleStack) Stop() {
|
||||||
|
|
||||||
// startSession builds the controller-facing session service: a session manager
|
// startSession builds the controller-facing session service: a session manager
|
||||||
// over the real zellij runtime, a per-session gitworktree workspace, the shared
|
// over the real zellij runtime, a per-session gitworktree workspace, the shared
|
||||||
// store + LCM, the per-session agent resolver (AO_AGENT default), and the
|
// store + LCM, the per-session agent resolver, and the agent messenger. The
|
||||||
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions.
|
// returned service is mounted at httpd APIDeps.Sessions.
|
||||||
func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) {
|
func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) {
|
||||||
// Resolve the default agent once and share it with both the resolver (which
|
|
||||||
// launches it for an unspecified harness) and the session manager (which
|
|
||||||
// persists it onto the seed row), so the stored harness matches what runs.
|
|
||||||
defaultAgent := cfg.Agent
|
defaultAgent := cfg.Agent
|
||||||
if defaultAgent == "" {
|
if defaultAgent == "" {
|
||||||
defaultAgent = config.DefaultAgent
|
defaultAgent = config.DefaultAgent
|
||||||
|
|
@ -87,15 +84,14 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
|
||||||
return nil, nil, fmt.Errorf("session workspace: %w", err)
|
return nil, nil, fmt.Errorf("session workspace: %w", err)
|
||||||
}
|
}
|
||||||
mgr := sessionmanager.New(sessionmanager.Deps{
|
mgr := sessionmanager.New(sessionmanager.Deps{
|
||||||
Runtime: runtime,
|
Runtime: runtime,
|
||||||
Agents: agents,
|
Agents: agents,
|
||||||
Workspace: ws,
|
Workspace: ws,
|
||||||
Store: store,
|
Store: store,
|
||||||
Messenger: messenger,
|
Messenger: messenger,
|
||||||
Lifecycle: lcm,
|
Lifecycle: lcm,
|
||||||
DataDir: cfg.DataDir,
|
DataDir: cfg.DataDir,
|
||||||
DefaultHarness: domain.AgentHarness(defaultAgent),
|
Logger: log,
|
||||||
Logger: log,
|
|
||||||
})
|
})
|
||||||
scmProvider, err := newGitHubSCMProvider(log)
|
scmProvider, err := newGitHubSCMProvider(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -179,20 +175,15 @@ func buildAgentRegistry() (*adapters.Registry, error) {
|
||||||
|
|
||||||
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
|
// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
|
||||||
// maps a session's harness onto the registered adapter of the same id and
|
// maps a session's harness onto the registered adapter of the same id and
|
||||||
// asserts that adapter drives an agent. An empty harness falls back to the
|
// asserts that adapter drives an agent. Empty harnesses are invalid at the
|
||||||
// daemon's configured default (AO_AGENT), so a spawn that names no harness still
|
// session manager boundary and deliberately do not resolve here.
|
||||||
// gets a real agent.
|
|
||||||
type agentRegistry struct {
|
type agentRegistry struct {
|
||||||
reg *adapters.Registry
|
reg *adapters.Registry
|
||||||
defaultHarness domain.AgentHarness
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ ports.AgentResolver = agentRegistry{}
|
var _ ports.AgentResolver = agentRegistry{}
|
||||||
|
|
||||||
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
|
func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
|
||||||
if harness == "" {
|
|
||||||
harness = a.defaultHarness
|
|
||||||
}
|
|
||||||
adapter, ok := a.reg.Get(string(harness))
|
adapter, ok := a.reg.Get(string(harness))
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
|
|
@ -203,10 +194,9 @@ func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
|
||||||
|
|
||||||
// buildAgentResolver constructs the per-session agent resolver the Session
|
// buildAgentResolver constructs the per-session agent resolver the Session
|
||||||
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
|
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
|
||||||
// adapters plus the configured default harness. It fails fast if the default
|
// adapters. It still validates AO_AGENT at startup for compatibility with the
|
||||||
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane
|
// config surface, but worker/orchestrator spawns must provide a resolved
|
||||||
// plugs this in when it mounts the controller-facing session service at the
|
// harness before calling Agent.
|
||||||
// httpd APIDeps.Sessions slot.
|
|
||||||
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
|
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
|
||||||
if defaultAgent == "" {
|
if defaultAgent == "" {
|
||||||
defaultAgent = config.DefaultAgent
|
defaultAgent = config.DefaultAgent
|
||||||
|
|
@ -215,8 +205,8 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)}
|
resolver := agentRegistry{reg: reg}
|
||||||
if _, ok := resolver.Agent(""); !ok {
|
if _, ok := resolver.Agent(domain.AgentHarness(defaultAgent)); !ok {
|
||||||
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
|
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
|
||||||
}
|
}
|
||||||
ids := make([]string, 0)
|
ids := make([]string, 0)
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {
|
||||||
|
|
||||||
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
|
// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
|
||||||
// real registry-backed per-session resolver: each harness resolves to the
|
// real registry-backed per-session resolver: each harness resolves to the
|
||||||
// matching registered adapter, an empty harness falls back to the AO_AGENT
|
// matching registered adapter, while empty and unknown harnesses miss.
|
||||||
// default, and an unknown harness misses.
|
|
||||||
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
resolver, err := buildAgentResolver("", log) // empty default → claude-code
|
resolver, err := buildAgentResolver("", log) // empty default → claude-code
|
||||||
|
|
@ -114,7 +113,6 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
||||||
{domain.HarnessVibe, "vibe"},
|
{domain.HarnessVibe, "vibe"},
|
||||||
{domain.HarnessPi, "pi"},
|
{domain.HarnessPi, "pi"},
|
||||||
{domain.HarnessAutohand, "autohand"},
|
{domain.HarnessAutohand, "autohand"},
|
||||||
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
|
|
||||||
} {
|
} {
|
||||||
agent, ok := resolver.Agent(tc.harness)
|
agent, ok := resolver.Agent(tc.harness)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -131,6 +129,9 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
|
||||||
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
|
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
|
||||||
t.Fatal("unknown harness resolved to an agent; want a miss")
|
t.Fatal("unknown harness resolved to an agent; want a miss")
|
||||||
}
|
}
|
||||||
|
if _, ok := resolver.Agent(""); ok {
|
||||||
|
t.Fatal("empty harness resolved to an agent; want a miss")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession
|
// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,15 @@ func newStack(t *testing.T) *stack {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = store.Close() })
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
|
if err := store.UpsertProject(ctx, domain.ProjectRecord{
|
||||||
|
ID: "mer",
|
||||||
|
Path: "/repo/mer",
|
||||||
|
RegisteredAt: time.Now(),
|
||||||
|
Config: domain.ProjectConfig{
|
||||||
|
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
||||||
|
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
msg := &captureMessenger{}
|
msg := &captureMessenger{}
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,8 @@ func toAPIError(err error) error {
|
||||||
return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
|
return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
|
||||||
case errors.Is(err, sessionmanager.ErrUnknownHarness):
|
case errors.Is(err, sessionmanager.ErrUnknownHarness):
|
||||||
return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil)
|
return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil)
|
||||||
|
case errors.Is(err, sessionmanager.ErrMissingHarness):
|
||||||
|
return apierr.Invalid("AGENT_REQUIRED", err.Error(), nil)
|
||||||
case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere):
|
case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere):
|
||||||
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
|
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
|
||||||
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):
|
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
|
||||||
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
|
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
|
||||||
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
|
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
|
||||||
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
|
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
|
||||||
|
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ var (
|
||||||
// adapter. The API maps it to a 400 so a typo'd `--harness` is a validation
|
// adapter. The API maps it to a 400 so a typo'd `--harness` is a validation
|
||||||
// error, not an opaque 500.
|
// error, not an opaque 500.
|
||||||
ErrUnknownHarness = errors.New("session: unknown agent harness")
|
ErrUnknownHarness = errors.New("session: unknown agent harness")
|
||||||
|
// ErrMissingHarness means neither the spawn request nor the project's role
|
||||||
|
// config selected an agent. Worker/orchestrator spawns must be explicit.
|
||||||
|
ErrMissingHarness = errors.New("session: agent harness required")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Env vars a spawned process reads to learn who it is.
|
// Env vars a spawned process reads to learn who it is.
|
||||||
|
|
@ -86,11 +89,7 @@ type Manager struct {
|
||||||
messenger ports.AgentMessenger
|
messenger ports.AgentMessenger
|
||||||
lcm lifecycleRecorder
|
lcm lifecycleRecorder
|
||||||
dataDir string
|
dataDir string
|
||||||
// defaultHarness is the daemon's configured default agent (AO_AGENT). A spawn
|
clock func() time.Time
|
||||||
// that names no harness resolves to it before the seed row is written, so the
|
|
||||||
// stored/returned harness matches the agent the resolver actually launches.
|
|
||||||
defaultHarness domain.AgentHarness
|
|
||||||
clock func() time.Time
|
|
||||||
// lookPath is exec.LookPath in production; tests substitute a stub so
|
// lookPath is exec.LookPath in production; tests substitute a stub so
|
||||||
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
|
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
|
||||||
// when the binary is missing so the sentinel propagates through toAPIError.
|
// when the binary is missing so the sentinel propagates through toAPIError.
|
||||||
|
|
@ -113,12 +112,7 @@ type Deps struct {
|
||||||
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
|
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
|
||||||
// commands can open the same store.
|
// commands can open the same store.
|
||||||
DataDir string
|
DataDir string
|
||||||
// DefaultHarness is the daemon's configured default agent (AO_AGENT), used to
|
Clock func() time.Time
|
||||||
// resolve a spawn that names no harness. Wiring passes config.DefaultAgent;
|
|
||||||
// left empty, an unspecified harness stays empty (the resolver still defaults
|
|
||||||
// it at launch, but the record won't reflect the real agent).
|
|
||||||
DefaultHarness domain.AgentHarness
|
|
||||||
Clock func() time.Time
|
|
||||||
// LookPath overrides exec.LookPath for the pre-launch agent-binary check.
|
// LookPath overrides exec.LookPath for the pre-launch agent-binary check.
|
||||||
// Production wiring leaves this nil and the manager defaults to
|
// Production wiring leaves this nil and the manager defaults to
|
||||||
// exec.LookPath; tests inject a stub so they need not seed real binaries.
|
// exec.LookPath; tests inject a stub so they need not seed real binaries.
|
||||||
|
|
@ -136,18 +130,17 @@ type Deps struct {
|
||||||
// time.Now when Deps.Clock is nil.
|
// time.Now when Deps.Clock is nil.
|
||||||
func New(d Deps) *Manager {
|
func New(d Deps) *Manager {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
runtime: d.Runtime,
|
runtime: d.Runtime,
|
||||||
agents: d.Agents,
|
agents: d.Agents,
|
||||||
workspace: d.Workspace,
|
workspace: d.Workspace,
|
||||||
store: d.Store,
|
store: d.Store,
|
||||||
messenger: d.Messenger,
|
messenger: d.Messenger,
|
||||||
lcm: d.Lifecycle,
|
lcm: d.Lifecycle,
|
||||||
dataDir: d.DataDir,
|
dataDir: d.DataDir,
|
||||||
defaultHarness: d.DefaultHarness,
|
clock: d.Clock,
|
||||||
clock: d.Clock,
|
lookPath: d.LookPath,
|
||||||
lookPath: d.LookPath,
|
executable: d.Executable,
|
||||||
executable: d.Executable,
|
logger: d.Logger,
|
||||||
logger: d.Logger,
|
|
||||||
}
|
}
|
||||||
if m.clock == nil {
|
if m.clock == nil {
|
||||||
// UTC so spawn-stamped CreatedAt/UpdatedAt match every other session
|
// UTC so spawn-stamped CreatedAt/UpdatedAt match every other session
|
||||||
|
|
@ -179,12 +172,8 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
||||||
// A per-project role override picks the harness when the spawn names none,
|
// A per-project role override picks the harness when the spawn names none,
|
||||||
// so a project can default workers to one agent and orchestrators to another.
|
// so a project can default workers to one agent and orchestrators to another.
|
||||||
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
|
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
|
||||||
// Resolve an unspecified harness to the daemon default BEFORE the seed row is
|
|
||||||
// written, so the stored/returned harness matches the agent the resolver
|
|
||||||
// launches (otherwise a default-agent session persists an empty harness and
|
|
||||||
// the UI can't tell which agent is running).
|
|
||||||
if cfg.Harness == "" {
|
if cfg.Harness == "" {
|
||||||
cfg.Harness = m.defaultHarness
|
return domain.SessionRecord{}, fmt.Errorf("spawn: %w: configure project %s.agent or pass --harness", ErrMissingHarness, roleConfigName(cfg.Kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject an unknown harness before any durable state is created. Doing this
|
// Reject an unknown harness before any durable state is created. Doing this
|
||||||
|
|
@ -307,8 +296,8 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveHarness resolves the harness for a spawn: an explicit harness wins;
|
// effectiveHarness resolves the harness for a spawn: an explicit harness wins;
|
||||||
// otherwise the project's role override for the session kind applies; otherwise
|
// otherwise the project's role override for the session kind applies. Empty is
|
||||||
// it stays empty so the daemon's global default (AO_AGENT) is used downstream.
|
// invalid for new worker/orchestrator launches and is rejected by Spawn.
|
||||||
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
|
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
|
||||||
if explicit != "" {
|
if explicit != "" {
|
||||||
return explicit
|
return explicit
|
||||||
|
|
@ -319,6 +308,13 @@ func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func roleConfigName(kind domain.SessionKind) string {
|
||||||
|
if kind == domain.KindOrchestrator {
|
||||||
|
return "orchestrator"
|
||||||
|
}
|
||||||
|
return "worker"
|
||||||
|
}
|
||||||
|
|
||||||
// effectiveAgentConfig merges the role override's agent config over the
|
// effectiveAgentConfig merges the role override's agent config over the
|
||||||
// project's base agent config; set override fields win.
|
// project's base agent config; set override fields win.
|
||||||
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {
|
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,7 @@ func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string)
|
||||||
|
|
||||||
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
rt := &fakeRuntime{}
|
rt := &fakeRuntime{}
|
||||||
ws := &fakeWorkspace{}
|
ws := &fakeWorkspace{}
|
||||||
// Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent
|
// Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent
|
||||||
|
|
@ -231,6 +232,12 @@ func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
||||||
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
||||||
return m, st, rt, ws
|
return m, st, rt, ws
|
||||||
}
|
}
|
||||||
|
func testRoleAgents() domain.ProjectConfig {
|
||||||
|
return domain.ProjectConfig{
|
||||||
|
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
||||||
|
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
||||||
|
}
|
||||||
|
}
|
||||||
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
|
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
|
||||||
st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
|
st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
|
||||||
}
|
}
|
||||||
|
|
@ -273,10 +280,11 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
|
||||||
t.Fatal("runtime env missing AO_SESSION_ID")
|
t.Fatal("runtime env missing AO_SESSION_ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A project with no stored config yields a zero AgentConfig (adapter defaults).
|
// A project with no stored config yields a zero AgentConfig (adapter defaults)
|
||||||
|
// when the spawn explicitly names its agent.
|
||||||
st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
|
st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
|
||||||
agent.lastConfig = ports.AgentConfig{Model: "stale"}
|
agent.lastConfig = ports.AgentConfig{Model: "stale"}
|
||||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil {
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !agent.lastConfig.IsZero() {
|
if !agent.lastConfig.IsZero() {
|
||||||
|
|
@ -284,30 +292,38 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSpawn_PersistsResolvedDefaultHarness locks the fix for the mislabelled
|
func TestSpawn_RejectsMissingRoleHarness(t *testing.T) {
|
||||||
// agent: a spawn that names no harness must persist the daemon's default agent
|
|
||||||
// (so the API/UI report what actually runs), while an explicit harness wins.
|
|
||||||
func TestSpawn_PersistsResolvedDefaultHarness(t *testing.T) {
|
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
||||||
m := New(Deps{
|
m := New(Deps{
|
||||||
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
||||||
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
||||||
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
||||||
DefaultHarness: domain.HarnessClaudeCode,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) {
|
||||||
t.Fatal(err)
|
t.Fatalf("worker err = %v, want ErrMissingHarness", err)
|
||||||
}
|
}
|
||||||
if got := st.sessions["mer-1"].Harness; got != domain.HarnessClaudeCode {
|
if len(st.sessions) != 0 {
|
||||||
t.Fatalf("unspecified harness = %q, want resolved default %q", got, domain.HarnessClaudeCode)
|
t.Fatalf("missing worker harness must not create a session row, got %d", len(st.sessions))
|
||||||
}
|
}
|
||||||
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) {
|
||||||
|
t.Fatalf("orchestrator err = %v, want ErrMissingHarness", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) {
|
||||||
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
||||||
|
m := New(Deps{
|
||||||
|
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
||||||
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
||||||
|
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
||||||
|
})
|
||||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := st.sessions["mer-2"].Harness; got != domain.HarnessCodex {
|
if got := st.sessions["mer-1"].Harness; got != domain.HarnessCodex {
|
||||||
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
|
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -561,7 +577,10 @@ func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
||||||
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto},
|
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto},
|
||||||
Worker: domain.RoleOverride{AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions}},
|
Worker: domain.RoleOverride{
|
||||||
|
Harness: domain.HarnessClaudeCode,
|
||||||
|
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
agent := &recordingAgent{}
|
agent := &recordingAgent{}
|
||||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||||
|
|
@ -610,6 +629,7 @@ func TestRestore_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
|
||||||
|
|
||||||
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
st.num = 1
|
st.num = 1
|
||||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
||||||
agent := &recordingAgent{}
|
agent := &recordingAgent{}
|
||||||
|
|
@ -646,6 +666,7 @@ func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
||||||
|
|
||||||
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
st.num = 1
|
st.num = 1
|
||||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
|
||||||
agent := &recordingAgent{}
|
agent := &recordingAgent{}
|
||||||
|
|
@ -666,6 +687,7 @@ func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
||||||
|
|
||||||
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
agent := &recordingAgent{}
|
agent := &recordingAgent{}
|
||||||
rt := &fakeRuntime{}
|
rt := &fakeRuntime{}
|
||||||
ws := &fakeWorkspace{}
|
ws := &fakeWorkspace{}
|
||||||
|
|
@ -878,6 +900,7 @@ func TestRollbackSpawn_FallsBackToKillForLiveRow(t *testing.T) {
|
||||||
// reaper later mistakes for a live session.
|
// reaper later mistakes for a live session.
|
||||||
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
rt := &fakeRuntime{}
|
rt := &fakeRuntime{}
|
||||||
ws := &fakeWorkspace{}
|
ws := &fakeWorkspace{}
|
||||||
notFound := func(name string) (string, error) {
|
notFound := func(name string) (string, error) {
|
||||||
|
|
@ -927,6 +950,7 @@ func TestSpawn_RejectsUnknownHarness(t *testing.T) {
|
||||||
// buffer capturing its log output, for the hook PATH pin tests.
|
// buffer capturing its log output, for the hook PATH pin tests.
|
||||||
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
|
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
|
||||||
st := newFakeStore()
|
st := newFakeStore()
|
||||||
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||||
rt := &fakeRuntime{}
|
rt := &fakeRuntime{}
|
||||||
logBuf := &bytes.Buffer{}
|
logBuf := &bytes.Buffer{}
|
||||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||||
|
|
@ -1018,7 +1042,8 @@ func TestSpawn_ProjectPATHIsPinBase(t *testing.T) {
|
||||||
daemonExe := filepath.Join(t.TempDir(), "ao")
|
daemonExe := filepath.Join(t.TempDir(), "ao")
|
||||||
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
|
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
|
||||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
||||||
Env: map[string]string{"PATH": "/proj/bin"},
|
Env: map[string]string{"PATH": "/proj/bin"},
|
||||||
|
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
||||||
}}
|
}}
|
||||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AGENT_OPTIONS } from "../lib/agent-options";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
|
|
||||||
|
export type CreateProjectAgentSelection = {
|
||||||
|
workerAgent: string;
|
||||||
|
orchestratorAgent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateProjectAgentSheetProps = {
|
||||||
|
error?: string | null;
|
||||||
|
isCreating: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (selection: CreateProjectAgentSelection) => Promise<void>;
|
||||||
|
open: boolean;
|
||||||
|
path: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateProjectAgentSheet({
|
||||||
|
error,
|
||||||
|
isCreating,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
path,
|
||||||
|
}: CreateProjectAgentSheetProps) {
|
||||||
|
const [workerAgent, setWorkerAgent] = useState("");
|
||||||
|
const [orchestratorAgent, setOrchestratorAgent] = useState("");
|
||||||
|
const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !isCreating;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setWorkerAgent("");
|
||||||
|
setOrchestratorAgent("");
|
||||||
|
}
|
||||||
|
}, [open, path]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={(next) => !isCreating && onOpenChange(next)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
|
||||||
|
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Dialog.Title className="text-[15px] font-semibold text-foreground">Project agents</Dialog.Title>
|
||||||
|
<Dialog.Description className="mt-1 break-all text-[12px] text-muted-foreground">
|
||||||
|
{path ?? ""}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
aria-label="Close project agents dialog"
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="space-y-4 px-5 py-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
void onSubmit({ workerAgent, orchestratorAgent });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<RequiredAgentField
|
||||||
|
id="newProjectWorkerAgent"
|
||||||
|
label="Worker agent"
|
||||||
|
placeholder="Select worker agent"
|
||||||
|
value={workerAgent}
|
||||||
|
onChange={setWorkerAgent}
|
||||||
|
/>
|
||||||
|
<RequiredAgentField
|
||||||
|
id="newProjectOrchestratorAgent"
|
||||||
|
label="Orchestrator agent"
|
||||||
|
placeholder="Select orchestrator agent"
|
||||||
|
value={orchestratorAgent}
|
||||||
|
onChange={setOrchestratorAgent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button type="button" variant="ghost" disabled={isCreating} onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={!canSubmit}>
|
||||||
|
{isCreating ? "Creating..." : "Create and start"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequiredAgentField({
|
||||||
|
id,
|
||||||
|
invalid = false,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={id} className="text-[12px] font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger id={id} className="h-8 w-full text-[13px]" aria-invalid={invalid || undefined}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AGENT_OPTIONS.map((agent) => (
|
||||||
|
<SelectItem key={agent} value={agent}>
|
||||||
|
{agent}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -149,6 +149,10 @@ describe("ProjectSettingsForm", () => {
|
||||||
path: "/repo/project-one",
|
path: "/repo/project-one",
|
||||||
repo: "",
|
repo: "",
|
||||||
defaultBranch: "main",
|
defaultBranch: "main",
|
||||||
|
config: {
|
||||||
|
worker: { agent: "codex" },
|
||||||
|
orchestrator: { agent: "claude-code" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
|
@ -165,4 +169,35 @@ describe("ProjectSettingsForm", () => {
|
||||||
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
|
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires worker and orchestrator agents for existing projects missing role config", async () => {
|
||||||
|
getMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
status: "ok",
|
||||||
|
project: {
|
||||||
|
id: "proj-1",
|
||||||
|
name: "Project One",
|
||||||
|
kind: "single_repo",
|
||||||
|
path: "/repo/project-one",
|
||||||
|
repo: "",
|
||||||
|
defaultBranch: "main",
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSettings();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent");
|
||||||
|
expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent(
|
||||||
|
"Select orchestrator agent",
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2);
|
||||||
|
expect(putMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from "react";
|
||||||
import type { components } from "../../api/schema";
|
import type { components } from "../../api/schema";
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||||
|
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||||
import { DashboardSubhead } from "./DashboardSubhead";
|
import { DashboardSubhead } from "./DashboardSubhead";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
|
@ -12,9 +13,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||||
type Project = components["schemas"]["Project"];
|
type Project = components["schemas"]["Project"];
|
||||||
type ProjectConfig = components["schemas"]["ProjectConfig"];
|
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;
|
|
||||||
|
|
||||||
const PERMISSION_MODE_OPTIONS = [
|
const PERMISSION_MODE_OPTIONS = [
|
||||||
{ value: "default", label: "Default" },
|
{ value: "default", label: "Default" },
|
||||||
{ value: "accept-edits", label: "Accept edits" },
|
{ value: "accept-edits", label: "Accept edits" },
|
||||||
|
|
@ -78,6 +76,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
|
reviewerHarness: config.reviewers?.[0]?.harness ?? "",
|
||||||
});
|
});
|
||||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === "";
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -87,8 +87,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
...config,
|
...config,
|
||||||
defaultBranch: form.defaultBranch || undefined,
|
defaultBranch: form.defaultBranch || undefined,
|
||||||
sessionPrefix: form.sessionPrefix || undefined,
|
sessionPrefix: form.sessionPrefix || undefined,
|
||||||
worker: blankToUndefined({ ...config.worker, agent: form.workerAgent || undefined }),
|
worker: { ...config.worker, agent: form.workerAgent },
|
||||||
orchestrator: blankToUndefined({ ...config.orchestrator, agent: form.orchestratorAgent || undefined }),
|
orchestrator: { ...config.orchestrator, agent: form.orchestratorAgent },
|
||||||
agentConfig: blankToUndefined({
|
agentConfig: blankToUndefined({
|
||||||
...config.agentConfig,
|
...config.agentConfig,
|
||||||
model: form.model || undefined,
|
model: form.model || undefined,
|
||||||
|
|
@ -104,6 +104,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSavedAt(Date.now());
|
setSavedAt(Date.now());
|
||||||
|
setValidationError(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
|
|
@ -114,6 +115,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
className="mx-auto flex max-w-2xl flex-col gap-4"
|
className="mx-auto flex max-w-2xl flex-col gap-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setSavedAt(null);
|
||||||
|
if (missingRequiredAgent) {
|
||||||
|
setValidationError("Worker and orchestrator agents are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError(null);
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -159,20 +166,25 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
<CardTitle className="text-[13px]">Agents</CardTitle>
|
<CardTitle className="text-[13px]">Agents</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Field label="Default worker agent" htmlFor="workerAgent">
|
<RequiredAgentField
|
||||||
<AgentSelect
|
id="workerAgent"
|
||||||
id="workerAgent"
|
value={form.workerAgent}
|
||||||
value={form.workerAgent}
|
placeholder="Select worker agent"
|
||||||
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
|
label="Default worker agent"
|
||||||
/>
|
invalid={validationError !== null && form.workerAgent === ""}
|
||||||
</Field>
|
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
|
||||||
<Field label="Default orchestrator agent" htmlFor="orchestratorAgent">
|
/>
|
||||||
<AgentSelect
|
<RequiredAgentField
|
||||||
id="orchestratorAgent"
|
id="orchestratorAgent"
|
||||||
value={form.orchestratorAgent}
|
value={form.orchestratorAgent}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
|
placeholder="Select orchestrator agent"
|
||||||
/>
|
label="Default orchestrator agent"
|
||||||
</Field>
|
invalid={validationError !== null && form.orchestratorAgent === ""}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
|
||||||
|
/>
|
||||||
|
{missingRequiredAgent && (
|
||||||
|
<p className="text-[12px] leading-5 text-error">Worker and orchestrator agents are required.</p>
|
||||||
|
)}
|
||||||
<Field label="Model override" htmlFor="model">
|
<Field label="Model override" htmlFor="model">
|
||||||
<input
|
<input
|
||||||
id="model"
|
id="model"
|
||||||
|
|
@ -211,6 +223,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
||||||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? "Saving…" : "Save changes"}
|
{mutation.isPending ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{validationError && <span className="text-[12px] text-error">{validationError}</span>}
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
<span className="text-[12px] text-error">
|
<span className="text-[12px] text-error">
|
||||||
{mutation.error instanceof Error ? mutation.error.message : "Save failed"}
|
{mutation.error instanceof Error ? mutation.error.message : "Save failed"}
|
||||||
|
|
@ -250,25 +263,6 @@ function PermissionModeSelect({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
|
|
||||||
// "" sentinel → daemon default; Select can't hold an empty value, so map it.
|
|
||||||
return (
|
|
||||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
|
||||||
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__default__">Daemon default</SelectItem>
|
|
||||||
{AGENT_OPTIONS.map((agent) => (
|
|
||||||
<SelectItem key={agent} value={agent}>
|
|
||||||
{agent}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
|
function ReviewerSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,16 @@ const workspace: WorkspaceSummary = {
|
||||||
sessions: [],
|
sessions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||||
|
type RemoveProjectHandler = (projectId: string) => Promise<void>;
|
||||||
|
|
||||||
|
function renderSidebar({
|
||||||
|
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
|
||||||
|
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
|
||||||
|
}: {
|
||||||
|
onCreateProject?: CreateProjectHandler;
|
||||||
|
onRemoveProject?: RemoveProjectHandler;
|
||||||
|
} = {}) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
});
|
});
|
||||||
|
|
@ -35,7 +44,7 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
daemonStatus={{ state: "running" }}
|
daemonStatus={{ state: "running" }}
|
||||||
onCreateProject={vi.fn()}
|
onCreateProject={onCreateProject}
|
||||||
onRemoveProject={onRemoveProject}
|
onRemoveProject={onRemoveProject}
|
||||||
workspaces={[workspace]}
|
workspaces={[workspace]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -45,6 +54,11 @@ function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
||||||
return onRemoveProject;
|
return onRemoveProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chooseOption(trigger: HTMLElement, optionName: string) {
|
||||||
|
await userEvent.click(trigger);
|
||||||
|
await userEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
navigateMock.mockReset();
|
navigateMock.mockReset();
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
|
@ -97,6 +111,30 @@ describe("Sidebar", () => {
|
||||||
expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
|
expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires explicit worker and orchestrator agents when creating a project", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
|
||||||
|
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
|
||||||
|
renderSidebar({ onCreateProject });
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("New project"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
|
||||||
|
const dialog = screen.getByRole("dialog", { name: "Project agents" });
|
||||||
|
expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2");
|
||||||
|
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex");
|
||||||
|
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Create and start" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onCreateProject).toHaveBeenCalledWith({
|
||||||
|
path: "/repo/new-project",
|
||||||
|
workerAgent: "codex",
|
||||||
|
orchestratorAgent: "claude-code",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("always shows action icons and reserves padding for them", () => {
|
it("always shows action icons and reserves padding for them", () => {
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
attentionZone,
|
attentionZone,
|
||||||
isOrchestratorSession,
|
isOrchestratorSession,
|
||||||
|
|
@ -55,6 +55,7 @@ import { OrchestratorIcon } from "./icons";
|
||||||
import aoLogo from "../assets/ao-logo.png";
|
import aoLogo from "../assets/ao-logo.png";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useUiStore } from "../stores/ui-store";
|
import { useUiStore } from "../stores/ui-store";
|
||||||
|
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
|
||||||
|
|
||||||
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
|
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
|
||||||
// in the full-width topbar's left inset (_shell renders the bar above the
|
// in the full-width topbar's left inset (_shell renders the bar above the
|
||||||
|
|
@ -74,7 +75,7 @@ type SidebarProps = {
|
||||||
underTopbar?: boolean;
|
underTopbar?: boolean;
|
||||||
workspaceError?: string;
|
workspaceError?: string;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
onCreateProject: (input: { path: string }) => Promise<void>;
|
onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||||
onRemoveProject: (projectId: string) => Promise<void>;
|
onRemoveProject: (projectId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -591,15 +592,70 @@ function ProjectItem({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
||||||
|
return (
|
||||||
|
<CreateProjectFlow onCreateProject={onCreateProject}>
|
||||||
|
{({ disabled, choosePath, label }) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label="New project"
|
||||||
|
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={choosePath}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CreateProjectFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
||||||
|
return (
|
||||||
|
<CreateProjectFlow onCreateProject={onCreateProject}>
|
||||||
|
{({ disabled, choosePath, label }) => (
|
||||||
|
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label="New project"
|
||||||
|
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={choosePath}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
|
</CreateProjectFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateProjectFlow({
|
||||||
|
children,
|
||||||
|
onCreateProject,
|
||||||
|
}: Pick<SidebarProps, "onCreateProject"> & {
|
||||||
|
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
|
||||||
|
}) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
const choosePath = async () => {
|
const choosePath = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsChoosingPath(true);
|
setIsChoosingPath(true);
|
||||||
try {
|
try {
|
||||||
const selectedPath = await aoBridge.app.chooseDirectory();
|
const path = await aoBridge.app.chooseDirectory();
|
||||||
if (selectedPath) await onCreateProject({ path: selectedPath });
|
if (path) setSelectedPath(path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Could not add project");
|
setError(err instanceof Error ? err.message : "Could not add project");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -607,22 +663,38 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createProject = async (selection: CreateProjectAgentSelection) => {
|
||||||
|
if (!selectedPath) return;
|
||||||
|
setError(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await onCreateProject({ path: selectedPath, ...selection });
|
||||||
|
setSelectedPath(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not add project");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
{children({ choosePath: () => void choosePath(), disabled: isChoosingPath || isCreating, label })}
|
||||||
<TooltipTrigger asChild>
|
<CreateProjectAgentSheet
|
||||||
<button
|
error={error}
|
||||||
aria-label="New project"
|
isCreating={isCreating}
|
||||||
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
|
onOpenChange={(open) => {
|
||||||
disabled={isChoosingPath}
|
if (!open) {
|
||||||
onClick={choosePath}
|
setSelectedPath(null);
|
||||||
type="button"
|
setError(null);
|
||||||
>
|
}
|
||||||
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
}}
|
||||||
</button>
|
onSubmit={createProject}
|
||||||
</TooltipTrigger>
|
open={selectedPath !== null}
|
||||||
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
|
path={selectedPath}
|
||||||
</Tooltip>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<span className="sr-only" role="status">
|
<span className="sr-only" role="status">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -631,45 +703,3 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
|
||||||
|
|
||||||
const choosePath = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsChoosingPath(true);
|
|
||||||
try {
|
|
||||||
const selectedPath = await aoBridge.app.chooseDirectory();
|
|
||||||
if (selectedPath) await onCreateProject({ path: selectedPath });
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Could not add project");
|
|
||||||
} finally {
|
|
||||||
setIsChoosingPath(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
aria-label="New project"
|
|
||||||
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
|
|
||||||
disabled={isChoosingPath}
|
|
||||||
onClick={choosePath}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{error && (
|
|
||||||
<span className="sr-only" role="status">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
export const AGENT_OPTIONS = [
|
||||||
|
"claude-code",
|
||||||
|
"codex",
|
||||||
|
"aider",
|
||||||
|
"opencode",
|
||||||
|
"grok",
|
||||||
|
"droid",
|
||||||
|
"amp",
|
||||||
|
"agy",
|
||||||
|
"crush",
|
||||||
|
"cursor",
|
||||||
|
"qwen",
|
||||||
|
"copilot",
|
||||||
|
"goose",
|
||||||
|
"auggie",
|
||||||
|
"continue",
|
||||||
|
"devin",
|
||||||
|
"cline",
|
||||||
|
"kimi",
|
||||||
|
"kiro",
|
||||||
|
"kilocode",
|
||||||
|
"vibe",
|
||||||
|
"pi",
|
||||||
|
"autohand",
|
||||||
|
] as const;
|
||||||
|
|
@ -6,7 +6,7 @@ import type { useDaemonStatus } from "../hooks/useDaemonStatus";
|
||||||
// it lives in the shell and is handed down here rather than re-run per route.
|
// it lives in the shell and is handed down here rather than re-run per route.
|
||||||
export type ShellContextValue = {
|
export type ShellContextValue = {
|
||||||
daemonStatus: ReturnType<typeof useDaemonStatus>;
|
daemonStatus: ReturnType<typeof useDaemonStatus>;
|
||||||
createProject: (input: { path: string }) => Promise<void>;
|
createProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShellContext = createContext<ShellContextValue | null>(null);
|
const ShellContext = createContext<ShellContextValue | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../
|
||||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||||
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
|
||||||
import { ShellProvider } from "../lib/shell-context";
|
import { ShellProvider } from "../lib/shell-context";
|
||||||
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
||||||
import type { WorkspaceSummary } from "../types/workspace";
|
import type { WorkspaceSummary } from "../types/workspace";
|
||||||
|
|
||||||
|
|
@ -51,9 +52,17 @@ function ShellLayout() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const createProject = useCallback(
|
const createProject = useCallback(
|
||||||
async (input: { path: string }) => {
|
async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => {
|
||||||
void captureRendererEvent("ao.renderer.project_add_requested");
|
void captureRendererEvent("ao.renderer.project_add_requested");
|
||||||
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
|
const { data, error } = await apiClient.POST("/api/v1/projects", {
|
||||||
|
body: {
|
||||||
|
path: input.path,
|
||||||
|
config: {
|
||||||
|
worker: { agent: input.workerAgent },
|
||||||
|
orchestrator: { agent: input.orchestratorAgent },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
const failure = new Error(apiErrorMessage(error));
|
const failure = new Error(apiErrorMessage(error));
|
||||||
void captureRendererException(failure, { source: "project-add" });
|
void captureRendererException(failure, { source: "project-add" });
|
||||||
|
|
@ -70,9 +79,20 @@ function ShellLayout() {
|
||||||
};
|
};
|
||||||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||||
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
||||||
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
|
try {
|
||||||
|
const sessionId = await spawnOrchestrator(workspace.id);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||||
|
void navigate({
|
||||||
|
to: "/projects/$projectId/sessions/$sessionId",
|
||||||
|
params: { projectId: workspace.id, sessionId },
|
||||||
|
});
|
||||||
|
} catch (spawnError) {
|
||||||
|
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
|
||||||
|
const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator";
|
||||||
|
throw new Error(`Project added, but orchestrator did not start: ${message}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[navigate, updateWorkspaces],
|
[navigate, queryClient, updateWorkspaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeProject = useCallback(
|
const removeProject = useCallback(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue