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", "--path", "/repo/mer",
"--id", "demo", "--id", "demo",
"--name", "Demo", "--name", "Demo",
"--as-workspace",
}) })
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("project add execute: %v\noutput: %s", err, out.String()) 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" { if got.Name == nil || *got.Name != "Demo" {
t.Errorf("Name = %v, want %q", got.Name, "Demo") t.Errorf("Name = %v, want %q", got.Name, "Demo")
} }
if !got.AsWorkspace {
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
}
if !bytes.Contains(out.Bytes(), []byte("registered project")) { if !bytes.Contains(out.Bytes(), []byte("registered project")) {
t.Errorf("output missing %q; got: %s", "registered project", out.String()) t.Errorf("output missing %q; got: %s", "registered project", out.String())
} }

View File

@ -15,9 +15,10 @@ import (
) )
type projectAddOptions struct { type projectAddOptions struct {
path string path string
id string id string
name string name string
asWorkspace bool
} }
type projectListOptions struct { type projectListOptions struct {
@ -36,27 +37,37 @@ type projectRemoveOptions struct {
// addProjectRequest mirrors the daemon's project AddInput body for // addProjectRequest mirrors the daemon's project AddInput body for
// POST /api/v1/projects. projectId and name are optional (pointers omit them). // POST /api/v1/projects. projectId and name are optional (pointers omit them).
type addProjectRequest struct { type addProjectRequest struct {
Path string `json:"path"` Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"` ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"`
} }
type projectSummary struct { type projectSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"`
SessionPrefix string `json:"sessionPrefix"` SessionPrefix string `json:"sessionPrefix"`
ResolveError string `json:"resolveError,omitempty"` ResolveError string `json:"resolveError,omitempty"`
} }
type projectDetails struct { type projectDetails struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Kind string `json:"kind"`
Repo string `json:"repo"` Path string `json:"path"`
DefaultBranch string `json:"defaultBranch"` Repo string `json:"repo"`
DefaultHarness string `json:"agent,omitempty"` DefaultBranch string `json:"defaultBranch"`
Config *projectConfig `json:"config,omitempty"` DefaultHarness string `json:"agent,omitempty"`
ResolveError string `json:"resolveError,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. // agentConfig mirrors the daemon's typed domain.AgentConfig for the CLI client.
@ -200,13 +211,15 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
Use: "add", Use: "add",
Short: "Register a local git repo as a project", 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" + 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, Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if opts.path == "" { if opts.path == "" {
return usageError{fmt.Errorf("--path is required")} return usageError{fmt.Errorf("--path is required")}
} }
req := addProjectRequest{Path: opts.path} req := addProjectRequest{Path: opts.path, AsWorkspace: opts.asWorkspace}
if opts.id != "" { if opts.id != "" {
req.ProjectID = &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.path, "path", "", "Absolute path to the local git repo (required)")
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)") f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
f.StringVar(&opts.name, "name", "", "Display name") f.StringVar(&opts.name, "name", "", "Display name")
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
return cmd return cmd
} }
@ -395,7 +409,7 @@ func writeProjectList(cmd *cobra.Command, projects []projectSummary) error {
} }
tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) 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 return err
} }
for _, p := range projects { for _, p := range projects {
@ -403,7 +417,11 @@ func writeProjectList(cmd *cobra.Command, projects []projectSummary) error {
if p.ResolveError != "" { if p.ResolveError != "" {
status = "degraded: " + 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 return err
} }
} }
@ -421,6 +439,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
value string value string
}{ }{
{label: "name", value: p.Name}, {label: "name", value: p.Name},
{label: "kind", value: p.Kind},
{label: "path", value: p.Path}, {label: "path", value: p.Path},
{label: "repo", value: p.Repo}, {label: "repo", value: p.Repo},
{label: "default branch", value: p.DefaultBranch}, {label: "default branch", value: p.DefaultBranch},
@ -436,6 +455,20 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
return err 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 return nil
} }

View File

@ -2,6 +2,26 @@ package domain
import "time" 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. // ProjectRecord is the durable project registry row used by storage and services.
type ProjectRecord struct { type ProjectRecord struct {
ID string ID string
@ -10,7 +30,31 @@ type ProjectRecord struct {
DisplayName string DisplayName string
RegisteredAt time.Time RegisteredAt time.Time
ArchivedAt time.Time ArchivedAt time.Time
Kind ProjectKind
// Config holds the typed per-project configuration AO resolves at spawn. An // Config holds the typed per-project configuration AO resolves at spawn. An
// IsZero value means unset. // IsZero value means unset.
Config ProjectConfig 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 type: object
AddProjectInput: AddProjectInput:
properties: properties:
asWorkspace:
type: boolean
config: config:
$ref: '#/components/schemas/ProjectConfig' $ref: '#/components/schemas/ProjectConfig'
name: name:
@ -1034,6 +1036,8 @@ components:
properties: properties:
id: id:
type: string type: string
kind:
type: string
name: name:
type: string type: string
path: path:
@ -1043,6 +1047,7 @@ components:
required: required:
- id - id
- name - name
- kind
- path - path
- resolveError - resolveError
type: object type: object
@ -1134,15 +1139,22 @@ components:
type: string type: string
id: id:
type: string type: string
kind:
type: string
name: name:
type: string type: string
path: path:
type: string type: string
repo: repo:
type: string type: string
workspaceRepos:
items:
$ref: '#/components/schemas/WorkspaceRepo'
type: array
required: required:
- id - id
- name - name
- kind
- path - path
- repo - repo
- defaultBranch - defaultBranch
@ -1201,6 +1213,8 @@ components:
properties: properties:
id: id:
type: string type: string
kind:
type: string
name: name:
type: string type: string
path: path:
@ -1213,6 +1227,7 @@ components:
- id - id
- name - name
- path - path
- kind
- sessionPrefix - sessionPrefix
type: object type: object
RemoveProjectResult: RemoveProjectResult:
@ -1485,6 +1500,19 @@ components:
required: required:
- projectId - projectId
type: object type: object
WorkspaceRepo:
properties:
name:
type: string
relativePath:
type: string
repo:
type: string
required:
- name
- relativePath
- repo
type: object
tags: tags:
- description: Project registry, configuration, and lifecycle administration - description: Project registry, configuration, and lifecycle administration
name: projects name: projects

View File

@ -163,6 +163,7 @@ var schemaNames = map[string]string{
"ProjectAddInput": "AddProjectInput", "ProjectAddInput": "AddProjectInput",
"ProjectRemoveResult": "RemoveProjectResult", "ProjectRemoveResult": "RemoveProjectResult",
"ProjectSetConfigInput": "SetProjectConfigInput", "ProjectSetConfigInput": "SetProjectConfigInput",
"ProjectWorkspaceRepo": "WorkspaceRepo",
} }
// markRequestBodyRequired sets requestBody.required: true on the operation's // 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. // AddInput is the body shape for POST /api/v1/projects.
type AddInput struct { type AddInput struct {
Path string `json:"path"` Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"` ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Config *domain.ProjectConfig `json:"config,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 // SetConfigInput is the body shape for PUT /api/v1/projects/{id}/config. Config

View File

@ -8,6 +8,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "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. // Service implements project registration and lookup use-cases for controllers.
type Service struct { type Service struct {
store Store 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) var _ Manager = (*Service)(nil)
@ -59,6 +65,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
ID: domain.ProjectID(row.ID), ID: domain.ProjectID(row.ID),
Name: displayName(row), Name: displayName(row),
Path: row.Path, Path: row.Path,
Kind: row.Kind.WithDefault(),
SessionPrefix: resolveSessionPrefix(row), 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") return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
} }
p := projectFromRow(row) 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 return GetResult{Status: "ok", Project: &p}, nil
} }
// Add registers a local git repository as a project. // 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) { func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
path, err := normalizePath(in.Path) path, err := normalizePath(in.Path)
if err != nil { if err != nil {
return Project{}, err 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) id := defaultProjectID(path)
if in.ProjectID != nil { if in.ProjectID != nil {
id = domain.ProjectID(strings.TrimSpace(*in.ProjectID)) 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 return Project{}, err
} }
m.addMu.Lock()
defer m.addMu.Unlock()
name := string(id) name := string(id)
if in.Name != nil { if in.Name != nil {
name = strings.TrimSpace(*in.Name) name = strings.TrimSpace(*in.Name)
@ -132,14 +150,33 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
config = *in.Config config = *in.Config
} }
registeredAt := time.Now()
row := domain.ProjectRecord{ row := domain.ProjectRecord{
ID: string(id), ID: string(id),
Path: path, Path: path,
RepoOriginURL: resolveGitOriginURL(path), DisplayName: name,
DisplayName: name, RegisteredAt: registeredAt,
RegisteredAt: time.Now(), Kind: domain.ProjectKindSingleRepo,
Config: config, 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 { if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project")
} }
@ -209,6 +246,7 @@ func projectFromRow(row domain.ProjectRecord) Project {
p := Project{ p := Project{
ID: domain.ProjectID(row.ID), ID: domain.ProjectID(row.ID),
Name: displayName(row), Name: displayName(row),
Kind: row.Kind.WithDefault(),
Path: row.Path, Path: row.Path,
Repo: row.RepoOriginURL, Repo: row.RepoOriginURL,
DefaultBranch: row.Config.WithDefaults().DefaultBranch, DefaultBranch: row.Config.WithDefaults().DefaultBranch,

View File

@ -3,7 +3,11 @@ package project_test
import ( import (
"context" "context"
"errors" "errors"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -288,3 +292,372 @@ func TestManager_GetUpdateRemoveErrors(t *testing.T) {
t.Fatalf("seed: %v", err) 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) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error) FindProjectByPath(ctx context.Context, path string) (domain.ProjectRecord, bool, error)
UpsertProject(ctx context.Context, row domain.ProjectRecord) 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) 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. // Summary is the row shape returned by GET /api/v1/projects.
type Summary struct { type Summary struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
SessionPrefix string `json:"sessionPrefix"` Kind domain.ProjectKind `json:"kind"`
ResolveError string `json:"resolveError,omitempty"` SessionPrefix string `json:"sessionPrefix"`
ResolveError string `json:"resolveError,omitempty"`
} }
// Project is the full read-model returned by GET /api/v1/projects/{id}. // Project is the full read-model returned by GET /api/v1/projects/{id}.
type Project struct { type Project struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Kind domain.ProjectKind `json:"kind"`
Repo string `json:"repo"` Path string `json:"path"`
DefaultBranch string `json:"defaultBranch"` Repo string `json:"repo"`
Agent string `json:"agent,omitempty"` DefaultBranch string `json:"defaultBranch"`
Config *domain.ProjectConfig `json:"config,omitempty"` 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. // Degraded is returned in place of Project when project config failed to load.
type Degraded struct { type Degraded struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Kind domain.ProjectKind `json:"kind"`
ResolveError string `json:"resolveError"` 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 RegisteredAt time.Time
ArchivedAt sql.NullTime ArchivedAt sql.NullTime
Config sql.NullString Config sql.NullString
Kind string
} }
type Session struct { type Session struct {
@ -129,3 +130,21 @@ type Session struct {
UpdatedAt time.Time UpdatedAt time.Time
DisplayName string 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 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 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.RegisteredAt,
&i.ArchivedAt, &i.ArchivedAt,
&i.Config, &i.Config,
&i.Kind,
) )
return i, err return i, err
} }
const getProject = `-- name: GetProject :one 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 = ? FROM projects WHERE id = ?
` `
@ -66,12 +67,13 @@ func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project,
&i.RegisteredAt, &i.RegisteredAt,
&i.ArchivedAt, &i.ArchivedAt,
&i.Config, &i.Config,
&i.Kind,
) )
return i, err return i, err
} }
const listProjects = `-- name: ListProjects :many 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 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.RegisteredAt,
&i.ArchivedAt, &i.ArchivedAt,
&i.Config, &i.Config,
&i.Kind,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -107,14 +110,15 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) {
} }
const upsertProject = `-- name: UpsertProject :exec const upsertProject = `-- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
path = excluded.path, path = excluded.path,
repo_origin_url = excluded.repo_origin_url, repo_origin_url = excluded.repo_origin_url,
display_name = excluded.display_name, display_name = excluded.display_name,
archived_at = excluded.archived_at, archived_at = excluded.archived_at,
config = excluded.config config = excluded.config,
kind = excluded.kind
` `
type UpsertProjectParams struct { type UpsertProjectParams struct {
@ -125,6 +129,7 @@ type UpsertProjectParams struct {
RegisteredAt time.Time RegisteredAt time.Time
ArchivedAt sql.NullTime ArchivedAt sql.NullTime
Config sql.NullString Config sql.NullString
Kind string
} }
func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { 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.RegisteredAt,
arg.ArchivedAt, arg.ArchivedAt,
arg.Config, arg.Config,
arg.Kind,
) )
return err 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 -- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config) INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config, kind)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
path = excluded.path, path = excluded.path,
repo_origin_url = excluded.repo_origin_url, repo_origin_url = excluded.repo_origin_url,
display_name = excluded.display_name, display_name = excluded.display_name,
archived_at = excluded.archived_at, archived_at = excluded.archived_at,
config = excluded.config; config = excluded.config,
kind = excluded.kind;
-- name: GetProject :one -- 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 = ?; FROM projects WHERE id = ?;
-- name: ListProjects :many -- 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; FROM projects WHERE archived_at IS NULL ORDER BY id;
-- name: FindProjectByPath :one -- 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; FROM projects WHERE path = ? AND archived_at IS NULL;
-- name: ArchiveProject :execrows -- 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() s.writeMu.Lock()
defer s.writeMu.Unlock() 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), ID: domain.ProjectID(r.ID),
Path: r.Path, Path: r.Path,
RepoOriginURL: r.RepoOriginURL, RepoOriginURL: r.RepoOriginURL,
@ -28,6 +83,7 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error
RegisteredAt: r.RegisteredAt, RegisteredAt: r.RegisteredAt,
ArchivedAt: nullTime(r.ArchivedAt), ArchivedAt: nullTime(r.ArchivedAt),
Config: config, Config: config,
Kind: string(kind),
}) })
} }
@ -89,6 +145,7 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord {
RepoOriginURL: p.RepoOriginURL, RepoOriginURL: p.RepoOriginURL,
DisplayName: p.DisplayName, DisplayName: p.DisplayName,
RegisteredAt: p.RegisteredAt, RegisteredAt: p.RegisteredAt,
Kind: domain.ProjectKind(p.Kind).WithDefault(),
Config: unmarshalProjectConfig(p.Config), Config: unmarshalProjectConfig(p.Config),
} }
if p.ArchivedAt.Valid { 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) 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: go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type: "ProjectID" 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" - column: "sessions.id"
go_type: go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"

View File

@ -329,6 +329,7 @@ export interface components {
requestId?: string; requestId?: string;
}; };
AddProjectInput: { AddProjectInput: {
asWorkspace?: boolean;
config?: components["schemas"]["ProjectConfig"]; config?: components["schemas"]["ProjectConfig"];
name?: null | string; name?: null | string;
path: string; path: string;
@ -355,6 +356,7 @@ export interface components {
}; };
DegradedProject: { DegradedProject: {
id: string; id: string;
kind: string;
name: string; name: string;
path: string; path: string;
resolveError: string; resolveError: string;
@ -394,9 +396,11 @@ export interface components {
config?: components["schemas"]["ProjectConfig"]; config?: components["schemas"]["ProjectConfig"];
defaultBranch: string; defaultBranch: string;
id: string; id: string;
kind: string;
name: string; name: string;
path: string; path: string;
repo: string; repo: string;
workspaceRepos?: components["schemas"]["WorkspaceRepo"][];
}; };
ProjectConfig: { ProjectConfig: {
agentConfig?: components["schemas"]["AgentConfig"]; agentConfig?: components["schemas"]["AgentConfig"];
@ -421,6 +425,7 @@ export interface components {
}; };
ProjectSummary: { ProjectSummary: {
id: string; id: string;
kind: string;
name: string; name: string;
path: string; path: string;
resolveError?: string; resolveError?: string;
@ -528,6 +533,11 @@ export interface components {
projectId: string; projectId: string;
prompt?: string; prompt?: string;
}; };
WorkspaceRepo: {
name: string;
relativePath: string;
repo: string;
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;