diff options
| -rw-r--r-- | internal/api/server.go | 12 | ||||
| -rw-r--r-- | internal/api/webhook.go | 56 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 78 | ||||
| -rw-r--r-- | internal/cli/root.go | 19 | ||||
| -rw-r--r-- | internal/cli/run.go | 16 | ||||
| -rw-r--r-- | internal/cli/serve.go | 31 | ||||
| -rw-r--r-- | internal/config/config.go | 28 | ||||
| -rw-r--r-- | internal/config/config_test.go | 82 | ||||
| -rw-r--r-- | internal/executor/container.go | 20 | ||||
| -rw-r--r-- | internal/executor/container_test.go | 77 | ||||
| -rw-r--r-- | internal/task/validator.go | 3 | ||||
| -rwxr-xr-x | scripts/deploy | 124 |
12 files changed, 458 insertions, 88 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index fe883bb..4af7325 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -57,6 +57,7 @@ type Server struct { elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation projects []config.Project // configured projects for webhook routing + githubURLForTest func(fullName string) string // overrides GitHub SSH URL in tests vapidPublicKey string vapidPrivateKey string vapidEmail string @@ -64,7 +65,7 @@ type Server struct { dropsDir string llm *llm.Client budget budgetReporter // optional; per-provider spend headroom for GET /api/budget - basePath string // URL prefix the UI is mounted at, e.g. "/claudomator-oss" + basePath string // URL prefix the UI is mounted at, e.g. "/claudomator" } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -95,10 +96,12 @@ func (s *Server) SetWorkspaceRoot(path string) { } // SetBasePath sets the URL prefix the UI is served under (e.g. "/claudomator-oss"). +// The server rewrites the base-path meta tag in index.html at request time. func (s *Server) SetBasePath(p string) { s.basePath = p } + // Pool returns the executor pool, for graceful shutdown by the caller. func (s *Server) Pool() *executor.Pool { return s.pool } @@ -194,15 +197,10 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/drops", s.handlePostDrop) if s.registry != nil { mcpHandler := executor.NewAgentMCPHandler(s.registry) - // The streamable HTTP transport uses POST (messages), GET (SSE stream), - // and DELETE (session end). Register them explicitly so the patterns are - // more specific than the "GET /" catch-all and don't conflict. s.mux.Handle("POST /mcp", mcpHandler) s.mux.Handle("GET /mcp", mcpHandler) s.mux.Handle("DELETE /mcp", mcpHandler) } - // Chatbot-facing MCP server. Always mounted; the handler refuses to serve - // unless a shared API token is configured and presented as a bearer. chatbotHandler := s.chatbotMCPHandler() s.mux.Handle("POST /chatbot/mcp", chatbotHandler) s.mux.Handle("GET /chatbot/mcp", chatbotHandler) @@ -703,6 +701,8 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, exec) } +// handleStaticFiles serves embedded web UI files. For index.html it rewrites the +// base-path meta tag so the UI knows which API prefix to use. func (s *Server) handleStaticFiles(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "/index.html" { raw, err := fs.ReadFile(webui.Files, "index.html") diff --git a/internal/api/webhook.go b/internal/api/webhook.go index 3af4cc8..182a85e 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "net/http" + "os/exec" "path/filepath" "strings" "time" @@ -115,6 +116,8 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) { s.handleCheckRunEvent(w, body) case "workflow_run": s.handleWorkflowRunEvent(w, body) + case "push": + s.handlePushEvent(w, body) default: w.WriteHeader(http.StatusNoContent) } @@ -230,7 +233,7 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, State: task.StatePending, CreatedAt: now, UpdatedAt: now, - RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName), + RepositoryURL: fmt.Sprintf("git@github.com:%s.git", fullName), } if project != nil { t.Project = project.Name @@ -243,3 +246,54 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, writeJSON(w, http.StatusOK, map[string]string{"task_id": t.ID}) } + +// pushPayload is a minimal GitHub push webhook payload. +type pushPayload struct { + Ref string `json:"ref"` + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + } `json:"repository"` +} + +// handlePushEvent syncs the local bare repo with GitHub when a push arrives. +// The fetch runs asynchronously so the webhook returns immediately. +func (s *Server) handlePushEvent(w http.ResponseWriter, body []byte) { + var p pushPayload + if err := json.Unmarshal(body, &p); err != nil || p.Repository.FullName == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + project := matchProject(s.projects, p.Repository.Name) + if project == nil { + slog.Info("push event: no matching project, skipping sync", "repo", p.Repository.FullName) + w.WriteHeader(http.StatusNoContent) + return + } + + dbProj, err := s.store.GetProject(project.Name) + if err != nil || dbProj == nil || dbProj.RemoteURL == "" { + slog.Info("push event: project has no local remote, skipping sync", "project", project.Name) + w.WriteHeader(http.StatusNoContent) + return + } + + githubURL := fmt.Sprintf("git@github.com:%s.git", p.Repository.FullName) + if s.githubURLForTest != nil { + githubURL = s.githubURLForTest(p.Repository.FullName) + } + bareRepo := dbProj.RemoteURL + + go func() { + out, err := exec.Command("git", "-C", bareRepo, "fetch", githubURL, + "+refs/heads/*:refs/heads/*", "--prune").CombinedOutput() + if err != nil { + slog.Error("github push sync failed", "repo", p.Repository.FullName, "bare", bareRepo, "error", err, "output", string(out)) + } else { + slog.Info("github push sync succeeded", "repo", p.Repository.FullName, "bare", bareRepo) + } + }() + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 967b62b..05fe82c 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -8,10 +8,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os/exec" + "path/filepath" "strings" "testing" + "time" "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/task" ) // signBody computes the HMAC-SHA256 signature for a webhook payload. @@ -124,8 +128,8 @@ func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) { if !strings.Contains(tk.Name, "main") { t.Errorf("task name %q does not contain branch", tk.Name) } - if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { - t.Errorf("task repository url = %q, want https://github.com/owner/myrepo.git", tk.RepositoryURL) + if tk.RepositoryURL != "git@github.com:owner/myrepo.git" { + t.Errorf("task repository url = %q, want git@github.com:owner/myrepo.git", tk.RepositoryURL) } if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") { t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags) @@ -375,8 +379,8 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) { if err != nil { t.Fatalf("task not found: %v", err) } - if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { - t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL) + if tk.RepositoryURL != "git@github.com:owner/myrepo.git" { + t.Errorf("expected fallback repository url (ssh), got %q", tk.RepositoryURL) } } @@ -400,6 +404,72 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing. } } +func TestGitHubWebhook_PushEvent_Returns204(t *testing.T) { + srv, _ := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}` + w := webhookPost(t, srv, "push", payload, "") + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_PushEvent_SyncsLocalBareRepo(t *testing.T) { + dir := t.TempDir() + + // Create a "github" source bare repo with a commit. + githubBare := filepath.Join(dir, "github.git") + localBare := filepath.Join(dir, "local.git") + for _, bare := range []string{githubBare, localBare} { + if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { + t.Fatalf("git init bare %s: %v\n%s", bare, err, out) + } + } + // Seed githubBare with a commit via a temp clone. + clone := filepath.Join(dir, "clone") + run := func(args ...string) { + t.Helper() + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + run("git", "clone", githubBare, clone) + run("git", "-C", clone, "config", "user.email", "test@test.com") + run("git", "-C", clone, "config", "user.name", "Test") + run("git", "-C", clone, "commit", "--allow-empty", "-m", "initial") + run("git", "-C", clone, "push", "origin", "main") + + // Register DB project with localBare as the remote_url. + srv, store := testServer(t) + proj := &task.Project{ID: "myrepo", Name: "myrepo", RemoteURL: localBare, LocalPath: clone} + if err := store.CreateProject(proj); err != nil { + t.Fatalf("CreateProject: %v", err) + } + srv.projects = []config.Project{{Name: "myrepo", Dir: clone}} + + // Override the github SSH URL to point to our local githubBare for testing. + srv.githubURLForTest = func(fullName string) string { return githubBare } + + payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}` + w := webhookPost(t, srv, "push", payload, "") + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", w.Code) + } + + // Wait for the async sync goroutine. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + out, err := exec.Command("git", "-C", localBare, "log", "--oneline", "-1").CombinedOutput() + if err == nil && strings.TrimSpace(string(out)) != "" { + return // success — local bare has the commit + } + time.Sleep(50 * time.Millisecond) + } + t.Error("local bare repo was not synced from github within timeout") +} + // contains checks if a string slice contains a value. func contains(slice []string, val string) bool { for _, s := range slice { diff --git a/internal/cli/root.go b/internal/cli/root.go index e57a9d9..2cc8f71 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -38,18 +38,27 @@ func NewRootCmd() *cobra.Command { cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") // Re-derive DBPath and LogDir after flags are parsed, so --data-dir takes effect. - // If --config is provided, load that file first; explicit CLI flags override it. + // Load config file: explicit --config flag, then default location, then defaults-only. cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if cfgFile != "" { + filePath := cfgFile + if filePath == "" { + home, _ := os.UserHomeDir() + filePath = filepath.Join(home, ".claudomator", "config.toml") + } + + if filePath != "" { // Save values set by explicit CLI flags before overwriting cfg from file. flagDataDir := cfg.DataDir flagClaudeBin := cfg.ClaudeBinaryPath - loaded, err := config.LoadFile(cfgFile) - if err != nil { + loaded, err := config.LoadFile(filePath) + if err != nil && cfgFile != "" { + // Only hard-fail if the file was explicitly specified. return err } - *cfg = *loaded + if err == nil { + *cfg = *loaded + } if cmd.Flags().Changed("data-dir") { cfg.DataDir = flagDataDir diff --git a/internal/cli/run.go b/internal/cli/run.go index 48f34b7..771c7b7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -77,8 +77,10 @@ func runTasks(file string, parallel int, dryRun bool) error { apiURL = "http://" + cfg.ServerAddr } - runners := map[string]executor.Runner{ - "claude": &executor.ContainerRunner{ + runners := map[string]executor.Runner{} + + if cfg.Runners.ClaudeEnabled() { + runners["claude"] = &executor.ContainerRunner{ Image: cfg.ClaudeImage, Logger: logger, LogDir: cfg.LogDir, @@ -87,8 +89,10 @@ func runTasks(file string, parallel int, dryRun bool) error { SSHAuthSock: cfg.SSHAuthSock, ClaudeBinary: cfg.ClaudeBinaryPath, GeminiBinary: cfg.GeminiBinaryPath, - }, - "gemini": &executor.ContainerRunner{ + } + } + if cfg.Runners.GeminiEnabled() { + runners["gemini"] = &executor.ContainerRunner{ Image: cfg.GeminiImage, Logger: logger, LogDir: cfg.LogDir, @@ -97,11 +101,11 @@ func runTasks(file string, parallel int, dryRun bool) error { SSHAuthSock: cfg.SSHAuthSock, ClaudeBinary: cfg.ClaudeBinaryPath, GeminiBinary: cfg.GeminiBinaryPath, - }, + } } localClient := buildLocalLLMClient(cfg.LocalModel, logger) - if localClient != nil { + if localClient != nil && cfg.Runners.LocalEnabled() { runners["local"] = &executor.LocalRunner{ Client: localClient, Logger: logger, diff --git a/internal/cli/serve.go b/internal/cli/serve.go index fe0ba8e..f687e14 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -86,13 +86,16 @@ func serve(addr, basePath string) error { claudeConfigDir := cfg.ClaudeConfigDir repoDir, _ := os.Getwd() + // Shared per-task agent MCP token registry: runners mint tokens; the API // server mounts /mcp and resolves them. agentRegistry := executor.NewRegistry() - runners := map[string]executor.Runner{ - // ContainerRunner: binaries are resolved via PATH inside the container image, - // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). - "claude": &executor.ContainerRunner{ + runners := map[string]executor.Runner{} + + // ContainerRunner: binaries are resolved via PATH inside the container image, + // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). + if cfg.Runners.ClaudeEnabled() { + runners["claude"] = &executor.ContainerRunner{ Image: cfg.ClaudeImage, Logger: logger, LogDir: cfg.LogDir, @@ -103,8 +106,10 @@ func serve(addr, basePath string) error { CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, - }, - "gemini": &executor.ContainerRunner{ + } + } + if cfg.Runners.GeminiEnabled() { + runners["gemini"] = &executor.ContainerRunner{ Image: cfg.GeminiImage, Logger: logger, LogDir: cfg.LogDir, @@ -115,8 +120,10 @@ func serve(addr, basePath string) error { CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, - }, - "container": &executor.ContainerRunner{ + } + } + if cfg.Runners.ContainerEnabled() { + runners["container"] = &executor.ContainerRunner{ Image: "claudomator-agent:latest", Logger: logger, LogDir: cfg.LogDir, @@ -127,11 +134,11 @@ func serve(addr, basePath string) error { CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, - }, + } } localClient := buildLocalLLMClient(cfg.LocalModel, logger) - if localClient != nil { + if localClient != nil && cfg.Runners.LocalEnabled() { runners["local"] = &executor.LocalRunner{ Client: localClient, Logger: logger, @@ -141,7 +148,6 @@ func serve(addr, basePath string) error { logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model) } - pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) pool.Classifier = &executor.Classifier{ LLM: localClient, @@ -152,7 +158,7 @@ func serve(addr, basePath string) error { } // Budget accountant: gate paid providers against rolling per-provider caps. - // With no limits configured this is created but allows everything. + // With no limits configured this is a no-op. var accountant *budget.Accountant if len(cfg.Budget.Provider5hUSD) > 0 { window := budget.DefaultWindow @@ -268,4 +274,3 @@ func serve(addr, basePath string) error { } return nil } - diff --git a/internal/config/config.go b/internal/config/config.go index 6f6b958..80777ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,24 @@ func (m LocalModel) UseForElaborate() bool { return *m.PreferForElaborate } +// RunnersConfig controls which agent runners are registered. Each field is a +// *bool so that nil (absent from TOML) means "enabled by default", while an +// explicit false disables the runner without affecting others. +// +// The local runner also requires LocalModel.Endpoint to be set; setting +// Local = true without an endpoint has no effect. +type RunnersConfig struct { + Claude *bool `toml:"claude"` + Gemini *bool `toml:"gemini"` + Local *bool `toml:"local"` + Container *bool `toml:"container"` +} + +func (r RunnersConfig) ClaudeEnabled() bool { return r.Claude == nil || *r.Claude } +func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Gemini } +func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local } +func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container } + type Config struct { DataDir string `toml:"data_dir"` DBPath string `toml:"-"` @@ -69,20 +87,16 @@ type Config struct { VAPIDEmail string `toml:"vapid_email"` ClaudeConfigDir string `toml:"claude_config_dir"` LocalModel LocalModel `toml:"local_model"` + Runners RunnersConfig `toml:"runners"` Budget BudgetConfig `toml:"budget"` // ExternalBindAllowed must be explicitly true to bind a non-loopback address. - // Default external access should go through a reverse proxy (Tailscale / - // Cloudflare Access / Caddy); binding the server directly is a footgun. ExternalBindAllowed bool `toml:"external_bind_allowed"` } // BudgetConfig configures rolling per-provider spend caps. With no providers -// set, budget gating is disabled (nothing is blocked) — there is intentionally -// no default cap; the user must opt in by configuring limits. +// set, budget gating is disabled. type BudgetConfig struct { - // Window is the rolling lookback duration (e.g. "5h"); defaults to 5h. - Window string `toml:"window"` - // Provider5hUSD maps a provider (claude/gemini) to its USD cap within Window. + Window string `toml:"window"` Provider5hUSD map[string]float64 `toml:"provider_5h_usd"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e4f1a5d..0d47ca0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,6 +54,88 @@ func TestLoadFile_MissingFile_ReturnsError(t *testing.T) { } } +func TestRunnersConfig_DefaultsAllEnabled(t *testing.T) { + r := RunnersConfig{} + if !r.ClaudeEnabled() { + t.Error("claude should be enabled by default (nil)") + } + if !r.GeminiEnabled() { + t.Error("gemini should be enabled by default (nil)") + } + if !r.LocalEnabled() { + t.Error("local should be enabled by default (nil)") + } + if !r.ContainerEnabled() { + t.Error("container should be enabled by default (nil)") + } +} + +func TestRunnersConfig_ExplicitFalseDisables(t *testing.T) { + f := false + r := RunnersConfig{Claude: &f, Gemini: &f, Local: &f, Container: &f} + if r.ClaudeEnabled() { + t.Error("explicit false should disable claude") + } + if r.GeminiEnabled() { + t.Error("explicit false should disable gemini") + } + if r.LocalEnabled() { + t.Error("explicit false should disable local") + } + if r.ContainerEnabled() { + t.Error("explicit false should disable container") + } +} + +func TestRunnersConfig_ExplicitTrueEnables(t *testing.T) { + tr := true + r := RunnersConfig{Claude: &tr, Gemini: &tr, Local: &tr, Container: &tr} + if !r.ClaudeEnabled() || !r.GeminiEnabled() || !r.LocalEnabled() || !r.ContainerEnabled() { + t.Error("explicit true should keep all runners enabled") + } +} + +func TestRunnersConfig_MixedToggles(t *testing.T) { + f, tr := false, true + r := RunnersConfig{Claude: &tr, Gemini: &f, Local: &tr} + if !r.ClaudeEnabled() { + t.Error("claude should be enabled") + } + if r.GeminiEnabled() { + t.Error("gemini should be disabled") + } + if !r.LocalEnabled() { + t.Error("local should be enabled") + } + if !r.ContainerEnabled() { + t.Error("container should be enabled (nil = default)") + } +} + +func TestRunnersConfig_TOMLRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + toml := "[runners]\ngemini = false\n" + if err := os.WriteFile(path, []byte(toml), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", dir) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Runners.GeminiEnabled() { + t.Error("gemini should be disabled after TOML load") + } + if !cfg.Runners.ClaudeEnabled() { + t.Error("claude should remain enabled (not in TOML)") + } + if !cfg.Runners.LocalEnabled() { + t.Error("local should remain enabled (not in TOML)") + } +} + func TestLocalModel_UseForElaborate_EmptyEndpoint(t *testing.T) { m := LocalModel{} if m.UseForElaborate() { diff --git a/internal/executor/container.go b/internal/executor/container.go index 3afce70..39f737a 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -155,6 +155,14 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } } } + // For non-story tasks (e.g. CI webhook tasks), also resolve local path from the task's + // project field. This lets the runner clone from the local mirror instead of an HTTPS + // remote that may require credentials not available on the host. + if storyLocalPath == "" && t.Project != "" && r.Store != nil { + if proj, err := r.Store.GetProject(t.Project); err == nil && proj != nil && proj.LocalPath != "" { + storyLocalPath = proj.LocalPath + } + } // Fall back to task-level BranchName (e.g. set explicitly by executor or tests). if storyBranch == "" { storyBranch = t.BranchName @@ -175,13 +183,15 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if err := os.Remove(workspace); err != nil { return fmt.Errorf("removing workspace before clone: %w", err) } - r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) - var cloneArgs []string + // Prefer the local path as the clone source to avoid HTTPS auth requirements. + // For story tasks the storyLocalPath is also available as a speed reference, + // but we clone from the local path directly since it is already authoritative. + cloneSrc := repoURL if storyLocalPath != "" { - cloneArgs = []string{"clone", "--reference", storyLocalPath, repoURL, workspace} - } else { - cloneArgs = []string{"clone", repoURL, workspace} + cloneSrc = storyLocalPath } + r.Logger.Info("cloning repository", "src", cloneSrc, "workspace", workspace) + cloneArgs := []string{"clone", cloneSrc, workspace} if out, err := r.command(ctx, "git", cloneArgs...).CombinedOutput(); err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index 26e67bc..dec666e 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -666,6 +666,73 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { } } +// TestContainerRunner_ClonesFromLocalPathForCITask verifies that when a task has +// an HTTPS repository_url but the project has a known local path, the runner +// clones from the local path to avoid HTTPS auth failures. +func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) { + dir := t.TempDir() + localRepo := filepath.Join(dir, "local-repo.git") + if out, err := exec.Command("git", "init", "--bare", localRepo).CombinedOutput(); err != nil { + t.Fatalf("git init bare: %v\n%s", err, out) + } + + store := testStore(t) + proj := &task.Project{ + ID: "nav", + Name: "nav", + RemoteURL: localRepo, + LocalPath: localRepo, + } + if err := store.CreateProject(proj); err != nil { + t.Fatalf("CreateProject: %v", err) + } + + var cloneSrc string + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner := &ContainerRunner{ + Logger: logger, + Image: "busybox", + Store: store, + Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if name == "git" && len(arg) > 0 && arg[0] == "clone" { + // Record what source was cloned from. + for i, a := range arg { + if a != "clone" && a != "--reference" && i > 0 && arg[i-1] != "--reference" { + // last non-dest arg before workspace is the source + } + _ = a + } + // source is second-to-last arg (before workspace dir) + cloneSrc = arg[len(arg)-2] + dir := arg[len(arg)-1] + os.MkdirAll(dir, 0755) + return exec.Command("true") + } + if name == "docker" { + return exec.Command("sh", "-c", "exit 1") + } + return exec.Command("true") + }, + } + + tk := &task.Task{ + ID: "ci-task-1", + RepositoryURL: "https://github.com/thepeterstone/nav.git", + Project: "nav", + Agent: task.AgentConfig{Type: "claude"}, + } + e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"} + runner.Run(context.Background(), tk, e, noopChannel{}) + os.RemoveAll(e.SandboxDir) + + if cloneSrc == "https://github.com/thepeterstone/nav.git" { + t.Error("runner cloned from GitHub HTTPS URL; expected local path to avoid auth failure") + } + if cloneSrc != localRepo { + t.Errorf("expected clone source %q, got %q", localRepo, cloneSrc) + } +} + func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) { // Set up a bare repo and a local clone to test branch creation. dir := t.TempDir() @@ -744,3 +811,13 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) { t.Fatalf("ensureStoryBranch on existing branch: %v", err) } } + +// noopChannel satisfies AgentChannel with no-op implementations for tests. +type noopChannel struct{} + +func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil } +func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil } +func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) { + return "", nil +} +func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil } diff --git a/internal/task/validator.go b/internal/task/validator.go index 43e482e..003fab9 100644 --- a/internal/task/validator.go +++ b/internal/task/validator.go @@ -29,9 +29,6 @@ func Validate(t *Task) error { if t.Name == "" { ve.Add("name is required") } - if t.RepositoryURL == "" { - ve.Add("repository_url is required") - } if t.Agent.Instructions == "" { ve.Add("agent.instructions is required") } diff --git a/scripts/deploy b/scripts/deploy index 2161535..3ee0bae 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -1,66 +1,114 @@ #!/bin/bash # deploy — Build and deploy claudomator to /site/doot.terst.org -# Usage: ./scripts/deploy [--dirty] +# Usage: ./scripts/deploy [--dirty] [--branch <name>] +# --branch <name> Deploy a named local branch as claudomator-<name> (e.g. oss) +# --dirty Skip stash check (main deploy only) # Example: sudo ./scripts/deploy +# sudo ./scripts/deploy --branch oss set -euo pipefail DIRTY=false -for arg in "$@"; do - if [ "$arg" == "--dirty" ]; then - DIRTY=true - fi +BRANCH="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dirty) DIRTY=true; shift ;; + --branch) BRANCH="$2"; shift 2 ;; + --branch=*) BRANCH="${1#--branch=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac done FQDN="doot.terst.org" SITE_DIR="/site/${FQDN}" BIN_DIR="${SITE_DIR}/bin" -SERVICE="claudomator@${FQDN}.service" REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" -cd "${REPO_DIR}" +export GOCACHE="${SITE_DIR}/cache/go-build" +export GOPATH="${SITE_DIR}/cache/gopath" +mkdir -p "${GOCACHE}" "${GOPATH}" -echo "==> Pulling latest from bare repo..." -git pull --ff-only local main +if [ -n "$BRANCH" ]; then + # ── Branch deploy ────────────────────────────────────────────────────────── + # Builds a named branch in an isolated git worktree so the main checkout is + # undisturbed. The resulting binary is deployed as claudomator-<branch> and + # managed by the claudomator-<branch>@<fqdn> service. + BINARY_NAME="claudomator-${BRANCH}" + SERVICE="claudomator-${BRANCH}@${FQDN}.service" + WORKTREE="/tmp/claudomator-deploy-${BRANCH}" -STASHED=false -if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then - echo "==> Stashing uncommitted changes..." - git stash push -u -m "Auto-stash before deploy" - STASHED=true - trap 'if [ "$STASHED" = true ]; then echo "==> Popping stash..."; git stash pop; fi' EXIT -fi + cleanup() { git -C "${REPO_DIR}" worktree remove "${WORKTREE}" --force 2>/dev/null || true; } + trap cleanup EXIT -echo "==> Verifying (build + tests)..." -"${REPO_DIR}/scripts/verify" + cd "${REPO_DIR}" -echo "==> Building claudomator..." -export GOCACHE="${SITE_DIR}/cache/go-build" -export GOPATH="${SITE_DIR}/cache/gopath" -mkdir -p "${GOCACHE}" "${GOPATH}" -go build -o "${BIN_DIR}/claudomator" ./cmd/claudomator/ + echo "==> Fetching all remotes..." + git fetch --all 2>/dev/null || git fetch local 2>/dev/null || true + + echo "==> Setting up worktree for ${BRANCH}..." + git worktree remove "${WORKTREE}" --force 2>/dev/null || true + git worktree add "${WORKTREE}" "${BRANCH}" + + echo "==> Fast-forwarding ${BRANCH} to tracking remote..." + git -C "${WORKTREE}" merge --ff-only "@{u}" 2>/dev/null || echo " (no upstream or already current)" + + echo "==> Verifying ${BRANCH} (build + tests)..." + (cd "${WORKTREE}" && go build ./... && go test -race ./...) + echo "==> All checks passed." -echo "==> Copying scripts..." -mkdir -p "${SITE_DIR}/scripts" -find "${REPO_DIR}/scripts" -maxdepth 1 -type f -exec cp -p {} "${SITE_DIR}/scripts/" \; + echo "==> Building ${BINARY_NAME}..." + (cd "${WORKTREE}" && go build -o "${BIN_DIR}/${BINARY_NAME}" ./cmd/claudomator/) -echo "==> Installing to /usr/local/bin..." -install -m 755 "${BIN_DIR}/claudomator" /usr/local/bin/claudomator +else + # ── Main deploy ──────────────────────────────────────────────────────────── + BINARY_NAME="claudomator" + SERVICE="claudomator@${FQDN}.service" -echo "==> Verifying system CLI version..." -/usr/local/bin/claudomator version + cd "${REPO_DIR}" -echo "==> Fixing permissions..." -"${REPO_DIR}/scripts/fix-permissions" + echo "==> Pulling latest from bare repo..." + git pull --ff-only local main -echo "==> Syncing credentials..." -"${REPO_DIR}/scripts/sync-credentials" + STASHED=false + if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then + echo "==> Stashing uncommitted changes..." + git stash push -u -m "Auto-stash before deploy" + STASHED=true + trap 'if [ "$STASHED" = true ]; then echo "==> Popping stash..."; git stash pop; fi' EXIT + fi + + echo "==> Verifying (build + tests)..." + "${REPO_DIR}/scripts/verify" + + echo "==> Building claudomator..." + go build -o "${BIN_DIR}/claudomator" ./cmd/claudomator/ + + echo "==> Copying scripts..." + mkdir -p "${SITE_DIR}/scripts" + find "${REPO_DIR}/scripts" -maxdepth 1 -type f -exec cp -p {} "${SITE_DIR}/scripts/" \; + + echo "==> Installing to /usr/local/bin..." + install -m 755 "${BIN_DIR}/claudomator" /usr/local/bin/claudomator + + echo "==> Verifying system CLI version..." + /usr/local/bin/claudomator version + + echo "==> Fixing permissions..." + "${REPO_DIR}/scripts/fix-permissions" + + echo "==> Syncing credentials..." + "${REPO_DIR}/scripts/sync-credentials" + + echo "==> Ensuring binary and scripts are executable..." + chmod +x "${BIN_DIR}/claudomator" /usr/local/bin/claudomator + find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} + +fi -echo "==> Ensuring binary and scripts are executable..." -chmod +x "${BIN_DIR}/claudomator" /usr/local/bin/claudomator -find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} + +echo "==> Ensuring ${BINARY_NAME} is executable..." +chmod +x "${BIN_DIR}/${BINARY_NAME}" +chown www-data:www-data "${BIN_DIR}/${BINARY_NAME}" -echo "==> Restarting service..." +echo "==> Restarting ${SERVICE}..." sudo systemctl restart "${SERVICE}" sudo systemctl status "${SERVICE}" --no-pager -l |
