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":"cd /project && git log --oneline -10 && git status"}
511c7f5 (HEAD -> issue-17-greg, origin/main, origin/HEAD, main) Mention the special git thing in the system prompt.
9b203d6 Try to resolve paths in tools somewhat better.
c1c4cba Allow more remote git operations.
809faf8 Less agents in example.
c3edf5a Add live streaming of job spying.
7e30176 Merge pull request 'Better container names: zoo-issue-{index}-{agent}' (#7) from issue-6-greg into main
1e3eb24 Add meaningful container names to Docker container creation
17cd966 Try to not let the bash tool get stuck on pager.
387906c Do not put the secret token inside the fricking sandbox.
ec7c296 More job logging.
On branch issue-17-greg
nothing to commit, working tree clean
{"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":250}
{"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\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","start_line":1,"end_line":250,"total_lines":843,"truncated":true}
{"query":"define \"jobs\"","context_after":80}
/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>
/project/internal/web/templates.go-671- </div>
/project/internal/web/templates.go-672- <div class="job-meta-item">
/project/internal/web/templates.go-673- <span class="label">Event</span>
/project/internal/web/templates.go-674- <span class="value">{{.Job.EventKind}} on <code>{{.Job.Owner}}/{{.Job.Repo}}#{{.Job.IssueIndex}}</code></span>
/project/internal/web/templates.go-675- </div>
/project/internal/web/templates.go-676- <div class="job-meta-item">
/project/internal/web/templates.go-677- <span class="label">Agent</span>
/project/internal/web/templates.go-678- <span class="value"><strong>{{.Job.Agent}}</strong></span>
/project/internal/web/templates.go-679- </div>
/project/internal/web/templates.go-680- {{if .Job.Error}}
/project/internal/web/templates.go-681- <div class="job-meta-item">
/project/internal/web/templates.go-682- <span class="label">Error</span>
/project/internal/web/templates.go-683- <span class="value error-text">{{.Job.Error}}</span>
/project/internal/web/templates.go-684- </div>
/project/internal/web/templates.go-685- {{end}}
/project/internal/web/templates.go-686- </div>
/project/internal/web/templates.go-687-
/project/internal/web/templates.go-688- <h2>Log</h2>
/project/internal/web/templates.go-689- <div class="log-container" id="log">
/project/internal/web/templates.go-690- {{range .Blocks}}
{"path":"internal/store/store.go","start_line":1,"end_line":150}
{"path":"internal/store/store.go","content":"// Package store provides zoo's embedded persistence: the Forgejo\r\n// poll watermark, event dedup, and job/log history for the dashboard.\r\npackage store\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"database/sql\"\r\n\t_ \"embed\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\t_ \"modernc.org/sqlite\"\r\n)\r\n\r\n//go:embed schema.sql\r\nvar schema string\r\n\r\ntype Store struct {\r\n\tdb *sql.DB\r\n}\r\n\r\n// Open opens (creating if necessary) the SQLite database at path and\r\n// applies the schema.\r\nfunc Open(path string) (*Store, error) {\r\n\tdb, err := sql.Open(\"sqlite\", path)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"open database: %w\", err)\r\n\t}\r\n\r\n\t// SQLite only supports one writer at a time; serialize access rather\r\n\t// than fighting SQLITE_BUSY errors under concurrent agent runs.\r\n\tdb.SetMaxOpenConns(1)\r\n\r\n\tif _, err := db.Exec(schema); err != nil {\r\n\t\tdb.Close()\r\n\r\n\t\treturn nil, fmt.Errorf(\"apply schema: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Store{db: db}, nil\r\n}\r\n\r\nfunc (s *Store) Close() error {\r\n\treturn s.db.Close()\r\n}\r\n\r\n// MarkSeen records that event id has been processed. It returns false if\r\n// the event was already seen (by webhook or poll), so callers can dedupe\r\n// regardless of source.\r\nfunc (s *Store) MarkSeen(ctx context.Context, id string) (isNew bool, err error) {\r\n\tres, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT OR IGNORE INTO seen_events (id, seen_at) VALUES (?, ?)`,\r\n\t\tid, time.Now().UTC())\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\tn, err := res.RowsAffected()\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\treturn n \u003e 0, nil\r\n}\r\n\r\n// GetWatermark returns the stored value for key, or \"\" if unset.\r\nfunc (s *Store) GetWatermark(ctx context.Context, key string) (string, error) {\r\n\tvar value string\r\n\r\n\terr := s.db.QueryRowContext(ctx, `SELECT value FROM watermark WHERE key = ?`, key).Scan(\u0026value)\r\n\tif err == sql.ErrNoRows {\r\n\t\treturn \"\", nil\r\n\t}\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"get watermark: %w\", err)\r\n\t}\r\n\r\n\treturn value, nil\r\n}\r\n\r\n// SetWatermark stores value for key, overwriting any previous value.\r\nfunc (s *Store) SetWatermark(ctx context.Context, key, value string) error {\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT INTO watermark (key, value) VALUES (?, ?)\r\n\t\t ON CONFLICT (key) DO UPDATE SET value = excluded.value`,\r\n\t\tkey, value)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set watermark: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\ntype JobStatus string\r\n\r\nconst (\r\n\tJobPending JobStatus = \"pending\"\r\n\tJobRunning JobStatus = \"running\"\r\n\tJobSucceeded JobStatus = \"succeeded\"\r\n\tJobFailed JobStatus = \"failed\"\r\n\tJobTimedOut JobStatus = \"timed_out\"\r\n)\r\n\r\ntype Job struct {\r\n\tID string\r\n\tEventKind string\r\n\tAgent string\r\n\tOwner string\r\n\tRepo string\r\n\tIssueIndex int64\r\n\tStatus JobStatus\r\n\tCreatedAt time.Time\r\n\tStartedAt sql.NullTime\r\n\tFinishedAt sql.NullTime\r\n\tError string\r\n}\r\n\r\n// CreateJob inserts a new job in JobPending status.\r\nfunc (s *Store) CreateJob(ctx context.Context, j Job) error {\r\n\tif j.Status == \"\" {\r\n\t\tj.Status = JobPending\r\n\t}\r\n\r\n\tif j.CreatedAt.IsZero() {\r\n\t\tj.CreatedAt = time.Now().UTC()\r\n\t}\r\n\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT INTO jobs (id, event_kind, agent, owner, repo, issue_index, status, created_at, error)\r\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')`,\r\n\t\tj.ID, j.EventKind, j.Agent, j.Owner, j.Repo, j.IssueIndex, j.Status, j.CreatedAt)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create job: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// ReapOrphanedJobs transitions any job still in JobRunning status to\r\n// JobFailed. It's meant to be called once at daemon startup: a job left\r\n// \"running\" in the database can only be one abandoned by a previous\r\n// process instance that died (crash, OOM kill, host restart) before it\r\n// could record a terminal status β no process in the current instance is\r\n// running it. Returns the number of jobs reaped.\r\nfunc (s *Store) ReapOrphanedJobs(ctx context.Context) (int64, error) {\r\n\tres, err := s.db.ExecContext(ctx,\r\n\t\t`UPDATE jobs SET status = ?, finished_at = ?, error = ? WHERE status = ?`,\r\n\t\tJobFailed, time.Now().UTC(), \"orphaned: process restarted while job was running\", JobRunning)\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"reap orphaned jobs: %w\", err)\r","start_line":1,"end_line":150,"total_lines":279,"truncated":true}
{"path":"internal/agentrun/roster.go","start_line":1,"end_line":120}
{"path":"internal/agentrun/roster.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\n// agentInfo pairs a configured agent with its Forgejo profile: the bio\r\n// (what its account says it does) and the name/email used to set git\r\n// commit authorship inside its container.\r\ntype agentInfo struct {\r\n\tName string\r\n\tDescription string\r\n\tFullName string\r\n\tEmail string\r\n}\r\n\r\n// buildRoster fetches every configured agent's Forgejo profile (agent\r\n// config labels double as Forgejo usernames). A per-agent lookup failure\r\n// is logged and that agent is included with an empty profile rather than\r\n// failing the whole run β one missing/unreachable profile shouldn't\r\n// block someone else's job.\r\nfunc buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {\r\n\troster := make([]agentInfo, 0, len(agents))\r\n\r\n\tfor _, a := range agents {\r\n\t\tprofile, err := fg.AgentProfile(a.Name)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"failed to fetch agent profile from forgejo\", \"agent\", a.Name, \"error\", err)\r\n\t\t}\r\n\r\n\t\troster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})\r\n\t}\r\n\r\n\treturn roster\r\n}\r\n\r\n// gitIdentity returns the git commit author name/email to configure\r\n// inside self's container, from its Forgejo profile, falling back to\r\n// its agent name and a synthetic zoo.local address for whichever fields\r\n// its profile doesn't set.\r\nfunc gitIdentity(self string, roster []agentInfo) (name, email string) {\r\n\tfor _, a := range roster {\r\n\t\tif a.Name == self {\r\n\t\t\tname, email = a.FullName, a.Email\r\n\t\t\tbreak\r\n\t\t}\r\n\t}\r\n\r\n\tif name == \"\" {\r\n\t\tname = self\r\n\t}\r\n\r\n\tif email == \"\" {\r\n\t\temail = self + \"@zoo.local\"\r\n\t}\r\n\r\n\treturn name, email\r\n}\r\n\r\n// identitySection renders the \"who am I / who else is there\" prompt\r\n// section injected after the static system prompt: the agent's own\r\n// Forgejo profile description, and everyone else's, so it can decide\r\n// when a task is better handed off via the assign_issue tool.\r\nfunc identitySection(self string, roster []agentInfo) string {\r\n\tvar b strings.Builder\r\n\r\n\tvar selfDesc string\r\n\tvar others []agentInfo\r\n\r\n\tfor _, a := range roster {\r\n\t\tif a.Name == self {\r\n\t\t\tselfDesc = a.Description\r\n\t\t} else {\r\n\t\t\tothers = append(others, a)\r\n\t\t}\r\n\t}\r\n\r\n\tfmt.Fprintf(\u0026b, \"\\n\\n## Your identity\\n\\nYou are the agent %q.\", self)\r\n\r\n\tif selfDesc != \"\" {\r\n\t\tfmt.Fprintf(\u0026b, \" Your role, from your Forgejo profile description: %s\", selfDesc)\r\n\t} else {\r\n\t\tb.WriteString(\" Your Forgejo profile has no description set.\")\r\n\t}\r\n\r\n\tif len(others) \u003e 0 {\r\n\t\tb.WriteString(\"\\n\\n## Other agents\\n\\nIf 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):\\n\")\r\n\r\n\t\tfor _, a := range others {\r\n\t\t\tdesc := a.Description\r\n\t\t\tif desc == \"\" {\r\n\t\t\t\tdesc = \"(no description set)\"\r\n\t\t\t}\r\n\r\n\t\t\tfmt.Fprintf(\u0026b, \"### %s\\n\\n%s\\n\\n\", a.Name, desc)\r\n\t\t}\r\n\t}\r\n\r\n\treturn b.String()\r\n}\r","start_line":1,"end_line":105,"total_lines":105,"truncated":false}
{"path":"internal/forgejo/client.go","start_line":1,"end_line":200}
{"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\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RemoveLabel detaches the label with the given name from an issue/PR, if\r\n// both the label and the attachment exist.\r\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\r\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t\t\t}\r\n\t\t\treturn nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\r\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l.ID, nil\r\n\t\t}\r\n\t}\r\n\r\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\r\n\t\tName: name,\r\n\t\tColor: \"#ee0000\",\r\n\t})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\r\n\t}\r\n\r\n\treturn created.ID, nil\r\n}\r\n\r\n// CreatePullRequest opens a PR from head into base.\r\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\r\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\r\n\t\tHead: head,\r\n\t\tBase: base,\r\n\t\tTitle: title,\r\n\t\tBody: body,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RequestReview asks the given users to review the pull request.\r\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\r\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// CloseIssue closes the given issue or pull request.\r\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\r\n}\r\n\r\n// ReopenIssue reopens the given issue or pull request.\r\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\r\n}\r\n\r\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\r\n// clone and branch off of the right place.\r\ntype RepositoryInfo struct {\r\n\tDefaultBranch string\r\n\tCloneURL string\r\n}\r\n\r\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\r\n\tr, _, err := c.sdk.GetRepo(owner, repo)\r\n\tif err != nil {\r\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\r\n}\r\n\r\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\r\n// (surfaced in the system prompt) and the name/email used to set git\r\n// commit authorship inside that agent's container.\r\ntype AgentProfile struct {\r\n\tDescription string\r\n\tFullName string\r\n\tEmail string\r\n}\r\n\r","start_line":1,"end_line":200,"total_lines":232,"truncated":true}
{"path":"internal/config/config.go","start_line":1,"end_line":200}
{"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\n\t\t}\r\n\t}\r\n\r\n\treturn Agent{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn LLM{}, false\r\n}\r\n\r\n// EventAgent returns the statically configured agent name for the given\r\n// event kind, if one is declared and set.\r\nfunc (c *Config) EventAgent(kind string) (string, bool) {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Agent, e.Agent != \"\"\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\", false\r\n}\r\n\r\n// EventInstructions returns the configured freeform instructions for the\r\n// given event kind, e.g. \"Please review this pull request and respond\r\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\r\nfunc (c *Config) EventInstructions(kind string) string {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Instructions\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\"\r\n}\r","start_line":1,"end_line":191,"total_lines":191,"truncated":false}
{"path":"internal/forgejo/client.go","start_line":200,"end_line":232}
{"path":"internal/forgejo/client.go","content":"\r\n// AgentProfile fetches the Forgejo profile of the user account named\r\n// username. Agent config labels double as their Forgejo usernames (see\r\n// issue:assigned resolution), so this is how an agent's \"what it does\"\r\n// text, and its commit identity, are authored: by editing that account's\r\n// profile on Forgejo, not in zoo.hcl.\r\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\r\n\tu, _, err := c.sdk.GetUserInfo(username)\r\n\tif err != nil {\r\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\r\n\t}\r\n\r\n\tp := AgentProfile{\r\n\t\tDescription: u.Description,\r\n\t\tFullName: u.FullName,\r\n\t\tEmail: u.Email,\r\n\t}\r\n\r\n\treturn p, nil\r\n}\r\n\r\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\r\n// the assign_issue tool so agents can hand work off to each other: the\r\n// resulting issue:assigned event (webhook or poll) triggers whichever\r\n// agent's config label matches username.\r\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":200,"end_line":232,"total_lines":232,"truncated":false}
{"path":"cmd/zoo/main.go","start_line":1,"end_line":200}
{"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\n\r\n\tif err := server.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\r\n\t}\r\n\r\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":131,"total_lines":131,"truncated":false}
{"path":"internal/web/web_test.go","start_line":1,"end_line":150}
{"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\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":1,"end_line":128,"total_lines":128,"truncated":false}
{"query":"buildRoster|AgentProfile","context_after":4}
No matches found.
{"query":"buildRoster"}
/project/internal/agentrun/run.go:132: roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
/project/internal/agentrun/roster.go:22:// buildRoster fetches every configured agent's Forgejo profile (agent
/project/internal/agentrun/roster.go:27:func buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {
{"query":"AgentProfile"}
/project/internal/forgejo/client.go:192:// AgentProfile is what zoo reads off an agent's Forgejo account: its bio
/project/internal/forgejo/client.go:195:type AgentProfile struct {
/project/internal/forgejo/client.go:201:// AgentProfile fetches the Forgejo profile of the user account named
/project/internal/forgejo/client.go:206:func (c *Client) AgentProfile(username string) (AgentProfile, error) {
/project/internal/forgejo/client.go:209: return AgentProfile{}, fmt.Errorf("get user %q: %w", username, err)
/project/internal/forgejo/client.go:212: p := AgentProfile{
/project/internal/agentrun/roster.go:31: profile, err := fg.AgentProfile(a.Name)
{"command":"cd /project && go env GOMODCACHE && grep -rn \"AvatarURL\" $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2*/user.go 2>/dev/null | head -20"}
/go/pkg/mod
{"command":"cd /project && cat go.mod && go mod download codeberg.org/mvdkleijn/forgejo-sdk 2>&1 | head -5; grep -rn \"AvatarURL\" /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2*/user.go 2>/dev/null | head"}
module github.com/abrander/zoo go 1.26.4 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 modernc.org/sqlite v1.56.0 ) require ( github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) go: module codeberg.org/mvdkleijn/forgejo-sdk: not a known dependency
{"command":"ls /go/pkg/mod/codeberg.org/mvdkleijn/ 2>/dev/null; find /go/pkg/mod/codeberg.org -name \"user.go\" -path \"*forgejo*\" 2>/dev/null | head"}
{"command":"cd /project && go build ./... 2>&1 | tail -5; find /go/pkg/mod/codeberg.org -name \"user.go\" 2>/dev/null | head","timeout":300}
go: downloading modernc.org/libc v1.74.4 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 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go
{"command":"grep -n \"AvatarURL\\|type User struct\" -A 30 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go | head -60"}
19:type User struct {
20- // the user's id
21- ID int64 `json:"id"`
22- // the user's username
23- UserName string `json:"login"`
24- // The login_name of non local users (e.g. LDAP / OAuth / SMTP)
25- LoginName string `json:"login_name"`
26- // The ID of the Authentication Source for non local users.
27- SourceID int64 `json:"source_id"`
28- // the user's full name
29- FullName string `json:"full_name"`
30- Email string `json:"email"`
31- // URL to the user's page
32- HTMLURL string `json:"html_url"`
33- // URL to the user's avatar
34: AvatarURL string `json:"avatar_url"`
35- // User locale
36- Language string `json:"language"`
37- // Is the user an administrator
38- IsAdmin bool `json:"is_admin"`
39- // Date and Time of last login
40- LastLogin time.Time `json:"last_login"`
41- // Date and Time of user creation
42- Created time.Time `json:"created"`
43- // Is user restricted
44- Restricted bool `json:"restricted"`
45- // Is user active
46- IsActive bool `json:"active"`
47- // Is user login prohibited
48- ProhibitLogin bool `json:"prohibit_login"`
49- // the user's location
50- Location string `json:"location"`
51- // the user's website
52- Website string `json:"website"`
53- // the user's description
54- Description string `json:"description"`
55- // User visibility level option
56- Visibility VisibleType `json:"visibility"`
57-
58- // user counts
59- FollowerCount int `json:"followers_count"`
60- FollowingCount int `json:"following_count"`
61- StarredRepoCount int `json:"starred_repos_count"`
62-}
63-
64-// GetUserInfo get user info by user's name
{"query":"web\\.New"}
/project/cmd/zoo/main.go:79: dashboard := web.New(cfg, st, hub)
{"path":"internal/web/templates.go","start_line":250,"end_line":420}
{"path":"internal/web/templates.go","content":" }\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\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","start_line":250,"end_line":420,"total_lines":843,"truncated":true}
{"query":"define \"index\"","context_after":60}
/project/internal/web/templates.go:539:{{define "index"}}
/project/internal/web/templates.go-540-{{template "layout_head" .}}
/project/internal/web/templates.go-541-<div class="container">
/project/internal/web/templates.go-542- <div class="page-header">
/project/internal/web/templates.go-543- <h1>Dashboard</h1>
/project/internal/web/templates.go-544- <p>Overview of your zoo configuration and running agents.</p>
/project/internal/web/templates.go-545- </div>
/project/internal/web/templates.go-546-
/project/internal/web/templates.go-547- <h2>LLMs</h2>
/project/internal/web/templates.go-548- <div class="table-wrap">
/project/internal/web/templates.go-549- <table>
/project/internal/web/templates.go-550- <thead><tr><th>Name</th><th>Endpoint</th><th>Model</th></tr></thead>
/project/internal/web/templates.go-551- <tbody>
/project/internal/web/templates.go-552- {{range .LLMs}}
/project/internal/web/templates.go-553- <tr>
/project/internal/web/templates.go-554- <td><strong>{{.Name}}</strong></td>
/project/internal/web/templates.go-555- <td><code>{{.OpenAI}}</code></td>
/project/internal/web/templates.go-556- <td>{{.Model}}</td>
/project/internal/web/templates.go-557- </tr>
/project/internal/web/templates.go-558- {{end}}
/project/internal/web/templates.go-559- </tbody>
/project/internal/web/templates.go-560- </table>
/project/internal/web/templates.go-561- </div>
/project/internal/web/templates.go-562-
/project/internal/web/templates.go-563- <h2>Agents</h2>
/project/internal/web/templates.go-564- <div class="table-wrap">
/project/internal/web/templates.go-565- <table>
/project/internal/web/templates.go-566- <thead><tr><th>Name</th><th>LLM</th></tr></thead>
/project/internal/web/templates.go-567- <tbody>
/project/internal/web/templates.go-568- {{range .Agents}}
/project/internal/web/templates.go-569- <tr>
/project/internal/web/templates.go-570- <td><strong>{{.Name}}</strong></td>
/project/internal/web/templates.go-571- <td>{{.LLM}}</td>
/project/internal/web/templates.go-572- </tr>
/project/internal/web/templates.go-573- {{end}}
/project/internal/web/templates.go-574- </tbody>
/project/internal/web/templates.go-575- </table>
/project/internal/web/templates.go-576- </div>
/project/internal/web/templates.go-577-
/project/internal/web/templates.go-578- <h2>Event Mappings</h2>
/project/internal/web/templates.go-579- <div class="table-wrap">
/project/internal/web/templates.go-580- <table>
/project/internal/web/templates.go-581- <thead><tr><th>Event</th><th>Agent</th><th>Instructions</th></tr></thead>
/project/internal/web/templates.go-582- <tbody>
/project/internal/web/templates.go-583- {{range .Events}}
/project/internal/web/templates.go-584- <tr>
/project/internal/web/templates.go-585- <td><code>{{.Kind}}</code></td>
/project/internal/web/templates.go-586- <td>{{if .Agent}}<strong>{{.Agent}}</strong>{{else}}<em style="color:var(--text-muted)">dynamic (assignee username)</em>{{end}}</td>
/project/internal/web/templates.go-587- <td>{{.Instructions}}</td>
/project/internal/web/templates.go-588- </tr>
/project/internal/web/templates.go-589- {{end}}
/project/internal/web/templates.go-590- </tbody>
/project/internal/web/templates.go-591- </table>
/project/internal/web/templates.go-592- </div>
/project/internal/web/templates.go-593-
/project/internal/web/templates.go-594- <h2>Environment</h2>
/project/internal/web/templates.go-595- <div class="info-grid">
/project/internal/web/templates.go-596- <div class="info-item">
/project/internal/web/templates.go-597- <span class="label">Docker Image</span>
/project/internal/web/templates.go-598- <span class="value"><code>{{.Environment.DockerImage}}</code></span>
/project/internal/web/templates.go-599- </div>