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:
parent
c2c4404c7d
commit
5244015802
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ?;
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue