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
This commit is contained in:
Harshit Singh Bhandari 2026-06-10 16:10:14 +05:30 committed by GitHub
parent c2c4404c7d
commit 5244015802
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1450 additions and 61 deletions

View File

@ -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())
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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=<elsewhere>` (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")
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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=<elsewhere>`
// also has .git as a file (containing "gitdir: <path>"). We must distinguish
// the two: in a linked worktree, --git-dir points into .git/worktrees/<name>
// 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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 = ?;

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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)
}
}

View File

@ -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"

View File

@ -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;