Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions backend/internal/adapters/workspace/gitworktree/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
if err := validateConfig(cfg); err != nil {
return ports.WorkspaceInfo{}, err
}
repo, err := w.repoPath(cfg.ProjectID)
repo, err := w.repoPathForConfig(cfg)
if err != nil {
return ports.WorkspaceInfo{}, err
}
Expand All @@ -141,7 +141,7 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil
}

// CreateWorkspaceProject materialises a root-as-repo workspace session: the
Expand Down Expand Up @@ -597,7 +597,7 @@ func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg
if branch == "" {
branch = cfg.Branch
}
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, true, nil
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, true, nil
}
return ports.WorkspaceInfo{}, false, nil
}
Expand Down
33 changes: 31 additions & 2 deletions backend/internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import (
// daemon's spawn handler so a direct API call is held to the same limit.
const maxDisplayNameLen = 20

// Scratch project constants mirrored from domain so the CLI can default spawns
// to the built-in pseudo-project without importing domain.
const (
scratchProjectID = "scratch"
scratchProjectKind = "scratch"
)

type spawnOptions struct {
project string
harness string
Expand Down Expand Up @@ -80,19 +87,26 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
}

explicitProject := strings.TrimSpace(opts.project)
project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project)
if err != nil {
return err
}
opts.project = project.ID
if project.ID == scratchProjectID && explicitProject == "" &&
strings.TrimSpace(os.Getenv("AO_PROJECT_ID")) == "" && strings.TrimSpace(os.Getenv("AO_SESSION_ID")) == "" {
// The fallback is silent otherwise: a user inside an unregistered
// repo could expect the agent to work on that repo.
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: no registered project matched; using the built-in Scratch project (throwaway worktree)")
}

harness, err := resolveSpawnHarness(opts.harness, project)
if err != nil {
return err
}
opts.harness = harness

if !opts.skipAgentCheck {
if !opts.skipAgentCheck && strings.TrimSpace(opts.harness) != "" {
if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil {
return err
}
Expand Down Expand Up @@ -206,7 +220,13 @@ func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit strin
if ok {
return project, nil
}
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")}
// No registered project context: fall back to the built-in Scratch pseudo-project
// so a freeform `ao spawn --prompt "..."` works without project registration.
return scratchProjectDetails(), nil
Comment thread
AgentWrapper marked this conversation as resolved.
}

func scratchProjectDetails() projectDetails {
return projectDetails{ID: string(scratchProjectID), Name: "Scratch", Kind: string(scratchProjectKind)}
}

func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) {
Expand Down Expand Up @@ -242,6 +262,10 @@ func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDeta
bestLen := -1
ambiguous := false
for _, summary := range list.Projects {
if summary.ID == scratchProjectID {
// The built-in Scratch pseudo-project is not tied to the filesystem.
continue
}
project, err := c.fetchProjectDetails(ctx, summary.ID)
if err != nil {
return projectDetails{}, false, err
Expand Down Expand Up @@ -306,6 +330,11 @@ func resolveSpawnHarness(explicit string, project projectDetails) (string, error
return harness, nil
}
}
if project.ID == scratchProjectID {
// Scratch spawns defer harness resolution to the daemon's configured
// default agent, so a zero-project `ao spawn --prompt ...` works.
return "", nil
}
return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)}
}

Expand Down
94 changes: 82 additions & 12 deletions backend/internal/cli/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,48 @@ func authorizedAgentsJSON(agent string) string {
return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
}

// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
func TestSpawnCommand_MissingProjectContext(t *testing.T) {
// TestSpawnCommand_MissingProjectContextDefaultsToScratch asserts `ao spawn`
// falls back to the built-in Scratch pseudo-project when no project context is
// available, so a freeform spawn works without registration.
func TestSpawnCommand_MissingProjectContextDefaultsToScratch(t *testing.T) {
cfg := setConfigEnv(t)
// Isolate from the outer shell's AO_PROJECT_ID/AO_SESSION_ID so the CLI
// exercises the Scratch fallback path.
t.Setenv("AO_PROJECT_ID", "")
t.Setenv("AO_SESSION_ID", "")
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects":
_, _ = io.WriteString(w, `{"projects":[]}`)
return
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(w, `{"session":{"id":"scratch-1","status":"idle"}}`)
default:
http.NotFound(w, r)
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex", "--name", "worker")
if err == nil {
t.Fatal("expected an error when project context is missing")
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex", "--name", "scratch idea", "--prompt", "explore an idea")
if err != nil {
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
}
if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <agent>") {
t.Fatalf("error = %v, want project add hint", err)
if !strings.Contains(out, "spawned session scratch-1") {
t.Fatalf("output missing spawn: %s", out)
}
if req.ProjectID != "scratch" || req.Harness != "codex" {
t.Fatalf("spawn request = %#v", req)
}
if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) {
if want := []string{"GET /api/v1/projects", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}; !reflect.DeepEqual(requests, want) {
t.Fatalf("requests=%#v want %#v", requests, want)
}
}
Expand Down Expand Up @@ -218,6 +235,8 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {

func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
cfg := setConfigEnv(t)
// Ensure AO_SESSION_ID wins over any inherited AO_PROJECT_ID.
t.Setenv("AO_PROJECT_ID", "")
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -258,6 +277,8 @@ func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {

func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
cfg := setConfigEnv(t)
// Ensure the test reaches AO_SESSION_ID resolution despite inherited context.
t.Setenv("AO_PROJECT_ID", "")
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -286,6 +307,9 @@ func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {

func TestSpawnResolvesProjectFromCWD(t *testing.T) {
cfg := setConfigEnv(t)
// Isolate from inherited project/session context so CWD resolution is tested.
t.Setenv("AO_PROJECT_ID", "")
t.Setenv("AO_SESSION_ID", "")
repo := filepath.Join(t.TempDir(), "repo")
subdir := filepath.Join(repo, "pkg")
if err := os.MkdirAll(subdir, 0o755); err != nil {
Expand Down Expand Up @@ -700,3 +724,49 @@ func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
t.Fatalf("spawn request = %#v", req)
}
}

// TestSpawnScratchDefaultsAgentPreflightSkipped asserts that a scratch spawn with
// no explicit --agent skips the local agent preflight; the daemon resolves the
// default harness server-side.
func TestSpawnScratchDefaultsAgentPreflightSkipped(t *testing.T) {
cfg := setConfigEnv(t)
// Isolate from the outer shell's AO_PROJECT_ID/AO_SESSION_ID so the CLI
// exercises the Scratch fallback path.
t.Setenv("AO_PROJECT_ID", "")
t.Setenv("AO_SESSION_ID", "")
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects":
_, _ = io.WriteString(w, `{"projects":[]}`)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
_, _ = io.WriteString(w, `{"session":{"id":"scratch-2","status":"idle"}}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
writeRunFileFor(t, cfg, srv)

out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--name", "freeform", "--prompt", "no agent specified")
if err != nil {
t.Fatalf("spawn failed: %v", err)
}
if !strings.Contains(out, "spawned session scratch-2") {
t.Fatalf("output missing spawn: %s", out)
}
if req.ProjectID != "scratch" || req.Harness != "" {
t.Fatalf("spawn request = %#v, want scratch project with empty harness", req)
}
for _, r := range requests {
if strings.HasPrefix(r, "POST /api/v1/agents") {
t.Fatalf("agent preflight should be skipped for scratch default, got %s", r)
}
}
}
13 changes: 7 additions & 6 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,13 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit
tracker = t
}
sessionSvc := sessionsvc.NewWithDeps(sessionsvc.Deps{
Manager: mgr,
Store: store,
PRClaimer: store,
SCM: scmProvider,
Tracker: tracker,
Telemetry: telemetry,
Manager: mgr,
Store: store,
PRClaimer: store,
SCM: scmProvider,
Tracker: tracker,
Telemetry: telemetry,
DefaultHarness: domain.AgentHarness(defaultAgent),
// no_signal only makes sense for harnesses whose adapters install
// activity hooks; the deriver registry is the source of truth for that.
SignalCapable: activitydispatch.SupportsHarness,
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/domain/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const (
ProjectKindSingleRepo ProjectKind = "single_repo"
// ProjectKindWorkspace is a parent root-as-repo plus child repositories.
ProjectKindWorkspace ProjectKind = "workspace"
// ProjectKindScratch is the built-in project-less pseudo-project.
ProjectKindScratch ProjectKind = "scratch"
// ScratchProjectID is the reserved project id for the built-in Scratch space.
ScratchProjectID ProjectID = "scratch"
Comment thread
AgentWrapper marked this conversation as resolved.
// RootWorkspaceRepoName is the reserved repo_name used for the parent root repo.
RootWorkspaceRepoName = "__root__"
)
Expand Down
20 changes: 15 additions & 5 deletions backend/internal/httpd/controllers/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ func TestProjectsAPI_ListAddGet(t *testing.T) {

mustJSON(t, body, &list)

if len(list.Projects) != 0 {
if len(list.Projects) != 1 || list.Projects[0].ID != "scratch" {

t.Fatalf("initial project count = %d, want 0", len(list.Projects))
t.Fatalf("initial projects = %#v, want scratch only", list.Projects)

}

Expand Down Expand Up @@ -191,7 +191,17 @@ func TestProjectsAPI_ListAddGet(t *testing.T) {
t.Fatalf("GET projects after add = %d, want 200; body=%s", status, body)
}
mustJSON(t, body, &list)
if len(list.Projects) != 1 || list.Projects[0].Path != repo {
if len(list.Projects) != 2 {
t.Fatalf("project count = %d, want 2 (scratch + registered)", len(list.Projects))
}
var gotAO *projectSummary
for i := range list.Projects {
if list.Projects[i].ID == "ao" {
gotAO = &list.Projects[i]
break
}
}
if gotAO == nil || gotAO.Path != repo {
t.Fatalf("project summary path = %#v, want path %q", list.Projects, repo)
}

Expand Down Expand Up @@ -336,9 +346,9 @@ func TestProjectsAPI_Delete(t *testing.T) {

mustJSON(t, body, &list)

if len(list.Projects) != 0 {
if len(list.Projects) != 1 || list.Projects[0].ID != "scratch" {

t.Fatalf("active projects after archive = %d, want 0", len(list.Projects))
t.Fatalf("active projects after archive = %#v, want scratch only", list.Projects)

}

Expand Down
5 changes: 3 additions & 2 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
// An empty projectId now means the built-in Scratch pseudo-project, so a
// zero-setup new session can skip the project picker.
if in.ProjectID == "" {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_ID_REQUIRED", "projectId is required", nil)
return
in.ProjectID = domain.ScratchProjectID
}
if len(in.Prompt) > maxPromptLen {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil)
Expand Down
Loading
Loading