From 3363dc1e61d5319c05f5f55574f0f5e4aa28e932 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Thu, 11 Jun 2026 16:18:54 +0530 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20wire=20the=20core=20workflows=20?= =?UTF-8?q?=E2=80=94=20daemon=20status,=20orchestrator=20attach,=20git=20r?= =?UTF-8?q?ail,=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four core-workflow gaps closed end to end: Daemon status (Electron main): - Discover an externally-started daemon by polling running.json and confirming /healthz identity, so the status pill reflects the daemon actually serving the renderer instead of reporting "stopped" forever. - Auto-start supervision at launch when AO_DAEMON_COMMAND is set; dedupe status broadcasts. Orchestrator (frontend): - Split orchestrator sessions out of the worker list; the orchestrator view attaches the selected project's orchestrator terminal and offers a Start-orchestrator empty state wired to POST /orchestrators. Git review rail (backend + frontend): - New WorkspaceGit port + git-CLI adapter (status/stage/discard/commit+push) with integration tests against real repos. - Session service + routes: GET /sessions/{id}/git, POST git/stage, git/discard, git/commit, with typed GIT_NOTHING_TO_COMMIT / GIT_NO_REMOTE errors; OpenAPI + TS types regenerated. - Session read model now carries the worktree branch. - GitRail shows live changed files, stage-all, armed discard-all, Commit & Push; Create PR is explicitly disabled until the PR lane exists. Lifecycle (frontend): - Right-click context menus: kill/restore workers, new-worker/cleanup/ remove-project with two-step destructive confirm and inline errors. Co-Authored-By: Claude Fable 5 --- .../adapters/workspace/gitworktree/gitops.go | 199 ++++++++++ .../gitworktree/gitops_integration_test.go | 151 ++++++++ backend/internal/cli/dto_drift_e2e_test.go | 16 + backend/internal/daemon/lifecycle_wiring.go | 1 + backend/internal/domain/session.go | 3 + backend/internal/httpd/apispec/openapi.yaml | 264 ++++++++++++++ .../internal/httpd/apispec/specgen/build.go | 55 +++ backend/internal/httpd/controllers/dto.go | 36 ++ .../internal/httpd/controllers/sessions.go | 76 ++++ .../httpd/controllers/sessions_test.go | 136 +++++++ backend/internal/ports/outbound.go | 43 +++ backend/internal/service/session/git.go | 92 +++++ backend/internal/service/session/service.go | 10 +- frontend/src/api/schema.ts | 344 ++++++++++++++++++ frontend/src/main.ts | 64 +++- frontend/src/renderer/App.test.tsx | 3 +- frontend/src/renderer/App.tsx | 74 +++- .../src/renderer/components/CenterPane.tsx | 93 ++++- frontend/src/renderer/components/SideRail.tsx | 142 ++++++-- frontend/src/renderer/components/Sidebar.tsx | 70 +++- .../renderer/components/ui/context-menu.tsx | 133 +++++++ frontend/src/renderer/hooks/useSessionGit.ts | 101 +++++ .../src/renderer/hooks/useWorkspaceQuery.ts | 45 ++- frontend/src/renderer/lib/api-errors.ts | 18 + frontend/src/renderer/types/workspace.ts | 3 + frontend/src/shared/daemon-discovery.test.ts | 40 +- frontend/src/shared/daemon-discovery.ts | 17 + frontend/src/shared/daemon-status.ts | 5 + 28 files changed, 2143 insertions(+), 91 deletions(-) create mode 100644 backend/internal/adapters/workspace/gitworktree/gitops.go create mode 100644 backend/internal/adapters/workspace/gitworktree/gitops_integration_test.go create mode 100644 backend/internal/service/session/git.go create mode 100644 frontend/src/renderer/components/ui/context-menu.tsx create mode 100644 frontend/src/renderer/hooks/useSessionGit.ts create mode 100644 frontend/src/renderer/lib/api-errors.ts diff --git a/backend/internal/adapters/workspace/gitworktree/gitops.go b/backend/internal/adapters/workspace/gitworktree/gitops.go new file mode 100644 index 0000000..6815f19 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/gitops.go @@ -0,0 +1,199 @@ +package gitworktree + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// GitOps implements ports.WorkspaceGit over the git CLI. It shares the +// adapter's exec conventions (runCommand) but operates inside an existing +// session workspace rather than managing worktree lifecycles. +type GitOps struct { + gitBinary string +} + +// NewGitOps returns a WorkspaceGit adapter using the `git` on PATH. +func NewGitOps() *GitOps { + return &GitOps{gitBinary: defaultGitBinary} +} + +var _ ports.WorkspaceGit = (*GitOps)(nil) + +func (g *GitOps) git(ctx context.Context, dir string, args ...string) ([]byte, error) { + return runCommand(ctx, g.gitBinary, append([]string{"-C", dir}, args...)...) +} + +// Status reports the workspace branch and its uncommitted files. Line counts +// come from `diff --numstat` for tracked files and a direct line count for +// untracked ones; exotic paths that fail the numstat lookup degrade to 0/0 +// rather than failing the whole status. +func (g *GitOps) Status(ctx context.Context, path string) (ports.GitStatus, error) { + branchOut, err := g.git(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return ports.GitStatus{}, fmt.Errorf("gitops: resolve branch: %w", err) + } + branch := strings.TrimSpace(string(branchOut)) + + porcelain, err := g.git(ctx, path, "status", "--porcelain", "-z") + if err != nil { + return ports.GitStatus{}, fmt.Errorf("gitops: status: %w", err) + } + + // One numstat against HEAD covers staged and unstaged edits together. It + // can fail legitimately (unborn HEAD); porcelain still lists files then. + counts := map[string][2]int{} + if out, err := g.git(ctx, path, "diff", "--numstat", "HEAD", "--"); err == nil { + mergeNumstat(counts, out) + } + + status := ports.GitStatus{Branch: branch, Files: []ports.GitFileChange{}} + entries := strings.Split(string(porcelain), "\x00") + for i := 0; i < len(entries); i++ { + entry := entries[i] + if len(entry) < 4 { + continue + } + xy, filePath := entry[:2], entry[3:] + // Renames/copies carry the original path as the next NUL entry. + if xy[0] == 'R' || xy[0] == 'C' { + i++ + } + change := ports.GitFileChange{ + Path: filePath, + Staged: xy[0] != ' ' && xy[0] != '?', + } + if c, ok := counts[filePath]; ok { + change.Additions, change.Deletions = c[0], c[1] + } else if xy == "??" { + change.Additions = countFileLines(filepath.Join(path, filePath)) + } + status.Files = append(status.Files, change) + } + return status, nil +} + +// StageAll stages every change in the workspace, including untracked files. +func (g *GitOps) StageAll(ctx context.Context, path string) error { + if _, err := g.git(ctx, path, "add", "-A"); err != nil { + return fmt.Errorf("gitops: stage all: %w", err) + } + return nil +} + +// DiscardAll resets tracked files to HEAD and removes untracked files and +// directories. Destructive by design; callers own the confirmation UX. +func (g *GitOps) DiscardAll(ctx context.Context, path string) error { + if _, err := g.git(ctx, path, "reset", "--hard", "HEAD"); err != nil { + return fmt.Errorf("gitops: reset: %w", err) + } + if _, err := g.git(ctx, path, "clean", "-fd"); err != nil { + return fmt.Errorf("gitops: clean: %w", err) + } + return nil +} + +// CommitAll stages everything, commits, and (optionally) pushes the branch to +// the repo's remote, preferring "origin" when several exist. A push request +// against a remoteless repo returns ErrGitNoRemote with the commit SHA in the +// message — the commit itself has already landed. +func (g *GitOps) CommitAll(ctx context.Context, path, message string, push bool) (ports.GitCommitResult, error) { + porcelain, err := g.git(ctx, path, "status", "--porcelain") + if err != nil { + return ports.GitCommitResult{}, fmt.Errorf("gitops: status: %w", err) + } + if strings.TrimSpace(string(porcelain)) == "" { + return ports.GitCommitResult{}, ports.ErrGitNothingToCommit + } + + if err := g.StageAll(ctx, path); err != nil { + return ports.GitCommitResult{}, err + } + if _, err := g.git(ctx, path, "commit", "-m", message); err != nil { + return ports.GitCommitResult{}, fmt.Errorf("gitops: commit: %w", err) + } + + shaOut, err := g.git(ctx, path, "rev-parse", "HEAD") + if err != nil { + return ports.GitCommitResult{}, fmt.Errorf("gitops: resolve sha: %w", err) + } + branchOut, err := g.git(ctx, path, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return ports.GitCommitResult{}, fmt.Errorf("gitops: resolve branch: %w", err) + } + result := ports.GitCommitResult{ + SHA: strings.TrimSpace(string(shaOut)), + Branch: strings.TrimSpace(string(branchOut)), + } + if !push { + return result, nil + } + + remote, err := g.pickRemote(ctx, path) + if err != nil { + return result, fmt.Errorf("gitops: committed %s: %w", result.SHA, err) + } + if _, err := g.git(ctx, path, "push", "--set-upstream", remote, "HEAD"); err != nil { + return result, fmt.Errorf("gitops: committed %s, push failed: %w", result.SHA, err) + } + result.Pushed = true + return result, nil +} + +func (g *GitOps) pickRemote(ctx context.Context, path string) (string, error) { + out, err := g.git(ctx, path, "remote") + if err != nil { + return "", fmt.Errorf("list remotes: %w", err) + } + remotes := strings.Fields(string(out)) + if len(remotes) == 0 { + return "", ports.ErrGitNoRemote + } + for _, remote := range remotes { + if remote == "origin" { + return remote, nil + } + } + return remotes[0], nil +} + +// mergeNumstat folds `diff --numstat` output ("adds\tdels\tpath" per line) +// into counts, summing across calls. Binary files report "-" and count as 0. +func mergeNumstat(counts map[string][2]int, out []byte) { + for _, line := range strings.Split(string(out), "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) != 3 { + continue + } + adds, _ := strconv.Atoi(parts[0]) + dels, _ := strconv.Atoi(parts[1]) + existing := counts[parts[2]] + counts[parts[2]] = [2]int{existing[0] + adds, existing[1] + dels} + } +} + +// countFileLines sizes an untracked file's "+N" the way numstat would once +// staged. Unreadable or oversized files degrade to 0 rather than erroring. +const maxCountableFileBytes = 4 << 20 + +func countFileLines(path string) int { + info, err := os.Stat(path) + if err != nil || info.IsDir() || info.Size() > maxCountableFileBytes { + return 0 + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return 0 + } + lines := bytes.Count(data, []byte("\n")) + if data[len(data)-1] != '\n' { + lines++ + } + return lines +} diff --git a/backend/internal/adapters/workspace/gitworktree/gitops_integration_test.go b/backend/internal/adapters/workspace/gitworktree/gitops_integration_test.go new file mode 100644 index 0000000..5886e50 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/gitops_integration_test.go @@ -0,0 +1,151 @@ +package gitworktree + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGitOpsIntegrationStatusCommitPush(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + runGit(t, git, repo, "config", "user.email", "ao@example.com") + runGit(t, git, repo, "config", "user.name", "Ao Agents") + + ops := NewGitOps() + ctx := context.Background() + + // Clean workspace: branch resolved, no files, commit refuses. + status, err := ops.Status(ctx, repo) + if err != nil { + t.Fatalf("status clean: %v", err) + } + if status.Branch != "main" || len(status.Files) != 0 { + t.Fatalf("clean status = %+v", status) + } + if _, err := ops.CommitAll(ctx, repo, "noop", false); !errors.Is(err, ports.ErrGitNothingToCommit) { + t.Fatalf("commit on clean tree = %v, want ErrGitNothingToCommit", err) + } + + // Tracked edit + untracked file show up with line counts and staged flags. + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("seed\nmore\n"), 0o644); err != nil { + t.Fatalf("edit README: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, "notes.txt"), []byte("a\nb\nc\n"), 0o644); err != nil { + t.Fatalf("write notes: %v", err) + } + status, err = ops.Status(ctx, repo) + if err != nil { + t.Fatalf("status dirty: %v", err) + } + if len(status.Files) != 2 { + t.Fatalf("dirty files = %+v", status.Files) + } + byPath := map[string]ports.GitFileChange{} + for _, f := range status.Files { + byPath[f.Path] = f + } + if f := byPath["README.md"]; f.Additions != 1 || f.Staged { + t.Fatalf("README change = %+v", f) + } + if f := byPath["notes.txt"]; f.Additions != 3 || f.Staged { + t.Fatalf("notes change = %+v", f) + } + + // StageAll flips the staged flag. + if err := ops.StageAll(ctx, repo); err != nil { + t.Fatalf("stage all: %v", err) + } + status, err = ops.Status(ctx, repo) + if err != nil { + t.Fatalf("status staged: %v", err) + } + for _, f := range status.Files { + if !f.Staged { + t.Fatalf("file not staged after StageAll: %+v", f) + } + } + + // CommitAll with push lands on the origin remote. + result, err := ops.CommitAll(ctx, repo, "feat: rail test", true) + if err != nil { + t.Fatalf("commit+push: %v", err) + } + if result.SHA == "" || result.Branch != "main" || !result.Pushed { + t.Fatalf("commit result = %+v", result) + } + status, err = ops.Status(ctx, repo) + if err != nil { + t.Fatalf("status after commit: %v", err) + } + if len(status.Files) != 0 { + t.Fatalf("files after commit = %+v", status.Files) + } +} + +func TestGitOpsIntegrationDiscardAll(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := setupOriginClone(t, git, tmp) + + ops := NewGitOps() + ctx := context.Background() + + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("clobbered\n"), 0o644); err != nil { + t.Fatalf("edit README: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, "junk.txt"), []byte("junk\n"), 0o644); err != nil { + t.Fatalf("write junk: %v", err) + } + + if err := ops.DiscardAll(ctx, repo); err != nil { + t.Fatalf("discard: %v", err) + } + status, err := ops.Status(ctx, repo) + if err != nil { + t.Fatalf("status after discard: %v", err) + } + if len(status.Files) != 0 { + t.Fatalf("files after discard = %+v", status.Files) + } + contents, err := os.ReadFile(filepath.Join(repo, "README.md")) + if err != nil || string(contents) != "seed\n" { + t.Fatalf("README after discard = %q, %v", contents, err) + } + if _, err := os.Stat(filepath.Join(repo, "junk.txt")); !os.IsNotExist(err) { + t.Fatalf("junk.txt still present after discard: %v", err) + } +} + +func TestGitOpsIntegrationCommitPushWithoutRemote(t *testing.T) { + git := requireGit(t) + tmp := t.TempDir() + repo := filepath.Join(tmp, "local") + run(t, git, "init", repo) + runGit(t, git, repo, "config", "user.email", "ao@example.com") + runGit(t, git, repo, "config", "user.name", "Ao Agents") + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + runGit(t, git, repo, "add", "-A") + runGit(t, git, repo, "commit", "-m", "seed") + + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatalf("edit: %v", err) + } + + ops := NewGitOps() + result, err := ops.CommitAll(context.Background(), repo, "feat: local", true) + if !errors.Is(err, ports.ErrGitNoRemote) { + t.Fatalf("push without remote = %v, want ErrGitNoRemote", err) + } + // The commit itself landed; only the push was impossible. + if result.SHA == "" || result.Pushed { + t.Fatalf("result = %+v", result) + } +} diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 2488d8f..cafff4a 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -102,6 +102,22 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string, return sessionsvc.ClaimPRResult{}, nil } +func (f *fakeSessionService) GitStatus(context.Context, domain.SessionID) (ports.GitStatus, error) { + return ports.GitStatus{}, nil +} + +func (f *fakeSessionService) GitStageAll(context.Context, domain.SessionID) error { + return nil +} + +func (f *fakeSessionService) GitDiscardAll(context.Context, domain.SessionID) error { + return nil +} + +func (f *fakeSessionService) GitCommitAll(context.Context, domain.SessionID, string, bool) (ports.GitCommitResult, error) { + return ports.GitCommitResult{}, 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. diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index b4d4090..9a3a0ed 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -93,6 +93,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, // no_signal only makes sense for harnesses whose adapters install // activity hooks; the deriver registry is the source of truth for that. SignalCapable: activitydispatch.SupportsHarness, + WorkspaceGit: gitworktree.NewGitOps(), }), nil } diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 7e289b3..be93d30 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -61,4 +61,7 @@ type Session struct { SessionRecord Status SessionStatus `json:"status"` TerminalHandleID string `json:"terminalHandleId,omitempty"` + // Branch is the session's worktree branch, surfaced from Metadata (which + // stays internal) so the UI's git rail can label the workspace. + Branch string `json:"branch,omitempty"` } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a5f472e..07a8a02 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -640,6 +640,198 @@ paths: summary: Report an agent activity-state signal for a session tags: - sessions + /api/v1/sessions/{sessionId}/git: + get: + operationId: getSessionGitStatus + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SessionGitStatusResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Inspect a session workspace's branch and uncommitted files + tags: + - sessions + /api/v1/sessions/{sessionId}/git/commit: + post: + operationId: commitSessionGit + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GitCommitRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/GitCommitResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Commit all changes in a session workspace, optionally pushing the branch + tags: + - sessions + /api/v1/sessions/{sessionId}/git/discard: + post: + operationId: discardSessionGit + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/GitActionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Discard all uncommitted changes in a session workspace + tags: + - sessions + /api/v1/sessions/{sessionId}/git/stage: + post: + operationId: stageSessionGit + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/GitActionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Stage all changes in a session workspace + tags: + - sessions /api/v1/sessions/{sessionId}/kill: post: operationId: killSession @@ -1077,6 +1269,61 @@ components: - state - lastActivityAt type: object + GitActionResponse: + properties: + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + type: object + GitCommitRequest: + properties: + message: + minLength: 1 + type: string + push: + type: boolean + required: + - message + type: object + GitCommitResponse: + properties: + branch: + type: string + ok: + type: boolean + pushed: + type: boolean + sessionId: + type: string + sha: + type: string + required: + - ok + - sessionId + - sha + - branch + - pushed + type: object + GitFileChange: + properties: + additions: + type: integer + deletions: + type: integer + path: + type: string + staged: + type: boolean + required: + - path + - additions + - deletions + - staged + type: object KillSessionResponse: properties: freed: @@ -1346,6 +1593,8 @@ components: properties: activity: $ref: '#/components/schemas/DomainActivity' + branch: + type: string createdAt: format: date-time type: string @@ -1380,6 +1629,21 @@ components: - updatedAt - status type: object + SessionGitStatusResponse: + properties: + branch: + type: string + files: + items: + $ref: '#/components/schemas/GitFileChange' + type: array + sessionId: + type: string + required: + - sessionId + - branch + - files + type: object SessionPRFacts: properties: ci: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index c198edd..e401ced 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -153,6 +153,11 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + "ControllersGitFileChange": "GitFileChange", + "ControllersSessionGitStatusResponse": "SessionGitStatusResponse", + "ControllersGitActionResponse": "GitActionResponse", + "ControllersGitCommitRequest": "GitCommitRequest", + "ControllersGitCommitResponse": "GitCommitResponse", // httpd/controllers — PR wire envelopes "ControllersMergePRResponse": "MergePRResponse", "ControllersResolveCommentsRequest": "ResolveCommentsRequest", @@ -466,6 +471,56 @@ func sessionOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/git", id: "getSessionGitStatus", tag: "sessions", + summary: "Inspect a session workspace's branch and uncommitted files", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.SessionGitStatusResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/git/stage", id: "stageSessionGit", tag: "sessions", + summary: "Stage all changes in a session workspace", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.GitActionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/git/discard", id: "discardSessionGit", tag: "sessions", + summary: "Discard all uncommitted changes in a session workspace", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.GitActionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/git/commit", id: "commitSessionGit", tag: "sessions", + summary: "Commit all changes in a session workspace, optionally pushing the branch", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.GitCommitRequest{}, + resps: []respUnit{ + {http.StatusOK, controllers.GitCommitResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, { method: http.MethodGet, path: "/api/v1/orchestrators", id: "listOrchestrators", tag: "sessions", summary: "List orchestrator sessions across projects", diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 557c42e..1f3d415 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -212,6 +212,42 @@ type ListSessionPRsResponse struct { PRs []SessionPRFacts `json:"prs"` } +// GitFileChange is one changed path in a session workspace's diff. +type GitFileChange struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Staged bool `json:"staged"` +} + +// SessionGitStatusResponse is the body of GET /sessions/{sessionId}/git. +type SessionGitStatusResponse struct { + SessionID domain.SessionID `json:"sessionId"` + Branch string `json:"branch"` + Files []GitFileChange `json:"files"` +} + +// GitActionResponse is the body of the stage/discard git routes. +type GitActionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` +} + +// GitCommitRequest is the body of POST /sessions/{sessionId}/git/commit. +type GitCommitRequest struct { + Message string `json:"message" minLength:"1"` + Push bool `json:"push,omitempty"` +} + +// GitCommitResponse is the body of POST /sessions/{sessionId}/git/commit. +type GitCommitResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + SHA string `json:"sha"` + Branch string `json:"branch"` + Pushed bool `json:"pushed"` +} + // ClaimPRRequest is the body of POST /sessions/{sessionId}/pr/claim. type ClaimPRRequest struct { PR string `json:"pr" minLength:"1"` diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index bf03358..f19bd83 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -36,6 +36,10 @@ type SessionService interface { Send(ctx context.Context, id domain.SessionID, message string) error ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) + GitStatus(ctx context.Context, id domain.SessionID) (ports.GitStatus, error) + GitStageAll(ctx context.Context, id domain.SessionID) error + GitDiscardAll(ctx context.Context, id domain.SessionID) error + GitCommitAll(ctx context.Context, id domain.SessionID, message string, push bool) (ports.GitCommitResult, error) } // ActivityRecorder applies an agent activity-state signal to a session. It is @@ -68,6 +72,10 @@ func (c *SessionsController) Register(r chi.Router) { r.Post("/sessions/{sessionId}/rollback", c.rollback) r.Post("/sessions/{sessionId}/send", c.send) r.Post("/sessions/{sessionId}/activity", c.activity) + r.Get("/sessions/{sessionId}/git", c.gitStatus) + r.Post("/sessions/{sessionId}/git/stage", c.gitStage) + r.Post("/sessions/{sessionId}/git/discard", c.gitDiscard) + r.Post("/sessions/{sessionId}/git/commit", c.gitCommit) r.Get("/orchestrators", c.listOrchestrators) r.Post("/orchestrators", c.spawnOrchestrator) r.Get("/orchestrators/{id}", c.getOrchestrator) @@ -310,6 +318,74 @@ func (c *SessionsController) activity(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, SetActivityResponse{OK: true, SessionID: sessionID(r), State: in.State}) } +func (c *SessionsController) gitStatus(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/git") + return + } + status, err := c.Svc.GitStatus(r.Context(), sessionID(r)) + if err != nil { + envelope.WriteError(w, r, err) + return + } + files := make([]GitFileChange, 0, len(status.Files)) + for _, file := range status.Files { + files = append(files, GitFileChange{Path: file.Path, Additions: file.Additions, Deletions: file.Deletions, Staged: file.Staged}) + } + envelope.WriteJSON(w, http.StatusOK, SessionGitStatusResponse{SessionID: sessionID(r), Branch: status.Branch, Files: files}) +} + +func (c *SessionsController) gitStage(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/git/stage") + return + } + if err := c.Svc.GitStageAll(r.Context(), sessionID(r)); err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, GitActionResponse{OK: true, SessionID: sessionID(r)}) +} + +func (c *SessionsController) gitDiscard(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/git/discard") + return + } + if err := c.Svc.GitDiscardAll(r.Context(), sessionID(r)); err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, GitActionResponse{OK: true, SessionID: sessionID(r)}) +} + +func (c *SessionsController) gitCommit(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/git/commit") + return + } + var in GitCommitRequest + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + message := strings.TrimSpace(in.Message) + if message == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_REQUIRED", "Commit message is required", nil) + return + } + if len(message) > maxMessageLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "MESSAGE_TOO_LONG", "Commit message is too long", nil) + return + } + result, err := c.Svc.GitCommitAll(r.Context(), sessionID(r), message, in.Push) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, GitCommitResponse{OK: true, SessionID: sessionID(r), SHA: result.SHA, Branch: result.Branch, Pushed: result.Pushed}) +} + func (c *SessionsController) spawnOrchestrator(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { apispec.NotImplemented(w, r, "POST", "/api/v1/orchestrators") diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 3bc53b6..03c74c2 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -25,6 +25,12 @@ type fakeSessionService struct { cleanupSkipped []sessionsvc.CleanupSkipped claimErr error listPRErr error + gitStatus ports.GitStatus + gitErr error + gitStaged []domain.SessionID + gitDiscarded []domain.SessionID + gitCommits []string + gitPushes []bool } func newFakeSessionService() *fakeSessionService { @@ -146,6 +152,44 @@ func (f *fakeSessionService) ClaimPR(_ context.Context, id domain.SessionID, ref return sessionsvc.ClaimPRResult{PRs: prs, TakenOverFrom: []domain.SessionID{}, BranchChanged: true}, nil } +func (f *fakeSessionService) GitStatus(_ context.Context, id domain.SessionID) (ports.GitStatus, error) { + if f.gitErr != nil { + return ports.GitStatus{}, f.gitErr + } + if _, ok := f.sessions[id]; !ok { + return ports.GitStatus{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + return f.gitStatus, nil +} + +func (f *fakeSessionService) GitStageAll(_ context.Context, id domain.SessionID) error { + if f.gitErr != nil { + return f.gitErr + } + f.gitStaged = append(f.gitStaged, id) + return nil +} + +func (f *fakeSessionService) GitDiscardAll(_ context.Context, id domain.SessionID) error { + if f.gitErr != nil { + return f.gitErr + } + f.gitDiscarded = append(f.gitDiscarded, id) + return nil +} + +func (f *fakeSessionService) GitCommitAll(_ context.Context, id domain.SessionID, message string, push bool) (ports.GitCommitResult, error) { + if f.gitErr != nil { + return ports.GitCommitResult{}, f.gitErr + } + if _, ok := f.sessions[id]; !ok { + return ports.GitCommitResult{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + f.gitCommits = append(f.gitCommits, message) + f.gitPushes = append(f.gitPushes, push) + return ports.GitCommitResult{SHA: "abc1234", Branch: "ao/ao-1", Pushed: push}, nil +} + func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -446,3 +490,95 @@ func TestSessionsAPI_ClaimPRErrors(t *testing.T) { }) } } + +func TestSessionsAPI_GitStatusAndActions(t *testing.T) { + svc := newFakeSessionService() + svc.gitStatus = ports.GitStatus{ + Branch: "ao/ao-1", + Files: []ports.GitFileChange{{Path: "main.go", Additions: 3, Deletions: 1, Staged: true}}, + } + srv := newSessionTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/git", "") + assertJSON(t, headers) + if status != http.StatusOK { + t.Fatalf("GET git = %d, want 200; body=%s", status, body) + } + var got struct { + SessionID string `json:"sessionId"` + Branch string `json:"branch"` + Files []struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Staged bool `json:"staged"` + } `json:"files"` + } + mustJSON(t, body, &got) + if got.SessionID != "ao-1" || got.Branch != "ao/ao-1" || len(got.Files) != 1 { + t.Fatalf("git status shape = %#v", got) + } + if f := got.Files[0]; f.Path != "main.go" || f.Additions != 3 || f.Deletions != 1 || !f.Staged { + t.Fatalf("git file shape = %#v", f) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/git/stage", "") + if status != http.StatusOK { + t.Fatalf("POST stage = %d, want 200; body=%s", status, body) + } + if len(svc.gitStaged) != 1 || svc.gitStaged[0] != "ao-1" { + t.Fatalf("staged sessions = %v", svc.gitStaged) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/git/discard", "") + if status != http.StatusOK { + t.Fatalf("POST discard = %d, want 200; body=%s", status, body) + } + if len(svc.gitDiscarded) != 1 || svc.gitDiscarded[0] != "ao-1" { + t.Fatalf("discarded sessions = %v", svc.gitDiscarded) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/git/commit", `{"message":"fix: wire git rail","push":true}`) + if status != http.StatusOK { + t.Fatalf("POST commit = %d, want 200; body=%s", status, body) + } + var committed struct { + OK bool `json:"ok"` + SHA string `json:"sha"` + Branch string `json:"branch"` + Pushed bool `json:"pushed"` + } + mustJSON(t, body, &committed) + if !committed.OK || committed.SHA != "abc1234" || committed.Branch != "ao/ao-1" || !committed.Pushed { + t.Fatalf("commit shape = %#v", committed) + } + if len(svc.gitCommits) != 1 || svc.gitCommits[0] != "fix: wire git rail" || !svc.gitPushes[0] { + t.Fatalf("commit recorded = %v pushes=%v", svc.gitCommits, svc.gitPushes) + } +} + +func TestSessionsAPI_GitCommitErrors(t *testing.T) { + cases := []struct { + name string + body string + err error + code int + want string + }{ + {"bad json", `{`, nil, http.StatusBadRequest, "INVALID_JSON"}, + {"missing message", `{}`, nil, http.StatusBadRequest, "MESSAGE_REQUIRED"}, + {"blank message", `{"message":" "}`, nil, http.StatusBadRequest, "MESSAGE_REQUIRED"}, + {"nothing to commit", `{"message":"m"}`, apierr.Conflict("GIT_NOTHING_TO_COMMIT", "No changes to commit", nil), http.StatusConflict, "GIT_NOTHING_TO_COMMIT"}, + {"no remote", `{"message":"m","push":true}`, apierr.Conflict("GIT_NO_REMOTE", "no remote", nil), http.StatusConflict, "GIT_NO_REMOTE"}, + {"no workspace", `{"message":"m"}`, apierr.Conflict("SESSION_NO_WORKSPACE", "Session has no workspace", nil), http.StatusConflict, "SESSION_NO_WORKSPACE"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newFakeSessionService() + svc.gitErr = tc.err + srv := newSessionTestServer(t, svc) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/git/commit", tc.body) + assertErrorCode(t, body, status, tc.code, tc.want) + }) + } +} diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 5e1cdba..156b3aa 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -124,6 +124,49 @@ var ( ErrWorkspaceDirty = errors.New("workspace: uncommitted changes present") ) +// WorkspaceGit inspects and commits uncommitted work inside a session's +// workspace. Paths come from session records (daemon-managed worktrees), +// never from API input. +type WorkspaceGit interface { + Status(ctx context.Context, path string) (GitStatus, error) + StageAll(ctx context.Context, path string) error + // DiscardAll throws away all uncommitted work: tracked changes reset to + // HEAD and untracked files removed. + DiscardAll(ctx context.Context, path string) error + // CommitAll stages everything, commits with message, and optionally pushes + // the workspace branch to its remote. + CommitAll(ctx context.Context, path, message string, push bool) (GitCommitResult, error) +} + +// WorkspaceGit sentinels, mapped to typed API errors at the service boundary. +var ( + // ErrGitNothingToCommit reports CommitAll found no staged or unstaged work. + ErrGitNothingToCommit = errors.New("workspace git: nothing to commit") + // ErrGitNoRemote reports a push was requested but the repo has no remote. + ErrGitNoRemote = errors.New("workspace git: no remote to push to") +) + +// GitFileChange is one changed path in a workspace's uncommitted diff. +type GitFileChange struct { + Path string + Additions int + Deletions int + Staged bool +} + +// GitStatus is a point-in-time view of a workspace's uncommitted work. +type GitStatus struct { + Branch string + Files []GitFileChange +} + +// GitCommitResult reports a CommitAll outcome. +type GitCommitResult struct { + SHA string + Branch string + Pushed bool +} + // WorkspaceConfig is the spec for creating or restoring a session's workspace. type WorkspaceConfig struct { ProjectID domain.ProjectID diff --git a/backend/internal/service/session/git.go b/backend/internal/service/session/git.go new file mode 100644 index 0000000..571f388 --- /dev/null +++ b/backend/internal/service/session/git.go @@ -0,0 +1,92 @@ +package session + +import ( + "context" + "errors" + "fmt" + + "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" +) + +// Session git operations: thin orchestration over the WorkspaceGit port. The +// service owns resolving a session to its workspace path and mapping adapter +// sentinels to typed API errors; all git mechanics live in the adapter. + +// GitStatus reports the session workspace's branch and uncommitted files. +func (s *Service) GitStatus(ctx context.Context, id domain.SessionID) (ports.GitStatus, error) { + path, err := s.gitWorkspacePath(ctx, id) + if err != nil { + return ports.GitStatus{}, err + } + status, err := s.workspaceGit.Status(ctx, path) + if err != nil { + return ports.GitStatus{}, toGitAPIError(err) + } + return status, nil +} + +// GitStageAll stages every change in the session workspace. +func (s *Service) GitStageAll(ctx context.Context, id domain.SessionID) error { + path, err := s.gitWorkspacePath(ctx, id) + if err != nil { + return err + } + return toGitAPIError(s.workspaceGit.StageAll(ctx, path)) +} + +// GitDiscardAll throws away all uncommitted work in the session workspace. +func (s *Service) GitDiscardAll(ctx context.Context, id domain.SessionID) error { + path, err := s.gitWorkspacePath(ctx, id) + if err != nil { + return err + } + return toGitAPIError(s.workspaceGit.DiscardAll(ctx, path)) +} + +// GitCommitAll stages and commits everything in the session workspace, +// optionally pushing the branch to its remote. +func (s *Service) GitCommitAll(ctx context.Context, id domain.SessionID, message string, push bool) (ports.GitCommitResult, error) { + path, err := s.gitWorkspacePath(ctx, id) + if err != nil { + return ports.GitCommitResult{}, err + } + result, err := s.workspaceGit.CommitAll(ctx, path, message, push) + if err != nil { + return result, toGitAPIError(err) + } + return result, nil +} + +// gitWorkspacePath resolves a session to the worktree its git routes operate +// on, with typed errors for the ways that can fail. +func (s *Service) gitWorkspacePath(ctx context.Context, id domain.SessionID) (string, error) { + if s.workspaceGit == nil { + return "", apierr.Conflict("GIT_UNAVAILABLE", "Git operations are not available on this daemon", nil) + } + rec, ok, err := s.store.GetSession(ctx, id) + if err != nil { + return "", fmt.Errorf("get session %s: %w", id, err) + } + if !ok { + return "", apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + if rec.Metadata.WorkspacePath == "" { + return "", apierr.Conflict("SESSION_NO_WORKSPACE", "Session has no workspace", nil) + } + return rec.Metadata.WorkspacePath, nil +} + +func toGitAPIError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, ports.ErrGitNothingToCommit): + return apierr.Conflict("GIT_NOTHING_TO_COMMIT", "No changes to commit", nil) + case errors.Is(err, ports.ErrGitNoRemote): + return apierr.Conflict("GIT_NO_REMOTE", err.Error(), nil) + default: + return err + } +} diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 51e4339..5ca427d 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -85,6 +85,7 @@ type Service struct { // the no_signal downgrade — a hook-less harness staying silent forever is // normal, not a broken pipeline. nil means "unknown": never downgrade. signalCapable func(domain.AgentHarness) bool + workspaceGit ports.WorkspaceGit } // New wires a controller-facing session service over an internal session Manager. @@ -105,11 +106,14 @@ type Deps struct { // wiring passes activitydispatch.SupportsHarness. Left nil, no session is // ever downgraded to no_signal. SignalCapable func(domain.AgentHarness) bool + // WorkspaceGit backs the session git routes (status, stage, discard, + // commit). Left nil, those routes report git as unavailable. + WorkspaceGit ports.WorkspaceGit } // NewWithDeps wires a session service with optional PR-claim dependencies. func NewWithDeps(d Deps) *Service { - s := &Service{manager: d.Manager, store: d.Store, prClaimer: d.PRClaimer, scm: d.SCM, clock: d.Clock, signalCapable: d.SignalCapable} + s := &Service{manager: d.Manager, store: d.Store, prClaimer: d.PRClaimer, scm: d.SCM, clock: d.Clock, signalCapable: d.SignalCapable, workspaceGit: d.WorkspaceGit} if s.prClaimer == nil { if w, ok := d.Store.(ports.PRClaimer); ok { s.prClaimer = w @@ -335,9 +339,9 @@ func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (doma return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) } if !ok { - return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil } - return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil } // now tolerates a zero-value Service (tests construct the struct literally diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 34a782c..9065bca 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -196,6 +196,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/sessions/{sessionId}/git": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Inspect a session workspace's branch and uncommitted files */ + get: operations["getSessionGitStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/git/commit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Commit all changes in a session workspace, optionally pushing the branch */ + post: operations["commitSessionGit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/git/discard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Discard all uncommitted changes in a session workspace */ + post: operations["discardSessionGit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/sessions/{sessionId}/git/stage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Stage all changes in a session workspace */ + post: operations["stageSessionGit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sessions/{sessionId}/kill": { parameters: { query?: never; @@ -371,6 +439,27 @@ export interface components { lastActivityAt: string; state: string; }; + GitActionResponse: { + ok: boolean; + sessionId: string; + }; + GitCommitRequest: { + message: string; + push?: boolean; + }; + GitCommitResponse: { + branch: string; + ok: boolean; + pushed: boolean; + sessionId: string; + sha: string; + }; + GitFileChange: { + additions: number; + deletions: number; + path: string; + staged: boolean; + }; KillSessionResponse: { freed?: boolean; ok: boolean; @@ -477,6 +566,7 @@ export interface components { }; Session: { activity: components["schemas"]["DomainActivity"]; + branch?: string; /** Format: date-time */ createdAt: string; displayName?: string; @@ -491,6 +581,11 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + SessionGitStatusResponse: { + branch: string; + files: components["schemas"]["GitFileChange"][]; + sessionId: string; + }; SessionPRFacts: { ci: string; mergeability: string; @@ -1349,6 +1444,255 @@ export interface operations { }; }; }; + getSessionGitStatus: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionGitStatusResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + commitSessionGit: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GitCommitRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitCommitResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + discardSessionGit: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitActionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + stageSessionGit: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitActionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; killSession: { parameters: { query?: never; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 4644b9e..511c7e2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,8 +5,8 @@ import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; -import type { DaemonStatus } from "./shared/daemon-status"; +import { createListenPortScanner, defaultRunFilePath, isDaemonHealthz, parseRunFile } from "./shared/daemon-discovery"; +import { daemonStatusEquals, type DaemonStatus } from "./shared/daemon-status"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -72,6 +72,7 @@ function preloadPath(): string { } function setDaemonStatus(nextStatus: DaemonStatus): void { + if (daemonStatusEquals(daemonStatus, nextStatus)) return; daemonStatus = nextStatus; mainWindow?.webContents.send("daemon:status", daemonStatus); } @@ -256,6 +257,58 @@ function startDaemon(): DaemonStatus { return daemonStatus; } +// The supervisor only learns about daemons it spawned, but in dev (and any +// setup where `ao start` runs outside the app) the daemon is external. Poll +// the running.json handshake and confirm over /healthz so the status pill +// reflects the daemon actually serving the renderer instead of reporting +// "stopped" forever. +const EXTERNAL_PROBE_INTERVAL_MS = 2_500; +const EXTERNAL_PROBE_TIMEOUT_MS = 1_500; + +async function probeExternalDaemon(): Promise { + const handshakePath = runFilePath(); + if (!handshakePath) return { state: "stopped" }; + + let contents: string; + try { + contents = await readFile(handshakePath, "utf8"); + } catch { + return { state: "stopped" }; + } + const info = parseRunFile(contents); + if (!info) return { state: "stopped" }; + + try { + const response = await fetch(`http://127.0.0.1:${info.port}/healthz`, { + signal: AbortSignal.timeout(EXTERNAL_PROBE_TIMEOUT_MS), + }); + if (response.ok && isDaemonHealthz(await response.text())) { + return { state: "ready", port: info.port }; + } + } catch { + // Stale run file or a daemon mid-restart; report stopped until it answers. + } + return { state: "stopped" }; +} + +function startExternalDaemonObserver(): void { + let probing = false; + const tick = async () => { + // A supervised daemon reports through its own lifecycle events; the + // observer only speaks for daemons this app did not spawn. + if (daemonProcess || probing) return; + probing = true; + try { + const observed = await probeExternalDaemon(); + if (!daemonProcess) setDaemonStatus(observed); + } finally { + probing = false; + } + }; + void tick(); + setInterval(() => void tick(), EXTERNAL_PROBE_INTERVAL_MS); +} + // Signal the daemon's whole process group so the kill reaches the real daemon // behind the /bin/sh wrapper (and any PTY children it forked), not just the // shell. Falls back to a direct kill if the group signal can't be delivered @@ -310,6 +363,13 @@ app.whenReady().then(() => { createWindow(); initAutoUpdates(); + // Supervise the daemon when configured; otherwise (dev, external `ao start`) + // the observer below discovers the daemon that is already running. + if (process.env.AO_DAEMON_COMMAND) { + startDaemon(); + } + startExternalDaemonObserver(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/frontend/src/renderer/App.test.tsx b/frontend/src/renderer/App.test.tsx index 1d3b9e3..62d1681 100644 --- a/frontend/src/renderer/App.test.tsx +++ b/frontend/src/renderer/App.test.tsx @@ -137,7 +137,8 @@ test("adds a project from the rail", async () => { expect(bridge.app.chooseDirectory).toHaveBeenCalled(); expect(postMock).toHaveBeenCalledWith("/api/v1/projects", { body: { path: "/Users/me/new-project" } }); - expect(await screen.findByText("New Project")).toBeInTheDocument(); + // Scope to the sidebar row: the orchestrator empty-state copy also names the project. + expect(await screen.findByRole("button", { name: "Select New Project" })).toBeInTheDocument(); }); test("spawns a worker from the New worker modal", async () => { diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index 4ea22e0..f4a8213 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -8,6 +8,7 @@ import { Topbar } from "./components/Topbar"; import { useDaemonStatus } from "./hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey } from "./hooks/useWorkspaceQuery"; import { apiClient } from "./lib/api-client"; +import { apiErrorMessage } from "./lib/api-errors"; import { Theme, useUiStore } from "./stores/ui-store"; import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "./types/workspace"; @@ -24,25 +25,6 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : "Could not load projects"; } -/** - * openapi-fetch resolves non-2xx responses to a plain APIError envelope - * ({ code, error, message, ... }), not an Error — String() on it renders - * "[object Object]". - */ -function apiErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error) return error.message; - if (typeof error === "string" && error) return error; - if (error && typeof error === "object") { - const envelope = error as { message?: unknown; code?: unknown }; - if (typeof envelope.message === "string" && envelope.message) { - return typeof envelope.code === "string" && envelope.code - ? `${envelope.message} (${envelope.code})` - : envelope.message; - } - } - return fallback; -} - export function App({ routeSessionId, routeWorkspaceId }: AppProps) { const queryClient = useQueryClient(); const { @@ -196,6 +178,52 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { selectSession(session.id, input.projectId); }; + const refetchWorkspaces = () => queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + + const killSession = async (sessionId: string) => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", { + params: { path: { sessionId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not kill worker")); + await refetchWorkspaces(); + }; + + const restoreSession = async (sessionId: string) => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/restore", { + params: { path: { sessionId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not restore worker")); + await refetchWorkspaces(); + }; + + const cleanupProject = async (projectId: string) => { + const { error } = await apiClient.POST("/api/v1/sessions/cleanup", { + params: { query: { project: projectId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not clean up sessions")); + await refetchWorkspaces(); + }; + + const removeProject = async (projectId: string) => { + const { error } = await apiClient.DELETE("/api/v1/projects/{id}", { + params: { path: { id: projectId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not remove project")); + if (selectedWorkspaceId === projectId) selectWorkspace(""); + await refetchWorkspaces(); + }; + + // The orchestrator view fronts the selected project's orchestrator session; + // spawning one is idempotent on the daemon side (one active per project). + const startOrchestrator = async () => { + if (!selectedWorkspace) return; + const { error } = await apiClient.POST("/api/v1/orchestrators", { + body: { projectId: selectedWorkspace.id }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not start orchestrator")); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }; + const showSideRail = !(view === "session" && workbenchTab === "terminal"); return ( @@ -213,16 +241,22 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
{showSideRail && ( diff --git a/frontend/src/renderer/components/CenterPane.tsx b/frontend/src/renderer/components/CenterPane.tsx index fdf8d55..17c41d2 100644 --- a/frontend/src/renderer/components/CenterPane.tsx +++ b/frontend/src/renderer/components/CenterPane.tsx @@ -1,6 +1,8 @@ -import { Columns2 } from "lucide-react"; +import { Columns2, Waypoints } from "lucide-react"; +import { useState } from "react"; import type { Theme, WorkbenchView } from "../stores/ui-store"; -import type { WorkspaceSession } from "../types/workspace"; +import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace"; +import { Button } from "./ui/button"; import { TerminalPane } from "./TerminalPane"; type CenterPaneProps = { @@ -8,20 +10,31 @@ type CenterPaneProps = { session?: WorkspaceSession; theme: Theme; daemonReady: boolean; + /** Project whose orchestrator the orchestrator view fronts. */ + workspace?: WorkspaceSummary | null; + onStartOrchestrator?: () => Promise; }; -export function CenterPane({ view, session, theme, daemonReady }: CenterPaneProps) { +export function CenterPane({ view, session, theme, daemonReady, workspace, onStartOrchestrator }: CenterPaneProps) { const isOrchestrator = view === "orchestrator"; const agentLabel = session?.provider ?? "claude-code"; + const live = Boolean(session?.terminalHandleId); return (
- + {isOrchestrator ? ( <> - orchestrator {agentLabel} + orchestrator{" "} + {session ? agentLabel : "not running"} ) : ( <> @@ -41,7 +54,75 @@ export function CenterPane({ view, session, theme, daemonReady }: CenterPaneProp
- + {isOrchestrator && !session ? ( + + ) : ( + + )} +
+
+ ); +} + +/** + * Empty state for a project with no live orchestrator: explains the model and + * offers the one action that makes the view real (spawn via POST /orchestrators). + */ +function StartOrchestratorPane({ + workspace, + onStart, +}: { + workspace?: WorkspaceSummary | null; + onStart?: () => Promise; +}) { + const [isStarting, setIsStarting] = useState(false); + const [error, setError] = useState(null); + + const start = async () => { + if (!onStart) return; + setError(null); + setIsStarting(true); + try { + await onStart(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not start orchestrator"); + } finally { + setIsStarting(false); + } + }; + + // This pane sits on the terminal surface, which keeps the dark palette in + // both themes (DESIGN.md → Color) — so it uses --term-* tokens, not theme ones. + return ( +
+
+ + + {workspace ? ( + <> +

No orchestrator running

+

+ The orchestrator coordinates {workspace.name} — talk to + it and it spawns and manages workers for you. +

+ + {error && ( +

+ {error} +

+ )} + + ) : ( + <> +

No project yet

+

+ Register a git repository from the sidebar to start its orchestrator. +

+ + )}
); diff --git a/frontend/src/renderer/components/SideRail.tsx b/frontend/src/renderer/components/SideRail.tsx index 25e273c..ac1f527 100644 --- a/frontend/src/renderer/components/SideRail.tsx +++ b/frontend/src/renderer/components/SideRail.tsx @@ -1,5 +1,7 @@ -import { GitBranch, GitCommitHorizontal, GitPullRequest, LoaderCircle, Plus, Square, Trash2 } from "lucide-react"; +import { GitBranch, GitCommitHorizontal, GitPullRequest, LoaderCircle, Plus, Square, SquareCheck, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; import type { WorkbenchView } from "../stores/ui-store"; +import { useSessionGit } from "../hooks/useSessionGit"; import { type WorkerDisplayStatus, type WorkspaceSession, @@ -9,6 +11,7 @@ import { workerStatusPulses, } from "../types/workspace"; import { Button } from "./ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { cn } from "../lib/utils"; // Session status is a single glyph, no text: spinner while working, a PR icon @@ -115,25 +118,62 @@ function WorkersList({ } function GitRail({ session }: { session?: WorkspaceSession }) { - const files = session?.changedFiles ?? []; + const git = useSessionGit(session?.id); + const [message, setMessage] = useState(""); + const [description, setDescription] = useState(""); + const [discardArmed, setDiscardArmed] = useState(false); + + // Armed confirm decays on its own instead of on mouse-leave: a pixel of + // cursor wobble silently disarming makes the second click re-arm forever. + useEffect(() => { + if (!discardArmed) return; + const timer = setTimeout(() => setDiscardArmed(false), 3_000); + return () => clearTimeout(timer); + }, [discardArmed]); + + const files = git.status?.files ?? []; + const branch = git.status?.branch || session?.branch || "—"; + + const discard = async () => { + // Destructive: first click arms, second click runs (mirrors the + // context-menu confirm pattern). + if (!discardArmed) { + setDiscardArmed(true); + return; + } + setDiscardArmed(false); + await git.discardAll(); + }; + + const commit = async () => { + if (await git.commitAndPush(message, description)) { + setMessage(""); + setDescription(""); + } + }; return ( <>
-
- {files.length === 0 ? ( -

No changes yet.

+ {git.statusError ? ( +

{git.statusError}

+ ) : files.length === 0 ? ( +

+ {git.isLoading ? "Loading changes…" : "No changes yet."} +

) : ( - files.map((file) => ( -
- {file.path} - +{file.additions} - −{file.deletions} -
- )) + files.map((file) => { + const StagedGlyph = file.staged ? SquareCheck : Square; + return ( +
+ {file.path} + +{file.additions} + −{file.deletions} +
+ ); + }) )}
setMessage(event.target.value)} placeholder="Commit message" + value={message} />