From 5244015802d3c52076a64839e101c2b66f9858d6 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Wed, 10 Jun 2026 16:10:14 +0530 Subject: [PATCH] feat: add workspace project registration foundation (#165) * feat: add workspace project registration schema * fix: satisfy workspace registration lint * fix: harden workspace registration edge paths - Reject linked-worktree and bare parents via validateWorkspaceParent before any mutation - Roll back git init/.gitignore on failure in initWorkspaceParent so retries are clean - Reject child repos named __root__ (reserved PK in session_worktrees) - Serialise Service.Add with addMu to eliminate TOCTOU on concurrent same-path calls - Fix ensureWorkspaceGitignore permission 0o600 -> 0o644 - Improve guardNoGitlinks suggestedFix with actionable git rm --cached guidance - Remove dead CASE/__root__ ordering from ListWorkspaceRepos SQL (regenerated via sqlc) - Resolve RepoOriginURL once per code path in Add (workspace vs single-repo) - Add 7 tests covering the new edge paths --- backend/internal/cli/dto_drift_e2e_test.go | 4 + backend/internal/cli/project.go | 69 +++- backend/internal/domain/project.go | 44 +++ backend/internal/httpd/apispec/openapi.yaml | 28 ++ .../internal/httpd/apispec/specgen/build.go | 1 + backend/internal/service/project/dto.go | 9 +- backend/internal/service/project/service.go | 58 ++- .../internal/service/project/service_test.go | 373 ++++++++++++++++++ backend/internal/service/project/store.go | 2 + backend/internal/service/project/types.go | 43 +- .../service/project/workspace_registration.go | 367 +++++++++++++++++ backend/internal/storage/sqlite/gen/models.go | 19 + .../storage/sqlite/gen/projects.sql.go | 18 +- .../storage/sqlite/gen/workspace.sql.go | 193 +++++++++ .../migrations/0009_workspace_projects.sql | 37 ++ .../storage/sqlite/queries/projects.sql | 13 +- .../storage/sqlite/queries/workspace.sql | 40 ++ .../storage/sqlite/store/project_store.go | 59 ++- .../sqlite/store/session_worktree_store.go | 70 ++++ .../storage/sqlite/store/store_test.go | 46 +++ backend/sqlc.yaml | 8 + frontend/src/api/schema.ts | 10 + 22 files changed, 1450 insertions(+), 61 deletions(-) create mode 100644 backend/internal/service/project/workspace_registration.go create mode 100644 backend/internal/storage/sqlite/gen/workspace.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql create mode 100644 backend/internal/storage/sqlite/queries/workspace.sql create mode 100644 backend/internal/storage/sqlite/store/session_worktree_store.go diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 216e7c521..3957ea30f 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -222,6 +222,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { "--path", "/repo/mer", "--id", "demo", "--name", "Demo", + "--as-workspace", }) if err := root.Execute(); err != nil { t.Fatalf("project add execute: %v\noutput: %s", err, out.String()) @@ -237,6 +238,9 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { if got.Name == nil || *got.Name != "Demo" { t.Errorf("Name = %v, want %q", got.Name, "Demo") } + if !got.AsWorkspace { + t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)") + } if !bytes.Contains(out.Bytes(), []byte("registered project")) { t.Errorf("output missing %q; got: %s", "registered project", out.String()) } diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index d9a4eb6f3..0de4b9475 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -15,9 +15,10 @@ import ( ) type projectAddOptions struct { - path string - id string - name string + path string + id string + name string + asWorkspace bool } type projectListOptions struct { @@ -36,27 +37,37 @@ type projectRemoveOptions struct { // addProjectRequest mirrors the daemon's project AddInput body for // POST /api/v1/projects. projectId and name are optional (pointers omit them). type addProjectRequest struct { - Path string `json:"path"` - ProjectID *string `json:"projectId,omitempty"` - Name *string `json:"name,omitempty"` + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` + AsWorkspace bool `json:"asWorkspace,omitempty"` } type projectSummary struct { ID string `json:"id"` Name string `json:"name"` + Kind string `json:"kind"` SessionPrefix string `json:"sessionPrefix"` ResolveError string `json:"resolveError,omitempty"` } type projectDetails struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` - DefaultBranch string `json:"defaultBranch"` - DefaultHarness string `json:"agent,omitempty"` - Config *projectConfig `json:"config,omitempty"` - ResolveError string `json:"resolveError,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + DefaultHarness string `json:"agent,omitempty"` + Config *projectConfig `json:"config,omitempty"` + WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"` + ResolveError string `json:"resolveError,omitempty"` +} + +type workspaceRepoDetails struct { + Name string `json:"name"` + RelativePath string `json:"relativePath"` + Repo string `json:"repo"` } // agentConfig mirrors the daemon's typed domain.AgentConfig for the CLI client. @@ -200,13 +211,15 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { Use: "add", Short: "Register a local git repo as a project", Long: "Register a local git repo as a project so sessions can be spawned in it.\n\n" + - "The path must be an existing git repository on disk.", + "The path must be an existing git repository on disk. With --as-workspace, " + + "the path may be a parent folder containing direct child git repositories; " + + "AO initializes/adopts the parent as the root repo and gitignores children.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { if opts.path == "" { return usageError{fmt.Errorf("--path is required")} } - req := addProjectRequest{Path: opts.path} + req := addProjectRequest{Path: opts.path, AsWorkspace: opts.asWorkspace} if opts.id != "" { req.ProjectID = &opts.id } @@ -225,6 +238,7 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { 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.name, "name", "", "Display name") + f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)") return cmd } @@ -395,7 +409,7 @@ func writeProjectList(cmd *cobra.Command, projects []projectSummary) error { } tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "ID\tNAME\tSESSION PREFIX\tSTATUS"); err != nil { + if _, err := fmt.Fprintln(tw, "ID\tNAME\tKIND\tSESSION PREFIX\tSTATUS"); err != nil { return err } for _, p := range projects { @@ -403,7 +417,11 @@ func writeProjectList(cmd *cobra.Command, projects []projectSummary) error { if p.ResolveError != "" { status = "degraded: " + p.ResolveError } - if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.ID, p.Name, p.SessionPrefix, status); err != nil { + kind := p.Kind + if kind == "" { + kind = "single_repo" + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Name, kind, p.SessionPrefix, status); err != nil { return err } } @@ -421,6 +439,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { value string }{ {label: "name", value: p.Name}, + {label: "kind", value: p.Kind}, {label: "path", value: p.Path}, {label: "repo", value: p.Repo}, {label: "default branch", value: p.DefaultBranch}, @@ -436,6 +455,20 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { return err } } + if len(p.WorkspaceRepos) > 0 { + if _, err := fmt.Fprintln(out, " workspace repos:"); err != nil { + return err + } + for _, repo := range p.WorkspaceRepos { + desc := repo.RelativePath + if repo.Repo != "" { + desc += " (" + repo.Repo + ")" + } + if _, err := fmt.Fprintf(out, " %s: %s\n", repo.Name, desc); err != nil { + return err + } + } + } return nil } diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index 28906cdf8..3f872f09e 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -2,6 +2,26 @@ package domain import "time" +const ( + // ProjectKindSingleRepo is the existing one-repository project shape. + ProjectKindSingleRepo ProjectKind = "single_repo" + // ProjectKindWorkspace is a parent root-as-repo plus child repositories. + ProjectKindWorkspace ProjectKind = "workspace" + // RootWorkspaceRepoName is the reserved repo_name used for the parent root repo. + RootWorkspaceRepoName = "__root__" +) + +// ProjectKind describes how a registered project materialises session workspaces. +type ProjectKind string + +// WithDefault returns ProjectKindSingleRepo when the stored value predates the kind column. +func (k ProjectKind) WithDefault() ProjectKind { + if k == "" { + return ProjectKindSingleRepo + } + return k +} + // ProjectRecord is the durable project registry row used by storage and services. type ProjectRecord struct { ID string @@ -10,7 +30,31 @@ type ProjectRecord struct { DisplayName string RegisteredAt time.Time ArchivedAt time.Time + Kind ProjectKind // Config holds the typed per-project configuration AO resolves at spawn. An // IsZero value means unset. Config ProjectConfig } + +// WorkspaceRepoRecord is a child repo registered under a workspace project. +// The root repo itself is represented by ProjectRecord and by session_worktrees +// rows using RootWorkspaceRepoName; workspace_repos contains direct children. +type WorkspaceRepoRecord struct { + ProjectID ProjectID + Name string + RelativePath string + RepoOriginURL string + RegisteredAt time.Time +} + +// SessionWorktreeRecord tracks one repo worktree in a session. Workspace +// projects create one root row plus one child row per WorkspaceRepoRecord. +type SessionWorktreeRecord struct { + SessionID SessionID + RepoName string + Branch string + BaseSHA string + WorktreePath string + PreservedRef string + State string +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index f24db0505..c1c3a087b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -961,6 +961,8 @@ components: type: object AddProjectInput: properties: + asWorkspace: + type: boolean config: $ref: '#/components/schemas/ProjectConfig' name: @@ -1034,6 +1036,8 @@ components: properties: id: type: string + kind: + type: string name: type: string path: @@ -1043,6 +1047,7 @@ components: required: - id - name + - kind - path - resolveError type: object @@ -1134,15 +1139,22 @@ components: type: string id: type: string + kind: + type: string name: type: string path: type: string repo: type: string + workspaceRepos: + items: + $ref: '#/components/schemas/WorkspaceRepo' + type: array required: - id - name + - kind - path - repo - defaultBranch @@ -1201,6 +1213,8 @@ components: properties: id: type: string + kind: + type: string name: type: string path: @@ -1213,6 +1227,7 @@ components: - id - name - path + - kind - sessionPrefix type: object RemoveProjectResult: @@ -1485,6 +1500,19 @@ components: required: - projectId type: object + WorkspaceRepo: + properties: + name: + type: string + relativePath: + type: string + repo: + type: string + required: + - name + - relativePath + - repo + type: object tags: - description: Project registry, configuration, and lifecycle administration name: projects diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 8159b7970..5a3ab42d9 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -163,6 +163,7 @@ var schemaNames = map[string]string{ "ProjectAddInput": "AddProjectInput", "ProjectRemoveResult": "RemoveProjectResult", "ProjectSetConfigInput": "SetProjectConfigInput", + "ProjectWorkspaceRepo": "WorkspaceRepo", } // markRequestBodyRequired sets requestBody.required: true on the operation's diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go index fd750fe9d..32f632e26 100644 --- a/backend/internal/service/project/dto.go +++ b/backend/internal/service/project/dto.go @@ -11,10 +11,11 @@ type GetResult struct { // AddInput is the body shape for POST /api/v1/projects. type AddInput struct { - Path string `json:"path"` - ProjectID *string `json:"projectId,omitempty"` - Name *string `json:"name,omitempty"` - Config *domain.ProjectConfig `json:"config,omitempty"` + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` + Config *domain.ProjectConfig `json:"config,omitempty"` + AsWorkspace bool `json:"asWorkspace,omitempty"` } // SetConfigInput is the body shape for PUT /api/v1/projects/{id}/config. Config diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 6b4860905..adc924386 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -8,6 +8,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -38,6 +39,11 @@ type Manager interface { // Service implements project registration and lookup use-cases for controllers. type Service struct { store Store + // addMu serialises the whole body of Add. Workspace registration performs + // filesystem mutations (git init, .gitignore writes, commits) that are not + // covered by the store's own writeMu, so path/id conflict checks plus the + // subsequent mutation must be atomic from the perspective of concurrent callers. + addMu sync.Mutex } var _ Manager = (*Service)(nil) @@ -59,6 +65,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) { ID: domain.ProjectID(row.ID), Name: displayName(row), Path: row.Path, + Kind: row.Kind.WithDefault(), SessionPrefix: resolveSessionPrefix(row), }) } @@ -78,19 +85,27 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } p := projectFromRow(row) + if row.Kind.WithDefault() == domain.ProjectKindWorkspace { + repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) + if err != nil { + return GetResult{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load workspace repositories") + } + p.WorkspaceRepos = workspaceReposFromRecords(repos) + } return GetResult{Status: "ok", Project: &p}, nil } // Add registers a local git repository as a project. +// +// The whole method body is serialised by addMu because workspace registration +// mutates the filesystem (git init, .gitignore, commits) between the conflict +// check and the store write — two concurrent calls for the same path would both +// pass FindProjectByPath and then race on those mutations. func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { path, err := normalizePath(in.Path) if err != nil { return Project{}, err } - if !isGitRepo(path) { - return Project{}, apierr.Invalid("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) - } - id := defaultProjectID(path) if in.ProjectID != nil { id = domain.ProjectID(strings.TrimSpace(*in.ProjectID)) @@ -99,6 +114,9 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, err } + m.addMu.Lock() + defer m.addMu.Unlock() + name := string(id) if in.Name != nil { name = strings.TrimSpace(*in.Name) @@ -132,14 +150,33 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { config = *in.Config } + registeredAt := time.Now() row := domain.ProjectRecord{ - ID: string(id), - Path: path, - RepoOriginURL: resolveGitOriginURL(path), - DisplayName: name, - RegisteredAt: time.Now(), - Config: config, + ID: string(id), + Path: path, + DisplayName: name, + RegisteredAt: registeredAt, + Kind: domain.ProjectKindSingleRepo, + Config: config, } + if in.AsWorkspace { + repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt) + if err != nil { + return Project{}, err + } + row.Kind = domain.ProjectKindWorkspace + row.RepoOriginURL = resolveGitOriginURL(path) + if err := m.store.UpsertWorkspaceProject(ctx, row, repos); err != nil { + return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project") + } + p := projectFromRow(row) + p.WorkspaceRepos = workspaceReposFromRecords(repos) + return p, nil + } + if !isGitRepo(path) { + return Project{}, apierr.Invalid("NOT_A_GIT_REPO", "Repository path must point to a git repository", nil) + } + row.RepoOriginURL = resolveGitOriginURL(path) if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } @@ -209,6 +246,7 @@ func projectFromRow(row domain.ProjectRecord) Project { p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), + Kind: row.Kind.WithDefault(), Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: row.Config.WithDefaults().DefaultBranch, diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4457e7e26..4d07dce9a 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -3,7 +3,11 @@ package project_test import ( "context" "errors" + "os" "os/exec" + "path/filepath" + "strings" + "sync" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -288,3 +292,372 @@ func TestManager_GetUpdateRemoveErrors(t *testing.T) { t.Fatalf("seed: %v", err) } } + +func configureCommitter(t *testing.T) { + t.Helper() + t.Setenv("GIT_AUTHOR_NAME", "AO Test") + t.Setenv("GIT_AUTHOR_EMAIL", "ao@example.com") + t.Setenv("GIT_COMMITTER_NAME", "AO Test") + t.Setenv("GIT_COMMITTER_EMAIL", "ao@example.com") +} + +func gitRepoWithCommit(t *testing.T, dir string) string { + t.Helper() + if out, err := exec.Command("git", "init", "-b", "main", dir).CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, out) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write readme: %v", err) + } + if out, err := exec.Command("git", "-C", dir, "add", "README.md").CombinedOutput(); err != nil { + t.Fatalf("git add: %v (%s)", err, out) + } + if out, err := exec.Command("git", "-C", dir, "commit", "-m", "initial").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v (%s)", err, out) + } + return dir +} + +func TestManager_AddWorkspaceInitializesPlainParent(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + parent := t.TempDir() + if err := os.WriteFile(filepath.Join(parent, "package.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + gitRepoWithCommit(t, filepath.Join(parent, "cli")) + gitRepoWithCommit(t, filepath.Join(parent, "api")) + + proj, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("ws"), AsWorkspace: true}) + if err != nil { + t.Fatalf("Add workspace: %v", err) + } + if proj.Kind != domain.ProjectKindWorkspace { + t.Fatalf("Kind = %q, want workspace", proj.Kind) + } + if len(proj.WorkspaceRepos) != 2 || proj.WorkspaceRepos[0].Name != "api" || proj.WorkspaceRepos[1].Name != "cli" { + t.Fatalf("WorkspaceRepos = %#v", proj.WorkspaceRepos) + } + ignored, err := os.ReadFile(filepath.Join(parent, ".gitignore")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"/api/", "/cli/", "node_modules/", "dist/"} { + if !strings.Contains(string(ignored), want) { + t.Fatalf(".gitignore missing %q:\n%s", want, ignored) + } + } + out, err := exec.Command("git", "-C", parent, "ls-files", "-s").CombinedOutput() + if err != nil { + t.Fatalf("git ls-files: %v (%s)", err, out) + } + if strings.Contains(string(out), "160000") { + t.Fatalf("parent tracked a child repo as a gitlink:\n%s", out) + } + if !strings.Contains(string(out), "package.json") || !strings.Contains(string(out), ".gitignore") { + t.Fatalf("parent root files not committed:\n%s", out) + } + + got, err := m.Get(ctx, "ws") + if err != nil { + t.Fatalf("Get workspace: %v", err) + } + if got.Project == nil || got.Project.Kind != domain.ProjectKindWorkspace || len(got.Project.WorkspaceRepos) != 2 { + t.Fatalf("Get = %#v", got) + } +} + +func TestManager_AddWorkspaceRejectsUncommittedChild(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + parent := t.TempDir() + child := filepath.Join(parent, "cli") + if out, err := exec.Command("git", "init", "-b", "main", child).CombinedOutput(); err != nil { + t.Fatalf("git init child: %v (%s)", err, out) + } + + _, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("ws"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_CHILD_UNBORN") +} + +// TestManager_AddWorkspaceAdoptsExistingParent verifies that when the parent is +// already a git repo, Add commits only .gitignore changes, preserves the prior +// commit history, and registers the children. +func TestManager_AddWorkspaceAdoptsExistingParent(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + parent := t.TempDir() + // Parent is an existing repo with one commit and a pre-existing .gitignore. + gitRepoWithCommit(t, parent) + if err := os.WriteFile(filepath.Join(parent, ".gitignore"), []byte("*.log\n"), 0o644); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + if out, err := exec.Command("git", "-C", parent, "add", ".gitignore").CombinedOutput(); err != nil { + t.Fatalf("git add .gitignore: %v (%s)", err, out) + } + if out, err := exec.Command("git", "-C", parent, "commit", "-m", "add gitignore").CombinedOutput(); err != nil { + t.Fatalf("git commit .gitignore: %v (%s)", err, out) + } + + gitRepoWithCommit(t, filepath.Join(parent, "api")) + gitRepoWithCommit(t, filepath.Join(parent, "backend")) + + proj, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("ws2"), AsWorkspace: true}) + if err != nil { + t.Fatalf("Add workspace: %v", err) + } + if proj.Kind != domain.ProjectKindWorkspace { + t.Fatalf("Kind = %q, want workspace", proj.Kind) + } + if len(proj.WorkspaceRepos) != 2 { + t.Fatalf("WorkspaceRepos = %#v, want 2", proj.WorkspaceRepos) + } + + // Original .gitignore line must be preserved. + ignored, err := os.ReadFile(filepath.Join(parent, ".gitignore")) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(ignored), "*.log") { + t.Fatalf(".gitignore lost original line; got:\n%s", ignored) + } + for _, want := range []string{"/api/", "/backend/"} { + if !strings.Contains(string(ignored), want) { + t.Fatalf(".gitignore missing %q:\n%s", want, ignored) + } + } + + // Exactly one new commit must have been created, touching only .gitignore. + logOut, err := exec.Command("git", "-C", parent, "log", "--format=%s").CombinedOutput() + if err != nil { + t.Fatalf("git log: %v (%s)", err, logOut) + } + lines := strings.Split(strings.TrimSpace(string(logOut)), "\n") + // Expect: AO workspace commit + "add gitignore" + "initial" = 3 commits. + if len(lines) != 3 { + t.Fatalf("expected 3 commits, got %d:\n%s", len(lines), logOut) + } + + showOut, err := exec.Command("git", "-C", parent, "show", "--name-only", "--format=", "HEAD").CombinedOutput() + if err != nil { + t.Fatalf("git show HEAD: %v (%s)", err, showOut) + } + files := strings.TrimSpace(string(showOut)) + if files != ".gitignore" { + t.Fatalf("HEAD touched files other than .gitignore: %q", files) + } +} + +// TestManager_AddWorkspaceRejectsWorktreeParent verifies that a linked worktree +// of another repository is rejected as a workspace parent. +func TestManager_AddWorkspaceRejectsWorktreeParent(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + base := t.TempDir() + mainRepo := filepath.Join(base, "main") + wtDir := filepath.Join(base, "wt") + gitRepoWithCommit(t, mainRepo) + + // Create a linked worktree from the main repo. + if out, err := exec.Command("git", "-C", mainRepo, "worktree", "add", wtDir).CombinedOutput(); err != nil { + t.Fatalf("git worktree add: %v (%s)", err, out) + } + + // Put a committed child repo inside the worktree dir. + gitRepoWithCommit(t, filepath.Join(wtDir, "child")) + + _, err := m.Add(ctx, project.AddInput{Path: wtDir, ProjectID: ptr("wt"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_PARENT_IS_WORKTREE") +} + +// TestManager_AddWorkspaceAdoptsSeparateGitDirParent verifies that a parent repo +// created with `git init --separate-git-dir=` (whose .git is a file, +// not a dir) is correctly identified as a standalone repo and NOT rejected as a +// linked worktree. Add with AsWorkspace must succeed and register the child. +func TestManager_AddWorkspaceAdoptsSeparateGitDirParent(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + base := t.TempDir() + parent := filepath.Join(base, "parent") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatalf("mkdir parent: %v", err) + } + // The git directory lives outside the parent tree — this is the + // separate-git-dir scenario. .git inside parent will be a file. + separateGitDir := filepath.Join(base, "parent.git") + if out, err := exec.Command("git", "init", "--separate-git-dir="+separateGitDir, "-b", "main", parent).CombinedOutput(); err != nil { + t.Fatalf("git init --separate-git-dir: %v (%s)", err, out) + } + // Commit a file in the parent so the parent is a valid (non-bare) repo. + if err := os.WriteFile(filepath.Join(parent, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write readme: %v", err) + } + if out, err := exec.Command("git", "-C", parent, "add", "README.md").CombinedOutput(); err != nil { + t.Fatalf("git add: %v (%s)", err, out) + } + if out, err := exec.Command("git", "-C", parent, "commit", "-m", "initial").CombinedOutput(); err != nil { + t.Fatalf("git commit: %v (%s)", err, out) + } + + // Put a committed child repo inside the parent. + gitRepoWithCommit(t, filepath.Join(parent, "svc")) + + proj, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("sgd"), AsWorkspace: true}) + if err != nil { + t.Fatalf("Add workspace with separate-git-dir parent: %v", err) + } + if proj.Kind != domain.ProjectKindWorkspace { + t.Fatalf("Kind = %q, want workspace", proj.Kind) + } + if len(proj.WorkspaceRepos) != 1 || proj.WorkspaceRepos[0].Name != "svc" { + t.Fatalf("WorkspaceRepos = %#v, want [{svc}]", proj.WorkspaceRepos) + } +} + +// TestManager_AddWorkspaceRejectsWorktreeChild verifies that a child whose .git +// is a file (linked worktree) is rejected. +func TestManager_AddWorkspaceRejectsWorktreeChild(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + base := t.TempDir() + parent := filepath.Join(base, "parent") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatalf("mkdir parent: %v", err) + } + // An external standalone repo used as the source for a worktree child. + extRepo := filepath.Join(base, "ext") + gitRepoWithCommit(t, extRepo) + + // child is a linked worktree of extRepo, placed inside parent. + child := filepath.Join(parent, "child") + if out, err := exec.Command("git", "-C", extRepo, "worktree", "add", child).CombinedOutput(); err != nil { + t.Fatalf("git worktree add child: %v (%s)", err, out) + } + + _, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("wc"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_CHILD_IS_WORKTREE") +} + +// TestManager_AddWorkspaceRejectsReservedChildName verifies that a child repo +// named __root__ is rejected to avoid a PK collision in session_worktrees. +func TestManager_AddWorkspaceRejectsReservedChildName(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + parent := t.TempDir() + gitRepoWithCommit(t, filepath.Join(parent, domain.RootWorkspaceRepoName)) + + _, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("res"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_CHILD_RESERVED_NAME") +} + +// TestManager_AddWorkspaceInitRollsBackOnNestedGitlink verifies that when a +// nested git repo at depth ≥2 causes guardNoGitlinks to fail, initWorkspaceParent +// rolls back the .git dir and .gitignore so the folder is exactly as it was. +// A retry of the same Add must fail with the same error, not a stranded-state error. +func TestManager_AddWorkspaceInitRollsBackOnNestedGitlink(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + parent := t.TempDir() + // One direct committed child repo — valid on its own. + gitRepoWithCommit(t, filepath.Join(parent, "app")) + // A nested git repo at packages/foo — depth 2 relative to parent. + // detectWorkspaceChildren never registers it (packages/ itself is not a repo), + // but git add -A would stage it as a gitlink. + pkgs := filepath.Join(parent, "packages") + if err := os.MkdirAll(pkgs, 0o755); err != nil { + t.Fatalf("mkdir packages: %v", err) + } + gitRepoWithCommit(t, filepath.Join(pkgs, "foo")) + + _, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("rbt"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_PARENT_GITLINK") + + // Rollback: .git must not exist. + if _, statErr := os.Lstat(filepath.Join(parent, ".git")); statErr == nil { + t.Fatal(".git still exists after rollback") + } + // Rollback: .gitignore must not exist (it didn't exist before the call). + if _, statErr := os.Lstat(filepath.Join(parent, ".gitignore")); statErr == nil { + t.Fatal(".gitignore still exists after rollback") + } + + // Retry must fail with the same error, not a different stranded-state error. + _, err2 := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("rbt"), AsWorkspace: true}) + wantCode(t, err2, "WORKSPACE_PARENT_GITLINK") +} + +// TestManager_AddWorkspaceConcurrentSamePath verifies that two goroutines racing +// on the same parent path result in exactly one success and one PATH_ALREADY_REGISTERED +// error. The -race detector will catch any unsynchronised access. +func TestManager_AddWorkspaceConcurrentSamePath(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + parent := t.TempDir() + gitRepoWithCommit(t, filepath.Join(parent, "svc")) + + type result struct { + proj project.Project + err error + } + results := make([]result, 2) + var wg sync.WaitGroup + wg.Add(2) + for i := range results { + go func() { + defer wg.Done() + p, err := m.Add(ctx, project.AddInput{Path: parent, ProjectID: ptr("con"), AsWorkspace: true}) + results[i] = result{p, err} + }() + } + wg.Wait() + + successes, failures := 0, 0 + for _, r := range results { + if r.err == nil { + successes++ + } else { + failures++ + wantCode(t, r.err, "PATH_ALREADY_REGISTERED") + } + } + if successes != 1 || failures != 1 { + t.Fatalf("expected 1 success and 1 PATH_ALREADY_REGISTERED; got successes=%d failures=%d (errors: %v %v)", + successes, failures, results[0].err, results[1].err) + } +} + +// TestManager_AddWorkspaceRejectsBareParent verifies that a bare git repository +// is rejected as a workspace parent before any mutation occurs. +func TestManager_AddWorkspaceRejectsBareParent(t *testing.T) { + configureCommitter(t) + ctx := context.Background() + m := newManager(t) + + base := t.TempDir() + bareParent := filepath.Join(base, "bare.git") + if out, err := exec.Command("git", "init", "--bare", bareParent).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v (%s)", err, out) + } + + // Place a committed child repo inside the bare parent directory. + gitRepoWithCommit(t, filepath.Join(bareParent, "child")) + + _, err := m.Add(ctx, project.AddInput{Path: bareParent, ProjectID: ptr("bare"), AsWorkspace: true}) + wantCode(t, err, "WORKSPACE_PARENT_BARE") +} diff --git a/backend/internal/service/project/store.go b/backend/internal/service/project/store.go index 504179c89..0f72b0ed2 100644 --- a/backend/internal/service/project/store.go +++ b/backend/internal/service/project/store.go @@ -13,5 +13,7 @@ type Store interface { GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) UpsertProject(ctx context.Context, row domain.ProjectRecord) error + UpsertWorkspaceProject(ctx context.Context, row domain.ProjectRecord, repos []domain.WorkspaceRepoRecord) error + ListWorkspaceRepos(ctx context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) ArchiveProject(ctx context.Context, id string, at time.Time) (bool, error) } diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index 9e7ad3ed8..f33b338d5 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -4,28 +4,39 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain" // Summary is the row shape returned by GET /api/v1/projects. type Summary struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - SessionPrefix string `json:"sessionPrefix"` - ResolveError string `json:"resolveError,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Kind domain.ProjectKind `json:"kind"` + SessionPrefix string `json:"sessionPrefix"` + ResolveError string `json:"resolveError,omitempty"` } // Project is the full read-model returned by GET /api/v1/projects/{id}. type Project struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Repo string `json:"repo"` - DefaultBranch string `json:"defaultBranch"` - Agent string `json:"agent,omitempty"` - Config *domain.ProjectConfig `json:"config,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Kind domain.ProjectKind `json:"kind"` + Path string `json:"path"` + Repo string `json:"repo"` + DefaultBranch string `json:"defaultBranch"` + Agent string `json:"agent,omitempty"` + Config *domain.ProjectConfig `json:"config,omitempty"` + WorkspaceRepos []WorkspaceRepo `json:"workspaceRepos,omitempty"` } // Degraded is returned in place of Project when project config failed to load. type Degraded struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - ResolveError string `json:"resolveError"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Kind domain.ProjectKind `json:"kind"` + Path string `json:"path"` + ResolveError string `json:"resolveError"` +} + +// WorkspaceRepo is the project-detail read shape for a registered child repo. +type WorkspaceRepo struct { + Name string `json:"name"` + RelativePath string `json:"relativePath"` + Repo string `json:"repo"` } diff --git a/backend/internal/service/project/workspace_registration.go b/backend/internal/service/project/workspace_registration.go new file mode 100644 index 000000000..dc44aab31 --- /dev/null +++ b/backend/internal/service/project/workspace_registration.go @@ -0,0 +1,367 @@ +package project + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" +) + +var workspaceRootIgnoreDenylist = []string{ + "node_modules/", + "dist/", + "build/", + ".cache/", + ".turbo/", + "target/", + "coverage/", + "tmp/", + "temp/", +} + +func prepareWorkspaceProject(ctx context.Context, parent string, projectID domain.ProjectID, registeredAt time.Time) ([]domain.WorkspaceRepoRecord, error) { + if err := validateWorkspaceParent(ctx, parent); err != nil { + return nil, err + } + children, err := detectWorkspaceChildren(ctx, parent, projectID, registeredAt) + if err != nil { + return nil, err + } + if len(children) == 0 { + return nil, apierr.Invalid("WORKSPACE_REPOS_REQUIRED", "Workspace project must contain at least one direct child git repository", map[string]any{ + "suggestedFix": "Create or move child repositories directly under the workspace folder, then retry.", + }) + } + if isGitRepo(parent) { + if err := adoptWorkspaceParent(ctx, parent, children); err != nil { + return nil, err + } + } else { + if err := initWorkspaceParent(ctx, parent, children); err != nil { + return nil, err + } + } + if err := guardNoGitlinks(ctx, parent); err != nil { + return nil, err + } + return children, nil +} + +// validateWorkspaceParent checks that the parent folder is not a linked +// worktree of another repository and not a bare repo. These edge cases slip +// past isGitRepo but would corrupt an external repo or fail with a confusing +// error partway through mutation. +func validateWorkspaceParent(ctx context.Context, parent string) error { + // Linked-worktree detection: a linked worktree has a .git FILE, not a dir. + // However, a repo created with `git init --separate-git-dir=` + // also has .git as a file (containing "gitdir: "). We must distinguish + // the two: in a linked worktree, --git-dir points into .git/worktrees/ + // which differs from --git-common-dir (the main .git). In a separate-git-dir + // repo, both resolve to the same directory. + gitPath := filepath.Join(parent, ".git") + info, err := os.Lstat(gitPath) + if err == nil && !info.IsDir() { + // Probe git to tell us whether this is a worktree or a separate-git-dir repo. + gitDir, errGD := gitOutput(ctx, parent, "rev-parse", "--git-dir") + gitCommonDir, errCD := gitOutput(ctx, parent, "rev-parse", "--git-common-dir") + if errGD != nil || errCD != nil { + // Cannot interrogate — conservatively reject; we don't know what this is. + probeErr := errGD + if probeErr == nil { + probeErr = errCD + } + return apierr.Invalid("WORKSPACE_PARENT_IS_WORKTREE", + "Workspace parent has a .git file that could not be inspected; it may be a linked worktree of another repository", + map[string]any{ + "path": parent, + "probeError": probeErr.Error(), + "suggestedFix": "Use the repository's main checkout directory, not a linked worktree.", + }) + } + // Resolve both paths to absolute, clean forms so the comparison is reliable + // whether git returns relative or absolute paths. + absGitDir := filepath.Clean(gitDir) + if !filepath.IsAbs(absGitDir) { + absGitDir = filepath.Clean(filepath.Join(parent, strings.TrimSpace(gitDir))) + } else { + absGitDir = filepath.Clean(strings.TrimSpace(gitDir)) + } + absCommonDir := filepath.Clean(gitCommonDir) + if !filepath.IsAbs(absCommonDir) { + absCommonDir = filepath.Clean(filepath.Join(parent, strings.TrimSpace(gitCommonDir))) + } else { + absCommonDir = filepath.Clean(strings.TrimSpace(gitCommonDir)) + } + // Resolve symlinks consistent with how isGitRepo normalises paths. + if resolved, err := filepath.EvalSymlinks(absGitDir); err == nil { + absGitDir = resolved + } + if resolved, err := filepath.EvalSymlinks(absCommonDir); err == nil { + absCommonDir = resolved + } + // In a linked worktree --git-dir != --git-common-dir; in a separate-git-dir + // repo they are the same (both point to the external git directory). + if absGitDir != absCommonDir { + return apierr.Invalid("WORKSPACE_PARENT_IS_WORKTREE", + "Workspace parent must be a standalone repository or plain folder, not a worktree of another repository", + map[string]any{ + "path": parent, + "suggestedFix": "Use the repository's main checkout directory, not a linked worktree.", + }) + } + // Same dir → separate-git-dir repo; fall through and allow it. + } + + // Bare-repo detection: git init --bare creates a repo with no .git subdir at + // all; --show-toplevel fails, so isGitRepo returns false, then git init + // re-initialises the bare repo and git add -A fails with an opaque error. + // Only reject on a definite "true" — if git can't run, keep the normal path. + if out, err := gitOutput(ctx, parent, "rev-parse", "--is-bare-repository"); err == nil { + if strings.TrimSpace(out) == "true" { + return apierr.Invalid("WORKSPACE_PARENT_BARE", + "Workspace parent must not be a bare repository", + map[string]any{ + "path": parent, + "suggestedFix": "Create a non-bare clone or plain folder as the workspace parent.", + }) + } + } + return nil +} + +func detectWorkspaceChildren(ctx context.Context, parent string, projectID domain.ProjectID, registeredAt time.Time) ([]domain.WorkspaceRepoRecord, error) { + entries, err := os.ReadDir(parent) + if err != nil { + return nil, apierr.Invalid("INVALID_PATH", "Workspace path could not be read", nil) + } + var repos []domain.WorkspaceRepoRecord + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if name == ".git" { + continue + } + child := filepath.Join(parent, name) + if !isGitRepo(child) { + continue + } + // Reject a child directory whose name collides with the reserved root name. + // Plain folders with this name are fine (they fall through before here); + // only a real git repo named __root__ would create a PK collision in + // session_worktrees. + if name == domain.RootWorkspaceRepoName { + return nil, apierr.Invalid("WORKSPACE_CHILD_RESERVED_NAME", + "Child repository name is reserved for internal use", + map[string]any{ + "path": child, + "suggestedFix": fmt.Sprintf("Rename the directory %q — the name %q is reserved by AO for the workspace root.", child, domain.RootWorkspaceRepoName), + }) + } + if err := validateWorkspaceChild(ctx, child); err != nil { + return nil, err + } + repos = append(repos, domain.WorkspaceRepoRecord{ + ProjectID: projectID, + Name: name, + RelativePath: filepath.ToSlash(name), + RepoOriginURL: resolveGitOriginURL(child), + RegisteredAt: registeredAt, + }) + } + sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name }) + return repos, nil +} + +func validateWorkspaceChild(ctx context.Context, child string) error { + gitPath := filepath.Join(child, ".git") + info, err := os.Lstat(gitPath) + if err != nil { + return apierr.Invalid("INVALID_WORKSPACE_CHILD", "Workspace child repository is missing a .git directory", map[string]any{"path": child}) + } + if !info.IsDir() { + return apierr.Invalid("WORKSPACE_CHILD_IS_WORKTREE", "Workspace child repositories must be standalone repos, not worktrees of an external repo", map[string]any{ + "path": child, + "suggestedFix": "Register a standalone child repository, or clone/init it directly under the workspace parent.", + }) + } + if out, err := gitOutput(ctx, child, "rev-parse", "--is-bare-repository"); err != nil { + return apierr.Invalid("INVALID_WORKSPACE_CHILD", "Workspace child repository could not be inspected", map[string]any{"path": child, "error": err.Error()}) + } else if strings.TrimSpace(out) == "true" { + return apierr.Invalid("WORKSPACE_CHILD_BARE", "Workspace child repositories must not be bare repositories", map[string]any{"path": child}) + } + if _, err := gitOutput(ctx, child, "rev-parse", "--verify", "HEAD"); err != nil { + return apierr.Invalid("WORKSPACE_CHILD_UNBORN", "Workspace child repositories must have at least one commit", map[string]any{ + "path": child, + "suggestedFix": "Run `git init -b main`, add the initial files, and create the first commit before registering the workspace.", + }) + } + branch, err := gitOutput(ctx, child, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil || strings.TrimSpace(branch) == "" { + return apierr.Invalid("WORKSPACE_CHILD_DEFAULT_BRANCH_UNKNOWN", "Workspace child repositories must have an identifiable default branch", map[string]any{ + "path": child, + "suggestedFix": "Check out the repository's default branch (for example `main`) and retry.", + }) + } + return nil +} + +func adoptWorkspaceParent(ctx context.Context, parent string, repos []domain.WorkspaceRepoRecord) error { + changed, err := ensureWorkspaceGitignore(parent, repos) + if err != nil { + return apierr.Invalid("WORKSPACE_PARENT_GITIGNORE_FAILED", "Failed to update workspace parent .gitignore", map[string]any{"error": err.Error()}) + } + if !changed { + return nil + } + if _, err := gitOutput(ctx, parent, "add", ".gitignore"); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_GITIGNORE_FAILED", "Failed to stage workspace parent .gitignore", map[string]any{"error": err.Error()}) + } + if err := guardNoGitlinks(ctx, parent); err != nil { + return err + } + if _, err := gitOutput(ctx, parent, "commit", "-m", "chore: configure AO workspace ignores", "--", ".gitignore"); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_COMMIT_FAILED", "Failed to commit workspace parent .gitignore", map[string]any{"error": err.Error()}) + } + return nil +} + +func initWorkspaceParent(ctx context.Context, parent string, repos []domain.WorkspaceRepoRecord) (retErr error) { + // Snapshot the original .gitignore so we can restore it on failure. + // If the file doesn't exist, originalGitignore is nil. + gitignorePath := filepath.Join(parent, ".gitignore") + originalGitignore, readErr := os.ReadFile(gitignorePath) + gitignoreExisted := readErr == nil + + if _, err := gitOutput(ctx, parent, "init", "-b", domain.DefaultBranchName); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_INIT_FAILED", "Failed to initialize workspace parent git repository", map[string]any{"error": err.Error()}) + } + + // Rollback helper: remove the .git dir we just created and restore the + // original .gitignore state. Only runs when we return an error after init. + defer func() { + if retErr == nil { + return + } + _ = os.RemoveAll(filepath.Join(parent, ".git")) + if gitignoreExisted { + _ = os.WriteFile(gitignorePath, originalGitignore, 0o600) + } else { + _ = os.Remove(gitignorePath) + } + }() + + if _, err := ensureWorkspaceGitignore(parent, repos); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_GITIGNORE_FAILED", "Failed to write workspace parent .gitignore", map[string]any{"error": err.Error()}) + } + if _, err := gitOutput(ctx, parent, "add", "-A"); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_ADD_FAILED", "Failed to stage workspace parent files", map[string]any{"error": err.Error()}) + } + if err := guardNoGitlinks(ctx, parent); err != nil { + return err + } + if _, err := gitOutput(ctx, parent, "commit", "-m", "chore: initialize AO workspace root"); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_COMMIT_FAILED", "Failed to create workspace parent initial commit", map[string]any{"error": err.Error()}) + } + return nil +} + +func ensureWorkspaceGitignore(parent string, repos []domain.WorkspaceRepoRecord) (bool, error) { + path := filepath.Join(parent, ".gitignore") + seen := map[string]bool{} + var lines []string + if data, err := os.ReadFile(path); err == nil { + s := bufio.NewScanner(strings.NewReader(string(data))) + for s.Scan() { + line := s.Text() + lines = append(lines, line) + seen[strings.TrimSpace(line)] = true + } + if err := s.Err(); err != nil { + return false, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + var additions []string + for _, repo := range repos { + additions = append(additions, "/"+filepath.ToSlash(repo.RelativePath)+"/") + } + additions = append(additions, workspaceRootIgnoreDenylist...) + changed := false + for _, entry := range additions { + if seen[entry] { + continue + } + lines = append(lines, entry) + seen[entry] = true + changed = true + } + if !changed { + return false, nil + } + content := strings.Join(lines, "\n") + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + return true, os.WriteFile(path, []byte(content), 0o600) +} + +func guardNoGitlinks(ctx context.Context, repo string) error { + out, err := gitOutput(ctx, repo, "ls-files", "-s") + if err != nil { + return apierr.Invalid("WORKSPACE_PARENT_INDEX_FAILED", "Failed to inspect workspace parent index", map[string]any{"error": err.Error()}) + } + var paths []string + s := bufio.NewScanner(strings.NewReader(out)) + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "160000 ") { + _, path, _ := strings.Cut(line, "\t") + paths = append(paths, path) + } + } + if err := s.Err(); err != nil { + return apierr.Invalid("WORKSPACE_PARENT_INDEX_FAILED", "Failed to inspect workspace parent index", map[string]any{"error": err.Error()}) + } + if len(paths) > 0 { + return apierr.Invalid("WORKSPACE_PARENT_GITLINK", + "Workspace parent index contains embedded gitlinks; child repos must be gitignored before committing", + map[string]any{ + "paths": paths, + "suggestedFix": fmt.Sprintf( + "Run `git rm --cached %s` for each listed path and add them to .gitignore, or remove nested repositories not directly under the workspace root.", + strings.Join(paths, " "), + ), + }) + } + return nil +} + +func workspaceReposFromRecords(records []domain.WorkspaceRepoRecord) []WorkspaceRepo { + out := make([]WorkspaceRepo, 0, len(records)) + for _, rec := range records { + out = append(out, WorkspaceRepo{Name: rec.Name, RelativePath: rec.RelativePath, Repo: rec.RepoOriginURL}) + } + return out +} + +func gitOutput(ctx context.Context, dir string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git -C %s %s: %w: %s", dir, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out), nil +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index d718e8bec..694d11967 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -108,6 +108,7 @@ type Project struct { RegisteredAt time.Time ArchivedAt sql.NullTime Config sql.NullString + Kind string } type Session struct { @@ -129,3 +130,21 @@ type Session struct { UpdatedAt time.Time DisplayName string } + +type SessionWorktree struct { + SessionID domain.SessionID + RepoName string + Branch string + BaseSha string + WorktreePath string + PreservedRef string + State string +} + +type WorkspaceRepo struct { + ProjectID domain.ProjectID + Name string + RelativePath string + RepoOriginURL string + RegisteredAt time.Time +} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 4ab0398ab..ff845ba20 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -31,7 +31,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) } const findProjectByPath = `-- name: FindProjectByPath :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE path = ? AND archived_at IS NULL ` @@ -46,12 +46,13 @@ func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, &i.RegisteredAt, &i.ArchivedAt, &i.Config, + &i.Kind, ) return i, err } const getProject = `-- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE id = ? ` @@ -66,12 +67,13 @@ func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project, &i.RegisteredAt, &i.ArchivedAt, &i.Config, + &i.Kind, ) return i, err } const listProjects = `-- name: ListProjects :many -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE archived_at IS NULL ORDER BY id ` @@ -92,6 +94,7 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { &i.RegisteredAt, &i.ArchivedAt, &i.Config, + &i.Kind, ); err != nil { return nil, err } @@ -107,14 +110,15 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { } const upsertProject = `-- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, repo_origin_url = excluded.repo_origin_url, display_name = excluded.display_name, archived_at = excluded.archived_at, - config = excluded.config + config = excluded.config, + kind = excluded.kind ` type UpsertProjectParams struct { @@ -125,6 +129,7 @@ type UpsertProjectParams struct { RegisteredAt time.Time ArchivedAt sql.NullTime Config sql.NullString + Kind string } func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { @@ -136,6 +141,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er arg.RegisteredAt, arg.ArchivedAt, arg.Config, + arg.Kind, ) return err } diff --git a/backend/internal/storage/sqlite/gen/workspace.sql.go b/backend/internal/storage/sqlite/gen/workspace.sql.go new file mode 100644 index 000000000..60d838764 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/workspace.sql.go @@ -0,0 +1,193 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: workspace.sql + +package gen + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +const deleteSessionWorktrees = `-- name: DeleteSessionWorktrees :exec +DELETE FROM session_worktrees WHERE session_id = ? +` + +func (q *Queries) DeleteSessionWorktrees(ctx context.Context, sessionID domain.SessionID) error { + _, err := q.db.ExecContext(ctx, deleteSessionWorktrees, sessionID) + return err +} + +const deleteWorkspaceReposByProject = `-- name: DeleteWorkspaceReposByProject :exec +DELETE FROM workspace_repos WHERE project_id = ? +` + +func (q *Queries) DeleteWorkspaceReposByProject(ctx context.Context, projectID domain.ProjectID) error { + _, err := q.db.ExecContext(ctx, deleteWorkspaceReposByProject, projectID) + return err +} + +const getSessionWorktree = `-- name: GetSessionWorktree :one +SELECT session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state +FROM session_worktrees +WHERE session_id = ? AND repo_name = ? +` + +type GetSessionWorktreeParams struct { + SessionID domain.SessionID + RepoName string +} + +func (q *Queries) GetSessionWorktree(ctx context.Context, arg GetSessionWorktreeParams) (SessionWorktree, error) { + row := q.db.QueryRowContext(ctx, getSessionWorktree, arg.SessionID, arg.RepoName) + var i SessionWorktree + err := row.Scan( + &i.SessionID, + &i.RepoName, + &i.Branch, + &i.BaseSha, + &i.WorktreePath, + &i.PreservedRef, + &i.State, + ) + return i, err +} + +const listSessionWorktrees = `-- name: ListSessionWorktrees :many +SELECT session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state +FROM session_worktrees +WHERE session_id = ? +ORDER BY CASE WHEN repo_name = '__root__' THEN 0 ELSE 1 END, repo_name +` + +func (q *Queries) ListSessionWorktrees(ctx context.Context, sessionID domain.SessionID) ([]SessionWorktree, error) { + rows, err := q.db.QueryContext(ctx, listSessionWorktrees, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SessionWorktree{} + for rows.Next() { + var i SessionWorktree + if err := rows.Scan( + &i.SessionID, + &i.RepoName, + &i.Branch, + &i.BaseSha, + &i.WorktreePath, + &i.PreservedRef, + &i.State, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWorkspaceRepos = `-- name: ListWorkspaceRepos :many +SELECT project_id, name, relative_path, repo_origin_url, registered_at +FROM workspace_repos +WHERE project_id = ? +ORDER BY name +` + +func (q *Queries) ListWorkspaceRepos(ctx context.Context, projectID domain.ProjectID) ([]WorkspaceRepo, error) { + rows, err := q.db.QueryContext(ctx, listWorkspaceRepos, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []WorkspaceRepo{} + for rows.Next() { + var i WorkspaceRepo + if err := rows.Scan( + &i.ProjectID, + &i.Name, + &i.RelativePath, + &i.RepoOriginURL, + &i.RegisteredAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertSessionWorktree = `-- name: UpsertSessionWorktree :exec +INSERT INTO session_worktrees (session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id, repo_name) DO UPDATE SET + branch = excluded.branch, + base_sha = excluded.base_sha, + worktree_path = excluded.worktree_path, + preserved_ref = excluded.preserved_ref, + state = excluded.state +` + +type UpsertSessionWorktreeParams struct { + SessionID domain.SessionID + RepoName string + Branch string + BaseSha string + WorktreePath string + PreservedRef string + State string +} + +func (q *Queries) UpsertSessionWorktree(ctx context.Context, arg UpsertSessionWorktreeParams) error { + _, err := q.db.ExecContext(ctx, upsertSessionWorktree, + arg.SessionID, + arg.RepoName, + arg.Branch, + arg.BaseSha, + arg.WorktreePath, + arg.PreservedRef, + arg.State, + ) + return err +} + +const upsertWorkspaceRepo = `-- name: UpsertWorkspaceRepo :exec +INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, registered_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (project_id, name) DO UPDATE SET + relative_path = excluded.relative_path, + repo_origin_url = excluded.repo_origin_url, + registered_at = excluded.registered_at +` + +type UpsertWorkspaceRepoParams struct { + ProjectID domain.ProjectID + Name string + RelativePath string + RepoOriginURL string + RegisteredAt time.Time +} + +func (q *Queries) UpsertWorkspaceRepo(ctx context.Context, arg UpsertWorkspaceRepoParams) error { + _, err := q.db.ExecContext(ctx, upsertWorkspaceRepo, + arg.ProjectID, + arg.Name, + arg.RelativePath, + arg.RepoOriginURL, + arg.RegisteredAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql b/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql new file mode 100644 index 000000000..cd9e074df --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0009_workspace_projects.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE projects ADD COLUMN kind TEXT NOT NULL DEFAULT 'single_repo' + CHECK (kind IN ('single_repo', 'workspace')); + +CREATE TABLE workspace_repos ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + relative_path TEXT NOT NULL, + repo_origin_url TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + PRIMARY KEY (project_id, name), + UNIQUE (project_id, relative_path) +); + +CREATE TABLE session_worktrees ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + repo_name TEXT NOT NULL, + branch TEXT NOT NULL, + base_sha TEXT NOT NULL, + worktree_path TEXT NOT NULL, + preserved_ref TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'active' + CHECK (state IN ('active', 'removed', 'retry_remove', 'unavailable', 'stray_moved')), + PRIMARY KEY (session_id, repo_name) +); +CREATE INDEX idx_session_worktrees_session ON session_worktrees(session_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE session_worktrees; +DROP TABLE workspace_repos; +-- SQLite cannot drop projects.kind without rebuilding the table. Existing down +-- migrations in this project are best-effort for dev databases; leave the +-- backward-compatible column in place. +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 61c04b896..13ed899d1 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -1,23 +1,24 @@ -- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, repo_origin_url = excluded.repo_origin_url, display_name = excluded.display_name, archived_at = excluded.archived_at, - config = excluded.config; + config = excluded.config, + kind = excluded.kind; -- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE id = ?; -- name: ListProjects :many -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: FindProjectByPath :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind FROM projects WHERE path = ? AND archived_at IS NULL; -- name: ArchiveProject :execrows diff --git a/backend/internal/storage/sqlite/queries/workspace.sql b/backend/internal/storage/sqlite/queries/workspace.sql new file mode 100644 index 000000000..79e4c7ec0 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/workspace.sql @@ -0,0 +1,40 @@ +-- name: DeleteWorkspaceReposByProject :exec +DELETE FROM workspace_repos WHERE project_id = ?; + +-- name: UpsertWorkspaceRepo :exec +INSERT INTO workspace_repos (project_id, name, relative_path, repo_origin_url, registered_at) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (project_id, name) DO UPDATE SET + relative_path = excluded.relative_path, + repo_origin_url = excluded.repo_origin_url, + registered_at = excluded.registered_at; + +-- name: ListWorkspaceRepos :many +SELECT project_id, name, relative_path, repo_origin_url, registered_at +FROM workspace_repos +WHERE project_id = ? +ORDER BY name; + +-- name: UpsertSessionWorktree :exec +INSERT INTO session_worktrees (session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id, repo_name) DO UPDATE SET + branch = excluded.branch, + base_sha = excluded.base_sha, + worktree_path = excluded.worktree_path, + preserved_ref = excluded.preserved_ref, + state = excluded.state; + +-- name: GetSessionWorktree :one +SELECT session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state +FROM session_worktrees +WHERE session_id = ? AND repo_name = ?; + +-- name: ListSessionWorktrees :many +SELECT session_id, repo_name, branch, base_sha, worktree_path, preserved_ref, state +FROM session_worktrees +WHERE session_id = ? +ORDER BY CASE WHEN repo_name = '__root__' THEN 0 ELSE 1 END, repo_name; + +-- name: DeleteSessionWorktrees :exec +DELETE FROM session_worktrees WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 06a3bd97a..abc5eca95 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -20,7 +20,62 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error } s.writeMu.Lock() defer s.writeMu.Unlock() - return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ + return upsertProject(ctx, s.qw, r, config) +} + +// UpsertWorkspaceProject inserts or replaces a workspace project and its child +// repository registry in one transaction. The child set is authoritative. +func (s *Store) UpsertWorkspaceProject(ctx context.Context, r domain.ProjectRecord, repos []domain.WorkspaceRepoRecord) error { + config, err := marshalProjectConfig(r.Config) + if err != nil { + return err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.inTx(ctx, "upsert workspace project", func(q *gen.Queries) error { + if err := upsertProject(ctx, q, r, config); err != nil { + return err + } + if err := q.DeleteWorkspaceReposByProject(ctx, domain.ProjectID(r.ID)); err != nil { + return err + } + for _, repo := range repos { + if err := q.UpsertWorkspaceRepo(ctx, gen.UpsertWorkspaceRepoParams{ + ProjectID: domain.ProjectID(r.ID), + Name: repo.Name, + RelativePath: repo.RelativePath, + RepoOriginURL: repo.RepoOriginURL, + RegisteredAt: repo.RegisteredAt, + }); err != nil { + return err + } + } + return nil + }) +} + +// ListWorkspaceRepos returns the registered direct child repos for a workspace project. +func (s *Store) ListWorkspaceRepos(ctx context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) { + rows, err := s.qr.ListWorkspaceRepos(ctx, domain.ProjectID(projectID)) + if err != nil { + return nil, fmt.Errorf("list workspace repos for %s: %w", projectID, err) + } + out := make([]domain.WorkspaceRepoRecord, 0, len(rows)) + for _, row := range rows { + out = append(out, domain.WorkspaceRepoRecord{ + ProjectID: row.ProjectID, + Name: row.Name, + RelativePath: row.RelativePath, + RepoOriginURL: row.RepoOriginURL, + RegisteredAt: row.RegisteredAt, + }) + } + return out, nil +} + +func upsertProject(ctx context.Context, q *gen.Queries, r domain.ProjectRecord, config sql.NullString) error { + kind := r.Kind.WithDefault() + return q.UpsertProject(ctx, gen.UpsertProjectParams{ ID: domain.ProjectID(r.ID), Path: r.Path, RepoOriginURL: r.RepoOriginURL, @@ -28,6 +83,7 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error RegisteredAt: r.RegisteredAt, ArchivedAt: nullTime(r.ArchivedAt), Config: config, + Kind: string(kind), }) } @@ -89,6 +145,7 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord { RepoOriginURL: p.RepoOriginURL, DisplayName: p.DisplayName, RegisteredAt: p.RegisteredAt, + Kind: domain.ProjectKind(p.Kind).WithDefault(), Config: unmarshalProjectConfig(p.Config), } if p.ArchivedAt.Valid { diff --git a/backend/internal/storage/sqlite/store/session_worktree_store.go b/backend/internal/storage/sqlite/store/session_worktree_store.go new file mode 100644 index 000000000..04cba5ccb --- /dev/null +++ b/backend/internal/storage/sqlite/store/session_worktree_store.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// UpsertSessionWorktree records or updates one repo worktree for a session. +func (s *Store) UpsertSessionWorktree(ctx context.Context, row domain.SessionWorktreeRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpsertSessionWorktree(ctx, gen.UpsertSessionWorktreeParams{ + SessionID: row.SessionID, + RepoName: row.RepoName, + Branch: row.Branch, + BaseSha: row.BaseSHA, + WorktreePath: row.WorktreePath, + PreservedRef: row.PreservedRef, + State: row.State, + }) +} + +// GetSessionWorktree returns one session worktree row. +func (s *Store) GetSessionWorktree(ctx context.Context, sessionID domain.SessionID, repoName string) (domain.SessionWorktreeRecord, bool, error) { + row, err := s.qr.GetSessionWorktree(ctx, gen.GetSessionWorktreeParams{SessionID: sessionID, RepoName: repoName}) + if errors.Is(err, sql.ErrNoRows) { + return domain.SessionWorktreeRecord{}, false, nil + } + if err != nil { + return domain.SessionWorktreeRecord{}, false, fmt.Errorf("get session worktree %s/%s: %w", sessionID, repoName, err) + } + return sessionWorktreeFromGen(row), true, nil +} + +// ListSessionWorktrees returns every repo worktree for a session, root first. +func (s *Store) ListSessionWorktrees(ctx context.Context, sessionID domain.SessionID) ([]domain.SessionWorktreeRecord, error) { + rows, err := s.qr.ListSessionWorktrees(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list session worktrees for %s: %w", sessionID, err) + } + out := make([]domain.SessionWorktreeRecord, 0, len(rows)) + for _, row := range rows { + out = append(out, sessionWorktreeFromGen(row)) + } + return out, nil +} + +// DeleteSessionWorktrees deletes the per-repo worktree rows for a session. +func (s *Store) DeleteSessionWorktrees(ctx context.Context, sessionID domain.SessionID) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.DeleteSessionWorktrees(ctx, sessionID) +} + +func sessionWorktreeFromGen(row gen.SessionWorktree) domain.SessionWorktreeRecord { + return domain.SessionWorktreeRecord{ + SessionID: row.SessionID, + RepoName: row.RepoName, + Branch: row.Branch, + BaseSHA: row.BaseSha, + WorktreePath: row.WorktreePath, + PreservedRef: row.PreservedRef, + State: row.State, + } +} diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 31a2af31f..61b8a4792 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -660,3 +660,49 @@ func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) { t.Fatalf("created %d sessions, want %d", len(all), n) } } + +func TestSessionWorktreesRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "ws") + rec, err := s.CreateSession(ctx, sampleRecord("ws")) + if err != nil { + t.Fatalf("create session: %v", err) + } + rows := []domain.SessionWorktreeRecord{ + {SessionID: rec.ID, RepoName: domain.RootWorkspaceRepoName, Branch: "ao/ws-1", BaseSHA: "root-base", WorktreePath: "/managed/ws/ws-1", State: "active"}, + {SessionID: rec.ID, RepoName: "api", Branch: "ao/ws-1", BaseSHA: "api-base", WorktreePath: "/managed/ws/ws-1/api", PreservedRef: "refs/ao/preserved/ws-1", State: "removed"}, + } + for _, row := range rows { + if err := s.UpsertSessionWorktree(ctx, row); err != nil { + t.Fatalf("upsert worktree %s: %v", row.RepoName, err) + } + } + got, err := s.ListSessionWorktrees(ctx, rec.ID) + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + if !reflect.DeepEqual(got, rows) { + t.Fatalf("worktrees = %#v, want %#v", got, rows) + } + one, ok, err := s.GetSessionWorktree(ctx, rec.ID, "api") + if err != nil || !ok || one.PreservedRef != "refs/ao/preserved/ws-1" { + t.Fatalf("get api = %#v ok=%v err=%v", one, ok, err) + } + rows[1].State = "active" + rows[1].PreservedRef = "" + if err := s.UpsertSessionWorktree(ctx, rows[1]); err != nil { + t.Fatalf("update api worktree: %v", err) + } + one, ok, err = s.GetSessionWorktree(ctx, rec.ID, "api") + if err != nil || !ok || one.State != "active" || one.PreservedRef != "" { + t.Fatalf("updated api = %#v ok=%v err=%v", one, ok, err) + } + if err := s.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + t.Fatalf("delete worktrees: %v", err) + } + got, err = s.ListSessionWorktrees(ctx, rec.ID) + if err != nil || len(got) != 0 { + t.Fatalf("after delete = %#v err=%v", got, err) + } +} diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml index 81707b209..1813a599c 100644 --- a/backend/sqlc.yaml +++ b/backend/sqlc.yaml @@ -60,6 +60,14 @@ sql: go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" type: "ProjectID" + - column: "workspace_repos.project_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ProjectID" + - column: "session_worktrees.session_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" - column: "sessions.id" go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 52f7d824a..8acb85eef 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -329,6 +329,7 @@ export interface components { requestId?: string; }; AddProjectInput: { + asWorkspace?: boolean; config?: components["schemas"]["ProjectConfig"]; name?: null | string; path: string; @@ -355,6 +356,7 @@ export interface components { }; DegradedProject: { id: string; + kind: string; name: string; path: string; resolveError: string; @@ -394,9 +396,11 @@ export interface components { config?: components["schemas"]["ProjectConfig"]; defaultBranch: string; id: string; + kind: string; name: string; path: string; repo: string; + workspaceRepos?: components["schemas"]["WorkspaceRepo"][]; }; ProjectConfig: { agentConfig?: components["schemas"]["AgentConfig"]; @@ -421,6 +425,7 @@ export interface components { }; ProjectSummary: { id: string; + kind: string; name: string; path: string; resolveError?: string; @@ -528,6 +533,11 @@ export interface components { projectId: string; prompt?: string; }; + WorkspaceRepo: { + name: string; + relativePath: string; + repo: string; + }; }; responses: never; parameters: never;