diff options
| -rw-r--r-- | internal/api/elaborate_test.go | 2 | ||||
| -rw-r--r-- | internal/api/server_test.go | 2 | ||||
| -rw-r--r-- | internal/executor/claude.go | 22 | ||||
| -rw-r--r-- | internal/executor/claude_test.go | 8 | ||||
| -rw-r--r-- | internal/executor/gemini.go | 57 | ||||
| -rwxr-xr-x | scripts/deploy | 13 | ||||
| -rw-r--r-- | scripts/hooks/pre-commit | 7 | ||||
| -rw-r--r-- | scripts/hooks/pre-push | 6 | ||||
| -rw-r--r-- | scripts/install-hooks | 23 | ||||
| -rw-r--r-- | scripts/verify | 17 |
10 files changed, 119 insertions, 38 deletions
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go index 0b5c706..5216bcd 100644 --- a/internal/api/elaborate_test.go +++ b/internal/api/elaborate_test.go @@ -350,6 +350,8 @@ func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { // Fake Claude returns something that is not valid JSON. srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) + // Ensure Gemini fallback also fails so we get the expected 502. + srv.geminiBinPath = "/nonexistent/gemini" body := `{"prompt":"do something"}` req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5c0deba..516e289 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -255,7 +255,7 @@ func TestGeminiLogs_ParsedCorrectly(t *testing.T) { } // 6. Verify the content retrieved via the API endpoint. - req = httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions/"+exec.ID+"/log", nil) + req = httptest.NewRequest("GET", "/api/executions/"+exec.ID+"/log", nil) w = httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 7e79ce0..df81f76 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -116,7 +116,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi e.SandboxDir = "" if projectDir != "" { var err error - sandboxDir, err := setupSandbox(t.Agent.ProjectDir, r.Logger) + sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger) if err != nil { return fmt.Errorf("setting up sandbox: %w", err) } @@ -128,7 +128,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi } } else if projectDir != "" { var err error - sandboxDir, err := setupSandbox(t.Agent.ProjectDir, r.Logger) + sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger) if err != nil { return fmt.Errorf("setting up sandbox: %w", err) } @@ -236,13 +236,17 @@ func gitSafe(args ...string) []string { // remote named "local" (a local bare repo that accepts pushes cleanly), then // falls back to "origin", then to the working copy path itself. func sandboxCloneSource(projectDir string) string { - for _, remote := range []string{"local", "origin"} { - out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", remote)...).Output() - if err == nil { - u := strings.TrimSpace(string(out)) - if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) { - return u - } + // Prefer "local" remote, but only if it points to a local path (accepts pushes). + if out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", "local")...).Output(); err == nil { + u := strings.TrimSpace(string(out)) + if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) { + return u + } + } + // Fall back to "origin" — any URL scheme is acceptable for cloning. + if out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", "origin")...).Output(); err == nil { + if u := strings.TrimSpace(string(out)); u != "" { + return u } } return projectDir diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index 04ea6b7..e76fbf2 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -414,7 +414,7 @@ func TestSetupSandbox_ClonesGitRepo(t *testing.T) { src := t.TempDir() initGitRepo(t, src) - sandbox, err := setupSandbox(src) + sandbox, err := setupSandbox(src, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("setupSandbox: %v", err) } @@ -441,7 +441,7 @@ func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { // A plain directory (not a git repo) should be initialised then cloned. src := t.TempDir() - sandbox, err := setupSandbox(src) + sandbox, err := setupSandbox(src, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("setupSandbox on plain dir: %v", err) } @@ -621,12 +621,12 @@ func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) { func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) { src := t.TempDir() initGitRepo(t, src) - sandbox, err := setupSandbox(src) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + sandbox, err := setupSandbox(src, logger) if err != nil { t.Fatalf("setupSandbox: %v", err) } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output() diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go index d79c47d..a13321b 100644 --- a/internal/executor/gemini.go +++ b/internal/executor/gemini.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "fmt" "io" "log/slog" @@ -146,31 +147,51 @@ func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, } logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput)) - outputStr := strings.TrimSpace(string(fullOutput)) // Trim leading/trailing whitespace/newlines from the whole output - - jsonContent := outputStr // Default to raw output if no markdown block is found or malformed - jsonStartIdx := strings.Index(outputStr, "```json") - if jsonStartIdx != -1 { - // Found "```json", now look for the closing "```" - jsonEndIdx := strings.LastIndex(outputStr, "```") - if jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx { - // Extract content between the markdown fences. - jsonContent = outputStr[jsonStartIdx+len("```json"):jsonEndIdx] - jsonContent = strings.TrimSpace(jsonContent) // Trim again after extraction, to remove potential inner newlines + // Default: write raw content as-is (preserves trailing newline). + jsonContent := string(fullOutput) + + // Unwrap markdown code fences if present. + trimmed := strings.TrimSpace(jsonContent) + if jsonStartIdx := strings.Index(trimmed, "```json"); jsonStartIdx != -1 { + if jsonEndIdx := strings.LastIndex(trimmed, "```"); jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx { + inner := trimmed[jsonStartIdx+len("```json") : jsonEndIdx] + jsonContent = strings.TrimSpace(inner) + "\n" } else { - logger.Warn("Malformed markdown JSON block from Gemini (missing closing ``` or invalid structure), falling back to raw output.", "outputLength", len(outputStr)) + logger.Warn("malformed markdown JSON block from Gemini, falling back to raw output", "outputLength", len(jsonContent)) } - } else { - logger.Warn("No markdown JSON block found from Gemini, falling back to raw output.", "outputLength", len(outputStr)) } - // Write the (possibly extracted and trimmed) JSON content to the writer. - _, writeErr := w.Write([]byte(jsonContent)) - if writeErr != nil { + // Write the (possibly extracted) JSON content to the writer. + if _, writeErr := w.Write([]byte(jsonContent)); writeErr != nil { return 0, fmt.Errorf("writing extracted gemini json: %w", writeErr) } - return 0, nil // For now, no cost/error parsing for Gemini stream + // Parse each line for result type to extract cost and execution errors. + var resultErr error + var costUSD float64 + for _, line := range strings.Split(jsonContent, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg struct { + Type string `json:"type"` + IsError bool `json:"is_error"` + Result string `json:"result"` + Cost float64 `json:"total_cost_usd"` + } + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + if msg.Type == "result" { + costUSD = msg.Cost + if msg.IsError { + resultErr = fmt.Errorf("gemini execution error: %s", msg.Result) + } + } + } + + return costUSD, resultErr } func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string { diff --git a/scripts/deploy b/scripts/deploy index c7ff734..206087d 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -31,21 +31,22 @@ if [ "$DIRTY" = false ] && [ -n "$(git status --porcelain)" ]; then 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..." 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/ +chown www-data:www-data "${BIN_DIR}/claudomator" +chmod +x "${BIN_DIR}/claudomator" echo "==> Copying scripts..." mkdir -p "${SITE_DIR}/scripts" -cp "${REPO_DIR}/scripts/"* "${SITE_DIR}/scripts/" +cp -p "${REPO_DIR}/scripts/"* "${SITE_DIR}/scripts/" chown -R www-data:www-data "${SITE_DIR}/scripts" -chmod +x "${SITE_DIR}/scripts/"* - -echo "==> Fixing permissions..." -chown www-data:www-data "${BIN_DIR}/claudomator" -chmod +x "${BIN_DIR}/claudomator" +find "${SITE_DIR}/scripts" -maxdepth 1 -type f -exec chmod +x {} + if [ -f "${BIN_DIR}/claude" ]; then echo "==> Fixing Claude permissions..." diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100644 index 0000000..faf91fc --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/bash +# pre-commit — Reject commits that don't compile. +set -euo pipefail +REPO_DIR="$(git rev-parse --show-toplevel)" +echo "pre-commit: go build ./..." +cd "${REPO_DIR}" +go build ./... diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100644 index 0000000..d851332 --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,6 @@ +#!/bin/bash +# pre-push — Reject pushes where tests fail. +set -euo pipefail +REPO_DIR="$(git rev-parse --show-toplevel)" +echo "pre-push: running scripts/verify..." +exec "${REPO_DIR}/scripts/verify" diff --git a/scripts/install-hooks b/scripts/install-hooks new file mode 100644 index 0000000..454f3cd --- /dev/null +++ b/scripts/install-hooks @@ -0,0 +1,23 @@ +#!/bin/bash +# install-hooks — Symlink version-controlled hooks into .git/hooks/ +# Usage: ./scripts/install-hooks +# Example: ./scripts/install-hooks + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +HOOKS_SRC="${REPO_DIR}/scripts/hooks" +HOOKS_DST="${REPO_DIR}/.git/hooks" + +for hook in "${HOOKS_SRC}"/*; do + name="$(basename "${hook}")" + target="${HOOKS_DST}/${name}" + if [ -e "${target}" ] && [ ! -L "${target}" ]; then + echo " skipping ${name}: non-symlink already exists at ${target}" + continue + fi + ln -sf "${hook}" "${target}" + echo " installed ${name}" +done + +echo "==> Hooks installed." diff --git a/scripts/verify b/scripts/verify new file mode 100644 index 0000000..4f9c52f --- /dev/null +++ b/scripts/verify @@ -0,0 +1,17 @@ +#!/bin/bash +# verify — Build and test the claudomator codebase +# Usage: ./scripts/verify +# Example: ./scripts/verify + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "${REPO_DIR}" + +echo "==> Building..." +go build ./... + +echo "==> Testing (race detector on)..." +go test -race ./... + +echo "==> All checks passed." |
