Details and log output for this agent run.
{"path":".","recursive":true}
/project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/forgejo.go /project/internal/tools/git.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go /project/zoo.hcl.example
{"command":"cat /event 2>/dev/null | head -50 || echo \"no /event file\""}
{"id":46,"url":"https://code.stdio.dk/api/v1/repos/abrander/zoo/issues/17","html_url":"https://code.stdio.dk/abrander/zoo/issues/17","number":17,"user":{"id":1,"login":"abrander","login_name":"","source_id":0,"full_name":"Anders Brander","email":"anders@brander.dk","html_url":"https://code.stdio.dk/abrander","avatar_url":"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d","language":"en-US","is_admin":true,"last_login":"2026-08-23T02:40:28+02:00","created":"2025-03-27T21:24:00+01:00","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"Real human :-)\r\n\r\nI manage all this. Talk to me if you get stuck.","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0},"original_author":"","original_author_id":0,"title":"Profile photos","body":"Can we add small profile photos to the /jobs endpoint to make it clearer who's currently running a job?\r\n\r\nJust link the actual profile photos on Forgejo.","ref":"","labels":[],"milestone":null,"assignees":[{"id":2,"login":"greg","login_name":"","source_id":0,"full_name":"Greg Coolio","email":"greg@noreply.localhost","html_url":"https://code.stdio.dk/greg","avatar_url":"https://code.stdio.dk/avatars/893e287248431844784e5109d4b0b7eaa5fe7ea602efdec156f7b57c7034aa57","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-08-18T20:13:06+02:00","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"\n### Senior Developer \u0026 Architect\n\n- 15+ years building scalable systems and leading teams.\n- Expert in architecture, clean code and problem solving.\n- Turns complex problems into elegant solutions.\n\t\t","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0}],"state":"open","is_locked":false,"comments":1,"created_at":"2026-08-23T04:12:45+02:00","updated_at":"2026-08-23T10:44:17+02:00","closed_at":null,"due_date":null,"pull_request":null,"repository":{"id":6,"name":"zoo","owner":"abrander","full_name":"abrander/zoo"}}
{"path":"internal/web/web.go","start_line":1,"end_line":200}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\r\n// overview, live queue/history, and a per-job log tail.\r\npackage web\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"html/template\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\ntype Server struct {\r\n\tcfg *config.Config\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\ttmpl *template.Template\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub) *Server {\r\n\treturn \u0026Server{cfg: cfg, store: st, hub: hub, tmpl: template.Must(template.New(\"\").Parse(templates))}\r\n}\r\n\r\n// Handler returns the dashboard's http.Handler, gated by config.Web's\r\n// bearer token if one is set.\r\nfunc (s *Server) Handler() http.Handler {\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\r\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\r\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\r\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\r\n\r\n\treturn s.authMiddleware(mux)\r\n}\r\n\r\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\r\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\r\n\t\treturn next\r\n\t}\r\n\r\n\ttoken := s.cfg.Web.Token\r\n\r\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tauth := r.Header.Get(\"Authorization\")\r\n\t\tif auth != \"Bearer \"+token {\r\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\r\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\r\n\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tnext.ServeHTTP(w, r)\r\n\t})\r\n}\r\n\r\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\r\n\ts.render(w, \"index\", s.cfg)\r\n}\r\n\r\nfunc (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\r\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\r\n\tif err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\ts.render(w, \"jobs\", jobs)\r\n}\r\n\r\n// toolBlock is the parsed form of a stream=\"tool\" store.LogLine, for the\r\n// template to render as a single collapsed detail.\r\ntype toolBlock struct {\r\n\tName string\r\n\tArguments string\r\n\tResult string\r\n\tError bool\r\n}\r\n\r\n// block is one self-contained, already-complete unit of job output: a\r\n// finished reasoning or assistant-message block, a finished tool call,\r\n// or a misc system note. Unlike the old flat log view, one store.LogLine\r\n// maps to exactly one block — grouping/streaming happens upstream, when\r\n// agentrun persists the row.\r\ntype block struct {\r\n\tKind string // \"reasoning\" | \"content\" | \"tool\" | \"system\"\r\n\tText string\r\n\tTool *toolBlock\r\n}\r\n\r\nfunc buildBlocks(logs []store.LogLine) []block {\r\n\tblocks := make([]block, 0, len(logs))\r\n\r\n\tfor _, l := range logs {\r\n\t\tswitch l.Stream {\r\n\t\tcase \"reasoning\", \"content\":\r\n\t\t\tblocks = append(blocks, block{Kind: l.Stream, Text: l.Line})\r\n\r\n\t\tcase \"tool\":\r\n\t\t\tvar entry store.ToolLogEntry\r\n\t\t\tif err := json.Unmarshal([]byte(l.Line), \u0026entry); err != nil {\r\n\t\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\r\n\r\n\t\t\t\tcontinue\r\n\t\t\t}\r\n\r\n\t\t\tblocks = append(blocks, block{Kind: \"tool\", Tool: \u0026toolBlock{\r\n\t\t\t\tName: entry.Name,\r\n\t\t\t\tArguments: entry.Arguments,\r\n\t\t\t\tResult: entry.Result,\r\n\t\t\t\tError: entry.Error,\r\n\t\t\t}})\r\n\r\n\t\tdefault:\r\n\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\r\n\t\t}\r\n\t}\r\n\r\n\treturn blocks\r\n}\r\n\r\nfunc (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {\r\n\tid := r.PathValue(\"id\")\r\n\r\n\tjob, err := s.store.GetJob(r.Context(), id)\r\n\tif err != nil {\r\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\tlogs, err := s.store.TailLogs(r.Context(), id, -1)\r\n\tif err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\ts.render(w, \"job_detail\", struct {\r\n\t\tJob store.Job\r\n\t\tBlocks []block\r\n\t\tLive bool\r\n\t}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning})\r\n}\r\n\r\n// handleJobEvents streams job jobID's live output as Server-Sent\r\n// Events. It only ever carries events for the current, not-yet-persisted\r\n// block (see livelog.Hub.Checkpoint) — everything already written to\r\n// the store is rendered once, statically, by handleJobDetail. If the\r\n// job isn't live, the response just ends immediately and the client\r\n// falls back to the static page.\r\nfunc (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {\r\n\tid := r.PathValue(\"id\")\r\n\r\n\tjob, err := s.store.GetJob(r.Context(), id)\r\n\tif err != nil {\r\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\tif job.Status != store.JobPending \u0026\u0026 job.Status != store.JobRunning {\r\n\t\treturn\r\n\t}\r\n\r\n\tflusher, ok := w.(http.Flusher)\r\n\tif !ok {\r\n\t\thttp.Error(w, \"streaming unsupported\", http.StatusInternalServerError)\r\n\r\n\t\treturn\r\n\t}\r\n\r\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\r\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\r\n\tw.Header().Set(\"Connection\", \"keep-alive\")\r\n\tw.WriteHeader(http.StatusOK)\r\n\r\n\tbuffered, ch, cancel := s.hub.Subscribe(id)\r\n\tdefer cancel()\r\n\r\n\twrite := func(ev livelog.Event) bool {\r\n\t\tdata, err := json.Marshal(ev)\r\n\t\tif err != nil {\r\n\t\t\treturn true\r\n\t\t}\r\n\r\n\t\tif _, err := fmt.Fprintf(w, \"data: %s\\n\\n\", data); err != nil {\r\n\t\t\treturn false\r\n\t\t}\r\n\r\n\t\tflusher.Flush()\r\n\r\n\t\treturn true\r\n\t}\r\n\r","start_line":1,"end_line":200,"total_lines":252,"truncated":true}
{"path":"internal/web/web.go","start_line":200,"end_line":252}
{"path":"internal/web/web.go","content":"\r\n\tfor _, ev := range buffered {\r\n\t\tif !write(ev) {\r\n\t\t\treturn\r\n\t\t}\r\n\t}\r\n\r\n\theartbeat := time.NewTicker(15 * time.Second)\r\n\tdefer heartbeat.Stop()\r\n\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase ev, ok := \u003c-ch:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\tif !write(ev) {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\tif ev.Type == livelog.Status {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\tcase \u003c-heartbeat.C:\r\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\tflusher.Flush()\r\n\r\n\t\tcase \u003c-r.Context().Done():\r\n\t\t\treturn\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {\r\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\r\n\r\n\t// Every page here reflects live, fast-changing state (job status, log\r\n\t// blocks). Without this, browsers are free to serve a stale cached\r\n\t// copy of the job detail page on a same-URL navigation — including\r\n\t// the auto location.reload() a job's SSE stream triggers when it\r\n\t// finishes — which looks exactly like blocks that were visible while\r\n\t// streaming vanishing once the page reloads.\r\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\r\n\r\n\tif err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {\r\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\r\n\t}\r\n}\r","start_line":200,"end_line":252,"total_lines":252,"truncated":false}
{"path":"internal/web/templates.go","start_line":1,"end_line":100}
{"path":"internal/web/templates.go","content":"package web\r\n\r\nconst templates = `\r\n{{define \"layout_head\"}}\r\n\u003c!doctype html\u003e\r\n\u003chtml lang=\"en\"\u003e\r\n\u003chead\u003e\r\n\u003cmeta charset=\"utf-8\"\u003e\r\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\r\n\u003ctitle\u003ezoo\u003c/title\u003e\r\n\u003cstyle\u003e\r\n :root {\r\n --bg: #0f0f13;\r\n --bg-surface: #1a1a24;\r\n --bg-surface-hover: #22222e;\r\n --bg-code: #12121a;\r\n --border: #2a2a3a;\r\n --text: #e4e4e8;\r\n --text-muted: #8888a0;\r\n --accent: #7c6aef;\r\n --accent-glow: rgba(124, 106, 239, 0.15);\r\n --radius: 12px;\r\n --radius-sm: 8px;\r\n --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\r\n --mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;\r\n }\r\n\r\n * { margin: 0; padding: 0; box-sizing: border-box; }\r\n\r\n body {\r\n font-family: var(--font);\r\n background: var(--bg);\r\n color: var(--text);\r\n line-height: 1.6;\r\n min-height: 100vh;\r\n }\r\n\r\n /* ── Navigation ─────────────────────────────── */\r\n nav {\r\n position: sticky;\r\n top: 0;\r\n z-index: 100;\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n padding: 0 2rem;\r\n height: 60px;\r\n background: var(--bg-surface);\r\n border-bottom: 1px solid var(--border);\r\n backdrop-filter: blur(12px);\r\n }\r\n\r\n nav .brand {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.6rem;\r\n font-size: 1.25rem;\r\n font-weight: 700;\r\n color: var(--text);\r\n text-decoration: none;\r\n letter-spacing: -0.02em;\r\n }\r\n\r\n nav .brand .logo {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 32px;\r\n height: 32px;\r\n border-radius: var(--radius-sm);\r\n background: linear-gradient(135deg, var(--accent), #a78bfa);\r\n color: #fff;\r\n font-size: 1rem;\r\n font-weight: 800;\r\n }\r\n\r\n nav .links {\r\n display: flex;\r\n gap: 0.25rem;\r\n }\r\n\r\n nav .links a {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.4rem;\r\n padding: 0.5rem 1rem;\r\n border-radius: var(--radius-sm);\r\n color: var(--text-muted);\r\n text-decoration: none;\r\n font-size: 0.9rem;\r\n font-weight: 500;\r\n transition: all 0.15s ease;\r\n }\r\n\r\n nav .links a:hover {\r\n color: var(--text);\r\n background: var(--bg-surface-hover);\r\n }\r\n\r\n nav .links a.active {\r","start_line":1,"end_line":100,"total_lines":843,"truncated":true}
{"query":"type Job struct","context_after":30}
/project/internal/store/store.go:104:type Job struct {
/project/internal/store/store.go-105- ID string
/project/internal/store/store.go-106- EventKind string
/project/internal/store/store.go-107- Agent string
/project/internal/store/store.go-108- Owner string
/project/internal/store/store.go-109- Repo string
/project/internal/store/store.go-110- IssueIndex int64
/project/internal/store/store.go-111- Status JobStatus
/project/internal/store/store.go-112- CreatedAt time.Time
/project/internal/store/store.go-113- StartedAt sql.NullTime
/project/internal/store/store.go-114- FinishedAt sql.NullTime
/project/internal/store/store.go-115- Error string
/project/internal/store/store.go-116-}
/project/internal/store/store.go-117-
/project/internal/store/store.go-118-// CreateJob inserts a new job in JobPending status.
/project/internal/store/store.go-119-func (s *Store) CreateJob(ctx context.Context, j Job) error {
/project/internal/store/store.go-120- if j.Status == "" {
/project/internal/store/store.go-121- j.Status = JobPending
/project/internal/store/store.go-122- }
/project/internal/store/store.go-123-
/project/internal/store/store.go-124- if j.CreatedAt.IsZero() {
/project/internal/store/store.go-125- j.CreatedAt = time.Now().UTC()
/project/internal/store/store.go-126- }
/project/internal/store/store.go-127-
/project/internal/store/store.go-128- _, err := s.db.ExecContext(ctx,
/project/internal/store/store.go-129- `INSERT INTO jobs (id, event_kind, agent, owner, repo, issue_index, status, created_at, error)
/project/internal/store/store.go-130- VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')`,
/project/internal/store/store.go-131- j.ID, j.EventKind, j.Agent, j.Owner, j.Repo, j.IssueIndex, j.Status, j.CreatedAt)
/project/internal/store/store.go-132- if err != nil {
/project/internal/store/store.go-133- return fmt.Errorf("create job: %w", err)
/project/internal/store/store.go-134- }
{"query":"define \"jobs\"","context_after":60}
/project/internal/web/templates.go:610:{{define "jobs"}}
/project/internal/web/templates.go-611-{{template "layout_head" .}}
/project/internal/web/templates.go-612-<div class="container">
/project/internal/web/templates.go-613- <div class="page-header">
/project/internal/web/templates.go-614- <h1>Jobs</h1>
/project/internal/web/templates.go-615- <p>All agent runs and their current status.</p>
/project/internal/web/templates.go-616- </div>
/project/internal/web/templates.go-617-
/project/internal/web/templates.go-618- <div class="table-wrap">
/project/internal/web/templates.go-619- <table>
/project/internal/web/templates.go-620- <thead>
/project/internal/web/templates.go-621- <tr>
/project/internal/web/templates.go-622- <th>ID</th>
/project/internal/web/templates.go-623- <th>Status</th>
/project/internal/web/templates.go-624- <th>Event</th>
/project/internal/web/templates.go-625- <th>Agent</th>
/project/internal/web/templates.go-626- <th>Repository</th>
/project/internal/web/templates.go-627- <th>Created</th>
/project/internal/web/templates.go-628- </tr>
/project/internal/web/templates.go-629- </thead>
/project/internal/web/templates.go-630- <tbody>
/project/internal/web/templates.go-631- {{range .}}
/project/internal/web/templates.go-632- <tr>
/project/internal/web/templates.go-633- <td><a href="/jobs/{{.ID}}">{{.ID}}</a></td>
/project/internal/web/templates.go-634- <td>
/project/internal/web/templates.go-635- <span class="badge badge-{{.Status}}">
/project/internal/web/templates.go-636- <span class="dot"></span>
/project/internal/web/templates.go-637- {{.Status}}
/project/internal/web/templates.go-638- </span>
/project/internal/web/templates.go-639- </td>
/project/internal/web/templates.go-640- <td>{{.EventKind}}</td>
/project/internal/web/templates.go-641- <td><strong>{{.Agent}}</strong></td>
/project/internal/web/templates.go-642- <td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>
/project/internal/web/templates.go-643- <td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
/project/internal/web/templates.go-644- </tr>
/project/internal/web/templates.go-645- {{end}}
/project/internal/web/templates.go-646- </tbody>
/project/internal/web/templates.go-647- </table>
/project/internal/web/templates.go-648- </div>
/project/internal/web/templates.go-649-</div>
/project/internal/web/templates.go-650-</body>
/project/internal/web/templates.go-651-</html>
/project/internal/web/templates.go-652-{{end}}
/project/internal/web/templates.go-653-
/project/internal/web/templates.go-654-{{define "job_detail"}}
/project/internal/web/templates.go-655-{{template "layout_head" .}}
/project/internal/web/templates.go-656-<div class="container">
/project/internal/web/templates.go-657- <div class="page-header">
/project/internal/web/templates.go-658- <h1>Job {{.Job.ID}}</h1>
/project/internal/web/templates.go-659- <p>Details and log output for this agent run.</p>
/project/internal/web/templates.go-660- </div>
/project/internal/web/templates.go-661-
/project/internal/web/templates.go-662- <div class="job-meta">
/project/internal/web/templates.go-663- <div class="job-meta-item">
/project/internal/web/templates.go-664- <span class="label">Status</span>
/project/internal/web/templates.go-665- <span class="value">
/project/internal/web/templates.go-666- <span class="badge badge-{{.Job.Status}}">
/project/internal/web/templates.go-667- <span class="dot"></span>
/project/internal/web/templates.go-668- {{.Job.Status}}
/project/internal/web/templates.go-669- </span>
/project/internal/web/templates.go-670- </span>
{"path":"internal/config/config.go","start_line":1,"end_line":150}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\r\npackage config\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\r\n)\r\n\r\n// Known event kinds. issue:assigned is resolved dynamically (agent name\r\n// must match the Forgejo assignee's username) so it never carries an\r\n// `agent` attribute; the rest map statically to one configured agent.\r\nconst (\r\n\tEventIssueNew = \"issue:new\"\r\n\tEventIssueComment = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew = \"pr:new\"\r\n)\r\n\r\nvar staticEventKinds = map[string]bool{\r\n\tEventIssueNew: true,\r\n\tEventIssueComment: true,\r\n\tEventPRNew: true,\r\n}\r\n\r\ntype Config struct {\r\n\tLLMs []LLM `hcl:\"llm,block\"`\r\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\r\n\tMaxLive int `hcl:\"max_live_agents\"`\r\n\tEnvironment Environment `hcl:\"environment,block\"`\r\n\tAgents []Agent `hcl:\"agent,block\"`\r\n\tEvents []Event `hcl:\"event,block\"`\r\n\tWeb *Web `hcl:\"web,block\"`\r\n}\r\n\r\n// Web configures the dashboard's optional bearer-token gate. Leave the\r\n// block out of zoo.hcl entirely to run without one (fine on localhost;\r\n// put a real gate or a proxy in front for anything else).\r\ntype Web struct {\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype LLM struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tOpenAI string `hcl:\"openai\"`\r\n\tToken string `hcl:\"token\"`\r\n\tModel string `hcl:\"model\"`\r\n}\r\n\r\ntype Forgejo struct {\r\n\tURL string `hcl:\"url\"`\r\n\tToken string `hcl:\"token\"`\r\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\r\n}\r\n\r\ntype Environment struct {\r\n\tDockerImage string `hcl:\"docker_image\"`\r\n}\r\n\r\ntype Agent struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tLLM string `hcl:\"llm\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind string `hcl:\"name,label\"`\r\n\tAgent string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r\n\r\n\tif c.MaxLive \u003c 1 {\r\n\t\treturn fmt.Errorf(\"max_live_agents must be \u003e= 1, got %d\", c.MaxLive)\r\n\t}\r\n\r\n\tif c.Environment.DockerImage == \"\" {\r\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\r\n\t}\r\n\r\n\tagentNames := make(map[string]bool, len(c.Agents))\r\n\tfor _, a := range c.Agents {\r\n\t\tif !llmNames[a.LLM] {\r\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\r\n\t\t}\r\n\t\tagentNames[a.Name] = true\r\n\t}\r\n\r\n\tseenEventKinds := make(map[string]bool, len(c.Events))\r\n\tfor _, e := range c.Events {\r\n\t\tif seenEventKinds[e.Kind] {\r\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\r\n\t\t}\r\n\t\tseenEventKinds[e.Kind] = true\r\n\r\n\t\tswitch {\r\n\t\tcase e.Kind == EventIssueAssigned:\r\n\t\t\tif e.Agent != \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\r\n\t\t\t}\r\n\r\n\t\tcase staticEventKinds[e.Kind]:\r\n\t\t\tif e.Agent == \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\r\n\t\t\t}\r\n\t\t\tif !agentNames[e.Agent] {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\r\n\t\t\t}\r\n\r\n\t\tdefault:\r\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (Agent, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn a, true\r","start_line":1,"end_line":150,"total_lines":191,"truncated":true}
{"path":"cmd/zoo/main.go","start_line":1,"end_line":120}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\r\n// issue/PR events, dispatches them to configured AI agents running in\r\n// Docker containers, and serves a small dashboard over the result.\r\npackage main\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"flag\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"os\"\r\n\t\"os/signal\"\r\n\t\"syscall\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/agentrun\"\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/scheduler\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n\t\"github.com/abrander/zoo/internal/web\"\r\n)\r\n\r\nfunc main() {\r\n\tif err := run(); err != nil {\r\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\r\n\r\n\t\tos.Exit(1)\r\n\t}\r\n}\r\n\r\nfunc run() error {\r\n\tvar (\r\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\r\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\r\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\r\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\r\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\r\n\t)\r\n\r\n\tflag.Parse()\r\n\r\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\r\n\r\n\tcfg, err := config.Load(*configPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"load config: %w\", err)\r\n\t}\r\n\r\n\tst, err := store.Open(*dbPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"open store: %w\", err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\r\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\r\n\t} else if n \u003e 0 {\r\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\r\n\t}\r\n\r\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With(\"component\", \"forgejo\"))\r\n\r\n\thub := livelog.NewHub()\r\n\r\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\r\n\t}\r\n\r\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\r\n\tdashboard := web.New(cfg, st, hub)\r\n\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\r\n\r\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t\tw.Write([]byte(\"ok\"))\r\n\t})\r\n\r\n\tmux.Handle(\"/\", dashboard.Handler())\r\n\r\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\r\n\r\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\r\n\tdefer stop()\r\n\r\n\tgo watcher.Run(ctx)\r\n\tgo sched.Run(ctx, watcher.Events())\r\n\r\n\tserveErr := make(chan error, 1)\r\n\r\n\tgo func() {\r\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\r\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\r\n\t\t\tserveErr \u003c- err\r\n\t\t}\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-ctx.Done():\r\n\t\tlogger.Info(\"shutting down\")\r\n\r\n\tcase err := \u003c-serveErr:\r\n\t\tstop()\r\n\r\n\t\tlogger.Error(\"http server failed\", \"error\", err)\r\n\t}\r\n\r\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\tdefer cancel()\r","start_line":1,"end_line":120,"total_lines":131,"truncated":true}
{"path":"internal/web/web_test.go","start_line":1,"end_line":120}
{"path":"internal/web/web_test.go","content":"package web\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"net/http\"\r\n\t\"net/http/httptest\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\nfunc testServer(t *testing.T) (*Server, *store.Store) {\r\n\tt.Helper()\r\n\r\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tt.Cleanup(func() { st.Close() })\r\n\r\n\tcfg := \u0026config.Config{\r\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\r\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\r\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\r\n\t\tMaxLive: 2,\r\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\r\n\t}\r\n\r\n\treturn New(cfg, st, livelog.NewHub()), st\r\n}\r\n\r\nfunc TestIndexRenders(t *testing.T) {\r\n\ts, _ := testServer(t)\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/\", nil))\r\n\r\n\tif rr.Code != http.StatusOK {\r\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\tif !strings.Contains(rr.Body.String(), \"leon\") {\r\n\t\tt.Fatalf(\"expected agent name in output, got %s\", rr.Body.String())\r\n\t}\r\n}\r\n\r\nfunc TestJobsAndDetail(t *testing.T) {\r\n\ts, st := testServer(t)\r\n\tctx := context.Background()\r\n\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif err := st.AppendLog(ctx, \"job-1\", \"stdout\", \"hello\"); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"job-1\") {\r\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"hello\") {\r\n\t\tt.Fatalf(\"unexpected job detail page: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\t// job-1 was never marked started/finished, so it's still \"live\":\r\n\t// hitting /events should stream whatever's currently buffered in the\r\n\t// hub (not yet checkpointed/persisted) and then wait for more, until\r\n\t// the request context is done.\r\n\ts.hub.Publish(\"job-1\", livelog.Event{Type: livelog.ContentDelta, Text: \"streaming\"})\r\n\r\n\tevCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)\r\n\tdefer cancel()\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1/events\", nil).WithContext(evCtx))\r\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"streaming\") {\r\n\t\tt.Fatalf(\"unexpected events response: %d %s\", rr.Code, rr.Body.String())\r\n\t}\r\n\r\n\t// A finished job isn't live: /events should return immediately with\r\n\t// nothing to stream, so the client falls back to the static page.\r\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif err := st.MarkJobFinished(ctx, \"job-2\", store.JobSucceeded, \"\"); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-2/events\", nil))\r\n\tif rr.Code != http.StatusOK || rr.Body.Len() != 0 {\r\n\t\tt.Fatalf(\"expected empty response for a non-live job, got %d %q\", rr.Code, rr.Body.String())\r\n\t}\r\n}\r\n\r\nfunc TestAuthGate(t *testing.T) {\r\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tcfg := \u0026config.Config{Web: \u0026config.Web{Token: \"secret\"}}\r\n\ts := New(cfg, st, livelog.NewHub())\r\n\r\n\trr := httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\r\n\tif rr.Code != http.StatusUnauthorized {\r\n\t\tt.Fatalf(\"expected 401 without token, got %d\", rr.Code)\r\n\t}\r\n\r","start_line":1,"end_line":120,"total_lines":128,"truncated":true}
{"query":"Agent:","glob":"internal/scheduler/*.go","context_after":3,"context_before":3}
No matches found.
{"query":"\\.agent|avatar","glob":"internal/web/templates.go","context_after":4}
No matches found.
{"query":"CreateJob","context_after":8}
/project/internal/web/web_test.go:55: if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
/project/internal/web/web_test.go-56- t.Fatal(err)
/project/internal/web/web_test.go-57- }
/project/internal/web/web_test.go-58- if err := st.AppendLog(ctx, "job-1", "stdout", "hello"); err != nil {
/project/internal/web/web_test.go-59- t.Fatal(err)
/project/internal/web/web_test.go-60- }
/project/internal/web/web_test.go-61-
/project/internal/web/web_test.go-62- rr := httptest.NewRecorder()
/project/internal/web/web_test.go-63- s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
--
/project/internal/web/web_test.go:91: if err := st.CreateJob(ctx, store.Job{ID: "job-2", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 2}); err != nil {
/project/internal/web/web_test.go-92- t.Fatal(err)
/project/internal/web/web_test.go-93- }
/project/internal/web/web_test.go-94- if err := st.MarkJobFinished(ctx, "job-2", store.JobSucceeded, ""); err != nil {
/project/internal/web/web_test.go-95- t.Fatal(err)
/project/internal/web/web_test.go-96- }
/project/internal/web/web_test.go-97-
/project/internal/web/web_test.go-98- rr = httptest.NewRecorder()
/project/internal/web/web_test.go-99- s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-2/events", nil))
--
/project/internal/store/store.go:118:// CreateJob inserts a new job in JobPending status.
/project/internal/store/store.go:119:func (s *Store) CreateJob(ctx context.Context, j Job) error {
/project/internal/store/store.go-120- if j.Status == "" {
/project/internal/store/store.go-121- j.Status = JobPending
/project/internal/store/store.go-122- }
/project/internal/store/store.go-123-
/project/internal/store/store.go-124- if j.CreatedAt.IsZero() {
/project/internal/store/store.go-125- j.CreatedAt = time.Now().UTC()
/project/internal/store/store.go-126- }
/project/internal/store/store.go-127-
--
/project/internal/store/store_test.go:92: if err := s.CreateJob(ctx, job); err != nil {
/project/internal/store/store_test.go-93- t.Fatal(err)
/project/internal/store/store_test.go-94- }
/project/internal/store/store_test.go-95-
/project/internal/store/store_test.go-96- got, err := s.GetJob(ctx, "job-1")
/project/internal/store/store_test.go-97- if err != nil {
/project/internal/store/store_test.go-98- t.Fatal(err)
/project/internal/store/store_test.go-99- }
/project/internal/store/store_test.go-100- if got.Status != JobPending {
--
/project/internal/store/store_test.go:141: if err := s.CreateJob(ctx, Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
/project/internal/store/store_test.go-142- t.Fatal(err)
/project/internal/store/store_test.go-143- }
/project/internal/store/store_test.go-144-
/project/internal/store/store_test.go-145- for _, line := range []string{"first", "second", "third"} {
/project/internal/store/store_test.go-146- if err := s.AppendLog(ctx, "job-1", "stdout", line); err != nil {
/project/internal/store/store_test.go-147- t.Fatal(err)
/project/internal/store/store_test.go-148- }
/project/internal/store/store_test.go-149- }
--
/project/internal/scheduler/scheduler.go:128: if err := s.store.CreateJob(ctx, store.Job{
/project/internal/scheduler/scheduler.go-129- ID: jobID,
/project/internal/scheduler/scheduler.go-130- EventKind: ev.Kind,
/project/internal/scheduler/scheduler.go-131- Agent: agentName,
/project/internal/scheduler/scheduler.go-132- Owner: ev.Owner,
/project/internal/scheduler/scheduler.go-133- Repo: ev.Repo,
/project/internal/scheduler/scheduler.go-134- IssueIndex: ev.Index,
/project/internal/scheduler/scheduler.go-135- }); err != nil {
/project/internal/scheduler/scheduler.go-136- s.logger.Error("failed to record job", "job", jobID, "error", err)
{"path":"internal/scheduler/scheduler.go","start_line":80,"end_line":140}
{"path":"internal/scheduler/scheduler.go","content":"// dispatching each to its resolved agent and blocking on the\r\n// max_live_agents semaphore before starting a run.\r\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase ev, ok := \u003c-events:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\ts.handle(ctx, ev)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\r\n\tagentName, ok := resolveAgent(s.cfg, ev)\r\n\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, attributed to it by Sudo) can themselves show up as new\r\n\t// events. Don't let an agent trigger itself off its own activity —\r\n\t// that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID: jobID,\r\n\t\tEventKind: ev.Kind,\r\n\t\tAgent: agentName,\r\n\t\tOwner: ev.Owner,\r\n\t\tRepo: ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r","start_line":80,"end_line":140,"total_lines":240,"truncated":true}
{"query":"func resolveAgent","context_after":30}
/project/internal/scheduler/scheduler.go:67:func resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {
/project/internal/scheduler/scheduler.go-68- if ev.Kind == config.EventIssueAssigned {
/project/internal/scheduler/scheduler.go-69- if _, ok := cfg.AgentByName(ev.Assignee); ok {
/project/internal/scheduler/scheduler.go-70- return ev.Assignee, true
/project/internal/scheduler/scheduler.go-71- }
/project/internal/scheduler/scheduler.go-72-
/project/internal/scheduler/scheduler.go-73- return "", false
/project/internal/scheduler/scheduler.go-74- }
/project/internal/scheduler/scheduler.go-75-
/project/internal/scheduler/scheduler.go-76- return cfg.EventAgent(ev.Kind)
/project/internal/scheduler/scheduler.go-77-}
/project/internal/scheduler/scheduler.go-78-
/project/internal/scheduler/scheduler.go-79-// Run consumes events until ctx is canceled or the channel closes,
/project/internal/scheduler/scheduler.go-80-// dispatching each to its resolved agent and blocking on the
/project/internal/scheduler/scheduler.go-81-// max_live_agents semaphore before starting a run.
/project/internal/scheduler/scheduler.go-82-func (s *Scheduler) Run(ctx context.Context, events <-chan forgejo.Event) {
/project/internal/scheduler/scheduler.go-83- for {
/project/internal/scheduler/scheduler.go-84- select {
/project/internal/scheduler/scheduler.go-85- case <-ctx.Done():
/project/internal/scheduler/scheduler.go-86- return
/project/internal/scheduler/scheduler.go-87-
/project/internal/scheduler/scheduler.go-88- case ev, ok := <-events:
/project/internal/scheduler/scheduler.go-89- if !ok {
/project/internal/scheduler/scheduler.go-90- return
/project/internal/scheduler/scheduler.go-91- }
/project/internal/scheduler/scheduler.go-92-
/project/internal/scheduler/scheduler.go-93- s.handle(ctx, ev)
/project/internal/scheduler/scheduler.go-94- }
/project/internal/scheduler/scheduler.go-95- }
/project/internal/scheduler/scheduler.go-96-}
/project/internal/scheduler/scheduler.go-97-
{"path":"internal/web/templates.go","start_line":100,"end_line":400}
{"path":"internal/web/templates.go","content":" nav .links a.active {\r\n color: var(--accent);\r\n background: var(--accent-glow);\r\n }\r\n\r\n /* ── Main container ─────────────────────────── */\r\n .container {\r\n max-width: 1200px;\r\n margin: 0 auto;\r\n padding: 2rem;\r\n }\r\n\r\n /* ── Page header ────────────────────────────── */\r\n .page-header {\r\n margin-bottom: 2rem;\r\n }\r\n\r\n h1 {\r\n font-size: 2rem;\r\n font-weight: 700;\r\n letter-spacing: -0.03em;\r\n margin-bottom: 0.25rem;\r\n background: linear-gradient(135deg, var(--text), var(--text-muted));\r\n -webkit-background-clip: text;\r\n -webkit-text-fill-color: transparent;\r\n background-clip: text;\r\n }\r\n\r\n .page-header p {\r\n color: var(--text-muted);\r\n font-size: 0.95rem;\r\n }\r\n\r\n h2 {\r\n font-size: 1.15rem;\r\n font-weight: 600;\r\n color: var(--text-muted);\r\n text-transform: uppercase;\r\n letter-spacing: 0.06em;\r\n margin: 2rem 0 1rem;\r\n padding-bottom: 0.5rem;\r\n border-bottom: 1px solid var(--border);\r\n }\r\n\r\n /* ── Cards ──────────────────────────────────── */\r\n .card {\r\n background: var(--bg-surface);\r\n border: 1px solid var(--border);\r\n border-radius: var(--radius);\r\n overflow: hidden;\r\n transition: border-color 0.2s ease;\r\n }\r\n\r\n .card:hover {\r\n border-color: #3a3a50;\r\n }\r\n\r\n /* ── Tables ─────────────────────────────────── */\r\n .table-wrap {\r\n border-radius: var(--radius);\r\n overflow: hidden;\r\n border: 1px solid var(--border);\r\n }\r\n\r\n table {\r\n width: 100%;\r\n border-collapse: collapse;\r\n font-size: 0.9rem;\r\n }\r\n\r\n thead {\r\n background: var(--bg-surface-hover);\r\n }\r\n\r\n th {\r\n text-align: left;\r\n padding: 0.75rem 1rem;\r\n font-weight: 600;\r\n font-size: 0.8rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.05em;\r\n color: var(--text-muted);\r\n border-bottom: 1px solid var(--border);\r\n }\r\n\r\n td {\r\n padding: 0.75rem 1rem;\r\n border-bottom: 1px solid var(--border);\r\n vertical-align: middle;\r\n }\r\n\r\n tbody tr:last-child td {\r\n border-bottom: none;\r\n }\r\n\r\n tbody tr {\r\n transition: background 0.15s ease;\r\n }\r\n\r\n tbody tr:hover {\r\n background: var(--bg-surface-hover);\r\n }\r\n\r\n td a {\r\n color: var(--accent);\r\n text-decoration: none;\r\n font-weight: 500;\r\n }\r\n\r\n td a:hover {\r\n text-decoration: underline;\r\n }\r\n\r\n /* ── Badges ─────────────────────────────────── */\r\n .badge {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.35rem;\r\n padding: 0.2rem 0.65rem;\r\n border-radius: 999px;\r\n font-size: 0.78rem;\r\n font-weight: 600;\r\n letter-spacing: 0.01em;\r\n }\r\n\r\n .badge .dot {\r\n width: 6px;\r\n height: 6px;\r\n border-radius: 50%;\r\n flex-shrink: 0;\r\n }\r\n\r\n .badge-pending {\r\n background: rgba(234, 170, 2, 0.12);\r\n color: #eab308;\r\n }\r\n .badge-pending .dot { background: #eab308; }\r\n\r\n .badge-running {\r\n background: rgba(124, 106, 239, 0.15);\r\n color: var(--accent);\r\n }\r\n .badge-running .dot {\r\n background: var(--accent);\r\n animation: pulse 1.5s ease-in-out infinite;\r\n }\r\n\r\n .badge-succeeded {\r\n background: rgba(34, 197, 94, 0.12);\r\n color: #22c55e;\r\n }\r\n .badge-succeeded .dot { background: #22c55e; }\r\n\r\n .badge-failed, .badge-timed_out {\r\n background: rgba(239, 68, 68, 0.12);\r\n color: #ef4444;\r\n }\r\n .badge-failed .dot, .badge-timed_out .dot { background: #ef4444; }\r\n\r\n @keyframes pulse {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0.3; }\r\n }\r\n\r\n /* ── Info grid ──────────────────────────────── */\r\n .info-grid {\r\n display: grid;\r\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\r\n gap: 1rem;\r\n margin-bottom: 1rem;\r\n }\r\n\r\n .info-item {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.2rem;\r\n }\r\n\r\n .info-item .label {\r\n font-size: 0.78rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.05em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n }\r\n\r\n .info-item .value {\r\n font-size: 0.95rem;\r\n color: var(--text);\r\n word-break: break-all;\r\n }\r\n\r\n /* ── Code / Log ─────────────────────────────── */\r\n .log-container {\r\n background: var(--bg-code);\r\n border: 1px solid var(--border);\r\n border-radius: var(--radius);\r\n overflow-y: auto;\r\n max-height: 70vh;\r\n padding: 1rem;\r\n }\r\n\r\n /* Plain block flow, not flex: a flex column with overflow:hidden\r\n children (.block-tool) gives those children an automatic min-height\r\n of 0 instead of their content height, so once total content\r\n exceeded max-height, flexbox was free to squash them down. */\r\n .log-container .block + .block {\r\n margin-top: 0.6rem;\r\n }\r\n\r\n pre {\r\n margin: 0;\r\n padding: 1.25rem;\r\n font-family: var(--mono);\r\n font-size: 0.82rem;\r\n line-height: 1.7;\r\n color: #c4c4d0;\r\n white-space: pre-wrap;\r\n word-break: break-all;\r\n }\r\n\r\n /* ── Log blocks ─────────────────────────────── */\r\n .block-label {\r\n font-size: 0.72rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.06em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n margin-bottom: 0.35rem;\r\n }\r\n\r\n .block-body {\r\n font-family: var(--font);\r\n font-size: 0.9rem;\r\n line-height: 1.6;\r\n color: var(--text);\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n }\r\n\r\n .block-reasoning,\r\n .block-content {\r\n padding: 0.75rem 1rem;\r\n border-radius: var(--radius-sm);\r\n }\r\n\r\n .block-reasoning {\r\n background: rgba(124, 106, 239, 0.06);\r\n border-left: 3px solid var(--accent);\r\n }\r\n\r\n .block-reasoning .block-body {\r\n color: var(--text-muted);\r\n font-style: italic;\r\n }\r\n\r\n .block-content {\r\n background: var(--bg-surface);\r\n border: 1px solid var(--border);\r\n }\r\n\r\n .block-system {\r\n padding: 0.35rem 0.75rem;\r\n color: var(--text-muted);\r\n font-family: var(--mono);\r\n font-size: 0.8rem;\r\n }\r\n\r\n .block-tool {\r\n background: rgba(34, 211, 238, 0.06);\r\n border: 1px solid var(--border);\r\n border-left: 4px solid #22d3ee;\r\n border-radius: var(--radius-sm);\r\n overflow: hidden;\r\n }\r\n\r\n .block-tool summary {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.75rem;\r\n cursor: pointer;\r\n padding: 0.9rem 1.1rem;\r\n min-height: 2.75rem;\r\n color: var(--text);\r\n list-style: none;\r\n }\r\n\r\n .block-tool summary::-webkit-details-marker { display: none; }\r\n\r\n .block-tool summary::before {\r\n content: \"▸\";\r\n display: inline-block;\r\n font-size: 1.1rem;\r\n color: var(--text-muted);\r\n transition: transform 0.15s ease;\r\n flex-shrink: 0;\r\n }\r\n\r\n .block-tool[open] summary::before { transform: rotate(90deg); }\r\n\r\n .tool-badge {\r","start_line":100,"end_line":400,"total_lines":843,"truncated":true}
{"path":"internal/web/templates.go","start_line":400,"end_line":610}
{"path":"internal/web/templates.go","content":" .tool-badge {\r\n flex-shrink: 0;\r\n padding: 0.25rem 0.6rem;\r\n border-radius: 999px;\r\n background: rgba(34, 211, 238, 0.15);\r\n color: #22d3ee;\r\n font-size: 0.7rem;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.06em;\r\n }\r\n\r\n .tool-summary-text {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.2rem;\r\n min-width: 0;\r\n }\r\n\r\n .block-tool .tool-name {\r\n font-size: 1rem;\r\n font-weight: 700;\r\n color: var(--text);\r\n }\r\n\r\n .block-tool .tool-args-preview {\r\n color: var(--text-muted);\r\n font-family: var(--mono);\r\n font-size: 0.78rem;\r\n font-weight: 400;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n min-width: 0;\r\n }\r\n\r\n .block-tool-error { border-left-color: #ef4444; }\r\n .block-tool-error .tool-badge { background: rgba(239, 68, 68, 0.15); color: #ef4444; }\r\n\r\n .block-tool .block-body {\r\n padding: 0 1.1rem 1rem;\r\n border-top: 1px solid var(--border);\r\n /* Unlike a reasoning/content block, this wraps element children\r\n (labels + \u003cpre\u003es), not raw text, so it must not inherit the base\r\n .block-body's white-space: pre-wrap — that would render the\r\n template source's own whitespace between those child tags as\r\n visible blank lines. */\r\n white-space: normal;\r\n }\r\n\r\n .block-tool .tool-section-label {\r\n font-size: 0.72rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.05em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n margin: 0.6rem 0 0.25rem;\r\n }\r\n\r\n .block-tool pre {\r\n margin: 0;\r\n padding: 0;\r\n background: transparent;\r\n font-size: 0.8rem;\r\n color: #c4c4d0;\r\n }\r\n\r\n code {\r\n font-family: var(--mono);\r\n background: var(--bg-code);\r\n padding: 0.15rem 0.45rem;\r\n border-radius: 4px;\r\n font-size: 0.85em;\r\n color: #c4b5fd;\r\n }\r\n\r\n /* ── Job detail meta ────────────────────────── */\r\n .job-meta {\r\n display: flex;\r\n flex-wrap: wrap;\r\n gap: 1.5rem;\r\n margin-bottom: 1.5rem;\r\n }\r\n\r\n .job-meta-item {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.15rem;\r\n }\r\n\r\n .job-meta-item .label {\r\n font-size: 0.75rem;\r\n text-transform: uppercase;\r\n letter-spacing: 0.05em;\r\n color: var(--text-muted);\r\n font-weight: 600;\r\n }\r\n\r\n .job-meta-item .value {\r\n font-size: 0.95rem;\r\n }\r\n\r\n .error-text {\r\n color: #ef4444;\r\n }\r\n\r\n /* ── Responsive ─────────────────────────────── */\r\n @media (max-width: 768px) {\r\n nav { padding: 0 1rem; }\r\n .container { padding: 1rem; }\r\n h1 { font-size: 1.5rem; }\r\n th, td { padding: 0.5rem 0.65rem; font-size: 0.82rem; }\r\n .info-grid { grid-template-columns: 1fr; }\r\n .job-meta { gap: 1rem; }\r\n }\r\n\r\n /* ── Scrollbar ──────────────────────────────── */\r\n ::-webkit-scrollbar { width: 8px; height: 8px; }\r\n ::-webkit-scrollbar-track { background: transparent; }\r\n ::-webkit-scrollbar-thumb {\r\n background: var(--border);\r\n border-radius: 4px;\r\n }\r\n ::-webkit-scrollbar-thumb:hover { background: #3a3a50; }\r\n\u003c/style\u003e\r\n\u003c/head\u003e\r\n\u003cbody\u003e\r\n\u003cnav\u003e\r\n \u003ca href=\"/\" class=\"brand\"\u003e\r\n \u003cspan class=\"logo\"\u003eZ\u003c/span\u003e\r\n zoo\r\n \u003c/a\u003e\r\n \u003cdiv class=\"links\"\u003e\r\n \u003ca href=\"/\"\u003eDashboard\u003c/a\u003e\r\n \u003ca href=\"/jobs\"\u003eJobs\u003c/a\u003e\r\n \u003c/div\u003e\r\n\u003c/nav\u003e\r\n{{end}}\r\n\r\n{{define \"index\"}}\r\n{{template \"layout_head\" .}}\r\n\u003cdiv class=\"container\"\u003e\r\n \u003cdiv class=\"page-header\"\u003e\r\n \u003ch1\u003eDashboard\u003c/h1\u003e\r\n \u003cp\u003eOverview of your zoo configuration and running agents.\u003c/p\u003e\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eLLMs\u003c/h2\u003e\r\n \u003cdiv class=\"table-wrap\"\u003e\r\n \u003ctable\u003e\r\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eName\u003c/th\u003e\u003cth\u003eEndpoint\u003c/th\u003e\u003cth\u003eModel\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\r\n \u003ctbody\u003e\r\n {{range .LLMs}}\r\n \u003ctr\u003e\r\n \u003ctd\u003e\u003cstrong\u003e{{.Name}}\u003c/strong\u003e\u003c/td\u003e\r\n \u003ctd\u003e\u003ccode\u003e{{.OpenAI}}\u003c/code\u003e\u003c/td\u003e\r\n \u003ctd\u003e{{.Model}}\u003c/td\u003e\r\n \u003c/tr\u003e\r\n {{end}}\r\n \u003c/tbody\u003e\r\n \u003c/table\u003e\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eAgents\u003c/h2\u003e\r\n \u003cdiv class=\"table-wrap\"\u003e\r\n \u003ctable\u003e\r\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eName\u003c/th\u003e\u003cth\u003eLLM\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\r\n \u003ctbody\u003e\r\n {{range .Agents}}\r\n \u003ctr\u003e\r\n \u003ctd\u003e\u003cstrong\u003e{{.Name}}\u003c/strong\u003e\u003c/td\u003e\r\n \u003ctd\u003e{{.LLM}}\u003c/td\u003e\r\n \u003c/tr\u003e\r\n {{end}}\r\n \u003c/tbody\u003e\r\n \u003c/table\u003e\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eEvent Mappings\u003c/h2\u003e\r\n \u003cdiv class=\"table-wrap\"\u003e\r\n \u003ctable\u003e\r\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eEvent\u003c/th\u003e\u003cth\u003eAgent\u003c/th\u003e\u003cth\u003eInstructions\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\r\n \u003ctbody\u003e\r\n {{range .Events}}\r\n \u003ctr\u003e\r\n \u003ctd\u003e\u003ccode\u003e{{.Kind}}\u003c/code\u003e\u003c/td\u003e\r\n \u003ctd\u003e{{if .Agent}}\u003cstrong\u003e{{.Agent}}\u003c/strong\u003e{{else}}\u003cem style=\"color:var(--text-muted)\"\u003edynamic (assignee username)\u003c/em\u003e{{end}}\u003c/td\u003e\r\n \u003ctd\u003e{{.Instructions}}\u003c/td\u003e\r\n \u003c/tr\u003e\r\n {{end}}\r\n \u003c/tbody\u003e\r\n \u003c/table\u003e\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eEnvironment\u003c/h2\u003e\r\n \u003cdiv class=\"info-grid\"\u003e\r\n \u003cdiv class=\"info-item\"\u003e\r\n \u003cspan class=\"label\"\u003eDocker Image\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e\u003ccode\u003e{{.Environment.DockerImage}}\u003c/code\u003e\u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003cdiv class=\"info-item\"\u003e\r\n \u003cspan class=\"label\"\u003eMax Live Agents\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e{{.MaxLive}}\u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003c/div\u003e\r\n\u003c/div\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n{{end}}\r\n\r\n{{define \"jobs\"}}\r","start_line":400,"end_line":610,"total_lines":843,"truncated":true}
{"path":"internal/web/templates.go","start_line":654,"end_line":760}
{"path":"internal/web/templates.go","content":"{{define \"job_detail\"}}\r\n{{template \"layout_head\" .}}\r\n\u003cdiv class=\"container\"\u003e\r\n \u003cdiv class=\"page-header\"\u003e\r\n \u003ch1\u003eJob {{.Job.ID}}\u003c/h1\u003e\r\n \u003cp\u003eDetails and log output for this agent run.\u003c/p\u003e\r\n \u003c/div\u003e\r\n\r\n \u003cdiv class=\"job-meta\"\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eStatus\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e\r\n \u003cspan class=\"badge badge-{{.Job.Status}}\"\u003e\r\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\r\n {{.Job.Status}}\r\n \u003c/span\u003e\r\n \u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eEvent\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e{{.Job.EventKind}} on \u003ccode\u003e{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}\u003c/code\u003e\u003c/span\u003e\r\n \u003c/div\u003e\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eAgent\u003c/span\u003e\r\n \u003cspan class=\"value\"\u003e\u003cstrong\u003e{{.Job.Agent}}\u003c/strong\u003e\u003c/span\u003e\r\n \u003c/div\u003e\r\n {{if .Job.Error}}\r\n \u003cdiv class=\"job-meta-item\"\u003e\r\n \u003cspan class=\"label\"\u003eError\u003c/span\u003e\r\n \u003cspan class=\"value error-text\"\u003e{{.Job.Error}}\u003c/span\u003e\r\n \u003c/div\u003e\r\n {{end}}\r\n \u003c/div\u003e\r\n\r\n \u003ch2\u003eLog\u003c/h2\u003e\r\n \u003cdiv class=\"log-container\" id=\"log\"\u003e\r\n {{range .Blocks}}\r\n {{if eq .Kind \"reasoning\"}}\r\n \u003cdiv class=\"block block-reasoning\"\u003e\r\n \u003cdiv class=\"block-label\"\u003eThinking\u003c/div\u003e\r\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\r\n \u003c/div\u003e\r\n {{else if eq .Kind \"content\"}}\r\n \u003cdiv class=\"block block-content\"\u003e\r\n \u003cdiv class=\"block-body\"\u003e{{.Text}}\u003c/div\u003e\r\n \u003c/div\u003e\r\n {{else if eq .Kind \"tool\"}}\r\n \u003cdetails class=\"block block-tool{{if .Tool.Error}} block-tool-error{{end}}\"\u003e\r\n \u003csummary\u003e\r\n \u003cspan class=\"tool-badge\"\u003eTool\u003c/span\u003e\r\n \u003cspan class=\"tool-summary-text\"\u003e\r\n \u003cspan class=\"tool-name\"\u003e🔧 {{.Tool.Name}}\u003c/span\u003e\r\n \u003cspan class=\"tool-args-preview\"\u003e{{.Tool.Arguments}}\u003c/span\u003e\r\n \u003c/span\u003e\r\n \u003c/summary\u003e\r\n \u003cdiv class=\"block-body\"\u003e\r\n \u003cdiv class=\"tool-section-label\"\u003eArguments\u003c/div\u003e\r\n \u003cpre\u003e{{.Tool.Arguments}}\u003c/pre\u003e\r\n \u003cdiv class=\"tool-section-label\"\u003eResult\u003c/div\u003e\r\n \u003cpre\u003e{{.Tool.Result}}\u003c/pre\u003e\r\n \u003c/div\u003e\r\n \u003c/details\u003e\r\n {{else}}\r\n \u003cdiv class=\"block block-system\"\u003e{{.Text}}\u003c/div\u003e\r\n {{end}}\r\n {{end}}\r\n \u003c/div\u003e\r\n\r\n {{if .Live}}\r\n \u003cscript\u003e\r\n (function() {\r\n var jobID = {{.Job.ID}};\r\n var log = document.getElementById(\"log\");\r\n var reasoningBody = null;\r\n var contentBody = null;\r\n\r\n function nearBottom() {\r\n return (window.innerHeight + window.scrollY) \u003e= (document.body.offsetHeight - 80);\r\n }\r\n\r\n function newBlock(kind, label) {\r\n var div = document.createElement(\"div\");\r\n div.className = \"block block-\" + kind;\r\n if (label) {\r\n var l = document.createElement(\"div\");\r\n l.className = \"block-label\";\r\n l.textContent = label;\r\n div.appendChild(l);\r\n }\r\n var body = document.createElement(\"div\");\r\n body.className = \"block-body\";\r\n div.appendChild(body);\r\n log.appendChild(div);\r\n return body;\r\n }\r\n\r\n function newToolBlock(ev) {\r\n var details = document.createElement(\"details\");\r\n details.className = \"block block-tool\" + (ev.error ? \" block-tool-error\" : \"\");\r\n\r\n var summary = document.createElement(\"summary\");\r\n\r\n var badge = document.createElement(\"span\");\r\n badge.className = \"tool-badge\";\r\n badge.textContent = \"Tool\";\r\n\r\n var text = document.createElement(\"span\");\r","start_line":654,"end_line":760,"total_lines":843,"truncated":true}
{"path":"internal/forgejo/client.go","start_line":1,"end_line":80}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\r\n// polling results into a common Event stream for the scheduler.\r\npackage forgejo\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\n// Client is zoo's single shared Forgejo identity, used both for the\r\n// event sources (webhook/poll) and for actions agents/scheduler take\r\n// (comments, labels, PRs).\r\ntype Client struct {\r\n\tsdk *sdk.Client\r\n\r\n\tbaseURL string\r\n\ttoken string\r\n}\r\n\r\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\r\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\r\n}\r\n\r\n// Token returns the shared zoo Forgejo identity's token, e.g. for\r\n// authenticating a host-side git clone/push against Forgejo (see\r\n// internal/agentrun) without ever writing the credential into a working\r\n// tree an agent's container can read.\r\nfunc (c *Client) Token() string {\r\n\treturn c.token\r\n}\r\n\r\n// Sudo returns a new Client that impersonates username (via Forgejo's\r\n// \"Sudo:\" header) on every API call it makes, using the same underlying\r\n// token. Actions an agent takes through it — comments, labels, PRs,\r\n// assignment — are attributed to that agent's own Forgejo account\r\n// instead of the shared zoo identity. The token must belong to a user\r\n// with sudo scope/admin rights for this to work; Forgejo rejects the\r\n// header otherwise.\r\n//\r\n// The SDK's Sudo setting lives on the *sdk.Client itself and isn't\r\n// safe to flip per-request on a shared client under concurrent agent\r\n// runs, so this constructs a separate client rather than mutating one.\r\nfunc (c *Client) Sudo(username string) (*Client, error) {\r\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\r\n}\r\n\r\n// CreateIssueComment posts a comment on the given issue or pull request\r\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\r\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AddLabel attaches the label with the given name to an issue/PR,\r\n// creating the label (with a default color) on the repo first if it\r\n// doesn't already exist.\r\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\r\n\tid, err := c.labelID(owner, repo, name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\r","start_line":1,"end_line":80,"total_lines":232,"truncated":true}
{"query":"Forgejo.URL|cfg.Forgejo","context_after":4,"context_before":2}
No matches found.
{"query":"func \\(s \\*Store\\) ListJobs","context_after":15}
No matches found.
{"path":"internal/web/web_test.go","start_line":120,"end_line":128}
{"path":"internal/web/web_test.go","content":"\r\n\treq := httptest.NewRequest(http.MethodGet, \"/jobs\", nil)\r\n\treq.Header.Set(\"Authorization\", \"Bearer secret\")\r\n\trr = httptest.NewRecorder()\r\n\ts.Handler().ServeHTTP(rr, req)\r\n\tif rr.Code != http.StatusOK {\r\n\t\tt.Fatalf(\"expected 200 with correct token, got %d\", rr.Code)\r\n\t}\r\n}\r","start_line":120,"end_line":128,"total_lines":128,"truncated":false}
{"query":"ListJobs","context_after":12,"glob":"internal/store/store.go"}
No matches found.
{"query":"ListJobs","context_after":14}
/project/internal/web/web.go:66: jobs, err := s.store.ListJobs(r.Context(), 200)
/project/internal/web/web.go-67- if err != nil {
/project/internal/web/web.go-68- http.Error(w, err.Error(), http.StatusInternalServerError)
/project/internal/web/web.go-69-
/project/internal/web/web.go-70- return
/project/internal/web/web.go-71- }
/project/internal/web/web.go-72-
/project/internal/web/web.go-73- s.render(w, "jobs", jobs)
/project/internal/web/web.go-74-}
/project/internal/web/web.go-75-
/project/internal/web/web.go-76-// toolBlock is the parsed form of a stream="tool" store.LogLine, for the
/project/internal/web/web.go-77-// template to render as a single collapsed detail.
/project/internal/web/web.go-78-type toolBlock struct {
/project/internal/web/web.go-79- Name string
/project/internal/web/web.go-80- Arguments string
--
/project/internal/store/store.go:202:// ListJobs returns the most recent jobs, newest first, up to limit.
/project/internal/store/store.go:203:func (s *Store) ListJobs(ctx context.Context, limit int) ([]Job, error) {
/project/internal/store/store.go-204- rows, err := s.db.QueryContext(ctx,
/project/internal/store/store.go-205- `SELECT id, event_kind, agent, owner, repo, issue_index, status, created_at, started_at, finished_at, error
/project/internal/store/store.go-206- FROM jobs ORDER BY created_at DESC LIMIT ?`, limit)
/project/internal/store/store.go-207- if err != nil {
/project/internal/store/store.go-208- return nil, fmt.Errorf("list jobs: %w", err)
/project/internal/store/store.go-209- }
/project/internal/store/store.go-210- defer rows.Close()
/project/internal/store/store.go-211-
/project/internal/store/store.go-212- var jobs []Job
/project/internal/store/store.go-213-
/project/internal/store/store.go-214- for rows.Next() {
/project/internal/store/store.go-215- var j Job
/project/internal/store/store.go-216- if err := rows.Scan(
/project/internal/store/store.go-217- &j.ID, &j.EventKind, &j.Agent, &j.Owner, &j.Repo, &j.IssueIndex, &j.Status,
--
/project/internal/store/store_test.go:128: jobs, err := s.ListJobs(ctx, 10)
/project/internal/store/store_test.go-129- if err != nil {
/project/internal/store/store_test.go-130- t.Fatal(err)
/project/internal/store/store_test.go-131- }
/project/internal/store/store_test.go-132- if len(jobs) != 1 || jobs[0].ID != "job-1" {
/project/internal/store/store_test.go-133- t.Fatalf("unexpected job list: %+v", jobs)
/project/internal/store/store_test.go-134- }
/project/internal/store/store_test.go-135-}
/project/internal/store/store_test.go-136-
/project/internal/store/store_test.go-137-func TestJobLogs(t *testing.T) {
/project/internal/store/store_test.go-138- s := open(t)
/project/internal/store/store_test.go-139- ctx := context.Background()
/project/internal/store/store_test.go-140-
/project/internal/store/store_test.go-141- if err := s.CreateJob(ctx, Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
/project/internal/store/store_test.go-142- t.Fatal(err)
--
/project/internal/scheduler/scheduler_test.go:147: jobs, err := st.ListJobs(ctx, 10)
/project/internal/scheduler/scheduler_test.go-148- if err != nil {
/project/internal/scheduler/scheduler_test.go-149- t.Fatal(err)
/project/internal/scheduler/scheduler_test.go-150- }
/project/internal/scheduler/scheduler_test.go-151- if len(jobs) != 1 || jobs[0].Status != store.JobSucceeded {
/project/internal/scheduler/scheduler_test.go-152- t.Fatalf("unexpected jobs: %+v", jobs)
/project/internal/scheduler/scheduler_test.go-153- }
/project/internal/scheduler/scheduler_test.go-154- if len(fg.comments) != 0 || len(fg.labels) != 0 {
/project/internal/scheduler/scheduler_test.go-155- t.Fatalf("expected no failure reporting on success, got comments=%v labels=%v", fg.comments, fg.labels)
/project/internal/scheduler/scheduler_test.go-156- }
/project/internal/scheduler/scheduler_test.go-157-}
/project/internal/scheduler/scheduler_test.go-158-
/project/internal/scheduler/scheduler_test.go-159-func TestSchedulerReportsFailure(t *testing.T) {
/project/internal/scheduler/scheduler_test.go-160- cfg := testConfig()
/project/internal/scheduler/scheduler_test.go-161- st := newStore(t)
--
/project/internal/scheduler/scheduler_test.go:178: jobs, err := st.ListJobs(ctx, 10)
/project/internal/scheduler/scheduler_test.go-179- if err != nil {
/project/internal/scheduler/scheduler_test.go-180- t.Fatal(err)
/project/internal/scheduler/scheduler_test.go-181- }
/project/internal/scheduler/scheduler_test.go-182- if len(jobs) != 1 || jobs[0].Status != store.JobFailed {
/project/internal/scheduler/scheduler_test.go-183- t.Fatalf("unexpected jobs: %+v", jobs)
/project/internal/scheduler/scheduler_test.go-184- }
/project/internal/scheduler/scheduler_test.go-185- if len(fg.comments) != 1 || len(fg.labels) != 1 || fg.labels[0] != FailureLabel {
/project/internal/scheduler/scheduler_test.go-186- t.Fatalf("expected failure comment+label, got comments=%v labels=%v", fg.comments, fg.labels)
/project/internal/scheduler/scheduler_test.go-187- }
/project/internal/scheduler/scheduler_test.go-188-}
/project/internal/scheduler/scheduler_test.go-189-
/project/internal/scheduler/scheduler_test.go-190-func TestSchedulerClassifiesTimeout(t *testing.T) {
/project/internal/scheduler/scheduler_test.go-191- cfg := testConfig()
/project/internal/scheduler/scheduler_test.go-192- st := newStore(t)
--
/project/internal/scheduler/scheduler_test.go:209: jobs, err := st.ListJobs(ctx, 10)
/project/internal/scheduler/scheduler_test.go-210- if err != nil {
/project/internal/scheduler/scheduler_test.go-211- t.Fatal(err)
/project/internal/scheduler/scheduler_test.go-212- }
/project/internal/scheduler/scheduler_test.go-213- if len(jobs) != 1 || jobs[0].Status != store.JobTimedOut {
/project/internal/scheduler/scheduler_test.go-214- t.Fatalf("expected timed_out status, got %+v", jobs)
/project/internal/scheduler/scheduler_test.go-215- }
/project/internal/scheduler/scheduler_test.go-216-}
/project/internal/scheduler/scheduler_test.go-217-
/project/internal/scheduler/scheduler_test.go-218-func TestSchedulerDropsUnresolvedEvent(t *testing.T) {
/project/internal/scheduler/scheduler_test.go-219- cfg := testConfig()
/project/internal/scheduler/scheduler_test.go-220- st := newStore(t)
/project/internal/scheduler/scheduler_test.go-221- fg := &fakeForgejo{}
/project/internal/scheduler/scheduler_test.go-222- runner := &fakeRunner{}
/project/internal/scheduler/scheduler_test.go-223- logger := slog.New(slog.DiscardHandler)
--
/project/internal/scheduler/scheduler_test.go:237: jobs, err := st.ListJobs(ctx, 10)
/project/internal/scheduler/scheduler_test.go-238- if err != nil {
/project/internal/scheduler/scheduler_test.go-239- t.Fatal(err)
{"command":"cd /project && sed -n '1,20p' internal/web/web.go"}
// Package web is zoo's minimal server-rendered dashboard: config
// overview, live queue/history, and a per-job log tail.
package web
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
type Server struct {
cfg *config.Config
store *store.Store
hub *livelog.Hub
{"command":"cd /project && python3 - <<'EOF'\nimport io\n\n# --- web.go ---\np = 'internal/web/web.go'\ns = io.open(p, encoding='utf-8').read()\n\ns = s.replace('''import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"time\"\n''', '''import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n''')\n\nold = '''\ts.render(w, \"jobs\", jobs)\n}'''\nnew = '''\ts.render(w, \"jobs\", struct {\n\t\tJobs []store.Job\n\t\tForgejoURL string\n\t}{jobs, strings.TrimSuffix(s.cfg.Forgejo.URL, \"/\")})\n}'''\nassert s.count(old) == 1, \"handleJobs render not found\"\ns = s.replace(old, new)\n\nio.open(p, 'w', encoding='utf-8').write(s)\nprint(\"web.go updated\")\nEOF"}
web.go updated
{"command":"cd /project && python3 - <<'EOF'\nimport io\n\np = 'internal/web/templates.go'\ns = io.open(p, encoding='utf-8').read()\n\n# --- CSS: add agent cell styles before the Responsive section ---\nold_css = ''' /* ── Responsive ─────────────────────────────── */'''\nnew_css = ''' /* ── Agent cell (avatar + name) ─────────────── */\n .agent {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n }\n\n .agent-avatar {\n width: 16px;\n height: 16px;\n border-radius: 50%;\n flex-shrink: 0;\n background: var(--bg-surface-hover);\n }\n\n /* ── Responsive ─────────────────────────────── */'''\nassert s.count(old_css) == 1, \"responsive css anchor not found\"\ns = s.replace(old_css, new_css)\n\n# --- jobs template: range over .Jobs, avatar + profile link in Agent cell ---\nold_row = ''' <tbody>\n {{range .}}\n <tr>\n <td><a href=\"/jobs/{{.ID}}\">{{.ID}}</a></td>\n <td>\n <span class=\"badge badge-{{.Status}}\">\n <span class=\"dot\"></span>\n {{.Status}}\n </span>\n </td>\n <td>{{.EventKind}}</td>\n <td><strong>{{.Agent}}</strong></td>\n <td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>\n <td>{{.CreatedAt.Format \"2006-01-02 15:04:05\"}}</td>\n </tr>\n {{end}}\n </tbody>'''\nnew_row = ''' <tbody>\n {{range .Jobs}}\n <tr>\n <td><a href=\"/jobs/{{.ID}}\">{{.ID}}</a></td>\n <td>\n <span class=\"badge badge-{{.Status}}\">\n <span class=\"dot\"></span>\n {{.Status}}\n </span>\n </td>\n <td>{{.EventKind}}</td>\n <td>\n <span class=\"agent\">\n <img class=\"agent-avatar\" src=\"{{$.ForgejoURL}}/avatars/{{.Agent}}\" alt=\"{{.Agent}} avatar\" width=\"16\" height=\"16\" onerror=\"this.remove()\">\n <a href=\"{{$.ForgejoURL}}/{{.Agent}}\" target=\"_blank\" rel=\"noopener\"><strong>{{.Agent}}</strong></a>\n </span>\n </td>\n <td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>\n <td>{{.CreatedAt.Format \"2006-01-02 15:04:05\"}}</td>\n </tr>\n {{end}}\n </tbody>'''\nassert s.count(old_row) == 1, \"jobs table row not found\"\ns = s.replace(old_row, new_row)\n\nio.open(p, 'w', encoding='utf-8').write(s)\nprint(\"templates.go updated\")\nEOF"}
templates.go updated
{"command":"cd /project && python3 - <<'EOF'\nimport io\n\np = 'internal/web/web_test.go'\ns = io.open(p, encoding='utf-8').read()\n\nold_cfg = '''\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t}'''\nnew_cfg = '''\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t\tForgejo: config.Forgejo{URL: \"https://forgejo.test\", Token: \"tok\"},\n\t}'''\nassert s.count(old_cfg) == 1, \"test config not found\"\ns = s.replace(old_cfg, new_cfg)\n\nold_check = '''\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"job-1\") {\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\n\t}'''\nnew_check = '''\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"job-1\") {\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\n\t}\n\tif !strings.Contains(rr.Body.String(), `src=\"https://forgejo.test/avatars/leon\"`) {\n\t\tt.Fatalf(\"expected agent avatar link on jobs page, got %s\", rr.Body.String())\n\t}\n\tif !strings.Contains(rr.Body.String(), `href=\"https://forgejo.test/leon\"`) {\n\t\tt.Fatalf(\"expected agent profile link on jobs page, got %s\", rr.Body.String())\n\t}'''\nassert s.count(old_check) == 1, \"jobs page check not found\"\ns = s.replace(old_check, new_check)\n\nio.open(p, 'w', encoding='utf-8').write(s)\nprint(\"web_test.go updated\")\nEOF"}
web_test.go updated
{"command":"cd /project && go build ./... && go vet ./internal/web/ && go test ./internal/web/ -v 2>&1 | tail -30"}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec === RUN TestIndexRenders --- PASS: TestIndexRenders (0.11s) === RUN TestJobsAndDetail --- PASS: TestJobsAndDetail (0.40s) === RUN TestAuthGate --- PASS: TestAuthGate (0.10s) PASS ok github.com/abrander/zoo/internal/web 0.613s
{"command":"cd /project && go test ./... 2>&1 | tail -15","timeout":300}
### mika
(no description set)
FAIL
FAIL github.com/abrander/zoo/internal/agentrun 0.004s
ok github.com/abrander/zoo/internal/config 0.004s
ok github.com/abrander/zoo/internal/forgejo 0.004s
ok github.com/abrander/zoo/internal/livelog 0.052s
ok github.com/abrander/zoo/internal/llm 0.003s
ok github.com/abrander/zoo/internal/scheduler 1.639s
ok github.com/abrander/zoo/internal/store 1.004s
ok github.com/abrander/zoo/internal/tools 0.003s
ok github.com/abrander/zoo/internal/web 0.622s
FAIL
{"command":"cd /project && go test ./internal/agentrun/ 2>&1 | head -20; echo \"---\"; git stash && go test ./internal/agentrun/ 2>&1 | head -5; git stash pop","timeout":120}
--- FAIL: TestIdentitySectionIncludesSelfAndOthers (0.00s)
roster_test.go:27: expected greg in roster, got:
## Your identity
You are the agent "leon". Your role, from your Forgejo profile description: Triages new issues
## Other agents
If this task is a better fit for another agent's role, use the assign_issue tool with their name to hand it off (this reassigns the issue/PR on Forgejo, which triggers their run):
### greg
Reviews pull requests
### mika
(no description set)
FAIL
FAIL github.com/abrander/zoo/internal/agentrun 0.004s
---
Saved working directory and index state WIP on issue-17-greg: 511c7f5 Mention the special git thing in the system prompt.
--- FAIL: TestIdentitySectionIncludesSelfAndOthers (0.00s)
roster_test.go:27: expected greg in roster, got:
## Your identity
On branch issue-17-greg
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: internal/web/templates.go
modified: internal/web/web.go
modified: internal/web/web_test.go
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (5901c92b79c44c1e9609f4ea994d9254b9925e29)
{"command":"cd /project && cat > /tmp/smoke_test.go <<'EOF'\npackage web\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestSmokeJobsHTML(t *testing.T) {\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer st.Close()\n\n\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"greg\", LLM: \"qwen\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t\tForgejo: config.Forgejo{URL: \"https://code.stdio.dk/\", Token: \"tok\"},\n\t}\n\n\ts := New(cfg, st, livelog.NewHub())\n\tif err := st.CreateJob(context.Background(), store.Job{ID: \"j1\", EventKind: \"issue:assigned\", Agent: \"greg\", Owner: \"abrander\", Repo: \"zoo\", IssueIndex: 17}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(\"GET\", \"/jobs\", nil))\n\tif rr.Code != 200 {\n\t\tt.Fatalf(\"status %d\", rr.Code)\n\t}\n\tbody := rr.Body.String()\n\ti := body.Index(\"<tbody>\")\n\tt.Log(body[i : i+900])\n}\nEOF\ncp /tmp/smoke_test.go internal/web/smoke_test.go && go test ./internal/web/ -run TestSmokeJobsHTML -v 2>&1 | head -40; rm internal/web/smoke_test.go","timeout":120}
# github.com/abrander/zoo/internal/web [github.com/abrander/zoo/internal/web.test] internal/web/smoke_test.go:40:12: body.Index undefined (type string has no field or method Index) FAIL github.com/abrander/zoo/internal/web [build failed] FAIL
{"command":"cd /project && cat > internal/web/smoke_test.go <<'EOF'\npackage web\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestSmokeJobsHTML(t *testing.T) {\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer st.Close()\n\n\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"greg\", LLM: \"qwen\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t\tForgejo: config.Forgejo{URL: \"https://code.stdio.dk/\", Token: \"tok\"},\n\t}\n\n\ts := New(cfg, st, livelog.NewHub())\n\tif err := st.CreateJob(context.Background(), store.Job{ID: \"j1\", EventKind: \"issue:assigned\", Agent: \"greg\", Owner: \"abrander\", Repo: \"zoo\", IssueIndex: 17}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(\"GET\", \"/jobs\", nil))\n\tif rr.Code != 200 {\n\t\tt.Fatalf(\"status %d\", rr.Code)\n\t}\n\tbody := rr.Body.String()\n\ti := strings.Index(body, \"<tbody>\")\n\tt.Log(body[i : i+900])\n}\nEOF\ngo test ./internal/web/ -run TestSmokeJobsHTML -v 2>&1 | head -40; rm internal/web/smoke_test.go","timeout":120}
=== RUN TestSmokeJobsHTML
--- FAIL: TestSmokeJobsHTML (0.12s)
panic: runtime error: slice bounds out of range [:11891] with length 11769 [recovered, repanicked]
goroutine 21 [running]:
testing.tRunner.func1.2({0x1079f00, 0x1a6d35156318})
/usr/local/go/src/testing/testing.go:2123 +0x232
testing.tRunner.func1()
/usr/local/go/src/testing/testing.go:2126 +0x329
panic({0x1079f00?, 0x1a6d35156318?})
/usr/local/go/src/runtime/panic.go:859 +0x125
github.com/abrander/zoo/internal/web.TestSmokeJobsHTML(0x1a6d3515c488)
/project/internal/web/smoke_test.go:42 +0x5a5
testing.tRunner(0x1a6d3515c488, 0x1097990)
/usr/local/go/src/testing/testing.go:2193 +0xea
created by testing.(*T).Run in goroutine 1
/usr/local/go/src/testing/testing.go:2258 +0x4d4
FAIL github.com/abrander/zoo/internal/web 0.130s
FAIL
{"command":"cd /project && cat > internal/web/smoke_test.go <<'EOF'\npackage web\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestSmokeJobsHTML(t *testing.T) {\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer st.Close()\n\n\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"greg\", LLM: \"qwen\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t\tForgejo: config.Forgejo{URL: \"https://code.stdio.dk/\", Token: \"tok\"},\n\t}\n\n\ts := New(cfg, st, livelog.NewHub())\n\tif err := st.CreateJob(context.Background(), store.Job{ID: \"j1\", EventKind: \"issue:assigned\", Agent: \"greg\", Owner: \"abrander\", Repo: \"zoo\", IssueIndex: 17}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(\"GET\", \"/jobs\", nil))\n\tif rr.Code != 200 {\n\t\tt.Fatalf(\"status %d\", rr.Code)\n\t}\n\tbody := rr.Body.String()\n\ti := strings.Index(body, \"<tr>\")\n\tj := strings.Index(body, \"</tr>\")\n\tt.Log(body[i : j+5])\n}\nEOF\ngo test ./internal/web/ -run TestSmokeJobsHTML -v 2>&1 | head -30; rm internal/web/smoke_test.go","timeout":120}
=== RUN TestSmokeJobsHTML
smoke_test.go:43: <tr>
<th>ID</th>
<th>Status</th>
<th>Event</th>
<th>Agent</th>
<th>Repository</th>
<th>Created</th>
</tr>
--- PASS: TestSmokeJobsHTML (0.12s)
PASS
ok github.com/abrander/zoo/internal/web 0.128s
{"command":"cd /project && cat > internal/web/smoke_test.go <<'EOF'\npackage web\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestSmokeJobsHTML(t *testing.T) {\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer st.Close()\n\n\tcfg := &config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"greg\", LLM: \"qwen\"}},\n\t\tMaxLive: 2,\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\"},\n\t\tForgejo: config.Forgejo{URL: \"https://code.stdio.dk/\", Token: \"tok\"},\n\t}\n\n\ts := New(cfg, st, livelog.NewHub())\n\tif err := st.CreateJob(context.Background(), store.Job{ID: \"j1\", EventKind: \"issue:assigned\", Agent: \"greg\", Owner: \"abrander\", Repo: \"zoo\", IssueIndex: 17}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(\"GET\", \"/jobs\", nil))\n\tif rr.Code != 200 {\n\t\tt.Fatalf(\"status %d\", rr.Code)\n\t}\n\tbody := rr.Body.String()\n\ti := strings.Index(body, \"agent-avatar\")\n\tif i < 0 {\n\t\tt.Fatal(\"avatar not found\")\n\t}\n\tstart := strings.LastIndex(body[:i], \"<tr>\")\n\tend := strings.Index(body[i:], \"</tr>\") + i + 5\n\tt.Log(body[start:end])\n}\nEOF\ngo test ./internal/web/ -run TestSmokeJobsHTML -v 2>&1 | head -30; rm internal/web/smoke_test.go","timeout":120}
=== RUN TestSmokeJobsHTML
--- FAIL: TestSmokeJobsHTML (0.13s)
panic: runtime error: slice bounds out of range [-1:] [recovered, repanicked]
goroutine 21 [running]:
testing.tRunner.func1.2({0x1079f40, 0x29f8d3956318})
/usr/local/go/src/testing/testing.go:2123 +0x232
testing.tRunner.func1()
/usr/local/go/src/testing/testing.go:2126 +0x329
panic({0x1079f40?, 0x29f8d3956318?})
/usr/local/go/src/runtime/panic.go:859 +0x125
github.com/abrander/zoo/internal/web.TestSmokeJobsHTML(0x29f8d395c488)
/project/internal/web/smoke_test.go:47 +0x68b
testing.tRunner(0x29f8d395c488, 0x10979d0)
/usr/local/go/src/testing/testing.go:2193 +0xea
created by testing.(*T).Run in goroutine 1
/usr/local/go/src/testing/testing.go:2258 +0x4d4
FAIL github.com/abrander/zoo/internal/web 0.138s
FAIL