diff --git a/backend/internal/adapters/workspace/gitworktree/gitops.go b/backend/internal/adapters/workspace/gitworktree/gitops.go new file mode 100644 index 0000000..5e3cd93 --- /dev/null +++ b/backend/internal/adapters/workspace/gitworktree/gitops.go @@ -0,0 +1,204 @@ +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. +// +// Gitignored files deliberately survive (`clean -fd`, not `-fdx`): discard +// means "drop my changes", not "wipe the workspace" — node_modules, build +// caches, and the gitignored AO session/hook files must keep the worktree +// usable after a discard. +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 c91c02e..1a5f588 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -729,6 +729,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 @@ -1174,6 +1366,63 @@ components: required: - sessionId 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 + pushError: + type: string + 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: @@ -1500,6 +1749,8 @@ components: properties: activity: $ref: '#/components/schemas/DomainActivity' + branch: + type: string createdAt: format: date-time type: string @@ -1534,6 +1785,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 502cc25..3b76599 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -155,6 +155,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", @@ -512,6 +517,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..adab3bf 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -212,6 +212,46 @@ 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. +// PushError is set when the commit landed but the push leg failed: the response +// is still a 200 carrying the SHA so the committed work is never lost, and the +// caller renders PushError as a warning rather than treating the commit as failed. +type GitCommitResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` + SHA string `json:"sha"` + Branch string `json:"branch"` + Pushed bool `json:"pushed"` + PushError string `json:"pushError,omitempty"` +} + // 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..866384d 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,89 @@ 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 { + // A non-empty SHA means the commit landed and only the push leg failed + // (CommitAll resolves the SHA before it ever attempts to push). Writing + // the bare error would 409 and drop the SHA, leaving committed work the + // user can't see; surface it as a 200 with a push warning instead. + if result.SHA != "" { + envelope.WriteJSON(w, http.StatusOK, GitCommitResponse{ + OK: true, + SessionID: sessionID(r), + SHA: result.SHA, + Branch: result.Branch, + Pushed: false, + PushError: err.Error(), + }) + return + } + 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..7614150 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -2,6 +2,7 @@ package controllers_test import ( "context" + "errors" "io" "log/slog" "net/http" @@ -25,6 +26,13 @@ type fakeSessionService struct { cleanupSkipped []sessionsvc.CleanupSkipped claimErr error listPRErr error + gitStatus ports.GitStatus + gitErr error + gitErrResult ports.GitCommitResult // result returned alongside gitErr (push-leg failures carry a SHA) + gitStaged []domain.SessionID + gitDiscarded []domain.SessionID + gitCommits []string + gitPushes []bool } func newFakeSessionService() *fakeSessionService { @@ -146,6 +154,46 @@ 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 { + // gitErrResult is zero for genuine pre-commit failures and carries a SHA + // for push-leg failures, mirroring CommitAll's real contract. + return f.gitErrResult, 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 +494,137 @@ 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 workspace", `{"message":"m"}`, apierr.Conflict("SESSION_NO_WORKSPACE", "Session has no workspace", nil), http.StatusConflict, "SESSION_NO_WORKSPACE"}, + // A push-leg failure (GIT_NO_REMOTE / push rejected) is NOT in this table: + // the commit lands first, so it returns 200 with the SHA — see + // TestSessionsAPI_GitCommitPushFailureKeepsSHA. + } + 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) + }) + } +} + +// A failure on the push leg must not lose the commit: CommitAll has already +// resolved the SHA, so the controller returns 200 with the SHA and a PushError +// warning rather than a bare 409 that would leave the committed work invisible. +func TestSessionsAPI_GitCommitPushFailureKeepsSHA(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"no remote", apierr.Conflict("GIT_NO_REMOTE", "no remote to push to", nil)}, + {"push rejected", errors.New("gitops: committed deadbeef, push failed: rejected")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newFakeSessionService() + svc.gitErr = tc.err + svc.gitErrResult = ports.GitCommitResult{SHA: "deadbeef", Branch: "ao/ao-1"} + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/git/commit", `{"message":"m","push":true}`) + if status != http.StatusOK { + t.Fatalf("POST commit = %d, want 200; body=%s", status, body) + } + var got struct { + OK bool `json:"ok"` + SHA string `json:"sha"` + Branch string `json:"branch"` + Pushed bool `json:"pushed"` + PushError string `json:"pushError"` + } + mustJSON(t, body, &got) + if !got.OK || got.SHA != "deadbeef" || got.Branch != "ao/ao-1" || got.Pushed { + t.Fatalf("commit shape = %#v", got) + } + if got.PushError != tc.err.Error() { + t.Fatalf("pushError = %q, want %q", got.PushError, tc.err.Error()) + } + }) + } +} 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 93d306a..8e521b3 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -247,6 +247,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; @@ -426,6 +494,28 @@ export interface components { /** @description Session whose PR to review. */ sessionId: string; }; + GitActionResponse: { + ok: boolean; + sessionId: string; + }; + GitCommitRequest: { + message: string; + push?: boolean; + }; + GitCommitResponse: { + branch: string; + ok: boolean; + pushError?: string; + pushed: boolean; + sessionId: string; + sha: string; + }; + GitFileChange: { + additions: number; + deletions: number; + path: string; + staged: boolean; + }; KillSessionResponse: { freed?: boolean; ok: boolean; @@ -553,6 +643,7 @@ export interface components { }; Session: { activity: components["schemas"]["DomainActivity"]; + branch?: string; /** Format: date-time */ createdAt: string; displayName?: string; @@ -567,6 +658,11 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + SessionGitStatusResponse: { + branch: string; + files: components["schemas"]["GitFileChange"][]; + sessionId: string; + }; SessionPRFacts: { ci: string; mergeability: string; @@ -1546,6 +1642,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 2d0d541..f7f71ee 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; @@ -75,6 +75,7 @@ function preloadPath(): string { } function setDaemonStatus(nextStatus: DaemonStatus): void { + if (daemonStatusEquals(daemonStatus, nextStatus)) return; daemonStatus = nextStatus; mainWindow?.webContents.send("daemon:status", daemonStatus); } @@ -259,6 +260,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 @@ -313,6 +366,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/components/ui/context-menu.tsx b/frontend/src/renderer/components/ui/context-menu.tsx new file mode 100644 index 0000000..4423b2f --- /dev/null +++ b/frontend/src/renderer/components/ui/context-menu.tsx @@ -0,0 +1,142 @@ +import { LoaderCircle } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; + +/** + * Lightweight right-click menu for sidebar rows. Items run async actions + * in-place: a `confirmLabel` arms a destructive item on first click, the busy + * row shows a spinner, and failures render inline so the user sees why. + */ +export type ContextMenuItem = { + id: string; + label: string; + icon?: React.ReactNode; + tone?: "default" | "danger"; + disabled?: boolean; + /** Two-step destructive confirm: first click re-labels, second click runs. */ + confirmLabel?: string; + onSelect: () => void | Promise; +}; + +export type ContextMenuState = { + x: number; + y: number; + items: ContextMenuItem[]; +}; + +export function useContextMenu() { + const [menu, setMenu] = useState(null); + + const openMenu = useCallback((event: React.MouseEvent, items: ContextMenuItem[]) => { + event.preventDefault(); + event.stopPropagation(); + setMenu({ x: event.clientX, y: event.clientY, items }); + }, []); + + const closeMenu = useCallback(() => setMenu(null), []); + + return { menu, openMenu, closeMenu }; +} + +const MENU_WIDTH = 208; + +export function ContextMenu({ menu, onClose }: { menu: ContextMenuState; onClose: () => void }) { + const [armedId, setArmedId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const menuRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + // Capture phase: the focused xterm pane stops propagation of keys it + // handles, so a bubble-phase listener would never see Escape. + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [onClose]); + + // An armed destructive item decays back to its resting label after 3s, so a + // pause after an accidental first click can't leave a live trigger behind + // for a much-later mistaken second click. + useEffect(() => { + if (!armedId) return; + const timer = window.setTimeout(() => setArmedId(null), 3000); + return () => window.clearTimeout(timer); + }, [armedId]); + + // Keep the menu on-screen for rows near the window edges. + const left = Math.min(menu.x, window.innerWidth - MENU_WIDTH - 8); + const estimatedHeight = menu.items.length * 30 + 12; + const top = Math.min(menu.y, window.innerHeight - estimatedHeight - 8); + + const run = async (item: ContextMenuItem) => { + if (item.disabled || busyId) return; + if (item.confirmLabel && armedId !== item.id) { + setArmedId(item.id); + return; + } + setError(null); + setBusyId(item.id); + try { + await item.onSelect(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Action failed"); + setArmedId(null); + } finally { + setBusyId(null); + } + }; + + return ( +
{ + event.preventDefault(); + onClose(); + }} + > +
event.stopPropagation()} + ref={menuRef} + role="menu" + style={{ left, top }} + > + {menu.items.map((item) => { + const armed = armedId === item.id; + const busy = busyId === item.id; + return ( + + ); + })} + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/src/renderer/hooks/useSessionGit.ts b/frontend/src/renderer/hooks/useSessionGit.ts new file mode 100644 index 0000000..efe3e92 --- /dev/null +++ b/frontend/src/renderer/hooks/useSessionGit.ts @@ -0,0 +1,107 @@ +// The Git review rail's data + actions: live workspace status from +// GET /sessions/{id}/git, and stage/discard/commit mutations that refetch it. +// The daemon owns all git mechanics; this hook only moves wire shapes. + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { apiClient } from "../lib/api-client"; +import { apiErrorMessage } from "../lib/api-errors"; + +export const sessionGitQueryKey = (sessionId: string) => ["session-git", sessionId] as const; + +const REFETCH_MS = 5_000; + +export function useSessionGit(sessionId: string | undefined) { + const queryClient = useQueryClient(); + const [isMutating, setIsMutating] = useState(false); + const [actionError, setActionError] = useState(null); + + const statusQuery = useQuery({ + queryKey: sessionGitQueryKey(sessionId ?? "none"), + enabled: Boolean(sessionId), + refetchInterval: REFETCH_MS, + retry: 1, + queryFn: async () => { + const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/git", { + params: { path: { sessionId: sessionId ?? "" } }, + }); + if (error || !data) throw new Error(apiErrorMessage(error, "Could not load git status")); + return data; + }, + }); + + const refetchStatus = useCallback(() => { + if (sessionId) void queryClient.invalidateQueries({ queryKey: sessionGitQueryKey(sessionId) }); + }, [queryClient, sessionId]); + + const runAction = useCallback( + async (action: () => Promise) => { + if (!sessionId || isMutating) return false; + setActionError(null); + setIsMutating(true); + try { + await action(); + return true; + } catch (err) { + setActionError(err instanceof Error ? err.message : "Git action failed"); + return false; + } finally { + setIsMutating(false); + refetchStatus(); + } + }, + [isMutating, refetchStatus, sessionId], + ); + + const stageAll = useCallback( + () => + runAction(async () => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/git/stage", { + params: { path: { sessionId: sessionId ?? "" } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not stage changes")); + }), + [runAction, sessionId], + ); + + const discardAll = useCallback( + () => + runAction(async () => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/git/discard", { + params: { path: { sessionId: sessionId ?? "" } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not discard changes")); + }), + [runAction, sessionId], + ); + + const commitAndPush = useCallback( + (message: string, description: string) => + runAction(async () => { + const fullMessage = description.trim() ? `${message.trim()}\n\n${description.trim()}` : message.trim(); + const { data, error } = await apiClient.POST("/api/v1/sessions/{sessionId}/git/commit", { + params: { path: { sessionId: sessionId ?? "" } }, + body: { message: fullMessage, push: true }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not commit")); + // The commit landed even when the push leg fails (the daemon returns + // the SHA with a pushError warning); surface that as a non-fatal + // notice instead of dropping it, since the work is already committed. + if (data?.pushError) { + setActionError(`Committed ${data.sha.slice(0, 7)} but push failed: ${data.pushError}`); + } + }), + [runAction, sessionId], + ); + + return { + status: statusQuery.data, + statusError: statusQuery.isError ? apiErrorMessage(statusQuery.error, "Could not load git status") : null, + isLoading: statusQuery.isLoading, + isMutating, + actionError, + stageAll, + discardAll, + commitAndPush, + }; +} diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 74a75e5..6935e4b 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -49,6 +49,7 @@ describe("useWorkspaceQuery", () => { displayName: "fix-bug", harness: "claude-code", status: "mergeable", + branch: "feat/custom-worktree", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z", }, @@ -82,12 +83,16 @@ describe("useWorkspaceQuery", () => { title: "fix-bug", provider: "claude-code", status: "mergeable", + // The daemon's real worktree branch is preserved, not overwritten. + branch: "feat/custom-worktree", }); expect(workspace.sessions[1]).toMatchObject({ id: "sess-2", title: "sess-2", provider: "codex", status: "working", + // No branch from the daemon yet: falls back to the synthesized name. + branch: "session/sess-2", }); }); diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 12b432f..764ce9e 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -30,7 +30,9 @@ async function fetchWorkspaces(): Promise { title: session.displayName ?? session.issueId ?? session.id, provider: toAgentProvider(session.harness), kind: session.kind === "orchestrator" ? "orchestrator" : session.kind === "worker" ? "worker" : undefined, - branch: `session/${session.id}`, + // Prefer the worktree branch the daemon reports; fall back to the + // synthesized name only when a session has no branch metadata yet. + branch: session.branch || `session/${session.id}`, status: toSessionStatus(session.status, session.isTerminated), createdAt: session.createdAt, updatedAt: session.updatedAt, diff --git a/frontend/src/renderer/lib/api-errors.ts b/frontend/src/renderer/lib/api-errors.ts new file mode 100644 index 0000000..653a28e --- /dev/null +++ b/frontend/src/renderer/lib/api-errors.ts @@ -0,0 +1,18 @@ +/** + * openapi-fetch resolves non-2xx responses to a plain APIError envelope + * ({ code, error, message, ... }), not an Error — String() on it renders + * "[object Object]". This normalizes either shape into a readable message. + */ +export 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; +} diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index d3db317..924ad7c 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -224,6 +224,9 @@ export type WorkspaceSummary = { number: number; state: "open" | "draft" | "merged" | "closed"; }; + /** The project's live orchestrator session (kind=orchestrator, not terminated). */ + orchestrator?: WorkspaceSession; + /** Worker sessions only; the orchestrator is surfaced separately above. */ sessions: WorkspaceSession[]; }; diff --git a/frontend/src/shared/daemon-discovery.test.ts b/frontend/src/shared/daemon-discovery.test.ts index 4a439d0..c6ae52d 100644 --- a/frontend/src/shared/daemon-discovery.test.ts +++ b/frontend/src/shared/daemon-discovery.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { createListenPortScanner, defaultRunFilePath, parseDaemonListenPort, parseRunFile } from "./daemon-discovery"; +import { + createListenPortScanner, + defaultRunFilePath, + isDaemonHealthz, + parseDaemonListenPort, + parseRunFile, +} from "./daemon-discovery"; +import { daemonStatusEquals } from "./daemon-status"; // Real shape emitted by slog's TextHandler in backend/internal/httpd/server.go. const LISTEN_LINE = 'time=2026-06-10T09:15:04.221-07:00 level=INFO msg="daemon listening" addr=127.0.0.1:3001 pid=4242'; @@ -90,6 +97,37 @@ describe("parseRunFile", () => { }); }); +describe("isDaemonHealthz", () => { + it("accepts the daemon's healthz payload", () => { + expect(isDaemonHealthz('{"pid":57329,"service":"agent-orchestrator-daemon","status":"ok"}')).toBe(true); + }); + + it("rejects foreign services squatting on a stale run-file port", () => { + expect(isDaemonHealthz('{"service":"something-else","status":"ok"}')).toBe(false); + expect(isDaemonHealthz('{"status":"ok"}')).toBe(false); + }); + + it("rejects non-ok status and malformed bodies", () => { + expect(isDaemonHealthz('{"service":"agent-orchestrator-daemon","status":"degraded"}')).toBe(false); + expect(isDaemonHealthz("not json")).toBe(false); + expect(isDaemonHealthz("")).toBe(false); + expect(isDaemonHealthz("null")).toBe(false); + }); +}); + +describe("daemonStatusEquals", () => { + it("treats identical state, port, and message as equal", () => { + expect(daemonStatusEquals({ state: "ready", port: 3001 }, { state: "ready", port: 3001 })).toBe(true); + expect(daemonStatusEquals({ state: "stopped" }, { state: "stopped" })).toBe(true); + }); + + it("distinguishes differing state, port, or message", () => { + expect(daemonStatusEquals({ state: "ready", port: 3001 }, { state: "ready", port: 3002 })).toBe(false); + expect(daemonStatusEquals({ state: "stopped" }, { state: "ready", port: 3001 })).toBe(false); + expect(daemonStatusEquals({ state: "stopped", message: "a" }, { state: "stopped", message: "b" })).toBe(false); + }); +}); + describe("defaultRunFilePath", () => { it("mirrors Go os.UserConfigDir on macOS", () => { expect(defaultRunFilePath("darwin", {}, "/Users/me")).toBe( diff --git a/frontend/src/shared/daemon-discovery.ts b/frontend/src/shared/daemon-discovery.ts index 79cddeb..2e0df10 100644 --- a/frontend/src/shared/daemon-discovery.ts +++ b/frontend/src/shared/daemon-discovery.ts @@ -87,6 +87,23 @@ export function parseRunFile(contents: string): RunFileInfo | null { }; } +/** + * Whether a /healthz response body identifies the agent-orchestrator daemon + * (backend/internal/httpd/router.go handleHealthz). Guards external-daemon + * discovery against a foreign process squatting on a stale run-file port. + */ +export function isDaemonHealthz(body: string): boolean { + let raw: unknown; + try { + raw = JSON.parse(body); + } catch { + return false; + } + if (typeof raw !== "object" || raw === null) return false; + const { service, status } = raw as { service?: unknown; status?: unknown }; + return service === "agent-orchestrator-daemon" && status === "ok"; +} + /** * Where the daemon writes running.json when AO_RUN_FILE is unset. Mirrors Go's * os.UserConfigDir() + "agent-orchestrator/running.json" resolution in diff --git a/frontend/src/shared/daemon-status.ts b/frontend/src/shared/daemon-status.ts index d980e22..0b1a50f 100644 --- a/frontend/src/shared/daemon-status.ts +++ b/frontend/src/shared/daemon-status.ts @@ -6,3 +6,8 @@ export type DaemonStatus = { port?: number; message?: string; }; + +/** Value equality so status emitters can skip no-op broadcasts. */ +export function daemonStatusEquals(a: DaemonStatus, b: DaemonStatus): boolean { + return a.state === b.state && a.port === b.port && a.message === b.message; +}