Add `ao spawn` + `ao project add` (spawn a real worker end-to-end) (#77)
* Add `ao spawn` and `ao project add`; resolve project repos for worktrees Make a registered project spawnable end-to-end from the CLI: - DB-backed RepoResolver: the daemon resolves a project's on-disk repo path from the projects table (replacing the empty StaticRepoResolver that failed every lookup), so a session's worktree is cut from the right repo. - session_manager defaults an empty spawn branch to ao/<session-id> — a fresh, unique branch per session, since gitworktree can't reuse a branch already checked out elsewhere (e.g. main). - `ao project add --path <repo>`: register a local git repo (POST /api/v1/projects). - `ao spawn --project <id> [--harness] [--branch] [--prompt] [--issue]`: spawn a worker session (POST /api/v1/sessions); harness defaults to the daemon's AO_AGENT. - Shared postJSON daemon client (reads the run-file for the port, surfaces the API error envelope). Stacked on #65, which lands the agent-adapter + session-manager wiring this depends on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address Copilot review on #77 - `ao spawn` no longer prints a branch the sessions API doesn't return (session metadata is json:"-"), so the output is no longer misleading. - Unregistered/archived/no-path projects now surface a 400 PROJECT_NOT_RESOLVABLE with an actionable message instead of a generic 500: a new sessionmanager.ErrProjectNotResolvable sentinel the resolver wraps and writeSessionError maps. - postJSON reuses the injected Deps.HTTPClient (cloned, with a longer timeout) instead of a fresh client, keeping HTTP behaviour stubbable. - postJSON treats a stale run-file (dead PID) as "not running" via ProcessAlive, matching its docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Assert the project-not-resolvable sentinel in the resolver test Greptile review: harden TestProjectRepoResolver to verify the unregistered -project error wraps ErrProjectNotResolvable, so a future regression in the sentinel wrapping (which the HTTP 400 mapping relies on) is caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix ao spawn 500 on long session ids (zellij socket-path overflow) Root cause: the daemon built the zellij runtime with an empty SocketDir, so zellij fell back to its $TMPDIR-based default (long on macOS). That left almost none of the ~103-byte unix-socket-path budget for the session name, so a long session id (e.g. "aoagents-agent-orchestrator-1", derived from a long project id) was rejected by zellij with "session name must be less than 0 characters". runtime.Create failed, the spawn 500'd, and the worktree was rolled back (leaving an orphan ao/ branch). - New zellij.DefaultSocketDir(): a short, stable per-user socket dir (/tmp/ao-zellij-<uid>); the daemon uses it (and MkdirAll's it). - ao spawn's attach hint now prefixes ZELLIJ_SOCKET_DIR so it stays copy-pasteable against the daemon's socket dir. - Regression test guards that the socket dir leaves >= 48 bytes for the session name within the 103-byte limit. Verified: ao spawn against a long-id project now succeeds (session live, worktree created) where it previously 500'd. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cli): guard CLI/daemon DTO drift with an e2e round-trip The CLI keeps its own request structs (spawnRequest, addProjectRequest) separate from the daemon's canonical DTOs (controllers.SpawnSessionRequest, project.AddInput). Nothing verified the JSON field names agreed, so a renamed tag on either side would compile but break at runtime. Drive `ao spawn` and `ao project add` through the real httpd router and controllers (fakes only at the service layer) over a real loopback round trip via postJSON, asserting each field decodes into the right SpawnConfig/AddInput field. Runs in the normal test lane (no extra ports/processes). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cli,daemon): address review findings on ao spawn - spawn: print the sanitised zellij session name (zellij.SessionName) in the attach hint; a long/non-conforming session id is registered under a different name, so the raw id sent users to a missing session. - client: surface the daemon error envelope's requestId so a failed command can be correlated with daemon logs. - daemon: don't swallow the zellij socket-dir MkdirAll error — log it, since a failure otherwise surfaces later as an opaque socket-bind error on every spawn. - project: reject an embedded ".." in a project id up front; it passed the id pattern but yielded an invalid branch (ao/a..b-1) and an opaque 500 at spawn. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
This commit is contained in:
parent
3346c6cb6c
commit
57bb63701d
|
|
@ -58,6 +58,19 @@ type Runtime struct {
|
||||||
|
|
||||||
var _ ports.Runtime = (*Runtime)(nil)
|
var _ ports.Runtime = (*Runtime)(nil)
|
||||||
|
|
||||||
|
// DefaultSocketDir returns a short, stable ZELLIJ_SOCKET_DIR for AO's daemon.
|
||||||
|
// zellij's own default lives under $TMPDIR (long on macOS), which leaves almost
|
||||||
|
// none of the ~103-byte unix-socket-path budget for the session name — a long
|
||||||
|
// session id then fails with "session name must be less than 0 characters". A
|
||||||
|
// short dir restores ample budget. Empty on Windows, where zellij is not used.
|
||||||
|
// Pure: callers that run zellij should MkdirAll the result.
|
||||||
|
func DefaultSocketDir() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/tmp/ao-zellij-" + strconv.Itoa(os.Getuid())
|
||||||
|
}
|
||||||
|
|
||||||
type runner interface {
|
type runner interface {
|
||||||
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
|
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
@ -330,10 +343,18 @@ func zellijSessionName(id domain.SessionID) (string, error) {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", errors.New("zellij runtime: session id is required")
|
return "", errors.New("zellij runtime: session id is required")
|
||||||
}
|
}
|
||||||
if sessionIDPattern.MatchString(raw) && len(raw) <= 48 {
|
return SessionName(raw), nil
|
||||||
return raw, nil
|
}
|
||||||
|
|
||||||
|
// SessionName returns the zellij session name the runtime registers for a given
|
||||||
|
// session id — applying the same sanitisation Create does. Callers that print an
|
||||||
|
// attach hint (e.g. `ao spawn`) must use this rather than the raw id, since a
|
||||||
|
// long or non-conforming id maps to a different, sanitised session name.
|
||||||
|
func SessionName(id string) string {
|
||||||
|
if sessionIDPattern.MatchString(id) && len(id) <= 48 {
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
return sanitizedSessionName(raw), nil
|
return sanitizedSessionName(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizedSessionName(raw string) string {
|
func sanitizedSessionName(raw string) string {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,6 +60,30 @@ func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionName must return the exact name Create registers a session under, so
|
||||||
|
// callers that print an attach hint (e.g. `ao spawn`) reference the real
|
||||||
|
// session. A short, conforming id passes through; a long one is sanitised to a
|
||||||
|
// different name — printing the raw id there would send users to a missing
|
||||||
|
// session.
|
||||||
|
func TestSessionNameMatchesCreateNaming(t *testing.T) {
|
||||||
|
short := "myproj-1"
|
||||||
|
if got := SessionName(short); got != short {
|
||||||
|
t.Fatalf("SessionName(%q) = %q, want it unchanged", short, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
long := domain.SessionID(strings.Repeat("x", 60) + "-1")
|
||||||
|
viaCreate, err := zellijSessionName(long)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zellijSessionName: %v", err)
|
||||||
|
}
|
||||||
|
if got := SessionName(string(long)); got != viaCreate {
|
||||||
|
t.Fatalf("SessionName = %q, but Create uses %q", got, viaCreate)
|
||||||
|
}
|
||||||
|
if SessionName(string(long)) == string(long) {
|
||||||
|
t.Fatal("expected a long id to be sanitised to a different name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSessionAndPaneID(t *testing.T) {
|
func TestValidateSessionAndPaneID(t *testing.T) {
|
||||||
for _, id := range []string{"sess-1", "S_2", "abc123"} {
|
for _, id := range []string{"sess-1", "S_2", "abc123"} {
|
||||||
if err := validateSessionID(id); err != nil {
|
if err := validateSessionID(id); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// commandTimeout bounds a mutating daemon call. Spawns do real work (git
|
||||||
|
// worktree add, zellij launch, hook install), so it is generous compared to the
|
||||||
|
// status probe timeout.
|
||||||
|
const commandTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// apiError is the subset of the daemon's JSON error envelope the CLI surfaces.
|
||||||
|
// RequestID is surfaced so a failed command can be correlated with daemon logs.
|
||||||
|
type apiError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the envelope for the user: "<message> (<code>) [request <id>]",
|
||||||
|
// omitting whichever parts the daemon left empty.
|
||||||
|
func (e apiError) String() string {
|
||||||
|
msg := e.Message
|
||||||
|
if e.Code != "" {
|
||||||
|
msg = fmt.Sprintf("%s (%s)", msg, e.Code)
|
||||||
|
}
|
||||||
|
if e.RequestID != "" {
|
||||||
|
msg = fmt.Sprintf("%s [request %s]", msg, e.RequestID)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON sends body as JSON to POST /api/v1/<path> on the running daemon and
|
||||||
|
// decodes a 2xx response into out (out may be nil). A non-2xx response becomes
|
||||||
|
// an error built from the API error envelope. A missing run-file or a stale one
|
||||||
|
// (dead PID) yields a clear "not running" message rather than a
|
||||||
|
// connection-refused dump.
|
||||||
|
func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := runfile.Read(cfg.RunFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
return fmt.Errorf("AO daemon is not running — start it with `ao start`")
|
||||||
|
}
|
||||||
|
if !c.deps.ProcessAlive(info.PID) {
|
||||||
|
return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("http://%s:%d/api/v1/%s", config.LoopbackHost, info.Port, path)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Reuse the injected client's transport (keeps it stubbable in tests) but
|
||||||
|
// give mutating calls far more headroom than the 2s status-probe timeout.
|
||||||
|
client := *c.deps.HTTPClient
|
||||||
|
client.Timeout = commandTimeout
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("call daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
var e apiError
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
|
if e.Message == "" {
|
||||||
|
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", e.String())
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestAPIErrorString covers how the CLI renders the daemon's error envelope,
|
||||||
|
// including the requestId it now surfaces for log correlation.
|
||||||
|
func TestAPIErrorString(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in apiError
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"message only", apiError{Message: "boom"}, "boom"},
|
||||||
|
{"message and code", apiError{Message: "boom", Code: "X"}, "boom (X)"},
|
||||||
|
{"with request id", apiError{Message: "boom", Code: "X", RequestID: "req-1"}, "boom (X) [request req-1]"},
|
||||||
|
{"message and request id", apiError{Message: "boom", RequestID: "req-1"}, "boom [request req-1]"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.in.String(); got != tc.want {
|
||||||
|
t.Fatalf("String() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
// dto_drift_e2e_test.go is the DTO-drift guard for the `ao spawn` and
|
||||||
|
// `ao project add` commands. The CLI defines its OWN request structs
|
||||||
|
// (spawnRequest in spawn.go, addProjectRequest in project.go) that are separate
|
||||||
|
// copies of the daemon's canonical request DTOs (controllers.SpawnSessionRequest
|
||||||
|
// and project.AddInput). Nothing else verifies the two sides agree on JSON field
|
||||||
|
// names — a renamed `json:"..."` tag on either side compiles fine but silently
|
||||||
|
// breaks at runtime.
|
||||||
|
//
|
||||||
|
// This test stands up the REAL daemon HTTP router + REAL controllers (with fakes
|
||||||
|
// only BELOW the controller, at the service layer) and drives the actual CLI
|
||||||
|
// commands through the actual postJSON client over a real loopback HTTP round
|
||||||
|
// trip. If the CLI's JSON field names diverge from what the controllers decode,
|
||||||
|
// the captured values are wrong/empty and the subtests fail.
|
||||||
|
//
|
||||||
|
// (This lives in a separate file from the build-tagged e2e_test.go so it runs in
|
||||||
|
// the normal `go test ./...` lane — it binds no extra ports beyond httptest and
|
||||||
|
// spawns no processes.)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"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"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||||
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||||
|
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSessionService captures the ports.SpawnConfig the controller decodes from
|
||||||
|
// the CLI's request body. Every other method is a no-op so it satisfies the
|
||||||
|
// controllers.SessionService interface.
|
||||||
|
type fakeSessionService struct {
|
||||||
|
spawned ports.SpawnConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ controllers.SessionService = (*fakeSessionService)(nil)
|
||||||
|
|
||||||
|
func (f *fakeSessionService) List(context.Context, sessionsvc.ListFilter) ([]domain.Session, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
|
||||||
|
f.spawned = cfg
|
||||||
|
return domain.Session{
|
||||||
|
SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1")},
|
||||||
|
Status: domain.StatusIdle,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) {
|
||||||
|
return domain.Session{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSessionService) Restore(context.Context, domain.SessionID) (domain.Session, error) {
|
||||||
|
return domain.Session{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeProjectManager captures the project.AddInput the controller decodes from
|
||||||
|
// the CLI's request body. Every other method is a no-op so it satisfies the
|
||||||
|
// projectsvc.Manager interface.
|
||||||
|
type fakeProjectManager struct {
|
||||||
|
added projectsvc.AddInput
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ projectsvc.Manager = (*fakeProjectManager)(nil)
|
||||||
|
|
||||||
|
func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
||||||
|
return projectsvc.GetResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) {
|
||||||
|
f.added = in
|
||||||
|
id := domain.ProjectID("demo")
|
||||||
|
if in.ProjectID != nil {
|
||||||
|
id = domain.ProjectID(*in.ProjectID)
|
||||||
|
}
|
||||||
|
return projectsvc.Project{ID: id, Path: in.Path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) {
|
||||||
|
return projectsvc.RemoveResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDriftTestDaemon stands up the real router+controllers backed by the
|
||||||
|
// supplied fakes and points the CLI's run-file at it. The CLI discovers the
|
||||||
|
// server purely via AO_RUN_FILE + the run-file port, so this is a genuine
|
||||||
|
// loopback round trip through postJSON.
|
||||||
|
func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, projects projectsvc.Manager) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
router := httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{
|
||||||
|
Sessions: sessions,
|
||||||
|
Projects: projects,
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(router)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
port := srv.Listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
rfPath := filepath.Join(t.TempDir(), "running.json")
|
||||||
|
t.Setenv("AO_RUN_FILE", rfPath)
|
||||||
|
if err := runfile.Write(rfPath, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Now()}); err != nil {
|
||||||
|
t.Fatalf("write run-file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
||||||
|
t.Run("spawn", func(t *testing.T) {
|
||||||
|
sessions := &fakeSessionService{}
|
||||||
|
startDriftTestDaemon(t, sessions, &fakeProjectManager{})
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
root := NewRootCommand(Deps{
|
||||||
|
Out: &out,
|
||||||
|
Err: &out,
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
ProcessAlive: func(int) bool { return true },
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{
|
||||||
|
"spawn",
|
||||||
|
"--project", "mer",
|
||||||
|
"--harness", "codex",
|
||||||
|
"--branch", "feat/x",
|
||||||
|
"--prompt", "hi",
|
||||||
|
"--issue", "ISS-1",
|
||||||
|
})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("spawn execute: %v\noutput: %s", err, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := sessions.spawned
|
||||||
|
if got.ProjectID != "mer" {
|
||||||
|
t.Errorf("ProjectID = %q, want %q (CLI json:\"projectId\" vs SpawnSessionRequest)", got.ProjectID, "mer")
|
||||||
|
}
|
||||||
|
if got.Harness != "codex" {
|
||||||
|
t.Errorf("Harness = %q, want %q", got.Harness, "codex")
|
||||||
|
}
|
||||||
|
if got.Branch != "feat/x" {
|
||||||
|
t.Errorf("Branch = %q, want %q", got.Branch, "feat/x")
|
||||||
|
}
|
||||||
|
if got.Prompt != "hi" {
|
||||||
|
t.Errorf("Prompt = %q, want %q", got.Prompt, "hi")
|
||||||
|
}
|
||||||
|
if got.IssueID != "ISS-1" {
|
||||||
|
t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(out.Bytes(), []byte("spawned session")) {
|
||||||
|
t.Errorf("output missing %q; got: %s", "spawned session", out.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("project add", func(t *testing.T) {
|
||||||
|
projects := &fakeProjectManager{}
|
||||||
|
startDriftTestDaemon(t, &fakeSessionService{}, projects)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
root := NewRootCommand(Deps{
|
||||||
|
Out: &out,
|
||||||
|
Err: &out,
|
||||||
|
HTTPClient: &http.Client{},
|
||||||
|
ProcessAlive: func(int) bool { return true },
|
||||||
|
})
|
||||||
|
root.SetArgs([]string{
|
||||||
|
"project", "add",
|
||||||
|
"--path", "/repo/mer",
|
||||||
|
"--id", "demo",
|
||||||
|
"--name", "Demo",
|
||||||
|
})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("project add execute: %v\noutput: %s", err, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := projects.added
|
||||||
|
if got.Path != "/repo/mer" {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, "/repo/mer")
|
||||||
|
}
|
||||||
|
if got.ProjectID == nil || *got.ProjectID != "demo" {
|
||||||
|
t.Errorf("ProjectID = %v, want %q (CLI json:\"projectId\" vs AddInput)", got.ProjectID, "demo")
|
||||||
|
}
|
||||||
|
if got.Name == nil || *got.Name != "Demo" {
|
||||||
|
t.Errorf("Name = %v, want %q", got.Name, "Demo")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(out.Bytes(), []byte("registered project")) {
|
||||||
|
t.Errorf("output missing %q; got: %s", "registered project", out.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectAddOptions struct {
|
||||||
|
path string
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// addProjectRequest mirrors the daemon's project AddInput body for
|
||||||
|
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
|
||||||
|
type addProjectRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ProjectID *string `json:"projectId,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectResult struct {
|
||||||
|
Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"project"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "project",
|
||||||
|
Short: "Manage projects",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newProjectAddCommand(ctx))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectAddCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
var opts projectAddOptions
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Register a local git repo as a project",
|
||||||
|
Long: "Register a local git repo as a project so sessions can be spawned in it.\n\n" +
|
||||||
|
"The path must be an existing git repository on disk.",
|
||||||
|
Args: noArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if opts.path == "" {
|
||||||
|
return usageError{fmt.Errorf("--path is required")}
|
||||||
|
}
|
||||||
|
req := addProjectRequest{Path: opts.path}
|
||||||
|
if opts.id != "" {
|
||||||
|
req.ProjectID = &opts.id
|
||||||
|
}
|
||||||
|
if opts.name != "" {
|
||||||
|
req.Name = &opts.name
|
||||||
|
}
|
||||||
|
var res projectResult
|
||||||
|
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(cmd.OutOrStdout(), "registered project %s at %s\n", res.Project.ID, res.Project.Path)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := cmd.Flags()
|
||||||
|
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
|
||||||
|
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
|
||||||
|
f.StringVar(&opts.name, "name", "", "Display name")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
@ -147,6 +147,8 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
||||||
root.AddCommand(newStopCommand(ctx))
|
root.AddCommand(newStopCommand(ctx))
|
||||||
root.AddCommand(newStatusCommand(ctx))
|
root.AddCommand(newStatusCommand(ctx))
|
||||||
root.AddCommand(newDoctorCommand(ctx))
|
root.AddCommand(newDoctorCommand(ctx))
|
||||||
|
root.AddCommand(newSpawnCommand(ctx))
|
||||||
|
root.AddCommand(newProjectCommand(ctx))
|
||||||
root.AddCommand(newCompletionCommand())
|
root.AddCommand(newCompletionCommand())
|
||||||
root.AddCommand(newVersionCommand())
|
root.AddCommand(newVersionCommand())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
|
||||||
|
)
|
||||||
|
|
||||||
|
type spawnOptions struct {
|
||||||
|
project string
|
||||||
|
harness string
|
||||||
|
branch string
|
||||||
|
prompt string
|
||||||
|
issue string
|
||||||
|
rules string
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
||||||
|
// POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd.
|
||||||
|
type spawnRequest struct {
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
IssueID string `json:"issueId,omitempty"`
|
||||||
|
Harness string `json:"harness,omitempty"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
Prompt string `json:"prompt,omitempty"`
|
||||||
|
AgentRules string `json:"agentRules,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type spawnResult struct {
|
||||||
|
Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||||
|
var opts spawnOptions
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "spawn",
|
||||||
|
Short: "Spawn a worker agent session in a registered project",
|
||||||
|
Long: "Spawn a worker agent session in a registered project.\n\n" +
|
||||||
|
"The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" +
|
||||||
|
"fresh git worktree. Register the project first with `ao project add`.",
|
||||||
|
Args: noArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if opts.project == "" {
|
||||||
|
return usageError{fmt.Errorf("--project is required")}
|
||||||
|
}
|
||||||
|
req := spawnRequest{
|
||||||
|
ProjectID: opts.project,
|
||||||
|
IssueID: opts.issue,
|
||||||
|
Harness: opts.harness,
|
||||||
|
Branch: opts.branch,
|
||||||
|
Prompt: opts.prompt,
|
||||||
|
AgentRules: opts.rules,
|
||||||
|
}
|
||||||
|
var res spawnResult
|
||||||
|
if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
if _, err := fmt.Fprintf(out, "spawned session %s (%s)\n", res.Session.ID, res.Session.Status); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The daemon runs zellij under a short, non-default socket dir (see
|
||||||
|
// zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find
|
||||||
|
// the session — prefix the env so the hint is copy-pasteable. Use the
|
||||||
|
// sanitised name zellij actually registers (zellij.SessionName): a long
|
||||||
|
// session id maps to a different name than the raw id.
|
||||||
|
attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID))
|
||||||
|
if dir := zellij.DefaultSocketDir(); dir != "" {
|
||||||
|
attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach)
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := cmd.Flags()
|
||||||
|
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
||||||
|
f.StringVar(&opts.harness, "harness", "", "Agent harness: claude-code, codex, … (default: the daemon's AO_AGENT)")
|
||||||
|
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>)")
|
||||||
|
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||||
|
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||||
|
f.StringVar(&opts.rules, "rules", "", "Agent rules appended to the prompt")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing
|
||||||
|
// --project before touching the network, so it fails fast without a daemon.
|
||||||
|
func TestSpawnCommand_RequiresProject(t *testing.T) {
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
root := NewRootCommand(Deps{Out: &out, Err: &errb})
|
||||||
|
root.SetArgs([]string{"spawn"})
|
||||||
|
err := root.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when --project is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--project is required") {
|
||||||
|
t.Fatalf("error = %v, want it to mention --project is required", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectAddCommand_RequiresPath asserts `ao project add` rejects a missing
|
||||||
|
// --path before touching the network.
|
||||||
|
func TestProjectAddCommand_RequiresPath(t *testing.T) {
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
root := NewRootCommand(Deps{Out: &out, Err: &errb})
|
||||||
|
root.SetArgs([]string{"project", "add"})
|
||||||
|
err := root.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when --path is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--path is required") {
|
||||||
|
t.Fatalf("error = %v, want it to mention --path is required", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,17 @@ func Run() error {
|
||||||
// liveness; the CDC broadcaster feeds the session-state channel. The manager
|
// liveness; the CDC broadcaster feeds the session-state channel. The manager
|
||||||
// is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow
|
// is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow
|
||||||
// through the CDC change_log — only session-state events do.
|
// through the CDC change_log — only session-state events do.
|
||||||
runtimeAdapter := zellij.New(zellij.Options{})
|
// zellij's default socket dir is too long on macOS for long session ids
|
||||||
|
// (see zellij.DefaultSocketDir); use a short, stable one and ensure it exists.
|
||||||
|
zellijSocketDir := zellij.DefaultSocketDir()
|
||||||
|
if zellijSocketDir != "" {
|
||||||
|
if err := os.MkdirAll(zellijSocketDir, 0o700); err != nil {
|
||||||
|
// Don't abort startup, but surface it: every spawn's zellij session
|
||||||
|
// would otherwise fail later with an opaque socket-bind error.
|
||||||
|
log.Warn("could not create zellij socket dir; spawns may fail", "dir", zellijSocketDir, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtimeAdapter := zellij.New(zellij.Options{SocketDir: zellijSocketDir})
|
||||||
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
|
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
|
||||||
defer termMgr.Close()
|
defer termMgr.Close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,10 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store,
|
||||||
// Per-session worktrees live under the data dir, so a single AO_DATA_DIR
|
// Per-session worktrees live under the data dir, so a single AO_DATA_DIR
|
||||||
// override moves all durable per-user state together.
|
// override moves all durable per-user state together.
|
||||||
ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"),
|
ManagedRoot: filepath.Join(cfg.DataDir, "worktrees"),
|
||||||
// An empty resolver fails every project lookup with a clear
|
// Resolve each project's source repo from the projects table, so a
|
||||||
// "no repo configured for project" error until the projects table feeds
|
// session spawned for a registered project materialises its worktree off
|
||||||
// repo paths in — better than silently misrouting spawns.
|
// that repo. Unregistered projects fail loudly.
|
||||||
RepoResolver: gitworktree.StaticRepoResolver{},
|
RepoResolver: projectRepoResolver{store: store},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("session workspace: %w", err)
|
return nil, fmt.Errorf("session workspace: %w", err)
|
||||||
|
|
@ -145,3 +145,28 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
|
||||||
log.Info("built per-session agent resolver", "default", defaultAgent, "registered", ids)
|
log.Info("built per-session agent resolver", "default", defaultAgent, "registered", ids)
|
||||||
return resolver, nil
|
return resolver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectRepoResolver resolves a project's on-disk repo path from the projects
|
||||||
|
// table so gitworktree can materialise per-session worktrees off it. It replaces
|
||||||
|
// the empty StaticRepoResolver the daemon used before (which failed every
|
||||||
|
// lookup), turning a registered project into a spawnable one.
|
||||||
|
type projectRepoResolver struct{ store *sqlite.Store }
|
||||||
|
|
||||||
|
var _ gitworktree.RepoResolver = projectRepoResolver{}
|
||||||
|
|
||||||
|
func (r projectRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) {
|
||||||
|
rec, ok, err := r.store.GetProject(context.Background(), string(projectID))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("look up project %q: %w", projectID, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no project registered with id %q — add one with `ao project add`: %w", projectID, sessionmanager.ErrProjectNotResolvable)
|
||||||
|
}
|
||||||
|
if !rec.ArchivedAt.IsZero() {
|
||||||
|
return "", fmt.Errorf("project %q is archived: %w", projectID, sessionmanager.ErrProjectNotResolvable)
|
||||||
|
}
|
||||||
|
if rec.Path == "" {
|
||||||
|
return "", fmt.Errorf("project %q has no repo path on record: %w", projectID, sessionmanager.ErrProjectNotResolvable)
|
||||||
|
}
|
||||||
|
return rec.Path, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package daemon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -15,6 +16,7 @@ import (
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
"github.com/aoagents/agent-orchestrator/backend/internal/lifecycle"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||||
|
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -132,3 +134,56 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) {
|
||||||
t.Fatal("startSession returned nil session service")
|
t.Fatal("startSession returned nil session service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectRepoResolver_ResolvesRegisteredProject asserts the DB-backed repo
|
||||||
|
// resolver turns a registered project into its on-disk repo path (so spawns
|
||||||
|
// materialise a worktree), and fails loudly for an unregistered project.
|
||||||
|
func TestProjectRepoResolver_ResolvesRegisteredProject(t *testing.T) {
|
||||||
|
store, err := sqlite.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := projectRepoResolver{store: store}
|
||||||
|
got, err := r.RepoPath("mer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RepoPath(mer): %v", err)
|
||||||
|
}
|
||||||
|
if got != "/repo/mer" {
|
||||||
|
t.Fatalf("RepoPath(mer) = %q, want /repo/mer", got)
|
||||||
|
}
|
||||||
|
_, err = r.RepoPath("nope")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for an unregistered project")
|
||||||
|
}
|
||||||
|
// Guard the sentinel wrapping so the HTTP 400 mapping can't silently regress.
|
||||||
|
if !errors.Is(err, sessionmanager.ErrProjectNotResolvable) {
|
||||||
|
t.Fatalf("unregistered-project error should wrap ErrProjectNotResolvable, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDaemonZellijSocketDir_LeavesBudgetForSessionNames guards the fix for the
|
||||||
|
// zellij "session name must be less than 0 characters" spawn failure: the
|
||||||
|
// daemon's socket dir must be short enough that a max-length (48-char) session
|
||||||
|
// name still fits the ~103-byte unix-domain-socket-path budget. zellij's long
|
||||||
|
// $TMPDIR default (the bug) would fail this.
|
||||||
|
func TestDaemonZellijSocketDir_LeavesBudgetForSessionNames(t *testing.T) {
|
||||||
|
dir := zellij.DefaultSocketDir()
|
||||||
|
if dir == "" {
|
||||||
|
t.Skip("zellij not used on this platform")
|
||||||
|
}
|
||||||
|
const (
|
||||||
|
unixSocketPathMax = 103 // sun_path budget zellij enforces on macOS
|
||||||
|
zellijOverhead = 24 // zellij's version subdir + separators (generous)
|
||||||
|
maxSessionName = 48 // zellijSessionName's cap
|
||||||
|
)
|
||||||
|
if budget := unixSocketPathMax - len(dir) - zellijOverhead; budget < maxSessionName {
|
||||||
|
t.Fatalf("zellij socket dir %q too long: %d bytes left for the session name, need >= %d", dir, budget, maxSessionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,8 @@ func writeSessionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil)
|
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_NOT_RESTORABLE", "Session is not restorable", nil)
|
||||||
case errors.Is(err, sessionmanager.ErrIncompleteHandle):
|
case errors.Is(err, sessionmanager.ErrIncompleteHandle):
|
||||||
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil)
|
envelope.WriteAPIError(w, r, http.StatusConflict, "conflict", "SESSION_INCOMPLETE_HANDLE", "Session is missing runtime or workspace handles", nil)
|
||||||
|
case errors.Is(err, sessionmanager.ErrProjectNotResolvable):
|
||||||
|
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
|
||||||
default:
|
default:
|
||||||
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil)
|
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SESSION_OPERATION_FAILED", "Session operation failed", nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,10 @@ var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
|
||||||
func validateProjectID(id domain.ProjectID) error {
|
func validateProjectID(id domain.ProjectID) error {
|
||||||
raw := string(id)
|
raw := string(id)
|
||||||
if raw == "" || raw == "." || raw == ".." || strings.ContainsAny(raw, `/\`) || !projectIDPattern.MatchString(raw) {
|
// 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 badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil)
|
return badRequest("INVALID_PROJECT_ID", "Project id failed storage-path validation", nil)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,11 @@ func TestManager_AddValidationAndConflicts(t *testing.T) {
|
||||||
_, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo
|
_, err = m.Add(ctx, project.AddInput{Path: t.TempDir()}) // exists but not a git repo
|
||||||
wantCode(t, err, "NOT_A_GIT_REPO")
|
wantCode(t, err, "NOT_A_GIT_REPO")
|
||||||
|
|
||||||
|
// An embedded ".." passes the id pattern but would yield an invalid git
|
||||||
|
// branch (ao/a..b-1) at spawn time; reject it up front as a clear 400.
|
||||||
|
_, err = m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("a..b")})
|
||||||
|
wantCode(t, err, "INVALID_PROJECT_ID")
|
||||||
|
|
||||||
repoA, repoB := gitRepo(t), gitRepo(t)
|
repoA, repoB := gitRepo(t), gitRepo(t)
|
||||||
if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil {
|
if _, err := m.Add(ctx, project.AddInput{Path: repoA, ProjectID: ptr("shared")}); err != nil {
|
||||||
t.Fatalf("seed add: %v", err)
|
t.Fatalf("seed add: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ var (
|
||||||
ErrNotFound = errors.New("session: not found")
|
ErrNotFound = errors.New("session: not found")
|
||||||
ErrNotRestorable = errors.New("session: not restorable (not terminal)")
|
ErrNotRestorable = errors.New("session: not restorable (not terminal)")
|
||||||
ErrIncompleteHandle = errors.New("session: incomplete teardown handle")
|
ErrIncompleteHandle = errors.New("session: incomplete teardown handle")
|
||||||
|
// ErrProjectNotResolvable means the spawn's project has no usable repo
|
||||||
|
// (unregistered, archived, or missing a path). The API maps it to a 400.
|
||||||
|
ErrProjectNotResolvable = errors.New("session: project repo not resolvable")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Env vars a spawned process reads to learn who it is.
|
// Env vars a spawned process reads to learn who it is.
|
||||||
|
|
@ -101,7 +104,14 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
||||||
}
|
}
|
||||||
id := rec.ID
|
id := rec.ID
|
||||||
|
|
||||||
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: cfg.Branch})
|
branch := cfg.Branch
|
||||||
|
if branch == "" {
|
||||||
|
// A fresh, unique branch per session: gitworktree can't add a worktree on
|
||||||
|
// a branch already checked out elsewhere (e.g. main), so default to one
|
||||||
|
// derived from the assigned session id.
|
||||||
|
branch = "ao/" + string(id)
|
||||||
|
}
|
||||||
|
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: branch})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.markSpawnFailedTerminated(ctx, id)
|
m.markSpawnFailedTerminated(ctx, id)
|
||||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
|
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
|
||||||
|
|
|
||||||
|
|
@ -245,3 +245,26 @@ func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) {
|
||||||
t.Fatal("live workspace must not be destroyed")
|
t.Fatal("live workspace must not be destroyed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) {
|
||||||
|
m, st, _, _ := newManager()
|
||||||
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An empty SpawnConfig.Branch defaults to a unique per-session branch.
|
||||||
|
if got := st.sessions[s.ID].Metadata.Branch; got != "ao/mer-1" {
|
||||||
|
t.Fatalf("default branch = %q, want ao/mer-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawn_KeepsExplicitBranch(t *testing.T) {
|
||||||
|
m, st, _, _ := newManager()
|
||||||
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := st.sessions[s.ID].Metadata.Branch; got != "feature/x" {
|
||||||
|
t.Fatalf("explicit branch = %q, want feature/x", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue