diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index b7422bc703..bb22d856ec 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -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 } @@ -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 @@ -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 } diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index 9854d56b73..0fc86baedd 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -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 @@ -80,11 +87,18 @@ 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 { @@ -92,7 +106,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } 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 } @@ -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 --worker-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 +} + +func scratchProjectDetails() projectDetails { + return projectDetails{ID: string(scratchProjectID), Name: "Scratch", Kind: string(scratchProjectKind)} } func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) { @@ -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 @@ -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 `", project.ID)} } diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index f48c5a99b6..d972aa12bf 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -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 --worker-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) } } @@ -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) { @@ -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) @@ -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 { @@ -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) + } + } +} diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5fddc83a98..cd8828f05c 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -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, diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index fd0fc25e2c..40b9925935 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -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" // RootWorkspaceRepoName is the reserved repo_name used for the parent root repo. RootWorkspaceRepoName = "__root__" ) diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index 7da2065c15..3c56fe1a19 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -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) } @@ -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) } @@ -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) } diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 5bdf597f27..bd57f3e6a0 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -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) diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index c2c94b3ccb..e0e9d85720 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -100,14 +100,25 @@ func NewWithDeps(d Deps) *Service { return s } -// List returns every active registered project. +// List returns every active registered project, with the built-in Scratch +// pseudo-project pinned at the front. func (m *Service) List(ctx context.Context) ([]Summary, error) { projects, err := m.store.ListProjects(ctx) if err != nil { return nil, apierr.Internal("PROJECTS_LIST_FAILED", "Failed to load projects") } - out := make([]Summary, 0, len(projects)) + out := make([]Summary, 0, len(projects)+1) + out = append(out, Summary{ + ID: domain.ScratchProjectID, + Name: "Scratch", + Kind: domain.ProjectKindScratch, + }) for _, row := range projects { + if row.ID == string(domain.ScratchProjectID) { + // The seeded scratch row exists to satisfy foreign keys; the pinned + // synthetic summary above is its only List representation. + continue + } out = append(out, Summary{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -133,6 +144,12 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } p := m.projectFromRow(row) + if id == domain.ScratchProjectID { + // The seeded row stores kind 'single_repo' (the projects.kind CHECK + // predates scratch); present the pseudo-project as scratch. + p.Kind = domain.ProjectKindScratch + p.Name = "Scratch" + } if row.Kind.WithDefault() == domain.ProjectKindWorkspace { repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) if err != nil { @@ -161,6 +178,9 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { if err := validateProjectID(id); err != nil { return Project{}, err } + if id == domain.ScratchProjectID { + return Project{}, apierr.Invalid("PROJECT_SCRATCH_RESERVED", "The id 'scratch' is reserved for the built-in Scratch project", nil) + } m.addMu.Lock() defer m.addMu.Unlock() @@ -476,7 +496,16 @@ func (m *Service) activeProjectCount(ctx context.Context) (int, error) { if err != nil { return 0, err } - return len(projects), nil + count := 0 + for _, row := range projects { + // The seeded Scratch pseudo-project is not a user-registered project; + // excluding it keeps first-project onboarding telemetry accurate. + if row.ID == string(domain.ScratchProjectID) { + continue + } + count++ + } + return count, nil } func (m *Service) emitProjectAdded(row domain.ProjectRecord, firstProject bool) { @@ -578,6 +607,9 @@ func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult if err := validateProjectID(id); err != nil { return RemoveResult{}, err } + if id == domain.ScratchProjectID { + return RemoveResult{}, apierr.Invalid("PROJECT_SCRATCH_RESERVED", "The built-in Scratch project cannot be removed", nil) + } row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project") diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index b28898f90a..c1105aad1f 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -125,8 +125,8 @@ func TestManager_AddListGetRemove(t *testing.T) { m := newManager(t) repo := gitRepo(t) - if got, err := m.List(ctx); err != nil || len(got) != 0 { - t.Fatalf("List() = %v, %v; want empty", got, err) + if got, err := m.List(ctx); err != nil || len(got) != 1 || got[0].ID != domain.ScratchProjectID { + t.Fatalf("List() = %v, %v; want [scratch]", got, err) } proj, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Name: ptr("Agent Orchestrator")}) @@ -138,8 +138,8 @@ func TestManager_AddListGetRemove(t *testing.T) { } list, err := m.List(ctx) - if err != nil || len(list) != 1 || list[0].ID != "ao" { - t.Fatalf("List() = %v, %v; want [ao]", list, err) + if err != nil || len(list) != 2 || list[0].ID != domain.ScratchProjectID || list[1].ID != "ao" { + t.Fatalf("List() = %v, %v; want [scratch ao]", list, err) } res, err := m.Get(ctx, "ao") @@ -157,8 +157,8 @@ func TestManager_AddListGetRemove(t *testing.T) { if rm.ProjectID != "ao" || rm.RemovedStorageDir { t.Fatalf("Remove = %#v", rm) } - if list, _ := m.List(ctx); len(list) != 0 { - t.Fatalf("active list after remove = %d, want 0", len(list)) + if list, _ := m.List(ctx); len(list) != 1 || list[0].ID != domain.ScratchProjectID { + t.Fatalf("active list after remove = %v, want [scratch]", list) } _, err = m.Get(ctx, "ao") wantCode(t, err, "PROJECT_NOT_FOUND") @@ -288,11 +288,11 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } list, err := m.List(ctx) - if err != nil || len(list) != 1 { + if err != nil || len(list) != 2 || list[0].ID != domain.ScratchProjectID { t.Fatalf("List = %v, %v", list, err) } - if list[0].SessionPrefix != "ao" { - t.Fatalf("default session prefix = %q, want derived 'ao'", list[0].SessionPrefix) + if list[1].SessionPrefix != "ao" { + t.Fatalf("default session prefix = %q, want derived 'ao'", list[1].SessionPrefix) } } @@ -467,11 +467,11 @@ func TestManager_ListIncludesOnlySummarySafeProjectConfig(t *testing.T) { if err != nil { t.Fatalf("List: %v", err) } - if len(list) != 1 { - t.Fatalf("List len = %d, want 1", len(list)) + if len(list) != 2 || list[0].ID != domain.ScratchProjectID { + t.Fatalf("List len = %d, want 2 with scratch first", len(list)) } - if list[0].OrchestratorAgent != domain.HarnessCodex { - t.Fatalf("summary orchestrator agent = %q, want codex", list[0].OrchestratorAgent) + if list[1].OrchestratorAgent != domain.HarnessCodex { + t.Fatalf("summary orchestrator agent = %q, want codex", list[1].OrchestratorAgent) } } @@ -1159,3 +1159,67 @@ func TestManager_AddWorkspaceRejectsBareParent(t *testing.T) { _, err := m.Add(ctx, project.AddInput{Path: bareParent, ProjectID: ptr("bare"), AsWorkspace: true}) wantCode(t, err, "WORKSPACE_PARENT_BARE") } + +// TestManager_ListPinsScratchFirst asserts that the built-in Scratch pseudo-project +// is always present and pinned to the front of the project list. +func TestManager_ListPinsScratchFirst(t *testing.T) { + ctx := context.Background() + m := newManager(t) + + list, err := m.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("List = %v, want 1 entry", list) + } + if list[0].ID != domain.ScratchProjectID || list[0].Name != "Scratch" || list[0].Kind != domain.ProjectKindScratch { + t.Fatalf("scratch summary = %+v", list[0]) + } +} + +// TestManager_RemoveRejectsScratch asserts the built-in Scratch pseudo-project +// cannot be archived: its seeded row backs the session foreign keys. +func TestManager_RemoveRejectsScratch(t *testing.T) { + ctx := context.Background() + m := newManager(t) + + _, err := m.Remove(ctx, domain.ScratchProjectID) + wantCode(t, err, "PROJECT_SCRATCH_RESERVED") +} + +// TestManager_AddRejectsScratchID asserts the reserved scratch id cannot be +// claimed by a user-registered project, whether given explicitly or derived +// from a repo named "scratch". +func TestManager_AddRejectsScratchID(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + repo := gitRepoWithCommit(t, filepath.Join(t.TempDir(), "scratch")) + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr(string(domain.ScratchProjectID))}); err == nil { + t.Fatal("Add with explicit scratch id succeeded") + } else { + wantCode(t, err, "PROJECT_SCRATCH_RESERVED") + } + if _, err := m.Add(ctx, project.AddInput{Path: repo}); err == nil { + t.Fatal("Add of repo named scratch succeeded") + } else { + wantCode(t, err, "PROJECT_SCRATCH_RESERVED") + } +} + +// TestManager_GetScratchPresentsPseudoProject asserts the seeded scratch row is +// presented with the scratch kind even though the stored kind is single_repo. +func TestManager_GetScratchPresentsPseudoProject(t *testing.T) { + ctx := context.Background() + m := newManager(t) + + got, err := m.Get(ctx, domain.ScratchProjectID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil || got.Project.Kind != domain.ProjectKindScratch || got.Project.Name != "Scratch" { + t.Fatalf("scratch get = %+v", got.Project) + } +} diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 92df9e1bec..10b189df1f 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -108,6 +108,7 @@ type Service struct { tracker ports.Tracker clock func() time.Time telemetry ports.EventSink + defaultHarness domain.AgentHarness orchestratorLocksMu sync.Mutex orchestratorLocks map[domain.ProjectID]*sync.Mutex // signalCapable reports whether a harness has a hook pipeline that can @@ -133,6 +134,9 @@ type Deps struct { Tracker ports.Tracker Clock func() time.Time Telemetry ports.EventSink + // DefaultHarness is the daemon's configured default agent (AO_AGENT). + // Scratch sessions fall back to this harness when the spawn request names none. + DefaultHarness domain.AgentHarness // SignalCapable gates the no_signal status downgrade per harness; daemon // wiring passes activitydispatch.SupportsHarness. Left nil, no session is // ever downgraded to no_signal. @@ -141,7 +145,7 @@ type Deps struct { // NewWithDeps wires a session service with optional PR-claim dependencies. func NewWithDeps(d Deps) *Service { - s := &Service{manager: d.Manager, store: d.Store, prClaimer: d.PRClaimer, scm: d.SCM, tracker: d.Tracker, clock: d.Clock, signalCapable: d.SignalCapable, telemetry: d.Telemetry} + s := &Service{manager: d.Manager, store: d.Store, prClaimer: d.PRClaimer, scm: d.SCM, tracker: d.Tracker, clock: d.Clock, signalCapable: d.SignalCapable, telemetry: d.Telemetry, defaultHarness: d.DefaultHarness} if s.prClaimer == nil { if w, ok := d.Store.(ports.PRClaimer); ok { s.prClaimer = w @@ -155,10 +159,16 @@ func NewWithDeps(d Deps) *Service { // Spawn creates a session and returns the API-facing read model. func (s *Service) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + if cfg.ProjectID == "" { + cfg.ProjectID = domain.ScratchProjectID + } project, err := s.requireProject(ctx, cfg.ProjectID) if err != nil { return domain.Session{}, err } + if project.Kind.WithDefault() == domain.ProjectKindScratch && cfg.Harness == "" && s.defaultHarness != "" { + cfg.Harness = s.defaultHarness + } start := s.now() firstSession, err := s.isFirstSession(ctx) if err != nil { @@ -184,6 +194,9 @@ func (s *Service) requireProject(ctx context.Context, id domain.ProjectID) (doma if id == "" { return domain.ProjectRecord{}, apierr.Invalid("PROJECT_ID_REQUIRED", "projectId is required", nil) } + if id == domain.ScratchProjectID { + return domain.ProjectRecord{ID: string(id), DisplayName: "Scratch", Kind: domain.ProjectKindScratch}, nil + } if s.store == nil { return domain.ProjectRecord{ID: string(id)}, nil } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index e4cb350238..f0041014c5 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -1290,3 +1290,74 @@ func containsString(values []string, want string) bool { } return false } + +// TestSpawnScratchDefaultsProjectAndHarness covers the zero-project spawn path: +// an empty projectId is treated as Scratch and an empty harness falls back to the +// daemon's configured default harness. +func TestSpawnScratchDefaultsProjectAndHarness(t *testing.T) { + st := newFakeStore() + fc := &fakeCommander{} + svc := NewWithDeps(Deps{ + Manager: fc, + Store: st, + DefaultHarness: domain.HarnessCodex, + }) + + _, err := svc.Spawn(context.Background(), ports.SpawnConfig{Prompt: "explore an idea"}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if !fc.spawned { + t.Fatal("manager.Spawn was not invoked") + } + if fc.spawnedCfg.ProjectID != domain.ScratchProjectID { + t.Fatalf("projectId = %q, want scratch", fc.spawnedCfg.ProjectID) + } + if fc.spawnedCfg.Harness != domain.HarnessCodex { + t.Fatalf("harness = %q, want codex default", fc.spawnedCfg.Harness) + } +} + +// TestSpawnScratchExplicitHarnessWins asserts that an explicit --harness overrides +// the default for scratch spawns. +func TestSpawnScratchExplicitHarnessWins(t *testing.T) { + st := newFakeStore() + fc := &fakeCommander{} + svc := NewWithDeps(Deps{ + Manager: fc, + Store: st, + DefaultHarness: domain.HarnessClaudeCode, + }) + + _, err := svc.Spawn(context.Background(), ports.SpawnConfig{Harness: domain.HarnessCodex, Prompt: "explore"}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if fc.spawnedCfg.Harness != domain.HarnessCodex { + t.Fatalf("harness = %q, want explicit codex", fc.spawnedCfg.Harness) + } +} + +// TestSpawnScratchNoDefaultHarnessPassesThroughEmptyHarness covers the case where +// the daemon has no default harness configured and the scratch spawn request +// names none. The service leaves the harness empty and lets the manager validate +// it downstream. +func TestSpawnScratchNoDefaultHarnessPassesThroughEmptyHarness(t *testing.T) { + st := newFakeStore() + fc := &fakeCommander{} + svc := NewWithDeps(Deps{Manager: fc, Store: st}) + + _, err := svc.Spawn(context.Background(), ports.SpawnConfig{Prompt: "explore"}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if !fc.spawned { + t.Fatal("manager.Spawn was not invoked") + } + if fc.spawnedCfg.ProjectID != domain.ScratchProjectID { + t.Fatalf("projectId = %q, want scratch", fc.spawnedCfg.ProjectID) + } + if fc.spawnedCfg.Harness != "" { + t.Fatalf("harness = %q, want empty", fc.spawnedCfg.Harness) + } +} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 71280b6c35..079253ca90 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -408,6 +408,9 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // unregistered yet still have live sessions, and an empty config simply means // every field falls back to its default. func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (domain.ProjectRecord, error) { + if projectID == domain.ScratchProjectID { + return domain.ProjectRecord{ID: string(projectID), DisplayName: "Scratch", Kind: domain.ProjectKindScratch}, nil + } row, ok, err := m.store.GetProject(ctx, string(projectID)) if err != nil { return domain.ProjectRecord{}, fmt.Errorf("load project: %w", err) @@ -418,7 +421,90 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) ( return row, nil } +// scratchRepoPath returns the throwaway bare-repo path backing a scratch +// session. The location is deterministic, so workspace operations after spawn +// (kill, shutdown save, restore) derive it instead of persisting it. +func (m *Manager) scratchRepoPath(id domain.SessionID) string { + if strings.TrimSpace(m.dataDir) == "" { + return "" + } + return filepath.Join(m.dataDir, "scratch", string(id)) +} + +// ensureScratchRepo creates a disposable bare git repository for a scratch +// session under the data dir. The existing gitworktree adapter materialises the +// session worktree from this repo, so no workspace machinery changes are needed. +func (m *Manager) ensureScratchRepo(ctx context.Context, id domain.SessionID) (string, error) { + repoPath := m.scratchRepoPath(id) + if repoPath == "" { + return "", fmt.Errorf("scratch session %s: data dir is not configured", id) + } + if err := os.MkdirAll(repoPath, 0o700); err != nil { + return "", fmt.Errorf("scratch session %s: create repo dir: %w", id, err) + } + // A bare repo keeps the backing repository and the checked-out worktree + // separate, which matches how registered projects are used. + if _, err := aoprocess.CommandContext(ctx, "git", "init", "--bare", repoPath).CombinedOutput(); err != nil { + return "", fmt.Errorf("scratch session %s: git init: %w", id, err) + } + if err := initEmptyCommit(ctx, repoPath); err != nil { + return "", fmt.Errorf("scratch session %s: initial commit: %w", id, err) + } + return repoPath, nil +} + +// initEmptyCommit creates an initial empty commit on the default branch in a +// bare repository so the gitworktree adapter has a base ref to create worktrees. +func initEmptyCommit(ctx context.Context, repoPath string) error { + // Resolve the empty-tree oid through mktree so the repo's object format + // (sha1 or sha256, via init.defaultObjectFormat) is honoured; the sha1 oid + // must not be hard-coded. + mktreeCmd := aoprocess.CommandContext(ctx, "git", "-C", repoPath, "mktree") + mktreeCmd.Stdin = strings.NewReader("") + treeOut, err := mktreeCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mktree: %w: %s", err, strings.TrimSpace(string(treeOut))) + } + emptyTree := strings.TrimSpace(string(treeOut)) + commitCmd := aoprocess.CommandContext(ctx, "git", "-C", repoPath, "commit-tree", "-m", "initial commit", emptyTree) + commitCmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Agent Orchestrator", + "GIT_AUTHOR_EMAIL=ao@example.com", + "GIT_COMMITTER_NAME=Agent Orchestrator", + "GIT_COMMITTER_EMAIL=ao@example.com", + ) + out, err := commitCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("commit-tree: %w: %s", err, strings.TrimSpace(string(out))) + } + commitSHA := strings.TrimSpace(string(out)) + branch := "refs/heads/" + domain.DefaultBranchName + if _, err := aoprocess.CommandContext(ctx, "git", "-C", repoPath, "update-ref", branch, commitSHA).CombinedOutput(); err != nil { + return fmt.Errorf("update-ref %s: %w", branch, err) + } + if _, err := aoprocess.CommandContext(ctx, "git", "-C", repoPath, "symbolic-ref", "HEAD", branch).CombinedOutput(); err != nil { + return fmt.Errorf("set HEAD: %w", err) + } + return nil +} + func (m *Manager) createSessionWorkspace(ctx context.Context, project domain.ProjectRecord, cfg ports.SpawnConfig, id domain.SessionID, branch string) (ports.WorkspaceInfo, *ports.WorkspaceProjectInfo, error) { + if project.Kind.WithDefault() == domain.ProjectKindScratch { + repoPath, err := m.ensureScratchRepo(ctx, id) + if err != nil { + return ports.WorkspaceInfo{}, nil, err + } + ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ + ProjectID: cfg.ProjectID, + SessionID: id, + Kind: cfg.Kind, + SessionPrefix: sessionPrefix(project), + Branch: branch, + BaseBranch: project.Config.WithDefaults().DefaultBranch, + RepoPath: repoPath, + }) + return ws, nil, err + } if project.Kind.WithDefault() != domain.ProjectKindWorkspace { ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ ProjectID: cfg.ProjectID, @@ -485,6 +571,9 @@ func (m *Manager) destroySpawnWorkspace(ctx context.Context, ws ports.WorkspaceI } err := m.workspace.Destroy(ctx, ws) _ = m.store.DeleteSessionWorktrees(ctx, ws.SessionID) + if err == nil { + m.removeScratchRepo(ctx, ws.ProjectID, ws.SessionID) + } return err == nil } @@ -637,7 +726,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return false, nil // already gone: benign race } handle := runtimeHandle(rec.Metadata) - ws := workspaceInfo(rec) + ws := m.workspaceInfo(rec) var workspaceProjectRows []ports.WorkspaceRepoInfo workspaceProject := false @@ -686,6 +775,7 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { } freed = true m.cleanupAgentWorkspace(ctx, rec, ws.Path) + m.removeScratchRepo(ctx, rec.ProjectID, id) } // Clear the restore marker so the next boot's RestoreAll cannot resurrect a // killed session (#2319). For workspace projects this must happen after @@ -737,7 +827,7 @@ func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) return m.retireWorkspaceProjectForReplacement(ctx, rec, rows) } - ws := workspaceInfo(rec) + ws := m.workspaceInfo(rec) staleWorkspace := false if _, err := m.workspace.StashUncommitted(ctx, ws); err != nil { if !errors.Is(err, ports.ErrWorkspaceStale) { @@ -967,7 +1057,7 @@ func (m *Manager) saveAndTeardownOne(ctx context.Context, rec domain.SessionReco } // 1. Capture uncommitted work (ref may be "" for clean worktrees). - ws := workspaceInfo(rec) + ws := m.workspaceInfo(rec) ref, err := m.workspace.StashUncommitted(ctx, ws) if err != nil { return fmt.Errorf("save %s: stash: %w", rec.ID, err) @@ -1168,13 +1258,7 @@ func (m *Manager) RestoreAll(ctx context.Context) error { ws = workspaceInfoFromRepoInfo(root) } else { var restoreErr error - ws, restoreErr = m.workspace.Restore(ctx, ports.WorkspaceConfig{ - ProjectID: rec.ProjectID, - SessionID: rec.ID, - Kind: rec.Kind, - SessionPrefix: sessionPrefix(project), - Branch: rec.Metadata.Branch, - }) + ws, restoreErr = m.workspace.Restore(ctx, m.restoreWorkspaceConfig(project, rec)) if restoreErr != nil { m.logger.Error("restore-all: workspace restore failed", "sessionID", rec.ID, "error", restoreErr) continue @@ -1267,15 +1351,25 @@ func (m *Manager) markSessionWorktreesActive(ctx context.Context, rows []domain. return nil } +// restoreWorkspaceConfig builds the workspace restore config for a session, +// deriving the throwaway repo path for scratch sessions (see scratchRepoPath). +func (m *Manager) restoreWorkspaceConfig(project domain.ProjectRecord, rec domain.SessionRecord) ports.WorkspaceConfig { + cfg := ports.WorkspaceConfig{ + ProjectID: rec.ProjectID, + SessionID: rec.ID, + Kind: rec.Kind, + SessionPrefix: sessionPrefix(project), + Branch: rec.Metadata.Branch, + } + if rec.ProjectID == domain.ScratchProjectID { + cfg.RepoPath = m.scratchRepoPath(rec.ID) + } + return cfg +} + func (m *Manager) restoreSessionWorkspace(ctx context.Context, project domain.ProjectRecord, rec domain.SessionRecord) (ports.WorkspaceInfo, error) { if project.Kind.WithDefault() != domain.ProjectKindWorkspace { - return m.workspace.Restore(ctx, ports.WorkspaceConfig{ - ProjectID: rec.ProjectID, - SessionID: rec.ID, - Kind: rec.Kind, - SessionPrefix: sessionPrefix(project), - Branch: rec.Metadata.Branch, - }) + return m.workspace.Restore(ctx, m.restoreWorkspaceConfig(project, rec)) } rows, err := m.workspaceProjectRestoreRows(ctx, project, rec) if err != nil { @@ -1756,7 +1850,7 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (Cleanu if !rec.IsTerminated { continue } - ws := workspaceInfo(rec) + ws := m.workspaceInfo(rec) if ws.Path == "" { m.cleanupSystemPromptDir(rec.ID) continue @@ -1787,6 +1881,7 @@ func (m *Manager) Cleanup(ctx context.Context, project domain.ProjectID) (Cleanu continue } else { m.cleanupAgentWorkspace(ctx, rec, ws.Path) + m.removeScratchRepo(ctx, rec.ProjectID, rec.ID) } m.cleanupSystemPromptDir(rec.ID) result.Cleaned = append(result.Cleaned, rec.ID) @@ -2548,6 +2643,33 @@ func workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo { } } +// workspaceInfo builds the adapter-facing workspace handle for a session +// record, deriving the throwaway repo path for scratch sessions so post-spawn +// operations (kill, shutdown save, cleanup) resolve the same repo spawn used. +func (m *Manager) workspaceInfo(rec domain.SessionRecord) ports.WorkspaceInfo { + info := workspaceInfo(rec) + if rec.ProjectID == domain.ScratchProjectID { + info.RepoPath = m.scratchRepoPath(rec.ID) + } + return info +} + +// removeScratchRepo deletes the throwaway bare repo backing a scratch session +// after its worktree has been destroyed. Best-effort: leftover disk is a leak, +// not a correctness problem, so a failure only logs. +func (m *Manager) removeScratchRepo(ctx context.Context, projectID domain.ProjectID, sessionID domain.SessionID) { + if projectID != domain.ScratchProjectID { + return + } + repoPath := m.scratchRepoPath(sessionID) + if repoPath == "" { + return + } + if err := os.RemoveAll(repoPath); err != nil { + m.logger.WarnContext(ctx, "scratch repo removal failed", "sessionID", sessionID, "path", repoPath, "error", err) + } +} + func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo { return ports.WorkspaceInfo{ Path: info.Path, diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 51e2d93f7c..34cbecec7c 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" "reflect" "runtime" @@ -14,6 +15,7 @@ import ( "testing" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -4791,3 +4793,190 @@ func (m *flipOnNudgeMessenger) Send(_ context.Context, _ domain.SessionID, msg s } return nil } + +// TestSpawn_ScratchCreatesBareRepoAndWorktree exercises the project-less spawn +// path end-to-end with a real gitworktree adapter. It verifies that a throwaway +// bare repo is created under the data dir and that the session worktree is +// materialised from it without changing the workspace adapter. +func TestSpawn_ScratchCreatesBareRepoAndWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dataDir := t.TempDir() + ws, err := gitworktree.New(gitworktree.Options{ + ManagedRoot: filepath.Join(dataDir, "worktrees"), + RepoResolver: gitworktree.StaticRepoResolver{}, + }) + if err != nil { + t.Fatalf("gitworktree.New: %v", err) + } + st := newFakeStore() + lookPath := func(string) (string, error) { return "/bin/true", nil } + agent := &recordingAgent{} + m := New(Deps{ + Runtime: &fakeRuntime{}, + Agents: singleAgent{agent: agent}, + Workspace: ws, + Store: st, + Messenger: &fakeMessenger{}, + Lifecycle: &fakeLCM{store: st}, + DataDir: dataDir, + LookPath: lookPath, + }) + + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: domain.ScratchProjectID, Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "explore"}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + + repoPath := filepath.Join(dataDir, "scratch", string(s.ID)) + if info, err := os.Stat(filepath.Join(repoPath, "HEAD")); err != nil || info.IsDir() { + t.Fatalf("scratch bare repo missing HEAD at %s: %v", repoPath, err) + } + if out, err := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", "HEAD").CombinedOutput(); err != nil { + t.Fatalf("scratch repo has no HEAD: %v (%s)", err, out) + } + if agent.lastLaunch.WorkspacePath == "" { + t.Fatal("workspace path was not set") + } + if !strings.Contains(agent.lastLaunch.SystemPrompt, "Scratch") && !strings.Contains(agent.lastLaunch.SystemPrompt, "scratch") { + t.Fatalf("system prompt should reference scratch context; got:\n%s", agent.lastLaunch.SystemPrompt) + } +} + +// recordingInfoWorkspace wraps fakeWorkspace and captures the WorkspaceInfo / +// WorkspaceConfig values the manager passes in, so tests can assert the derived +// scratch repo path reaches the adapter on post-spawn paths. +type recordingInfoWorkspace struct { + fakeWorkspace + stashInfo ports.WorkspaceInfo + restoreCfg ports.WorkspaceConfig +} + +func (w *recordingInfoWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { + w.stashInfo = info + return "", nil +} + +func (w *recordingInfoWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + w.restoreCfg = cfg + return w.fakeWorkspace.Restore(ctx, cfg) +} + +// TestKill_ScratchDestroysWorktreeAndRemovesRepo covers the post-spawn teardown +// path for scratch sessions with a real gitworktree adapter: Kill must resolve +// the derived scratch repo (not the project repo resolver) and remove the +// throwaway bare repo so it does not leak under the data dir. +func TestKill_ScratchDestroysWorktreeAndRemovesRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dataDir := t.TempDir() + ws, err := gitworktree.New(gitworktree.Options{ + ManagedRoot: filepath.Join(dataDir, "worktrees"), + RepoResolver: gitworktree.StaticRepoResolver{}, + }) + if err != nil { + t.Fatalf("gitworktree.New: %v", err) + } + st := newFakeStore() + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{ + Runtime: &fakeRuntime{}, + Agents: singleAgent{agent: &recordingAgent{}}, + Workspace: ws, + Store: st, + Messenger: &fakeMessenger{}, + Lifecycle: &fakeLCM{store: st}, + DataDir: dataDir, + LookPath: lookPath, + }) + + s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: domain.ScratchProjectID, Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "explore"}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + repoPath := filepath.Join(dataDir, "scratch", string(s.ID)) + worktreePath := st.sessions[s.ID].Metadata.WorkspacePath + + freed, err := m.Kill(ctx, s.ID) + if err != nil { + t.Fatalf("Kill: %v", err) + } + if !freed { + t.Fatal("Kill did not free the scratch workspace") + } + if !st.sessions[s.ID].IsTerminated { + t.Fatal("scratch session not marked terminated after Kill") + } + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("scratch worktree still present at %s", worktreePath) + } + if _, err := os.Stat(repoPath); !os.IsNotExist(err) { + t.Fatalf("throwaway scratch repo leaked at %s", repoPath) + } +} + +// TestSaveAndTeardownAll_ScratchDerivesRepoPath asserts the shutdown-save path +// hands the adapter the derived scratch repo path instead of letting it fall +// back to the project repo resolver (which has no entry for scratch). +func TestSaveAndTeardownAll_ScratchDerivesRepoPath(t *testing.T) { + dataDir := t.TempDir() + st := newFakeStore() + rt := &fakeRuntime{} + ws := &recordingInfoWorkspace{} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{ + Runtime: rt, + Agents: fakeAgents{}, + Workspace: ws, + Store: st, + Messenger: &fakeMessenger{}, + Lifecycle: &fakeLCM{store: st}, + DataDir: dataDir, + LookPath: lookPath, + }) + st.sessions["scratch-1"] = domain.SessionRecord{ + ID: "scratch-1", + ProjectID: domain.ScratchProjectID, + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/scratch-1", Branch: "ao/scratch-1/root", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + + if err := m.SaveAndTeardownAll(ctx); err != nil { + t.Fatalf("SaveAndTeardownAll: %v", err) + } + want := filepath.Join(dataDir, "scratch", "scratch-1") + if ws.stashInfo.RepoPath != want { + t.Fatalf("StashUncommitted RepoPath = %q, want %q", ws.stashInfo.RepoPath, want) + } +} + +// TestRestoreWorkspaceConfig_ScratchDerivesRepoPath pins the restore config for +// scratch sessions: the repo path is derived from the data dir, and non-scratch +// sessions keep relying on the project repo resolver. +func TestRestoreWorkspaceConfig_ScratchDerivesRepoPath(t *testing.T) { + dataDir := t.TempDir() + m := New(Deps{DataDir: dataDir}) + + scratch := m.restoreWorkspaceConfig(domain.ProjectRecord{}, domain.SessionRecord{ + ID: "scratch-1", + ProjectID: domain.ScratchProjectID, + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{Branch: "ao/scratch-1/root"}, + }) + if want := filepath.Join(dataDir, "scratch", "scratch-1"); scratch.RepoPath != want { + t.Fatalf("scratch RepoPath = %q, want %q", scratch.RepoPath, want) + } + + registered := m.restoreWorkspaceConfig(domain.ProjectRecord{}, domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Metadata: domain.SessionMetadata{Branch: "ao/mer-1/root"}, + }) + if registered.RepoPath != "" { + t.Fatalf("registered RepoPath = %q, want empty (resolver-backed)", registered.RepoPath) + } +} diff --git a/backend/internal/storage/sqlite/migrations/0025_scratch_project.sql b/backend/internal/storage/sqlite/migrations/0025_scratch_project.sql new file mode 100644 index 0000000000..5020ab4761 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0025_scratch_project.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +-- Seed the built-in Scratch pseudo-project so project-less "scratch" sessions +-- satisfy the sessions.project_id foreign key (and every other project-scoped +-- FK). kind stays 'single_repo': the projects.kind CHECK predates the scratch +-- kind and cannot be altered without rebuilding the table, so the service +-- layer always presents id 'scratch' as kind 'scratch' instead. +INSERT OR IGNORE INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind) +VALUES ('scratch', '', '', 'Scratch', datetime('now'), NULL, NULL, 'single_repo'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM projects WHERE id = 'scratch' + AND NOT EXISTS (SELECT 1 FROM sessions WHERE project_id = 'scratch'); +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 0e2ee65837..0ba99bd067 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -45,6 +45,25 @@ func sampleRecord(project string) domain.SessionRecord { } } +// TestScratchSeedAllowsScratchSession locks the migration-seeded scratch project +// row: a scratch session insert must satisfy the sessions.project_id foreign key +// without any explicit project registration. +func TestScratchSeedAllowsScratchSession(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + row, ok, err := s.GetProject(ctx, "scratch") + if err != nil || !ok { + t.Fatalf("scratch seed: ok=%v err=%v", ok, err) + } + if row.ArchivedAt != (time.Time{}) { + t.Fatalf("scratch seed archived_at = %v, want active", row.ArchivedAt) + } + if _, err := s.CreateSession(ctx, sampleRecord("scratch")); err != nil { + t.Fatalf("create scratch session: %v", err) + } +} + func TestProjectCRUDAndArchive(t *testing.T) { s := newTestStore(t) ctx := context.Background() @@ -57,15 +76,16 @@ func TestProjectCRUDAndArchive(t *testing.T) { if got.ID != "mer" || got.Path != "/tmp/mer" { t.Fatalf("project = %+v", got) } - if list, _ := s.ListProjects(ctx); len(list) != 1 { - t.Fatalf("active list = %d, want 1", len(list)) + // The seeded scratch row is always active, so it rides along in every list. + if list, _ := s.ListProjects(ctx); len(list) != 2 { + t.Fatalf("active list = %d, want 2 (scratch seed + mer)", len(list)) } // archive hides from the active list but still resolves by id. if ok, err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil || !ok { t.Fatalf("archive: ok=%v err=%v", ok, err) } - if list, _ := s.ListProjects(ctx); len(list) != 0 { - t.Fatalf("after archive, active list = %d, want 0", len(list)) + if list, _ := s.ListProjects(ctx); len(list) != 1 || list[0].ID != "scratch" { + t.Fatalf("after archive, active list = %+v, want scratch seed only", list) } if _, ok, _ := s.GetProject(ctx, "mer"); !ok { t.Fatal("archived project must still resolve by id")