summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/elaborate_test.go2
-rw-r--r--internal/api/server_test.go2
-rw-r--r--internal/executor/claude.go22
-rw-r--r--internal/executor/claude_test.go8
-rw-r--r--internal/executor/gemini.go57
-rwxr-xr-xscripts/deploy13
-rw-r--r--scripts/hooks/pre-commit7
-rw-r--r--scripts/hooks/pre-push6
-rw-r--r--scripts/install-hooks23
-rw-r--r--scripts/verify17
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."