Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
99fe60d
Add design spec for Unix socket transport support
cpcloud Mar 18, 2026
602ae98
Address spec review findings: complete CLI/TUI coverage, fix probe paths
cpcloud Mar 18, 2026
be7f0a6
Fix minor spec review findings: add refine.go, clarify FindAvailableP…
cpcloud Mar 18, 2026
466032e
Move Unix socket transport spec to docs/plans/
cpcloud Mar 18, 2026
f79d1f9
Gitignore .claude/settings.local.json and remove from tracking
cpcloud Mar 18, 2026
e44e85d
Add Unix socket transport implementation plan
cpcloud Mar 18, 2026
b63b264
Add DaemonEndpoint type with ParseEndpoint, Listener, HTTPClient
cpcloud Mar 18, 2026
6b8d2ac
Add Network field to RuntimeInfo, Endpoint() method, update WriteRunt…
cpcloud Mar 18, 2026
3610e45
Update probe, kill, and alive-check functions to use DaemonEndpoint
cpcloud Mar 18, 2026
1b351bc
Wire DaemonEndpoint through Server.Start(), add Unix socket setup and…
cpcloud Mar 18, 2026
55bad9b
Update daemon HTTPClient to use DaemonEndpoint, rename addr to baseURL
cpcloud Mar 18, 2026
d33b5a4
Migrate all CLI command files to use DaemonEndpoint
cpcloud Mar 18, 2026
b3ed47e
Update TUI to use DaemonEndpoint for transport-agnostic daemon commun…
cpcloud Mar 18, 2026
e05aaee
Fix 3 failing fix tests for DaemonEndpoint migration
cpcloud Mar 18, 2026
da904b7
Remove dead code: validateDaemonBindAddr
cpcloud Mar 18, 2026
f59c5b4
Address CI review findings: endpoint consistency, socket perms, FD leaks
cpcloud Mar 18, 2026
ea7129f
Fix lint: errcheck, CutPrefix, testifylint assertions
cpcloud Mar 18, 2026
09dc243
Fix review findings: symlink-safe dir check, waitForPromptJob endpoint
cpcloud Mar 18, 2026
39aa801
Fix analyze.go endpoint split: parse serverAddr locally for waitForPr…
cpcloud Mar 18, 2026
f57f175
Fix analyze.go: check ParseEndpoint errors, hoist parse above loop
cpcloud Mar 18, 2026
a6af201
Thread DaemonEndpoint through analyze functions, validate --server fl…
cpcloud Mar 18, 2026
69704de
Fix doFixDaemonRequest to use endpoint-aware HTTP client
cpcloud Mar 18, 2026
aa4380b
Explicitly set Proxy: nil on Unix socket transport, add proxy bypass …
cpcloud Mar 18, 2026
d3b52c9
Fix registerRepo split endpoint pattern
cpcloud Mar 18, 2026
ae706a1
Skip Unix socket tests on Windows
cpcloud Mar 18, 2026
c64cd67
Fix data race on Server.endpoint, skip Unix socket tests on Windows
cpcloud Mar 18, 2026
b3bb416
Replace endpoint mutex with ordering: clean up socket after Shutdown
cpcloud Mar 18, 2026
4293940
Restore mutex for Server.endpoint synchronization
cpcloud Mar 18, 2026
6e6904b
Address holistic review: --server flag precedence, fixSingleJob snapshot
cpcloud Mar 18, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ REPORT.md
.zed
.superset

# Claude Code worktrees
# Claude Code
.claude/worktrees/
.claude/settings.local.json
43 changes: 23 additions & 20 deletions cmd/roborev/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,17 +295,19 @@ func runAnalysis(cmd *cobra.Command, typeName string, filePatterns []string, opt
cfg, _ := config.LoadGlobal()
maxPromptSize := config.ResolveMaxPromptSize(repoRoot, cfg)

ep := getDaemonEndpoint()

// Per-file mode: create one job per file
if opts.perFile {
return runPerFileAnalysis(cmd, serverAddr, repoRoot, analysisType, files, opts, maxPromptSize)
return runPerFileAnalysis(cmd, ep, repoRoot, analysisType, files, opts, maxPromptSize)
}

// Standard mode: all files in one job
return runSingleAnalysis(cmd, serverAddr, repoRoot, analysisType, files, opts, maxPromptSize)
return runSingleAnalysis(cmd, ep, repoRoot, analysisType, files, opts, maxPromptSize)
}

