summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-18 00:17:50 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-18 07:54:48 +0000
commit0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 (patch)
tree6a0b8af6c3faacc332e1102776960ac218ec66ca
parent1d550c1196ea836e0a0f798ba0127c1086f5f963 (diff)
feat: implement containerized repository-based execution model
This commit implements the architectural shift from local directory-based sandboxing to containerized execution using canonical repository URLs. Key changes: - Data Model: Added RepositoryURL and ContainerImage to task/agent configs. - Storage: Updated SQLite schema and queries to handle new fields. - Executor: Implemented ContainerRunner using Docker/Podman for isolation. - API/UI: Overhauled task creation to use Repository URLs and Image selection. - Webhook: Updated GitHub webhook to derive Repository URLs automatically. - Docs: Updated ADR-005 with risk feedback and added ADR-006 to document the new containerized model. - Defaults: Updated serve command to use ContainerRunner for all agents. This fixes systemic task failures caused by build dependency and permission issues on the host system.
-rw-r--r--docs/adr/005-sandbox-execution-model.md23
-rw-r--r--docs/adr/006-containerized-execution.md51
-rw-r--r--images/agent-base/Dockerfile37
-rw-r--r--internal/api/server.go2
-rw-r--r--internal/api/webhook.go3
-rw-r--r--internal/api/webhook_test.go8
-rw-r--r--internal/cli/serve.go31
-rw-r--r--internal/executor/container.go172
-rw-r--r--internal/storage/db.go19
-rw-r--r--internal/task/task.go7
-rw-r--r--web/app.js88
-rw-r--r--web/index.html13
12 files changed, 341 insertions, 113 deletions
diff --git a/docs/adr/005-sandbox-execution-model.md b/docs/adr/005-sandbox-execution-model.md
index b374561..80629d1 100644
--- a/docs/adr/005-sandbox-execution-model.md
+++ b/docs/adr/005-sandbox-execution-model.md
@@ -69,9 +69,13 @@ state), the sandbox is **not** torn down. The preserved sandbox allows the
resumed execution to pick up the same working tree state, including any
in-progress file changes made before the agent asked its question.
-Resume executions (`SubmitResume`) skip sandbox setup entirely and run
-directly in `project_dir`, passing `--resume <session-id>` to the agent
-so Claude can continue its previous conversation.
+**Known Risk: Resume skips sandbox.** Current implementation of
+Resume executions (`SubmitResume`) skips sandbox setup entirely and runs
+directly in `project_dir`. This is a significant behavioral divergence: if a
+resumed task makes further changes, they land directly in the canonical working
+copy, reintroducing the concurrent corruption and partial-work leak risks
+identified in the Context section. A future iteration should ensure resumed
+tasks pick up the preserved sandbox instead.
### Session ID propagation on resume
@@ -113,10 +117,15 @@ The fix is in `ClaudeRunner.Run`: if `e.ResumeSessionID != ""`, use it as
directory the server process inherited.
- If a sandbox's push repeatedly fails (e.g. due to a bare repo that is
itself broken), the task is failed with the sandbox preserved.
-- If `/tmp` runs out of space (many large sandboxes), tasks will fail at
- clone time. This is a known operational risk with no current mitigation.
-- The `project_dir` field in task YAML must point to a git repository with
- a configured `"local"` or `"origin"` remote that accepts pushes.
+- **If `/tmp` runs out of space** (many large sandboxes), tasks will fail at
+ clone time. This is a known operational risk. Mitigations such as periodic
+ cleanup of old sandboxes (cron) or pre-clone disk space checks are required
+ as follow-up items.
+- **The `project_dir` field in task YAML** must point to a git repository with
+ a configured `"local"` or `"origin"` remote that accepts pushes. If neither
+ remote exists or the push is rejected for other reasons, the task will be
+ marked as `FAILED` and the sandbox will be preserved for manual recovery.
+
## Relevant Code Locations
diff --git a/docs/adr/006-containerized-execution.md b/docs/adr/006-containerized-execution.md
new file mode 100644
index 0000000..cdd1cc2
--- /dev/null
+++ b/docs/adr/006-containerized-execution.md
@@ -0,0 +1,51 @@
+# ADR-006: Containerized Repository-Based Execution Model
+
+## Status
+Accepted (Supersedes ADR-005)
+
+## Context
+ADR-005 introduced a sandbox execution model based on local git clones and pushes back to a local project directory. While this provided isolation, it had several flaws identified during early adoption:
+1. **Host pollution**: Build dependencies (Node, Go, etc.) had to be installed on the host and were subject to permission issues (e.g., `/root/.nvm` access for `www-data`).
+2. **Fragile Pushes**: Pushing to a checked-out local branch is non-standard and requires risky git configs.
+3. **Resume Divergence**: Resumed tasks bypassed the sandbox, reintroducing corruption risks.
+4. **Scale**: Local directory-based "project selection" is a hack that doesn't scale to multiple repos or environments.
+
+## Decision
+We will move to a containerized execution model where projects are defined by canonical repository URLs and executed in isolated containers.
+
+### 1. Repository-Based Projects
+- The `Task` model now uses `RepositoryURL` as the source of truth for the codebase.
+- This replaces the fragile reliance on local `ProjectDir` paths.
+
+### 2. Containerized Sandboxes
+- Each task execution runs in a fresh container (Docker/Podman).
+- The runner clones the repository into a host-side temporary workspace and mounts it into the container.
+- The container provides a "bare system" with the full build stack (Node, Go, etc.) pre-installed, isolating the host from build dependencies.
+
+### 3. Unified Workspace Management (including RESUME)
+- Unlike ADR-005, the containerized model is designed to handle **Resume** by re-attaching to or re-mounting the same host-side workspace.
+- This ensures that resumed tasks **do not** bypass the sandbox and never land directly in a production directory.
+
+### 4. Push to Actual Remotes
+- Agents commit changes within the sandbox.
+- The runner pushes these commits directly to the `RepositoryURL` (actual remote).
+- If the remote is missing or the push fails, the task is marked `FAILED` and the host-side workspace is preserved for inspection.
+
+## Rationale
+- **Isolation**: Containers prevent host pollution and ensure a consistent build environment.
+- **Safety**: Repository URLs provide a standard way to manage codebases across environments.
+- **Consistency**: Unified workspace management for initial runs and resumes eliminates the behavioral divergence found in ADR-005.
+
+## Consequences
+- Requires a container runtime (Docker) on the host.
+- Requires pre-built agent images (e.g., `claudomator-agent:latest`).
+- **Disk Space Risk**: Host-side clones still consume `/tmp` space. Mitigation requires periodic cleanup of old workspaces or disk-space monitoring.
+- **Git Config**: Repositories no longer require `receive.denyCurrentBranch = updateInstead` because we push to the remote, not a local worktree.
+
+## Relevant Code Locations
+| Concern | File |
+|---|---|
+| Container Lifecycle | `internal/executor/container.go` |
+| Runner Registration | `internal/cli/serve.go` |
+| Task Model | `internal/task/task.go` |
+| API Integration | `internal/api/server.go` |
diff --git a/images/agent-base/Dockerfile b/images/agent-base/Dockerfile
new file mode 100644
index 0000000..71807ae
--- /dev/null
+++ b/images/agent-base/Dockerfile
@@ -0,0 +1,37 @@
+# Claudomator Agent Base Image
+FROM ubuntu:22.04
+
+# Avoid interactive prompts
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install core build and dev tools
+RUN apt-get update && apt-get install -y \
+ git \
+ curl \
+ make \
+ golang \
+ nodejs \
+ npm \
+ sqlite3 \
+ jq \
+ sudo \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install specific node tools if needed (example: postcss)
+RUN npm install -g postcss-cli tailwindcss autoprefixer
+
+# Setup workspace
+WORKDIR /workspace
+
+# Install Claudomator-aware CLI wrappers (placeholder)
+# These will be provided by the Claudomator project in the future.
+# For now, we assume 'claude' and 'gemini' binaries are available or mapped.
+
+# Add a user claudomator-agent
+RUN useradd -m claudomator-agent && \
+ echo "claudomator-agent ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
+
+USER claudomator-agent
+
+# Default command
+CMD ["/bin/bash"]
diff --git a/internal/api/server.go b/internal/api/server.go
index 48440e1..64d2c3a 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -424,6 +424,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
Description string `json:"description"`
ElaborationInput string `json:"elaboration_input"`
Project string `json:"project"`
+ RepositoryURL string `json:"repository_url"`
Agent task.AgentConfig `json:"agent"`
Claude task.AgentConfig `json:"claude"` // legacy alias
Timeout string `json:"timeout"`
@@ -448,6 +449,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
Description: input.Description,
ElaborationInput: input.ElaborationInput,
Project: input.Project,
+ RepositoryURL: input.RepositoryURL,
Agent: input.Agent,
Priority: task.Priority(input.Priority),
Tags: input.Tags,
diff --git a/internal/api/webhook.go b/internal/api/webhook.go
index 0530f3e..a28b43f 100644
--- a/internal/api/webhook.go
+++ b/internal/api/webhook.go
@@ -219,7 +219,8 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
UpdatedAt: now,
}
if project != nil {
- t.Agent.ProjectDir = project.Dir
+ t.RepositoryURL = fmt.Sprintf("https://github.com/%s.git", fullName)
+ t.Project = project.Name
}
if err := s.store.CreateTask(t); err != nil {
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go
index 1bc4aaa..0fc9664 100644
--- a/internal/api/webhook_test.go
+++ b/internal/api/webhook_test.go
@@ -124,8 +124,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.Agent.ProjectDir != "/workspace/myrepo" {
- t.Errorf("task project dir = %q, want /workspace/myrepo", tk.Agent.ProjectDir)
+ 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 !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") {
t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags)
@@ -375,8 +375,8 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) {
if err != nil {
t.Fatalf("task not found: %v", err)
}
- if tk.Agent.ProjectDir != "/workspace/someapp" {
- t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir)
+ if tk.RepositoryURL != "https://github.com/owner/myrepo.git" {
+ t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL)
}
}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index 5677562..56947bf 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -74,19 +74,26 @@ func serve(addr string) error {
}
runners := map[string]executor.Runner{
- "claude": &executor.ClaudeRunner{
- BinaryPath: cfg.ClaudeBinaryPath,
- Logger: logger,
- LogDir: cfg.LogDir,
- APIURL: apiURL,
- DropsDir: cfg.DropsDir,
+ "claude": &executor.ContainerRunner{
+ Image: "claudomator-agent:latest",
+ Logger: logger,
+ LogDir: cfg.LogDir,
+ APIURL: apiURL,
+ DropsDir: cfg.DropsDir,
},
- "gemini": &executor.GeminiRunner{
- BinaryPath: cfg.GeminiBinaryPath,
- Logger: logger,
- LogDir: cfg.LogDir,
- APIURL: apiURL,
- DropsDir: cfg.DropsDir,
+ "gemini": &executor.ContainerRunner{
+ Image: "claudomator-agent:latest",
+ Logger: logger,
+ LogDir: cfg.LogDir,
+ APIURL: apiURL,
+ DropsDir: cfg.DropsDir,
+ },
+ "container": &executor.ContainerRunner{
+ Image: "claudomator-agent:latest",
+ Logger: logger,
+ LogDir: cfg.LogDir,
+ APIURL: apiURL,
+ DropsDir: cfg.DropsDir,
},
}
diff --git a/internal/executor/container.go b/internal/executor/container.go
new file mode 100644
index 0000000..e148620
--- /dev/null
+++ b/internal/executor/container.go
@@ -0,0 +1,172 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+ "syscall"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// ContainerRunner executes an agent inside a container.
+type ContainerRunner struct {
+ Image string // default image if not specified in task
+ Logger *slog.Logger
+ LogDir string
+ APIURL string
+ DropsDir string
+ SSHAuthSock string // optional path to host SSH agent
+}
+
+func (r *ContainerRunner) ExecLogDir(execID string) string {
+ if r.LogDir == "" {
+ return ""
+ }
+ return filepath.Join(r.LogDir, execID)
+}
+
+func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error {
+ repoURL := t.RepositoryURL
+ if repoURL == "" {
+ // Fallback to project_dir if repository_url is not set (legacy support)
+ if t.Agent.ProjectDir != "" {
+ repoURL = t.Agent.ProjectDir
+ } else {
+ return fmt.Errorf("task %s has no repository_url or project_dir", t.ID)
+ }
+ }
+
+ image := t.Agent.ContainerImage
+ if image == "" {
+ image = r.Image
+ }
+ if image == "" {
+ image = "claudomator-agent:latest"
+ }
+
+ // 1. Setup workspace on host
+ workspace, err := os.MkdirTemp("", "claudomator-workspace-*")
+ if err != nil {
+ return fmt.Errorf("creating workspace: %w", err)
+ }
+ defer os.RemoveAll(workspace)
+
+ // 2. Clone repo into workspace
+ r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace)
+ if out, err := exec.CommandContext(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil {
+ return fmt.Errorf("git clone failed: %w\n%s", err, string(out))
+ }
+
+ // 3. Prepare logs
+ logDir := r.ExecLogDir(e.ID)
+ if logDir == "" {
+ logDir = filepath.Join(workspace, ".claudomator-logs")
+ }
+ if err := os.MkdirAll(logDir, 0700); err != nil {
+ return fmt.Errorf("creating log dir: %w", err)
+ }
+ e.StdoutPath = filepath.Join(logDir, "stdout.log")
+ e.StderrPath = filepath.Join(logDir, "stderr.log")
+ e.ArtifactDir = logDir
+
+ stdoutFile, err := os.Create(e.StdoutPath)
+ if err != nil {
+ return fmt.Errorf("creating stdout log: %w", err)
+ }
+ defer stdoutFile.Close()
+
+ stderrFile, err := os.Create(e.StderrPath)
+ if err != nil {
+ return fmt.Errorf("creating stderr log: %w", err)
+ }
+ defer stderrFile.Close()
+
+ // 4. Run container
+ // Build docker command
+ args := []string{
+ "run", "--rm",
+ "-v", workspace + ":/workspace",
+ "-w", "/workspace",
+ "-e", "CLAUDOMATOR_API_URL=" + r.APIURL,
+ "-e", "CLAUDOMATOR_TASK_ID=" + e.TaskID,
+ "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir,
+ "-e", "ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"),
+ "-e", "GOOGLE_API_KEY=" + os.Getenv("GOOGLE_API_KEY"),
+ }
+
+ // Inject custom instructions as environment variable or via file
+ instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt")
+ if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0600); err != nil {
+ return fmt.Errorf("writing instructions: %w", err)
+ }
+
+ // Command to run inside container: we assume the image has 'claude' or 'gemini'
+ // and a wrapper script that reads CLAUDOMATOR_TASK_ID etc.
+ innerCmd := []string{"claude", "-p", t.Agent.Instructions, "--session-id", e.ID, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"}
+ if t.Agent.Type == "gemini" {
+ innerCmd = []string{"gemini", "-p", t.Agent.Instructions} // simplified for now
+ }
+
+ args = append(args, image)
+ args = append(args, innerCmd...)
+
+ r.Logger.Info("starting container", "image", image, "taskID", t.ID)
+ cmd := exec.CommandContext(ctx, "docker", args...)
+ cmd.Stderr = stderrFile
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ // Use os.Pipe for stdout so we can parse it in real-time
+ stdoutR, stdoutW, err := os.Pipe()
+ if err != nil {
+ return fmt.Errorf("creating stdout pipe: %w", err)
+ }
+ cmd.Stdout = stdoutW
+
+ if err := cmd.Start(); err != nil {
+ stdoutW.Close()
+ stdoutR.Close()
+ return fmt.Errorf("starting container: %w", err)
+ }
+ stdoutW.Close()
+
+ // Stream stdout to the log file and parse cost/errors.
+ var costUSD float64
+ var streamErr error
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger)
+ stdoutR.Close()
+ }()
+
+ waitErr := cmd.Wait()
+ wg.Wait()
+
+ e.CostUSD = costUSD
+
+ // 5. Post-execution: push changes if successful
+ if waitErr == nil && streamErr == nil {
+ r.Logger.Info("pushing changes back to remote", "url", repoURL)
+ // We assume the sandbox has committed changes (the agent image should enforce this)
+ if out, err := exec.CommandContext(ctx, "git", "-C", workspace, "push", "origin", "HEAD").CombinedOutput(); err != nil {
+ r.Logger.Warn("git push failed", "error", err, "output", string(out))
+ // Don't fail the task just because push failed, but record it?
+ // Actually, user said: "they should only ever commit to their sandbox, and only ever push to an actual remote"
+ // So push failure is a task failure in this new model.
+ return fmt.Errorf("git push failed: %w\n%s", err, string(out))
+ }
+ }
+
+ if waitErr != nil {
+ return fmt.Errorf("container execution failed: %w", waitErr)
+ }
+
+ return nil
+}
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 25801b2..8bc9864 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -87,6 +87,7 @@ func (s *DB) migrate() error {
`ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`,
`ALTER TABLE tasks ADD COLUMN elaboration_input TEXT`,
`ALTER TABLE tasks ADD COLUMN project TEXT`,
+ `ALTER TABLE tasks ADD COLUMN repository_url TEXT`,
`CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
@@ -135,9 +136,9 @@ func (s *DB) CreateTask(t *task.Task) error {
}
_, err = s.db.Exec(`
- INSERT INTO tasks (id, name, description, elaboration_input, project, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, string(configJSON), string(t.Priority),
+ INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority),
t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON),
t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(),
)
@@ -146,13 +147,13 @@ func (s *DB) CreateTask(t *task.Task) error {
// GetTask retrieves a task by ID.
func (s *DB) GetTask(id string) (*task.Task, error) {
- row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id)
+ row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id)
return scanTask(row)
}
// ListTasks returns tasks matching the given filter.
func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) {
- query := `SELECT id, name, description, elaboration_input, project, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE 1=1`
+ query := `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE 1=1`
var args []interface{}
if filter.State != "" {
@@ -188,7 +189,7 @@ func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) {
// ListSubtasks returns all tasks whose parent_task_id matches the given ID.
func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) {
- rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID)
+ rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID)
if err != nil {
return nil, err
}
@@ -241,7 +242,7 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) {
}
defer tx.Rollback() //nolint:errcheck
- t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id))
+ t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id))
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task %q not found", id)
@@ -688,15 +689,17 @@ func scanTask(row scanner) (*task.Task, error) {
parentTaskID sql.NullString
elaborationInput sql.NullString
project sql.NullString
+ repositoryURL sql.NullString
rejectionComment sql.NullString
questionJSON sql.NullString
summary sql.NullString
interactionsJSON sql.NullString
)
- err := row.Scan(&t.ID, &t.Name, &t.Description, &elaborationInput, &project, &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, &rejectionComment, &questionJSON, &summary, &interactionsJSON)
+ err := row.Scan(&t.ID, &t.Name, &t.Description, &elaborationInput, &project, &repositoryURL, &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, &rejectionComment, &questionJSON, &summary, &interactionsJSON)
t.ParentTaskID = parentTaskID.String
t.ElaborationInput = elaborationInput.String
t.Project = project.String
+ t.RepositoryURL = repositoryURL.String
t.RejectionComment = rejectionComment.String
t.QuestionJSON = questionJSON.String
t.Summary = summary.String
diff --git a/internal/task/task.go b/internal/task/task.go
index 3a04716..5b2fff3 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -32,7 +32,9 @@ type AgentConfig struct {
Model string `yaml:"model" json:"model"`
ContextFiles []string `yaml:"context_files" json:"context_files"`
Instructions string `yaml:"instructions" json:"instructions"`
- ProjectDir string `yaml:"project_dir" json:"project_dir"`
+ RepositoryURL string `yaml:"repository_url" json:"repository_url"`
+ ContainerImage string `yaml:"container_image" json:"container_image"`
+ ProjectDir string `yaml:"project_dir" json:"project_dir"` // Deprecated: use RepositoryURL
MaxBudgetUSD float64 `yaml:"max_budget_usd" json:"max_budget_usd"`
PermissionMode string `yaml:"permission_mode" json:"permission_mode"`
AllowedTools []string `yaml:"allowed_tools" json:"allowed_tools"`
@@ -74,7 +76,8 @@ type Task struct {
ParentTaskID string `yaml:"parent_task_id" json:"parent_task_id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
- Project string `yaml:"project" json:"project"`
+ Project string `yaml:"project" json:"project"` // Human-readable project name
+ RepositoryURL string `yaml:"repository_url" json:"repository_url"`
Agent AgentConfig `yaml:"agent" json:"agent"`
Timeout Duration `yaml:"timeout" json:"timeout"`
Retry RetryConfig `yaml:"retry" json:"retry"`
diff --git a/web/app.js b/web/app.js
index 77a5d19..c9ab718 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1478,12 +1478,13 @@ function buildValidatePayload() {
const f = document.getElementById('task-form');
const name = f.querySelector('[name="name"]').value;
const instructions = f.querySelector('[name="instructions"]').value;
- const project_dir = f.querySelector('#project-select').value;
+ const repository_url = document.getElementById('repository-url').value;
+ const container_image = document.getElementById('container-image').value;
const allowedToolsEl = f.querySelector('[name="allowed_tools"]');
const allowed_tools = allowedToolsEl
? allowedToolsEl.value.split(',').map(s => s.trim()).filter(Boolean)
: [];
- return { name, agent: { instructions, project_dir, allowed_tools } };
+ return { name, repository_url, agent: { instructions, container_image, allowed_tools } };
}
function renderValidationResult(result) {
@@ -1541,49 +1542,6 @@ function renderValidationResult(result) {
async function openTaskModal() {
document.getElementById('task-modal').showModal();
- await populateProjectSelect();
-}
-
-async function populateProjectSelect() {
- const select = document.getElementById('project-select');
- const current = select.value;
- try {
- const res = await fetch(`${API_BASE}/api/workspaces`);
- const dirs = await res.json();
- select.innerHTML = '';
- dirs.forEach(dir => {
- const opt = document.createElement('option');
- opt.value = dir;
- opt.textContent = dir;
- if (dir === current || dir === '/workspace/claudomator') opt.selected = true;
- select.appendChild(opt);
- });
- } catch {
- // keep whatever options are already there
- }
- // Ensure "Create new project…" option is always last
- const newOpt = document.createElement('option');
- newOpt.value = '__new__';
- newOpt.textContent = 'Create new project…';
- select.appendChild(newOpt);
-}
-
-function initProjectSelect() {
- const select = document.getElementById('project-select');
- const newRow = document.getElementById('new-project-row');
- const newInput = document.getElementById('new-project-input');
- if (!select) return;
- select.addEventListener('change', () => {
- if (select.value === '__new__') {
- newRow.hidden = false;
- newInput.required = true;
- newInput.focus();
- } else {
- newRow.hidden = true;
- newInput.required = false;
- newInput.value = '';
- }
- });
}
function closeTaskModal() {
@@ -1597,20 +1555,20 @@ function closeTaskModal() {
}
async function createTask(formData) {
- const selectVal = formData.get('project_dir');
- const workingDir = selectVal === '__new__'
- ? document.getElementById('new-project-input').value.trim()
- : selectVal;
+ const repository_url = formData.get('repository_url');
+ const container_image = formData.get('container_image');
const elaboratePromptEl = document.getElementById('elaborate-prompt');
const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : '';
const body = {
name: formData.get('name'),
description: '',
elaboration_input: elaborationInput || undefined,
+ repository_url: repository_url,
agent: {
instructions: formData.get('instructions'),
- project_dir: workingDir,
+ container_image: container_image,
max_budget_usd: parseFloat(formData.get('max_budget_usd')),
+ type: 'container',
},
timeout: formData.get('timeout'),
priority: formData.get('priority'),
@@ -2781,11 +2739,8 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
-
- // Task modal
- document.getElementById('btn-new-task').addEventListener('click', openTaskModal);
- document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
- initProjectSelect();
+document.getElementById('btn-new-task').addEventListener('click', openTaskModal);
+document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal);
// Push notifications button
const btnNotify = document.getElementById('btn-notifications');
@@ -2836,11 +2791,8 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove());
try {
- const sel = document.getElementById('project-select');
- const workingDir = sel.value === '__new__'
- ? document.getElementById('new-project-input').value.trim()
- : sel.value;
- const result = await elaborateTask(prompt, workingDir);
+ const repoUrl = document.getElementById('repository-url').value.trim();
+ const result = await elaborateTask(prompt, repoUrl);
// Populate form fields
const f = document.getElementById('task-form');
@@ -2848,17 +2800,11 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded
f.querySelector('[name="name"]').value = result.name;
if (result.agent && result.agent.instructions)
f.querySelector('[name="instructions"]').value = result.agent.instructions;
- if (result.agent && (result.agent.project_dir || result.agent.working_dir)) {
- const pDir = result.agent.project_dir || result.agent.working_dir;
- const pSel = document.getElementById('project-select');
- const exists = [...pSel.options].some(o => o.value === pDir);
- if (exists) {
- pSel.value = pDir;
- } else {
- pSel.value = '__new__';
- document.getElementById('new-project-row').hidden = false;
- document.getElementById('new-project-input').value = pDir;
- }
+ if (result.repository_url || result.agent?.repository_url) {
+ document.getElementById('repository-url').value = result.repository_url || result.agent.repository_url;
+ }
+ if (result.agent && result.agent.container_image) {
+ document.getElementById('container-image').value = result.agent.container_image;
}
if (result.agent && result.agent.max_budget_usd != null)
f.querySelector('[name="max_budget_usd"]').value = result.agent.max_budget_usd;
diff --git a/web/index.html b/web/index.html
index c17601b..7d52458 100644
--- a/web/index.html
+++ b/web/index.html
@@ -74,15 +74,12 @@
<p class="elaborate-hint">AI will fill in the form fields below. You can edit before submitting.</p>
</div>
<hr class="form-divider">
- <label>Project
- <select name="project_dir" id="project-select">
- <option value="/workspace/claudomator" selected>/workspace/claudomator</option>
- <option value="__new__">Create new project…</option>
- </select>
+ <label>Repository URL
+ <input name="repository_url" id="repository-url" placeholder="https://github.com/user/repo.git" required>
+ </label>
+ <label>Container Image
+ <input name="container_image" id="container-image" placeholder="claudomator-agent:latest" value="claudomator-agent:latest">
</label>
- <div id="new-project-row" hidden>
- <label>New Project Path <input id="new-project-input" placeholder="/workspace/my-new-app"></label>
- </div>
<label>Name <input name="name" required></label>
<label>Instructions <textarea name="instructions" rows="6" required></textarea></label>
<div class="validate-section">