agent-orchestrator/backend/internal/service/project/service.go

636 lines
22 KiB
Go

package project
import (
"context"
"errors"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process"
)
// Manager is the controller-facing contract for the /api/v1/projects surface.
type Manager interface {
// List returns every registered project, including degraded entries
// (those whose config failed to load but whose registry entry survives).
List(ctx context.Context) ([]Summary, error)
// Get returns one project, discriminating ok vs degraded via GetResult.
Get(ctx context.Context, id domain.ProjectID) (GetResult, error)
// Add registers a new project from a git repository path.
Add(ctx context.Context, in AddInput) (Project, error)
// InitializeRepository prepares a selected folder for project registration.
InitializeRepository(ctx context.Context, in InitializeRepositoryInput) (InitializeRepositoryResult, error)
// SetConfig replaces a project's per-project config, returning the updated
// read-model.
SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error)
// Remove unregisters a project, stopping its sessions and reclaiming
// managed workspaces.
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
}
// SessionTeardowner is the narrow session-service surface project removal
// needs: stop live project sessions and reclaim managed terminal workspaces.
type SessionTeardowner interface {
TeardownProject(ctx context.Context, project domain.ProjectID) error
}
// Service implements project registration and lookup use-cases for controllers.
type Service struct {
store Store
sessions SessionTeardowner
clock func() time.Time
telemetry ports.EventSink
defaultHarness domain.AgentHarness
// 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)
// Deps captures optional collaborators for project use-cases.
type Deps struct {
// DefaultHarness is the daemon's configured default agent (AO_AGENT).
// When empty, the service falls back to config.DefaultAgent.
DefaultHarness domain.AgentHarness
Store Store
Sessions SessionTeardowner
Clock func() time.Time
Telemetry ports.EventSink
}
// New returns a project service backed by the given durable store.
func New(store Store) *Service {
return NewWithDeps(Deps{Store: store})
}
// NewWithDeps returns a project service with optional teardown dependencies.
func NewWithDeps(d Deps) *Service {
defaultHarness := d.DefaultHarness
if defaultHarness == "" {
defaultHarness = domain.AgentHarness(config.DefaultAgent)
}
s := &Service{
store: d.Store,
sessions: d.Sessions,
clock: d.Clock,
telemetry: d.Telemetry,
defaultHarness: defaultHarness,
}
if s.clock == nil {
s.clock = time.Now
}
return s
}
// List returns every active registered project.
func (m *Service) List(ctx context.Context) ([]Summary, error) {
projects, err := m.store.ListProjects(ctx)
if err != nil {
return nil, apierr.Internal("PROJECTS_LIST_FAILED", "Failed to load projects")
}
out := make([]Summary, 0, len(projects))
for _, row := range projects {
out = append(out, Summary{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
Path: row.Path,
Kind: row.Kind.WithDefault(),
SessionPrefix: resolveSessionPrefix(row),
OrchestratorAgent: row.Config.Orchestrator.Harness,
})
}
return out, nil
}
// Get returns one active project by id.
func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, error) {
if err := validateProjectID(id); err != nil {
return GetResult{}, err
}
row, ok, err := m.store.GetProject(ctx, string(id))
if err != nil {
return GetResult{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
}
if !ok || !row.ArchivedAt.IsZero() {
return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
p := m.projectFromRow(row)
if row.Kind.WithDefault() == domain.ProjectKindWorkspace {
repos, err := m.store.ListWorkspaceRepos(ctx, row.ID)
if err != nil {
return GetResult{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load workspace repositories")
}
p.WorkspaceRepos = workspaceReposFromRecords(repos)
}
return GetResult{Status: "ok", Project: &p}, nil
}
// Add registers a local git repository as a project.
//
// The whole method body is serialised by addMu because workspace registration
// mutates the filesystem (git init, .gitignore, commits) between the conflict
// check and the store write — two concurrent calls for the same path would both
// pass FindProjectByPath and then race on those mutations.
func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
path, err := normalizePath(in.Path)
if err != nil {
return Project{}, err
}
id := defaultProjectID(path)
if in.ProjectID != nil {
id = domain.ProjectID(strings.TrimSpace(*in.ProjectID))
}
if err := validateProjectID(id); err != nil {
return Project{}, err
}
m.addMu.Lock()
defer m.addMu.Unlock()
projectCountBefore, err := m.activeProjectCount(ctx)
if err != nil {
return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
}
name := string(id)
if in.Name != nil {
name = strings.TrimSpace(*in.Name)
}
if name == "" {
name = string(id)
}
if existing, ok, err := m.store.FindProjectByPath(ctx, path); err != nil {
return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
} else if ok {
return Project{}, apierr.Conflict("PATH_ALREADY_REGISTERED", "A project at this path is already registered", map[string]any{
"existingProjectId": existing.ID,
"suggestedProjectId": string(m.suggestID(ctx, id)),
})
}
if existing, ok, err := m.store.GetProject(ctx, string(id)); err != nil {
return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
} else if ok && existing.ArchivedAt.IsZero() && existing.Path != path {
return Project{}, apierr.Conflict("ID_ALREADY_REGISTERED", "A project with this id is already registered for a different path", map[string]any{
"existingProjectId": existing.ID,
"suggestedProjectId": string(m.suggestID(ctx, id)),
})
}
var projectConfig domain.ProjectConfig
if in.Config != nil {
if err := in.Config.Validate(); err != nil {
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
}
projectConfig = *in.Config
}
registeredAt := time.Now()
row := domain.ProjectRecord{
ID: string(id),
Path: path,
DisplayName: name,
RegisteredAt: registeredAt,
Kind: domain.ProjectKindSingleRepo,
Config: projectConfig,
}
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")
}
m.emitProjectAdded(row, projectCountBefore == 0)
p := m.projectFromRow(row)
p.WorkspaceRepos = workspaceReposFromRecords(repos)
return p, nil
}
if !isGitRepo(path) {
return Project{}, apierr.Invalid("NOT_A_GIT_REPO", "AO needs a Git repository with an initial commit before it can create agent workspaces.", nil)
}
if !repoHasCommit(ctx, path) {
return Project{}, apierr.Invalid("PROJECT_UNBORN", "AO needs a Git repository with an initial commit before it can create agent workspaces.", map[string]any{
"path": path,
"suggestedFix": "Run `git commit --allow-empty -m \"initial commit\"` in this folder, then try again.",
})
}
// Record the repo's actual checked-out branch as the project default so
// session worktrees base off a branch that exists. Without this a repo on
// `master` (or any non-`main` default) falls back to DefaultBranchName and
// every spawn fails BRANCH_NOT_FETCHED. Only persist when it diverges from
// the default, so the common `main` repo keeps an empty (NULL) config.
if row.Config.DefaultBranch == "" {
if branch := resolveDefaultBranch(path); branch != "" && branch != domain.DefaultBranchName {
row.Config.DefaultBranch = branch
}
}
row.RepoOriginURL = resolveGitOriginURL(path)
if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project")
}
m.emitProjectAdded(row, projectCountBefore == 0)
return m.projectFromRow(row), nil
}
type repositorySetupTarget int
const (
repositorySetupPlainFolder repositorySetupTarget = iota
repositorySetupUnbornRepo
)
// InitializeRepository prepares a selected folder for project registration by ensuring it has an initial Git commit.
func (m *Service) InitializeRepository(ctx context.Context, in InitializeRepositoryInput) (InitializeRepositoryResult, error) {
path, err := normalizePath(in.Path)
if err != nil {
return InitializeRepositoryResult{}, err
}
if err := ensureDirectoryPath(path); err != nil {
return InitializeRepositoryResult{}, err
}
m.addMu.Lock()
defer m.addMu.Unlock()
target, err := classifyRepositorySetupTarget(ctx, path)
if err != nil {
return InitializeRepositoryResult{}, err
}
if target == repositorySetupPlainFolder {
if _, err := gitOutput(ctx, path, "init", "-b", domain.DefaultBranchName); err != nil {
return InitializeRepositoryResult{}, apierr.Invalid("GIT_INIT_FAILED", "Could not initialize a Git repository in this folder.", map[string]any{"error": err.Error()})
}
}
if _, err := gitOutput(ctx, path, "add", "-A"); err != nil {
return InitializeRepositoryResult{}, apierr.Invalid("GIT_ADD_FAILED", "Could not stage files for the initial commit.", map[string]any{"error": err.Error()})
}
if _, err := gitOutput(ctx, path, "-c", "user.name=Agent Orchestrator", "-c", "user.email=ao@example.com", "commit", "--allow-empty", "-m", "initial commit"); err != nil {
return InitializeRepositoryResult{}, apierr.Invalid("INITIAL_COMMIT_FAILED", "Could not create the initial commit.", map[string]any{"error": err.Error()})
}
return InitializeRepositoryResult{Path: path}, nil
}
func classifyRepositorySetupTarget(ctx context.Context, path string) (repositorySetupTarget, error) {
if isBareGitRepository(ctx, path) {
return repositorySetupPlainFolder, apierr.Invalid("PROJECT_BARE_REPOSITORY", "Selected folder must be a non-bare Git repository or a plain folder.", map[string]any{
"path": path,
"suggestedFix": "Use a normal checkout, or select a plain folder for AO to initialize.",
})
}
if isGitRepo(path) {
if repoHasCommit(ctx, path) {
return repositorySetupUnbornRepo, apierr.Conflict("PROJECT_ALREADY_INITIALIZED", "This repository already has commits.", map[string]any{"path": path})
}
return repositorySetupUnbornRepo, nil
}
if top, err := gitOutput(ctx, path, "rev-parse", "--show-toplevel"); err == nil {
root := normalizeGitReportedPath(path, strings.TrimSpace(top))
selected := comparablePath(path)
if !samePath(root, selected) {
return repositorySetupPlainFolder, apierr.Invalid("PROJECT_PATH_NOT_REPO_ROOT", "Selected folder is inside a Git repository. Select the repository root instead.", map[string]any{
"path": path,
"repoRoot": root,
"suggestedFix": "Select the repository root folder, then try again.",
})
}
return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains an unsupported Git repository layout.", map[string]any{"path": path})
}
if hasGitMetadata(path) {
return repositorySetupPlainFolder, apierr.Invalid("UNSUPPORTED_GIT_REPO", "Selected folder contains Git metadata that AO could not inspect.", map[string]any{
"path": path,
"suggestedFix": "Repair the Git repository or select a plain folder.",
})
}
return repositorySetupPlainFolder, nil
}
func (m *Service) activeProjectCount(ctx context.Context) (int, error) {
projects, err := m.store.ListProjects(ctx)
if err != nil {
return 0, err
}
return len(projects), nil
}
func (m *Service) emitProjectAdded(row domain.ProjectRecord, firstProject bool) {
if m.telemetry == nil {
return
}
projectID := domain.ProjectID(row.ID)
at := m.clock().UTC()
payload := map[string]any{
"kind": string(row.Kind.WithDefault()),
"has_git_remote": row.RepoOriginURL != "",
}
m.telemetry.Emit(context.Background(), ports.TelemetryEvent{
Name: "ao.projects.created",
Source: "project_service",
OccurredAt: at,
Level: ports.TelemetryLevelInfo,
ProjectID: &projectID,
Payload: payload,
})
if !firstProject {
return
}
m.telemetry.Emit(context.Background(), ports.TelemetryEvent{
Name: "ao.onboarding.first_project_added",
Source: "project_service",
OccurredAt: at,
Level: ports.TelemetryLevelInfo,
ProjectID: &projectID,
Payload: payload,
})
}
// SetConfig replaces the project's stored config. The typed config is validated
// here so a bad value is rejected when set rather than surfacing at spawn.
func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error) {
if err := validateProjectID(id); err != nil {
return Project{}, err
}
if err := in.Config.Validate(); err != nil {
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
}
row, ok, err := m.store.GetProject(ctx, string(id))
if err != nil {
return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
}
if !ok || !row.ArchivedAt.IsZero() {
return Project{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
row.Config = in.Config
if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config")
}
return m.projectFromRow(row), nil
}
// resolveGitOriginURL returns the project's `origin` remote URL via
// `git -C path remote get-url origin`. A missing remote, missing repo, or any
// other git error returns an empty string — `project add` must not fail just
// because no origin is configured (the SCM observer skips such projects).
func resolveGitOriginURL(path string) string {
out, err := aoprocess.Command("git", "-C", path, "remote", "get-url", "origin").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// resolveDefaultBranch returns the repo's default branch, preferring the
// remote's default (`origin/HEAD`) over the currently checked-out branch. This
// matters because the user may have the repo on a feature branch when adding the
// project: keying off HEAD would persist that feature branch as the project
// default and base every session worktree on it. `origin/HEAD` reflects the
// real default (e.g. `master`, `develop`) regardless of the active branch.
//
// Falls back to the checked-out branch when origin/HEAD is unset (no remote, or
// it was never fetched). A detached HEAD, missing repo, or any other git error
// returns an empty string — `project add` must not fail just because the branch
// can't be resolved (the caller falls back to DefaultBranchName).
func resolveDefaultBranch(path string) string {
if out, err := aoprocess.Command(
"git", "-C", path, "symbolic-ref", "--short", "refs/remotes/origin/HEAD",
).Output(); err == nil {
if ref := strings.TrimSpace(string(out)); ref != "" {
return strings.TrimPrefix(ref, "origin/")
}
}
out, err := aoprocess.Command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// Remove stops live project sessions, reclaims safe managed workspaces, then
// archives the project registration. The original repository path and durable
// session/history rows are preserved.
func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
if err := validateProjectID(id); err != nil {
return RemoveResult{}, err
}
row, ok, err := m.store.GetProject(ctx, string(id))
if err != nil {
return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
}
if !ok || !row.ArchivedAt.IsZero() {
return RemoveResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
if m.sessions != nil {
if err := m.sessions.TeardownProject(ctx, id); err != nil {
return RemoveResult{}, err
}
}
ok, err = m.store.ArchiveProject(ctx, string(id), time.Now())
if err != nil {
return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
}
if !ok {
return RemoveResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
return RemoveResult{ProjectID: id, RemovedStorageDir: false}, nil
}
func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.ProjectID {
for i := 1; ; i++ {
candidate := domain.ProjectID(string(base) + strconv.Itoa(i))
if _, ok, _ := m.store.GetProject(ctx, string(candidate)); !ok {
return candidate
}
}
}
func (m *Service) projectFromRow(row domain.ProjectRecord) Project {
p := Project{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
Kind: row.Kind.WithDefault(),
Path: row.Path,
Repo: row.RepoOriginURL,
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
Agent: string(m.defaultHarness),
}
p.Config = projectConfigPtr(row.Config)
return p
}
func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig {
if projectConfig.IsZero() {
return nil
}
cfg := projectConfig
return &cfg
}
func displayName(row domain.ProjectRecord) string {
if strings.TrimSpace(row.DisplayName) != "" {
return row.DisplayName
}
return row.ID
}
func normalizePath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", apierr.Invalid("PATH_REQUIRED", "Repository path is required", nil)
}
if strings.HasPrefix(raw, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", apierr.Invalid("INVALID_PATH", "Repository path could not be expanded", nil)
}
if raw == "~" {
raw = home
} else if strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`) {
raw = filepath.Join(home, raw[2:])
}
}
abs, err := filepath.Abs(raw)
if err != nil {
return "", apierr.Invalid("INVALID_PATH", "Repository path is invalid", nil)
}
return filepath.Clean(abs), nil
}
func ensureDirectoryPath(path string) error {
info, err := os.Stat(path)
if err != nil {
return apierr.Invalid("INVALID_PATH", "Selected folder could not be read", map[string]any{"path": path})
}
if !info.IsDir() {
return apierr.Invalid("INVALID_PATH", "Selected path must be a folder", map[string]any{"path": path})
}
return nil
}
func repoHasCommit(ctx context.Context, path string) bool {
_, err := gitOutput(ctx, path, "rev-parse", "--verify", "HEAD")
return err == nil
}
func isBareGitRepository(ctx context.Context, path string) bool {
out, err := gitOutput(ctx, path, "rev-parse", "--is-bare-repository")
return err == nil && strings.TrimSpace(out) == "true"
}
func hasGitMetadata(path string) bool {
_, err := os.Lstat(filepath.Join(path, ".git"))
return err == nil || !errors.Is(err, os.ErrNotExist)
}
func normalizeGitReportedPath(base, reported string) string {
if reported == "" {
return comparablePath(reported)
}
if !filepath.IsAbs(reported) {
reported = filepath.Join(base, reported)
}
return comparablePath(reported)
}
func comparablePath(path string) string {
clean := filepath.Clean(path)
if resolved, err := filepath.EvalSymlinks(clean); err == nil {
clean = resolved
}
return filepath.Clean(clean)
}
func samePath(a, b string) bool {
if strings.EqualFold(a, b) {
return true
}
return a == b
}
func isGitRepo(path string) bool {
cmd := aoprocess.Command("git", "-C", path, "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return false
}
top := filepath.Clean(strings.TrimSpace(string(out)))
path = filepath.Clean(path)
top, err = filepath.EvalSymlinks(top)
if err != nil {
return false
}
path, err = filepath.EvalSymlinks(path)
if err != nil {
return false
}
if strings.EqualFold(top, path) {
return true
}
return top == path
}
func defaultProjectID(path string) domain.ProjectID {
id := strings.ToLower(filepath.Base(path))
id = strings.TrimSpace(id)
id = strings.ReplaceAll(id, " ", "-")
return domain.ProjectID(id)
}
var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
func validateProjectID(id domain.ProjectID) error {
raw := string(id)
// Reject any "." run: a "." prefix fails the pattern, but an embedded ".."
// (e.g. "a..b") passes it yet yields a branch like "ao/a..b-1" that git's
// check-ref-format rejects — surfacing as an opaque 500 at spawn time.
if raw == "" || raw == "." || strings.Contains(raw, "..") || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) {
return apierr.Invalid("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil)
}
return nil
}
// resolveSessionPrefix prefers an explicit per-project SessionPrefix and falls
// back to the id-derived prefix. (Display only; session-id generation is
// unchanged.)
func resolveSessionPrefix(row domain.ProjectRecord) string {
if p := strings.TrimSpace(row.Config.SessionPrefix); p != "" {
return p
}
return sessionPrefix(row.ID)
}
func sessionPrefix(id string) string {
if id == "" {
return "ao"
}
if len(id) <= 12 {
return id
}
return id[:12]
}