// runSingleAnalysis creates a single analysis job for all files
func runSingleAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string, analysisType *analyze.AnalysisType, files map[string]string, opts analyzeOptions, maxPromptSize int) error {
func runSingleAnalysis(cmd *cobra.Command, ep daemon.DaemonEndpoint, repoRoot string, analysisType *analyze.AnalysisType, files map[string]string, opts analyzeOptions, maxPromptSize int) error {
if !opts.quiet && !opts.jsonOutput {
cmd.Printf("Analyzing %d file(s) with %q analysis...\n", len(files), analysisType.Name)
}
Expand Down Expand Up @@ -339,7 +341,7 @@ func runSingleAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string, a
}

// Enqueue the job
job, err := enqueueAnalysisJob(serverAddr, repoRoot, fullPrompt, outputPrefix, analysisType.Name, opts)
job, err := enqueueAnalysisJob(ep, repoRoot, fullPrompt, outputPrefix, analysisType.Name, opts)
if err != nil {
return err
}
Expand All @@ -362,19 +364,19 @@ func runSingleAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string, a

// If --fix, we need to wait for analysis, run fixer, then mark closed
if opts.fix {
return runAnalyzeAndFix(cmd, serverAddr, repoRoot, job.ID, analysisType, opts)
return runAnalyzeAndFix(cmd, ep, repoRoot, job.ID, analysisType, opts)
}

// If --wait, poll until job completes and show result
if opts.wait {
return waitForPromptJob(cmd, serverAddr, job.ID, opts.quiet, promptPollInterval)
return waitForPromptJob(cmd, ep, job.ID, opts.quiet, promptPollInterval)
}

return nil
}

