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/edit-file.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"}
6565a52 (HEAD -> issue-17-greg, origin/main, origin/HEAD, main) Use base64 when reading files. TTY can fuck up special characters. f348d6c Merge pull request 'Fix TestIdentitySectionIncludesSelfAndOthers: align test with actual roster rendering' (#19) from issue-18-greg into main 5329c37 test(agentrun): align identitySection roster assertions with actual rendering 2ea3094 Better assignee tracking. e62607f Allow agents two hours of runtime. b6a6738 Add a edit_file tool. 511c7f5 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. 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/templates.go","start_line":1,"end_line":200}
{"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","start_line":1,"end_line":200,"total_lines":843,"truncated":true}
{"end_line":843,"path":"internal/web/templates.go","start_line":200}
{"path":"internal/web/templates.go","content":" 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\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\n{{template \"layout_head\" .}}\r\n\u003cdiv class=\"container\"\u003e\r\n \u003cdiv class=\"page-header\"\u003e\r\n \u003ch1\u003eJobs\u003c/h1\u003e\r\n \u003cp\u003eAll agent runs and their current status.\u003c/p\u003e\r\n \u003c/div\u003e\r\n\r\n \u003cdiv class=\"table-wrap\"\u003e\r\n \u003ctable\u003e\r\n \u003cthead\u003e\r\n \u003ctr\u003e\r\n \u003cth\u003eID\u003c/th\u003e\r\n \u003cth\u003eStatus\u003c/th\u003e\r\n \u003cth\u003eEvent\u003c/th\u003e\r\n \u003cth\u003eAgent\u003c/th\u003e\r\n \u003cth\u003eRepository\u003c/th\u003e\r\n \u003cth\u003eCreated\u003c/th\u003e\r\n \u003c/tr\u003e\r\n \u003c/thead\u003e\r\n \u003ctbody\u003e\r\n {{range .}}\r\n \u003ctr\u003e\r\n \u003ctd\u003e\u003ca href=\"/jobs/{{.ID}}\"\u003e{{.ID}}\u003c/a\u003e\u003c/td\u003e\r\n \u003ctd\u003e\r\n \u003cspan class=\"badge badge-{{.Status}}\"\u003e\r\n \u003cspan class=\"dot\"\u003e\u003c/span\u003e\r\n {{.Status}}\r\n \u003c/span\u003e\r\n \u003c/td\u003e\r\n \u003ctd\u003e{{.EventKind}}\u003c/td\u003e\r\n \u003ctd\u003e\u003cstrong\u003e{{.Agent}}\u003c/strong\u003e\u003c/td\u003e\r\n \u003ctd\u003e\u003ccode\u003e{{.Owner}}/{{.Repo}}#{{.IssueIndex}}\u003c/code\u003e\u003c/td\u003e\r\n \u003ctd\u003e{{.CreatedAt.Format \"2006-01-02 15:04:05\"}}\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\u003c/div\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n{{end}}\r\n\r\n{{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\n text.className = \"tool-summary-text\";\r\n\r\n var name = document.createElement(\"span\");\r\n name.className = \"tool-name\";\r\n name.textContent = \"🔧 \" + ev.name;\r\n\r\n var preview = document.createElement(\"span\");\r\n preview.className = \"tool-args-preview\";\r\n preview.textContent = ev.arguments;\r\n\r\n text.appendChild(name);\r\n text.appendChild(preview);\r\n summary.appendChild(badge);\r\n summary.appendChild(text);\r\n details.appendChild(summary);\r\n\r\n var body = document.createElement(\"div\");\r\n body.className = \"block-body\";\r\n\r\n var argsLabel = document.createElement(\"div\");\r\n argsLabel.className = \"tool-section-label\";\r\n argsLabel.textContent = \"Arguments\";\r\n var argsPre = document.createElement(\"pre\");\r\n argsPre.textContent = ev.arguments;\r\n\r\n var resultLabel = document.createElement(\"div\");\r\n resultLabel.className = \"tool-section-label\";\r\n resultLabel.textContent = \"Result\";\r\n var resultPre = document.createElement(\"pre\");\r\n resultPre.textContent = ev.result;\r\n\r\n body.appendChild(argsLabel);\r\n body.appendChild(argsPre);\r\n body.appendChild(resultLabel);\r\n body.appendChild(resultPre);\r\n details.appendChild(body);\r\n log.appendChild(details);\r\n }\r\n\r\n var es = new EventSource(\"/jobs/\" + jobID + \"/events\");\r\n\r\n es.onmessage = function(e) {\r\n var ev = JSON.parse(e.data);\r\n var shouldScroll = nearBottom();\r\n\r\n switch (ev.type) {\r\n case \"reasoning_start\":\r\n reasoningBody = newBlock(\"reasoning\", \"Thinking\");\r\n break;\r\n case \"reasoning_delta\":\r\n if (reasoningBody) reasoningBody.textContent += ev.text;\r\n break;\r\n case \"reasoning_end\":\r\n reasoningBody = null;\r\n break;\r\n case \"content_start\":\r\n contentBody = newBlock(\"content\", null);\r\n break;\r\n case \"content_delta\":\r\n if (contentBody) contentBody.textContent += ev.text;\r\n break;\r\n case \"content_end\":\r\n contentBody = null;\r\n break;\r\n case \"tool\":\r\n newToolBlock(ev);\r\n break;\r\n case \"status\":\r\n es.close();\r\n location.reload();\r\n return;\r\n }\r\n\r\n if (shouldScroll) window.scrollTo(0, document.body.scrollHeight);\r\n };\r\n })();\r\n \u003c/script\u003e\r\n {{end}}\r\n\u003c/div\u003e\r\n\u003c/body\u003e\r\n\u003c/html\u003e\r\n{{end}}\r\n`\r","start_line":200,"end_line":843,"total_lines":843,"truncated":false}
{"end_line":252,"path":"internal/web/web.go","start_line":200}
{"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/forgejo/client.go","start_line":1,"end_line":150}
{"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","start_line":1,"end_line":150,"total_lines":232,"truncated":true}
{"query":"type Job struct","context_after":30}
/project/internal/store/store.go:184:type Job struct {
/project/internal/store/store.go-185- ID string
/project/internal/store/store.go-186- EventKind string
/project/internal/store/store.go-187- Agent string
/project/internal/store/store.go-188- Owner string
/project/internal/store/store.go-189- Repo string
/project/internal/store/store.go-190- IssueIndex int64
/project/internal/store/store.go-191- Status JobStatus
/project/internal/store/store.go-192- CreatedAt time.Time
/project/internal/store/store.go-193- StartedAt sql.NullTime
/project/internal/store/store.go-194- FinishedAt sql.NullTime
/project/internal/store/store.go-195- Error string
/project/internal/store/store.go-196-}
/project/internal/store/store.go-197-
/project/internal/store/store.go-198-// CreateJob inserts a new job in JobPending status.
/project/internal/store/store.go-199-func (s *Store) CreateJob(ctx context.Context, j Job) error {
/project/internal/store/store.go-200- if j.Status == "" {
/project/internal/store/store.go-201- j.Status = JobPending
/project/internal/store/store.go-202- }
/project/internal/store/store.go-203-
/project/internal/store/store.go-204- if j.CreatedAt.IsZero() {
/project/internal/store/store.go-205- j.CreatedAt = time.Now().UTC()
/project/internal/store/store.go-206- }
/project/internal/store/store.go-207-
/project/internal/store/store.go-208- _, err := s.db.ExecContext(ctx,
/project/internal/store/store.go-209- `INSERT INTO jobs (id, event_kind, agent, owner, repo, issue_index, status, created_at, error)
/project/internal/store/store.go-210- VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')`,
/project/internal/store/store.go-211- j.ID, j.EventKind, j.Agent, j.Owner, j.Repo, j.IssueIndex, j.Status, j.CreatedAt)
/project/internal/store/store.go-212- if err != nil {
/project/internal/store/store.go-213- return fmt.Errorf("create job: %w", err)
/project/internal/store/store.go-214- }
{"end_line":232,"path":"internal/forgejo/client.go","start_line":150}
{"path":"internal/forgejo/client.go","content":"\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\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":150,"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":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":"Forgejo struct|URL|Token","glob":"internal/config/config.go","context_after":10}
No matches found.
{"path":"internal/config/config.go","start_line":1,"end_line":120}
{"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","start_line":1,"end_line":120,"total_lines":191,"truncated":true}
{"command":"curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\n\" \"https://code.stdio.dk/greg/avatar\" 2>&1; curl -s -o /dev/null -w \"%{http_code} %{content_type}\\n\" \"https://code.stdio.dk/greg/avatar\" -L 2>&1"}
404 404 text/plain;charset=utf-8
{"command":"curl -s -o /dev/null -w \"greg: %{http_code} %{content_type}\\n\" \"https://code.stdio.dk/avatars/893e287248431844784e5109d4b0b7eaa5fe7ea602efdec156f7b57c7034aa57\"; curl -s -o /dev/null -w \"abrander: %{http_code} %{content_type}\\n\" \"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d\"; curl -s \"https://code.stdio.dk/api/v1/users/greg\" | head -c 400; echo; curl -s -o /dev/null -w \"api-greg: %{http_code}\\n\" \"https://code.stdio.dk/api/v1/users/greg\""}
greg: 200 image/png
abrander: 303 text/html; charset=utf-8
{"message":"Visit Project","url":"https://code.stdio.dk/api/swagger","errors":[]}
api-greg: 404
{"command":"cd /project && cat go.mod && grep -rn \"GetUserInfo\" $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2*/ 2>/dev/null | head -5"}
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 )
{"command":"cd /project && ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/ 2>/dev/null; find $(go env GOMODCACHE)/codeberg.org -maxdepth 4 -name \"*.go\" -path \"*forgejo*\" 2>/dev/null | head; grep -rn \"func (c \\*Client) GetUserInfo\" $(go env GOMODCACHE)/codeberg.org/ 2>/dev/null | head -3"}
{"command":"cd /project && go version && go mod download 2>&1 | tail -2; find $(go env GOMODCACHE) -maxdepth 3 -type d -name \"*forgejo*\" 2>/dev/null","timeout":300}
go version go1.27.0 linux/amd64 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk && ls forgejo/v2@v2.2.0/ | head -30; grep -n \"func (c \\*Client) GetUserInfo\" forgejo/v2@v2.2.0/*.go; grep -n \"AvatarURL\" forgejo/v2@v2.2.0/user.go | head -5"}
LICENSE
admin_cron.go
admin_org.go
admin_repo.go
admin_test.go
admin_user.go
agent.go
agent_windows.go
attachment.go
client.go
client_test.go
doc.go
fork.go
git_blob.go
git_hook.go
go.mod
go.sum
helper.go
hook.go
hook_validate.go
hook_validate_test.go
httpsign.go
issue.go
issue_comment.go
issue_comment_test.go
issue_label.go
issue_label_test.go
issue_milestone.go
issue_milestone_test.go
issue_reaction.go
forgejo/v2@v2.2.0/user.go:65:func (c *Client) GetUserInfo(user string) (*User, *Response, error) {
34: AvatarURL string `json:"avatar_url"`
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n 55,80p user.go"}
// User visibility level option
Visibility VisibleType `json:"visibility"`
// user counts
FollowerCount int `json:"followers_count"`
FollowingCount int `json:"following_count"`
StarredRepoCount int `json:"starred_repos_count"`
}
// GetUserInfo get user info by user's name
func (c *Client) GetUserInfo(user string) (*User, *Response, error) {
if err := escapeValidatePathSegments(&user); err != nil {
return nil, nil, err
}
u := new(User)
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s", user), nil, nil, u)
return u, resp, err
}
// GetMyUserInfo get user info of current user
func (c *Client) GetMyUserInfo() (*User, *Response, error) {
u := new(User)
resp, err := c.getParsedResponse("GET", "/user", nil, nil, u)
return u, resp, err
}
{"query":"web\\.New|AgentProfile","context_after":3}
No matches found.
{"query":"AgentProfile","glob":"*.go"}
/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)
{"query":"web\\.New","glob":"*.go"}
/project/cmd/zoo/main.go:79: dashboard := web.New(cfg, st, hub)
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"api/v1\\|func NewClient\\|func (c \\*Client) getParsedResponse\\|func (c \\*Client) do\\|baseURL\" client.go | head -20 && sed -n '/func (c \\*Client) do(/,/^}/p' client.go | head -40"}
69:func NewClient(url string, options ...ClientOption) (*Client, error) {
92:func NewClientWithHTTP(url string, httpClient *http.Client) *Client {
333:func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) {
343: fmt.Printf("%s: %s\nHeader: %v\nBody: %s\n", method, c.url+"/api/v1"+path, header, bodyStr)
345: req, err := http.NewRequestWithContext(c.ctx, method, c.url+"/api/v1"+path, body)
465:func (c *Client) getParsedResponse(method, path string, header http.Header, body io.Reader, obj interface{}) (*Response, error) {
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n 60,100p client.go"}
NextPage int
LastPage int
}
// ClientOption are functions used to init a new client
type ClientOption func(*Client) error
// NewClient initializes and returns a API client.
// Usage of all forgejo.Client methods is concurrency-safe.
func NewClient(url string, options ...ClientOption) (*Client, error) {
client := &Client{
url: strings.TrimSuffix(url, "/"),
client: &http.Client{},
ctx: context.Background(),
}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
if errors.Is(err, &ErrUnknownVersion{}) {
return client, err
}
return nil, err
}
return client, nil
}
// NewClientWithHTTP creates an API client with a custom http client
// Deprecated: use SetHTTPClient option
func NewClientWithHTTP(url string, httpClient *http.Client) *Client {
client, _ := NewClient(url, SetHTTPClient(httpClient))
return client
}
// SetHTTPClient is an option for NewClient to set custom http client
func SetHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) error {
client.SetHTTPClient(httpClient)
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"checkServerVersionGreaterThanOrEqual\\|api/version\\|func (c \\*Client) ServerVersion\\|ErrUnknownVersion\" *.go | grep -v _test | head -15 && sed -n '/func (c \\*Client) checkServerVersionGreaterThanOrEqual/,/^}/p' client.go"}
admin_cron.go:32: if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
admin_cron.go:43: if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
client.go:80: if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
client.go:81: if errors.Is(err, &ErrUnknownVersion{}) {
client.go:136: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
client.go:159: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
httpsign.go:213: if err = c.checkServerVersionGreaterThanOrEqual(version1_23_0); err != nil {
issue.go:164: if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil {
issue.go:188: if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil {
issue.go:208: if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil && issue.Repository != nil {
issue.go:308: if c.checkServerVersionGreaterThanOrEqual(version1_12_0) != nil {
issue_comment.go:84: if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
issue_label.go:87: if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
issue_milestone.go:80: if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
issue_milestone.go:171: if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"func (c \\*Client) checkServerVersion\" *.go && sed -n '/func (c \\*Client) checkServerVersionGreaterThanOrEqual/,/^}/p' $(grep -rln \"func (c \\*Client) checkServerVersionGreaterThanOrEqual\" *.go)"}
version.go:97:func (c *Client) checkServerVersionGreaterThanOrEqual(v *version.Version) error {
func (c *Client) checkServerVersionGreaterThanOrEqual(v *version.Version) error {
if c.ignoreVersion {
return nil
}
if err := c.loadServerVersion(); err != nil {
return err
}
if !c.serverVersion.GreaterThanOrEqual(v) {
c.mutex.RLock()
url := c.url
c.mutex.RUnlock()
return fmt.Errorf("forgejo server at %s is older than %s", url, v.Original())
}
return nil
}
{"command":"cd /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,96p' version.go"}
// Copyright 2024 The Forgejo Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgejo
import (
"fmt"
"strings"
"github.com/hashicorp/go-version"
)
// ServerVersion returns the version of the server
func (c *Client) ServerVersion() (string, *Response, error) {
v := struct {
Version string `json:"version"`
}{}
resp, err := c.getParsedResponse("GET", "/version", nil, nil, &v)
return v.Version, resp, err
}
// CheckServerVersionConstraint validates that the login's server satisfies a
// given version constraint such as ">= 1.11.0+dev"
func (c *Client) CheckServerVersionConstraint(constraint string) error {
if err := c.loadServerVersion(); err != nil {
return err
}
check, err := version.NewConstraint(constraint)
if err != nil {
return err
}
if !check.Check(c.serverVersion) {
c.mutex.RLock()
url := c.url
c.mutex.RUnlock()
return fmt.Errorf("forgejo server at %s does not satisfy version constraint %s", url, constraint)
}
return nil
}
// SetForgejoVersion configures the Client to assume the given version of the
// Forgejo server, instead of querying the server for it when initializing.
// Use "" to skip all canonical ways in the SDK to check for versions
func SetForgejoVersion(v string) ClientOption {
if v == "" {
return func(c *Client) error {
c.ignoreVersion = true
return nil
}
}
return func(c *Client) (err error) {
c.getVersionOnce.Do(func() {
c.serverVersion, err = version.NewVersion(v)
})
return //nolint
}
}
// predefined versions only have to be parsed by library once
var (
version1_11_0 = version.Must(version.NewVersion("1.11.0"))
version1_11_5 = version.Must(version.NewVersion("1.11.5"))
version1_12_0 = version.Must(version.NewVersion("1.12.0"))
version1_12_3 = version.Must(version.NewVersion("1.12.3"))
version1_13_0 = version.Must(version.NewVersion("1.13.0"))
version1_14_0 = version.Must(version.NewVersion("1.14.0"))
version1_15_0 = version.Must(version.NewVersion("1.15.0"))
version1_16_0 = version.Must(version.NewVersion("1.16.0"))
version1_17_0 = version.Must(version.NewVersion("1.17.0"))
version1_22_0 = version.Must(version.NewVersion("1.22.0"))
version1_23_0 = version.Must(version.NewVersion("1.23.0"))
version8_0_3 = version.Must(version.NewVersion("8.0.3"))
)
// ErrUnknownVersion is an unknown version from the API
type ErrUnknownVersion struct {
raw string
}
// Error fulfills error
func (e *ErrUnknownVersion) Error() string {
return fmt.Sprintf("unknown version: %s", e.raw)
}
func (*ErrUnknownVersion) Is(target error) bool {
_, ok := target.(*ErrUnknownVersion)
return ok
}
// checkServerVersionGreaterThanOrEqual is the canonical way in the SDK to check for versions for API compatibility reasons
{"end_line":128,"path":"internal/web/web_test.go","start_line":120}
{"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}
{"path":"internal/forgejo/client.go","find":"// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\n// (surfaced in the system prompt) and the name/email used to set git\n// commit authorship inside that agent's container.\ntype AgentProfile struct {\n\tDescription string\n\tFullName string\n\tEmail string\n}","replace":"// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\n// (surfaced in the system prompt), the name/email used to set git commit\n// authorship inside that agent's container, and its avatar URL (surfaced\n// on the dashboard).\ntype AgentProfile struct {\n\tDescription string\n\tFullName string\n\tEmail string\n\tAvatarURL string\n}"}
1 occurrences replaced
{"find":"\tp := AgentProfile{\n\t\tDescription: u.Description,\n\t\tFullName: u.FullName,\n\t\tEmail: u.Email,\n\t}","path":"internal/forgejo/client.go","replace":"\tp := AgentProfile{\n\t\tDescription: u.Description,\n\t\tFullName: u.FullName,\n\t\tEmail: u.Email,\n\t\tAvatarURL: u.AvatarURL,\n\t}"}
1 occurrences replaced
{"find":"import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"time\"\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\ntype Server struct {\n\tcfg *config.Config\n\tstore *store.Store\n\thub *livelog.Hub\n\ttmpl *template.Template\n}\n\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub) *Server {\n\treturn &Server{cfg: cfg, store: st, hub: hub, tmpl: template.Must(template.New(\"\").Parse(templates))}\n}","path":"internal/web/web.go","replace":"import (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\ntype Server struct {\n\tcfg *config.Config\n\tstore *store.Store\n\thub *livelog.Hub\n\tfg *forgejo.Client\n\ttmpl *template.Template\n\n\tavatarMu sync.Mutex\n\tavatarCache map[string]avatarCacheEntry\n}\n\n// avatarCacheTTL bounds how long a resolved avatar URL is trusted before\n// it's re-fetched from Forgejo. Avatars rarely change, but a user can\n// re-upload one (which changes its URL), so the cache expires instead of\n// living for the process lifetime.\nconst avatarCacheTTL = time.Hour\n\ntype avatarCacheEntry struct {\n\turl string\n\tfetchedAt time.Time\n}\n\nfunc New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {\n\treturn &Server{\n\t\tcfg: cfg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tfg: fg,\n\t\ttmpl: template.Must(template.New(\"\").Parse(templates)),\n\t\tavatarCache: map[string]avatarCacheEntry{},\n\t}\n}"}
1 occurrences replaced
{"find":"func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\ts.render(w, \"jobs\", jobs)\n}","path":"internal/web/web.go","replace":"// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the\n// jobs table so it's immediately clear who is running each job.\ntype jobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\nfunc (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {\n\tjobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\trows := make([]jobRow, 0, len(jobs))\n\n\tfor _, j := range jobs {\n\t\trows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})\n\t}\n\n\ts.render(w, \"jobs\", rows)\n}\n\n// avatarFor returns the Forgejo avatar URL of the agent named username,\n// or \"\" if it can't be resolved (no Forgejo client configured, unknown\n// user, API error). The dashboard must never fail to render because of a\n// missing avatar, so every failure mode degrades to no image. Results are\n// cached per username for avatarCacheTTL so a page refresh doesn't turn\n// into one GetUserInfo call per unique agent.\nfunc (s *Server) avatarFor(username string) string {\n\tif s.fg == nil || username == \"\" {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\n\tif e, ok := s.avatarCache[username]; ok && time.Since(e.fetchedAt) < avatarCacheTTL {\n\t\ts.avatarMu.Unlock()\n\n\t\treturn e.url\n\t}\n\n\ts.avatarMu.Unlock()\n\n\tprofile, err := s.fg.AgentProfile(username)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\ts.avatarMu.Lock()\n\ts.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}\n\ts.avatarMu.Unlock()\n\n\treturn profile.AvatarURL\n}"}
1 occurrences replaced
{"find":"\ts.render(w, \"job_detail\", struct {\n\t\tJob store.Job\n\t\tBlocks []block\n\t\tLive bool\n\t}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning})","path":"internal/web/web.go","replace":"\ts.render(w, \"job_detail\", struct {\n\t\tJob store.Job\n\t\tBlocks []block\n\t\tLive bool\n\t\tAvatarURL string\n\t}{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})"}
1 occurrences replaced
{"find":" @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.3; }\n }","path":"internal/web/templates.go","replace":" @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.3; }\n }\n\n /* ── Agent avatars ──────────────────────────── */\n .agent {\n display: inline-flex;\n align-items: center;\n gap: 0.5rem;\n }\n\n .agent-avatar {\n width: 22px;\n height: 22px;\n border-radius: 50%;\n border: 1px solid var(--border);\n background: var(--bg-code);\n flex-shrink: 0;\n }"}
1 occurrences replaced
{"find":" <td>{{.EventKind}}</td>\n <td><strong>{{.Agent}}</strong></td>\n <td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>","path":"internal/web/templates.go","replace":" <td>{{.EventKind}}</td>\n <td>\n <span class=\"agent\">\n {{if .AvatarURL}}<img class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Agent}}\" title=\"{{.Agent}}\" loading=\"lazy\">{{end}}\n <strong>{{.Agent}}</strong>\n </span>\n </td>\n <td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>"}
1 occurrences replaced
{"find":" <div class=\"job-meta-item\">\n <span class=\"label\">Agent</span>\n <span class=\"value\"><strong>{{.Job.Agent}}</strong></span>\n </div>","path":"internal/web/templates.go","replace":" <div class=\"job-meta-item\">\n <span class=\"label\">Agent</span>\n <span class=\"value\">\n <span class=\"agent\">\n {{if .AvatarURL}}<img class=\"agent-avatar\" src=\"{{.AvatarURL}}\" alt=\"{{.Job.Agent}}\" title=\"{{.Job.Agent}}\">{{end}}\n <strong>{{.Job.Agent}}</strong>\n </span>\n </span>\n </div>"}
1 occurrences replaced
{"find":"\tdashboard := web.New(cfg, st, hub)","path":"cmd/zoo/main.go","replace":"\tdashboard := web.New(cfg, st, hub, fgClient)"}
1 occurrences replaced
{"find":"\treturn New(cfg, st, livelog.NewHub()), st\n}","path":"internal/web/web_test.go","replace":"\t// No Forgejo client: pages render without avatars.\n\treturn New(cfg, st, livelog.NewHub(), nil), st\n}"}
1 occurrences replaced
{"find":"import (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\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)","path":"internal/web/web_test.go","replace":"import (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)"}
1 occurrences replaced
{"find":"\treq := httptest.NewRequest(http.MethodGet, \"/jobs\", nil)\n\treq.Header.Set(\"Authorization\", \"Bearer secret\")\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, req)\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200 with correct token, got %d\", rr.Code)\n\t}\n}","path":"internal/web/web_test.go","replace":"\treq := httptest.NewRequest(http.MethodGet, \"/jobs\", nil)\n\treq.Header.Set(\"Authorization\", \"Bearer secret\")\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, req)\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200 with correct token, got %d\", rr.Code)\n\t}\n}\n\n// TestJobsShowAgentAvatars verifies the jobs pages render each agent's\n// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades\n// to no image instead of breaking the page.\nfunc TestJobsShowAgentAvatars(t *testing.T) {\n\t// Minimal in-test Forgejo API: the version probe the SDK makes when\n\t// the client is constructed, plus per-user profile lookups.\n\tavatars := map[string]string{\n\t\t\"leon\": \"https://forgejo.example/avatars/leon\",\n\t}\n\n\tapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/v1/version\" {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tfmt.Fprint(w, `{\"version\":\"1.23.0\"}`)\n\n\t\t\treturn\n\t\t}\n\n\t\tif user, ok := strings.CutPrefix(r.URL.Path, \"/api/v1/users/\"); ok {\n\t\t\tif url, ok := avatars[user]; ok {\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tfmt.Fprintf(w, `{\"login_name\":%q,\"avatar_url\":%q}`, user, url)\n\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\thttp.NotFound(w, r)\n\t}))\n\tt.Cleanup(api.Close)\n\n\tfg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\ts := New(&config.Config{}, st, livelog.NewHub(), fg)\n\n\tctx := context.Background()\n\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-1\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 1}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// \"ghost\" doesn't exist on the mock Forgejo: the page must still\n\t// render, just without an avatar for that job.\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"ghost\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n\n\tbody := rr.Body.String()\n\tif !strings.Contains(body, `src=\"https://forgejo.example/avatars/leon\"`) {\n\t\tt.Fatalf(\"expected leon's avatar on the jobs page, got: %s\", body)\n\t}\n\tif !strings.Contains(body, \"job-2\") {\n\t\tt.Fatalf(\"expected the unknown-agent job to still render, got: %s\", body)\n\t}\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `src=\"https://forgejo.example/avatars/leon\"`) {\n\t\tt.Fatalf(\"expected leon's avatar on the job detail page, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n}"}
1 occurrences replaced
{"command":"cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20","timeout":300}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command":"cd /project && go build ./... 2>&1 | head -30; echo \"build exit: $?\"","timeout":300}
build exit: 0
{"command":"cd /project && go vet ./... 2>&1 | head -30; echo \"vet exit: $?\"","timeout":300}
# github.com/abrander/zoo/internal/web # [github.com/abrander/zoo/internal/web] vet: internal/web/web_test.go:116:36: not enough arguments in call to New have (*config.Config, *store.Store, *livelog.Hub) want (*config.Config, *store.Store, *livelog.Hub, *"github.com/abrander/zoo/internal/forgejo".Client) vet exit: 0
{"find":"\tcfg := &config.Config{Web: &config.Web{Token: \"secret\"}}\n\ts := New(cfg, st, livelog.NewHub())","path":"internal/web/web_test.go","replace":"\tcfg := &config.Config{Web: &config.Web{Token: \"secret\"}}\n\ts := New(cfg, st, livelog.NewHub(), nil)"}
1 occurrences replaced
{"command":"cd /project && go vet ./... 2>&1 | head -20; echo \"vet exit: ${PIPESTATUS[0]}\"; go test ./... 2>&1 | tail -25","timeout":300}
ERROR CALLING TOOL: command failed: command exited with status 2
{"command":"cd /project && go vet ./... 2>&1 | head -20","timeout":300}
{"command":"cd /project && go test ./... 2>&1 | tail -25","timeout":300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.005s ok github.com/abrander/zoo/internal/forgejo 0.003s 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 2.048s ok github.com/abrander/zoo/internal/store 1.721s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.801s
{"command":"cd /project && go test ./internal/web/ -run \"TestJobsShowAgentAvatars|TestJobsAndDetail\" -v 2>&1 | tail -10","timeout":120}
=== RUN TestJobsAndDetail --- PASS: TestJobsAndDetail (0.41s) === RUN TestJobsShowAgentAvatars --- PASS: TestJobsShowAgentAvatars (0.16s) PASS ok github.com/abrander/zoo/internal/web 0.567s
{"path":"internal/web/smoke_avatar_test.go","content":"package web\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\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/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc TestSmokeRenderJobs(t *testing.T) {\n\tapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/v1/version\" {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tfmt.Fprint(w, `{\"version\":\"1.23.0\"}`)\n\t\t\treturn\n\t\t}\n\t\tif user, ok := strings.CutPrefix(r.URL.Path, \"/api/v1/users/\"); ok {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tfmt.Fprintf(w, `{\"login_name\":%q,\"avatar_url\":\"https://code.stdio.dk/avatars/%s\"}`, user, user)\n\t\t\treturn\n\t\t}\n\t\thttp.NotFound(w, r)\n\t}))\n\tt.Cleanup(api.Close)\n\n\tfg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\ts := New(&config.Config{}, st, livelog.NewHub(), fg)\n\n\tfor i, agent := range []string{\"greg\", \"mika\", \"ghost\"} {\n\t\tid := fmt.Sprintf(\"job-%d\", i+1)\n\t\tif err := st.CreateJob(t.Context(), store.Job{ID: id, EventKind: \"issue:assigned\", Agent: agent, Owner: \"abrander\", Repo: \"zoo\", IssueIndex: int64(10 + i)}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs\", nil))\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d\", rr.Code)\n\t}\n\n\tbody := rr.Body.String()\n\tstart := strings.Index(body, \"<tbody>\")\n\tend := strings.Index(body, \"</tbody>\")\n\tfmt.Println(body[start:end])\n}\n"}
OK
{"command":"cd /project && go test ./internal/web/ -run TestSmokeRenderJobs -v 2>&1 | sed -n '1,40p'","timeout":120}
=== RUN TestSmokeRenderJobs
<tbody>
<tr>
<td><a href="/jobs/job-3">job-3</a></td>
<td>
<span class="badge badge-pending">
<span class="dot"></span>
pending
</span>
</td>
<td>issue:assigned</td>
<td>
<span class="agent">
<img class="agent-avatar" src="https://code.stdio.dk/avatars/ghost" alt="ghost" title="ghost" loading="lazy">
<strong>ghost</strong>
</span>
</td>
<td><code>abrander/zoo#12</code></td>
<td>2026-08-23 11:19:53</td>
</tr>
<tr>
<td><a href="/jobs/job-2">job-2</a></td>
<td>
<span class="badge badge-pending">
<span class="dot"></span>
pending
</span>
</td>
<td>issue:assigned</td>
<td>
<span class="agent">
<img class="agent-avatar" src="https://code.stdio.dk/avatars/mika" alt="mika" title="mika" loading="lazy">
<strong>mika</strong>
</span>
</td>
<td><code>abrander/zoo#11</code></td>
<td>2026-08-23 11:19:52</td>
</tr>
{"path":"internal/web/smoke_avatar_test.go"}
File removed successfully
{"command":"cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -12 && git diff --stat","timeout":300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun (cached) ok github.com/abrander/zoo/internal/config (cached) ok github.com/abrander/zoo/internal/forgejo (cached) ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/web (cached) cmd/zoo/main.go | 2 +- internal/forgejo/client.go | 7 ++-- internal/web/templates.go | 30 ++++++++++++++-- internal/web/web.go | 85 ++++++++++++++++++++++++++++++++++++++++++---- internal/web/web_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 193 insertions(+), 14 deletions(-)
{"command":"cd /project && go test ./internal/web/ ./internal/forgejo/ -count=1 2>&1 | tail -3 && git diff","timeout":120}
ok github.com/abrander/zoo/internal/web 0.812s
ok github.com/abrander/zoo/internal/forgejo 0.004s
diff --git a/cmd/zoo/main.go b/cmd/zoo/main.go
index c25133a..91cf963 100644
--- a/cmd/zoo/main.go
+++ b/cmd/zoo/main.go
@@ -76,7 +76,7 @@ func run() error {
}
sched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With("component", "scheduler"))
- dashboard := web.New(cfg, st, hub)
+ dashboard := web.New(cfg, st, hub, fgClient)
mux := http.NewServeMux()
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index 86056ad..2acc56a 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -190,12 +190,14 @@ func (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateT
}
// AgentProfile is what zoo reads off an agent's Forgejo account: its bio
-// (surfaced in the system prompt) and the name/email used to set git
-// commit authorship inside that agent's container.
+// (surfaced in the system prompt), the name/email used to set git commit
+// authorship inside that agent's container, and its avatar URL (surfaced
+// on the dashboard).
type AgentProfile struct {
Description string
FullName string
Email string
+ AvatarURL string
}
// AgentProfile fetches the Forgejo profile of the user account named
@@ -213,6 +215,7 @@ func (c *Client) AgentProfile(username string) (AgentProfile, error) {
Description: u.Description,
FullName: u.FullName,
Email: u.Email,
+ AvatarURL: u.AvatarURL,
}
return p, nil
diff --git a/internal/web/templates.go b/internal/web/templates.go
index 9d09bfa..2e5957a 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -261,6 +261,22 @@ const templates = `
50% { opacity: 0.3; }
}
+ /* ── Agent avatars ──────────────────────────── */
+ .agent {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .agent-avatar {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ background: var(--bg-code);
+ flex-shrink: 0;
+ }
+
/* ── Info grid ──────────────────────────────── */
.info-grid {
display: grid;
@@ -638,7 +654,12 @@ const templates = `
</span>
</td>
<td>{{.EventKind}}</td>
- <td><strong>{{.Agent}}</strong></td>
+ <td>
+ <span class="agent">
+ {{if .AvatarURL}}<img class="agent-avatar" src="{{.AvatarURL}}" alt="{{.Agent}}" title="{{.Agent}}" loading="lazy">{{end}}
+ <strong>{{.Agent}}</strong>
+ </span>
+ </td>
<td><code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
</tr>
@@ -675,7 +696,12 @@ const templates = `
</div>
<div class="job-meta-item">
<span class="label">Agent</span>
- <span class="value"><strong>{{.Job.Agent}}</strong></span>
+ <span class="value">
+ <span class="agent">
+ {{if .AvatarURL}}<img class="agent-avatar" src="{{.AvatarURL}}" alt="{{.Job.Agent}}" title="{{.Job.Agent}}">{{end}}
+ <strong>{{.Job.Agent}}</strong>
+ </span>
+ </span>
</div>
{{if .Job.Error}}
<div class="job-meta-item">
diff --git a/internal/web/web.go b/internal/web/web.go
index c1f1bb5..ca90ed0 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -7,9 +7,11 @@ import (
"fmt"
"html/template"
"net/http"
+ "sync"
"time"
"github.com/abrander/zoo/internal/config"
+ "github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
@@ -18,11 +20,33 @@ type Server struct {
cfg *config.Config
store *store.Store
hub *livelog.Hub
+ fg *forgejo.Client
tmpl *template.Template
+
+ avatarMu sync.Mutex
+ avatarCache map[string]avatarCacheEntry
+}
+
+// avatarCacheTTL bounds how long a resolved avatar URL is trusted before
+// it's re-fetched from Forgejo. Avatars rarely change, but a user can
+// re-upload one (which changes its URL), so the cache expires instead of
+// living for the process lifetime.
+const avatarCacheTTL = time.Hour
+
+type avatarCacheEntry struct {
+ url string
+ fetchedAt time.Time
}
-func New(cfg *config.Config, st *store.Store, hub *livelog.Hub) *Server {
- return &Server{cfg: cfg, store: st, hub: hub, tmpl: template.Must(template.New("").Parse(templates))}
+func New(cfg *config.Config, st *store.Store, hub *livelog.Hub, fg *forgejo.Client) *Server {
+ return &Server{
+ cfg: cfg,
+ store: st,
+ hub: hub,
+ fg: fg,
+ tmpl: template.Must(template.New("").Parse(templates)),
+ avatarCache: map[string]avatarCacheEntry{},
+ }
}
// Handler returns the dashboard's http.Handler, gated by config.Web's
@@ -62,6 +86,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, "index", s.cfg)
}
+// jobRow is a store.Job plus the agent's Forgejo avatar, resolved for the
+// jobs table so it's immediately clear who is running each job.
+type jobRow struct {
+ store.Job
+ AvatarURL string
+}
+
func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.store.ListJobs(r.Context(), 200)
if err != nil {
@@ -70,7 +101,46 @@ func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
return
}
- s.render(w, "jobs", jobs)
+ rows := make([]jobRow, 0, len(jobs))
+
+ for _, j := range jobs {
+ rows = append(rows, jobRow{Job: j, AvatarURL: s.avatarFor(j.Agent)})
+ }
+
+ s.render(w, "jobs", rows)
+}
+
+// avatarFor returns the Forgejo avatar URL of the agent named username,
+// or "" if it can't be resolved (no Forgejo client configured, unknown
+// user, API error). The dashboard must never fail to render because of a
+// missing avatar, so every failure mode degrades to no image. Results are
+// cached per username for avatarCacheTTL so a page refresh doesn't turn
+// into one GetUserInfo call per unique agent.
+func (s *Server) avatarFor(username string) string {
+ if s.fg == nil || username == "" {
+ return ""
+ }
+
+ s.avatarMu.Lock()
+
+ if e, ok := s.avatarCache[username]; ok && time.Since(e.fetchedAt) < avatarCacheTTL {
+ s.avatarMu.Unlock()
+
+ return e.url
+ }
+
+ s.avatarMu.Unlock()
+
+ profile, err := s.fg.AgentProfile(username)
+ if err != nil {
+ return ""
+ }
+
+ s.avatarMu.Lock()
+ s.avatarCache[username] = avatarCacheEntry{url: profile.AvatarURL, fetchedAt: time.Now()}
+ s.avatarMu.Unlock()
+
+ return profile.AvatarURL
}
// toolBlock is the parsed form of a stream="tool" store.LogLine, for the
@@ -142,10 +212,11 @@ func (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {
}
s.render(w, "job_detail", struct {
- Job store.Job
- Blocks []block
- Live bool
- }{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning})
+ Job store.Job
+ Blocks []block
+ Live bool
+ AvatarURL string
+ }{job, buildBlocks(logs), job.Status == store.JobPending || job.Status == store.JobRunning, s.avatarFor(job.Agent)})
}
// handleJobEvents streams job jobID's live output as Server-Sent
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 5ba8e0a..c8876c1 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -2,6 +2,7 @@ package web
import (
"context"
+ "fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/abrander/zoo/internal/config"
+ "github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/store"
)
@@ -31,7 +33,8 @@ func testServer(t *testing.T) (*Server, *store.Store) {
Environment: config.Environment{DockerImage: "debian:unstable"},
}
- return New(cfg, st, livelog.NewHub()), st
+ // No Forgejo client: pages render without avatars.
+ return New(cfg, st, livelog.NewHub(), nil), st
}
func TestIndexRenders(t *testing.T) {
@@ -110,7 +113,7 @@ func TestAuthGate(t *testing.T) {
defer st.Close()
cfg := &config.Config{Web: &config.Web{Token: "secret"}}
- s := New(cfg, st, livelog.NewHub())
+ s := New(cfg, st, livelog.NewHub(), nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
@@ -126,3 +129,79 @@ func TestAuthGate(t *testing.T) {
t.Fatalf("expected 200 with correct token, got %d", rr.Code)
}
}
+
+// TestJobsShowAgentAvatars verifies the jobs pages render each agent's
+// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades
+// to no image instead of breaking the page.
+func TestJobsShowAgentAvatars(t *testing.T) {
+ // Minimal in-test Forgejo API: the version probe the SDK makes when
+ // the client is constructed, plus per-user profile lookups.
+ avatars := map[string]string{
+ "leon": "https://forgejo.example/avatars/leon",
+ }
+
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/v1/version" {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, `{"version":"1.23.0"}`)
+
+ return
+ }
+
+ if user, ok := strings.CutPrefix(r.URL.Path, "/api/v1/users/"); ok {
+ if url, ok := avatars[user]; ok {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"login_name":%q,"avatar_url":%q}`, user, url)
+
+ return
+ }
+ }
+
+ http.NotFound(w, r)
+ }))
+ t.Cleanup(api.Close)
+
+ fg, err := forgejo.NewClient(config.Forgejo{URL: api.URL, Token: "test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+
+ s := New(&config.Config{}, st, livelog.NewHub(), fg)
+
+ ctx := context.Background()
+
+ if err := st.CreateJob(ctx, store.Job{ID: "job-1", EventKind: "issue:new", Agent: "leon", Owner: "acme", Repo: "widgets", IssueIndex: 1}); err != nil {
+ t.Fatal(err)
+ }
+ // "ghost" doesn't exist on the mock Forgejo: the page must still
+ // render, just without an avatar for that job.
+ if err := st.CreateJob(ctx, store.Job{ID: "job-2", EventKind: "issue:new", Agent: "ghost", Owner: "acme", Repo: "widgets", IssueIndex: 2}); err != nil {
+ t.Fatal(err)
+ }
+
+ rr := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+
+ body := rr.Body.String()
+ if !strings.Contains(body, `src="https://forgejo.example/avatars/leon"`) {
+ t.Fatalf("expected leon's avatar on the jobs page, got: %s", body)
+ }
+ if !strings.Contains(body, "job-2") {
+ t.Fatalf("expected the unknown-agent job to still render, got: %s", body)
+ }
+
+ rr = httptest.NewRecorder()
+ s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs/job-1", nil))
+ if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `src="https://forgejo.example/avatars/leon"`) {
+ t.Fatalf("expected leon's avatar on the job detail page, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
{"command":"cd /project && gofmt -l . && echo \"gofmt clean\""}
gofmt clean
{"subcommand":"add","args":["-A"]}
{"subcommand":"status"}
On branch issue-17-greg Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: cmd/zoo/main.go modified: internal/forgejo/client.go modified: internal/web/templates.go modified: internal/web/web.go modified: internal/web/web_test.go
{"subcommand":"commit","args":["-m", "web: show agent profile photos on the jobs pages\n\nResolve the agent's avatar URL from Forgejo (via the shared client,\ncached for an hour) and render it next to the agent name on /jobs and\nthe job detail page. Unresolvable avatars degrade to no image so the\ndashboard never fails to render because of one."]}
[issue-17-greg e1bf291] web: show agent profile photos on the jobs pages 5 files changed, 193 insertions(+), 14 deletions(-)
{"subcommand":"push","args":["-u", "origin", "issue-17-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-17-greg -> issue-17-greg branch 'issue-17-greg' set up to track 'origin/issue-17-greg'.
{"head":"issue-17-greg","base":"main","title":"web: show agent profile photos on the jobs pages","body":"## What\n\nAdds small profile photos next to each agent's name on the **/jobs** list, so it's immediately clear who's running a job. The images are plain `<img>` tags pointing at the real avatar URLs on Forgejo — nothing is proxied or stored by zoo (per the issue: \"just link the actual profile photos on Forgejo\").\n\nThe job **detail** page's Agent meta gets the same treatment, since it answers the same \"who's running this?\" question.\n\n## How\n\n- `forgejo.Client.AgentProfile` now also returns `AvatarURL` (same `GetUserInfo` call it already makes for the roster).\n- `web.Server` takes the shared Forgejo client (`main.go` passes it in) and resolves each job's agent avatar via `avatarFor()`:\n - results are cached per-username for 1h, so a page refresh doesn't turn into one API call per unique agent;\n - any failure (unknown user, API error, no client) degrades to *no image* — the dashboard never fails to render because of a missing avatar.\n- Jobs table rows become `jobRow{store.Job, AvatarURL}` so the template can render the avatar.\n\n## Why the API instead of guessing URLs\n\nForgejo avatar URLs are opaque hashes (`/avatar/<md5>` vs `/avatars/<sha256>` depending on version), and on this instance the public user API is not reachable anonymously (accounts are `limited` visibility) — so the URL has to come from an authenticated lookup, which is what the shared zoo token already does everywhere else.\n\n## Tests\n\n- New `TestJobsShowAgentAvatars`: spins up a mock Forgejo API (version probe + user lookups), asserts the avatar renders on `/jobs` and the detail page, and that a job whose agent doesn't exist still renders fine without an image.\n- Existing tests updated for the new `web.New` signature (they pass a nil client → pages render without avatars).\n\n`go build ./...`, `go vet ./...`, `go test ./...` all pass; `gofmt` clean."}
Pull request opened
{"body":"Done — PR is up: **web: show agent profile photos on the jobs pages** (branch `issue-17-greg`).\n\n**What you'll see:** a small round profile photo next to each agent's name in the /jobs table (and on the job detail page, same \"who's running this?\" question). The `<img>` links straight to the real avatar URL on Forgejo — zoo doesn't proxy or store the images.\n\n**Implementation notes:**\n- The avatar URL is resolved per agent via the shared Forgejo client (the same `GetUserInfo` call the roster already uses), cached for an hour so refreshing the page doesn't spam the API.\n- I went with an authenticated API lookup rather than constructing avatar URLs, because Forgejo's avatar paths are opaque hashes that differ between versions, and on this instance the public user API isn't reachable anonymously (agent accounts are `limited` visibility).\n- If an avatar can't be resolved (unknown user, transient API error), the page just renders without the image — the dashboard never breaks because of a missing photo.\n\nCovered by a new test with a mock Forgejo API (`TestJobsShowAgentAvatars`); full build/vet/test suite passes."}
Comment posted
{"name":"zoo:failed"}
Label removed