From a55b250ca2bff44170c8c26640d16927df91f88e Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 17:02:23 +0000 Subject: chore: autocommit uncommitted changes --- web/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index eff8054..6070d2c 100644 --- a/web/app.js +++ b/web/app.js @@ -1743,7 +1743,7 @@ function makeMetaItem(label, valueText, opts = {}) { return item; } -function renderTaskPanel(task, executions) { +export function renderTaskPanel(task, executions) { document.getElementById('task-panel-title').textContent = task.name; const content = document.getElementById('task-panel-content'); content.innerHTML = ''; -- cgit v1.2.3 From b5df9cadd7b8a275b8688ee8fb957142536fd26a Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 18:02:22 +0000 Subject: feat: persist active main tab to localStorage On tab click, store the tab name under 'activeMainTab' in localStorage. On DOMContentLoaded, restore the previously active tab instead of always defaulting to 'queue'. Exported getActiveMainTab/setActiveMainTab for testability, following the same pattern as getTaskFilterTab/setTaskFilterTab. Tests: web/test/tab-persistence.test.mjs (6 tests, all green). Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 12 +++++++- web/test/tab-persistence.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 web/test/tab-persistence.test.mjs (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index 6070d2c..9a5a460 100644 --- a/web/app.js +++ b/web/app.js @@ -407,6 +407,14 @@ export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } +export function getActiveMainTab() { + return localStorage.getItem('activeMainTab') ?? 'queue'; +} + +export function setActiveMainTab(tab) { + localStorage.setItem('activeMainTab', tab); +} + // ── Tab badge counts ─────────────────────────────────────────────────────────── /** @@ -2721,6 +2729,8 @@ async function renderDropsPanel() { // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { + setActiveMainTab(name); + // Update tab button active state document.querySelectorAll('.tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === name); @@ -2746,7 +2756,7 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded handleStartNextTask(this); }); - switchTab('queue'); + switchTab(getActiveMainTab()); startPolling(); connectWebSocket(); diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs new file mode 100644 index 0000000..9311453 --- /dev/null +++ b/web/test/tab-persistence.test.mjs @@ -0,0 +1,58 @@ +// tab-persistence.test.mjs — TDD tests for main-tab localStorage persistence +// +// Run with: node --test web/test/tab-persistence.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── localStorage mock ────────────────────────────────────────────────────────── +// Must be set up before importing app.js so the module sees the global. +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => store.has(k) ? store.get(k) : null, + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), +}; + +import { getActiveMainTab, setActiveMainTab } from '../app.js'; + +describe('getActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('returns "queue" when localStorage has no stored value', () => { + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('returns the tab name stored by setActiveMainTab', () => { + setActiveMainTab('settings'); + assert.equal(getActiveMainTab(), 'settings'); + }); + + it('returns "queue" after localStorage value is removed', () => { + setActiveMainTab('stats'); + localStorage.removeItem('activeMainTab'); + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('reflects the most recent setActiveMainTab call', () => { + setActiveMainTab('stats'); + setActiveMainTab('running'); + assert.equal(getActiveMainTab(), 'running'); + }); +}); + +describe('setActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('writes the tab name to localStorage under key "activeMainTab"', () => { + setActiveMainTab('drops'); + assert.equal(localStorage.getItem('activeMainTab'), 'drops'); + }); + + it('overwrites a previously stored tab', () => { + setActiveMainTab('queue'); + setActiveMainTab('interrupted'); + assert.equal(localStorage.getItem('activeMainTab'), 'interrupted'); + }); +}); -- cgit v1.2.3 From 1d550c1196ea836e0a0f798ba0127c1086f5f963 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 08:04:52 +0000 Subject: fix: unsubscribe stale push subscription before re-subscribing When the VAPID key changes (e.g. after the key-swap fix), the browser's cached PushSubscription was created with the old key. Calling PushManager.subscribe() with a different applicationServerKey then throws "The provided applicationServerKey is not valid". Fix by calling getSubscription()/unsubscribe() before subscribe() so any stale subscription is cleared. Adds web test covering both the stale and fresh subscription paths. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 8 +++++ web/test/enable-notifications.test.mjs | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 web/test/enable-notifications.test.mjs (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index 9a5a460..77a5d19 100644 --- a/web/app.js +++ b/web/app.js @@ -2646,6 +2646,14 @@ async function enableNotifications(btn) { await registerServiceWorker(); const registration = await navigator.serviceWorker.ready; + // Unsubscribe any stale subscription (e.g. from a VAPID key rotation). + // PushManager.subscribe() throws "applicationServerKey is not valid" if the + // existing subscription was created with a different key. + const existingSub = await registration.pushManager.getSubscription(); + if (existingSub) { + await existingSub.unsubscribe(); + } + // Subscribe via PushManager. const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, diff --git a/web/test/enable-notifications.test.mjs b/web/test/enable-notifications.test.mjs new file mode 100644 index 0000000..c8afdd3 --- /dev/null +++ b/web/test/enable-notifications.test.mjs @@ -0,0 +1,64 @@ +// enable-notifications.test.mjs — Tests for the enableNotifications subscription flow. +// +// Run with: node --test web/test/enable-notifications.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Logic under test ────────────────────────────────────────────────────────── +// +// When subscribing to push notifications, any existing stale subscription +// (e.g. from before a VAPID key rotation) must be unsubscribed first. +// Otherwise the browser rejects subscribe() with "applicationServerKey is not valid". + +/** + * Extracted subscription logic (mirrors enableNotifications in app.js). + * Returns the new subscription endpoint, or throws on error. + */ +async function subscribeWithUnsubscribeStale(pushManager, applicationServerKey) { + // Clear any stale existing subscription. + const existing = await pushManager.getSubscription(); + if (existing) { + await existing.unsubscribe(); + } + const sub = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + return sub; +} + +describe('subscribeWithUnsubscribeStale', () => { + it('unsubscribes existing subscription before subscribing', async () => { + let unsubscribeCalled = false; + const existingSub = { + unsubscribe: async () => { unsubscribeCalled = true; }, + }; + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => existingSub, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(unsubscribeCalled, true, 'existing subscription should have been unsubscribed'); + assert.equal(subscribeCalled, true, 'new subscription should have been created'); + }); + + it('subscribes normally when no existing subscription', async () => { + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => null, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + const sub = await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(subscribeCalled, true, 'subscribe should have been called'); + assert.ok(sub, 'subscription object should be returned'); + }); +}); -- cgit v1.2.3 From 0fb4e3e81c20b2e2b58040772b747ec1dd9e09e7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 18 Mar 2026 00:17:50 +0000 Subject: 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. --- docs/adr/005-sandbox-execution-model.md | 23 +++-- docs/adr/006-containerized-execution.md | 51 ++++++++++ images/agent-base/Dockerfile | 37 +++++++ internal/api/server.go | 2 + internal/api/webhook.go | 3 +- internal/api/webhook_test.go | 8 +- internal/cli/serve.go | 31 +++--- internal/executor/container.go | 172 ++++++++++++++++++++++++++++++++ internal/storage/db.go | 19 ++-- internal/task/task.go | 7 +- web/app.js | 88 ++++------------ web/index.html | 13 +-- 12 files changed, 341 insertions(+), 113 deletions(-) create mode 100644 docs/adr/006-containerized-execution.md create mode 100644 images/agent-base/Dockerfile create mode 100644 internal/executor/container.go (limited to 'web/app.js') 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 ` 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 @@

AI will fill in the form fields below. You can edit before submitting.


-