// runPerFileAnalysis creates one analysis job per file
func runPerFileAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string, analysisType *analyze.AnalysisType, files map[string]string, opts analyzeOptions, maxPromptSize int) error {
func runPerFileAnalysis(cmd *cobra.Command, ep daemon.DaemonEndpoint, repoRoot string, analysisType *analyze.AnalysisType, files map[string]string, opts analyzeOptions, maxPromptSize int) error {
// Sort files for deterministic order
fileNames := make([]string, 0, len(files))
for name := range files {
Expand Down Expand Up @@ -410,7 +412,7 @@ func runPerFileAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string,
}
}

job, err := enqueueAnalysisJob(serverAddr, repoRoot, fullPrompt, outputPrefix, analysisType.Name, opts)
job, err := enqueueAnalysisJob(ep, repoRoot, fullPrompt, outputPrefix, analysisType.Name, opts)
if err != nil {
return fmt.Errorf("enqueue job for %s: %w", fileName, err)
}
Expand Down Expand Up @@ -451,7 +453,7 @@ func runPerFileAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string,
if !opts.quiet {
cmd.Printf("\n=== Fixing job %d (%d/%d) ===\n", info.ID, i+1, len(jobInfos))
}
if err := runAnalyzeAndFix(cmd, serverAddr, repoRoot, info.ID, analysisType, opts); err != nil {
if err := runAnalyzeAndFix(cmd, ep, repoRoot, info.ID, analysisType, opts); err != nil {
if !opts.quiet {
cmd.Printf("Warning: fix for job %d failed: %v\n", info.ID, err)
}
Expand All @@ -470,7 +472,7 @@ func runPerFileAnalysis(cmd *cobra.Command, serverAddr string, repoRoot string,
if !opts.quiet {
cmd.Printf("\n=== Job %d (%d/%d) ===\n", info.ID, i+1, len(jobInfos))
}
if err := waitForPromptJob(cmd, serverAddr, info.ID, opts.quiet, promptPollInterval); err != nil {
if err := waitForPromptJob(cmd, ep, info.ID, opts.quiet, promptPollInterval); err != nil {
if !opts.quiet {
cmd.Printf("Warning: job %d failed: %v\n", info.ID, err)
}
Expand All @@ -496,7 +498,7 @@ func buildOutputPrefix(analysisType string, filePaths []string) string {
}

// enqueueAnalysisJob sends a job to the daemon
func enqueueAnalysisJob(serverAddr string, repoRoot, prompt, outputPrefix, label string, opts analyzeOptions) (*storage.ReviewJob, error) {
func enqueueAnalysisJob(ep daemon.DaemonEndpoint, repoRoot, prompt, outputPrefix, label string, opts analyzeOptions) (*storage.ReviewJob, error) {
branch := git.GetCurrentBranch(repoRoot)
if opts.branch != "" && opts.branch != "HEAD" {
branch = opts.branch
Expand All @@ -514,7 +516,7 @@ func enqueueAnalysisJob(serverAddr string, repoRoot, prompt, outputPrefix, label
Agentic: true, // Agentic mode needed for reading files when prompt exceeds size limit
})

resp, err := http.Post(serverAddr+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
resp, err := ep.HTTPClient(10*time.Second).Post(ep.BaseURL()+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to connect to daemon: %w", err)
}
Expand All @@ -538,7 +540,7 @@ func enqueueAnalysisJob(serverAddr string, repoRoot, prompt, outputPrefix, label
}

// runAnalyzeAndFix waits for analysis to complete, runs a fixer agent, then marks closed
func runAnalyzeAndFix(cmd *cobra.Command, serverAddr, repoRoot string, jobID int64, analysisType *analyze.AnalysisType, opts analyzeOptions) error {
func runAnalyzeAndFix(cmd *cobra.Command, ep daemon.DaemonEndpoint, repoRoot string, jobID int64, analysisType *analyze.AnalysisType, opts analyzeOptions) error {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
Expand All @@ -552,7 +554,7 @@ func runAnalyzeAndFix(cmd *cobra.Command, serverAddr, repoRoot string, jobID int
ctx, cancel := context.WithTimeout(ctx, analyzeJobTimeout)
defer cancel()

review, err := waitForAnalysisJob(ctx, serverAddr, jobID)
review, err := waitForAnalysisJob(ctx, ep, jobID)
if err != nil {
return fmt.Errorf("analysis failed: %w", err)
}
Expand Down Expand Up @@ -643,14 +645,14 @@ func runAnalyzeAndFix(cmd *cobra.Command, serverAddr, repoRoot string, jobID int
// Ensure the fix commit gets a review enqueued
if commitCreated {
if head, err := git.ResolveSHA(repoRoot, "HEAD"); err == nil {
if err := enqueueIfNeeded(ctx, serverAddr, repoRoot, head); err != nil && !opts.quiet {
if err := enqueueIfNeeded(ctx, ep.BaseURL(), repoRoot, head); err != nil && !opts.quiet {
cmd.Printf("Warning: could not enqueue review for fix commit: %v\n", err)
}
}
}

// Close the analysis job
if err := markJobClosed(ctx, serverAddr, jobID); err != nil {
if err := markJobClosed(ctx, ep.BaseURL(), jobID); err != nil {
// Non-fatal - the fixes were applied, just couldn't update status
if !opts.quiet {
cmd.Printf("\nWarning: could not close job: %v\n", err)
Expand All @@ -664,8 +666,9 @@ func runAnalyzeAndFix(cmd *cobra.Command, serverAddr, repoRoot string, jobID int

// waitForAnalysisJob polls until the job completes and returns the review.
// The context controls the maximum wait time.
func waitForAnalysisJob(ctx context.Context, serverAddr string, jobID int64) (*storage.Review, error) {
client := &http.Client{Timeout: 30 * time.Second}
func waitForAnalysisJob(ctx context.Context, ep daemon.DaemonEndpoint, jobID int64) (*storage.Review, error) {
client := ep.HTTPClient(30 * time.Second)
baseURL := ep.BaseURL()
pollInterval := 1 * time.Second
maxInterval := 5 * time.Second

Expand All @@ -677,7 +680,7 @@ func waitForAnalysisJob(ctx context.Context, serverAddr string, jobID int64) (*s
default:
}

req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/jobs?id=%d", serverAddr, jobID), nil)
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/jobs?id=%d", baseURL, jobID), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
Expand Down Expand Up @@ -710,7 +713,7 @@ func waitForAnalysisJob(ctx context.Context, serverAddr string, jobID int64) (*s
switch job.Status {
case storage.JobStatusDone:
// Fetch the review
reviewReq, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/review?job_id=%d", serverAddr, jobID), nil)
reviewReq, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/review?job_id=%d", baseURL, jobID), nil)
if err != nil {
return nil, fmt.Errorf("create review request: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/roborev/analyze_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func TestWaitForAnalysisJob(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

review, err := waitForAnalysisJob(ctx, ts.URL, testJobID)
review, err := waitForAnalysisJob(ctx, mustParseEndpoint(t, ts.URL), testJobID)

if tt.wantErr {
require.Error(t, err)
Expand Down Expand Up @@ -132,7 +132,7 @@ func TestRunAnalyzeAndFix_Integration(t *testing.T) {
reasoning: "fast",
}

err := runAnalyzeAndFix(cmd, ts.URL, repo.Dir, 99, analysisType, opts)
err := runAnalyzeAndFix(cmd, mustParseEndpoint(t, ts.URL), repo.Dir, 99, analysisType, opts)
require.NoError(t, err, "runAnalyzeAndFix failed: %v")

// Verify the workflow was executed
Expand Down
16 changes: 8 additions & 8 deletions cmd/roborev/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func TestWaitForAnalysisJob_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

_, err := waitForAnalysisJob(ctx, ts.URL, 42)
_, err := waitForAnalysisJob(ctx, mustParseEndpoint(t, ts.URL), 42)
require.Error(t, err, "expected timeout error")
require.ErrorContains(t, err, "context deadline exceeded", "expected context deadline error")
}
Expand Down Expand Up @@ -411,7 +411,7 @@ func TestPerFileAnalysis(t *testing.T) {
cmd, output := newTestCmd(t)

analysisType := analyze.GetType("refactor")
err = runPerFileAnalysis(cmd, ts.URL, tmpDir, analysisType, files, analyzeOptions{quiet: false}, config.DefaultMaxPromptSize)
err = runPerFileAnalysis(cmd, mustParseEndpoint(t, ts.URL), tmpDir, analysisType, files, analyzeOptions{quiet: false}, config.DefaultMaxPromptSize)
require.NoError(t, err, "runPerFileAnalysis")

// Should have created 3 jobs (one per file)
Expand Down Expand Up @@ -440,7 +440,7 @@ func TestEnqueueAnalysisJob(t *testing.T) {
},
})

job, err := enqueueAnalysisJob(ts.URL, "/repo", "test prompt", "", "test-fixtures", analyzeOptions{agentName: "test"})
job, err := enqueueAnalysisJob(mustParseEndpoint(t, ts.URL), "/repo", "test prompt", "", "test-fixtures", analyzeOptions{agentName: "test"})
require.NoError(t, err, "enqueueAnalysisJob")

assert.Equal(t, int64(42), job.ID, "job.ID")
Expand Down Expand Up @@ -470,23 +470,23 @@ func TestEnqueueAnalysisJobBranchName(t *testing.T) {
t.Run("no branch flag uses current branch", func(t *testing.T) {
ts, gotBranch := captureBranch(t)

_, err := enqueueAnalysisJob(ts.URL, repo.Dir, "prompt", "", "refactor", analyzeOptions{})
_, err := enqueueAnalysisJob(mustParseEndpoint(t, ts.URL), repo.Dir, "prompt", "", "refactor", analyzeOptions{})
require.NoError(t, err, "enqueueAnalysisJob")
assert.Equal(t, "test-current", *gotBranch, "expected branch 'test-current'")
})

t.Run("branch=HEAD uses current branch", func(t *testing.T) {
ts, gotBranch := captureBranch(t)

_, err := enqueueAnalysisJob(ts.URL, repo.Dir, "prompt", "", "refactor", analyzeOptions{branch: "HEAD"})
_, err := enqueueAnalysisJob(mustParseEndpoint(t, ts.URL), repo.Dir, "prompt", "", "refactor", analyzeOptions{branch: "HEAD"})
require.NoError(t, err, "enqueueAnalysisJob")
assert.Equal(t, "test-current", *gotBranch, "expected branch 'test-current'")
})

t.Run("named branch overrides current branch", func(t *testing.T) {
ts, gotBranch := captureBranch(t)

_, err := enqueueAnalysisJob(ts.URL, repo.Dir, "prompt", "", "refactor", analyzeOptions{branch: "feature-xyz"})
_, err := enqueueAnalysisJob(mustParseEndpoint(t, ts.URL), repo.Dir, "prompt", "", "refactor", analyzeOptions{branch: "feature-xyz"})
require.NoError(t, err, "enqueueAnalysisJob")
assert.Equal(t, "feature-xyz", *gotBranch, "expected branch 'feature-xyz'")
})
Expand Down Expand Up @@ -611,7 +611,7 @@ func TestAnalyzeJSONOutput(t *testing.T) {

cmd, output := newTestCmd(t)

err := runSingleAnalysis(cmd, ts.URL, tmpDir, analysisType, files, analyzeOptions{jsonOutput: true}, config.DefaultMaxPromptSize)
err := runSingleAnalysis(cmd, mustParseEndpoint(t, ts.URL), tmpDir, analysisType, files, analyzeOptions{jsonOutput: true}, config.DefaultMaxPromptSize)
require.NoError(t, err, "runSingleAnalysis: %v")

var result AnalyzeResult
Expand All @@ -638,7 +638,7 @@ func TestAnalyzeJSONOutput(t *testing.T) {

cmd, output := newTestCmd(t)

err := runPerFileAnalysis(cmd, ts.URL, tmpDir, analysisType, files, analyzeOptions{jsonOutput: true}, config.DefaultMaxPromptSize)
err := runPerFileAnalysis(cmd, mustParseEndpoint(t, ts.URL), tmpDir, analysisType, files, analyzeOptions{jsonOutput: true}, config.DefaultMaxPromptSize)
require.NoError(t, err, "runPerFileAnalysis")

var result AnalyzeResult
Expand Down
11 changes: 7 additions & 4 deletions cmd/roborev/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/exec"
"strconv"
"strings"
"time"

"github.com/roborev-dev/roborev/internal/git"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -134,8 +135,9 @@ Examples:

reqBody, _ := json.Marshal(reqData)

addr := getDaemonAddr()
resp, err := http.Post(addr+"/api/comment", "application/json", bytes.NewReader(reqBody))
ep := getDaemonEndpoint()
addr := ep.BaseURL()
resp, err := ep.HTTPClient(5*time.Second).Post(addr+"/api/comment", "application/json", bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to connect to daemon: %w", err)
}
Expand Down Expand Up @@ -191,8 +193,9 @@ func closeCmd() *cobra.Command {
"closed": closed,
})

addr := getDaemonAddr()
resp, err := http.Post(addr+"/api/review/close", "application/json", bytes.NewReader(reqBody))
ep := getDaemonEndpoint()
addr := ep.BaseURL()
resp, err := ep.HTTPClient(5*time.Second).Post(addr+"/api/review/close", "application/json", bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to connect to daemon: %w", err)
}
Expand Down
14 changes: 8 additions & 6 deletions cmd/roborev/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,14 @@ func fetchJobBatch(ctx context.Context, ids []int64) (map[int64]storage.JobWithR
return nil, fmt.Errorf("marshal batch request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, "POST", serverAddr+"/api/jobs/batch", bytes.NewReader(reqBody))
ep := getDaemonEndpoint()
req, err := http.NewRequestWithContext(ctx, "POST", ep.BaseURL()+"/api/jobs/batch", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("create batch request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

client := &http.Client{Timeout: 30 * time.Second}
client := ep.HTTPClient(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("batch fetch: %w", err)
Expand Down Expand Up @@ -285,7 +286,7 @@ func waitForConsolidation(ctx context.Context, cmd *cobra.Command, jobID int64,
output = cmd.OutOrStdout()
}

_, err := waitForJobCompletion(ctx, serverAddr, jobID, output)
_, err := waitForJobCompletion(ctx, getDaemonEndpoint().BaseURL(), jobID, output)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
Expand Down Expand Up @@ -400,7 +401,7 @@ func runCompact(cmd *cobra.Command, opts compactOptions) error {
// CRITICAL: This must succeed or source jobs will never be closed
if err := writeCompactMetadata(consolidatedJobID, successfulJobIDs); err != nil {
// Try to cancel the job we just created
if cancelErr := cancelJob(serverAddr, consolidatedJobID); cancelErr != nil {
if cancelErr := cancelJob(getDaemonEndpoint().BaseURL(), consolidatedJobID); cancelErr != nil {
// Best effort - log but don't mask the original error
log.Printf("Failed to cancel job %d after metadata write failure: %v", consolidatedJobID, cancelErr)
}
Expand Down Expand Up @@ -550,7 +551,8 @@ func enqueueCompactJob(repoRoot, prompt, outputPrefix, label, branch string, opt
return nil, fmt.Errorf("marshal enqueue request: %w", err)
}

resp, err := http.Post(serverAddr+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
ep := getDaemonEndpoint()
resp, err := ep.HTTPClient(10*time.Second).Post(ep.BaseURL()+"/api/enqueue", "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("connect to daemon: %w", err)
}
Expand Down Expand Up @@ -607,7 +609,7 @@ func cancelJob(serverAddr string, jobID int64) error {
if err != nil {
return fmt.Errorf("marshal cancel request: %w", err)
}
resp, err := http.Post(serverAddr+"/api/job/cancel", "application/json", bytes.NewReader(reqBody))
resp, err := getDaemonHTTPClient(10*time.Second).Post(serverAddr+"/api/job/cancel", "application/json", bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
Expand Down
Loading
Loading