Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cloud/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ func New(options Options) *Server {
}
router.Get("/sessions/{sessionId}/workspace/files", server.listWorkspaceFiles)
router.Get("/sessions/{sessionId}/workspace/file", server.readWorkspaceFile)
router.Get("/sessions/{sessionId}/workspace/file/diff", server.readWorkspaceDiffFile)
router.Put("/sessions/{sessionId}/workspace/file", server.writeWorkspaceFile)
router.Get("/sessions/{sessionId}/workspace/diff", server.getWorkspaceDiff)
router.Get("/sessions/{sessionId}/pull-requests", server.listSessionPullRequests)
Expand Down
89 changes: 89 additions & 0 deletions cloud/internal/httpapi/workspace_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/aoagents/agent-orchestrator/cloud/internal/domain"
"github.com/aoagents/agent-orchestrator/cloud/internal/postgres"
"github.com/aoagents/agent-orchestrator/cloud/internal/sandbox"
"github.com/aoagents/agent-orchestrator/cloud/internal/worker"
"github.com/go-chi/chi/v5"
)
Expand Down Expand Up @@ -80,6 +81,52 @@ func (s *Server) readWorkspaceFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, file)
}

// readWorkspaceDiffFile exposes the shared cloud worker's per-file review model.
func (s *Server) readWorkspaceDiffFile(w http.ResponseWriter, r *http.Request) {
orgID, sessionID, ok := workspaceRoute(w, r)
if !ok {
return
}
path := r.URL.Query().Get("path")
category := r.URL.Query().Get("category")
if strings.TrimSpace(path) == "" || len(path) > maxWorkspacePath {
writeError(w, r, http.StatusBadRequest, "invalid_request", "A valid workspace-relative path is required.")
return
}
if !validWorkspaceDiffCategory(category) {
writeError(w, r, http.StatusBadRequest, "invalid_request", "A valid workspace diff category is required.")
return
}
principal := principalFrom(r)
session, err := s.store.GetSession(r.Context(), principal, orgID, sessionID)
if err != nil {
s.logger.Warn("workspace diff-file request rejected", "org_id", orgID, "session_id", sessionID, "path", path, "error", err)
s.writeStoreError(w, r, err)
return
}
if !supportsWorkspaceDiff(session.SandboxProvider) {
s.logger.Warn("workspace diff-file request unsupported", "org_id", orgID, "session_id", sessionID, "path", path, "provider", session.SandboxProvider)
writeError(w, r, http.StatusNotImplemented, "WORKSPACE_DIFF_FILE_UNSUPPORTED", "Per-file diffs are unavailable for this cloud sandbox provider.")
return
}

s.logger.Debug("workspace diff-file request started", "org_id", orgID, "session_id", sessionID, "path", path, "provider", session.SandboxProvider)
payload, _ := json.Marshal(worker.WorkspaceDiffFileRequest{Path: path, Category: category})
result, ok := s.runWorkspaceRequest(w, r, orgID, sessionID, "workspace.diff-file", payload)
if !ok {
s.logger.Info("workspace diff-file request failed", "org_id", orgID, "session_id", sessionID, "path", path, "provider", session.SandboxProvider)
return
}
var file worker.WorkspaceDiffFile
if err := json.Unmarshal(result, &file); err != nil {
s.logger.Warn("workspace diff-file worker response invalid", "org_id", orgID, "session_id", sessionID, "path", path, "error", err)
writeError(w, r, http.StatusBadGateway, "INVALID_WORKER_RESPONSE", "The worker returned an invalid workspace diff file.")
return
}
s.logger.Info("workspace diff-file request completed", "org_id", orgID, "session_id", sessionID, "path", file.Path, "provider", session.SandboxProvider, "size", file.Size, "binary", file.Binary, "deleted", file.Deleted, "diff_truncated", file.DiffTruncated)
writeJSON(w, http.StatusOK, file)
}

