From 7521377835dfa0d94c2f7d7a7a0fd6b14c279dfd Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 15:27:32 +0530 Subject: [PATCH 01/11] feat(review): configurable AO code review backend (V1) Add per-project configurable code review of a worker's PR. A reviewer agent runs one-shot over the worker's own worktree and posts its result to the PR; the worker picks the feedback up through the existing SCM observer review-nudge path. - domain: ProjectConfig.reviewers (+ default reviewer harness), Review / ReviewRun types and verdict/status vocab. - storage: review + review_run tables (0011), sqlc queries, store methods. - service/review: rewrite the in-memory stub as a persisted ReviewService (Trigger/Submit/List) with a reviewer Runner over agent resolver + runtime; ports.PRReviewPoster implemented on the GitHub adapter. - http: session-scoped routes POST /sessions/{id}/reviews/trigger, POST .../submit, GET .../reviews; regenerated OpenAPI + TS types. - cli: ao review trigger|submit|list. - frontend: adapt ReviewDashboard to the per-worker reviews API. Closes #192 Co-Authored-By: Claude Opus 4.8 --- .../adapters/scm/github/review_poster.go | 42 ++ .../adapters/scm/github/review_poster_test.go | 61 +++ backend/internal/cli/review.go | 186 +++++++++ backend/internal/cli/review_test.go | 130 ++++++ backend/internal/cli/root.go | 1 + backend/internal/daemon/daemon.go | 5 +- backend/internal/daemon/lifecycle_wiring.go | 27 +- backend/internal/daemon/wiring_test.go | 5 +- backend/internal/domain/projectconfig.go | 27 ++ backend/internal/domain/projectconfig_test.go | 17 + backend/internal/domain/review.go | 57 +++ backend/internal/httpd/apispec/openapi.yaml | 292 +++++++------ .../internal/httpd/apispec/specgen/build.go | 41 +- backend/internal/httpd/controllers/reviews.go | 65 ++- backend/internal/ports/outbound.go | 7 + backend/internal/service/review/review.go | 325 +++++++++++---- .../internal/service/review/review_test.go | 266 ++++++++++++ backend/internal/service/review/runner.go | 65 +++ backend/internal/storage/sqlite/gen/models.go | 23 ++ .../internal/storage/sqlite/gen/review.sql.go | 182 +++++++++ .../migrations/0011_add_review_tables.sql | 49 +++ .../storage/sqlite/queries/review.sql | 26 ++ .../storage/sqlite/store/review_store.go | 123 ++++++ .../storage/sqlite/store/review_store_test.go | 87 ++++ backend/sqlc.yaml | 28 ++ frontend/src/api/schema.ts | 382 ++++++++++-------- 26 files changed, 2093 insertions(+), 426 deletions(-) create mode 100644 backend/internal/adapters/scm/github/review_poster.go create mode 100644 backend/internal/adapters/scm/github/review_poster_test.go create mode 100644 backend/internal/cli/review.go create mode 100644 backend/internal/cli/review_test.go create mode 100644 backend/internal/domain/review.go create mode 100644 backend/internal/service/review/review_test.go create mode 100644 backend/internal/service/review/runner.go create mode 100644 backend/internal/storage/sqlite/gen/review.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql create mode 100644 backend/internal/storage/sqlite/queries/review.sql create mode 100644 backend/internal/storage/sqlite/store/review_store.go create mode 100644 backend/internal/storage/sqlite/store/review_store_test.go diff --git a/backend/internal/adapters/scm/github/review_poster.go b/backend/internal/adapters/scm/github/review_poster.go new file mode 100644 index 00000000..786c3089 --- /dev/null +++ b/backend/internal/adapters/scm/github/review_poster.go @@ -0,0 +1,42 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// PostPRReview posts an AO code-review result to a PR as a GitHub pull-request +// review. The verdict maps to the review event so the result lands in the +// review-decision path the worker already consumes through the SCM observer. +func (p *Provider) PostPRReview(ctx context.Context, prURL string, verdict domain.ReviewVerdict, body string) error { + owner, repo, number, err := parsePRURL(prURL) + if err != nil { + return err + } + event, err := reviewEvent(verdict) + if err != nil { + return err + } + payload := map[string]any{"event": event, "body": body} + _, err = p.client.doREST(ctx, http.MethodPost, repoPath(owner, repo, "pulls", strconv.Itoa(number), "reviews"), nil, payload) + if err != nil { + return fmt.Errorf("github scm: post review on %s: %w", prURL, err) + } + return nil +} + +// reviewEvent maps an AO verdict onto a GitHub review event. +func reviewEvent(verdict domain.ReviewVerdict) (string, error) { + switch verdict { + case domain.VerdictApproved: + return "APPROVE", nil + case domain.VerdictChangesRequested: + return "REQUEST_CHANGES", nil + default: + return "", fmt.Errorf("github scm: unsupported review verdict %q", verdict) + } +} diff --git a/backend/internal/adapters/scm/github/review_poster_test.go b/backend/internal/adapters/scm/github/review_poster_test.go new file mode 100644 index 00000000..a746e18a --- /dev/null +++ b/backend/internal/adapters/scm/github/review_poster_test.go @@ -0,0 +1,61 @@ +package github + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestPostPRReview(t *testing.T) { + tests := []struct { + name string + verdict domain.ReviewVerdict + wantEvent string + }{ + {"changes requested", domain.VerdictChangesRequested, "REQUEST_CHANGES"}, + {"approved", domain.VerdictApproved, "APPROVE"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFakeGH(t) + f.on(http.MethodPost, "/repos/octocat/hello/pulls/42/reviews", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":1}`)) + }) + p := newProviderForTest(t, f) + + err := p.PostPRReview(ctx(), "https://github.com/octocat/hello/pull/42", tt.verdict, "please fix X") + if err != nil { + t.Fatalf("PostPRReview: %v", err) + } + if n := f.callsTo(http.MethodPost, "/repos/octocat/hello/pulls/42/reviews"); n != 1 { + t.Fatalf("review POST count = %d, want 1", n) + } + var body struct { + Event string `json:"event"` + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(f.calls()[0].Body), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Event != tt.wantEvent || body.Body != "please fix X" { + t.Fatalf("posted body = %+v, want event %q", body, tt.wantEvent) + } + }) + } +} + +func TestPostPRReviewRejectsUnsubmittableVerdict(t *testing.T) { + f := newFakeGH(t) + p := newProviderForTest(t, f) + err := p.PostPRReview(ctx(), "https://github.com/octocat/hello/pull/42", domain.VerdictNone, "") + if err == nil || !strings.Contains(err.Error(), "verdict") { + t.Fatalf("want verdict error, got %v", err) + } + if len(f.calls()) != 0 { + t.Fatalf("expected no HTTP call for invalid verdict, got %d", len(f.calls())) + } +} diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go new file mode 100644 index 00000000..e33257ef --- /dev/null +++ b/backend/internal/cli/review.go @@ -0,0 +1,186 @@ +package cli + +import ( + "errors" + "fmt" + "net/url" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" +) + +// reviewRun mirrors the daemon's domain.ReviewRun for the CLI client. +type reviewRun struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + Harness string `json:"harness"` + PRURL string `json:"prUrl"` + Status string `json:"status"` + Verdict string `json:"verdict"` + Iteration int `json:"iteration"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// reviewRunResponse mirrors controllers.ReviewRunResponse. +type reviewRunResponse struct { + Review reviewRun `json:"review"` +} + +// listReviewsResponse mirrors controllers.ListReviewsResponse. +type listReviewsResponse struct { + Reviews []reviewRun `json:"reviews"` +} + +// submitReviewRequest mirrors controllers.SubmitReviewInput. +type submitReviewRequest struct { + Verdict string `json:"verdict"` + Body string `json:"body"` +} + +type reviewSubmitOptions struct { + session string + verdict string + body string +} + +func newReviewCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "review", + Short: "Trigger and manage AO code reviews of a worker's PR", + } + cmd.AddCommand(newReviewTriggerCommand(ctx)) + cmd.AddCommand(newReviewSubmitCommand(ctx)) + cmd.AddCommand(newReviewListCommand(ctx)) + return cmd +} + +func newReviewTriggerCommand(ctx *commandContext) *cobra.Command { + return &cobra.Command{ + Use: "trigger ", + Short: "Trigger a code review of a worker's PR", + Args: exactSessionArg, + RunE: func(cmd *cobra.Command, args []string) error { + session := strings.TrimSpace(args[0]) + var res reviewRunResponse + if err := ctx.postJSON(cmd.Context(), reviewPath(session, "trigger"), nil, &res); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "triggered review %s for %s (iteration %d, %s)\n", + res.Review.ID, session, res.Review.Iteration, res.Review.Harness) + return err + }, + } +} + +func newReviewListCommand(ctx *commandContext) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "list ", + Short: "List a worker's code-review runs", + Args: exactSessionArg, + RunE: func(cmd *cobra.Command, args []string) error { + session := strings.TrimSpace(args[0]) + var res listReviewsResponse + if err := ctx.getJSON(cmd.Context(), reviewPath(session, ""), &res); err != nil { + return err + } + if asJSON { + return writeJSON(cmd.OutOrStdout(), res) + } + return writeReviewList(cmd, res.Reviews) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output review runs as JSON") + return cmd +} + +func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { + var opts reviewSubmitOptions + cmd := &cobra.Command{ + Use: "submit [worker-session-id]", + Short: "Submit a reviewer's result for a worker's PR", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return ctx.submitReview(cmd, args, opts) + }, + } + cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (defaults to $AO_REVIEW_WORKER)") + cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") + cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body") + return cmd +} + +func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts reviewSubmitOptions) error { + session := strings.TrimSpace(opts.session) + if len(args) == 1 { + session = strings.TrimSpace(args[0]) + } + if session == "" { + session = strings.TrimSpace(os.Getenv("AO_REVIEW_WORKER")) + } + if session == "" { + return usageError{errors.New("usage: worker session id is required (positional, --session, or $AO_REVIEW_WORKER)")} + } + verdict := strings.TrimSpace(opts.verdict) + if verdict == "" { + return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")} + } + var body string + if path := strings.TrimSpace(opts.body); path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return usageError{fmt.Errorf("read body file: %w", err)} + } + body = string(raw) + } + var res reviewRunResponse + if err := c.postJSON(cmd.Context(), reviewPath(session, "submit"), submitReviewRequest{Verdict: verdict, Body: body}, &res); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "submitted %s review for %s\n", res.Review.Verdict, session) + return err +} + +func reviewPath(session, action string) string { + base := "sessions/" + url.PathEscape(session) + "/reviews" + if action == "" { + return base + } + return base + "/" + action +} + +func exactSessionArg(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return usageError{err} + } + if strings.TrimSpace(args[0]) == "" { + return usageError{errors.New("usage: worker session id is required")} + } + return nil +} + +func writeReviewList(cmd *cobra.Command, runs []reviewRun) error { + out := cmd.OutOrStdout() + if len(runs) == 0 { + _, err := fmt.Fprintln(out, "No reviews yet. Run `ao review trigger ` to start one.") + return err + } + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "ITER\tSTATUS\tVERDICT\tHARNESS\tPR"); err != nil { + return err + } + for _, r := range runs { + verdict := r.Verdict + if verdict == "" { + verdict = "-" + } + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", r.Iteration, r.Status, verdict, r.Harness, r.PRURL); err != nil { + return err + } + } + return tw.Flush() +} diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go new file mode 100644 index 00000000..f2029987 --- /dev/null +++ b/backend/internal/cli/review_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// reviewServer captures the method/path/body of the request the CLI made. +type reviewCapture struct { + method string + path string + body string +} + +func reviewServer(t *testing.T, status int, respBody string) (*httptest.Server, *reviewCapture) { + t.Helper() + capture := &reviewCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capture.method = r.Method + capture.path = r.URL.Path + capture.body = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, capture +} + +func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true }} } + +func TestReviewTrigger(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := reviewServer(t, http.StatusCreated, + `{"review":{"id":"r1","iteration":1,"harness":"codex","status":"pending"}}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, aliveDeps(), "review", "trigger", "mer-1") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodPost || capture.path != "/api/v1/sessions/mer-1/reviews/trigger" { + t.Fatalf("request = %s %s", capture.method, capture.path) + } + if !strings.Contains(out, "triggered review r1") { + t.Fatalf("output = %q", out) + } +} + +func TestReviewList(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := reviewServer(t, http.StatusOK, + `{"reviews":[{"iteration":2,"status":"complete","verdict":"changes_requested","harness":"codex","prUrl":"u"}]}`) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, aliveDeps(), "review", "list", "mer-1") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodGet || capture.path != "/api/v1/sessions/mer-1/reviews" { + t.Fatalf("request = %s %s", capture.method, capture.path) + } + if !strings.Contains(out, "changes_requested") || !strings.Contains(out, "ITER") { + t.Fatalf("output = %q", out) + } +} + +func TestReviewSubmitReadsBodyFile(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`) + writeRunFileFor(t, cfg, srv) + + bodyFile := filepath.Join(t.TempDir(), "review.md") + if err := os.WriteFile(bodyFile, []byte("please fix"), 0o600); err != nil { + t.Fatal(err) + } + + _, errOut, err := executeCLI(t, aliveDeps(), + "review", "submit", "mer-1", "--verdict", "changes_requested", "--body", bodyFile) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.path != "/api/v1/sessions/mer-1/reviews/submit" { + t.Fatalf("path = %q", capture.path) + } + var req submitReviewRequest + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v", err) + } + if req.Verdict != "changes_requested" || req.Body != "please fix" { + t.Fatalf("request = %+v", req) + } +} + +func TestReviewSubmitUsesEnvWorker(t *testing.T) { + cfg := setConfigEnv(t) + t.Setenv("AO_REVIEW_WORKER", "mer-7") + srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) + writeRunFileFor(t, cfg, srv) + + if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved"); err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.path != "/api/v1/sessions/mer-7/reviews/submit" { + t.Fatalf("path = %q, want mer-7", capture.path) + } +} + +func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1") + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) + } +} + +func TestReviewTriggerMissingArgIsUsageError(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, aliveDeps(), "review", "trigger") + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 4dec629c..f536459c 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -171,6 +171,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) root.AddCommand(newOrchestratorCommand(ctx)) + root.AddCommand(newReviewCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 260ccd55..a0b9fda3 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -16,7 +16,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -93,7 +92,7 @@ func Run() error { // zellij runtime, a gitworktree workspace, the per-session agent resolver // (AO_AGENT default, validated here), and the agent messenger, then mount it // on the API. - sessionSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, log) + sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, log) if err != nil { stop() lcStack.Stop() @@ -106,7 +105,7 @@ func Run() error { srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ Projects: projectsvc.New(store), Sessions: sessionSvc, - Reviews: reviewsvc.NewInMemory(), + Reviews: reviewSvc, CDC: store, Events: cdcPipe.Broadcaster, Activity: lcStack.LCM, diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index b4d40905..e9905099 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -15,6 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -54,10 +55,10 @@ func (l *lifecycleStack) Stop() { // over the real zellij runtime, a per-session gitworktree workspace, the shared // store + LCM, the per-session agent resolver (AO_AGENT default), and the // agent messenger. The returned service is mounted at httpd APIDeps.Sessions. -func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, error) { +func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { agents, err := buildAgentResolver(cfg.Agent, log) if err != nil { - return nil, err + return nil, nil, err } ws, err := gitworktree.New(gitworktree.Options{ // Per-session worktrees live under the data dir, so a single AO_DATA_DIR @@ -69,7 +70,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, RepoResolver: projectRepoResolver{store: store}, }) if err != nil { - return nil, fmt.Errorf("session workspace: %w", err) + return nil, nil, fmt.Errorf("session workspace: %w", err) } mgr := sessionmanager.New(sessionmanager.Deps{ Runtime: runtime, @@ -85,7 +86,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, if err != nil { logSCMProviderDisabled(log, err) } - return sessionsvc.NewWithDeps(sessionsvc.Deps{ + sessionSvc := sessionsvc.NewWithDeps(sessionsvc.Deps{ Manager: mgr, Store: store, PRClaimer: store, @@ -93,7 +94,23 @@ 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, - }), nil + }) + // The reviewer runs over the worker's own worktree (reusing the agent + // resolver + runtime) and posts its result to the PR. A nil scmProvider + // leaves the poster unset; Submit then fails loudly rather than panicking. + var poster ports.PRReviewPoster + if scmProvider != nil { + poster = scmProvider + } + reviewSvc := reviewsvc.New(reviewsvc.Deps{ + Store: store, + Sessions: store, + PRs: store, + Projects: store, + Runner: reviewsvc.NewAgentRunner(agents, runtime), + Poster: poster, + }) + return sessionSvc, reviewSvc, nil } // runtimeMessageSender is the narrow part of the concrete runtime needed by diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 0e4815d0..19bc965b 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -149,13 +149,16 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { runtime := zellij.New(zellij.Options{}) messenger := newSessionMessenger(store, runtime, log) - svc, err := startSession(cfg, runtime, store, lcm, messenger, log) + svc, reviewSvc, err := startSession(cfg, runtime, store, lcm, messenger, log) if err != nil { t.Fatalf("startSession: %v", err) } if svc == nil { t.Fatal("startSession returned nil session service") } + if reviewSvc == nil { + t.Fatal("startSession returned nil review service") + } } type captureRuntimeSender struct { diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 1e6cf32f..f7f8e4da 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -36,6 +36,28 @@ type ProjectConfig struct { // Worker and Orchestrator are role-specific harness/agent-config overrides. Worker RoleOverride `json:"worker,omitempty"` Orchestrator RoleOverride `json:"orchestrator,omitempty"` + + // Reviewers names the agent(s) that review a worker's PR when a review is + // triggered. An empty list resolves to one default reviewer + // (see ResolvedReviewers). + Reviewers []ReviewerConfig `json:"reviewers,omitempty"` +} + +// ReviewerConfig names one reviewer agent by harness. +type ReviewerConfig struct { + Harness AgentHarness `json:"harness"` +} + +// DefaultReviewerHarness is the reviewer used when a project configures none. +const DefaultReviewerHarness = HarnessClaudeCode + +// ResolvedReviewers returns the configured reviewers, or a single default +// reviewer when the project sets none. +func (c ProjectConfig) ResolvedReviewers() []ReviewerConfig { + if len(c.Reviewers) == 0 { + return []ReviewerConfig{{Harness: DefaultReviewerHarness}} + } + return c.Reviewers } // RoleOverride overrides the harness and/or agent config for a session role. @@ -91,6 +113,11 @@ func (c ProjectConfig) Validate() error { return fmt.Errorf("symlink %q: %w", s, err) } } + for i, rv := range c.Reviewers { + if !rv.Harness.IsKnown() { + return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness) + } + } return nil } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 76155101..df62d2c0 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -19,6 +19,9 @@ func TestProjectConfigValidate(t *testing.T) { {"symlink parent escape", ProjectConfig{Symlinks: []string{"../escape"}}, true}, {"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true}, {"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, true}, + {"good reviewers", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}}}, false}, + {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, + {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -65,6 +68,20 @@ func TestProjectConfigWithDefaults(t *testing.T) { } } +func TestResolvedReviewers(t *testing.T) { + // Empty config resolves to the single default reviewer. + got := (ProjectConfig{}).ResolvedReviewers() + if len(got) != 1 || got[0].Harness != DefaultReviewerHarness { + t.Fatalf("ResolvedReviewers() = %#v, want one default reviewer", got) + } + + // A configured list is returned as-is. + cfg := ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}, {Harness: HarnessAider}}} + if got := cfg.ResolvedReviewers(); len(got) != 2 || got[0].Harness != HarnessCodex { + t.Fatalf("ResolvedReviewers() = %#v, want configured list", got) + } +} + func TestProjectConfigIsZero(t *testing.T) { if !(ProjectConfig{}).IsZero() { t.Fatal("empty config should be zero") diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go new file mode 100644 index 00000000..ea28c04c --- /dev/null +++ b/backend/internal/domain/review.go @@ -0,0 +1,57 @@ +package domain + +import "time" + +// Review is the per-worker code-review record: one row per worker session +// (SessionID is unique). A repeat trigger reuses this row; the per-pass facts +// live on ReviewRun. +type Review struct { + ID string `json:"id"` + SessionID SessionID `json:"sessionId"` + ProjectID ProjectID `json:"projectId"` + Harness AgentHarness `json:"harness"` + PRURL string `json:"prUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ReviewRun is one review pass against a worker's PR. +type ReviewRun struct { + ID string `json:"id"` + ReviewID string `json:"reviewId"` + SessionID SessionID `json:"sessionId"` + Harness AgentHarness `json:"harness"` + PRURL string `json:"prUrl"` + Status ReviewRunStatus `json:"status"` + Verdict ReviewVerdict `json:"verdict"` + Iteration int `json:"iteration"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ReviewRunStatus is the lifecycle state of a single review pass. +type ReviewRunStatus string + +// Review run statuses. +const ( + ReviewRunPending ReviewRunStatus = "pending" + ReviewRunComplete ReviewRunStatus = "complete" + ReviewRunFailed ReviewRunStatus = "failed" +) + +// ReviewVerdict is the outcome a reviewer reports. The empty verdict marks a +// run that has not produced an outcome yet. +type ReviewVerdict string + +// Review verdicts. +const ( + VerdictNone ReviewVerdict = "" + VerdictApproved ReviewVerdict = "approved" + VerdictChangesRequested ReviewVerdict = "changes_requested" +) + +// Valid reports whether v is a verdict a reviewer may submit (the empty verdict +// is a stored default, not a submittable one). +func (v ReviewVerdict) Valid() bool { + return v == VerdictApproved || v == VerdictChangesRequested +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index c91c02e3..551216f6 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -412,95 +412,6 @@ paths: summary: Resolve review threads on a pull request tags: - prs - /api/v1/reviews: - get: - operationId: listReviews - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/ListReviewsResponse' - description: OK - "501": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Not Implemented - summary: List code-review runs - tags: - - reviews - /api/v1/reviews/{id}/send: - post: - operationId: sendReview - parameters: - - description: Review run id. - in: path - name: id - required: true - schema: - description: Review run id. - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/ReviewResponse' - description: OK - "404": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Not Found - "501": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Not Implemented - summary: Send a review run's findings to its PR - tags: - - reviews - /api/v1/reviews/execute: - post: - operationId: executeReview - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ExecuteReviewInput' - required: true - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/ReviewResponse' - description: Created - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Bad Request - "422": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Unprocessable Entity - "501": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Not Implemented - summary: Start a code-review run for a session's PR - tags: - - reviews /api/v1/sessions: get: operationId: listSessions @@ -909,6 +820,129 @@ paths: summary: Restore a terminated session tags: - sessions + /api/v1/sessions/{sessionId}/reviews: + get: + operationId: listReviews + 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/ListReviewsResponse' + description: OK + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: List a worker's code-review runs + tags: + - reviews + /api/v1/sessions/{sessionId}/reviews/submit: + post: + operationId: submitReview + 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/SubmitReviewInput' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewRunResponse' + 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 + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Submit a reviewer's result for a worker's PR + tags: + - reviews + /api/v1/sessions/{sessionId}/reviews/trigger: + post: + operationId: triggerReview + 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: + "201": + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewRunResponse' + description: Created + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Trigger a code review of a worker's PR + tags: + - reviews /api/v1/sessions/{sessionId}/rollback: post: operationId: rollbackSession @@ -1166,13 +1200,12 @@ components: - state - lastActivityAt type: object - ExecuteReviewInput: + DomainReviewerConfig: properties: - sessionId: - description: Session whose PR to review. + harness: type: string required: - - sessionId + - harness type: object KillSessionResponse: properties: @@ -1296,6 +1329,10 @@ components: items: type: string type: array + reviewers: + items: + $ref: '#/components/schemas/DomainReviewerConfig' + type: array sessionPrefix: type: string symlinks: @@ -1405,53 +1442,48 @@ components: - sessionId - session type: object - ReviewFinding: - properties: - body: - type: string - id: - type: string - line: - type: integer - path: - type: string - severity: - type: string - required: - - id - - path - - line - - severity - - body - type: object - ReviewResponse: - properties: - review: - $ref: '#/components/schemas/ReviewRun' - required: - - review - type: object ReviewRun: properties: createdAt: format: date-time type: string - findings: - items: - $ref: '#/components/schemas/ReviewFinding' - type: array + harness: + type: string id: type: string + iteration: + type: integer + prUrl: + type: string + reviewId: + type: string sessionId: type: string status: type: string + updatedAt: + format: date-time + type: string + verdict: + type: string required: - id + - reviewId - sessionId + - harness + - prUrl - status + - verdict + - iteration - createdAt - - findings + - updatedAt + type: object + ReviewRunResponse: + properties: + review: + $ref: '#/components/schemas/ReviewRun' + required: + - review type: object RoleOverride: properties: @@ -1669,6 +1701,18 @@ components: required: - projectId type: object + SubmitReviewInput: + properties: + body: + description: Review body, posted to the PR. Required for changes_requested. + type: string + verdict: + description: 'Review verdict: approved or changes_requested.' + type: string + required: + - verdict + - body + type: object WorkspaceRepo: properties: name: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 502cc252..ff528916 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -161,11 +161,10 @@ var schemaNames = map[string]string{ "ControllersResolveCommentsResponse": "ResolveCommentsResponse", // httpd/controllers — review wire envelopes "ControllersListReviewsResponse": "ListReviewsResponse", - "ControllersExecuteReviewInput": "ExecuteReviewInput", - "ControllersReviewResponse": "ReviewResponse", - // service/review entities - "ReviewRun": "ReviewRun", - "ReviewFinding": "ReviewFinding", + "ControllersReviewRunResponse": "ReviewRunResponse", + "ControllersSubmitReviewInput": "SubmitReviewInput", + // domain review entities + "DomainReviewRun": "ReviewRun", // service/project entities + DTOs "ProjectProject": "Project", "ProjectSummary": "ProjectSummary", @@ -255,35 +254,41 @@ func operations() []operation { return ops } -// reviewOperations declares the /reviews operations. Must stay 1:1 with the -// routes ReviewsController.Register mounts (enforced by the parity test). +// reviewOperations declares the session-scoped /reviews operations. Must stay +// 1:1 with the routes ReviewsController.Register mounts (enforced by the parity +// test). func reviewOperations() []operation { return []operation{ { - method: http.MethodGet, path: "/api/v1/reviews", id: "listReviews", tag: "reviews", - summary: "List code-review runs", + method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/reviews", id: "listReviews", tag: "reviews", + summary: "List a worker's code-review runs", + pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ {http.StatusOK, controllers.ListReviewsResponse{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, { - method: http.MethodPost, path: "/api/v1/reviews/execute", id: "executeReview", tag: "reviews", - summary: "Start a code-review run for a session's PR", - reqBody: controllers.ExecuteReviewInput{}, + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/trigger", id: "triggerReview", tag: "reviews", + summary: "Trigger a code review of a worker's PR", + pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ - {http.StatusCreated, controllers.ReviewResponse{}}, - {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusCreated, controllers.ReviewRunResponse{}}, {http.StatusUnprocessableEntity, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, }, { - method: http.MethodPost, path: "/api/v1/reviews/{id}/send", id: "sendReview", tag: "reviews", - summary: "Send a review run's findings to its PR", - pathParams: []any{controllers.ReviewIDParam{}}, + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/submit", id: "submitReview", tag: "reviews", + summary: "Submit a reviewer's result for a worker's PR", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.SubmitReviewInput{}, resps: []respUnit{ - {http.StatusOK, controllers.ReviewResponse{}}, + {http.StatusOK, controllers.ReviewRunResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, }, diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index 5d7df6bd..7a0b9d7c 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -7,88 +7,85 @@ import ( "github.com/go-chi/chi/v5" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" ) -// ReviewIDParam is the {id} path parameter on the /reviews/{id} routes. -type ReviewIDParam struct { - ID string `path:"id" description:"Review run id."` -} - -// ListReviewsResponse is the body of GET /api/v1/reviews. +// ListReviewsResponse is the body of GET /api/v1/sessions/{sessionId}/reviews. type ListReviewsResponse struct { - Reviews []reviewsvc.Run `json:"reviews"` + Reviews []domain.ReviewRun `json:"reviews"` } -// ExecuteReviewInput is the body of POST /api/v1/reviews/execute. -type ExecuteReviewInput struct { - SessionID string `json:"sessionId" description:"Session whose PR to review."` +// ReviewRunResponse is the { review } body of trigger (201) and submit (200). +type ReviewRunResponse struct { + Review domain.ReviewRun `json:"review"` } -// ReviewResponse is the { review } body of execute (201) and send (200). -type ReviewResponse struct { - Review reviewsvc.Run `json:"review"` +// SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. +type SubmitReviewInput struct { + Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` + Body string `json:"body" description:"Review body, posted to the PR. Required for changes_requested."` } -// ReviewsController owns the /reviews routes. A nil Svc returns 501. +// ReviewsController owns the session-scoped /reviews routes. A nil Svc returns 501. type ReviewsController struct { Svc reviewsvc.Manager } // Register mounts the review routes on the supplied router. func (c *ReviewsController) Register(r chi.Router) { - r.Get("/reviews", c.list) - r.Post("/reviews/execute", c.execute) - r.Post("/reviews/{id}/send", c.send) + r.Get("/sessions/{sessionId}/reviews", c.list) + r.Post("/sessions/{sessionId}/reviews/trigger", c.trigger) + r.Post("/sessions/{sessionId}/reviews/submit", c.submit) } func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { - apispec.NotImplemented(w, r, "GET", "/api/v1/reviews") + apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/reviews") return } - runs, err := c.Svc.List(r.Context()) + runs, err := c.Svc.List(r.Context(), sessionID(r)) if err != nil { writeReviewError(w, r, err) return } if runs == nil { - runs = []reviewsvc.Run{} + runs = []domain.ReviewRun{} } envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{Reviews: runs}) } -func (c *ReviewsController) execute(w http.ResponseWriter, r *http.Request) { +func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/reviews/execute") - return - } - var in ExecuteReviewInput - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/trigger") return } - run, err := c.Svc.Execute(r.Context(), in.SessionID) + run, err := c.Svc.Trigger(r.Context(), sessionID(r)) if err != nil { writeReviewError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, ReviewResponse{Review: run}) + envelope.WriteJSON(w, http.StatusCreated, ReviewRunResponse{Review: run}) } -func (c *ReviewsController) send(w http.ResponseWriter, r *http.Request) { +func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/reviews/{id}/send") + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/submit") + return + } + var in SubmitReviewInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) return } - run, err := c.Svc.Send(r.Context(), chi.URLParam(r, "id")) + run, err := c.Svc.Submit(r.Context(), sessionID(r), domain.ReviewVerdict(in.Verdict), in.Body) if err != nil { writeReviewError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, ReviewResponse{Review: run}) + envelope.WriteJSON(w, http.StatusOK, ReviewRunResponse{Review: run}) } func writeReviewError(w http.ResponseWriter, r *http.Request, err error) { @@ -96,7 +93,7 @@ func writeReviewError(w http.ResponseWriter, r *http.Request, err error) { case errors.Is(err, reviewsvc.ErrInvalid): envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "REVIEW_INVALID", err.Error(), nil) case errors.Is(err, reviewsvc.ErrNotFound): - envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "REVIEW_NOT_FOUND", "Unknown review run", nil) + envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "REVIEW_NOT_FOUND", err.Error(), nil) default: envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "REVIEW_OPERATION_FAILED", "Review operation failed", nil) } diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 4f7b20ac..00b2dc59 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -72,6 +72,13 @@ type AgentMessenger interface { Send(ctx context.Context, id domain.SessionID, message string) error } +// PRReviewPoster posts an AO code-review result to a PR on the SCM provider. +// The worker picks the posted review up through the SCM observer's review-nudge +// path, so no separate in-process delivery is needed. +type PRReviewPoster interface { + PostPRReview(ctx context.Context, prURL string, verdict domain.ReviewVerdict, body string) error +} + // ---- runtime / agent / workspace plugin ports ---- // Runtime is the full runtime adapter contract: session creation/teardown plus diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index 2e23c6b8..f62acf49 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -1,19 +1,23 @@ -// Package review is the daemon's code-review surface: review runs against a -// session's PR and the findings they produce. +// Package review is the daemon's code-review surface: a configured reviewer +// agent reviews a worker's PR over the worker's own worktree, and the result is +// posted to the SCM provider. The worker picks the feedback up through the +// existing SCM observer → review-nudge path. // -// This is an in-memory implementation. Execution is not yet wired to a real -// review agent — Execute records a pending run so the HTTP surface is live and -// the dashboard renders against real endpoints; agent-backed findings and -// persistence are a follow-up. Mirrors agent-orchestrator's reviews feature -// (packages/web/src/app/api/reviews) on reverbcode's daemon. +// V1 is manual and one-shot: a review runs only when triggered. The reviewer is +// not modeled as a session — it is tracked by the review (one per worker) and +// review_run (one per pass) tables. package review import ( "context" "errors" "fmt" - "sync" "time" + + "github.com/google/uuid" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // ErrInvalid and ErrNotFound let the HTTP layer map service failures to 422/404. @@ -22,87 +26,270 @@ var ( ErrNotFound = errors.New("review: not found") ) -// Severity ranks a finding by how much it should block the human; one of -// "info" | "warning" | "error". Kept as a plain string on the wire. -const ( - SeverityInfo = "info" - SeverityWarning = "warning" - SeverityError = "error" -) +// Store is the persistence surface the review service needs. *sqlite.Store +// satisfies it in production; tests use a fake. +type Store interface { + UpsertReview(ctx context.Context, r domain.Review) error + GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) + InsertReviewRun(ctx context.Context, r domain.ReviewRun) error + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error + GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) + ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) +} + +// Sessions resolves the worker session under review. +type Sessions interface { + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) +} + +// PRs resolves the PR a worker owns. +type PRs interface { + ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) +} -// Finding is one review comment produced for a run. -type Finding struct { - ID string `json:"id"` - Path string `json:"path"` - Line int `json:"line"` - Severity string `json:"severity"` - Body string `json:"body"` +// Projects resolves the per-project reviewer config. +type Projects interface { + GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } -// Run is one code-review execution against a session's PR. -type Run struct { - ID string `json:"id"` - SessionID string `json:"sessionId"` - // Status is one of: pending | complete | sent. - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - Findings []Finding `json:"findings"` +// Runner launches the reviewer agent one-shot over the worker's worktree. +type Runner interface { + Run(ctx context.Context, spec RunSpec) error +} + +// RunSpec describes one reviewer launch. +type RunSpec struct { + WorkerID domain.SessionID + Harness domain.AgentHarness + WorkspacePath string + PRURL string } // Manager is the reviews surface the HTTP controller depends on. type Manager interface { - List(ctx context.Context) ([]Run, error) - Execute(ctx context.Context, sessionID string) (Run, error) - Send(ctx context.Context, id string) (Run, error) + Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) + Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) + List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) } -type memStore struct { - mu sync.Mutex - runs map[string]*Run - seq int +// Deps wires the review service. +type Deps struct { + Store Store + Sessions Sessions + PRs PRs + Projects Projects + Runner Runner + Poster ports.PRReviewPoster + + // Clock and NewID are injectable for deterministic tests. + Clock func() time.Time + NewID func() string } -// NewInMemory returns an in-memory Manager. Runs do not survive a daemon -// restart. -func NewInMemory() Manager { - return &memStore{runs: map[string]*Run{}} +// Service is the daemon's code-review service. +type Service struct { + store Store + sessions Sessions + prs PRs + projects Projects + runner Runner + poster ports.PRReviewPoster + clock func() time.Time + newID func() string } -func (s *memStore) List(_ context.Context) ([]Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]Run, 0, len(s.runs)) - for _, run := range s.runs { - out = append(out, *run) +var _ Manager = (*Service)(nil) + +// New wires a Service from its dependencies, defaulting the clock and id source. +func New(d Deps) *Service { + clock := d.Clock + if clock == nil { + clock = func() time.Time { return time.Now().UTC() } + } + newID := d.NewID + if newID == nil { + newID = uuid.NewString + } + return &Service{ + store: d.Store, + sessions: d.Sessions, + prs: d.PRs, + projects: d.Projects, + runner: d.Runner, + poster: d.Poster, + clock: clock, + newID: newID, } - return out, nil } -func (s *memStore) Execute(_ context.Context, sessionID string) (Run, error) { - if sessionID == "" { - return Run{}, fmt.Errorf("%w: sessionId is required", ErrInvalid) +// Trigger starts a review pass for a worker's PR: it reuses (or creates) the +// worker's review row, records a pending review_run, and launches the configured +// reviewer agent over the worker's worktree. +func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) { + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + worker, ok, err := s.sessions.GetSession(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err } - s.mu.Lock() - defer s.mu.Unlock() - s.seq++ - run := &Run{ - ID: fmt.Sprintf("rev-%d", s.seq), - SessionID: sessionID, - Status: "pending", - CreatedAt: time.Now().UTC(), - Findings: []Finding{}, + if !ok { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID) + } + if worker.IsTerminated { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID) + } + if worker.Metadata.WorkspacePath == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) + } + + prURL, err := s.workerPRURL(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err + } + + harness, err := s.reviewerHarness(ctx, worker.ProjectID) + if err != nil { + return domain.ReviewRun{}, err + } + + now := s.clock() + review, err := s.upsertReview(ctx, worker, harness, prURL, now) + if err != nil { + return domain.ReviewRun{}, err + } + + run := domain.ReviewRun{ + ID: s.newID(), + ReviewID: review.ID, + SessionID: workerID, + Harness: harness, + PRURL: prURL, + Status: domain.ReviewRunPending, + Verdict: domain.VerdictNone, + Iteration: s.nextIteration(ctx, workerID), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.InsertReviewRun(ctx, run); err != nil { + return domain.ReviewRun{}, err + } + + if err := s.runner.Run(ctx, RunSpec{ + WorkerID: workerID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: prURL, + }); err != nil { + // The pass never launched; record it as failed so a stale pending row + // does not look like an in-flight review forever. + _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, s.clock()) + return domain.ReviewRun{}, fmt.Errorf("launch reviewer: %w", err) } - s.runs[run.ID] = run - return *run, nil + return run, nil } -func (s *memStore) Send(_ context.Context, id string) (Run, error) { - s.mu.Lock() - defer s.mu.Unlock() - run, ok := s.runs[id] +// Submit records a reviewer's result for a worker's active review pass and posts +// it to the PR. The review body is not persisted — it lives on the PR. +func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + if !verdict.Valid() { + return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) + } + if verdict == domain.VerdictChangesRequested && body == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) + } + if s.poster == nil { + return domain.ReviewRun{}, fmt.Errorf("%w: review posting is unavailable (no SCM credentials)", ErrInvalid) + } + + run, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err + } if !ok { - return Run{}, fmt.Errorf("%w: review %q", ErrNotFound, id) + return domain.ReviewRun{}, fmt.Errorf("%w: no review run for worker %q", ErrNotFound, workerID) + } + + if err := s.poster.PostPRReview(ctx, run.PRURL, verdict, body); err != nil { + _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, verdict, s.clock()) + return domain.ReviewRun{}, fmt.Errorf("post review: %w", err) + } + + now := s.clock() + if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, now); err != nil { + return domain.ReviewRun{}, err + } + run.Status = domain.ReviewRunComplete + run.Verdict = verdict + run.UpdatedAt = now + return run, nil +} + +// List returns the review passes recorded for a worker, newest first. +func (s *Service) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { + if workerID == "" { + return nil, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + return s.store.ListReviewRunsBySession(ctx, workerID) +} + +func (s *Service) workerPRURL(ctx context.Context, workerID domain.SessionID) (string, error) { + prs, err := s.prs.ListPRsBySession(ctx, workerID) + if err != nil { + return "", err + } + if len(prs) == 0 { + return "", fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) + } + return prs[0].URL, nil +} + +func (s *Service) reviewerHarness(ctx context.Context, projectID domain.ProjectID) (domain.AgentHarness, error) { + var cfg domain.ProjectConfig + if s.projects != nil { + if proj, ok, err := s.projects.GetProject(ctx, string(projectID)); err != nil { + return "", err + } else if ok { + cfg = proj.Config + } + } + reviewers := cfg.ResolvedReviewers() + // V1 runs a single reviewer; the first configured (or default) one. + return reviewers[0].Harness, nil +} + +func (s *Service) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.AgentHarness, prURL string, now time.Time) (domain.Review, error) { + existing, ok, err := s.store.GetReviewBySession(ctx, worker.ID) + if err != nil { + return domain.Review{}, err + } + review := domain.Review{ + ID: s.newID(), + SessionID: worker.ID, + ProjectID: worker.ProjectID, + Harness: harness, + PRURL: prURL, + CreatedAt: now, + UpdatedAt: now, + } + if ok { + // Reuse the existing row's identity and creation time; UpsertReview + // refreshes harness/pr_url/updated_at. + review.ID = existing.ID + review.CreatedAt = existing.CreatedAt + } + if err := s.store.UpsertReview(ctx, review); err != nil { + return domain.Review{}, err + } + return review, nil +} + +func (s *Service) nextIteration(ctx context.Context, workerID domain.SessionID) int { + if latest, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID); err == nil && ok { + return latest.Iteration + 1 } - run.Status = "sent" - return *run, nil + return 1 } diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go new file mode 100644 index 00000000..aabcb43d --- /dev/null +++ b/backend/internal/service/review/review_test.go @@ -0,0 +1,266 @@ +package review + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// --- fakes --- + +type fakeStore struct { + review *domain.Review + runs []domain.ReviewRun + upsertErr error + insertErr error + updateErr error +} + +func (f *fakeStore) UpsertReview(_ context.Context, r domain.Review) error { + if f.upsertErr != nil { + return f.upsertErr + } + cp := r + f.review = &cp + return nil +} +func (f *fakeStore) GetReviewBySession(_ context.Context, _ domain.SessionID) (domain.Review, bool, error) { + if f.review == nil { + return domain.Review{}, false, nil + } + return *f.review, true, nil +} +func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error { + if f.insertErr != nil { + return f.insertErr + } + f.runs = append(f.runs, r) + return nil +} +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error { + if f.updateErr != nil { + return f.updateErr + } + for i := range f.runs { + if f.runs[i].ID == id { + f.runs[i].Status = status + f.runs[i].Verdict = verdict + f.runs[i].UpdatedAt = updatedAt + } + } + return nil +} +func (f *fakeStore) GetLatestReviewRunBySession(_ context.Context, _ domain.SessionID) (domain.ReviewRun, bool, error) { + if len(f.runs) == 0 { + return domain.ReviewRun{}, false, nil + } + return f.runs[len(f.runs)-1], true, nil +} +func (f *fakeStore) ListReviewRunsBySession(_ context.Context, _ domain.SessionID) ([]domain.ReviewRun, error) { + return f.runs, nil +} + +type fakeSessions struct { + rec domain.SessionRecord + ok bool +} + +func (f fakeSessions) GetSession(_ context.Context, _ domain.SessionID) (domain.SessionRecord, bool, error) { + return f.rec, f.ok, nil +} + +type fakePRs struct{ prs []domain.PullRequest } + +func (f fakePRs) ListPRsBySession(_ context.Context, _ domain.SessionID) ([]domain.PullRequest, error) { + return f.prs, nil +} + +type fakeProjects struct{ cfg domain.ProjectConfig } + +func (f fakeProjects) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { + return domain.ProjectRecord{ID: id, Config: f.cfg}, true, nil +} + +type fakeRunner struct { + spec RunSpec + err error + ran bool +} + +func (f *fakeRunner) Run(_ context.Context, spec RunSpec) error { + f.ran = true + f.spec = spec + return f.err +} + +type fakePoster struct { + verdict domain.ReviewVerdict + body string + url string + err error + called bool +} + +func (f *fakePoster) PostPRReview(_ context.Context, prURL string, verdict domain.ReviewVerdict, body string) error { + f.called = true + f.url = prURL + f.verdict = verdict + f.body = body + return f.err +} + +func liveWorker() domain.SessionRecord { + return domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}, + } +} + +func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner, poster *fakePoster) *Service { + ids := 0 + return New(Deps{ + Store: store, Sessions: sessions, PRs: prs, Projects: projects, Runner: runner, Poster: poster, + Clock: func() time.Time { return time.Unix(0, 0).UTC() }, + NewID: func() string { ids++; return "id-" + string(rune('0'+ids)) }, + }) +} + +// --- tests --- + +func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { + store := &fakeStore{} + sessions := fakeSessions{rec: liveWorker(), ok: true} + prs := fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1"}}} + projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.HarnessCodex}}}} + runner := &fakeRunner{} + svc := newServiceForTest(store, sessions, prs, projects, runner, &fakePoster{}) + + run, err := svc.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if run.Status != domain.ReviewRunPending || run.Iteration != 1 || run.Harness != domain.HarnessCodex { + t.Fatalf("run = %+v", run) + } + if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.HarnessCodex { + t.Fatalf("runner spec = %+v ran=%v", runner.spec, runner.ran) + } + if store.review == nil || store.review.PRURL != "https://github.com/o/r/pull/1" { + t.Fatalf("review row = %+v", store.review) + } +} + +func TestTriggerDefaultsReviewerHarness(t *testing.T) { + store := &fakeStore{} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + run, err := svc.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if run.Harness != domain.DefaultReviewerHarness { + t.Fatalf("harness = %q, want default %q", run.Harness, domain.DefaultReviewerHarness) + } +} + +func TestTriggerSecondPassIncrementsIteration(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "old", Iteration: 1}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + run, err := svc.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if run.Iteration != 2 { + t.Fatalf("iteration = %d, want 2", run.Iteration) + } +} + +func TestTriggerRejectsMissingWorkerPRAndState(t *testing.T) { + base := func() *fakeStore { return &fakeStore{} } + t.Run("unknown worker", func(t *testing.T) { + svc := newServiceForTest(base(), fakeSessions{ok: false}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + t.Run("terminated worker", func(t *testing.T) { + rec := liveWorker() + rec.IsTerminated = true + svc := newServiceForTest(base(), fakeSessions{rec: rec, ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } + }) + t.Run("no pr", func(t *testing.T) { + svc := newServiceForTest(base(), fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } + }) +} + +func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { + store := &fakeStore{} + runner := &fakeRunner{err: errors.New("boom")} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, runner, &fakePoster{}) + if _, err := svc.Trigger(context.Background(), "mer-1"); err == nil { + t.Fatal("want launch error") + } + if len(store.runs) != 1 || store.runs[0].Status != domain.ReviewRunFailed { + t.Fatalf("run not marked failed: %+v", store.runs) + } +} + +func TestSubmitPostsToGitHubAndCompletes(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "https://github.com/o/r/pull/1", Status: domain.ReviewRunPending}}} + poster := &fakePoster{} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, poster) + + run, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, "fix it") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if !poster.called || poster.url != "https://github.com/o/r/pull/1" || poster.verdict != domain.VerdictChangesRequested || poster.body != "fix it" { + t.Fatalf("poster = %+v", poster) + } + if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested { + t.Fatalf("run = %+v", run) + } +} + +func TestSubmitValidation(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunPending}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + + if _, err := svc.Submit(context.Background(), "mer-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { + t.Fatalf("bad verdict err = %v", err) + } + if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("empty body err = %v", err) + } +} + +func TestSubmitNoRun(t *testing.T) { + svc := newServiceForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestSubmitPostFailureMarksFailed(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunPending}}} + poster := &fakePoster{err: errors.New("network")} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, poster) + if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); err == nil { + t.Fatal("want post error") + } + if store.runs[0].Status != domain.ReviewRunFailed { + t.Fatalf("run not marked failed: %+v", store.runs[0]) + } +} diff --git a/backend/internal/service/review/runner.go b/backend/internal/service/review/runner.go new file mode 100644 index 00000000..061443bd --- /dev/null +++ b/backend/internal/service/review/runner.go @@ -0,0 +1,65 @@ +package review + +import ( + "context" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// agentRunner launches a reviewer agent one-shot over the worker's worktree by +// reusing the per-session agent resolver and runtime. The reviewer is not a +// session: its runtime pane is not persisted and not reaped here. It reviews the +// worktree and reports back by running `ao review submit`. +type agentRunner struct { + agents ports.AgentResolver + runtime ports.Runtime +} + +// NewAgentRunner builds the production reviewer runner. +func NewAgentRunner(agents ports.AgentResolver, runtime ports.Runtime) Runner { + return agentRunner{agents: agents, runtime: runtime} +} + +func (r agentRunner) Run(ctx context.Context, spec RunSpec) error { + agent, ok := r.agents.Agent(spec.Harness) + if !ok { + return fmt.Errorf("no agent adapter for reviewer harness %q", spec.Harness) + } + reviewerID := "review-" + string(spec.WorkerID) + prompt := reviewPrompt(spec) + argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: reviewerID, + WorkspacePath: spec.WorkspacePath, + Prompt: prompt, + }) + if err != nil { + return fmt.Errorf("reviewer launch command: %w", err) + } + if _, err := r.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: domain.SessionID(reviewerID), + WorkspacePath: spec.WorkspacePath, + Argv: argv, + Env: reviewerEnv(spec), + }); err != nil { + return fmt.Errorf("reviewer runtime: %w", err) + } + return nil +} + +// reviewerEnv carries the worker the reviewer submits against, so the reviewer's +// `ao review submit` targets the right session. +func reviewerEnv(spec RunSpec) map[string]string { + return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} +} + +func reviewPrompt(spec RunSpec) string { + return fmt.Sprintf(`You are an AO code reviewer. Review the changes in this worktree for pull request %s. + +Write your full review as Markdown to a file (for example review.md), then submit it by running: + + ao review submit %s --verdict --body review.md + +Use changes_requested if the PR needs work, approved if it is ready. Do not push commits or modify the code — only review it.`, spec.PRURL, spec.WorkerID) +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index f8d614bf..3d301655 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -111,6 +111,29 @@ type Project struct { Kind string } +type Review struct { + ID string + SessionID domain.SessionID + ProjectID domain.ProjectID + Harness domain.AgentHarness + PRURL string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ReviewRun struct { + ID string + ReviewID string + SessionID domain.SessionID + Harness domain.AgentHarness + PRURL string + Status domain.ReviewRunStatus + Verdict domain.ReviewVerdict + Iteration int64 + CreatedAt time.Time + UpdatedAt time.Time +} + type Session struct { ID domain.SessionID ProjectID domain.ProjectID diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go new file mode 100644 index 00000000..cf838035 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -0,0 +1,182 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: review.sql + +package gen + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +const getLatestReviewRunBySession = `-- name: GetLatestReviewRunBySession :one +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1 +` + +func (q *Queries) GetLatestReviewRunBySession(ctx context.Context, sessionID domain.SessionID) (ReviewRun, error) { + row := q.db.QueryRowContext(ctx, getLatestReviewRunBySession, sessionID) + var i ReviewRun + err := row.Scan( + &i.ID, + &i.ReviewID, + &i.SessionID, + &i.Harness, + &i.PRURL, + &i.Status, + &i.Verdict, + &i.Iteration, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getReviewBySession = `-- name: GetReviewBySession :one +SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at +FROM review WHERE session_id = ? +` + +func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.SessionID) (Review, error) { + row := q.db.QueryRowContext(ctx, getReviewBySession, sessionID) + var i Review + err := row.Scan( + &i.ID, + &i.SessionID, + &i.ProjectID, + &i.Harness, + &i.PRURL, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertReviewRun = `-- name: InsertReviewRun :exec +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type InsertReviewRunParams struct { + ID string + ReviewID string + SessionID domain.SessionID + Harness domain.AgentHarness + PRURL string + Status domain.ReviewRunStatus + Verdict domain.ReviewVerdict + Iteration int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams) error { + _, err := q.db.ExecContext(ctx, insertReviewRun, + arg.ID, + arg.ReviewID, + arg.SessionID, + arg.Harness, + arg.PRURL, + arg.Status, + arg.Verdict, + arg.Iteration, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC +` + +func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain.SessionID) ([]ReviewRun, error) { + rows, err := q.db.QueryContext(ctx, listReviewRunsBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReviewRun{} + for rows.Next() { + var i ReviewRun + if err := rows.Scan( + &i.ID, + &i.ReviewID, + &i.SessionID, + &i.Harness, + &i.PRURL, + &i.Status, + &i.Verdict, + &i.Iteration, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateReviewRunResult = `-- name: UpdateReviewRunResult :exec +UPDATE review_run SET status = ?, verdict = ?, updated_at = ? WHERE id = ? +` + +type UpdateReviewRunResultParams struct { + Status domain.ReviewRunStatus + Verdict domain.ReviewVerdict + UpdatedAt time.Time + ID string +} + +func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRunResultParams) error { + _, err := q.db.ExecContext(ctx, updateReviewRunResult, + arg.Status, + arg.Verdict, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const upsertReview = `-- name: UpsertReview :exec +INSERT INTO review (id, session_id, project_id, harness, pr_url, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + harness = excluded.harness, + pr_url = excluded.pr_url, + updated_at = excluded.updated_at +` + +type UpsertReviewParams struct { + ID string + SessionID domain.SessionID + ProjectID domain.ProjectID + Harness domain.AgentHarness + PRURL string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (q *Queries) UpsertReview(ctx context.Context, arg UpsertReviewParams) error { + _, err := q.db.ExecContext(ctx, upsertReview, + arg.ID, + arg.SessionID, + arg.ProjectID, + arg.Harness, + arg.PRURL, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql new file mode 100644 index 00000000..ca1e604a --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql @@ -0,0 +1,49 @@ +-- Configurable AO code review (issue #192). review holds one row per worker +-- session under review (session_id UNIQUE); a repeat trigger reuses the row. +-- review_run holds the per-pass facts. The review body is not persisted — it is +-- posted to the SCM provider and flows to the worker through the SCM observer. + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE review ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE REFERENCES sessions (id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects (id), + harness TEXT NOT NULL, + pr_url TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE review_run ( + id TEXT PRIMARY KEY, + review_id TEXT NOT NULL REFERENCES review (id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + harness TEXT NOT NULL, + pr_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'complete', 'failed')), + verdict TEXT NOT NULL DEFAULT '' + CHECK (verdict IN ('', 'approved', 'changes_requested')), + iteration INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_review_run_session ON review_run (session_id, iteration); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_review_run_session; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE review_run; +-- +goose StatementEnd +-- +goose StatementBegin +DROP TABLE review; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql new file mode 100644 index 00000000..3240b517 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -0,0 +1,26 @@ +-- name: UpsertReview :exec +INSERT INTO review (id, session_id, project_id, harness, pr_url, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + harness = excluded.harness, + pr_url = excluded.pr_url, + updated_at = excluded.updated_at; + +-- name: GetReviewBySession :one +SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at +FROM review WHERE session_id = ?; + +-- name: InsertReviewRun :exec +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateReviewRunResult :exec +UPDATE review_run SET status = ?, verdict = ?, updated_at = ? WHERE id = ?; + +-- name: GetLatestReviewRunBySession :one +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1; + +-- name: ListReviewRunsBySession :many +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go new file mode 100644 index 00000000..7057f735 --- /dev/null +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -0,0 +1,123 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// UpsertReview inserts the per-worker review row, or reuses the existing one +// (session_id is unique) by refreshing its harness/pr_url/updated_at. +func (s *Store) UpsertReview(ctx context.Context, r domain.Review) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpsertReview(ctx, gen.UpsertReviewParams{ + ID: r.ID, + SessionID: r.SessionID, + ProjectID: r.ProjectID, + Harness: r.Harness, + PRURL: r.PRURL, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }) +} + +// GetReviewBySession returns the review row for a worker session, ok=false if none. +func (s *Store) GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) { + row, err := s.qr.GetReviewBySession(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return domain.Review{}, false, nil + } + if err != nil { + return domain.Review{}, false, fmt.Errorf("get review by session %s: %w", id, err) + } + return reviewFromRow(row), true, nil +} + +// InsertReviewRun records a new review pass. +func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.InsertReviewRun(ctx, gen.InsertReviewRunParams{ + ID: r.ID, + ReviewID: r.ReviewID, + SessionID: r.SessionID, + Harness: r.Harness, + PRURL: r.PRURL, + Status: r.Status, + Verdict: r.Verdict, + Iteration: int64(r.Iteration), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }) +} + +// UpdateReviewRunResult sets the status/verdict of a review pass. +func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpdateReviewRunResult(ctx, gen.UpdateReviewRunResultParams{ + Status: status, + Verdict: verdict, + UpdatedAt: updatedAt, + ID: id, + }) +} + +// GetLatestReviewRunBySession returns the most recent review pass for a worker +// session, ok=false if none. +func (s *Store) GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) { + row, err := s.qr.GetLatestReviewRunBySession(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return domain.ReviewRun{}, false, nil + } + if err != nil { + return domain.ReviewRun{}, false, fmt.Errorf("get latest review run for session %s: %w", id, err) + } + return reviewRunFromRow(row), true, nil +} + +// ListReviewRunsBySession returns all review passes for a worker session, newest first. +func (s *Store) ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) { + rows, err := s.qr.ListReviewRunsBySession(ctx, id) + if err != nil { + return nil, fmt.Errorf("list review runs for session %s: %w", id, err) + } + out := make([]domain.ReviewRun, 0, len(rows)) + for _, row := range rows { + out = append(out, reviewRunFromRow(row)) + } + return out, nil +} + +func reviewFromRow(r gen.Review) domain.Review { + return domain.Review{ + ID: r.ID, + SessionID: r.SessionID, + ProjectID: r.ProjectID, + Harness: r.Harness, + PRURL: r.PRURL, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { + return domain.ReviewRun{ + ID: r.ID, + ReviewID: r.ReviewID, + SessionID: r.SessionID, + Harness: r.Harness, + PRURL: r.PRURL, + Status: r.Status, + Verdict: r.Verdict, + Iteration: int(r.Iteration), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go new file mode 100644 index 00000000..4a570eca --- /dev/null +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -0,0 +1,87 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + rec, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Fatalf("create session: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + + // First upsert creates the review row. + if err := s.UpsertReview(ctx, domain.Review{ + ID: "rev-1", SessionID: rec.ID, ProjectID: rec.ProjectID, + Harness: domain.HarnessCodex, PRURL: "https://example/pr/1", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert review: %v", err) + } + // Second upsert with the same session reuses the row (session_id UNIQUE), + // refreshing harness/pr_url but keeping the original id. + if err := s.UpsertReview(ctx, domain.Review{ + ID: "rev-2", SessionID: rec.ID, ProjectID: rec.ProjectID, + Harness: domain.HarnessAider, PRURL: "https://example/pr/2", + CreatedAt: now, UpdatedAt: now.Add(time.Second), + }); err != nil { + t.Fatalf("upsert review (reuse): %v", err) + } + got, ok, err := s.GetReviewBySession(ctx, rec.ID) + if err != nil || !ok { + t.Fatalf("get review: ok=%v err=%v", ok, err) + } + if got.ID != "rev-1" { + t.Fatalf("upsert created a new row, want reuse: id=%q", got.ID) + } + if got.Harness != domain.HarnessAider || got.PRURL != "https://example/pr/2" { + t.Fatalf("upsert did not refresh fields: %+v", got) + } + + // A run inserts pending and updates to complete/changes_requested. + if err := s.InsertReviewRun(ctx, domain.ReviewRun{ + ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.HarnessAider, + PRURL: got.PRURL, Status: domain.ReviewRunPending, Verdict: domain.VerdictNone, + Iteration: 1, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("insert run: %v", err) + } + if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, now.Add(2*time.Second)); err != nil { + t.Fatalf("update run: %v", err) + } + + latest, ok, err := s.GetLatestReviewRunBySession(ctx, rec.ID) + if err != nil || !ok { + t.Fatalf("latest run: ok=%v err=%v", ok, err) + } + if latest.Status != domain.ReviewRunComplete || latest.Verdict != domain.VerdictChangesRequested { + t.Fatalf("run result not persisted: %+v", latest) + } + + runs, err := s.ListReviewRunsBySession(ctx, rec.ID) + if err != nil { + t.Fatalf("list runs: %v", err) + } + if len(runs) != 1 || runs[0].ID != "run-1" { + t.Fatalf("list runs = %+v", runs) + } +} + +func TestReviewGettersMissing(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + if _, ok, err := s.GetReviewBySession(ctx, "mer-1"); err != nil || ok { + t.Fatalf("missing review: ok=%v err=%v", ok, err) + } + if _, ok, err := s.GetLatestReviewRunBySession(ctx, "mer-1"); err != nil || ok { + t.Fatalf("missing run: ok=%v err=%v", ok, err) + } +} diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml index 1813a599..d91d35b9 100644 --- a/backend/sqlc.yaml +++ b/backend/sqlc.yaml @@ -92,3 +92,31 @@ sql: go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" type: "ActivityState" + - column: "review.session_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" + - column: "review.project_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ProjectID" + - column: "review.harness" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "AgentHarness" + - column: "review_run.session_id" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "SessionID" + - column: "review_run.harness" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "AgentHarness" + - column: "review_run.status" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ReviewRunStatus" + - column: "review_run.verdict" + go_type: + import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" + type: "ReviewVerdict" diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 93d306ad..bf682e4a 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -143,41 +143,43 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/reviews": { + "/api/v1/sessions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List code-review runs */ - get: operations["listReviews"]; + /** List sessions */ + get: operations["listSessions"]; put?: never; - post?: never; + /** Spawn a new agent session */ + post: operations["spawnSession"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/reviews/{id}/send": { + "/api/v1/sessions/{sessionId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Fetch one session */ + get: operations["getSession"]; put?: never; - /** Send a review run's findings to its PR */ - post: operations["sendReview"]; + post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Rename a session display name */ + patch: operations["renameSession"]; trace?: never; }; - "/api/v1/reviews/execute": { + "/api/v1/sessions/{sessionId}/activity": { parameters: { query?: never; header?: never; @@ -186,51 +188,49 @@ export interface paths { }; get?: never; put?: never; - /** Start a code-review run for a session's PR */ - post: operations["executeReview"]; + /** Report an agent activity-state signal for a session */ + post: operations["setSessionActivity"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/sessions": { + "/api/v1/sessions/{sessionId}/kill": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List sessions */ - get: operations["listSessions"]; + get?: never; put?: never; - /** Spawn a new agent session */ - post: operations["spawnSession"]; + /** Mark a session terminated and tear down runtime/workspace resources */ + post: operations["killSession"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}": { + "/api/v1/sessions/{sessionId}/pr": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Fetch one session */ - get: operations["getSession"]; + /** List pull requests owned by a session */ + get: operations["listSessionPRs"]; put?: never; post?: never; delete?: never; options?: never; head?: never; - /** Rename a session display name */ - patch: operations["renameSession"]; + patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/activity": { + "/api/v1/sessions/{sessionId}/pr/claim": { parameters: { query?: never; header?: never; @@ -239,15 +239,15 @@ export interface paths { }; get?: never; put?: never; - /** Report an agent activity-state signal for a session */ - post: operations["setSessionActivity"]; + /** Claim an existing pull request for a session */ + post: operations["claimSessionPR"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/kill": { + "/api/v1/sessions/{sessionId}/restore": { parameters: { query?: never; header?: never; @@ -256,23 +256,23 @@ export interface paths { }; get?: never; put?: never; - /** Mark a session terminated and tear down runtime/workspace resources */ - post: operations["killSession"]; + /** Restore a terminated session */ + post: operations["restoreSession"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/pr": { + "/api/v1/sessions/{sessionId}/reviews": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List pull requests owned by a session */ - get: operations["listSessionPRs"]; + /** List a worker's code-review runs */ + get: operations["listReviews"]; put?: never; post?: never; delete?: never; @@ -281,7 +281,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/pr/claim": { + "/api/v1/sessions/{sessionId}/reviews/submit": { parameters: { query?: never; header?: never; @@ -290,15 +290,15 @@ export interface paths { }; get?: never; put?: never; - /** Claim an existing pull request for a session */ - post: operations["claimSessionPR"]; + /** Submit a reviewer's result for a worker's PR */ + post: operations["submitReview"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/restore": { + "/api/v1/sessions/{sessionId}/reviews/trigger": { parameters: { query?: never; header?: never; @@ -307,8 +307,8 @@ export interface paths { }; get?: never; put?: never; - /** Restore a terminated session */ - post: operations["restoreSession"]; + /** Trigger a code review of a worker's PR */ + post: operations["triggerReview"]; delete?: never; options?: never; head?: never; @@ -422,9 +422,8 @@ export interface components { lastActivityAt: string; state: string; }; - ExecuteReviewInput: { - /** @description Session whose PR to review. */ - sessionId: string; + DomainReviewerConfig: { + harness: string; }; KillSessionResponse: { freed?: boolean; @@ -473,6 +472,7 @@ export interface components { }; orchestrator?: components["schemas"]["RoleOverride"]; postCreate?: string[]; + reviewers?: components["schemas"]["DomainReviewerConfig"][]; sessionPrefix?: string; symlinks?: string[]; worker?: components["schemas"]["RoleOverride"]; @@ -515,23 +515,22 @@ export interface components { session: components["schemas"]["Session"]; sessionId: string; }; - ReviewFinding: { - body: string; - id: string; - line: number; - path: string; - severity: string; - }; - ReviewResponse: { - review: components["schemas"]["ReviewRun"]; - }; ReviewRun: { /** Format: date-time */ createdAt: string; - findings: components["schemas"]["ReviewFinding"][]; + harness: string; id: string; + iteration: number; + prUrl: string; + reviewId: string; sessionId: string; status: string; + /** Format: date-time */ + updatedAt: string; + verdict: string; + }; + ReviewRunResponse: { + review: components["schemas"]["ReviewRun"]; }; RoleOverride: { agent?: string; @@ -614,6 +613,12 @@ export interface components { projectId: string; prompt?: string; }; + SubmitReviewInput: { + /** @description Review body, posted to the PR. Required for changes_requested. */ + body: string; + /** @description Review verdict: approved or changes_requested. */ + verdict: string; + }; WorkspaceRepo: { name: string; relativePath: string; @@ -1160,127 +1165,6 @@ export interface operations { }; }; }; - listReviews: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ListReviewsResponse"]; - }; - }; - /** @description Not Implemented */ - 501: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - }; - }; - sendReview: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Review run id. */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReviewResponse"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - /** @description Not Implemented */ - 501: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - }; - }; - executeReview: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ExecuteReviewInput"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReviewResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - /** @description Not Implemented */ - 501: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - }; - }; listSessions: { parameters: { query?: { @@ -1777,6 +1661,160 @@ export interface operations { }; }; }; + listReviews: { + 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"]["ListReviewsResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + submitReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewRunResponse"]; + }; + }; + /** @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 Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + triggerReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewRunResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; rollbackSession: { parameters: { query?: never; From 296173d9f0dcd8f84f05526b4e1ded05c7d9f0ac Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 19:32:28 +0530 Subject: [PATCH 02/11] =?UTF-8?q?refactor(review):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20submit/poster/CLI,=20default=20reviewer=20to?= =?UTF-8?q?=20worker=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #197 review feedback: - Reviewer agent posts its review to the PR itself, so remove the ports.PRReviewPoster port, the GitHub review poster, the submit HTTP route + DTO, and the service Submit method (#1, #4, #7). - Trigger spawns the reviewer agent over the worker's worktree with its own review prompt, mirroring the session launch flow (resolve agent by harness -> argv -> runtime.Create) (#8, #9). - Default reviewer harness reuses the worker's harness when supported, falling back to claude-code; reviewer config stays independent of the worker override (#5, #6). - Drop the `ao review` CLI for this PR's scope (#2, #3). Regenerated OpenAPI + TS types. Co-Authored-By: Claude Opus 4.8 --- .../adapters/scm/github/review_poster.go | 42 ---- .../adapters/scm/github/review_poster_test.go | 61 ------ backend/internal/cli/review.go | 186 ------------------ backend/internal/cli/review_test.go | 130 ------------ backend/internal/cli/root.go | 1 - backend/internal/daemon/lifecycle_wiring.go | 11 +- backend/internal/domain/projectconfig.go | 25 ++- backend/internal/domain/projectconfig_test.go | 22 ++- backend/internal/httpd/apispec/openapi.yaml | 63 ------ .../internal/httpd/apispec/specgen/build.go | 14 -- backend/internal/httpd/controllers/reviews.go | 28 +-- backend/internal/ports/outbound.go | 7 - backend/internal/service/review/review.go | 68 ++----- .../internal/service/review/review_test.go | 114 ++++------- backend/internal/service/review/runner.go | 23 +-- frontend/src/api/schema.ts | 86 -------- 16 files changed, 91 insertions(+), 790 deletions(-) delete mode 100644 backend/internal/adapters/scm/github/review_poster.go delete mode 100644 backend/internal/adapters/scm/github/review_poster_test.go delete mode 100644 backend/internal/cli/review.go delete mode 100644 backend/internal/cli/review_test.go diff --git a/backend/internal/adapters/scm/github/review_poster.go b/backend/internal/adapters/scm/github/review_poster.go deleted file mode 100644 index 786c3089..00000000 --- a/backend/internal/adapters/scm/github/review_poster.go +++ /dev/null @@ -1,42 +0,0 @@ -package github - -import ( - "context" - "fmt" - "net/http" - "strconv" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// PostPRReview posts an AO code-review result to a PR as a GitHub pull-request -// review. The verdict maps to the review event so the result lands in the -// review-decision path the worker already consumes through the SCM observer. -func (p *Provider) PostPRReview(ctx context.Context, prURL string, verdict domain.ReviewVerdict, body string) error { - owner, repo, number, err := parsePRURL(prURL) - if err != nil { - return err - } - event, err := reviewEvent(verdict) - if err != nil { - return err - } - payload := map[string]any{"event": event, "body": body} - _, err = p.client.doREST(ctx, http.MethodPost, repoPath(owner, repo, "pulls", strconv.Itoa(number), "reviews"), nil, payload) - if err != nil { - return fmt.Errorf("github scm: post review on %s: %w", prURL, err) - } - return nil -} - -// reviewEvent maps an AO verdict onto a GitHub review event. -func reviewEvent(verdict domain.ReviewVerdict) (string, error) { - switch verdict { - case domain.VerdictApproved: - return "APPROVE", nil - case domain.VerdictChangesRequested: - return "REQUEST_CHANGES", nil - default: - return "", fmt.Errorf("github scm: unsupported review verdict %q", verdict) - } -} diff --git a/backend/internal/adapters/scm/github/review_poster_test.go b/backend/internal/adapters/scm/github/review_poster_test.go deleted file mode 100644 index a746e18a..00000000 --- a/backend/internal/adapters/scm/github/review_poster_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package github - -import ( - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestPostPRReview(t *testing.T) { - tests := []struct { - name string - verdict domain.ReviewVerdict - wantEvent string - }{ - {"changes requested", domain.VerdictChangesRequested, "REQUEST_CHANGES"}, - {"approved", domain.VerdictApproved, "APPROVE"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f := newFakeGH(t) - f.on(http.MethodPost, "/repos/octocat/hello/pulls/42/reviews", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"id":1}`)) - }) - p := newProviderForTest(t, f) - - err := p.PostPRReview(ctx(), "https://github.com/octocat/hello/pull/42", tt.verdict, "please fix X") - if err != nil { - t.Fatalf("PostPRReview: %v", err) - } - if n := f.callsTo(http.MethodPost, "/repos/octocat/hello/pulls/42/reviews"); n != 1 { - t.Fatalf("review POST count = %d, want 1", n) - } - var body struct { - Event string `json:"event"` - Body string `json:"body"` - } - if err := json.Unmarshal([]byte(f.calls()[0].Body), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body.Event != tt.wantEvent || body.Body != "please fix X" { - t.Fatalf("posted body = %+v, want event %q", body, tt.wantEvent) - } - }) - } -} - -func TestPostPRReviewRejectsUnsubmittableVerdict(t *testing.T) { - f := newFakeGH(t) - p := newProviderForTest(t, f) - err := p.PostPRReview(ctx(), "https://github.com/octocat/hello/pull/42", domain.VerdictNone, "") - if err == nil || !strings.Contains(err.Error(), "verdict") { - t.Fatalf("want verdict error, got %v", err) - } - if len(f.calls()) != 0 { - t.Fatalf("expected no HTTP call for invalid verdict, got %d", len(f.calls())) - } -} diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go deleted file mode 100644 index e33257ef..00000000 --- a/backend/internal/cli/review.go +++ /dev/null @@ -1,186 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "net/url" - "os" - "strings" - "text/tabwriter" - "time" - - "github.com/spf13/cobra" -) - -// reviewRun mirrors the daemon's domain.ReviewRun for the CLI client. -type reviewRun struct { - ID string `json:"id"` - SessionID string `json:"sessionId"` - Harness string `json:"harness"` - PRURL string `json:"prUrl"` - Status string `json:"status"` - Verdict string `json:"verdict"` - Iteration int `json:"iteration"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// reviewRunResponse mirrors controllers.ReviewRunResponse. -type reviewRunResponse struct { - Review reviewRun `json:"review"` -} - -// listReviewsResponse mirrors controllers.ListReviewsResponse. -type listReviewsResponse struct { - Reviews []reviewRun `json:"reviews"` -} - -// submitReviewRequest mirrors controllers.SubmitReviewInput. -type submitReviewRequest struct { - Verdict string `json:"verdict"` - Body string `json:"body"` -} - -type reviewSubmitOptions struct { - session string - verdict string - body string -} - -func newReviewCommand(ctx *commandContext) *cobra.Command { - cmd := &cobra.Command{ - Use: "review", - Short: "Trigger and manage AO code reviews of a worker's PR", - } - cmd.AddCommand(newReviewTriggerCommand(ctx)) - cmd.AddCommand(newReviewSubmitCommand(ctx)) - cmd.AddCommand(newReviewListCommand(ctx)) - return cmd -} - -func newReviewTriggerCommand(ctx *commandContext) *cobra.Command { - return &cobra.Command{ - Use: "trigger ", - Short: "Trigger a code review of a worker's PR", - Args: exactSessionArg, - RunE: func(cmd *cobra.Command, args []string) error { - session := strings.TrimSpace(args[0]) - var res reviewRunResponse - if err := ctx.postJSON(cmd.Context(), reviewPath(session, "trigger"), nil, &res); err != nil { - return err - } - _, err := fmt.Fprintf(cmd.OutOrStdout(), "triggered review %s for %s (iteration %d, %s)\n", - res.Review.ID, session, res.Review.Iteration, res.Review.Harness) - return err - }, - } -} - -func newReviewListCommand(ctx *commandContext) *cobra.Command { - var asJSON bool - cmd := &cobra.Command{ - Use: "list ", - Short: "List a worker's code-review runs", - Args: exactSessionArg, - RunE: func(cmd *cobra.Command, args []string) error { - session := strings.TrimSpace(args[0]) - var res listReviewsResponse - if err := ctx.getJSON(cmd.Context(), reviewPath(session, ""), &res); err != nil { - return err - } - if asJSON { - return writeJSON(cmd.OutOrStdout(), res) - } - return writeReviewList(cmd, res.Reviews) - }, - } - cmd.Flags().BoolVar(&asJSON, "json", false, "Output review runs as JSON") - return cmd -} - -func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { - var opts reviewSubmitOptions - cmd := &cobra.Command{ - Use: "submit [worker-session-id]", - Short: "Submit a reviewer's result for a worker's PR", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return ctx.submitReview(cmd, args, opts) - }, - } - cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (defaults to $AO_REVIEW_WORKER)") - cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") - cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body") - return cmd -} - -func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts reviewSubmitOptions) error { - session := strings.TrimSpace(opts.session) - if len(args) == 1 { - session = strings.TrimSpace(args[0]) - } - if session == "" { - session = strings.TrimSpace(os.Getenv("AO_REVIEW_WORKER")) - } - if session == "" { - return usageError{errors.New("usage: worker session id is required (positional, --session, or $AO_REVIEW_WORKER)")} - } - verdict := strings.TrimSpace(opts.verdict) - if verdict == "" { - return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")} - } - var body string - if path := strings.TrimSpace(opts.body); path != "" { - raw, err := os.ReadFile(path) - if err != nil { - return usageError{fmt.Errorf("read body file: %w", err)} - } - body = string(raw) - } - var res reviewRunResponse - if err := c.postJSON(cmd.Context(), reviewPath(session, "submit"), submitReviewRequest{Verdict: verdict, Body: body}, &res); err != nil { - return err - } - _, err := fmt.Fprintf(cmd.OutOrStdout(), "submitted %s review for %s\n", res.Review.Verdict, session) - return err -} - -func reviewPath(session, action string) string { - base := "sessions/" + url.PathEscape(session) + "/reviews" - if action == "" { - return base - } - return base + "/" + action -} - -func exactSessionArg(cmd *cobra.Command, args []string) error { - if err := cobra.ExactArgs(1)(cmd, args); err != nil { - return usageError{err} - } - if strings.TrimSpace(args[0]) == "" { - return usageError{errors.New("usage: worker session id is required")} - } - return nil -} - -func writeReviewList(cmd *cobra.Command, runs []reviewRun) error { - out := cmd.OutOrStdout() - if len(runs) == 0 { - _, err := fmt.Fprintln(out, "No reviews yet. Run `ao review trigger ` to start one.") - return err - } - tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "ITER\tSTATUS\tVERDICT\tHARNESS\tPR"); err != nil { - return err - } - for _, r := range runs { - verdict := r.Verdict - if verdict == "" { - verdict = "-" - } - if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", r.Iteration, r.Status, verdict, r.Harness, r.PRURL); err != nil { - return err - } - } - return tw.Flush() -} diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go deleted file mode 100644 index f2029987..00000000 --- a/backend/internal/cli/review_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package cli - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -// reviewServer captures the method/path/body of the request the CLI made. -type reviewCapture struct { - method string - path string - body string -} - -func reviewServer(t *testing.T, status int, respBody string) (*httptest.Server, *reviewCapture) { - t.Helper() - capture := &reviewCapture{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - capture.method = r.Method - capture.path = r.URL.Path - capture.body = string(body) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = io.WriteString(w, respBody) - })) - t.Cleanup(srv.Close) - return srv, capture -} - -func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true }} } - -func TestReviewTrigger(t *testing.T) { - cfg := setConfigEnv(t) - srv, capture := reviewServer(t, http.StatusCreated, - `{"review":{"id":"r1","iteration":1,"harness":"codex","status":"pending"}}`) - writeRunFileFor(t, cfg, srv) - - out, errOut, err := executeCLI(t, aliveDeps(), "review", "trigger", "mer-1") - if err != nil { - t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) - } - if capture.method != http.MethodPost || capture.path != "/api/v1/sessions/mer-1/reviews/trigger" { - t.Fatalf("request = %s %s", capture.method, capture.path) - } - if !strings.Contains(out, "triggered review r1") { - t.Fatalf("output = %q", out) - } -} - -func TestReviewList(t *testing.T) { - cfg := setConfigEnv(t) - srv, capture := reviewServer(t, http.StatusOK, - `{"reviews":[{"iteration":2,"status":"complete","verdict":"changes_requested","harness":"codex","prUrl":"u"}]}`) - writeRunFileFor(t, cfg, srv) - - out, errOut, err := executeCLI(t, aliveDeps(), "review", "list", "mer-1") - if err != nil { - t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) - } - if capture.method != http.MethodGet || capture.path != "/api/v1/sessions/mer-1/reviews" { - t.Fatalf("request = %s %s", capture.method, capture.path) - } - if !strings.Contains(out, "changes_requested") || !strings.Contains(out, "ITER") { - t.Fatalf("output = %q", out) - } -} - -func TestReviewSubmitReadsBodyFile(t *testing.T) { - cfg := setConfigEnv(t) - srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`) - writeRunFileFor(t, cfg, srv) - - bodyFile := filepath.Join(t.TempDir(), "review.md") - if err := os.WriteFile(bodyFile, []byte("please fix"), 0o600); err != nil { - t.Fatal(err) - } - - _, errOut, err := executeCLI(t, aliveDeps(), - "review", "submit", "mer-1", "--verdict", "changes_requested", "--body", bodyFile) - if err != nil { - t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) - } - if capture.path != "/api/v1/sessions/mer-1/reviews/submit" { - t.Fatalf("path = %q", capture.path) - } - var req submitReviewRequest - if err := json.Unmarshal([]byte(capture.body), &req); err != nil { - t.Fatalf("decode body: %v", err) - } - if req.Verdict != "changes_requested" || req.Body != "please fix" { - t.Fatalf("request = %+v", req) - } -} - -func TestReviewSubmitUsesEnvWorker(t *testing.T) { - cfg := setConfigEnv(t) - t.Setenv("AO_REVIEW_WORKER", "mer-7") - srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) - writeRunFileFor(t, cfg, srv) - - if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved"); err != nil { - t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) - } - if capture.path != "/api/v1/sessions/mer-7/reviews/submit" { - t.Fatalf("path = %q, want mer-7", capture.path) - } -} - -func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { - setConfigEnv(t) - _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1") - if got := ExitCode(err); got != 2 { - t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) - } -} - -func TestReviewTriggerMissingArgIsUsageError(t *testing.T) { - setConfigEnv(t) - _, _, err := executeCLI(t, aliveDeps(), "review", "trigger") - if got := ExitCode(err); got != 2 { - t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) - } -} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index f536459c..4dec629c 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -171,7 +171,6 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) root.AddCommand(newOrchestratorCommand(ctx)) - root.AddCommand(newReviewCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index e9905099..078ee6cd 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -95,20 +95,15 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, // activity hooks; the deriver registry is the source of truth for that. SignalCapable: activitydispatch.SupportsHarness, }) - // The reviewer runs over the worker's own worktree (reusing the agent - // resolver + runtime) and posts its result to the PR. A nil scmProvider - // leaves the poster unset; Submit then fails loudly rather than panicking. - var poster ports.PRReviewPoster - if scmProvider != nil { - poster = scmProvider - } + // Triggering a review spawns the reviewer agent over the worker's worktree + // (reusing the agent resolver + runtime); the reviewer posts its review to + // the PR itself, so the review service needs no SCM writer. reviewSvc := reviewsvc.New(reviewsvc.Deps{ Store: store, Sessions: store, PRs: store, Projects: store, Runner: reviewsvc.NewAgentRunner(agents, runtime), - Poster: poster, }) return sessionSvc, reviewSvc, nil } diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index f7f8e4da..5e28cc8c 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -38,8 +38,8 @@ type ProjectConfig struct { Orchestrator RoleOverride `json:"orchestrator,omitempty"` // Reviewers names the agent(s) that review a worker's PR when a review is - // triggered. An empty list resolves to one default reviewer - // (see ResolvedReviewers). + // triggered. It is configured independently of the Worker override; an empty + // list falls back to the worker's own harness (see ResolveReviewerHarness). Reviewers []ReviewerConfig `json:"reviewers,omitempty"` } @@ -48,16 +48,21 @@ type ReviewerConfig struct { Harness AgentHarness `json:"harness"` } -// DefaultReviewerHarness is the reviewer used when a project configures none. -const DefaultReviewerHarness = HarnessClaudeCode +// FallbackReviewerHarness is the reviewer used when a project configures none +// and the worker's harness cannot be reused. +const FallbackReviewerHarness = HarnessClaudeCode -// ResolvedReviewers returns the configured reviewers, or a single default -// reviewer when the project sets none. -func (c ProjectConfig) ResolvedReviewers() []ReviewerConfig { - if len(c.Reviewers) == 0 { - return []ReviewerConfig{{Harness: DefaultReviewerHarness}} +// ResolveReviewerHarness picks the reviewer harness for a worker. A configured +// reviewer wins; otherwise it reuses the worker's own harness when that is +// supported, falling back to claude-code. +func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) AgentHarness { + if len(c.Reviewers) > 0 { + return c.Reviewers[0].Harness } - return c.Reviewers + if workerHarness.IsKnown() { + return workerHarness + } + return FallbackReviewerHarness } // RoleOverride overrides the harness and/or agent config for a session role. diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index df62d2c0..fceecfb1 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -68,17 +68,21 @@ func TestProjectConfigWithDefaults(t *testing.T) { } } -func TestResolvedReviewers(t *testing.T) { - // Empty config resolves to the single default reviewer. - got := (ProjectConfig{}).ResolvedReviewers() - if len(got) != 1 || got[0].Harness != DefaultReviewerHarness { - t.Fatalf("ResolvedReviewers() = %#v, want one default reviewer", got) +func TestResolveReviewerHarness(t *testing.T) { + // A configured reviewer always wins, regardless of the worker harness. + cfg := ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}}} + if got := cfg.ResolveReviewerHarness(HarnessAider); got != HarnessCodex { + t.Fatalf("configured reviewer = %q, want codex", got) } - // A configured list is returned as-is. - cfg := ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}, {Harness: HarnessAider}}} - if got := cfg.ResolvedReviewers(); len(got) != 2 || got[0].Harness != HarnessCodex { - t.Fatalf("ResolvedReviewers() = %#v, want configured list", got) + // No reviewer configured: reuse the worker harness when supported. + if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != HarnessAider { + t.Fatalf("default = %q, want worker harness aider", got) + } + + // Unknown/empty worker harness falls back to claude-code. + if got := (ProjectConfig{}).ResolveReviewerHarness("nope"); got != FallbackReviewerHarness { + t.Fatalf("fallback = %q, want %q", got, FallbackReviewerHarness) } } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 551216f6..e73fc630 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -853,57 +853,6 @@ paths: summary: List a worker's code-review runs tags: - reviews - /api/v1/sessions/{sessionId}/reviews/submit: - post: - operationId: submitReview - 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/SubmitReviewInput' - required: true - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/ReviewRunResponse' - 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 - "422": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Unprocessable Entity - "501": - content: - application/json: - schema: - $ref: '#/components/schemas/APIError' - description: Not Implemented - summary: Submit a reviewer's result for a worker's PR - tags: - - reviews /api/v1/sessions/{sessionId}/reviews/trigger: post: operationId: triggerReview @@ -1701,18 +1650,6 @@ components: required: - projectId type: object - SubmitReviewInput: - properties: - body: - description: Review body, posted to the PR. Required for changes_requested. - type: string - verdict: - description: 'Review verdict: approved or changes_requested.' - type: string - required: - - verdict - - body - type: object WorkspaceRepo: properties: name: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index ff528916..fd038dfc 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -162,7 +162,6 @@ var schemaNames = map[string]string{ // httpd/controllers — review wire envelopes "ControllersListReviewsResponse": "ListReviewsResponse", "ControllersReviewRunResponse": "ReviewRunResponse", - "ControllersSubmitReviewInput": "SubmitReviewInput", // domain review entities "DomainReviewRun": "ReviewRun", // service/project entities + DTOs @@ -280,19 +279,6 @@ func reviewOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, - { - method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/submit", id: "submitReview", tag: "reviews", - summary: "Submit a reviewer's result for a worker's PR", - pathParams: []any{controllers.SessionIDParam{}}, - reqBody: controllers.SubmitReviewInput{}, - resps: []respUnit{ - {http.StatusOK, controllers.ReviewRunResponse{}}, - {http.StatusBadRequest, envelope.APIError{}}, - {http.StatusUnprocessableEntity, envelope.APIError{}}, - {http.StatusNotFound, envelope.APIError{}}, - {http.StatusNotImplemented, envelope.APIError{}}, - }, - }, } } diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index 7a0b9d7c..b7dc0234 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -1,7 +1,6 @@ package controllers import ( - "encoding/json" "errors" "net/http" @@ -18,17 +17,11 @@ type ListReviewsResponse struct { Reviews []domain.ReviewRun `json:"reviews"` } -// ReviewRunResponse is the { review } body of trigger (201) and submit (200). +// ReviewRunResponse is the { review } body of trigger (201). type ReviewRunResponse struct { Review domain.ReviewRun `json:"review"` } -// SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. -type SubmitReviewInput struct { - Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` - Body string `json:"body" description:"Review body, posted to the PR. Required for changes_requested."` -} - // ReviewsController owns the session-scoped /reviews routes. A nil Svc returns 501. type ReviewsController struct { Svc reviewsvc.Manager @@ -38,7 +31,6 @@ type ReviewsController struct { func (c *ReviewsController) Register(r chi.Router) { r.Get("/sessions/{sessionId}/reviews", c.list) r.Post("/sessions/{sessionId}/reviews/trigger", c.trigger) - r.Post("/sessions/{sessionId}/reviews/submit", c.submit) } func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) { @@ -70,24 +62,6 @@ func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusCreated, ReviewRunResponse{Review: run}) } -func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { - if c.Svc == nil { - apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/submit") - return - } - var in SubmitReviewInput - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) - return - } - run, err := c.Svc.Submit(r.Context(), sessionID(r), domain.ReviewVerdict(in.Verdict), in.Body) - if err != nil { - writeReviewError(w, r, err) - return - } - envelope.WriteJSON(w, http.StatusOK, ReviewRunResponse{Review: run}) -} - func writeReviewError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, reviewsvc.ErrInvalid): diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 00b2dc59..4f7b20ac 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -72,13 +72,6 @@ type AgentMessenger interface { Send(ctx context.Context, id domain.SessionID, message string) error } -// PRReviewPoster posts an AO code-review result to a PR on the SCM provider. -// The worker picks the posted review up through the SCM observer's review-nudge -// path, so no separate in-process delivery is needed. -type PRReviewPoster interface { - PostPRReview(ctx context.Context, prURL string, verdict domain.ReviewVerdict, body string) error -} - // ---- runtime / agent / workspace plugin ports ---- // Runtime is the full runtime adapter contract: session creation/teardown plus diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index f62acf49..d3c61ba2 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -1,11 +1,10 @@ -// Package review is the daemon's code-review surface: a configured reviewer -// agent reviews a worker's PR over the worker's own worktree, and the result is -// posted to the SCM provider. The worker picks the feedback up through the -// existing SCM observer → review-nudge path. +// Package review is the daemon's code-review surface: triggering a review spawns +// a configured reviewer agent over the worker's worktree with its own review +// prompt. The reviewer agent posts its review to the PR itself; the worker picks +// the feedback up through the existing SCM observer → review-nudge path. // // V1 is manual and one-shot: a review runs only when triggered. The reviewer is -// not modeled as a session — it is tracked by the review (one per worker) and -// review_run (one per pass) tables. +// tracked by the review (one per worker) and review_run (one per pass) tables. package review import ( @@ -17,7 +16,6 @@ import ( "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // ErrInvalid and ErrNotFound let the HTTP layer map service failures to 422/404. @@ -68,7 +66,6 @@ type RunSpec struct { // Manager is the reviews surface the HTTP controller depends on. type Manager interface { Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) - Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) } @@ -79,7 +76,6 @@ type Deps struct { PRs PRs Projects Projects Runner Runner - Poster ports.PRReviewPoster // Clock and NewID are injectable for deterministic tests. Clock func() time.Time @@ -93,7 +89,6 @@ type Service struct { prs PRs projects Projects runner Runner - poster ports.PRReviewPoster clock func() time.Time newID func() string } @@ -116,7 +111,6 @@ func New(d Deps) *Service { prs: d.PRs, projects: d.Projects, runner: d.Runner, - poster: d.Poster, clock: clock, newID: newID, } @@ -148,7 +142,7 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai return domain.ReviewRun{}, err } - harness, err := s.reviewerHarness(ctx, worker.ProjectID) + harness, err := s.reviewerHarness(ctx, worker) if err != nil { return domain.ReviewRun{}, err } @@ -189,45 +183,6 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai return run, nil } -// Submit records a reviewer's result for a worker's active review pass and posts -// it to the PR. The review body is not persisted — it lives on the PR. -func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { - if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) - } - if !verdict.Valid() { - return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) - } - if verdict == domain.VerdictChangesRequested && body == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) - } - if s.poster == nil { - return domain.ReviewRun{}, fmt.Errorf("%w: review posting is unavailable (no SCM credentials)", ErrInvalid) - } - - run, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID) - if err != nil { - return domain.ReviewRun{}, err - } - if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: no review run for worker %q", ErrNotFound, workerID) - } - - if err := s.poster.PostPRReview(ctx, run.PRURL, verdict, body); err != nil { - _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, verdict, s.clock()) - return domain.ReviewRun{}, fmt.Errorf("post review: %w", err) - } - - now := s.clock() - if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, now); err != nil { - return domain.ReviewRun{}, err - } - run.Status = domain.ReviewRunComplete - run.Verdict = verdict - run.UpdatedAt = now - return run, nil -} - // List returns the review passes recorded for a worker, newest first. func (s *Service) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { if workerID == "" { @@ -247,18 +202,19 @@ func (s *Service) workerPRURL(ctx context.Context, workerID domain.SessionID) (s return prs[0].URL, nil } -func (s *Service) reviewerHarness(ctx context.Context, projectID domain.ProjectID) (domain.AgentHarness, error) { +// reviewerHarness resolves which harness reviews the worker's PR: a configured +// reviewer wins, otherwise the worker's own harness is reused (falling back to +// claude-code), per domain.ResolveReviewerHarness. +func (s *Service) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.AgentHarness, error) { var cfg domain.ProjectConfig if s.projects != nil { - if proj, ok, err := s.projects.GetProject(ctx, string(projectID)); err != nil { + if proj, ok, err := s.projects.GetProject(ctx, string(worker.ProjectID)); err != nil { return "", err } else if ok { cfg = proj.Config } } - reviewers := cfg.ResolvedReviewers() - // V1 runs a single reviewer; the first configured (or default) one. - return reviewers[0].Harness, nil + return cfg.ResolveReviewerHarness(worker.Harness), nil } func (s *Service) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.AgentHarness, prURL string, now time.Time) (domain.Review, error) { diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index aabcb43d..ca8e9761 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -96,34 +96,19 @@ func (f *fakeRunner) Run(_ context.Context, spec RunSpec) error { return f.err } -type fakePoster struct { - verdict domain.ReviewVerdict - body string - url string - err error - called bool -} - -func (f *fakePoster) PostPRReview(_ context.Context, prURL string, verdict domain.ReviewVerdict, body string) error { - f.called = true - f.url = prURL - f.verdict = verdict - f.body = body - return f.err -} - func liveWorker() domain.SessionRecord { return domain.SessionRecord{ ID: "mer-1", ProjectID: "mer", + Harness: domain.HarnessCodex, Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}, } } -func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner, poster *fakePoster) *Service { +func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner) *Service { ids := 0 return New(Deps{ - Store: store, Sessions: sessions, PRs: prs, Projects: projects, Runner: runner, Poster: poster, + Store: store, Sessions: sessions, PRs: prs, Projects: projects, Runner: runner, Clock: func() time.Time { return time.Unix(0, 0).UTC() }, NewID: func() string { ids++; return "id-" + string(rune('0'+ids)) }, }) @@ -135,18 +120,19 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { store := &fakeStore{} sessions := fakeSessions{rec: liveWorker(), ok: true} prs := fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1"}}} - projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.HarnessCodex}}}} + projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.HarnessAider}}}} runner := &fakeRunner{} - svc := newServiceForTest(store, sessions, prs, projects, runner, &fakePoster{}) + svc := newServiceForTest(store, sessions, prs, projects, runner) run, err := svc.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Status != domain.ReviewRunPending || run.Iteration != 1 || run.Harness != domain.HarnessCodex { + // A configured reviewer wins over the worker harness. + if run.Status != domain.ReviewRunPending || run.Iteration != 1 || run.Harness != domain.HarnessAider { t.Fatalf("run = %+v", run) } - if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.HarnessCodex { + if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.HarnessAider { t.Fatalf("runner spec = %+v ran=%v", runner.spec, runner.ran) } if store.review == nil || store.review.PRURL != "https://github.com/o/r/pull/1" { @@ -154,23 +140,39 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { } } -func TestTriggerDefaultsReviewerHarness(t *testing.T) { +func TestTriggerDefaultsToWorkerHarness(t *testing.T) { store := &fakeStore{} + // No reviewer configured: reuse the worker's harness (codex). svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) + run, err := svc.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if run.Harness != domain.HarnessCodex { + t.Fatalf("harness = %q, want worker harness codex", run.Harness) + } +} + +func TestTriggerFallsBackWhenWorkerHarnessUnknown(t *testing.T) { + store := &fakeStore{} + rec := liveWorker() + rec.Harness = "" + svc := newServiceForTest(store, fakeSessions{rec: rec, ok: true}, + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) run, err := svc.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Harness != domain.DefaultReviewerHarness { - t.Fatalf("harness = %q, want default %q", run.Harness, domain.DefaultReviewerHarness) + if run.Harness != domain.FallbackReviewerHarness { + t.Fatalf("harness = %q, want fallback %q", run.Harness, domain.FallbackReviewerHarness) } } func TestTriggerSecondPassIncrementsIteration(t *testing.T) { store := &fakeStore{runs: []domain.ReviewRun{{ID: "old", Iteration: 1}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) run, err := svc.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) @@ -183,7 +185,7 @@ func TestTriggerSecondPassIncrementsIteration(t *testing.T) { func TestTriggerRejectsMissingWorkerPRAndState(t *testing.T) { base := func() *fakeStore { return &fakeStore{} } t.Run("unknown worker", func(t *testing.T) { - svc := newServiceForTest(base(), fakeSessions{ok: false}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + svc := newServiceForTest(base(), fakeSessions{ok: false}, fakePRs{}, fakeProjects{}, &fakeRunner{}) if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } @@ -191,13 +193,13 @@ func TestTriggerRejectsMissingWorkerPRAndState(t *testing.T) { t.Run("terminated worker", func(t *testing.T) { rec := liveWorker() rec.IsTerminated = true - svc := newServiceForTest(base(), fakeSessions{rec: rec, ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + svc := newServiceForTest(base(), fakeSessions{rec: rec, ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { t.Fatalf("err = %v, want ErrInvalid", err) } }) t.Run("no pr", func(t *testing.T) { - svc := newServiceForTest(base(), fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) + svc := newServiceForTest(base(), fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { t.Fatalf("err = %v, want ErrInvalid", err) } @@ -208,7 +210,7 @@ func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { store := &fakeStore{} runner := &fakeRunner{err: errors.New("boom")} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, runner, &fakePoster{}) + fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, runner) if _, err := svc.Trigger(context.Background(), "mer-1"); err == nil { t.Fatal("want launch error") } @@ -217,50 +219,14 @@ func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { } } -func TestSubmitPostsToGitHubAndCompletes(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "https://github.com/o/r/pull/1", Status: domain.ReviewRunPending}}} - poster := &fakePoster{} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, poster) - - run, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, "fix it") +func TestListReturnsRuns(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Iteration: 1}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + runs, err := svc.List(context.Background(), "mer-1") if err != nil { - t.Fatalf("Submit: %v", err) - } - if !poster.called || poster.url != "https://github.com/o/r/pull/1" || poster.verdict != domain.VerdictChangesRequested || poster.body != "fix it" { - t.Fatalf("poster = %+v", poster) - } - if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested { - t.Fatalf("run = %+v", run) - } -} - -func TestSubmitValidation(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunPending}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) - - if _, err := svc.Submit(context.Background(), "mer-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { - t.Fatalf("bad verdict err = %v", err) - } - if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("empty body err = %v", err) - } -} - -func TestSubmitNoRun(t *testing.T) { - svc := newServiceForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, &fakePoster{}) - if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) - } -} - -func TestSubmitPostFailureMarksFailed(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunPending}}} - poster := &fakePoster{err: errors.New("network")} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}, poster) - if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); err == nil { - t.Fatal("want post error") + t.Fatalf("List: %v", err) } - if store.runs[0].Status != domain.ReviewRunFailed { - t.Fatalf("run not marked failed: %+v", store.runs[0]) + if len(runs) != 1 || runs[0].ID != "run-1" { + t.Fatalf("runs = %+v", runs) } } diff --git a/backend/internal/service/review/runner.go b/backend/internal/service/review/runner.go index 061443bd..7cdfcffa 100644 --- a/backend/internal/service/review/runner.go +++ b/backend/internal/service/review/runner.go @@ -8,10 +8,12 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -// agentRunner launches a reviewer agent one-shot over the worker's worktree by -// reusing the per-session agent resolver and runtime. The reviewer is not a -// session: its runtime pane is not persisted and not reaped here. It reviews the -// worktree and reports back by running `ao review submit`. +// agentRunner spawns a reviewer agent over the worker's worktree, mirroring the +// session-manager launch flow (resolve agent by harness → build argv with its +// own prompt → runtime.Create). It reuses the worker's worktree rather than +// cutting a second one: a fresh session worktree would branch off the project's +// default branch and so would not contain the worker's PR changes. The reviewer +// reviews the code and posts its review to the PR itself. type agentRunner struct { agents ports.AgentResolver runtime ports.Runtime @@ -41,25 +43,14 @@ func (r agentRunner) Run(ctx context.Context, spec RunSpec) error { SessionID: domain.SessionID(reviewerID), WorkspacePath: spec.WorkspacePath, Argv: argv, - Env: reviewerEnv(spec), }); err != nil { return fmt.Errorf("reviewer runtime: %w", err) } return nil } -// reviewerEnv carries the worker the reviewer submits against, so the reviewer's -// `ao review submit` targets the right session. -func reviewerEnv(spec RunSpec) map[string]string { - return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} -} - func reviewPrompt(spec RunSpec) string { return fmt.Sprintf(`You are an AO code reviewer. Review the changes in this worktree for pull request %s. -Write your full review as Markdown to a file (for example review.md), then submit it by running: - - ao review submit %s --verdict --body review.md - -Use changes_requested if the PR needs work, approved if it is ready. Do not push commits or modify the code — only review it.`, spec.PRURL, spec.WorkerID) +Post your review directly on the pull request on GitHub (use `+"`gh pr review`"+` or the GitHub CLI): request changes if the PR needs work, approve if it is ready, and leave inline comments for specific findings. Do not push commits or modify the code — only review it.`, spec.PRURL) } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index bf682e4a..d1fff43f 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -281,23 +281,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/sessions/{sessionId}/reviews/submit": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Submit a reviewer's result for a worker's PR */ - post: operations["submitReview"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/sessions/{sessionId}/reviews/trigger": { parameters: { query?: never; @@ -613,12 +596,6 @@ export interface components { projectId: string; prompt?: string; }; - SubmitReviewInput: { - /** @description Review body, posted to the PR. Required for changes_requested. */ - body: string; - /** @description Review verdict: approved or changes_requested. */ - verdict: string; - }; WorkspaceRepo: { name: string; relativePath: string; @@ -1702,69 +1679,6 @@ export interface operations { }; }; }; - submitReview: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Session identifier, e.g. project-1. */ - sessionId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SubmitReviewInput"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReviewRunResponse"]; - }; - }; - /** @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 Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - /** @description Not Implemented */ - 501: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["APIError"]; - }; - }; - }; - }; triggerReview: { parameters: { query?: never; From 8ace34bf290f4a257d85d6b6db55e7926bb0cb34 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 19:58:54 +0530 Subject: [PATCH 03/11] feat(review): restore ao review submit (records verdict+body in AO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer request, bring back `ao review submit`. AO records the reviewer's verdict and body on the review_run and marks the pass complete; it does not post to GitHub — the reviewer agent posts its review to the PR itself. - storage: add review_run.body (0011), persist via Insert/UpdateReviewRunResult. - service: restore Submit (no SCM poster) storing verdict + body. - http: restore POST /sessions/{id}/reviews/submit + SubmitReviewInput. - cli: ao review submit [worker] --verdict --body (worker from arg/--session/$AO_REVIEW_WORKER). - runner: reviewer prompt instructs posting to GitHub and recording via ao review submit. Regenerated OpenAPI + TS types. Co-Authored-By: Claude Opus 4.8 --- backend/internal/cli/review.go | 100 ++++++++++++++++++ backend/internal/cli/review_test.go | 94 ++++++++++++++++ backend/internal/cli/root.go | 1 + backend/internal/domain/review.go | 7 +- backend/internal/httpd/apispec/openapi.yaml | 66 ++++++++++++ .../internal/httpd/apispec/specgen/build.go | 14 +++ backend/internal/httpd/controllers/reviews.go | 28 ++++- backend/internal/service/review/review.go | 38 ++++++- .../internal/service/review/review_test.go | 38 ++++++- backend/internal/service/review/runner.go | 14 ++- backend/internal/storage/sqlite/gen/models.go | 1 + .../internal/storage/sqlite/gen/review.sql.go | 16 ++- .../migrations/0011_add_review_tables.sql | 1 + .../storage/sqlite/queries/review.sql | 10 +- .../storage/sqlite/store/review_store.go | 7 +- .../storage/sqlite/store/review_store_test.go | 4 +- frontend/src/api/schema.ts | 87 +++++++++++++++ 17 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 backend/internal/cli/review.go create mode 100644 backend/internal/cli/review_test.go diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go new file mode 100644 index 00000000..29ab6235 --- /dev/null +++ b/backend/internal/cli/review.go @@ -0,0 +1,100 @@ +package cli + +import ( + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// reviewRun mirrors the daemon's domain.ReviewRun for the CLI client. +type reviewRun struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + Harness string `json:"harness"` + PRURL string `json:"prUrl"` + Status string `json:"status"` + Verdict string `json:"verdict"` + Iteration int `json:"iteration"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// reviewRunResponse mirrors controllers.ReviewRunResponse. +type reviewRunResponse struct { + Review reviewRun `json:"review"` +} + +// submitReviewRequest mirrors controllers.SubmitReviewInput. +type submitReviewRequest struct { + Verdict string `json:"verdict"` + Body string `json:"body"` +} + +type reviewSubmitOptions struct { + session string + verdict string + body string +} + +func newReviewCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "review", + Short: "Manage AO code reviews of a worker's PR", + } + cmd.AddCommand(newReviewSubmitCommand(ctx)) + return cmd +} + +func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { + var opts reviewSubmitOptions + cmd := &cobra.Command{ + Use: "submit [worker-session-id]", + Short: "Record a reviewer's result for a worker's PR", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return ctx.submitReview(cmd, args, opts) + }, + } + cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (defaults to $AO_REVIEW_WORKER)") + cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") + cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body") + return cmd +} + +func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts reviewSubmitOptions) error { + session := strings.TrimSpace(opts.session) + if len(args) == 1 { + session = strings.TrimSpace(args[0]) + } + if session == "" { + session = strings.TrimSpace(os.Getenv("AO_REVIEW_WORKER")) + } + if session == "" { + return usageError{errors.New("usage: worker session id is required (positional, --session, or $AO_REVIEW_WORKER)")} + } + verdict := strings.TrimSpace(opts.verdict) + if verdict == "" { + return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")} + } + var body string + if path := strings.TrimSpace(opts.body); path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return usageError{fmt.Errorf("read body file: %w", err)} + } + body = string(raw) + } + path := "sessions/" + url.PathEscape(session) + "/reviews/submit" + var res reviewRunResponse + if err := c.postJSON(cmd.Context(), path, submitReviewRequest{Verdict: verdict, Body: body}, &res); err != nil { + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session) + return err +} diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go new file mode 100644 index 00000000..47b8ec81 --- /dev/null +++ b/backend/internal/cli/review_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// reviewCapture records the method/path/body of the request the CLI made. +type reviewCapture struct { + method string + path string + body string +} + +func reviewServer(t *testing.T, status int, respBody string) (*httptest.Server, *reviewCapture) { + t.Helper() + capture := &reviewCapture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capture.method = r.Method + capture.path = r.URL.Path + capture.body = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, capture +} + +func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true }} } + +func TestReviewSubmitReadsBodyFile(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`) + writeRunFileFor(t, cfg, srv) + + bodyFile := filepath.Join(t.TempDir(), "review.md") + if err := os.WriteFile(bodyFile, []byte("please fix"), 0o600); err != nil { + t.Fatal(err) + } + + _, errOut, err := executeCLI(t, aliveDeps(), + "review", "submit", "mer-1", "--verdict", "changes_requested", "--body", bodyFile) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodPost || capture.path != "/api/v1/sessions/mer-1/reviews/submit" { + t.Fatalf("request = %s %s", capture.method, capture.path) + } + var req submitReviewRequest + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v", err) + } + if req.Verdict != "changes_requested" || req.Body != "please fix" { + t.Fatalf("request = %+v", req) + } +} + +func TestReviewSubmitUsesEnvWorker(t *testing.T) { + cfg := setConfigEnv(t) + t.Setenv("AO_REVIEW_WORKER", "mer-7") + srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) + writeRunFileFor(t, cfg, srv) + + if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved"); err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.path != "/api/v1/sessions/mer-7/reviews/submit" { + t.Fatalf("path = %q, want mer-7", capture.path) + } +} + +func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { + setConfigEnv(t) + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1") + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) + } +} + +func TestReviewSubmitMissingWorkerIsUsageError(t *testing.T) { + setConfigEnv(t) + t.Setenv("AO_REVIEW_WORKER", "") + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved") + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 4dec629c..f536459c 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -171,6 +171,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) root.AddCommand(newOrchestratorCommand(ctx)) + root.AddCommand(newReviewCommand(ctx)) root.AddCommand(newCompletionCommand()) root.AddCommand(newVersionCommand()) diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index ea28c04c..3e022f3c 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -25,8 +25,11 @@ type ReviewRun struct { Status ReviewRunStatus `json:"status"` Verdict ReviewVerdict `json:"verdict"` Iteration int `json:"iteration"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + // Body is the review text the reviewer submitted. It is recorded for AO's + // own tracking; the reviewer also posts the review to the PR itself. + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ReviewRunStatus is the lifecycle state of a single review pass. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index e73fc630..de665f58 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -853,6 +853,57 @@ paths: summary: List a worker's code-review runs tags: - reviews + /api/v1/sessions/{sessionId}/reviews/submit: + post: + operationId: submitReview + 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/SubmitReviewInput' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewRunResponse' + 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 + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Unprocessable Entity + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Record a reviewer's result for a worker's PR + tags: + - reviews /api/v1/sessions/{sessionId}/reviews/trigger: post: operationId: triggerReview @@ -1393,6 +1444,8 @@ components: type: object ReviewRun: properties: + body: + type: string createdAt: format: date-time type: string @@ -1424,6 +1477,7 @@ components: - status - verdict - iteration + - body - createdAt - updatedAt type: object @@ -1650,6 +1704,18 @@ components: required: - projectId type: object + SubmitReviewInput: + properties: + body: + description: Review body recorded by AO. Required for changes_requested. + type: string + verdict: + description: 'Review verdict: approved or changes_requested.' + type: string + required: + - verdict + - body + type: object WorkspaceRepo: properties: name: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index fd038dfc..d70d00f3 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -162,6 +162,7 @@ var schemaNames = map[string]string{ // httpd/controllers — review wire envelopes "ControllersListReviewsResponse": "ListReviewsResponse", "ControllersReviewRunResponse": "ReviewRunResponse", + "ControllersSubmitReviewInput": "SubmitReviewInput", // domain review entities "DomainReviewRun": "ReviewRun", // service/project entities + DTOs @@ -279,6 +280,19 @@ func reviewOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/reviews/submit", id: "submitReview", tag: "reviews", + summary: "Record a reviewer's result for a worker's PR", + pathParams: []any{controllers.SessionIDParam{}}, + reqBody: controllers.SubmitReviewInput{}, + resps: []respUnit{ + {http.StatusOK, controllers.ReviewRunResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusUnprocessableEntity, envelope.APIError{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index b7dc0234..0415dd93 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -1,6 +1,7 @@ package controllers import ( + "encoding/json" "errors" "net/http" @@ -17,11 +18,17 @@ type ListReviewsResponse struct { Reviews []domain.ReviewRun `json:"reviews"` } -// ReviewRunResponse is the { review } body of trigger (201). +// ReviewRunResponse is the { review } body of trigger (201) and submit (200). type ReviewRunResponse struct { Review domain.ReviewRun `json:"review"` } +// SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. +type SubmitReviewInput struct { + Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` + Body string `json:"body" description:"Review body recorded by AO. Required for changes_requested."` +} + // ReviewsController owns the session-scoped /reviews routes. A nil Svc returns 501. type ReviewsController struct { Svc reviewsvc.Manager @@ -31,6 +38,7 @@ type ReviewsController struct { func (c *ReviewsController) Register(r chi.Router) { r.Get("/sessions/{sessionId}/reviews", c.list) r.Post("/sessions/{sessionId}/reviews/trigger", c.trigger) + r.Post("/sessions/{sessionId}/reviews/submit", c.submit) } func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) { @@ -62,6 +70,24 @@ func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusCreated, ReviewRunResponse{Review: run}) } +func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/submit") + return + } + var in SubmitReviewInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) + return + } + run, err := c.Svc.Submit(r.Context(), sessionID(r), domain.ReviewVerdict(in.Verdict), in.Body) + if err != nil { + writeReviewError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ReviewRunResponse{Review: run}) +} + func writeReviewError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, reviewsvc.ErrInvalid): diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index d3c61ba2..274c16b7 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -30,7 +30,7 @@ type Store interface { UpsertReview(ctx context.Context, r domain.Review) error GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error - UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) } @@ -66,6 +66,7 @@ type RunSpec struct { // Manager is the reviews surface the HTTP controller depends on. type Manager interface { Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) + Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) } @@ -177,12 +178,45 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai }); err != nil { // The pass never launched; record it as failed so a stale pending row // does not look like an in-flight review forever. - _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, s.clock()) + _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, "", s.clock()) return domain.ReviewRun{}, fmt.Errorf("launch reviewer: %w", err) } return run, nil } +// Submit records the reviewer's result for a worker's latest review pass: it +// marks the run complete and stores the verdict and body. AO does not post the +// review — the reviewer agent posts it to the PR itself. +func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + if !verdict.Valid() { + return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) + } + if verdict == domain.VerdictChangesRequested && body == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) + } + + run, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err + } + if !ok { + return domain.ReviewRun{}, fmt.Errorf("%w: no review run for worker %q", ErrNotFound, workerID) + } + + now := s.clock() + if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body, now); err != nil { + return domain.ReviewRun{}, err + } + run.Status = domain.ReviewRunComplete + run.Verdict = verdict + run.Body = body + run.UpdatedAt = now + return run, nil +} + // List returns the review passes recorded for a worker, newest first. func (s *Service) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { if workerID == "" { diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index ca8e9761..2ab30e1d 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -40,7 +40,7 @@ func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error f.runs = append(f.runs, r) return nil } -func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error { +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error { if f.updateErr != nil { return f.updateErr } @@ -48,6 +48,7 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status d if f.runs[i].ID == id { f.runs[i].Status = status f.runs[i].Verdict = verdict + f.runs[i].Body = body f.runs[i].UpdatedAt = updatedAt } } @@ -219,6 +220,41 @@ func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { } } +func TestSubmitRecordsVerdictAndBody(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunPending}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + + run, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, "please fix") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested || run.Body != "please fix" { + t.Fatalf("run = %+v", run) + } + if store.runs[0].Status != domain.ReviewRunComplete || store.runs[0].Body != "please fix" { + t.Fatalf("persisted run = %+v", store.runs[0]) + } +} + +func TestSubmitValidation(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunPending}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + + if _, err := svc.Submit(context.Background(), "mer-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { + t.Fatalf("bad verdict err = %v", err) + } + if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("empty body err = %v", err) + } +} + +func TestSubmitNoRun(t *testing.T) { + svc := newServiceForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + func TestListReturnsRuns(t *testing.T) { store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Iteration: 1}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) diff --git a/backend/internal/service/review/runner.go b/backend/internal/service/review/runner.go index 7cdfcffa..b2201830 100644 --- a/backend/internal/service/review/runner.go +++ b/backend/internal/service/review/runner.go @@ -43,14 +43,26 @@ func (r agentRunner) Run(ctx context.Context, spec RunSpec) error { SessionID: domain.SessionID(reviewerID), WorkspacePath: spec.WorkspacePath, Argv: argv, + Env: reviewerEnv(spec), }); err != nil { return fmt.Errorf("reviewer runtime: %w", err) } return nil } +// reviewerEnv carries the worker the reviewer reports against, so the reviewer's +// `ao review submit` resolves the right worker session without a flag. +func reviewerEnv(spec RunSpec) map[string]string { + return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} +} + func reviewPrompt(spec RunSpec) string { return fmt.Sprintf(`You are an AO code reviewer. Review the changes in this worktree for pull request %s. -Post your review directly on the pull request on GitHub (use `+"`gh pr review`"+` or the GitHub CLI): request changes if the PR needs work, approve if it is ready, and leave inline comments for specific findings. Do not push commits or modify the code — only review it.`, spec.PRURL) +1. Post your review directly on the pull request on GitHub (use `+"`gh pr review`"+`): request changes if the PR needs work, approve if it is ready, and leave inline comments for specific findings. +2. Write your full review as Markdown to a file (for example review.md) and record the result with AO by running: + + ao review submit --verdict --body review.md + +Do not push commits or modify the code — only review it.`, spec.PRURL) } diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 3d301655..7e976d80 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -130,6 +130,7 @@ type ReviewRun struct { Status domain.ReviewRunStatus Verdict domain.ReviewVerdict Iteration int64 + Body string CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index cf838035..485f1453 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -13,7 +13,7 @@ import ( ) const getLatestReviewRunBySession = `-- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1 ` @@ -29,6 +29,7 @@ func (q *Queries) GetLatestReviewRunBySession(ctx context.Context, sessionID dom &i.Status, &i.Verdict, &i.Iteration, + &i.Body, &i.CreatedAt, &i.UpdatedAt, ) @@ -56,8 +57,8 @@ func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.Sessi } const insertReviewRun = `-- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertReviewRunParams struct { @@ -69,6 +70,7 @@ type InsertReviewRunParams struct { Status domain.ReviewRunStatus Verdict domain.ReviewVerdict Iteration int64 + Body string CreatedAt time.Time UpdatedAt time.Time } @@ -83,6 +85,7 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams arg.Status, arg.Verdict, arg.Iteration, + arg.Body, arg.CreatedAt, arg.UpdatedAt, ) @@ -90,7 +93,7 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams } const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC ` @@ -112,6 +115,7 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. &i.Status, &i.Verdict, &i.Iteration, + &i.Body, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -129,12 +133,13 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. } const updateReviewRunResult = `-- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, updated_at = ? WHERE id = ? +UPDATE review_run SET status = ?, verdict = ?, body = ?, updated_at = ? WHERE id = ? ` type UpdateReviewRunResultParams struct { Status domain.ReviewRunStatus Verdict domain.ReviewVerdict + Body string UpdatedAt time.Time ID string } @@ -143,6 +148,7 @@ func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRun _, err := q.db.ExecContext(ctx, updateReviewRunResult, arg.Status, arg.Verdict, + arg.Body, arg.UpdatedAt, arg.ID, ) diff --git a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql index ca1e604a..6592fd63 100644 --- a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql +++ b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql @@ -28,6 +28,7 @@ CREATE TABLE review_run ( verdict TEXT NOT NULL DEFAULT '' CHECK (verdict IN ('', 'approved', 'changes_requested')), iteration INTEGER NOT NULL DEFAULT 1, + body TEXT NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index 3240b517..1c4104fd 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -11,16 +11,16 @@ SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at FROM review WHERE session_id = ?; -- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, updated_at = ? WHERE id = ?; +UPDATE review_run SET status = ?, verdict = ?, body = ?, updated_at = ? WHERE id = ?; -- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1; -- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index 7057f735..7dc4cd6d 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -52,18 +52,20 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { Status: r.Status, Verdict: r.Verdict, Iteration: int64(r.Iteration), + Body: r.Body, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, }) } -// UpdateReviewRunResult sets the status/verdict of a review pass. -func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, updatedAt time.Time) error { +// UpdateReviewRunResult sets the status/verdict/body of a review pass. +func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpdateReviewRunResult(ctx, gen.UpdateReviewRunResultParams{ Status: status, Verdict: verdict, + Body: body, UpdatedAt: updatedAt, ID: id, }) @@ -117,6 +119,7 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { Status: r.Status, Verdict: r.Verdict, Iteration: int(r.Iteration), + Body: r.Body, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index 4a570eca..37d1d629 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -54,7 +54,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { }); err != nil { t.Fatalf("insert run: %v", err) } - if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, now.Add(2*time.Second)); err != nil { + if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix", now.Add(2*time.Second)); err != nil { t.Fatalf("update run: %v", err) } @@ -62,7 +62,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if err != nil || !ok { t.Fatalf("latest run: ok=%v err=%v", ok, err) } - if latest.Status != domain.ReviewRunComplete || latest.Verdict != domain.VerdictChangesRequested { + if latest.Status != domain.ReviewRunComplete || latest.Verdict != domain.VerdictChangesRequested || latest.Body != "please fix" { t.Fatalf("run result not persisted: %+v", latest) } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index d1fff43f..cb3c45c8 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -281,6 +281,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/sessions/{sessionId}/reviews/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Record a reviewer's result for a worker's PR */ + post: operations["submitReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sessions/{sessionId}/reviews/trigger": { parameters: { query?: never; @@ -499,6 +516,7 @@ export interface components { sessionId: string; }; ReviewRun: { + body: string; /** Format: date-time */ createdAt: string; harness: string; @@ -596,6 +614,12 @@ export interface components { projectId: string; prompt?: string; }; + SubmitReviewInput: { + /** @description Review body recorded by AO. Required for changes_requested. */ + body: string; + /** @description Review verdict: approved or changes_requested. */ + verdict: string; + }; WorkspaceRepo: { name: string; relativePath: string; @@ -1679,6 +1703,69 @@ export interface operations { }; }; }; + submitReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewRunResponse"]; + }; + }; + /** @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 Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; triggerReview: { parameters: { query?: never; From f52c2e4be2386837511b425e194211bc6e66da8b Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 20:04:14 +0530 Subject: [PATCH 04/11] refactor(review): move reviewer runner to its own package; sharpen prompt Per PR #197 review: - Move the concrete reviewer runner out of the service layer into a new internal/review_runner package (package reviewrunner), beside other orchestration packages like session_manager. The service keeps only the Runner interface + RunSpec it depends on; the agent-resolver + runtime launch flow lives in review_runner. - Sharpen the reviewer prompt: tell the agent to diff against the PR base, focus on high-confidence findings, post via `gh pr review`, and record the result with `ao review submit`; review-only (no commits/edits). - Add unit tests for the runner. Co-Authored-By: Claude Opus 4.8 --- backend/internal/daemon/lifecycle_wiring.go | 3 +- backend/internal/review_runner/runner.go | 78 ++++++++++++++++ backend/internal/review_runner/runner_test.go | 92 +++++++++++++++++++ backend/internal/service/review/runner.go | 68 -------------- 4 files changed, 172 insertions(+), 69 deletions(-) create mode 100644 backend/internal/review_runner/runner.go create mode 100644 backend/internal/review_runner/runner_test.go delete mode 100644 backend/internal/service/review/runner.go diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 078ee6cd..06f328a4 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -15,6 +15,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewrunner "github.com/aoagents/agent-orchestrator/backend/internal/review_runner" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" @@ -103,7 +104,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, Sessions: store, PRs: store, Projects: store, - Runner: reviewsvc.NewAgentRunner(agents, runtime), + Runner: reviewrunner.New(agents, runtime), }) return sessionSvc, reviewSvc, nil } diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go new file mode 100644 index 00000000..5a3c0723 --- /dev/null +++ b/backend/internal/review_runner/runner.go @@ -0,0 +1,78 @@ +// Package reviewrunner spawns a reviewer agent for a code review. It is kept out +// of the service layer (which stays thin and HTTP-facing) and sits beside the +// other orchestration packages such as session_manager: it owns the +// agent-resolver + runtime launch flow, not request handling. +package reviewrunner + +import ( + "context" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" +) + +// Runner spawns a reviewer agent over the worker's worktree, mirroring the +// session-manager launch flow (resolve agent by harness → build argv with its +// own prompt → runtime.Create). It reuses the worker's worktree rather than +// cutting a second one: a fresh session worktree would branch off the project's +// default branch and so would not contain the worker's PR changes. The reviewer +// reviews the code and posts its review to the PR itself. +type Runner struct { + agents ports.AgentResolver + runtime ports.Runtime +} + +// New builds the production reviewer runner. +func New(agents ports.AgentResolver, runtime ports.Runtime) *Runner { + return &Runner{agents: agents, runtime: runtime} +} + +var _ reviewsvc.Runner = (*Runner)(nil) + +// Run launches the reviewer agent for one review pass. +func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { + agent, ok := r.agents.Agent(spec.Harness) + if !ok { + return fmt.Errorf("no agent adapter for reviewer harness %q", spec.Harness) + } + reviewerID := "review-" + string(spec.WorkerID) + argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: reviewerID, + WorkspacePath: spec.WorkspacePath, + Prompt: reviewPrompt(spec), + }) + if err != nil { + return fmt.Errorf("reviewer launch command: %w", err) + } + if _, err := r.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: domain.SessionID(reviewerID), + WorkspacePath: spec.WorkspacePath, + Argv: argv, + Env: reviewerEnv(spec), + }); err != nil { + return fmt.Errorf("reviewer runtime: %w", err) + } + return nil +} + +// reviewerEnv carries the worker the reviewer reports against, so the reviewer's +// `ao review submit` resolves the right worker session without a flag. +func reviewerEnv(spec reviewsvc.RunSpec) map[string]string { + return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} +} + +func reviewPrompt(spec reviewsvc.RunSpec) string { + return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a git worktree containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. + +Steps: +1. Find what the PR changed: run `+"`git diff $(git merge-base HEAD origin/HEAD)...HEAD`"+` (or compare against the PR base branch) to see the diff under review. +2. Review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. +3. Post your review on the PR with the GitHub CLI: `+"`gh pr review %s`"+` — use --request-changes (with a summary and inline --comment items) if it needs work, or --approve if it is ready. +4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run + + ao review submit --verdict --body review.md + +Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot determine the diff or post the review, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, spec.PRURL, spec.PRURL) +} diff --git a/backend/internal/review_runner/runner_test.go b/backend/internal/review_runner/runner_test.go new file mode 100644 index 00000000..4420e2d5 --- /dev/null +++ b/backend/internal/review_runner/runner_test.go @@ -0,0 +1,92 @@ +package reviewrunner + +import ( + "context" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" +) + +// fakeAgent is a minimal ports.Agent that records the launch config and returns +// a canned argv. +type fakeAgent struct{ gotLaunch ports.LaunchConfig } + +func (f *fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} +func (f *fakeAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) { + f.gotLaunch = cfg + return []string{"review-agent", "--go"}, nil +} +func (f *fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} +func (f *fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } +func (f *fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} +func (f *fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +type fakeResolver struct { + agent ports.Agent + ok bool +} + +func (f fakeResolver) Agent(domain.AgentHarness) (ports.Agent, bool) { return f.agent, f.ok } + +type fakeRuntime struct { + cfg ports.RuntimeConfig + created bool +} + +func (f *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + f.created = true + f.cfg = cfg + return ports.RuntimeHandle{ID: "h1"}, nil +} +func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } +func (f *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { + return true, nil +} + +func TestRunLaunchesReviewerOverWorkerWorktree(t *testing.T) { + agent := &fakeAgent{} + rt := &fakeRuntime{} + r := New(fakeResolver{agent: agent, ok: true}, rt) + + err := r.Run(context.Background(), reviewsvc.RunSpec{ + WorkerID: "mer-1", + Harness: domain.HarnessCodex, + WorkspacePath: "/ws/mer-1", + PRURL: "https://github.com/o/r/pull/1", + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !rt.created || rt.cfg.WorkspacePath != "/ws/mer-1" { + t.Fatalf("runtime cfg = %+v created=%v", rt.cfg, rt.created) + } + if len(rt.cfg.Argv) == 0 || rt.cfg.Argv[0] != "review-agent" { + t.Fatalf("argv = %v", rt.cfg.Argv) + } + if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" { + t.Fatalf("env = %v, want AO_REVIEW_WORKER=mer-1", rt.cfg.Env) + } + // The launch prompt names the PR and the submit step. + if !strings.Contains(agent.gotLaunch.Prompt, "pull/1") || !strings.Contains(agent.gotLaunch.Prompt, "ao review submit") { + t.Fatalf("prompt missing PR/submit reference: %q", agent.gotLaunch.Prompt) + } +} + +func TestRunErrorsWhenNoAdapter(t *testing.T) { + r := New(fakeResolver{ok: false}, &fakeRuntime{}) + err := r.Run(context.Background(), reviewsvc.RunSpec{Harness: "nope", WorkspacePath: "/ws"}) + if err == nil || !strings.Contains(err.Error(), "no agent adapter") { + t.Fatalf("err = %v, want no-adapter error", err) + } +} diff --git a/backend/internal/service/review/runner.go b/backend/internal/service/review/runner.go deleted file mode 100644 index b2201830..00000000 --- a/backend/internal/service/review/runner.go +++ /dev/null @@ -1,68 +0,0 @@ -package review - -import ( - "context" - "fmt" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -// agentRunner spawns a reviewer agent over the worker's worktree, mirroring the -// session-manager launch flow (resolve agent by harness → build argv with its -// own prompt → runtime.Create). It reuses the worker's worktree rather than -// cutting a second one: a fresh session worktree would branch off the project's -// default branch and so would not contain the worker's PR changes. The reviewer -// reviews the code and posts its review to the PR itself. -type agentRunner struct { - agents ports.AgentResolver - runtime ports.Runtime -} - -// NewAgentRunner builds the production reviewer runner. -func NewAgentRunner(agents ports.AgentResolver, runtime ports.Runtime) Runner { - return agentRunner{agents: agents, runtime: runtime} -} - -func (r agentRunner) Run(ctx context.Context, spec RunSpec) error { - agent, ok := r.agents.Agent(spec.Harness) - if !ok { - return fmt.Errorf("no agent adapter for reviewer harness %q", spec.Harness) - } - reviewerID := "review-" + string(spec.WorkerID) - prompt := reviewPrompt(spec) - argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ - SessionID: reviewerID, - WorkspacePath: spec.WorkspacePath, - Prompt: prompt, - }) - if err != nil { - return fmt.Errorf("reviewer launch command: %w", err) - } - if _, err := r.runtime.Create(ctx, ports.RuntimeConfig{ - SessionID: domain.SessionID(reviewerID), - WorkspacePath: spec.WorkspacePath, - Argv: argv, - Env: reviewerEnv(spec), - }); err != nil { - return fmt.Errorf("reviewer runtime: %w", err) - } - return nil -} - -// reviewerEnv carries the worker the reviewer reports against, so the reviewer's -// `ao review submit` resolves the right worker session without a flag. -func reviewerEnv(spec RunSpec) map[string]string { - return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} -} - -func reviewPrompt(spec RunSpec) string { - return fmt.Sprintf(`You are an AO code reviewer. Review the changes in this worktree for pull request %s. - -1. Post your review directly on the pull request on GitHub (use `+"`gh pr review`"+`): request changes if the PR needs work, approve if it is ready, and leave inline comments for specific findings. -2. Write your full review as Markdown to a file (for example review.md) and record the result with AO by running: - - ao review submit --verdict --body review.md - -Do not push commits or modify the code — only review it.`, spec.PRURL) -} From 30795f66cf61c679e2fc51a2478aa546808ce141 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 21:11:06 +0530 Subject: [PATCH 05/11] refactor(review): simplify review_run schema; provider-agnostic reviewer prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #197 review: - review_run: status default 'running' (drop 'pending'), drop CHECK constraints on status/verdict, drop the updated_at column and the session/iteration index. Propagated through queries, domain, store, service, and tests. - Reviewer prompt no longer hardcodes GitHub/gh commands — it instructs the agent to use whatever review tooling the provider offers, keeping the flow extensible across SCM providers. Regenerated sqlc + OpenAPI/TS. Co-Authored-By: Claude Opus 4.8 --- backend/internal/cli/review.go | 1 - backend/internal/domain/review.go | 3 +-- backend/internal/httpd/apispec/openapi.yaml | 4 ---- backend/internal/review_runner/runner.go | 8 +++---- backend/internal/service/review/review.go | 13 ++++------ .../internal/service/review/review_test.go | 9 ++++--- backend/internal/storage/sqlite/gen/models.go | 1 - .../internal/storage/sqlite/gen/review.sql.go | 24 +++++++------------ .../migrations/0011_add_review_tables.sql | 20 ++++------------ .../storage/sqlite/queries/review.sql | 10 ++++---- .../storage/sqlite/store/review_store.go | 14 ++++------- .../storage/sqlite/store/review_store_test.go | 6 ++--- frontend/src/api/schema.ts | 2 -- 13 files changed, 41 insertions(+), 74 deletions(-) diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go index 29ab6235..b3ac7e64 100644 --- a/backend/internal/cli/review.go +++ b/backend/internal/cli/review.go @@ -22,7 +22,6 @@ type reviewRun struct { Iteration int `json:"iteration"` Body string `json:"body"` CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` } // reviewRunResponse mirrors controllers.ReviewRunResponse. diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index 3e022f3c..4f5da6c1 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -29,7 +29,6 @@ type ReviewRun struct { // own tracking; the reviewer also posts the review to the PR itself. Body string `json:"body"` CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` } // ReviewRunStatus is the lifecycle state of a single review pass. @@ -37,7 +36,7 @@ type ReviewRunStatus string // Review run statuses. const ( - ReviewRunPending ReviewRunStatus = "pending" + ReviewRunRunning ReviewRunStatus = "running" ReviewRunComplete ReviewRunStatus = "complete" ReviewRunFailed ReviewRunStatus = "failed" ) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index de665f58..a62824ea 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1463,9 +1463,6 @@ components: type: string status: type: string - updatedAt: - format: date-time - type: string verdict: type: string required: @@ -1479,7 +1476,6 @@ components: - iteration - body - createdAt - - updatedAt type: object ReviewRunResponse: properties: diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go index 5a3c0723..20cdf153 100644 --- a/backend/internal/review_runner/runner.go +++ b/backend/internal/review_runner/runner.go @@ -64,15 +64,15 @@ func reviewerEnv(spec reviewsvc.RunSpec) map[string]string { } func reviewPrompt(spec reviewsvc.RunSpec) string { - return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a git worktree containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. + return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a checkout containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. Steps: -1. Find what the PR changed: run `+"`git diff $(git merge-base HEAD origin/HEAD)...HEAD`"+` (or compare against the PR base branch) to see the diff under review. +1. Inspect what the PR changed by diffing the checkout against the PR's base branch. 2. Review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. -3. Post your review on the PR with the GitHub CLI: `+"`gh pr review %s`"+` — use --request-changes (with a summary and inline --comment items) if it needs work, or --approve if it is ready. +3. Post your review on the pull request using whatever review tooling is available for this provider (request changes if it needs work, approve if it is ready), with inline comments for specific findings. 4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run ao review submit --verdict --body review.md -Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot determine the diff or post the review, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, spec.PRURL, spec.PRURL) +Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot post the review on the provider, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, spec.PRURL) } diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index 274c16b7..74dd5623 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -30,7 +30,7 @@ type Store interface { UpsertReview(ctx context.Context, r domain.Review) error GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error - UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) } @@ -160,11 +160,10 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai SessionID: workerID, Harness: harness, PRURL: prURL, - Status: domain.ReviewRunPending, + Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, Iteration: s.nextIteration(ctx, workerID), CreatedAt: now, - UpdatedAt: now, } if err := s.store.InsertReviewRun(ctx, run); err != nil { return domain.ReviewRun{}, err @@ -176,9 +175,9 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai WorkspacePath: worker.Metadata.WorkspacePath, PRURL: prURL, }); err != nil { - // The pass never launched; record it as failed so a stale pending row + // The pass never launched; record it as failed so a stale running row // does not look like an in-flight review forever. - _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, "", s.clock()) + _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, "") return domain.ReviewRun{}, fmt.Errorf("launch reviewer: %w", err) } return run, nil @@ -206,14 +205,12 @@ func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict return domain.ReviewRun{}, fmt.Errorf("%w: no review run for worker %q", ErrNotFound, workerID) } - now := s.clock() - if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body, now); err != nil { + if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body); err != nil { return domain.ReviewRun{}, err } run.Status = domain.ReviewRunComplete run.Verdict = verdict run.Body = body - run.UpdatedAt = now return run, nil } diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index 2ab30e1d..a0b8270d 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -40,7 +40,7 @@ func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error f.runs = append(f.runs, r) return nil } -func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error { +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error { if f.updateErr != nil { return f.updateErr } @@ -49,7 +49,6 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status d f.runs[i].Status = status f.runs[i].Verdict = verdict f.runs[i].Body = body - f.runs[i].UpdatedAt = updatedAt } } return nil @@ -130,7 +129,7 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { t.Fatalf("Trigger: %v", err) } // A configured reviewer wins over the worker harness. - if run.Status != domain.ReviewRunPending || run.Iteration != 1 || run.Harness != domain.HarnessAider { + if run.Status != domain.ReviewRunRunning || run.Iteration != 1 || run.Harness != domain.HarnessAider { t.Fatalf("run = %+v", run) } if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.HarnessAider { @@ -221,7 +220,7 @@ func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { } func TestSubmitRecordsVerdictAndBody(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunPending}}} + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunRunning}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) run, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, "please fix") @@ -237,7 +236,7 @@ func TestSubmitRecordsVerdictAndBody(t *testing.T) { } func TestSubmitValidation(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunPending}}} + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunRunning}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) if _, err := svc.Submit(context.Background(), "mer-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 7e976d80..a7ae5365 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -132,7 +132,6 @@ type ReviewRun struct { Iteration int64 Body string CreatedAt time.Time - UpdatedAt time.Time } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index 485f1453..787e6af0 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -13,7 +13,7 @@ import ( ) const getLatestReviewRunBySession = `-- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1 ` @@ -31,7 +31,6 @@ func (q *Queries) GetLatestReviewRunBySession(ctx context.Context, sessionID dom &i.Iteration, &i.Body, &i.CreatedAt, - &i.UpdatedAt, ) return i, err } @@ -57,8 +56,8 @@ func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.Sessi } const insertReviewRun = `-- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertReviewRunParams struct { @@ -72,7 +71,6 @@ type InsertReviewRunParams struct { Iteration int64 Body string CreatedAt time.Time - UpdatedAt time.Time } func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams) error { @@ -87,13 +85,12 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams arg.Iteration, arg.Body, arg.CreatedAt, - arg.UpdatedAt, ) return err } const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC ` @@ -117,7 +114,6 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. &i.Iteration, &i.Body, &i.CreatedAt, - &i.UpdatedAt, ); err != nil { return nil, err } @@ -133,15 +129,14 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. } const updateReviewRunResult = `-- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, body = ?, updated_at = ? WHERE id = ? +UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ? ` type UpdateReviewRunResultParams struct { - Status domain.ReviewRunStatus - Verdict domain.ReviewVerdict - Body string - UpdatedAt time.Time - ID string + Status domain.ReviewRunStatus + Verdict domain.ReviewVerdict + Body string + ID string } func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRunResultParams) error { @@ -149,7 +144,6 @@ func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRun arg.Status, arg.Verdict, arg.Body, - arg.UpdatedAt, arg.ID, ) return err diff --git a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql index 6592fd63..0ae3047f 100644 --- a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql +++ b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql @@ -1,7 +1,7 @@ -- Configurable AO code review (issue #192). review holds one row per worker -- session under review (session_id UNIQUE); a repeat trigger reuses the row. --- review_run holds the per-pass facts. The review body is not persisted — it is --- posted to the SCM provider and flows to the worker through the SCM observer. +-- review_run holds the per-pass facts. The reviewer agent posts its review to +-- the PR itself; `ao review submit` records the verdict and body on the run. -- +goose Up -- +goose StatementBegin @@ -23,26 +23,16 @@ CREATE TABLE review_run ( session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, harness TEXT NOT NULL, pr_url TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'complete', 'failed')), - verdict TEXT NOT NULL DEFAULT '' - CHECK (verdict IN ('', 'approved', 'changes_requested')), + status TEXT NOT NULL DEFAULT 'running', + verdict TEXT NOT NULL DEFAULT '', iteration INTEGER NOT NULL DEFAULT 1, body TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL + created_at TIMESTAMP NOT NULL ); -- +goose StatementEnd --- +goose StatementBegin -CREATE INDEX idx_review_run_session ON review_run (session_id, iteration); --- +goose StatementEnd - -- +goose Down -- +goose StatementBegin -DROP INDEX IF EXISTS idx_review_run_session; --- +goose StatementEnd --- +goose StatementBegin DROP TABLE review_run; -- +goose StatementEnd -- +goose StatementBegin diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index 1c4104fd..0ef78d0e 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -11,16 +11,16 @@ SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at FROM review WHERE session_id = ?; -- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, body = ?, updated_at = ? WHERE id = ?; +UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ?; -- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1; -- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at, updated_at +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index 7dc4cd6d..2ac1b5ca 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" @@ -54,20 +53,18 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { Iteration: int64(r.Iteration), Body: r.Body, CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, }) } // UpdateReviewRunResult sets the status/verdict/body of a review pass. -func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string, updatedAt time.Time) error { +func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpdateReviewRunResult(ctx, gen.UpdateReviewRunResultParams{ - Status: status, - Verdict: verdict, - Body: body, - UpdatedAt: updatedAt, - ID: id, + Status: status, + Verdict: verdict, + Body: body, + ID: id, }) } @@ -121,6 +118,5 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { Iteration: int(r.Iteration), Body: r.Body, CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, } } diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index 37d1d629..8fdcef7e 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -49,12 +49,12 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { // A run inserts pending and updates to complete/changes_requested. if err := s.InsertReviewRun(ctx, domain.ReviewRun{ ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.HarnessAider, - PRURL: got.PRURL, Status: domain.ReviewRunPending, Verdict: domain.VerdictNone, - Iteration: 1, CreatedAt: now, UpdatedAt: now, + PRURL: got.PRURL, Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, + Iteration: 1, CreatedAt: now, }); err != nil { t.Fatalf("insert run: %v", err) } - if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix", now.Add(2*time.Second)); err != nil { + if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix"); err != nil { t.Fatalf("update run: %v", err) } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index cb3c45c8..32d8aaa0 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -526,8 +526,6 @@ export interface components { reviewId: string; sessionId: string; status: string; - /** Format: date-time */ - updatedAt: string; verdict: string; }; ReviewRunResponse: { From 2a87cca3e78302f6cbf4b9a41473492f719ea204 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 21:36:02 +0530 Subject: [PATCH 06/11] refactor(review): launch reviewer before persisting the run Trigger now spawns the reviewer agent first and then writes the review_run with a status derived from the launch outcome (running on success, failed if it never started), instead of inserting a running row and correcting it to failed afterwards. Co-Authored-By: Claude Opus 4.8 --- backend/internal/service/review/review.go | 34 +++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index 74dd5623..a3e5a353 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -149,36 +149,42 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai } now := s.clock() + iteration := s.nextIteration(ctx, workerID) + + // Launch the reviewer first, then persist the pass with a status that + // reflects the launch outcome: running on success, failed if it never + // started. This avoids writing a row that has to be corrected afterwards. + runErr := s.runner.Run(ctx, RunSpec{ + WorkerID: workerID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: prURL, + }) + status := domain.ReviewRunRunning + if runErr != nil { + status = domain.ReviewRunFailed + } + review, err := s.upsertReview(ctx, worker, harness, prURL, now) if err != nil { return domain.ReviewRun{}, err } - run := domain.ReviewRun{ ID: s.newID(), ReviewID: review.ID, SessionID: workerID, Harness: harness, PRURL: prURL, - Status: domain.ReviewRunRunning, + Status: status, Verdict: domain.VerdictNone, - Iteration: s.nextIteration(ctx, workerID), + Iteration: iteration, CreatedAt: now, } if err := s.store.InsertReviewRun(ctx, run); err != nil { return domain.ReviewRun{}, err } - - if err := s.runner.Run(ctx, RunSpec{ - WorkerID: workerID, - Harness: harness, - WorkspacePath: worker.Metadata.WorkspacePath, - PRURL: prURL, - }); err != nil { - // The pass never launched; record it as failed so a stale running row - // does not look like an in-flight review forever. - _ = s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, "") - return domain.ReviewRun{}, fmt.Errorf("launch reviewer: %w", err) + if runErr != nil { + return run, fmt.Errorf("launch reviewer: %w", runErr) } return run, nil } From d6d52b9f2ebf313bdbd476f34fb22e09765e107c Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Fri, 12 Jun 2026 21:52:42 +0530 Subject: [PATCH 07/11] refactor(review): pluggable reviewer registry distinct from worker harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewers are now their own pluggable adapter set, separate from the worker agent registry — adding a reviewer (claude-code today, greptile tomorrow) is a one-line registration that does not widen the worker harness vocabulary, and a worker harness does not automatically become a valid reviewer. - domain.ReviewerHarness: a distinct vocabulary (AllReviewerHarnesses) with its own IsKnown; ReviewerConfig/Review/ReviewRun use it. ResolveReviewerHarness reuses the worker harness only when it is itself a supported reviewer, else falls back to claude-code. - ports.Reviewer: a reviewer-specific contract (ReviewCommand → argv + env) that models one-shot / non-prompt CLIs natively instead of forcing every reviewer through the worker's interactive GetLaunchCommand(Prompt:...). - internal/adapters/reviewer: a separate registry + resolver (mirrors the worker agent registry) with the claude-code reviewer adapter, which owns the review prompt and reuses the worker claude-code launch construction. - review_runner resolves via the reviewer registry (not the worker AgentResolver) and merges AO_REVIEW_WORKER into the adapter's env. - daemon wires the reviewer resolver. Registry/domain parity is test-enforced. Co-Authored-By: Claude Opus 4.8 --- .../reviewer/claudecode/claudecode.go | 60 +++++++++++++++ .../internal/adapters/reviewer/registry.go | 58 ++++++++++++++ .../adapters/reviewer/registry_test.go | 44 +++++++++++ backend/internal/daemon/lifecycle_wiring.go | 14 +++- backend/internal/domain/projectconfig.go | 20 ++--- backend/internal/domain/projectconfig_test.go | 20 ++--- backend/internal/domain/review.go | 16 ++-- backend/internal/domain/reviewerharness.go | 30 ++++++++ backend/internal/ports/reviewer.go | 44 +++++++++++ backend/internal/review_runner/runner.go | 71 ++++++++---------- backend/internal/review_runner/runner_test.go | 75 ++++++++----------- backend/internal/service/review/review.go | 6 +- .../internal/service/review/review_test.go | 28 +++---- backend/internal/storage/sqlite/gen/models.go | 4 +- .../internal/storage/sqlite/gen/review.sql.go | 4 +- .../storage/sqlite/store/review_store_test.go | 8 +- backend/sqlc.yaml | 4 +- 17 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 backend/internal/adapters/reviewer/claudecode/claudecode.go create mode 100644 backend/internal/adapters/reviewer/registry.go create mode 100644 backend/internal/adapters/reviewer/registry_test.go create mode 100644 backend/internal/domain/reviewerharness.go create mode 100644 backend/internal/ports/reviewer.go diff --git a/backend/internal/adapters/reviewer/claudecode/claudecode.go b/backend/internal/adapters/reviewer/claudecode/claudecode.go new file mode 100644 index 00000000..a75c95b7 --- /dev/null +++ b/backend/internal/adapters/reviewer/claudecode/claudecode.go @@ -0,0 +1,60 @@ +// Package claudecode is the claude-code reviewer adapter. claude-code is a +// prompt-driven agent, so this reviewer builds a review prompt and reuses the +// worker claude-code adapter's launch-command construction (binary resolution, +// flags). The reviewer contract itself stays prompt-agnostic, so a one-shot CLI +// reviewer (e.g. greptile) can implement it without a prompt. +package claudecode + +import ( + "context" + "fmt" + + workeragent "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Reviewer is the claude-code code-review adapter. +type Reviewer struct { + agent ports.Agent +} + +// New builds the claude-code reviewer adapter. +func New() *Reviewer { + return &Reviewer{agent: workeragent.New()} +} + +// Harness identifies this reviewer in the reviewer registry. +func (r *Reviewer) Harness() domain.ReviewerHarness { + return domain.ReviewerClaudeCode +} + +var _ ports.Reviewer = (*Reviewer)(nil) + +// ReviewCommand builds a one-shot claude-code invocation that reviews the +// worker's checkout for the PR, with the review prompt baked in. +func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { + argv, err := r.agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: inv.ReviewerID, + WorkspacePath: inv.WorkspacePath, + Prompt: reviewPrompt(inv), + }) + if err != nil { + return ports.ReviewCommandSpec{}, err + } + return ports.ReviewCommandSpec{Argv: argv}, nil +} + +func reviewPrompt(inv ports.ReviewInvocation) string { + return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a checkout containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. + +Steps: +1. Inspect what the PR changed by diffing the checkout against the PR's base branch. +2. Review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. +3. Post your review on the pull request using the available review tooling (request changes if it needs work, approve if it is ready), with inline comments for specific findings. +4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run + + ao review submit --verdict --body review.md + +Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot post the review, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, inv.PRURL) +} diff --git a/backend/internal/adapters/reviewer/registry.go b/backend/internal/adapters/reviewer/registry.go new file mode 100644 index 00000000..b5fbcb7d --- /dev/null +++ b/backend/internal/adapters/reviewer/registry.go @@ -0,0 +1,58 @@ +// Package reviewer is the single source of truth for the code-review adapters +// the daemon ships. It mirrors the worker agent registry but is a separate set: +// adding a reviewer (claude-code today, greptile tomorrow) is one edit here and +// does not widen the worker AgentHarness vocabulary. +package reviewer + +import ( + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Adapter is a registered reviewer: a ports.Reviewer that names its harness. +type Adapter interface { + ports.Reviewer + Harness() domain.ReviewerHarness +} + +// Constructors returns every reviewer adapter the daemon ships. Add a reviewer +// here (and to domain.AllReviewerHarnesses) to register it. +func Constructors() []Adapter { + return []Adapter{ + claudecode.New(), + } +} + +// Resolver maps a reviewer harness onto its adapter. +type Resolver struct { + reviewers map[domain.ReviewerHarness]ports.Reviewer +} + +var _ ports.ReviewerResolver = (*Resolver)(nil) + +// NewResolver builds a Resolver from the shipped reviewer adapters. It fails if +// two adapters claim the same harness, or if a registered harness is not in the +// domain reviewer vocabulary (the two must stay in sync). +func NewResolver() (*Resolver, error) { + m := make(map[domain.ReviewerHarness]ports.Reviewer) + for _, a := range Constructors() { + h := a.Harness() + if !h.IsKnown() { + return nil, fmt.Errorf("reviewer adapter %q is not in domain.AllReviewerHarnesses", h) + } + if _, dup := m[h]; dup { + return nil, fmt.Errorf("reviewer harness %q is registered twice", h) + } + m[h] = a + } + return &Resolver{reviewers: m}, nil +} + +// Reviewer returns the adapter for a harness, ok=false when none is registered. +func (r *Resolver) Reviewer(harness domain.ReviewerHarness) (ports.Reviewer, bool) { + rv, ok := r.reviewers[harness] + return rv, ok +} diff --git a/backend/internal/adapters/reviewer/registry_test.go b/backend/internal/adapters/reviewer/registry_test.go new file mode 100644 index 00000000..fba7020f --- /dev/null +++ b/backend/internal/adapters/reviewer/registry_test.go @@ -0,0 +1,44 @@ +package reviewer + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// TestRegistryMatchesDomainVocabulary enforces that the shipped reviewer +// adapters and domain.AllReviewerHarnesses stay in sync: every registered +// adapter is a known reviewer harness, and every known harness has an adapter. +func TestRegistryMatchesDomainVocabulary(t *testing.T) { + registered := map[domain.ReviewerHarness]bool{} + for _, a := range Constructors() { + h := a.Harness() + if !h.IsKnown() { + t.Errorf("adapter harness %q is not in domain.AllReviewerHarnesses", h) + } + if registered[h] { + t.Errorf("reviewer harness %q registered twice", h) + } + registered[h] = true + } + for _, h := range domain.AllReviewerHarnesses { + if !registered[h] { + t.Errorf("reviewer harness %q has no registered adapter", h) + } + } +} + +func TestNewResolverResolvesShippedReviewers(t *testing.T) { + resolver, err := NewResolver() + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + for _, h := range domain.AllReviewerHarnesses { + if _, ok := resolver.Reviewer(h); !ok { + t.Errorf("resolver missing reviewer %q", h) + } + } + if _, ok := resolver.Reviewer("nope"); ok { + t.Error("resolver returned an adapter for an unknown harness") + } +} diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 06f328a4..562a17cc 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -9,6 +9,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -96,15 +97,20 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, // activity hooks; the deriver registry is the source of truth for that. SignalCapable: activitydispatch.SupportsHarness, }) - // Triggering a review spawns the reviewer agent over the worker's worktree - // (reusing the agent resolver + runtime); the reviewer posts its review to - // the PR itself, so the review service needs no SCM writer. + // Triggering a review spawns a reviewer over the worker's worktree, resolved + // from the reviewer registry (distinct from the worker agent set). The + // reviewer posts its review to the PR itself, so the service needs no SCM + // writer. + reviewers, err := reviewer.NewResolver() + if err != nil { + return nil, nil, fmt.Errorf("reviewer resolver: %w", err) + } reviewSvc := reviewsvc.New(reviewsvc.Deps{ Store: store, Sessions: store, PRs: store, Projects: store, - Runner: reviewrunner.New(agents, runtime), + Runner: reviewrunner.New(reviewers, runtime), }) return sessionSvc, reviewSvc, nil } diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 5e28cc8c..9724155e 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -43,24 +43,26 @@ type ProjectConfig struct { Reviewers []ReviewerConfig `json:"reviewers,omitempty"` } -// ReviewerConfig names one reviewer agent by harness. +// ReviewerConfig names one reviewer agent by harness. The harness is drawn from +// the reviewer vocabulary (ReviewerHarness), which is distinct from the worker +// AgentHarness set. type ReviewerConfig struct { - Harness AgentHarness `json:"harness"` + Harness ReviewerHarness `json:"harness"` } // FallbackReviewerHarness is the reviewer used when a project configures none -// and the worker's harness cannot be reused. -const FallbackReviewerHarness = HarnessClaudeCode +// and the worker's harness is not itself a supported reviewer. +const FallbackReviewerHarness = ReviewerClaudeCode // ResolveReviewerHarness picks the reviewer harness for a worker. A configured -// reviewer wins; otherwise it reuses the worker's own harness when that is -// supported, falling back to claude-code. -func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) AgentHarness { +// reviewer wins; otherwise it reuses the worker's own harness when that harness +// is also a supported reviewer, falling back to claude-code. +func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness { if len(c.Reviewers) > 0 { return c.Reviewers[0].Harness } - if workerHarness.IsKnown() { - return workerHarness + if h := ReviewerHarness(workerHarness); h.IsKnown() { + return h } return FallbackReviewerHarness } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index fceecfb1..b7a969f9 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -19,8 +19,9 @@ func TestProjectConfigValidate(t *testing.T) { {"symlink parent escape", ProjectConfig{Symlinks: []string{"../escape"}}, true}, {"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true}, {"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, true}, - {"good reviewers", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}}}, false}, + {"good reviewers", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerClaudeCode}}}, false}, {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, + {"worker harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessCodex)}}}, true}, {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, } for _, tt := range tests { @@ -70,18 +71,19 @@ func TestProjectConfigWithDefaults(t *testing.T) { func TestResolveReviewerHarness(t *testing.T) { // A configured reviewer always wins, regardless of the worker harness. - cfg := ProjectConfig{Reviewers: []ReviewerConfig{{Harness: HarnessCodex}}} - if got := cfg.ResolveReviewerHarness(HarnessAider); got != HarnessCodex { - t.Fatalf("configured reviewer = %q, want codex", got) + cfg := ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerClaudeCode}}} + if got := cfg.ResolveReviewerHarness(HarnessAider); got != ReviewerClaudeCode { + t.Fatalf("configured reviewer = %q, want claude-code", got) } - // No reviewer configured: reuse the worker harness when supported. - if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != HarnessAider { - t.Fatalf("default = %q, want worker harness aider", got) + // No reviewer configured: reuse the worker harness when it is also a + // supported reviewer (claude-code is). + if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessClaudeCode); got != ReviewerClaudeCode { + t.Fatalf("default = %q, want reviewer claude-code", got) } - // Unknown/empty worker harness falls back to claude-code. - if got := (ProjectConfig{}).ResolveReviewerHarness("nope"); got != FallbackReviewerHarness { + // A worker harness that is not a supported reviewer falls back to claude-code. + if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != FallbackReviewerHarness { t.Fatalf("fallback = %q, want %q", got, FallbackReviewerHarness) } } diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index 4f5da6c1..55cb1b83 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -6,13 +6,13 @@ import "time" // (SessionID is unique). A repeat trigger reuses this row; the per-pass facts // live on ReviewRun. type Review struct { - ID string `json:"id"` - SessionID SessionID `json:"sessionId"` - ProjectID ProjectID `json:"projectId"` - Harness AgentHarness `json:"harness"` - PRURL string `json:"prUrl"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + SessionID SessionID `json:"sessionId"` + ProjectID ProjectID `json:"projectId"` + Harness ReviewerHarness `json:"harness"` + PRURL string `json:"prUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ReviewRun is one review pass against a worker's PR. @@ -20,7 +20,7 @@ type ReviewRun struct { ID string `json:"id"` ReviewID string `json:"reviewId"` SessionID SessionID `json:"sessionId"` - Harness AgentHarness `json:"harness"` + Harness ReviewerHarness `json:"harness"` PRURL string `json:"prUrl"` Status ReviewRunStatus `json:"status"` Verdict ReviewVerdict `json:"verdict"` diff --git a/backend/internal/domain/reviewerharness.go b/backend/internal/domain/reviewerharness.go new file mode 100644 index 00000000..760be13e --- /dev/null +++ b/backend/internal/domain/reviewerharness.go @@ -0,0 +1,30 @@ +package domain + +// ReviewerHarness identifies a code-review agent. It is a separate vocabulary +// from AgentHarness on purpose: a reviewer-only tool (e.g. the Greptile CLI) +// must not become a valid worker, and a worker harness does not automatically +// become a valid reviewer. The two sets are maintained independently and only +// happen to share ids where the same tool serves both roles (claude-code). +type ReviewerHarness string + +// Supported reviewer harnesses. Add a reviewer-only tool here (and register its +// adapter) without widening the worker AgentHarness set. +const ( + ReviewerClaudeCode ReviewerHarness = "claude-code" +) + +// AllReviewerHarnesses is the canonical set used to validate a configured +// reviewer harness. +var AllReviewerHarnesses = []ReviewerHarness{ + ReviewerClaudeCode, +} + +// IsKnown reports whether h is one of the supported reviewer harnesses. +func (h ReviewerHarness) IsKnown() bool { + for _, k := range AllReviewerHarnesses { + if h == k { + return true + } + } + return false +} diff --git a/backend/internal/ports/reviewer.go b/backend/internal/ports/reviewer.go new file mode 100644 index 00000000..a05a30e9 --- /dev/null +++ b/backend/internal/ports/reviewer.go @@ -0,0 +1,44 @@ +package ports + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// Reviewer is the contract a code-review adapter satisfies. It is deliberately +// separate from Agent: a reviewer is invoked once over a checkout to review a +// PR, and need not be a prompt-fed interactive agent. A prompt-driven reviewer +// (claude-code) builds its own prompt internally; a one-shot CLI (greptile) +// returns its own argv with no prompt at all. +type Reviewer interface { + // ReviewCommand builds the command (and any extra env) AO should run to + // review the worker's checkout for a PR. + ReviewCommand(ctx context.Context, inv ReviewInvocation) (ReviewCommandSpec, error) +} + +// ReviewInvocation describes one review pass for a reviewer to act on. +type ReviewInvocation struct { + // ReviewerID is a stable id for the reviewer's runtime instance (pane, + // native session id), derived from the worker session. + ReviewerID string + // WorkerSessionID is the worker whose PR is under review. + WorkerSessionID domain.SessionID + // PRURL is the pull request to review. + PRURL string + // WorkspacePath is the worker's checkout the reviewer reads. + WorkspacePath string +} + +// ReviewCommandSpec is how to launch a reviewer: the argv and any extra env the +// adapter needs. AO supplies the workspace and review-tracking env around it. +type ReviewCommandSpec struct { + Argv []string + Env map[string]string +} + +// ReviewerResolver maps a reviewer harness onto its adapter. ok=false means no +// adapter is registered for that harness. +type ReviewerResolver interface { + Reviewer(harness domain.ReviewerHarness) (Reviewer, bool) +} diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go index 20cdf153..90478e52 100644 --- a/backend/internal/review_runner/runner.go +++ b/backend/internal/review_runner/runner.go @@ -1,7 +1,7 @@ // Package reviewrunner spawns a reviewer agent for a code review. It is kept out // of the service layer (which stays thin and HTTP-facing) and sits beside the // other orchestration packages such as session_manager: it owns the -// agent-resolver + runtime launch flow, not request handling. +// reviewer-resolver + runtime launch flow, not request handling. package reviewrunner import ( @@ -13,66 +13,59 @@ import ( reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" ) -// Runner spawns a reviewer agent over the worker's worktree, mirroring the -// session-manager launch flow (resolve agent by harness → build argv with its -// own prompt → runtime.Create). It reuses the worker's worktree rather than -// cutting a second one: a fresh session worktree would branch off the project's -// default branch and so would not contain the worker's PR changes. The reviewer -// reviews the code and posts its review to the PR itself. +// Runner spawns a reviewer over the worker's worktree, resolving the reviewer +// adapter from the reviewer registry (distinct from the worker agent set) and +// launching the command it returns on the runtime. It reuses the worker's +// worktree rather than cutting a second one: a fresh session worktree would +// branch off the project's default branch and so would not contain the worker's +// PR changes. type Runner struct { - agents ports.AgentResolver - runtime ports.Runtime + reviewers ports.ReviewerResolver + runtime ports.Runtime } // New builds the production reviewer runner. -func New(agents ports.AgentResolver, runtime ports.Runtime) *Runner { - return &Runner{agents: agents, runtime: runtime} +func New(reviewers ports.ReviewerResolver, runtime ports.Runtime) *Runner { + return &Runner{reviewers: reviewers, runtime: runtime} } var _ reviewsvc.Runner = (*Runner)(nil) -// Run launches the reviewer agent for one review pass. +// Run launches the reviewer for one review pass. func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { - agent, ok := r.agents.Agent(spec.Harness) + reviewer, ok := r.reviewers.Reviewer(spec.Harness) if !ok { - return fmt.Errorf("no agent adapter for reviewer harness %q", spec.Harness) + return fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) } reviewerID := "review-" + string(spec.WorkerID) - argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ - SessionID: reviewerID, - WorkspacePath: spec.WorkspacePath, - Prompt: reviewPrompt(spec), + cmd, err := reviewer.ReviewCommand(ctx, ports.ReviewInvocation{ + ReviewerID: reviewerID, + WorkerSessionID: spec.WorkerID, + PRURL: spec.PRURL, + WorkspacePath: spec.WorkspacePath, }) if err != nil { - return fmt.Errorf("reviewer launch command: %w", err) + return fmt.Errorf("reviewer command: %w", err) } if _, err := r.runtime.Create(ctx, ports.RuntimeConfig{ SessionID: domain.SessionID(reviewerID), WorkspacePath: spec.WorkspacePath, - Argv: argv, - Env: reviewerEnv(spec), + Argv: cmd.Argv, + Env: reviewerEnv(spec, cmd.Env), }); err != nil { return fmt.Errorf("reviewer runtime: %w", err) } return nil } -// reviewerEnv carries the worker the reviewer reports against, so the reviewer's -// `ao review submit` resolves the right worker session without a flag. -func reviewerEnv(spec reviewsvc.RunSpec) map[string]string { - return map[string]string{"AO_REVIEW_WORKER": string(spec.WorkerID)} -} - -func reviewPrompt(spec reviewsvc.RunSpec) string { - return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a checkout containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. - -Steps: -1. Inspect what the PR changed by diffing the checkout against the PR's base branch. -2. Review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. -3. Post your review on the pull request using whatever review tooling is available for this provider (request changes if it needs work, approve if it is ready), with inline comments for specific findings. -4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run - - ao review submit --verdict --body review.md - -Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot post the review on the provider, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, spec.PRURL) +// reviewerEnv merges the adapter's env with AO_REVIEW_WORKER, which carries the +// worker the reviewer reports against so its `ao review submit` resolves the +// right worker session without a flag. +func reviewerEnv(spec reviewsvc.RunSpec, adapterEnv map[string]string) map[string]string { + env := make(map[string]string, len(adapterEnv)+1) + for k, v := range adapterEnv { + env[k] = v + } + env["AO_REVIEW_WORKER"] = string(spec.WorkerID) + return env } diff --git a/backend/internal/review_runner/runner_test.go b/backend/internal/review_runner/runner_test.go index 4420e2d5..d0585a6f 100644 --- a/backend/internal/review_runner/runner_test.go +++ b/backend/internal/review_runner/runner_test.go @@ -10,34 +10,25 @@ import ( reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" ) -// fakeAgent is a minimal ports.Agent that records the launch config and returns -// a canned argv. -type fakeAgent struct{ gotLaunch ports.LaunchConfig } - -func (f *fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { - return ports.ConfigSpec{}, nil -} -func (f *fakeAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) { - f.gotLaunch = cfg - return []string{"review-agent", "--go"}, nil -} -func (f *fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - return ports.PromptDeliveryInCommand, nil +type fakeReviewer struct { + gotInv ports.ReviewInvocation + spec ports.ReviewCommandSpec + err error } -func (f *fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } -func (f *fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { - return nil, false, nil -} -func (f *fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { - return ports.SessionInfo{}, false, nil + +func (f *fakeReviewer) ReviewCommand(_ context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { + f.gotInv = inv + return f.spec, f.err } type fakeResolver struct { - agent ports.Agent - ok bool + reviewer ports.Reviewer + ok bool } -func (f fakeResolver) Agent(domain.AgentHarness) (ports.Agent, bool) { return f.agent, f.ok } +func (f fakeResolver) Reviewer(domain.ReviewerHarness) (ports.Reviewer, bool) { + return f.reviewer, f.ok +} type fakeRuntime struct { cfg ports.RuntimeConfig @@ -49,44 +40,44 @@ func (f *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports. f.cfg = cfg return ports.RuntimeHandle{ID: "h1"}, nil } -func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } -func (f *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { - return true, nil -} +func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } +func (f *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil } -func TestRunLaunchesReviewerOverWorkerWorktree(t *testing.T) { - agent := &fakeAgent{} +func TestRunLaunchesResolvedReviewer(t *testing.T) { + reviewer := &fakeReviewer{spec: ports.ReviewCommandSpec{ + Argv: []string{"greptile", "review"}, + Env: map[string]string{"GREPTILE_MODE": "ci"}, + }} rt := &fakeRuntime{} - r := New(fakeResolver{agent: agent, ok: true}, rt) + r := New(fakeResolver{reviewer: reviewer, ok: true}, rt) err := r.Run(context.Background(), reviewsvc.RunSpec{ WorkerID: "mer-1", - Harness: domain.HarnessCodex, + Harness: domain.ReviewerHarness("greptile"), WorkspacePath: "/ws/mer-1", PRURL: "https://github.com/o/r/pull/1", }) if err != nil { t.Fatalf("Run: %v", err) } - if !rt.created || rt.cfg.WorkspacePath != "/ws/mer-1" { - t.Fatalf("runtime cfg = %+v created=%v", rt.cfg, rt.created) + // The reviewer adapter receives the invocation (PR + worktree + reviewer id). + if reviewer.gotInv.PRURL != "https://github.com/o/r/pull/1" || reviewer.gotInv.WorkspacePath != "/ws/mer-1" || reviewer.gotInv.ReviewerID != "review-mer-1" { + t.Fatalf("invocation = %+v", reviewer.gotInv) } - if len(rt.cfg.Argv) == 0 || rt.cfg.Argv[0] != "review-agent" { - t.Fatalf("argv = %v", rt.cfg.Argv) - } - if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" { - t.Fatalf("env = %v, want AO_REVIEW_WORKER=mer-1", rt.cfg.Env) + // The runtime launches the adapter's argv over the worker's worktree. + if !rt.created || rt.cfg.WorkspacePath != "/ws/mer-1" || rt.cfg.Argv[0] != "greptile" { + t.Fatalf("runtime cfg = %+v created=%v", rt.cfg, rt.created) } - // The launch prompt names the PR and the submit step. - if !strings.Contains(agent.gotLaunch.Prompt, "pull/1") || !strings.Contains(agent.gotLaunch.Prompt, "ao review submit") { - t.Fatalf("prompt missing PR/submit reference: %q", agent.gotLaunch.Prompt) + // AO_REVIEW_WORKER is added; adapter env is preserved. + if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" || rt.cfg.Env["GREPTILE_MODE"] != "ci" { + t.Fatalf("env = %v", rt.cfg.Env) } } -func TestRunErrorsWhenNoAdapter(t *testing.T) { +func TestRunErrorsWhenNoReviewerAdapter(t *testing.T) { r := New(fakeResolver{ok: false}, &fakeRuntime{}) err := r.Run(context.Background(), reviewsvc.RunSpec{Harness: "nope", WorkspacePath: "/ws"}) - if err == nil || !strings.Contains(err.Error(), "no agent adapter") { + if err == nil || !strings.Contains(err.Error(), "no reviewer adapter") { t.Fatalf("err = %v, want no-adapter error", err) } } diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index a3e5a353..560ae2e5 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -58,7 +58,7 @@ type Runner interface { // RunSpec describes one reviewer launch. type RunSpec struct { WorkerID domain.SessionID - Harness domain.AgentHarness + Harness domain.ReviewerHarness WorkspacePath string PRURL string } @@ -242,7 +242,7 @@ func (s *Service) workerPRURL(ctx context.Context, workerID domain.SessionID) (s // reviewerHarness resolves which harness reviews the worker's PR: a configured // reviewer wins, otherwise the worker's own harness is reused (falling back to // claude-code), per domain.ResolveReviewerHarness. -func (s *Service) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.AgentHarness, error) { +func (s *Service) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { var cfg domain.ProjectConfig if s.projects != nil { if proj, ok, err := s.projects.GetProject(ctx, string(worker.ProjectID)); err != nil { @@ -254,7 +254,7 @@ func (s *Service) reviewerHarness(ctx context.Context, worker domain.SessionReco return cfg.ResolveReviewerHarness(worker.Harness), nil } -func (s *Service) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.AgentHarness, prURL string, now time.Time) (domain.Review, error) { +func (s *Service) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL string, now time.Time) (domain.Review, error) { existing, ok, err := s.store.GetReviewBySession(ctx, worker.ID) if err != nil { return domain.Review{}, err diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index a0b8270d..8c03aa70 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -120,7 +120,8 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { store := &fakeStore{} sessions := fakeSessions{rec: liveWorker(), ok: true} prs := fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1"}}} - projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.HarnessAider}}}} + // A reviewer-only harness (greptile) is configured; it wins over the worker harness. + projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.ReviewerHarness("greptile")}}}} runner := &fakeRunner{} svc := newServiceForTest(store, sessions, prs, projects, runner) @@ -128,11 +129,10 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { if err != nil { t.Fatalf("Trigger: %v", err) } - // A configured reviewer wins over the worker harness. - if run.Status != domain.ReviewRunRunning || run.Iteration != 1 || run.Harness != domain.HarnessAider { + if run.Status != domain.ReviewRunRunning || run.Iteration != 1 || run.Harness != domain.ReviewerHarness("greptile") { t.Fatalf("run = %+v", run) } - if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.HarnessAider { + if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.ReviewerHarness("greptile") { t.Fatalf("runner spec = %+v ran=%v", runner.spec, runner.ran) } if store.review == nil || store.review.PRURL != "https://github.com/o/r/pull/1" { @@ -140,25 +140,27 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { } } -func TestTriggerDefaultsToWorkerHarness(t *testing.T) { +func TestTriggerReusesWorkerHarnessWhenItIsAReviewer(t *testing.T) { store := &fakeStore{} - // No reviewer configured: reuse the worker's harness (codex). - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, + // No reviewer configured; the worker's harness (claude-code) is also a + // supported reviewer, so it is reused. + rec := liveWorker() + rec.Harness = domain.HarnessClaudeCode + svc := newServiceForTest(store, fakeSessions{rec: rec, ok: true}, fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) run, err := svc.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Harness != domain.HarnessCodex { - t.Fatalf("harness = %q, want worker harness codex", run.Harness) + if run.Harness != domain.ReviewerClaudeCode { + t.Fatalf("harness = %q, want reviewer claude-code", run.Harness) } } -func TestTriggerFallsBackWhenWorkerHarnessUnknown(t *testing.T) { +func TestTriggerFallsBackWhenWorkerHarnessNotAReviewer(t *testing.T) { store := &fakeStore{} - rec := liveWorker() - rec.Harness = "" - svc := newServiceForTest(store, fakeSessions{rec: rec, ok: true}, + // liveWorker's harness is codex, which is not a supported reviewer. + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) run, err := svc.Trigger(context.Background(), "mer-1") if err != nil { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index a7ae5365..14050db5 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -115,7 +115,7 @@ type Review struct { ID string SessionID domain.SessionID ProjectID domain.ProjectID - Harness domain.AgentHarness + Harness domain.ReviewerHarness PRURL string CreatedAt time.Time UpdatedAt time.Time @@ -125,7 +125,7 @@ type ReviewRun struct { ID string ReviewID string SessionID domain.SessionID - Harness domain.AgentHarness + Harness domain.ReviewerHarness PRURL string Status domain.ReviewRunStatus Verdict domain.ReviewVerdict diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index 787e6af0..fa285357 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -64,7 +64,7 @@ type InsertReviewRunParams struct { ID string ReviewID string SessionID domain.SessionID - Harness domain.AgentHarness + Harness domain.ReviewerHarness PRURL string Status domain.ReviewRunStatus Verdict domain.ReviewVerdict @@ -162,7 +162,7 @@ type UpsertReviewParams struct { ID string SessionID domain.SessionID ProjectID domain.ProjectID - Harness domain.AgentHarness + Harness domain.ReviewerHarness PRURL string CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index 8fdcef7e..c82b73e1 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -21,7 +21,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { // First upsert creates the review row. if err := s.UpsertReview(ctx, domain.Review{ ID: "rev-1", SessionID: rec.ID, ProjectID: rec.ProjectID, - Harness: domain.HarnessCodex, PRURL: "https://example/pr/1", + Harness: domain.ReviewerClaudeCode, PRURL: "https://example/pr/1", CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("upsert review: %v", err) @@ -30,7 +30,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { // refreshing harness/pr_url but keeping the original id. if err := s.UpsertReview(ctx, domain.Review{ ID: "rev-2", SessionID: rec.ID, ProjectID: rec.ProjectID, - Harness: domain.HarnessAider, PRURL: "https://example/pr/2", + Harness: domain.ReviewerHarness("greptile"), PRURL: "https://example/pr/2", CreatedAt: now, UpdatedAt: now.Add(time.Second), }); err != nil { t.Fatalf("upsert review (reuse): %v", err) @@ -42,13 +42,13 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if got.ID != "rev-1" { t.Fatalf("upsert created a new row, want reuse: id=%q", got.ID) } - if got.Harness != domain.HarnessAider || got.PRURL != "https://example/pr/2" { + if got.Harness != domain.ReviewerHarness("greptile") || got.PRURL != "https://example/pr/2" { t.Fatalf("upsert did not refresh fields: %+v", got) } // A run inserts pending and updates to complete/changes_requested. if err := s.InsertReviewRun(ctx, domain.ReviewRun{ - ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.HarnessAider, + ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.ReviewerHarness("greptile"), PRURL: got.PRURL, Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, Iteration: 1, CreatedAt: now, }); err != nil { diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml index d91d35b9..d9a8ad1f 100644 --- a/backend/sqlc.yaml +++ b/backend/sqlc.yaml @@ -103,7 +103,7 @@ sql: - column: "review.harness" go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" - type: "AgentHarness" + type: "ReviewerHarness" - column: "review_run.session_id" go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -111,7 +111,7 @@ sql: - column: "review_run.harness" go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" - type: "AgentHarness" + type: "ReviewerHarness" - column: "review_run.status" go_type: import: "github.com/aoagents/agent-orchestrator/backend/internal/domain" From a84b94333d15c6dc1773607ae979df69cdf6c345 Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Fri, 12 Jun 2026 22:29:01 +0530 Subject: [PATCH 08/11] test(review): cover run-scoped reviewer submit --- backend/internal/cli/review.go | 12 ++- backend/internal/cli/review_test.go | 16 +++- backend/internal/httpd/apispec/openapi.yaml | 4 + backend/internal/httpd/controllers/reviews.go | 3 +- backend/internal/review_runner/runner.go | 10 +-- backend/internal/review_runner/runner_test.go | 10 ++- backend/internal/service/review/review.go | 56 ++++++++----- .../internal/service/review/review_test.go | 81 +++++++++++++++---- .../internal/storage/sqlite/gen/review.sql.go | 36 +++++++-- .../storage/sqlite/queries/review.sql | 8 +- .../storage/sqlite/store/review_store.go | 22 ++++- .../storage/sqlite/store/review_store_test.go | 23 +++++- 12 files changed, 222 insertions(+), 59 deletions(-) diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go index b3ac7e64..9c00dc62 100644 --- a/backend/internal/cli/review.go +++ b/backend/internal/cli/review.go @@ -31,12 +31,14 @@ type reviewRunResponse struct { // submitReviewRequest mirrors controllers.SubmitReviewInput. type submitReviewRequest struct { + RunID string `json:"runId"` Verdict string `json:"verdict"` Body string `json:"body"` } type reviewSubmitOptions struct { session string + runID string verdict string body string } @@ -61,6 +63,7 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { }, } cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (defaults to $AO_REVIEW_WORKER)") + cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (defaults to $AO_REVIEW_RUN_ID)") cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body") return cmd @@ -77,6 +80,13 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re if session == "" { return usageError{errors.New("usage: worker session id is required (positional, --session, or $AO_REVIEW_WORKER)")} } + runID := strings.TrimSpace(opts.runID) + if runID == "" { + runID = strings.TrimSpace(os.Getenv("AO_REVIEW_RUN_ID")) + } + if runID == "" { + return usageError{errors.New("usage: review run id is required (--run or $AO_REVIEW_RUN_ID)")} + } verdict := strings.TrimSpace(opts.verdict) if verdict == "" { return usageError{errors.New("usage: --verdict is required (approved or changes_requested)")} @@ -91,7 +101,7 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re } path := "sessions/" + url.PathEscape(session) + "/reviews/submit" var res reviewRunResponse - if err := c.postJSON(cmd.Context(), path, submitReviewRequest{Verdict: verdict, Body: body}, &res); err != nil { + if err := c.postJSON(cmd.Context(), path, submitReviewRequest{RunID: runID, Verdict: verdict, Body: body}, &res); err != nil { return err } _, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session) diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go index 47b8ec81..14ab4eff 100644 --- a/backend/internal/cli/review_test.go +++ b/backend/internal/cli/review_test.go @@ -37,6 +37,7 @@ func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true } func TestReviewSubmitReadsBodyFile(t *testing.T) { cfg := setConfigEnv(t) + t.Setenv("AO_REVIEW_RUN_ID", "run-1") srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`) writeRunFileFor(t, cfg, srv) @@ -57,7 +58,7 @@ func TestReviewSubmitReadsBodyFile(t *testing.T) { if err := json.Unmarshal([]byte(capture.body), &req); err != nil { t.Fatalf("decode body: %v", err) } - if req.Verdict != "changes_requested" || req.Body != "please fix" { + if req.RunID != "run-1" || req.Verdict != "changes_requested" || req.Body != "please fix" { t.Fatalf("request = %+v", req) } } @@ -65,6 +66,7 @@ func TestReviewSubmitReadsBodyFile(t *testing.T) { func TestReviewSubmitUsesEnvWorker(t *testing.T) { cfg := setConfigEnv(t) t.Setenv("AO_REVIEW_WORKER", "mer-7") + t.Setenv("AO_REVIEW_RUN_ID", "run-7") srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) writeRunFileFor(t, cfg, srv) @@ -78,6 +80,7 @@ func TestReviewSubmitUsesEnvWorker(t *testing.T) { func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { setConfigEnv(t) + t.Setenv("AO_REVIEW_RUN_ID", "run-1") _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1") if got := ExitCode(err); got != 2 { t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) @@ -87,6 +90,17 @@ func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { func TestReviewSubmitMissingWorkerIsUsageError(t *testing.T) { setConfigEnv(t) t.Setenv("AO_REVIEW_WORKER", "") + t.Setenv("AO_REVIEW_RUN_ID", "run-1") + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved") + if got := ExitCode(err); got != 2 { + t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) + } +} + +func TestReviewSubmitMissingRunIsUsageError(t *testing.T) { + setConfigEnv(t) + t.Setenv("AO_REVIEW_WORKER", "mer-1") + t.Setenv("AO_REVIEW_RUN_ID", "") _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved") if got := ExitCode(err); got != 2 { t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a62824ea..3fdd0f0c 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1705,10 +1705,14 @@ components: body: description: Review body recorded by AO. Required for changes_requested. type: string + runId: + description: Review run id being completed. + type: string verdict: description: 'Review verdict: approved or changes_requested.' type: string required: + - runId - verdict - body type: object diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index 0415dd93..33eec9ca 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -25,6 +25,7 @@ type ReviewRunResponse struct { // SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. type SubmitReviewInput struct { + RunID string `json:"runId" description:"Review run id being completed."` Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` Body string `json:"body" description:"Review body recorded by AO. Required for changes_requested."` } @@ -80,7 +81,7 @@ func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) return } - run, err := c.Svc.Submit(r.Context(), sessionID(r), domain.ReviewVerdict(in.Verdict), in.Body) + run, err := c.Svc.Submit(r.Context(), sessionID(r), in.RunID, domain.ReviewVerdict(in.Verdict), in.Body) if err != nil { writeReviewError(w, r, err) return diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go index 90478e52..2df5878f 100644 --- a/backend/internal/review_runner/runner.go +++ b/backend/internal/review_runner/runner.go @@ -37,7 +37,7 @@ func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { if !ok { return fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) } - reviewerID := "review-" + string(spec.WorkerID) + reviewerID := "review-" + spec.RunID cmd, err := reviewer.ReviewCommand(ctx, ports.ReviewInvocation{ ReviewerID: reviewerID, WorkerSessionID: spec.WorkerID, @@ -58,14 +58,14 @@ func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { return nil } -// reviewerEnv merges the adapter's env with AO_REVIEW_WORKER, which carries the -// worker the reviewer reports against so its `ao review submit` resolves the -// right worker session without a flag. +// reviewerEnv merges the adapter's env with AO_REVIEW_WORKER and +// AO_REVIEW_RUN_ID so `ao review submit` resolves the exact run being completed. func reviewerEnv(spec reviewsvc.RunSpec, adapterEnv map[string]string) map[string]string { - env := make(map[string]string, len(adapterEnv)+1) + env := make(map[string]string, len(adapterEnv)+2) for k, v := range adapterEnv { env[k] = v } env["AO_REVIEW_WORKER"] = string(spec.WorkerID) + env["AO_REVIEW_RUN_ID"] = spec.RunID return env } diff --git a/backend/internal/review_runner/runner_test.go b/backend/internal/review_runner/runner_test.go index d0585a6f..b61c4e08 100644 --- a/backend/internal/review_runner/runner_test.go +++ b/backend/internal/review_runner/runner_test.go @@ -52,6 +52,7 @@ func TestRunLaunchesResolvedReviewer(t *testing.T) { r := New(fakeResolver{reviewer: reviewer, ok: true}, rt) err := r.Run(context.Background(), reviewsvc.RunSpec{ + RunID: "run-1", WorkerID: "mer-1", Harness: domain.ReviewerHarness("greptile"), WorkspacePath: "/ws/mer-1", @@ -61,15 +62,18 @@ func TestRunLaunchesResolvedReviewer(t *testing.T) { t.Fatalf("Run: %v", err) } // The reviewer adapter receives the invocation (PR + worktree + reviewer id). - if reviewer.gotInv.PRURL != "https://github.com/o/r/pull/1" || reviewer.gotInv.WorkspacePath != "/ws/mer-1" || reviewer.gotInv.ReviewerID != "review-mer-1" { + if reviewer.gotInv.PRURL != "https://github.com/o/r/pull/1" || reviewer.gotInv.WorkspacePath != "/ws/mer-1" || reviewer.gotInv.ReviewerID != "review-run-1" { t.Fatalf("invocation = %+v", reviewer.gotInv) } // The runtime launches the adapter's argv over the worker's worktree. if !rt.created || rt.cfg.WorkspacePath != "/ws/mer-1" || rt.cfg.Argv[0] != "greptile" { t.Fatalf("runtime cfg = %+v created=%v", rt.cfg, rt.created) } - // AO_REVIEW_WORKER is added; adapter env is preserved. - if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" || rt.cfg.Env["GREPTILE_MODE"] != "ci" { + if rt.cfg.SessionID != "review-run-1" { + t.Fatalf("runtime session id = %q, want review-run-1", rt.cfg.SessionID) + } + // AO_REVIEW_WORKER and AO_REVIEW_RUN_ID are added; adapter env is preserved. + if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" || rt.cfg.Env["AO_REVIEW_RUN_ID"] != "run-1" || rt.cfg.Env["GREPTILE_MODE"] != "ci" { t.Fatalf("env = %v", rt.cfg.Env) } } diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index 560ae2e5..9fb390cc 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -30,7 +30,8 @@ type Store interface { UpsertReview(ctx context.Context, r domain.Review) error GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error - UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) + GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) } @@ -57,6 +58,7 @@ type Runner interface { // RunSpec describes one reviewer launch. type RunSpec struct { + RunID string WorkerID domain.SessionID Harness domain.ReviewerHarness WorkspacePath string @@ -66,7 +68,7 @@ type RunSpec struct { // Manager is the reviews surface the HTTP controller depends on. type Manager interface { Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) - Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) + Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) } @@ -151,20 +153,6 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai now := s.clock() iteration := s.nextIteration(ctx, workerID) - // Launch the reviewer first, then persist the pass with a status that - // reflects the launch outcome: running on success, failed if it never - // started. This avoids writing a row that has to be corrected afterwards. - runErr := s.runner.Run(ctx, RunSpec{ - WorkerID: workerID, - Harness: harness, - WorkspacePath: worker.Metadata.WorkspacePath, - PRURL: prURL, - }) - status := domain.ReviewRunRunning - if runErr != nil { - status = domain.ReviewRunFailed - } - review, err := s.upsertReview(ctx, worker, harness, prURL, now) if err != nil { return domain.ReviewRun{}, err @@ -175,7 +163,7 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai SessionID: workerID, Harness: harness, PRURL: prURL, - Status: status, + Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, Iteration: iteration, CreatedAt: now, @@ -183,19 +171,33 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domai if err := s.store.InsertReviewRun(ctx, run); err != nil { return domain.ReviewRun{}, err } + runErr := s.runner.Run(ctx, RunSpec{ + RunID: run.ID, + WorkerID: workerID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: prURL, + }) if runErr != nil { + if _, err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, ""); err != nil { + return domain.ReviewRun{}, err + } + run.Status = domain.ReviewRunFailed return run, fmt.Errorf("launch reviewer: %w", runErr) } return run, nil } -// Submit records the reviewer's result for a worker's latest review pass: it +// Submit records the reviewer's result for a specific worker review pass: it // marks the run complete and stores the verdict and body. AO does not post the // review — the reviewer agent posts it to the PR itself. -func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { +func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { if workerID == "" { return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) } + if runID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) + } if !verdict.Valid() { return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) } @@ -203,17 +205,27 @@ func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, verdict return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) } - run, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID) + run, ok, err := s.store.GetReviewRun(ctx, runID) if err != nil { return domain.ReviewRun{}, err } if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: no review run for worker %q", ErrNotFound, workerID) + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q", ErrNotFound, runID) + } + if run.SessionID != workerID { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q does not belong to worker %q", ErrInvalid, runID, workerID) + } + if run.Status != domain.ReviewRunRunning { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) } - if err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body); err != nil { + updated, err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body) + if err != nil { return domain.ReviewRun{}, err } + if !updated { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) + } run.Status = domain.ReviewRunComplete run.Verdict = verdict run.Body = body diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index 8c03aa70..d585fde0 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -14,6 +14,7 @@ import ( type fakeStore struct { review *domain.Review runs []domain.ReviewRun + inserted bool upsertErr error insertErr error updateErr error @@ -37,21 +38,31 @@ func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error if f.insertErr != nil { return f.insertErr } + f.inserted = true f.runs = append(f.runs, r) return nil } -func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error { +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) { if f.updateErr != nil { - return f.updateErr + return false, f.updateErr } for i := range f.runs { if f.runs[i].ID == id { f.runs[i].Status = status f.runs[i].Verdict = verdict f.runs[i].Body = body + return true, nil } } - return nil + return false, nil +} +func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) { + for _, run := range f.runs { + if run.ID == id { + return run, true, nil + } + } + return domain.ReviewRun{}, false, nil } func (f *fakeStore) GetLatestReviewRunBySession(_ context.Context, _ domain.SessionID) (domain.ReviewRun, bool, error) { if len(f.runs) == 0 { @@ -85,12 +96,17 @@ func (f fakeProjects) GetProject(_ context.Context, id string) (domain.ProjectRe } type fakeRunner struct { - spec RunSpec - err error - ran bool + store *fakeStore + requireRun bool + spec RunSpec + err error + ran bool } func (f *fakeRunner) Run(_ context.Context, spec RunSpec) error { + if f.requireRun && (f.store == nil || !f.store.inserted) { + return errors.New("review run was not inserted before launch") + } f.ran = true f.spec = spec return f.err @@ -122,7 +138,7 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { prs := fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1"}}} // A reviewer-only harness (greptile) is configured; it wins over the worker harness. projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.ReviewerHarness("greptile")}}}} - runner := &fakeRunner{} + runner := &fakeRunner{store: store, requireRun: true} svc := newServiceForTest(store, sessions, prs, projects, runner) run, err := svc.Trigger(context.Background(), "mer-1") @@ -132,7 +148,7 @@ func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { if run.Status != domain.ReviewRunRunning || run.Iteration != 1 || run.Harness != domain.ReviewerHarness("greptile") { t.Fatalf("run = %+v", run) } - if !runner.ran || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.ReviewerHarness("greptile") { + if !runner.ran || runner.spec.RunID != run.ID || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.ReviewerHarness("greptile") { t.Fatalf("runner spec = %+v ran=%v", runner.spec, runner.ran) } if store.review == nil || store.review.PRURL != "https://github.com/o/r/pull/1" { @@ -210,7 +226,7 @@ func TestTriggerRejectsMissingWorkerPRAndState(t *testing.T) { func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { store := &fakeStore{} - runner := &fakeRunner{err: errors.New("boom")} + runner := &fakeRunner{store: store, requireRun: true, err: errors.New("boom")} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, runner) if _, err := svc.Trigger(context.Background(), "mer-1"); err == nil { @@ -222,10 +238,10 @@ func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { } func TestSubmitRecordsVerdictAndBody(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", PRURL: "u", Status: domain.ReviewRunRunning}}} + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "u", Status: domain.ReviewRunRunning}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - run, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, "please fix") + run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "please fix") if err != nil { t.Fatalf("Submit: %v", err) } @@ -241,21 +257,58 @@ func TestSubmitValidation(t *testing.T) { store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunRunning}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Submit(context.Background(), "mer-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { t.Fatalf("bad verdict err = %v", err) } - if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { t.Fatalf("empty body err = %v", err) } } func TestSubmitNoRun(t *testing.T) { svc := newServiceForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Submit(context.Background(), "mer-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } +func TestSubmitTargetsSpecifiedRun(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{ + {ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning, Iteration: 1}, + {ID: "run-2", SessionID: "mer-1", Status: domain.ReviewRunRunning, Iteration: 2}, + }} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + + run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, "") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if run.ID != "run-1" || store.runs[0].Status != domain.ReviewRunComplete { + t.Fatalf("run-1 not completed: returned=%+v stored=%+v", run, store.runs[0]) + } + if store.runs[1].Status != domain.ReviewRunRunning { + t.Fatalf("run-2 should remain running: %+v", store.runs[1]) + } +} + +func TestSubmitRejectsNonRunningRun(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunComplete}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } +} + +func TestSubmitRejectsRunForDifferentWorker(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "other-1", Status: domain.ReviewRunRunning}}} + svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + + if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("err = %v, want ErrInvalid", err) + } +} + func TestListReturnsRuns(t *testing.T) { store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Iteration: 1}}} svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index fa285357..3103a802 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -55,6 +55,29 @@ func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.Sessi return i, err } +const getReviewRun = `-- name: GetReviewRun :one +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at +FROM review_run WHERE id = ? +` + +func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error) { + row := q.db.QueryRowContext(ctx, getReviewRun, id) + var i ReviewRun + err := row.Scan( + &i.ID, + &i.ReviewID, + &i.SessionID, + &i.Harness, + &i.PRURL, + &i.Status, + &i.Verdict, + &i.Iteration, + &i.Body, + &i.CreatedAt, + ) + return i, err +} + const insertReviewRun = `-- name: InsertReviewRun :exec INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -128,8 +151,8 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. return items, nil } -const updateReviewRunResult = `-- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ? +const updateReviewRunResult = `-- name: UpdateReviewRunResult :execrows +UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ? AND status = 'running' ` type UpdateReviewRunResultParams struct { @@ -139,14 +162,17 @@ type UpdateReviewRunResultParams struct { ID string } -func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRunResultParams) error { - _, err := q.db.ExecContext(ctx, updateReviewRunResult, +func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRunResultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateReviewRunResult, arg.Status, arg.Verdict, arg.Body, arg.ID, ) - return err + if err != nil { + return 0, err + } + return result.RowsAffected() } const upsertReview = `-- name: UpsertReview :exec diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index 0ef78d0e..0ae1be99 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -14,8 +14,12 @@ FROM review WHERE session_id = ?; INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); --- name: UpdateReviewRunResult :exec -UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ?; +-- name: UpdateReviewRunResult :execrows +UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ? AND status = 'running'; + +-- name: GetReviewRun :one +SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at +FROM review_run WHERE id = ?; -- name: GetLatestReviewRunBySession :one SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index 2ac1b5ca..a55aa3f9 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -56,16 +56,32 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { }) } -// UpdateReviewRunResult sets the status/verdict/body of a review pass. -func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) error { +// UpdateReviewRunResult sets the status/verdict/body of a running review pass. +func (s *Store) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.qw.UpdateReviewRunResult(ctx, gen.UpdateReviewRunResultParams{ + n, err := s.qw.UpdateReviewRunResult(ctx, gen.UpdateReviewRunResultParams{ Status: status, Verdict: verdict, Body: body, ID: id, }) + if err != nil { + return false, err + } + return n > 0, nil +} + +// GetReviewRun returns one review pass by id. +func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) { + row, err := s.qr.GetReviewRun(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return domain.ReviewRun{}, false, nil + } + if err != nil { + return domain.ReviewRun{}, false, fmt.Errorf("get review run %s: %w", id, err) + } + return reviewRunFromRow(row), true, nil } // GetLatestReviewRunBySession returns the most recent review pass for a worker diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index c82b73e1..4216a1dc 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -46,7 +46,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { t.Fatalf("upsert did not refresh fields: %+v", got) } - // A run inserts pending and updates to complete/changes_requested. + // A run inserts running and updates to complete/changes_requested. if err := s.InsertReviewRun(ctx, domain.ReviewRun{ ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.ReviewerHarness("greptile"), PRURL: got.PRURL, Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, @@ -54,8 +54,18 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { }); err != nil { t.Fatalf("insert run: %v", err) } - if err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix"); err != nil { + if ok, err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix"); err != nil { t.Fatalf("update run: %v", err) + } else if !ok { + t.Fatal("update run: got ok=false") + } + + gotRun, ok, err := s.GetReviewRun(ctx, "run-1") + if err != nil || !ok { + t.Fatalf("get run: ok=%v err=%v", ok, err) + } + if gotRun.ID != "run-1" || gotRun.SessionID != rec.ID { + t.Fatalf("get run = %+v", gotRun) } latest, ok, err := s.GetLatestReviewRunBySession(ctx, rec.ID) @@ -73,6 +83,12 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if len(runs) != 1 || runs[0].ID != "run-1" { t.Fatalf("list runs = %+v", runs) } + + if ok, err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictApproved, "again"); err != nil { + t.Fatalf("second update: %v", err) + } else if ok { + t.Fatal("second update completed an already-complete run") + } } func TestReviewGettersMissing(t *testing.T) { @@ -84,4 +100,7 @@ func TestReviewGettersMissing(t *testing.T) { if _, ok, err := s.GetLatestReviewRunBySession(ctx, "mer-1"); err != nil || ok { t.Fatalf("missing run: ok=%v err=%v", ok, err) } + if _, ok, err := s.GetReviewRun(ctx, "run-missing"); err != nil || ok { + t.Fatalf("missing run by id: ok=%v err=%v", ok, err) + } } From 2c49f9c06266fe7baac82bf3b4ce596b836407e2 Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Sat, 13 Jun 2026 01:01:35 +0530 Subject: [PATCH 09/11] fix(api): update generated review submit schema --- frontend/src/api/schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 32d8aaa0..6995f847 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -615,6 +615,8 @@ export interface components { SubmitReviewInput: { /** @description Review body recorded by AO. Required for changes_requested. */ body: string; + /** @description Review run id being completed. */ + runId: string; /** @description Review verdict: approved or changes_requested. */ verdict: string; }; From 4109503586c17cf78c5f66ba969ae7023c89ea99 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sat, 13 Jun 2026 10:02:52 +0530 Subject: [PATCH 10/11] refactor(review): split core engine (internal/review) from API service Move the review orchestration (Trigger/Submit/List, run-id generation, deps, RunSpec/Runner, sentinels) into a transport-independent core package internal/review (Engine). internal/service/review is now a thin API-flow boundary: the controller-facing Manager interface + a Service that delegates to the engine + error re-exports. This keeps the service layer to API concerns and lets the same engine back a future in-process CLI trigger without going through HTTP. review_runner now depends on the core package; daemon builds the engine and wraps it in the service. No API/schema changes. Co-Authored-By: Claude Opus 4.8 --- backend/internal/daemon/lifecycle_wiring.go | 4 +- backend/internal/review/review.go | 292 ++++++++++++++++++ .../{service => }/review/review_test.go | 2 +- backend/internal/review_runner/runner.go | 8 +- backend/internal/review_runner/runner_test.go | 6 +- backend/internal/service/review/review.go | 287 ++--------------- 6 files changed, 323 insertions(+), 276 deletions(-) create mode 100644 backend/internal/review/review.go rename backend/internal/{service => }/review/review_test.go (99%) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 562a17cc..b42543d3 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -16,6 +16,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/lifecycle" "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" reviewrunner "github.com/aoagents/agent-orchestrator/backend/internal/review_runner" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" @@ -105,13 +106,14 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, if err != nil { return nil, nil, fmt.Errorf("reviewer resolver: %w", err) } - reviewSvc := reviewsvc.New(reviewsvc.Deps{ + reviewEngine := reviewcore.New(reviewcore.Deps{ Store: store, Sessions: store, PRs: store, Projects: store, Runner: reviewrunner.New(reviewers, runtime), }) + reviewSvc := reviewsvc.New(reviewEngine) return sessionSvc, reviewSvc, nil } diff --git a/backend/internal/review/review.go b/backend/internal/review/review.go new file mode 100644 index 00000000..ea14cd3e --- /dev/null +++ b/backend/internal/review/review.go @@ -0,0 +1,292 @@ +// Package review holds the core code-review logic: triggering a reviewer over a +// worker's worktree, recording review runs, and accepting submitted results. +// +// It is independent of any transport. The daemon's HTTP service +// (internal/service/review) is a thin boundary over this engine today, and the +// same engine can back an in-process CLI trigger later without going through the +// API. Transport-specific concerns (DTOs, error→status mapping) stay in the +// service/controller layers; the orchestration and run-id generation live here. +package review + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// ErrInvalid and ErrNotFound let the transport layer map failures to 422/404. +var ( + ErrInvalid = errors.New("review: invalid input") + ErrNotFound = errors.New("review: not found") +) + +// Store is the persistence surface the engine needs. *sqlite.Store satisfies it +// in production; tests use a fake. +type Store interface { + UpsertReview(ctx context.Context, r domain.Review) error + GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) + InsertReviewRun(ctx context.Context, r domain.ReviewRun) error + UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) + GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) + GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) + ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) +} + +// Sessions resolves the worker session under review. +type Sessions interface { + GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) +} + +// PRs resolves the PR a worker owns. +type PRs interface { + ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) +} + +// Projects resolves the per-project reviewer config. +type Projects interface { + GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) +} + +// Runner launches the reviewer one-shot over the worker's worktree. +type Runner interface { + Run(ctx context.Context, spec RunSpec) error +} + +// RunSpec describes one reviewer launch. +type RunSpec struct { + RunID string + WorkerID domain.SessionID + Harness domain.ReviewerHarness + WorkspacePath string + PRURL string +} + +// Deps wires the engine. +type Deps struct { + Store Store + Sessions Sessions + PRs PRs + Projects Projects + Runner Runner + + // Clock and NewID are injectable for deterministic tests. + Clock func() time.Time + NewID func() string +} + +// Engine is the core code-review engine. +type Engine struct { + store Store + sessions Sessions + prs PRs + projects Projects + runner Runner + clock func() time.Time + newID func() string +} + +// New wires an Engine from its dependencies, defaulting the clock and id source. +func New(d Deps) *Engine { + clock := d.Clock + if clock == nil { + clock = func() time.Time { return time.Now().UTC() } + } + newID := d.NewID + if newID == nil { + newID = uuid.NewString + } + return &Engine{ + store: d.Store, + sessions: d.Sessions, + prs: d.PRs, + projects: d.Projects, + runner: d.Runner, + clock: clock, + newID: newID, + } +} + +// Trigger starts a review pass for a worker's PR: it reuses (or creates) the +// worker's review row, records a running review_run (whose id is the run id), +// and launches the configured reviewer over the worker's worktree. +func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) { + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + worker, ok, err := e.sessions.GetSession(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err + } + if !ok { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID) + } + if worker.IsTerminated { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID) + } + if worker.Metadata.WorkspacePath == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) + } + + prURL, err := e.workerPRURL(ctx, workerID) + if err != nil { + return domain.ReviewRun{}, err + } + + harness, err := e.reviewerHarness(ctx, worker) + if err != nil { + return domain.ReviewRun{}, err + } + + now := e.clock() + iteration := e.nextIteration(ctx, workerID) + + review, err := e.upsertReview(ctx, worker, harness, prURL, now) + if err != nil { + return domain.ReviewRun{}, err + } + run := domain.ReviewRun{ + ID: e.newID(), + ReviewID: review.ID, + SessionID: workerID, + Harness: harness, + PRURL: prURL, + Status: domain.ReviewRunRunning, + Verdict: domain.VerdictNone, + Iteration: iteration, + CreatedAt: now, + } + if err := e.store.InsertReviewRun(ctx, run); err != nil { + return domain.ReviewRun{}, err + } + runErr := e.runner.Run(ctx, RunSpec{ + RunID: run.ID, + WorkerID: workerID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: prURL, + }) + if runErr != nil { + if _, err := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, ""); err != nil { + return domain.ReviewRun{}, err + } + run.Status = domain.ReviewRunFailed + return run, fmt.Errorf("launch reviewer: %w", runErr) + } + return run, nil +} + +// Submit records the reviewer's result for a specific worker review pass: it +// marks the run complete and stores the verdict and body. AO does not post the +// review — the reviewer agent posts it to the PR itself. +func (e *Engine) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { + if workerID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + if runID == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) + } + if !verdict.Valid() { + return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) + } + if verdict == domain.VerdictChangesRequested && body == "" { + return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) + } + + run, ok, err := e.store.GetReviewRun(ctx, runID) + if err != nil { + return domain.ReviewRun{}, err + } + if !ok { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q", ErrNotFound, runID) + } + if run.SessionID != workerID { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q does not belong to worker %q", ErrInvalid, runID, workerID) + } + if run.Status != domain.ReviewRunRunning { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) + } + + updated, err := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body) + if err != nil { + return domain.ReviewRun{}, err + } + if !updated { + return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) + } + run.Status = domain.ReviewRunComplete + run.Verdict = verdict + run.Body = body + return run, nil +} + +// List returns the review passes recorded for a worker, newest first. +func (e *Engine) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { + if workerID == "" { + return nil, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + return e.store.ListReviewRunsBySession(ctx, workerID) +} + +func (e *Engine) workerPRURL(ctx context.Context, workerID domain.SessionID) (string, error) { + prs, err := e.prs.ListPRsBySession(ctx, workerID) + if err != nil { + return "", err + } + if len(prs) == 0 { + return "", fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) + } + return prs[0].URL, nil +} + +// reviewerHarness resolves which harness reviews the worker's PR: a configured +// reviewer wins, otherwise the worker's own harness is reused (falling back to +// claude-code), per domain.ResolveReviewerHarness. +func (e *Engine) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { + var cfg domain.ProjectConfig + if e.projects != nil { + if proj, ok, err := e.projects.GetProject(ctx, string(worker.ProjectID)); err != nil { + return "", err + } else if ok { + cfg = proj.Config + } + } + return cfg.ResolveReviewerHarness(worker.Harness), nil +} + +func (e *Engine) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL string, now time.Time) (domain.Review, error) { + existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID) + if err != nil { + return domain.Review{}, err + } + review := domain.Review{ + ID: e.newID(), + SessionID: worker.ID, + ProjectID: worker.ProjectID, + Harness: harness, + PRURL: prURL, + CreatedAt: now, + UpdatedAt: now, + } + if ok { + // Reuse the existing row's identity and creation time; UpsertReview + // refreshes harness/pr_url/updated_at. + review.ID = existing.ID + review.CreatedAt = existing.CreatedAt + } + if err := e.store.UpsertReview(ctx, review); err != nil { + return domain.Review{}, err + } + return review, nil +} + +func (e *Engine) nextIteration(ctx context.Context, workerID domain.SessionID) int { + if latest, ok, err := e.store.GetLatestReviewRunBySession(ctx, workerID); err == nil && ok { + return latest.Iteration + 1 + } + return 1 +} diff --git a/backend/internal/service/review/review_test.go b/backend/internal/review/review_test.go similarity index 99% rename from backend/internal/service/review/review_test.go rename to backend/internal/review/review_test.go index d585fde0..afd432fa 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/review/review_test.go @@ -121,7 +121,7 @@ func liveWorker() domain.SessionRecord { } } -func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner) *Service { +func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner) *Engine { ids := 0 return New(Deps{ Store: store, Sessions: sessions, PRs: prs, Projects: projects, Runner: runner, diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go index 2df5878f..d9ef70e4 100644 --- a/backend/internal/review_runner/runner.go +++ b/backend/internal/review_runner/runner.go @@ -10,7 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" ) // Runner spawns a reviewer over the worker's worktree, resolving the reviewer @@ -29,10 +29,10 @@ func New(reviewers ports.ReviewerResolver, runtime ports.Runtime) *Runner { return &Runner{reviewers: reviewers, runtime: runtime} } -var _ reviewsvc.Runner = (*Runner)(nil) +var _ reviewcore.Runner = (*Runner)(nil) // Run launches the reviewer for one review pass. -func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { +func (r *Runner) Run(ctx context.Context, spec reviewcore.RunSpec) error { reviewer, ok := r.reviewers.Reviewer(spec.Harness) if !ok { return fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) @@ -60,7 +60,7 @@ func (r *Runner) Run(ctx context.Context, spec reviewsvc.RunSpec) error { // reviewerEnv merges the adapter's env with AO_REVIEW_WORKER and // AO_REVIEW_RUN_ID so `ao review submit` resolves the exact run being completed. -func reviewerEnv(spec reviewsvc.RunSpec, adapterEnv map[string]string) map[string]string { +func reviewerEnv(spec reviewcore.RunSpec, adapterEnv map[string]string) map[string]string { env := make(map[string]string, len(adapterEnv)+2) for k, v := range adapterEnv { env[k] = v diff --git a/backend/internal/review_runner/runner_test.go b/backend/internal/review_runner/runner_test.go index b61c4e08..672593f1 100644 --- a/backend/internal/review_runner/runner_test.go +++ b/backend/internal/review_runner/runner_test.go @@ -7,7 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" ) type fakeReviewer struct { @@ -51,7 +51,7 @@ func TestRunLaunchesResolvedReviewer(t *testing.T) { rt := &fakeRuntime{} r := New(fakeResolver{reviewer: reviewer, ok: true}, rt) - err := r.Run(context.Background(), reviewsvc.RunSpec{ + err := r.Run(context.Background(), reviewcore.RunSpec{ RunID: "run-1", WorkerID: "mer-1", Harness: domain.ReviewerHarness("greptile"), @@ -80,7 +80,7 @@ func TestRunLaunchesResolvedReviewer(t *testing.T) { func TestRunErrorsWhenNoReviewerAdapter(t *testing.T) { r := New(fakeResolver{ok: false}, &fakeRuntime{}) - err := r.Run(context.Background(), reviewsvc.RunSpec{Harness: "nope", WorkspacePath: "/ws"}) + err := r.Run(context.Background(), reviewcore.RunSpec{Harness: "nope", WorkspacePath: "/ws"}) if err == nil || !strings.Contains(err.Error(), "no reviewer adapter") { t.Fatalf("err = %v, want no-adapter error", err) } diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index 9fb390cc..db2112ba 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -1,70 +1,23 @@ -// Package review is the daemon's code-review surface: triggering a review spawns -// a configured reviewer agent over the worker's worktree with its own review -// prompt. The reviewer agent posts its review to the PR itself; the worker picks -// the feedback up through the existing SCM observer → review-nudge path. -// -// V1 is manual and one-shot: a review runs only when triggered. The reviewer is -// tracked by the review (one per worker) and review_run (one per pass) tables. +// Package review is the daemon's HTTP-facing code-review service boundary. The +// core orchestration lives in internal/review; this layer is the thin contract +// the API controller depends on and delegates to the engine, so the same engine +// can also back a future in-process CLI trigger. package review import ( "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" ) -// ErrInvalid and ErrNotFound let the HTTP layer map service failures to 422/404. +// ErrInvalid and ErrNotFound re-export the engine sentinels so the HTTP +// controller maps service failures to 422/404 without importing the core. var ( - ErrInvalid = errors.New("review: invalid input") - ErrNotFound = errors.New("review: not found") + ErrInvalid = reviewcore.ErrInvalid + ErrNotFound = reviewcore.ErrNotFound ) -// Store is the persistence surface the review service needs. *sqlite.Store -// satisfies it in production; tests use a fake. -type Store interface { - UpsertReview(ctx context.Context, r domain.Review) error - GetReviewBySession(ctx context.Context, id domain.SessionID) (domain.Review, bool, error) - InsertReviewRun(ctx context.Context, r domain.ReviewRun) error - UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) - GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) - GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) - ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) -} - -// Sessions resolves the worker session under review. -type Sessions interface { - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) -} - -// PRs resolves the PR a worker owns. -type PRs interface { - ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) -} - -// Projects resolves the per-project reviewer config. -type Projects interface { - GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) -} - -// Runner launches the reviewer agent one-shot over the worker's worktree. -type Runner interface { - Run(ctx context.Context, spec RunSpec) error -} - -// RunSpec describes one reviewer launch. -type RunSpec struct { - RunID string - WorkerID domain.SessionID - Harness domain.ReviewerHarness - WorkspacePath string - PRURL string -} - // Manager is the reviews surface the HTTP controller depends on. type Manager interface { Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) @@ -72,229 +25,29 @@ type Manager interface { List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) } -// Deps wires the review service. -type Deps struct { - Store Store - Sessions Sessions - PRs PRs - Projects Projects - Runner Runner - - // Clock and NewID are injectable for deterministic tests. - Clock func() time.Time - NewID func() string -} - -// Service is the daemon's code-review service. +// Service is the API-facing review service. It delegates to the core engine. type Service struct { - store Store - sessions Sessions - prs PRs - projects Projects - runner Runner - clock func() time.Time - newID func() string + engine *reviewcore.Engine } var _ Manager = (*Service)(nil) -// New wires a Service from its dependencies, defaulting the clock and id source. -func New(d Deps) *Service { - clock := d.Clock - if clock == nil { - clock = func() time.Time { return time.Now().UTC() } - } - newID := d.NewID - if newID == nil { - newID = uuid.NewString - } - return &Service{ - store: d.Store, - sessions: d.Sessions, - prs: d.PRs, - projects: d.Projects, - runner: d.Runner, - clock: clock, - newID: newID, - } +// New wraps a core review engine as the API-facing service. +func New(engine *reviewcore.Engine) *Service { + return &Service{engine: engine} } -// Trigger starts a review pass for a worker's PR: it reuses (or creates) the -// worker's review row, records a pending review_run, and launches the configured -// reviewer agent over the worker's worktree. +// Trigger starts a review pass for a worker's PR. func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) { - if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) - } - worker, ok, err := s.sessions.GetSession(ctx, workerID) - if err != nil { - return domain.ReviewRun{}, err - } - if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID) - } - if worker.IsTerminated { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID) - } - if worker.Metadata.WorkspacePath == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) - } - - prURL, err := s.workerPRURL(ctx, workerID) - if err != nil { - return domain.ReviewRun{}, err - } - - harness, err := s.reviewerHarness(ctx, worker) - if err != nil { - return domain.ReviewRun{}, err - } - - now := s.clock() - iteration := s.nextIteration(ctx, workerID) - - review, err := s.upsertReview(ctx, worker, harness, prURL, now) - if err != nil { - return domain.ReviewRun{}, err - } - run := domain.ReviewRun{ - ID: s.newID(), - ReviewID: review.ID, - SessionID: workerID, - Harness: harness, - PRURL: prURL, - Status: domain.ReviewRunRunning, - Verdict: domain.VerdictNone, - Iteration: iteration, - CreatedAt: now, - } - if err := s.store.InsertReviewRun(ctx, run); err != nil { - return domain.ReviewRun{}, err - } - runErr := s.runner.Run(ctx, RunSpec{ - RunID: run.ID, - WorkerID: workerID, - Harness: harness, - WorkspacePath: worker.Metadata.WorkspacePath, - PRURL: prURL, - }) - if runErr != nil { - if _, err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, ""); err != nil { - return domain.ReviewRun{}, err - } - run.Status = domain.ReviewRunFailed - return run, fmt.Errorf("launch reviewer: %w", runErr) - } - return run, nil + return s.engine.Trigger(ctx, workerID) } -// Submit records the reviewer's result for a specific worker review pass: it -// marks the run complete and stores the verdict and body. AO does not post the -// review — the reviewer agent posts it to the PR itself. +// Submit records a reviewer's result for a specific worker review pass. func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) { - if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) - } - if runID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) - } - if !verdict.Valid() { - return domain.ReviewRun{}, fmt.Errorf("%w: verdict must be %q or %q", ErrInvalid, domain.VerdictApproved, domain.VerdictChangesRequested) - } - if verdict == domain.VerdictChangesRequested && body == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) - } - - run, ok, err := s.store.GetReviewRun(ctx, runID) - if err != nil { - return domain.ReviewRun{}, err - } - if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q", ErrNotFound, runID) - } - if run.SessionID != workerID { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q does not belong to worker %q", ErrInvalid, runID, workerID) - } - if run.Status != domain.ReviewRunRunning { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) - } - - updated, err := s.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunComplete, verdict, body) - if err != nil { - return domain.ReviewRun{}, err - } - if !updated { - return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) - } - run.Status = domain.ReviewRunComplete - run.Verdict = verdict - run.Body = body - return run, nil + return s.engine.Submit(ctx, workerID, runID, verdict, body) } -// List returns the review passes recorded for a worker, newest first. +// List returns the review passes recorded for a worker. func (s *Service) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { - if workerID == "" { - return nil, fmt.Errorf("%w: worker session id is required", ErrInvalid) - } - return s.store.ListReviewRunsBySession(ctx, workerID) -} - -func (s *Service) workerPRURL(ctx context.Context, workerID domain.SessionID) (string, error) { - prs, err := s.prs.ListPRsBySession(ctx, workerID) - if err != nil { - return "", err - } - if len(prs) == 0 { - return "", fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) - } - return prs[0].URL, nil -} - -// reviewerHarness resolves which harness reviews the worker's PR: a configured -// reviewer wins, otherwise the worker's own harness is reused (falling back to -// claude-code), per domain.ResolveReviewerHarness. -func (s *Service) reviewerHarness(ctx context.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) { - var cfg domain.ProjectConfig - if s.projects != nil { - if proj, ok, err := s.projects.GetProject(ctx, string(worker.ProjectID)); err != nil { - return "", err - } else if ok { - cfg = proj.Config - } - } - return cfg.ResolveReviewerHarness(worker.Harness), nil -} - -func (s *Service) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL string, now time.Time) (domain.Review, error) { - existing, ok, err := s.store.GetReviewBySession(ctx, worker.ID) - if err != nil { - return domain.Review{}, err - } - review := domain.Review{ - ID: s.newID(), - SessionID: worker.ID, - ProjectID: worker.ProjectID, - Harness: harness, - PRURL: prURL, - CreatedAt: now, - UpdatedAt: now, - } - if ok { - // Reuse the existing row's identity and creation time; UpsertReview - // refreshes harness/pr_url/updated_at. - review.ID = existing.ID - review.CreatedAt = existing.CreatedAt - } - if err := s.store.UpsertReview(ctx, review); err != nil { - return domain.Review{}, err - } - return review, nil -} - -func (s *Service) nextIteration(ctx context.Context, workerID domain.SessionID) int { - if latest, ok, err := s.store.GetLatestReviewRunBySession(ctx, workerID); err == nil && ok { - return latest.Iteration + 1 - } - return 1 + return s.engine.List(ctx, workerID) } From 0d3ff518fc0cf980009566f0a398c27f769c45fe Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sat, 13 Jun 2026 10:43:55 +0530 Subject: [PATCH 11/11] feat(review): commit-aware trigger, reviewer handle for UI, no env vars Reworks the review trigger lifecycle and drops env-based coupling: - review_run gains target_sha (the reviewed commit) and drops iteration. A repeat trigger for the same PR head short-circuits to the existing run. - review gains reviewer_handle_id: the live reviewer pane's runtime handle, reused across passes and exposed in the reviews API so the UI can attach its terminal over /mux. - Trigger flow: if a live reviewer pane exists and a new commit arrived, message it to re-review; otherwise spawn a fresh reviewer. The run is recorded only after the reviewer is launched. - No environment variables: the reviewer adapter embeds the explicit `ao review submit --session --run ` command in the spawn prompt and the re-review message. CLI submit requires --run/--session (no env fallbacks). - Merge review_runner into internal/review as a Launcher (spawn/notify/alive). - Trigger returns 201 for a new pass, 200 when reusing an existing run. Regenerated sqlc + OpenAPI/TS. Co-Authored-By: Claude Opus 4.8 --- .../reviewer/claudecode/claudecode.go | 20 +- backend/internal/cli/review.go | 19 +- backend/internal/cli/review_test.go | 20 +- backend/internal/daemon/lifecycle_wiring.go | 6 +- backend/internal/domain/review.go | 10 +- backend/internal/httpd/apispec/openapi.yaml | 18 +- .../internal/httpd/apispec/specgen/build.go | 1 + backend/internal/httpd/controllers/reviews.go | 26 +- backend/internal/ports/reviewer.go | 15 +- backend/internal/review/launcher.go | 115 +++++++ backend/internal/review/launcher_test.go | 113 ++++++ backend/internal/review/review.go | 190 ++++++----- backend/internal/review/review_test.go | 323 +++++++++--------- backend/internal/review_runner/runner.go | 71 ---- backend/internal/review_runner/runner_test.go | 87 ----- backend/internal/service/review/review.go | 12 +- backend/internal/storage/sqlite/gen/models.go | 17 +- .../internal/storage/sqlite/gen/review.sql.go | 89 ++--- .../migrations/0011_add_review_tables.sql | 21 +- .../storage/sqlite/queries/review.sql | 21 +- .../storage/sqlite/store/review_store.go | 45 +-- .../storage/sqlite/store/review_store_test.go | 29 +- frontend/src/api/schema.ts | 13 +- 23 files changed, 726 insertions(+), 555 deletions(-) create mode 100644 backend/internal/review/launcher.go create mode 100644 backend/internal/review/launcher_test.go delete mode 100644 backend/internal/review_runner/runner.go delete mode 100644 backend/internal/review_runner/runner_test.go diff --git a/backend/internal/adapters/reviewer/claudecode/claudecode.go b/backend/internal/adapters/reviewer/claudecode/claudecode.go index a75c95b7..91e890d8 100644 --- a/backend/internal/adapters/reviewer/claudecode/claudecode.go +++ b/backend/internal/adapters/reviewer/claudecode/claudecode.go @@ -31,8 +31,8 @@ func (r *Reviewer) Harness() domain.ReviewerHarness { var _ ports.Reviewer = (*Reviewer)(nil) -// ReviewCommand builds a one-shot claude-code invocation that reviews the -// worker's checkout for the PR, with the review prompt baked in. +// ReviewCommand builds a claude-code invocation that reviews the worker's +// checkout for the PR, with the review prompt baked in. func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { argv, err := r.agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: inv.ReviewerID, @@ -45,16 +45,24 @@ func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation return ports.ReviewCommandSpec{Argv: argv}, nil } +// ReviewMessage is the text injected into an already-running reviewer pane to +// review a new commit. It carries the same explicit instructions as the spawn +// prompt. +func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) { + return reviewPrompt(inv), nil +} + func reviewPrompt(inv ports.ReviewInvocation) string { - return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a checkout containing the changes for pull request %s. Review only this PR's changes — do not start unrelated work. + return fmt.Sprintf(`You are an AO code reviewer. The current working directory is a checkout containing the changes for pull request %s (head commit %s). Review only this PR's changes — do not start unrelated work. Steps: 1. Inspect what the PR changed by diffing the checkout against the PR's base branch. 2. Review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. 3. Post your review on the pull request using the available review tooling (request changes if it needs work, approve if it is ready), with inline comments for specific findings. -4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run +4. Record the outcome with AO so the worker is nudged: write your full review to review.md, then run exactly: - ao review submit --verdict --body review.md + ao review submit --session %s --run %s --verdict --body review.md -Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot post the review, still run `+"`ao review submit`"+` with your verdict and findings so the result is recorded.`, inv.PRURL) +Constraints: do not push commits, edit files, or modify the branch — review only. If you cannot post the review, still run the `+"`ao review submit`"+` command above so the result is recorded.`, + inv.PRURL, inv.TargetSHA, inv.WorkerSessionID, inv.RunID) } diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go index 9c00dc62..7bb2f7f5 100644 --- a/backend/internal/cli/review.go +++ b/backend/internal/cli/review.go @@ -17,16 +17,17 @@ type reviewRun struct { SessionID string `json:"sessionId"` Harness string `json:"harness"` PRURL string `json:"prUrl"` + TargetSHA string `json:"targetSha"` Status string `json:"status"` Verdict string `json:"verdict"` - Iteration int `json:"iteration"` Body string `json:"body"` CreatedAt time.Time `json:"createdAt"` } // reviewRunResponse mirrors controllers.ReviewRunResponse. type reviewRunResponse struct { - Review reviewRun `json:"review"` + Review reviewRun `json:"review"` + ReviewerHandleID string `json:"reviewerHandleId"` } // submitReviewRequest mirrors controllers.SubmitReviewInput. @@ -62,8 +63,8 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { return ctx.submitReview(cmd, args, opts) }, } - cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (defaults to $AO_REVIEW_WORKER)") - cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (defaults to $AO_REVIEW_RUN_ID)") + cmd.Flags().StringVar(&opts.session, "session", "", "Worker session id (or pass it as the positional argument)") + cmd.Flags().StringVar(&opts.runID, "run", "", "Review run id (required)") cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") cmd.Flags().StringVar(&opts.body, "body", "", "Path to a Markdown file with the review body") return cmd @@ -75,17 +76,11 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re session = strings.TrimSpace(args[0]) } if session == "" { - session = strings.TrimSpace(os.Getenv("AO_REVIEW_WORKER")) - } - if session == "" { - return usageError{errors.New("usage: worker session id is required (positional, --session, or $AO_REVIEW_WORKER)")} + return usageError{errors.New("usage: worker session id is required (positional or --session)")} } runID := strings.TrimSpace(opts.runID) if runID == "" { - runID = strings.TrimSpace(os.Getenv("AO_REVIEW_RUN_ID")) - } - if runID == "" { - return usageError{errors.New("usage: review run id is required (--run or $AO_REVIEW_RUN_ID)")} + return usageError{errors.New("usage: --run is required")} } verdict := strings.TrimSpace(opts.verdict) if verdict == "" { diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go index 14ab4eff..29cdee64 100644 --- a/backend/internal/cli/review_test.go +++ b/backend/internal/cli/review_test.go @@ -37,7 +37,6 @@ func aliveDeps() Deps { return Deps{ProcessAlive: func(int) bool { return true } func TestReviewSubmitReadsBodyFile(t *testing.T) { cfg := setConfigEnv(t) - t.Setenv("AO_REVIEW_RUN_ID", "run-1") srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"changes_requested"}}`) writeRunFileFor(t, cfg, srv) @@ -47,7 +46,7 @@ func TestReviewSubmitReadsBodyFile(t *testing.T) { } _, errOut, err := executeCLI(t, aliveDeps(), - "review", "submit", "mer-1", "--verdict", "changes_requested", "--body", bodyFile) + "review", "submit", "mer-1", "--run", "run-1", "--verdict", "changes_requested", "--body", bodyFile) if err != nil { t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) } @@ -63,14 +62,12 @@ func TestReviewSubmitReadsBodyFile(t *testing.T) { } } -func TestReviewSubmitUsesEnvWorker(t *testing.T) { +func TestReviewSubmitUsesSessionFlag(t *testing.T) { cfg := setConfigEnv(t) - t.Setenv("AO_REVIEW_WORKER", "mer-7") - t.Setenv("AO_REVIEW_RUN_ID", "run-7") srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) writeRunFileFor(t, cfg, srv) - if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved"); err != nil { + if _, errOut, err := executeCLI(t, aliveDeps(), "review", "submit", "--session", "mer-7", "--run", "run-7", "--verdict", "approved"); err != nil { t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) } if capture.path != "/api/v1/sessions/mer-7/reviews/submit" { @@ -80,8 +77,7 @@ func TestReviewSubmitUsesEnvWorker(t *testing.T) { func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { setConfigEnv(t) - t.Setenv("AO_REVIEW_RUN_ID", "run-1") - _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1") + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1", "--run", "run-1") if got := ExitCode(err); got != 2 { t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) } @@ -89,9 +85,7 @@ func TestReviewSubmitMissingVerdictIsUsageError(t *testing.T) { func TestReviewSubmitMissingWorkerIsUsageError(t *testing.T) { setConfigEnv(t) - t.Setenv("AO_REVIEW_WORKER", "") - t.Setenv("AO_REVIEW_RUN_ID", "run-1") - _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved") + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--run", "run-1", "--verdict", "approved") if got := ExitCode(err); got != 2 { t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) } @@ -99,9 +93,7 @@ func TestReviewSubmitMissingWorkerIsUsageError(t *testing.T) { func TestReviewSubmitMissingRunIsUsageError(t *testing.T) { setConfigEnv(t) - t.Setenv("AO_REVIEW_WORKER", "mer-1") - t.Setenv("AO_REVIEW_RUN_ID", "") - _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "--verdict", "approved") + _, _, err := executeCLI(t, aliveDeps(), "review", "submit", "mer-1", "--verdict", "approved") if got := ExitCode(err); got != 2 { t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err) } diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index b42543d3..1b5f9a5e 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -10,6 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitydispatch" agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -17,7 +18,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/observe/reaper" "github.com/aoagents/agent-orchestrator/backend/internal/ports" reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" - reviewrunner "github.com/aoagents/agent-orchestrator/backend/internal/review_runner" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager" @@ -58,7 +58,7 @@ func (l *lifecycleStack) Stop() { // over the real zellij runtime, a per-session gitworktree workspace, the shared // store + LCM, the per-session agent resolver (AO_AGENT default), and the // agent messenger. The returned service is mounted at httpd APIDeps.Sessions. -func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { +func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) { agents, err := buildAgentResolver(cfg.Agent, log) if err != nil { return nil, nil, err @@ -111,7 +111,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, Sessions: store, PRs: store, Projects: store, - Runner: reviewrunner.New(reviewers, runtime), + Launcher: reviewcore.NewLauncher(reviewers, runtime), }) reviewSvc := reviewsvc.New(reviewEngine) return sessionSvc, reviewSvc, nil diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index 55cb1b83..a71556da 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -11,8 +11,11 @@ type Review struct { ProjectID ProjectID `json:"projectId"` Harness ReviewerHarness `json:"harness"` PRURL string `json:"prUrl"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + // ReviewerHandleID is the runtime handle of the live reviewer pane, reused + // across passes and exposed so the UI can attach its terminal. + ReviewerHandleID string `json:"reviewerHandleId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ReviewRun is one review pass against a worker's PR. @@ -22,9 +25,10 @@ type ReviewRun struct { SessionID SessionID `json:"sessionId"` Harness ReviewerHarness `json:"harness"` PRURL string `json:"prUrl"` + // TargetSHA is the PR head commit this pass reviewed. + TargetSHA string `json:"targetSha"` Status ReviewRunStatus `json:"status"` Verdict ReviewVerdict `json:"verdict"` - Iteration int `json:"iteration"` // Body is the review text the reviewer submitted. It is recorded for AO's // own tracking; the reviewer also posts the review to the PR itself. Body string `json:"body"` diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 3fdd0f0c..82b13334 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -916,6 +916,12 @@ paths: description: Session identifier, e.g. project-1. type: string responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewRunResponse' + description: OK "201": content: application/json: @@ -1230,11 +1236,14 @@ components: type: object ListReviewsResponse: properties: + reviewerHandleId: + type: string reviews: items: $ref: '#/components/schemas/ReviewRun' type: array required: + - reviewerHandleId - reviews type: object ListSessionPRsResponse: @@ -1453,8 +1462,6 @@ components: type: string id: type: string - iteration: - type: integer prUrl: type: string reviewId: @@ -1463,6 +1470,8 @@ components: type: string status: type: string + targetSha: + type: string verdict: type: string required: @@ -1471,9 +1480,9 @@ components: - sessionId - harness - prUrl + - targetSha - status - verdict - - iteration - body - createdAt type: object @@ -1481,8 +1490,11 @@ components: properties: review: $ref: '#/components/schemas/ReviewRun' + reviewerHandleId: + type: string required: - review + - reviewerHandleId type: object RoleOverride: properties: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index d70d00f3..eb9b47a0 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -274,6 +274,7 @@ func reviewOperations() []operation { summary: "Trigger a code review of a worker's PR", pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ + {http.StatusOK, controllers.ReviewRunResponse{}}, {http.StatusCreated, controllers.ReviewRunResponse{}}, {http.StatusUnprocessableEntity, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index 33eec9ca..b9fc3c68 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -14,13 +14,18 @@ import ( ) // ListReviewsResponse is the body of GET /api/v1/sessions/{sessionId}/reviews. +// reviewerHandleId is the live reviewer pane's runtime handle, for the UI to +// attach its terminal over /mux (empty when no reviewer has run). type ListReviewsResponse struct { - Reviews []domain.ReviewRun `json:"reviews"` + ReviewerHandleID string `json:"reviewerHandleId"` + Reviews []domain.ReviewRun `json:"reviews"` } -// ReviewRunResponse is the { review } body of trigger (201) and submit (200). +// ReviewRunResponse is the body of trigger (200/201) and submit (200). It +// carries the run plus the reviewer pane handle so the UI can attach a terminal. type ReviewRunResponse struct { - Review domain.ReviewRun `json:"review"` + Review domain.ReviewRun `json:"review"` + ReviewerHandleID string `json:"reviewerHandleId"` } // SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. @@ -47,15 +52,16 @@ func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/reviews") return } - runs, err := c.Svc.List(r.Context(), sessionID(r)) + res, err := c.Svc.List(r.Context(), sessionID(r)) if err != nil { writeReviewError(w, r, err) return } + runs := res.Runs if runs == nil { runs = []domain.ReviewRun{} } - envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{Reviews: runs}) + envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{ReviewerHandleID: res.ReviewerHandleID, Reviews: runs}) } func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { @@ -63,12 +69,18 @@ func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/reviews/trigger") return } - run, err := c.Svc.Trigger(r.Context(), sessionID(r)) + res, err := c.Svc.Trigger(r.Context(), sessionID(r)) if err != nil { writeReviewError(w, r, err) return } - envelope.WriteJSON(w, http.StatusCreated, ReviewRunResponse{Review: run}) + // 201 when a new pass was started; 200 when an existing run for the same + // commit was reused. + status := http.StatusOK + if res.Created { + status = http.StatusCreated + } + envelope.WriteJSON(w, status, ReviewRunResponse{Review: res.Run, ReviewerHandleID: res.ReviewerHandleID}) } func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/ports/reviewer.go b/backend/internal/ports/reviewer.go index a05a30e9..938fb070 100644 --- a/backend/internal/ports/reviewer.go +++ b/backend/internal/ports/reviewer.go @@ -13,19 +13,30 @@ import ( // returns its own argv with no prompt at all. type Reviewer interface { // ReviewCommand builds the command (and any extra env) AO should run to - // review the worker's checkout for a PR. + // spawn a fresh reviewer over the worker's checkout for a PR. ReviewCommand(ctx context.Context, inv ReviewInvocation) (ReviewCommandSpec, error) + // ReviewMessage builds the text AO injects into an already-running reviewer + // pane to ask it to review a new commit. It must be self-contained (carry + // the ids the reviewer needs to submit) since AO passes no environment. + ReviewMessage(ctx context.Context, inv ReviewInvocation) (string, error) } -// ReviewInvocation describes one review pass for a reviewer to act on. +// ReviewInvocation describes one review pass for a reviewer to act on. All ids +// the reviewer needs are passed explicitly here (and embedded in the prompt / +// message), never through environment variables. type ReviewInvocation struct { // ReviewerID is a stable id for the reviewer's runtime instance (pane, // native session id), derived from the worker session. ReviewerID string + // RunID is the review_run this pass completes; the reviewer passes it to + // `ao review submit`. + RunID string // WorkerSessionID is the worker whose PR is under review. WorkerSessionID domain.SessionID // PRURL is the pull request to review. PRURL string + // TargetSHA is the PR head commit under review. + TargetSHA string // WorkspacePath is the worker's checkout the reviewer reads. WorkspacePath string } diff --git a/backend/internal/review/launcher.go b/backend/internal/review/launcher.go new file mode 100644 index 00000000..f376ac81 --- /dev/null +++ b/backend/internal/review/launcher.go @@ -0,0 +1,115 @@ +package review + +import ( + "context" + "fmt" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Launcher spawns, re-notifies, and probes a reviewer over a worker's worktree. +// It is the side of the engine that talks to the reviewer registry and runtime; +// the engine owns the orchestration and persistence. +type Launcher interface { + // Spawn launches a fresh reviewer and returns the runtime handle id of the + // live pane (stable per worker, reused across passes). + Spawn(ctx context.Context, spec LaunchSpec) (handleID string, err error) + // Notify asks an already-running reviewer pane to review a new commit. + Notify(ctx context.Context, handleID string, spec LaunchSpec) error + // Alive reports whether a reviewer pane is still running. + Alive(ctx context.Context, handleID string) (bool, error) +} + +// LaunchSpec is the engine's request to (re)launch a reviewer for one pass. +type LaunchSpec struct { + RunID string + WorkerID domain.SessionID + Harness domain.ReviewerHarness + WorkspacePath string + PRURL string + TargetSHA string +} + +// reviewerRuntime is the runtime surface the launcher needs: create a pane, +// inject a message into a running pane, and probe liveness. The zellij runtime +// satisfies it. +type reviewerRuntime interface { + Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) + IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) + SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error +} + +// agentLauncher resolves a reviewer adapter from the registry and drives the +// runtime. The reviewer reuses the worker's worktree (a fresh session worktree +// would branch off the default branch and so would not contain the PR changes). +type agentLauncher struct { + reviewers ports.ReviewerResolver + runtime reviewerRuntime +} + +// NewLauncher builds the production reviewer launcher. +func NewLauncher(reviewers ports.ReviewerResolver, runtime reviewerRuntime) Launcher { + return &agentLauncher{reviewers: reviewers, runtime: runtime} +} + +// reviewerHandleID is the stable runtime handle for a worker's reviewer pane, so +// one live reviewer is reused across passes. +func reviewerHandleID(workerID domain.SessionID) string { + return "review-" + string(workerID) +} + +func (l *agentLauncher) invocation(spec LaunchSpec) ports.ReviewInvocation { + return ports.ReviewInvocation{ + ReviewerID: reviewerHandleID(spec.WorkerID), + RunID: spec.RunID, + WorkerSessionID: spec.WorkerID, + PRURL: spec.PRURL, + TargetSHA: spec.TargetSHA, + WorkspacePath: spec.WorkspacePath, + } +} + +func (l *agentLauncher) Spawn(ctx context.Context, spec LaunchSpec) (string, error) { + reviewer, ok := l.reviewers.Reviewer(spec.Harness) + if !ok { + return "", fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) + } + handleID := reviewerHandleID(spec.WorkerID) + cmd, err := reviewer.ReviewCommand(ctx, l.invocation(spec)) + if err != nil { + return "", fmt.Errorf("reviewer command: %w", err) + } + handle, err := l.runtime.Create(ctx, ports.RuntimeConfig{ + SessionID: domain.SessionID(handleID), + WorkspacePath: spec.WorkspacePath, + Argv: cmd.Argv, + Env: cmd.Env, + }) + if err != nil { + return "", fmt.Errorf("reviewer runtime: %w", err) + } + return handle.ID, nil +} + +func (l *agentLauncher) Notify(ctx context.Context, handleID string, spec LaunchSpec) error { + reviewer, ok := l.reviewers.Reviewer(spec.Harness) + if !ok { + return fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) + } + msg, err := reviewer.ReviewMessage(ctx, l.invocation(spec)) + if err != nil { + return fmt.Errorf("reviewer message: %w", err) + } + if err := l.runtime.SendMessage(ctx, ports.RuntimeHandle{ID: handleID}, msg); err != nil { + return fmt.Errorf("notify reviewer: %w", err) + } + return nil +} + +func (l *agentLauncher) Alive(ctx context.Context, handleID string) (bool, error) { + if handleID == "" { + return false, nil + } + return l.runtime.IsAlive(ctx, ports.RuntimeHandle{ID: handleID}) +} diff --git a/backend/internal/review/launcher_test.go b/backend/internal/review/launcher_test.go new file mode 100644 index 00000000..71098371 --- /dev/null +++ b/backend/internal/review/launcher_test.go @@ -0,0 +1,113 @@ +package review + +import ( + "context" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeReviewer struct { + gotInv ports.ReviewInvocation +} + +func (f *fakeReviewer) ReviewCommand(_ context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { + f.gotInv = inv + return ports.ReviewCommandSpec{Argv: []string{"greptile", "review"}}, nil +} +func (f *fakeReviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) { + f.gotInv = inv + return "review run " + inv.RunID, nil +} + +type fakeReviewerResolver struct { + reviewer ports.Reviewer + ok bool +} + +func (f fakeReviewerResolver) Reviewer(domain.ReviewerHarness) (ports.Reviewer, bool) { + return f.reviewer, f.ok +} + +type fakeRuntime struct { + createCfg ports.RuntimeConfig + sentMsg string + sentTo string + alive bool +} + +func (f *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { + f.createCfg = cfg + return ports.RuntimeHandle{ID: string(cfg.SessionID)}, nil +} +func (f *fakeRuntime) IsAlive(_ context.Context, _ ports.RuntimeHandle) (bool, error) { + return f.alive, nil +} +func (f *fakeRuntime) SendMessage(_ context.Context, handle ports.RuntimeHandle, msg string) error { + f.sentTo = handle.ID + f.sentMsg = msg + return nil +} + +func launchSpec() LaunchSpec { + return LaunchSpec{ + RunID: "run-1", WorkerID: "mer-1", Harness: domain.ReviewerClaudeCode, + WorkspacePath: "/ws/mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", + } +} + +func TestLauncherSpawnReturnsStableHandle(t *testing.T) { + reviewer := &fakeReviewer{} + rt := &fakeRuntime{} + l := NewLauncher(fakeReviewerResolver{reviewer: reviewer, ok: true}, rt) + + handle, err := l.Spawn(context.Background(), launchSpec()) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if handle != "review-mer-1" { + t.Fatalf("handle = %q, want review-mer-1", handle) + } + if rt.createCfg.WorkspacePath != "/ws/mer-1" || len(rt.createCfg.Argv) == 0 || rt.createCfg.Argv[0] != "greptile" { + t.Fatalf("create cfg = %+v", rt.createCfg) + } + // No environment is used to carry review identity. + if len(rt.createCfg.Env) != 0 { + t.Fatalf("expected no env, got %v", rt.createCfg.Env) + } + if reviewer.gotInv.RunID != "run-1" || reviewer.gotInv.TargetSHA != "sha1" || reviewer.gotInv.ReviewerID != "review-mer-1" { + t.Fatalf("invocation = %+v", reviewer.gotInv) + } +} + +func TestLauncherNotifySendsMessageToHandle(t *testing.T) { + reviewer := &fakeReviewer{} + rt := &fakeRuntime{} + l := NewLauncher(fakeReviewerResolver{reviewer: reviewer, ok: true}, rt) + + if err := l.Notify(context.Background(), "review-mer-1", launchSpec()); err != nil { + t.Fatalf("Notify: %v", err) + } + if rt.sentTo != "review-mer-1" || !strings.Contains(rt.sentMsg, "run-1") { + t.Fatalf("sent to %q msg %q", rt.sentTo, rt.sentMsg) + } +} + +func TestLauncherAlive(t *testing.T) { + l := NewLauncher(fakeReviewerResolver{ok: true}, &fakeRuntime{alive: true}) + if ok, _ := l.Alive(context.Background(), "review-mer-1"); !ok { + t.Fatal("want alive true") + } + if ok, _ := l.Alive(context.Background(), ""); ok { + t.Fatal("empty handle should not be alive") + } +} + +func TestLauncherSpawnNoAdapter(t *testing.T) { + l := NewLauncher(fakeReviewerResolver{ok: false}, &fakeRuntime{}) + if _, err := l.Spawn(context.Background(), launchSpec()); err == nil || !strings.Contains(err.Error(), "no reviewer adapter") { + t.Fatalf("err = %v, want no-adapter", err) + } +} diff --git a/backend/internal/review/review.go b/backend/internal/review/review.go index ea14cd3e..9664b0a0 100644 --- a/backend/internal/review/review.go +++ b/backend/internal/review/review.go @@ -33,7 +33,7 @@ type Store interface { InsertReviewRun(ctx context.Context, r domain.ReviewRun) error UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) - GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) + GetReviewRunBySessionAndSHA(ctx context.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) ListReviewRunsBySession(ctx context.Context, id domain.SessionID) ([]domain.ReviewRun, error) } @@ -52,27 +52,13 @@ type Projects interface { GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } -// Runner launches the reviewer one-shot over the worker's worktree. -type Runner interface { - Run(ctx context.Context, spec RunSpec) error -} - -// RunSpec describes one reviewer launch. -type RunSpec struct { - RunID string - WorkerID domain.SessionID - Harness domain.ReviewerHarness - WorkspacePath string - PRURL string -} - // Deps wires the engine. type Deps struct { Store Store Sessions Sessions PRs PRs Projects Projects - Runner Runner + Launcher Launcher // Clock and NewID are injectable for deterministic tests. Clock func() time.Time @@ -85,7 +71,7 @@ type Engine struct { sessions Sessions prs PRs projects Projects - runner Runner + launcher Launcher clock func() time.Time newID func() string } @@ -105,79 +91,127 @@ func New(d Deps) *Engine { sessions: d.Sessions, prs: d.PRs, projects: d.Projects, - runner: d.Runner, + launcher: d.Launcher, clock: clock, newID: newID, } } -// Trigger starts a review pass for a worker's PR: it reuses (or creates) the -// worker's review row, records a running review_run (whose id is the run id), -// and launches the configured reviewer over the worker's worktree. -func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) { +// TriggerResult is the outcome of a trigger: the (new or existing) run, the live +// reviewer pane's handle so the UI can attach its terminal, and whether a new +// pass was started (false when an existing run for the same commit was reused). +type TriggerResult struct { + Run domain.ReviewRun + ReviewerHandleID string + Created bool +} + +// SessionReviews is a worker's review state: the live reviewer handle plus its +// recorded passes, newest first. +type SessionReviews struct { + ReviewerHandleID string + Runs []domain.ReviewRun +} + +// Trigger starts (or reuses) a review of a worker's PR at its current head: +// - if a run already exists for this commit, it is returned unchanged; +// - otherwise, if a live reviewer pane exists, it is messaged to review the +// new commit; if not, a fresh reviewer is spawned; +// - only after the reviewer is launched is the run recorded. +func (e *Engine) Trigger(ctx context.Context, workerID domain.SessionID) (TriggerResult, error) { if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + return TriggerResult{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) } worker, ok, err := e.sessions.GetSession(ctx, workerID) if err != nil { - return domain.ReviewRun{}, err + return TriggerResult{}, err } if !ok { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID) + return TriggerResult{}, fmt.Errorf("%w: worker session %q", ErrNotFound, workerID) } if worker.IsTerminated { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID) + return TriggerResult{}, fmt.Errorf("%w: worker session %q is terminated", ErrInvalid, workerID) } if worker.Metadata.WorkspacePath == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) + return TriggerResult{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) } - prURL, err := e.workerPRURL(ctx, workerID) + pr, err := e.workerPR(ctx, workerID) if err != nil { - return domain.ReviewRun{}, err + return TriggerResult{}, err + } + targetSHA := pr.HeadSHA + + review, hasReview, err := e.store.GetReviewBySession(ctx, workerID) + if err != nil { + return TriggerResult{}, err + } + + // Idempotency: a pass already exists for this commit — return it as-is. + if existing, ok, err := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); err != nil { + return TriggerResult{}, err + } else if ok { + return TriggerResult{Run: existing, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil } harness, err := e.reviewerHarness(ctx, worker) if err != nil { - return domain.ReviewRun{}, err + return TriggerResult{}, err } now := e.clock() - iteration := e.nextIteration(ctx, workerID) + runID := e.newID() + spec := LaunchSpec{ + RunID: runID, + WorkerID: workerID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: pr.URL, + TargetSHA: targetSHA, + } - review, err := e.upsertReview(ctx, worker, harness, prURL, now) + // Reuse a live reviewer pane if there is one; otherwise spawn a fresh one. + handleID := "" + if hasReview && review.ReviewerHandleID != "" { + alive, err := e.launcher.Alive(ctx, review.ReviewerHandleID) + if err != nil { + return TriggerResult{}, err + } + if alive { + if err := e.launcher.Notify(ctx, review.ReviewerHandleID, spec); err != nil { + return TriggerResult{}, fmt.Errorf("notify reviewer: %w", err) + } + handleID = review.ReviewerHandleID + } + } + if handleID == "" { + h, err := e.launcher.Spawn(ctx, spec) + if err != nil { + return TriggerResult{}, fmt.Errorf("launch reviewer: %w", err) + } + handleID = h + } + + // The reviewer is running; now record the pass. + review, err = e.upsertReview(ctx, worker, harness, pr.URL, handleID, now) if err != nil { - return domain.ReviewRun{}, err + return TriggerResult{}, err } run := domain.ReviewRun{ - ID: e.newID(), + ID: runID, ReviewID: review.ID, SessionID: workerID, Harness: harness, - PRURL: prURL, + PRURL: pr.URL, + TargetSHA: targetSHA, Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, - Iteration: iteration, CreatedAt: now, } if err := e.store.InsertReviewRun(ctx, run); err != nil { - return domain.ReviewRun{}, err + return TriggerResult{}, err } - runErr := e.runner.Run(ctx, RunSpec{ - RunID: run.ID, - WorkerID: workerID, - Harness: harness, - WorkspacePath: worker.Metadata.WorkspacePath, - PRURL: prURL, - }) - if runErr != nil { - if _, err := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, ""); err != nil { - return domain.ReviewRun{}, err - } - run.Status = domain.ReviewRunFailed - return run, fmt.Errorf("launch reviewer: %w", runErr) - } - return run, nil + return TriggerResult{Run: run, ReviewerHandleID: handleID, Created: true}, nil } // Submit records the reviewer's result for a specific worker review pass: it @@ -224,23 +258,33 @@ func (e *Engine) Submit(ctx context.Context, workerID domain.SessionID, runID st return run, nil } -// List returns the review passes recorded for a worker, newest first. -func (e *Engine) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { +// List returns a worker's review state: the live reviewer handle and its passes. +func (e *Engine) List(ctx context.Context, workerID domain.SessionID) (SessionReviews, error) { if workerID == "" { - return nil, fmt.Errorf("%w: worker session id is required", ErrInvalid) + return SessionReviews{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + runs, err := e.store.ListReviewRunsBySession(ctx, workerID) + if err != nil { + return SessionReviews{}, err + } + var handle string + if review, ok, err := e.store.GetReviewBySession(ctx, workerID); err != nil { + return SessionReviews{}, err + } else if ok { + handle = review.ReviewerHandleID } - return e.store.ListReviewRunsBySession(ctx, workerID) + return SessionReviews{ReviewerHandleID: handle, Runs: runs}, nil } -func (e *Engine) workerPRURL(ctx context.Context, workerID domain.SessionID) (string, error) { +func (e *Engine) workerPR(ctx context.Context, workerID domain.SessionID) (domain.PullRequest, error) { prs, err := e.prs.ListPRsBySession(ctx, workerID) if err != nil { - return "", err + return domain.PullRequest{}, err } if len(prs) == 0 { - return "", fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) + return domain.PullRequest{}, fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) } - return prs[0].URL, nil + return prs[0], nil } // reviewerHarness resolves which harness reviews the worker's PR: a configured @@ -258,23 +302,24 @@ func (e *Engine) reviewerHarness(ctx context.Context, worker domain.SessionRecor return cfg.ResolveReviewerHarness(worker.Harness), nil } -func (e *Engine) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL string, now time.Time) (domain.Review, error) { +func (e *Engine) upsertReview(ctx context.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL, handleID string, now time.Time) (domain.Review, error) { existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID) if err != nil { return domain.Review{}, err } review := domain.Review{ - ID: e.newID(), - SessionID: worker.ID, - ProjectID: worker.ProjectID, - Harness: harness, - PRURL: prURL, - CreatedAt: now, - UpdatedAt: now, + ID: e.newID(), + SessionID: worker.ID, + ProjectID: worker.ProjectID, + Harness: harness, + PRURL: prURL, + ReviewerHandleID: handleID, + CreatedAt: now, + UpdatedAt: now, } if ok { // Reuse the existing row's identity and creation time; UpsertReview - // refreshes harness/pr_url/updated_at. + // refreshes harness/pr_url/reviewer_handle_id/updated_at. review.ID = existing.ID review.CreatedAt = existing.CreatedAt } @@ -283,10 +328,3 @@ func (e *Engine) upsertReview(ctx context.Context, worker domain.SessionRecord, } return review, nil } - -func (e *Engine) nextIteration(ctx context.Context, workerID domain.SessionID) int { - if latest, ok, err := e.store.GetLatestReviewRunBySession(ctx, workerID); err == nil && ok { - return latest.Iteration + 1 - } - return 1 -} diff --git a/backend/internal/review/review_test.go b/backend/internal/review/review_test.go index afd432fa..333bcdba 100644 --- a/backend/internal/review/review_test.go +++ b/backend/internal/review/review_test.go @@ -12,18 +12,11 @@ import ( // --- fakes --- type fakeStore struct { - review *domain.Review - runs []domain.ReviewRun - inserted bool - upsertErr error - insertErr error - updateErr error + review *domain.Review + runs []domain.ReviewRun } func (f *fakeStore) UpsertReview(_ context.Context, r domain.Review) error { - if f.upsertErr != nil { - return f.upsertErr - } cp := r f.review = &cp return nil @@ -35,19 +28,15 @@ func (f *fakeStore) GetReviewBySession(_ context.Context, _ domain.SessionID) (d return *f.review, true, nil } func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error { - if f.insertErr != nil { - return f.insertErr - } - f.inserted = true f.runs = append(f.runs, r) return nil } func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body string) (bool, error) { - if f.updateErr != nil { - return false, f.updateErr - } for i := range f.runs { if f.runs[i].ID == id { + if f.runs[i].Status != domain.ReviewRunRunning { + return false, nil + } f.runs[i].Status = status f.runs[i].Verdict = verdict f.runs[i].Body = body @@ -57,18 +46,20 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status d return false, nil } func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) { - for _, run := range f.runs { - if run.ID == id { - return run, true, nil + for _, r := range f.runs { + if r.ID == id { + return r, true, nil } } return domain.ReviewRun{}, false, nil } -func (f *fakeStore) GetLatestReviewRunBySession(_ context.Context, _ domain.SessionID) (domain.ReviewRun, bool, error) { - if len(f.runs) == 0 { - return domain.ReviewRun{}, false, nil +func (f *fakeStore) GetReviewRunBySessionAndSHA(_ context.Context, _ domain.SessionID, sha string) (domain.ReviewRun, bool, error) { + for i := len(f.runs) - 1; i >= 0; i-- { + if f.runs[i].TargetSHA == sha { + return f.runs[i], true, nil + } } - return f.runs[len(f.runs)-1], true, nil + return domain.ReviewRun{}, false, nil } func (f *fakeStore) ListReviewRunsBySession(_ context.Context, _ domain.SessionID) ([]domain.ReviewRun, error) { return f.runs, nil @@ -95,228 +86,230 @@ func (f fakeProjects) GetProject(_ context.Context, id string) (domain.ProjectRe return domain.ProjectRecord{ID: id, Config: f.cfg}, true, nil } -type fakeRunner struct { - store *fakeStore - requireRun bool - spec RunSpec - err error - ran bool +type fakeLauncher struct { + handle string + alive bool + spawnErr error + notifyErr error + spawned bool + notified bool + gotSpec LaunchSpec + gotHandle string } -func (f *fakeRunner) Run(_ context.Context, spec RunSpec) error { - if f.requireRun && (f.store == nil || !f.store.inserted) { - return errors.New("review run was not inserted before launch") +func (f *fakeLauncher) Spawn(_ context.Context, spec LaunchSpec) (string, error) { + f.spawned = true + f.gotSpec = spec + if f.spawnErr != nil { + return "", f.spawnErr } - f.ran = true - f.spec = spec - return f.err + return f.handle, nil } +func (f *fakeLauncher) Notify(_ context.Context, handleID string, spec LaunchSpec) error { + f.notified = true + f.gotHandle = handleID + f.gotSpec = spec + return f.notifyErr +} +func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) { return f.alive, nil } func liveWorker() domain.SessionRecord { return domain.SessionRecord{ ID: "mer-1", ProjectID: "mer", - Harness: domain.HarnessCodex, + Harness: domain.HarnessClaudeCode, Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1"}, } } -func newServiceForTest(store Store, sessions Sessions, prs PRs, projects Projects, runner Runner) *Engine { +func newEngineForTest(store Store, sessions Sessions, prs PRs, projects Projects, launcher Launcher) *Engine { ids := 0 return New(Deps{ - Store: store, Sessions: sessions, PRs: prs, Projects: projects, Runner: runner, + Store: store, Sessions: sessions, PRs: prs, Projects: projects, Launcher: launcher, Clock: func() time.Time { return time.Unix(0, 0).UTC() }, NewID: func() string { ids++; return "id-" + string(rune('0'+ids)) }, }) } +func prAt(sha string) fakePRs { + return fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1", HeadSHA: sha}}} +} + // --- tests --- -func TestTriggerCreatesPendingRunAndLaunchesReviewer(t *testing.T) { +func TestTriggerSpawnsNewReviewerAndRecordsRunAfterLaunch(t *testing.T) { store := &fakeStore{} - sessions := fakeSessions{rec: liveWorker(), ok: true} - prs := fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1"}}} - // A reviewer-only harness (greptile) is configured; it wins over the worker harness. - projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.ReviewerHarness("greptile")}}}} - runner := &fakeRunner{store: store, requireRun: true} - svc := newServiceForTest(store, sessions, prs, projects, runner) + launcher := &fakeLauncher{handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) - run, err := svc.Trigger(context.Background(), "mer-1") + res, err := eng.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Status != domain.ReviewRunRunning || run.Iteration != 1 || run.Harness != domain.ReviewerHarness("greptile") { - t.Fatalf("run = %+v", run) + if !res.Created || res.ReviewerHandleID != "review-mer-1" { + t.Fatalf("result = %+v", res) + } + if !launcher.spawned || launcher.notified { + t.Fatalf("expected spawn (no live reviewer): %+v", launcher) } - if !runner.ran || runner.spec.RunID != run.ID || runner.spec.WorkspacePath != "/ws/mer-1" || runner.spec.Harness != domain.ReviewerHarness("greptile") { - t.Fatalf("runner spec = %+v ran=%v", runner.spec, runner.ran) + if res.Run.TargetSHA != "sha1" || res.Run.Status != domain.ReviewRunRunning || res.Run.Harness != domain.ReviewerClaudeCode { + t.Fatalf("run = %+v", res.Run) } - if store.review == nil || store.review.PRURL != "https://github.com/o/r/pull/1" { - t.Fatalf("review row = %+v", store.review) + if launcher.gotSpec.RunID != res.Run.ID { + t.Fatalf("launch spec run id %q != run id %q", launcher.gotSpec.RunID, res.Run.ID) + } + if len(store.runs) != 1 || store.review == nil || store.review.ReviewerHandleID != "review-mer-1" { + t.Fatalf("persisted review=%+v runs=%+v", store.review, store.runs) } } -func TestTriggerReusesWorkerHarnessWhenItIsAReviewer(t *testing.T) { - store := &fakeStore{} - // No reviewer configured; the worker's harness (claude-code) is also a - // supported reviewer, so it is reused. - rec := liveWorker() - rec.Harness = domain.HarnessClaudeCode - svc := newServiceForTest(store, fakeSessions{rec: rec, ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) - run, err := svc.Trigger(context.Background(), "mer-1") +func TestTriggerIsIdempotentForSameCommit(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}, + } + launcher := &fakeLauncher{} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Harness != domain.ReviewerClaudeCode { - t.Fatalf("harness = %q, want reviewer claude-code", run.Harness) + if res.Created || res.Run.ID != "run-1" || res.ReviewerHandleID != "review-mer-1" { + t.Fatalf("expected reuse of existing run: %+v", res) + } + if launcher.spawned || launcher.notified { + t.Fatalf("should not launch for an already-reviewed commit: %+v", launcher) + } + if len(store.runs) != 1 { + t.Fatalf("should not insert another run: %+v", store.runs) } } -func TestTriggerFallsBackWhenWorkerHarnessNotAReviewer(t *testing.T) { - store := &fakeStore{} - // liveWorker's harness is codex, which is not a supported reviewer. - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) - run, err := svc.Trigger(context.Background(), "mer-1") +func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, + } + launcher := &fakeLauncher{alive: true} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Harness != domain.FallbackReviewerHarness { - t.Fatalf("harness = %q, want fallback %q", run.Harness, domain.FallbackReviewerHarness) + if !launcher.notified || launcher.spawned { + t.Fatalf("expected notify on live reviewer: %+v", launcher) + } + if launcher.gotHandle != "review-mer-1" { + t.Fatalf("notify handle = %q", launcher.gotHandle) + } + if !res.Created || res.Run.TargetSHA != "sha1" || len(store.runs) != 2 { + t.Fatalf("expected a new run for sha1: res=%+v runs=%+v", res, store.runs) } } -func TestTriggerSecondPassIncrementsIteration(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "old", Iteration: 1}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, &fakeRunner{}) - run, err := svc.Trigger(context.Background(), "mer-1") +func TestTriggerSpawnsWhenReviewerDead(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, + } + launcher := &fakeLauncher{alive: false, handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + if _, err := eng.Trigger(context.Background(), "mer-1"); err != nil { + t.Fatalf("Trigger: %v", err) + } + if !launcher.spawned || launcher.notified { + t.Fatalf("expected spawn when reviewer dead: %+v", launcher) + } +} + +func TestTriggerLaunchFailureRecordsNothing(t *testing.T) { + store := &fakeStore{} + launcher := &fakeLauncher{spawnErr: errors.New("boom")} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + if _, err := eng.Trigger(context.Background(), "mer-1"); err == nil { + t.Fatal("want launch error") + } + if len(store.runs) != 0 || store.review != nil { + t.Fatalf("nothing should be persisted on launch failure: review=%+v runs=%+v", store.review, store.runs) + } +} + +func TestTriggerUsesConfiguredReviewerHarness(t *testing.T) { + store := &fakeStore{} + projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.ReviewerHarness("greptile")}}}} + launcher := &fakeLauncher{handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), projects, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") if err != nil { t.Fatalf("Trigger: %v", err) } - if run.Iteration != 2 { - t.Fatalf("iteration = %d, want 2", run.Iteration) + if res.Run.Harness != domain.ReviewerHarness("greptile") || launcher.gotSpec.Harness != domain.ReviewerHarness("greptile") { + t.Fatalf("harness not used: run=%+v spec=%+v", res.Run, launcher.gotSpec) } } -func TestTriggerRejectsMissingWorkerPRAndState(t *testing.T) { - base := func() *fakeStore { return &fakeStore{} } +func TestTriggerRejectsBadWorkerState(t *testing.T) { t.Run("unknown worker", func(t *testing.T) { - svc := newServiceForTest(base(), fakeSessions{ok: false}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrNotFound) { + eng := newEngineForTest(&fakeStore{}, fakeSessions{ok: false}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) + if _, err := eng.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } }) - t.Run("terminated worker", func(t *testing.T) { - rec := liveWorker() - rec.IsTerminated = true - svc := newServiceForTest(base(), fakeSessions{rec: rec, ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { - t.Fatalf("err = %v, want ErrInvalid", err) - } - }) t.Run("no pr", func(t *testing.T) { - svc := newServiceForTest(base(), fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { + eng := newEngineForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeLauncher{}) + if _, err := eng.Trigger(context.Background(), "mer-1"); !errors.Is(err, ErrInvalid) { t.Fatalf("err = %v, want ErrInvalid", err) } }) } -func TestTriggerLaunchFailureMarksRunFailed(t *testing.T) { - store := &fakeStore{} - runner := &fakeRunner{store: store, requireRun: true, err: errors.New("boom")} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, - fakePRs{prs: []domain.PullRequest{{URL: "u"}}}, fakeProjects{}, runner) - if _, err := svc.Trigger(context.Background(), "mer-1"); err == nil { - t.Fatal("want launch error") - } - if len(store.runs) != 1 || store.runs[0].Status != domain.ReviewRunFailed { - t.Fatalf("run not marked failed: %+v", store.runs) - } -} - func TestSubmitRecordsVerdictAndBody(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "u", Status: domain.ReviewRunRunning}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning}}} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) - run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "please fix") + run, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "please fix") if err != nil { t.Fatalf("Submit: %v", err) } if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested || run.Body != "please fix" { t.Fatalf("run = %+v", run) } - if store.runs[0].Status != domain.ReviewRunComplete || store.runs[0].Body != "please fix" { - t.Fatalf("persisted run = %+v", store.runs[0]) - } } -func TestSubmitValidation(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Status: domain.ReviewRunRunning}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) +func TestSubmitValidationAndOwnership(t *testing.T) { + store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "other", Status: domain.ReviewRunRunning}}} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) - if _, err := svc.Submit(context.Background(), "mer-1", "run-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { - t.Fatalf("bad verdict err = %v", err) - } - if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("empty body err = %v", err) + if _, err := eng.Submit(context.Background(), "mer-1", "", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("missing run id err = %v", err) } -} - -func TestSubmitNoRun(t *testing.T) { - svc := newServiceForTest(&fakeStore{}, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) - } -} - -func TestSubmitTargetsSpecifiedRun(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{ - {ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunRunning, Iteration: 1}, - {ID: "run-2", SessionID: "mer-1", Status: domain.ReviewRunRunning, Iteration: 2}, - }} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - - run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, "") - if err != nil { - t.Fatalf("Submit: %v", err) - } - if run.ID != "run-1" || store.runs[0].Status != domain.ReviewRunComplete { - t.Fatalf("run-1 not completed: returned=%+v stored=%+v", run, store.runs[0]) + if _, err := eng.Submit(context.Background(), "mer-1", "run-1", "garbage", "b"); !errors.Is(err, ErrInvalid) { + t.Fatalf("bad verdict err = %v", err) } - if store.runs[1].Status != domain.ReviewRunRunning { - t.Fatalf("run-2 should remain running: %+v", store.runs[1]) + if _, err := eng.Submit(context.Background(), "mer-1", "missing", domain.VerdictApproved, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown run err = %v", err) } -} - -func TestSubmitRejectsNonRunningRun(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", Status: domain.ReviewRunComplete}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - - if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("err = %v, want ErrInvalid", err) + if _, err := eng.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { + t.Fatalf("ownership err = %v", err) } } -func TestSubmitRejectsRunForDifferentWorker(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", SessionID: "other-1", Status: domain.ReviewRunRunning}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - - if _, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictApproved, ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("err = %v, want ErrInvalid", err) +func TestListReturnsHandleAndRuns(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1"}}, } -} - -func TestListReturnsRuns(t *testing.T) { - store := &fakeStore{runs: []domain.ReviewRun{{ID: "run-1", Iteration: 1}}} - svc := newServiceForTest(store, fakeSessions{rec: liveWorker(), ok: true}, fakePRs{}, fakeProjects{}, &fakeRunner{}) - runs, err := svc.List(context.Background(), "mer-1") + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) + got, err := eng.List(context.Background(), "mer-1") if err != nil { t.Fatalf("List: %v", err) } - if len(runs) != 1 || runs[0].ID != "run-1" { - t.Fatalf("runs = %+v", runs) + if got.ReviewerHandleID != "review-mer-1" || len(got.Runs) != 1 { + t.Fatalf("list = %+v", got) } } diff --git a/backend/internal/review_runner/runner.go b/backend/internal/review_runner/runner.go deleted file mode 100644 index d9ef70e4..00000000 --- a/backend/internal/review_runner/runner.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package reviewrunner spawns a reviewer agent for a code review. It is kept out -// of the service layer (which stays thin and HTTP-facing) and sits beside the -// other orchestration packages such as session_manager: it owns the -// reviewer-resolver + runtime launch flow, not request handling. -package reviewrunner - -import ( - "context" - "fmt" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" -) - -// Runner spawns a reviewer over the worker's worktree, resolving the reviewer -// adapter from the reviewer registry (distinct from the worker agent set) and -// launching the command it returns on the runtime. It reuses the worker's -// worktree rather than cutting a second one: a fresh session worktree would -// branch off the project's default branch and so would not contain the worker's -// PR changes. -type Runner struct { - reviewers ports.ReviewerResolver - runtime ports.Runtime -} - -// New builds the production reviewer runner. -func New(reviewers ports.ReviewerResolver, runtime ports.Runtime) *Runner { - return &Runner{reviewers: reviewers, runtime: runtime} -} - -var _ reviewcore.Runner = (*Runner)(nil) - -// Run launches the reviewer for one review pass. -func (r *Runner) Run(ctx context.Context, spec reviewcore.RunSpec) error { - reviewer, ok := r.reviewers.Reviewer(spec.Harness) - if !ok { - return fmt.Errorf("no reviewer adapter for harness %q", spec.Harness) - } - reviewerID := "review-" + spec.RunID - cmd, err := reviewer.ReviewCommand(ctx, ports.ReviewInvocation{ - ReviewerID: reviewerID, - WorkerSessionID: spec.WorkerID, - PRURL: spec.PRURL, - WorkspacePath: spec.WorkspacePath, - }) - if err != nil { - return fmt.Errorf("reviewer command: %w", err) - } - if _, err := r.runtime.Create(ctx, ports.RuntimeConfig{ - SessionID: domain.SessionID(reviewerID), - WorkspacePath: spec.WorkspacePath, - Argv: cmd.Argv, - Env: reviewerEnv(spec, cmd.Env), - }); err != nil { - return fmt.Errorf("reviewer runtime: %w", err) - } - return nil -} - -// reviewerEnv merges the adapter's env with AO_REVIEW_WORKER and -// AO_REVIEW_RUN_ID so `ao review submit` resolves the exact run being completed. -func reviewerEnv(spec reviewcore.RunSpec, adapterEnv map[string]string) map[string]string { - env := make(map[string]string, len(adapterEnv)+2) - for k, v := range adapterEnv { - env[k] = v - } - env["AO_REVIEW_WORKER"] = string(spec.WorkerID) - env["AO_REVIEW_RUN_ID"] = spec.RunID - return env -} diff --git a/backend/internal/review_runner/runner_test.go b/backend/internal/review_runner/runner_test.go deleted file mode 100644 index 672593f1..00000000 --- a/backend/internal/review_runner/runner_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package reviewrunner - -import ( - "context" - "strings" - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" -) - -type fakeReviewer struct { - gotInv ports.ReviewInvocation - spec ports.ReviewCommandSpec - err error -} - -func (f *fakeReviewer) ReviewCommand(_ context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { - f.gotInv = inv - return f.spec, f.err -} - -type fakeResolver struct { - reviewer ports.Reviewer - ok bool -} - -func (f fakeResolver) Reviewer(domain.ReviewerHarness) (ports.Reviewer, bool) { - return f.reviewer, f.ok -} - -type fakeRuntime struct { - cfg ports.RuntimeConfig - created bool -} - -func (f *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) { - f.created = true - f.cfg = cfg - return ports.RuntimeHandle{ID: "h1"}, nil -} -func (f *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { return nil } -func (f *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) { return true, nil } - -func TestRunLaunchesResolvedReviewer(t *testing.T) { - reviewer := &fakeReviewer{spec: ports.ReviewCommandSpec{ - Argv: []string{"greptile", "review"}, - Env: map[string]string{"GREPTILE_MODE": "ci"}, - }} - rt := &fakeRuntime{} - r := New(fakeResolver{reviewer: reviewer, ok: true}, rt) - - err := r.Run(context.Background(), reviewcore.RunSpec{ - RunID: "run-1", - WorkerID: "mer-1", - Harness: domain.ReviewerHarness("greptile"), - WorkspacePath: "/ws/mer-1", - PRURL: "https://github.com/o/r/pull/1", - }) - if err != nil { - t.Fatalf("Run: %v", err) - } - // The reviewer adapter receives the invocation (PR + worktree + reviewer id). - if reviewer.gotInv.PRURL != "https://github.com/o/r/pull/1" || reviewer.gotInv.WorkspacePath != "/ws/mer-1" || reviewer.gotInv.ReviewerID != "review-run-1" { - t.Fatalf("invocation = %+v", reviewer.gotInv) - } - // The runtime launches the adapter's argv over the worker's worktree. - if !rt.created || rt.cfg.WorkspacePath != "/ws/mer-1" || rt.cfg.Argv[0] != "greptile" { - t.Fatalf("runtime cfg = %+v created=%v", rt.cfg, rt.created) - } - if rt.cfg.SessionID != "review-run-1" { - t.Fatalf("runtime session id = %q, want review-run-1", rt.cfg.SessionID) - } - // AO_REVIEW_WORKER and AO_REVIEW_RUN_ID are added; adapter env is preserved. - if rt.cfg.Env["AO_REVIEW_WORKER"] != "mer-1" || rt.cfg.Env["AO_REVIEW_RUN_ID"] != "run-1" || rt.cfg.Env["GREPTILE_MODE"] != "ci" { - t.Fatalf("env = %v", rt.cfg.Env) - } -} - -func TestRunErrorsWhenNoReviewerAdapter(t *testing.T) { - r := New(fakeResolver{ok: false}, &fakeRuntime{}) - err := r.Run(context.Background(), reviewcore.RunSpec{Harness: "nope", WorkspacePath: "/ws"}) - if err == nil || !strings.Contains(err.Error(), "no reviewer adapter") { - t.Fatalf("err = %v, want no-adapter error", err) - } -} diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index db2112ba..1bfc8e3f 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -20,9 +20,9 @@ var ( // Manager is the reviews surface the HTTP controller depends on. type Manager interface { - Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) + Trigger(ctx context.Context, workerID domain.SessionID) (reviewcore.TriggerResult, error) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body string) (domain.ReviewRun, error) - List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) + List(ctx context.Context, workerID domain.SessionID) (reviewcore.SessionReviews, error) } // Service is the API-facing review service. It delegates to the core engine. @@ -37,8 +37,8 @@ func New(engine *reviewcore.Engine) *Service { return &Service{engine: engine} } -// Trigger starts a review pass for a worker's PR. -func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (domain.ReviewRun, error) { +// Trigger starts (or reuses) a review pass for a worker's PR. +func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (reviewcore.TriggerResult, error) { return s.engine.Trigger(ctx, workerID) } @@ -47,7 +47,7 @@ func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID s return s.engine.Submit(ctx, workerID, runID, verdict, body) } -// List returns the review passes recorded for a worker. -func (s *Service) List(ctx context.Context, workerID domain.SessionID) ([]domain.ReviewRun, error) { +// List returns a worker's review state. +func (s *Service) List(ctx context.Context, workerID domain.SessionID) (reviewcore.SessionReviews, error) { return s.engine.List(ctx, workerID) } diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 14050db5..57b4f285 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -112,13 +112,14 @@ type Project struct { } type Review struct { - ID string - SessionID domain.SessionID - ProjectID domain.ProjectID - Harness domain.ReviewerHarness - PRURL string - CreatedAt time.Time - UpdatedAt time.Time + ID string + SessionID domain.SessionID + ProjectID domain.ProjectID + Harness domain.ReviewerHarness + PRURL string + ReviewerHandleID string + CreatedAt time.Time + UpdatedAt time.Time } type ReviewRun struct { @@ -127,9 +128,9 @@ type ReviewRun struct { SessionID domain.SessionID Harness domain.ReviewerHarness PRURL string + TargetSha string Status domain.ReviewRunStatus Verdict domain.ReviewVerdict - Iteration int64 Body string CreatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index 3103a802..c075e2b5 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -12,56 +12,62 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -const getLatestReviewRunBySession = `-- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at -FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1 +const getReviewBySession = `-- name: GetReviewBySession :one +SELECT id, session_id, project_id, harness, pr_url, reviewer_handle_id, created_at, updated_at +FROM review WHERE session_id = ? ` -func (q *Queries) GetLatestReviewRunBySession(ctx context.Context, sessionID domain.SessionID) (ReviewRun, error) { - row := q.db.QueryRowContext(ctx, getLatestReviewRunBySession, sessionID) - var i ReviewRun +func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.SessionID) (Review, error) { + row := q.db.QueryRowContext(ctx, getReviewBySession, sessionID) + var i Review err := row.Scan( &i.ID, - &i.ReviewID, &i.SessionID, + &i.ProjectID, &i.Harness, &i.PRURL, - &i.Status, - &i.Verdict, - &i.Iteration, - &i.Body, + &i.ReviewerHandleID, &i.CreatedAt, + &i.UpdatedAt, ) return i, err } -const getReviewBySession = `-- name: GetReviewBySession :one -SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at -FROM review WHERE session_id = ? +const getReviewRun = `-- name: GetReviewRun :one +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at +FROM review_run WHERE id = ? ` -func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.SessionID) (Review, error) { - row := q.db.QueryRowContext(ctx, getReviewBySession, sessionID) - var i Review +func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error) { + row := q.db.QueryRowContext(ctx, getReviewRun, id) + var i ReviewRun err := row.Scan( &i.ID, + &i.ReviewID, &i.SessionID, - &i.ProjectID, &i.Harness, &i.PRURL, + &i.TargetSha, + &i.Status, + &i.Verdict, + &i.Body, &i.CreatedAt, - &i.UpdatedAt, ) return i, err } -const getReviewRun = `-- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at -FROM review_run WHERE id = ? +const getReviewRunBySessionAndSHA = `-- name: GetReviewRunBySessionAndSHA :one +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at +FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1 ` -func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error) { - row := q.db.QueryRowContext(ctx, getReviewRun, id) +type GetReviewRunBySessionAndSHAParams struct { + SessionID domain.SessionID + TargetSha string +} + +func (q *Queries) GetReviewRunBySessionAndSHA(ctx context.Context, arg GetReviewRunBySessionAndSHAParams) (ReviewRun, error) { + row := q.db.QueryRowContext(ctx, getReviewRunBySessionAndSHA, arg.SessionID, arg.TargetSha) var i ReviewRun err := row.Scan( &i.ID, @@ -69,9 +75,9 @@ func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error &i.SessionID, &i.Harness, &i.PRURL, + &i.TargetSha, &i.Status, &i.Verdict, - &i.Iteration, &i.Body, &i.CreatedAt, ) @@ -79,7 +85,7 @@ func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error } const insertReviewRun = `-- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` @@ -89,9 +95,9 @@ type InsertReviewRunParams struct { SessionID domain.SessionID Harness domain.ReviewerHarness PRURL string + TargetSha string Status domain.ReviewRunStatus Verdict domain.ReviewVerdict - Iteration int64 Body string CreatedAt time.Time } @@ -103,9 +109,9 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams arg.SessionID, arg.Harness, arg.PRURL, + arg.TargetSha, arg.Status, arg.Verdict, - arg.Iteration, arg.Body, arg.CreatedAt, ) @@ -113,8 +119,8 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams } const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at -FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at +FROM review_run WHERE session_id = ? ORDER BY created_at DESC ` func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain.SessionID) ([]ReviewRun, error) { @@ -132,9 +138,9 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. &i.SessionID, &i.Harness, &i.PRURL, + &i.TargetSha, &i.Status, &i.Verdict, - &i.Iteration, &i.Body, &i.CreatedAt, ); err != nil { @@ -176,22 +182,24 @@ func (q *Queries) UpdateReviewRunResult(ctx context.Context, arg UpdateReviewRun } const upsertReview = `-- name: UpsertReview :exec -INSERT INTO review (id, session_id, project_id, harness, pr_url, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO review (id, session_id, project_id, harness, pr_url, reviewer_handle_id, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (session_id) DO UPDATE SET harness = excluded.harness, pr_url = excluded.pr_url, + reviewer_handle_id = excluded.reviewer_handle_id, updated_at = excluded.updated_at ` type UpsertReviewParams struct { - ID string - SessionID domain.SessionID - ProjectID domain.ProjectID - Harness domain.ReviewerHarness - PRURL string - CreatedAt time.Time - UpdatedAt time.Time + ID string + SessionID domain.SessionID + ProjectID domain.ProjectID + Harness domain.ReviewerHarness + PRURL string + ReviewerHandleID string + CreatedAt time.Time + UpdatedAt time.Time } func (q *Queries) UpsertReview(ctx context.Context, arg UpsertReviewParams) error { @@ -201,6 +209,7 @@ func (q *Queries) UpsertReview(ctx context.Context, arg UpsertReviewParams) erro arg.ProjectID, arg.Harness, arg.PRURL, + arg.ReviewerHandleID, arg.CreatedAt, arg.UpdatedAt, ) diff --git a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql index 0ae3047f..88419a3a 100644 --- a/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql +++ b/backend/internal/storage/sqlite/migrations/0011_add_review_tables.sql @@ -6,13 +6,16 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE review ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL UNIQUE REFERENCES sessions (id) ON DELETE CASCADE, - project_id TEXT NOT NULL REFERENCES projects (id), - harness TEXT NOT NULL, - pr_url TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE REFERENCES sessions (id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects (id), + harness TEXT NOT NULL, + pr_url TEXT NOT NULL DEFAULT '', + -- runtime handle id of the live reviewer pane, reused across passes and + -- exposed so the UI can attach its terminal over /mux. + reviewer_handle_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); -- +goose StatementEnd @@ -23,9 +26,11 @@ CREATE TABLE review_run ( session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, harness TEXT NOT NULL, pr_url TEXT NOT NULL DEFAULT '', + -- the commit the pass reviewed; lets a repeat trigger for the same head + -- short-circuit to the existing run. + target_sha TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running', verdict TEXT NOT NULL DEFAULT '', - iteration INTEGER NOT NULL DEFAULT 1, body TEXT NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL ); diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index 0ae1be99..1151c946 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -1,30 +1,31 @@ -- name: UpsertReview :exec -INSERT INTO review (id, session_id, project_id, harness, pr_url, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO review (id, session_id, project_id, harness, pr_url, reviewer_handle_id, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (session_id) DO UPDATE SET harness = excluded.harness, pr_url = excluded.pr_url, + reviewer_handle_id = excluded.reviewer_handle_id, updated_at = excluded.updated_at; -- name: GetReviewBySession :one -SELECT id, session_id, project_id, harness, pr_url, created_at, updated_at +SELECT id, session_id, project_id, harness, pr_url, reviewer_handle_id, created_at, updated_at FROM review WHERE session_id = ?; -- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at) +INSERT INTO review_run (id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateReviewRunResult :execrows UPDATE review_run SET status = ?, verdict = ?, body = ? WHERE id = ? AND status = 'running'; -- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at FROM review_run WHERE id = ?; --- name: GetLatestReviewRunBySession :one -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at -FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC LIMIT 1; +-- name: GetReviewRunBySessionAndSHA :one +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at +FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1; -- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, status, verdict, iteration, body, created_at -FROM review_run WHERE session_id = ? ORDER BY iteration DESC, created_at DESC; +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at +FROM review_run WHERE session_id = ? ORDER BY created_at DESC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index a55aa3f9..36dfc9c7 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -16,13 +16,14 @@ func (s *Store) UpsertReview(ctx context.Context, r domain.Review) error { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.UpsertReview(ctx, gen.UpsertReviewParams{ - ID: r.ID, - SessionID: r.SessionID, - ProjectID: r.ProjectID, - Harness: r.Harness, - PRURL: r.PRURL, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.ID, + SessionID: r.SessionID, + ProjectID: r.ProjectID, + Harness: r.Harness, + PRURL: r.PRURL, + ReviewerHandleID: r.ReviewerHandleID, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, }) } @@ -48,9 +49,9 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { SessionID: r.SessionID, Harness: r.Harness, PRURL: r.PRURL, + TargetSha: r.TargetSHA, Status: r.Status, Verdict: r.Verdict, - Iteration: int64(r.Iteration), Body: r.Body, CreatedAt: r.CreatedAt, }) @@ -84,15 +85,16 @@ func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, return reviewRunFromRow(row), true, nil } -// GetLatestReviewRunBySession returns the most recent review pass for a worker -// session, ok=false if none. -func (s *Store) GetLatestReviewRunBySession(ctx context.Context, id domain.SessionID) (domain.ReviewRun, bool, error) { - row, err := s.qr.GetLatestReviewRunBySession(ctx, id) +// GetReviewRunBySessionAndSHA returns the most recent review pass for a worker +// session at a specific commit, ok=false if none. It lets a repeat trigger for +// the same PR head short-circuit to the existing run. +func (s *Store) GetReviewRunBySessionAndSHA(ctx context.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) { + row, err := s.qr.GetReviewRunBySessionAndSHA(ctx, gen.GetReviewRunBySessionAndSHAParams{SessionID: id, TargetSha: targetSHA}) if errors.Is(err, sql.ErrNoRows) { return domain.ReviewRun{}, false, nil } if err != nil { - return domain.ReviewRun{}, false, fmt.Errorf("get latest review run for session %s: %w", id, err) + return domain.ReviewRun{}, false, fmt.Errorf("get review run for session %s sha %s: %w", id, targetSHA, err) } return reviewRunFromRow(row), true, nil } @@ -112,13 +114,14 @@ func (s *Store) ListReviewRunsBySession(ctx context.Context, id domain.SessionID func reviewFromRow(r gen.Review) domain.Review { return domain.Review{ - ID: r.ID, - SessionID: r.SessionID, - ProjectID: r.ProjectID, - Harness: r.Harness, - PRURL: r.PRURL, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.ID, + SessionID: r.SessionID, + ProjectID: r.ProjectID, + Harness: r.Harness, + PRURL: r.PRURL, + ReviewerHandleID: r.ReviewerHandleID, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, } } @@ -129,9 +132,9 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { SessionID: r.SessionID, Harness: r.Harness, PRURL: r.PRURL, + TargetSHA: r.TargetSha, Status: r.Status, Verdict: r.Verdict, - Iteration: int(r.Iteration), Body: r.Body, CreatedAt: r.CreatedAt, } diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index 4216a1dc..e6643ac4 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -22,16 +22,18 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if err := s.UpsertReview(ctx, domain.Review{ ID: "rev-1", SessionID: rec.ID, ProjectID: rec.ProjectID, Harness: domain.ReviewerClaudeCode, PRURL: "https://example/pr/1", - CreatedAt: now, UpdatedAt: now, + ReviewerHandleID: "review-mer-1", + CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("upsert review: %v", err) } // Second upsert with the same session reuses the row (session_id UNIQUE), - // refreshing harness/pr_url but keeping the original id. + // refreshing harness/pr_url/reviewer_handle_id but keeping the original id. if err := s.UpsertReview(ctx, domain.Review{ ID: "rev-2", SessionID: rec.ID, ProjectID: rec.ProjectID, Harness: domain.ReviewerHarness("greptile"), PRURL: "https://example/pr/2", - CreatedAt: now, UpdatedAt: now.Add(time.Second), + ReviewerHandleID: "review-mer-1b", + CreatedAt: now, UpdatedAt: now.Add(time.Second), }); err != nil { t.Fatalf("upsert review (reuse): %v", err) } @@ -42,15 +44,15 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if got.ID != "rev-1" { t.Fatalf("upsert created a new row, want reuse: id=%q", got.ID) } - if got.Harness != domain.ReviewerHarness("greptile") || got.PRURL != "https://example/pr/2" { + if got.Harness != domain.ReviewerHarness("greptile") || got.PRURL != "https://example/pr/2" || got.ReviewerHandleID != "review-mer-1b" { t.Fatalf("upsert did not refresh fields: %+v", got) } // A run inserts running and updates to complete/changes_requested. if err := s.InsertReviewRun(ctx, domain.ReviewRun{ ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.ReviewerHarness("greptile"), - PRURL: got.PRURL, Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, - Iteration: 1, CreatedAt: now, + PRURL: got.PRURL, TargetSHA: "sha1", Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, + CreatedAt: now, }); err != nil { t.Fatalf("insert run: %v", err) } @@ -64,16 +66,19 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if err != nil || !ok { t.Fatalf("get run: ok=%v err=%v", ok, err) } - if gotRun.ID != "run-1" || gotRun.SessionID != rec.ID { + if gotRun.ID != "run-1" || gotRun.SessionID != rec.ID || gotRun.TargetSHA != "sha1" { t.Fatalf("get run = %+v", gotRun) } - latest, ok, err := s.GetLatestReviewRunBySession(ctx, rec.ID) + bySHA, ok, err := s.GetReviewRunBySessionAndSHA(ctx, rec.ID, "sha1") if err != nil || !ok { - t.Fatalf("latest run: ok=%v err=%v", ok, err) + t.Fatalf("by sha: ok=%v err=%v", ok, err) } - if latest.Status != domain.ReviewRunComplete || latest.Verdict != domain.VerdictChangesRequested || latest.Body != "please fix" { - t.Fatalf("run result not persisted: %+v", latest) + if bySHA.Status != domain.ReviewRunComplete || bySHA.Verdict != domain.VerdictChangesRequested || bySHA.Body != "please fix" { + t.Fatalf("run result not persisted: %+v", bySHA) + } + if _, ok, _ := s.GetReviewRunBySessionAndSHA(ctx, rec.ID, "other"); ok { + t.Fatal("unexpected run for a different sha") } runs, err := s.ListReviewRunsBySession(ctx, rec.ID) @@ -97,7 +102,7 @@ func TestReviewGettersMissing(t *testing.T) { if _, ok, err := s.GetReviewBySession(ctx, "mer-1"); err != nil || ok { t.Fatalf("missing review: ok=%v err=%v", ok, err) } - if _, ok, err := s.GetLatestReviewRunBySession(ctx, "mer-1"); err != nil || ok { + if _, ok, err := s.GetReviewRunBySessionAndSHA(ctx, "mer-1", "sha1"); err != nil || ok { t.Fatalf("missing run: ok=%v err=%v", ok, err) } if _, ok, err := s.GetReviewRun(ctx, "run-missing"); err != nil || ok { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 6995f847..17dc2e75 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -434,6 +434,7 @@ export interface components { projects: components["schemas"]["ProjectSummary"][]; }; ListReviewsResponse: { + reviewerHandleId: string; reviews: components["schemas"]["ReviewRun"][]; }; ListSessionPRsResponse: { @@ -521,15 +522,16 @@ export interface components { createdAt: string; harness: string; id: string; - iteration: number; prUrl: string; reviewId: string; sessionId: string; status: string; + targetSha: string; verdict: string; }; ReviewRunResponse: { review: components["schemas"]["ReviewRun"]; + reviewerHandleId: string; }; RoleOverride: { agent?: string; @@ -1778,6 +1780,15 @@ export interface operations { }; requestBody?: never; responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewRunResponse"]; + }; + }; /** @description Created */ 201: { headers: {