func (s *Server) writeWorkspaceFile(w http.ResponseWriter, r *http.Request) {
orgID, sessionID, ok := workspaceRoute(w, r)
if !ok {
Expand Down Expand Up @@ -120,20 +167,62 @@ func (s *Server) getWorkspaceDiff(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
principal := principalFrom(r)
session, err := s.store.GetSession(r.Context(), principal, orgID, sessionID)
if err != nil {
s.logger.Warn("workspace diff request rejected", "org_id", orgID, "session_id", sessionID, "error", err)
s.writeStoreError(w, r, err)
return
}
if !supportsWorkspaceDiff(session.SandboxProvider) {
s.logger.Warn("workspace diff request unsupported", "org_id", orgID, "session_id", sessionID, "provider", session.SandboxProvider)
writeError(w, r, http.StatusNotImplemented, "WORKSPACE_DIFF_UNSUPPORTED", "Workspace diffs are unavailable for this cloud sandbox provider.")
return
}
s.logger.Debug("workspace diff request started", "org_id", orgID, "session_id", sessionID, "provider", session.SandboxProvider)
result, ok := s.runWorkspaceRequest(
w, r, orgID, sessionID, "workspace.diff", json.RawMessage(`{}`),
)
if !ok {
s.logger.Info("workspace diff request failed", "org_id", orgID, "session_id", sessionID, "provider", session.SandboxProvider)
return
}
var value map[string]any
if json.Unmarshal(result, &value) != nil {
s.logger.Warn("workspace diff worker response invalid", "org_id", orgID, "session_id", sessionID, "provider", session.SandboxProvider)
writeError(w, r, http.StatusBadGateway, "INVALID_WORKER_RESPONSE", "The worker returned an invalid workspace diff.")
return
}
fileCount := 0
if files, ok := value["files"].([]any); ok {
fileCount = len(files)
}
s.logger.Info("workspace diff request completed", "org_id", orgID, "session_id", sessionID, "provider", session.SandboxProvider, "file_count", fileCount)
writeJSON(w, http.StatusOK, value)
}

// supportsWorkspaceDiff is intentionally explicit: all supported providers
// bootstrap the same ao-worker and supply it an isolated repository through
// AO_WORKSPACE_DIR. Unknown providers must opt in before they can dispatch
// workspace review requests.
func supportsWorkspaceDiff(provider string) bool {
switch provider {
case sandbox.ProviderDocker, sandbox.ProviderNodeOps, sandbox.ProviderCoder:
return true
default:
return false
}
}

func validWorkspaceDiffCategory(category string) bool {
switch category {
case "", "uncommitted", "unpushed", "pushed":
return true
default:
return false
}
}

func workspaceRoute(w http.ResponseWriter, r *http.Request) (string, string, bool) {
orgID := chi.URLParam(r, "orgId")
sessionID := chi.URLParam(r, "sessionId")
Expand Down
132 changes: 132 additions & 0 deletions cloud/internal/httpapi/workspace_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package httpapi

import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/aoagents/agent-orchestrator/cloud/internal/domain"
"github.com/aoagents/agent-orchestrator/cloud/internal/sandbox"
"github.com/aoagents/agent-orchestrator/cloud/internal/worker"
"github.com/go-chi/chi/v5"
)

const (
workspaceTestOrgID = "00000000-0000-4000-8000-000000000001"
workspaceTestSessionID = "00000000-0000-4000-8000-000000000002"
)

type workspaceHandlerStore struct {
Store
provider string
createdKind string
createdPayload json.RawMessage
created bool
}

func (s *workspaceHandlerStore) GetSession(_ context.Context, _ domain.Principal, _, _ string) (domain.Session, error) {
return domain.Session{SandboxProvider: s.provider}, nil
}

func (s *workspaceHandlerStore) ResumeSession(_ context.Context, _ domain.Principal, _, sessionID string) (domain.SandboxLifecycle, error) {
return domain.SandboxLifecycle{SessionID: sessionID}, nil
}

func (s *workspaceHandlerStore) CreateWorkspaceRequest(_ context.Context, _ domain.Principal, orgID, sessionID, kind string, payload json.RawMessage, _ time.Duration) (domain.WorkerRequest, error) {
s.created, s.createdKind, s.createdPayload = true, kind, payload
return domain.WorkerRequest{ID: "00000000-0000-4000-8000-000000000003", OrgID: orgID, SessionID: sessionID}, nil
}

func (s *workspaceHandlerStore) GetWorkspaceRequest(_ context.Context, _ domain.Principal, _, _, _ string) (domain.WorkerRequest, error) {
response, _ := json.Marshal(worker.WorkspaceDiffFile{
Path: "notes.txt", Status: "untracked", Content: "hello\n",
Diff: "diff --git a/notes.txt b/notes.txt\n", DiffTruncated: false,
})
return domain.WorkerRequest{Status: "succeeded", Response: response}, nil
}

func TestWorkspaceDiffDispatchesForSupportedProviders(t *testing.T) {
providers := []string{sandbox.ProviderDocker, sandbox.ProviderNodeOps, sandbox.ProviderCoder}
for _, provider := range providers {
t.Run(provider, func(t *testing.T) {
store := &workspaceHandlerStore{provider: provider}
server := workspaceHandlerServer(store)
recorder := httptest.NewRecorder()
server.readWorkspaceDiffFile(recorder, workspaceHandlerRequest(t, "notes.txt", "unpushed"))
if recorder.Code != http.StatusOK {
t.Fatalf("diff-file status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if !store.created || store.createdKind != "workspace.diff-file" {
t.Fatalf("created request = %t %q", store.created, store.createdKind)
}
var payload worker.WorkspaceDiffFileRequest
if err := json.Unmarshal(store.createdPayload, &payload); err != nil || payload.Path != "notes.txt" || payload.Category != "unpushed" {
t.Fatalf("transport payload = %#v, err=%v", payload, err)
}

store.created = false
recorder = httptest.NewRecorder()
server.getWorkspaceDiff(recorder, workspaceHandlerRequest(t, "", ""))
if recorder.Code != http.StatusOK {
t.Fatalf("diff status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if !store.created || store.createdKind != "workspace.diff" {
t.Fatalf("created request = %t %q", store.created, store.createdKind)
}
})
}
}

func TestWorkspaceDiffRejectsUnknownProviderWithoutDispatch(t *testing.T) {
store := &workspaceHandlerStore{provider: "unknown"}
server := workspaceHandlerServer(store)
recorder := httptest.NewRecorder()
server.readWorkspaceDiffFile(recorder, workspaceHandlerRequest(t, "notes.txt", ""))
if recorder.Code != http.StatusNotImplemented {
t.Fatalf("diff-file status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if store.created {
t.Fatal("unknown provider dispatched a diff-file request")
}
}

func TestReadWorkspaceDiffFileRejectsUnknownCategoryWithoutDispatch(t *testing.T) {
store := &workspaceHandlerStore{provider: sandbox.ProviderDocker}
server := workspaceHandlerServer(store)
recorder := httptest.NewRecorder()
server.readWorkspaceDiffFile(recorder, workspaceHandlerRequest(t, "notes.txt", "unexpected"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("diff-file status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if store.created {
t.Fatal("invalid category dispatched a diff-file request")
}
}

func workspaceHandlerServer(store Store) *Server {
return &Server{
store: store,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
workerRequestTimeout: time.Second,
}
}

func workspaceHandlerRequest(t *testing.T, path, category string) *http.Request {
t.Helper()
query := url.Values{"path": []string{path}}
if category != "" {
query.Set("category", category)
}
request := httptest.NewRequest(http.MethodGet, "/workspace/file/diff?"+query.Encode(), nil)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("orgId", workspaceTestOrgID)
routeContext.URLParams.Add("sessionId", workspaceTestSessionID)
request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
return request.WithContext(context.WithValue(request.Context(), principalKey, domain.Principal{UserID: "user-1"}))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Docker-backed per-file diff reads use a distinct durable transport kind so
-- NodeOps and Coder workers cannot accidentally receive an unsupported request.
-- +goose Up
ALTER TABLE ao_worker_requests
DROP CONSTRAINT ao_worker_requests_kind_check;
ALTER TABLE ao_worker_requests
ADD CONSTRAINT ao_worker_requests_kind_check
CHECK (kind IN (
'workspace.list', 'workspace.read', 'workspace.write', 'workspace.diff',
'workspace.diff-file',
'terminal.open', 'terminal.input', 'terminal.resize', 'terminal.close',
'browser.fetch'
));

-- +goose Down
DELETE FROM ao_worker_requests WHERE kind = 'workspace.diff-file';
ALTER TABLE ao_worker_requests
DROP CONSTRAINT ao_worker_requests_kind_check;
ALTER TABLE ao_worker_requests
ADD CONSTRAINT ao_worker_requests_kind_check
CHECK (kind IN (
'workspace.list', 'workspace.read', 'workspace.write', 'workspace.diff',
'terminal.open', 'terminal.input', 'terminal.resize', 'terminal.close',
'browser.fetch'
));
27 changes: 27 additions & 0 deletions cloud/internal/worker/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,15 @@ type WorkspaceReadRequest struct {
Path string `json:"path"`
}

// WorkspaceDiffFileRequest asks the worker for one file's current text and
// its bounded unified patch against the requested comparison category. It is
// deliberately distinct from WorkspaceReadRequest so it cannot be mistaken
// for an ordinary file read.
type WorkspaceDiffFileRequest struct {
Path string `json:"path"`
Category string `json:"category,omitempty"`
}

type WorkspaceWriteRequest struct {
Path string `json:"path"`
Content string `json:"content"`
Expand Down Expand Up @@ -259,6 +268,24 @@ type WorkspaceFile struct {
Size int64 `json:"size"`
}

// WorkspaceDiffFile is the shared cloud worker's per-file review model. It
// mirrors the local daemon's useful file-review facts without exposing host
// paths or provider implementation details.
type WorkspaceDiffFile struct {
Path string `json:"path"`
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Size int64 `json:"size"`
Binary bool `json:"binary"`
Deleted bool `json:"deleted"`
Content string `json:"content"`
BaseContent string `json:"baseContent"`
ContentTruncated bool `json:"contentTruncated"`
Diff string `json:"diff"`
DiffTruncated bool `json:"diffTruncated"`
}

type TerminalCommand struct {
TerminalID string `json:"terminalId"`
Kind string `json:"kind,omitempty"`
Expand Down
14 changes: 14 additions & 0 deletions cloud/internal/workertransport/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,20 @@ func (s *Supervisor) handle(
if err == nil {
response, err = workspace.Read(input)
}
case "workspace.diff-file":
var input worker.WorkspaceDiffFileRequest
err = decodePayload(request.Payload, &input)
if err == nil {
s.Logger.Debug("workspace diff-file request started", "request_id", request.ID, "path", input.Path)
response, err = workspace.DiffFile(ctx, input)
if err != nil {
failureCode, _ := transportError(err)
s.Logger.Warn("workspace diff-file request failed", "request_id", request.ID, "path", input.Path, "failure_code", failureCode)
} else {
file := response.(worker.WorkspaceDiffFile)
s.Logger.Debug("workspace diff-file request completed", "request_id", request.ID, "path", file.Path, "size", file.Size, "binary", file.Binary, "deleted", file.Deleted, "diff_truncated", file.DiffTruncated)
}
}
case "workspace.write":
var input worker.WorkspaceWriteRequest
err = decodePayload(request.Payload, &input)
Expand Down
Loading
Loading