Details and log output for this agent run.
{"path":".","recursive":false}
drwxr-xr-x 6 user user 4096 Aug 26 11:31 . drwxr-xr-x 1 root root 4096 Aug 26 11:31 .. drwxr-xr-x 7 user user 4096 Aug 26 11:31 .git -rw-r--r-- 1 user user 22 Aug 26 11:31 .gitignore -rw-r--r-- 1 user user 6387 Aug 26 11:31 README.md drwxr-xr-x 3 user user 4096 Aug 26 11:31 cmd drwxr-xr-x 2 user user 4096 Aug 26 11:31 docker -rw-r--r-- 1 user user 2628 Aug 26 11:31 go.mod -rw-r--r-- 1 user user 16578 Aug 26 11:31 go.sum drwxr-xr-x 11 user user 4096 Aug 26 11:31 internal -rw-r--r-- 1 user user 3092 Aug 26 11:31 zoo.hcl.example
{"command":"cd /project && git log --oneline -5 && git status"}
081d4bd Merge pull request 'livelog: open reasoning/content blocks lazily, on first real text' (#63) from issue-62-greg into main 49f034a livelog: open reasoning/content blocks lazily, on first real text f9b1877 Add some retry logic for the LLM backend. 7423d86 Try to re-roll broken tool calls. f58963a Merge pull request 'Jobs page: follow the live log tail, with a jump-to-bottom button' (#58) from issue-57-greg into main On branch issue-64-greg nothing to commit, working tree clean
{"path":"cmd","recursive":true}
/project/cmd/zoo /project/cmd/zoo/main.go
{"path":"internal","recursive":true}
/project/internal/agentrun /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/loop_test.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/run_test.go /project/internal/agentrun/sandboxgit.go /project/internal/agentrun/sandboxgit_test.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/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/poll_test.go /project/internal/forgejo/repo_filter.go /project/internal/forgejo/repo_filter_test.go /project/internal/forgejo/watcher.go /project/internal/forgejo/watcher_test.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/arguments.go /project/internal/llm/arguments_test.go /project/internal/llm/client.go /project/internal/llm/error.go /project/internal/llm/error_test.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/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
{"end_line":200,"path":"internal/web/web.go","start_line":1}
{"path":"internal/web/web.go","content":"// Package web is zoo's minimal server-rendered dashboard: config\n// overview, live queue/history, and a per-job log tail.\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strings\"\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 \u0026Server{\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}\n\n// Handler returns the dashboard's http.Handler, gated by config.Web's\n// bearer token if one is set.\nfunc (s *Server) Handler() http.Handler {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\n\treturn s.authMiddleware(mux)\n}\n\nfunc (s *Server) authMiddleware(next http.Handler) http.Handler {\n\tif s.cfg.Web == nil || s.cfg.Web.Token == \"\" {\n\t\treturn next\n\t}\n\n\ttoken := s.cfg.Web.Token\n\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tauth := r.Header.Get(\"Authorization\")\n\t\tif auth != \"Bearer \"+token {\n\t\t\tw.Header().Set(\"WWW-Authenticate\", `Bearer realm=\"zoo\"`)\n\t\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\nfunc (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {\n\t// Fetch active (pending or running) jobs for the dashboard overview.\n\t// We fetch more than we display so we can filter to just active ones.\n\tallJobs, err := s.store.ListJobs(r.Context(), 200)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Collect unique agent names from active jobs.\n\tvar agentNames []string\n\tseenAgents := make(map[string]bool)\n\n\tvar activeJobs []activeJobRow\n\tfor _, j := range allJobs {\n\t\tif j.Status != store.JobPending \u0026\u0026 j.Status != store.JobRunning {\n\t\t\tcontinue\n\t\t}\n\t\tif !seenAgents[j.Agent] {\n\t\t\tseenAgents[j.Agent] = true\n\t\t\tagentNames = append(agentNames, j.Agent)\n\t\t}\n\t\tactiveJobs = append(activeJobs, activeJobRow{\n\t\t\tJob: j,\n\t\t\tAvatarURL: s.avatarFor(j.Agent),\n\t\t})\n\t}\n\n\ttype indexData struct {\n\t\t*config.Config\n\t\tActiveJobs []activeJobRow\n\t}\n\n\ts.render(w, \"index\", indexData{\n\t\tConfig: s.cfg,\n\t\tActiveJobs: activeJobs,\n\t})\n}\n\n// activeJobRow is a store.Job enriched with the agent's avatar URL.\ntype activeJobRow struct {\n\tstore.Job\n\tAvatarURL string\n}\n\n// 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 \u0026\u0026 time.Since(e.fetchedAt) \u003c 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}\n\n// toolBlock is the parsed form of a stream=\"tool\" store.LogLine, for the\n// template to render as a single collapsed detail.\ntype toolBlock struct {\n\tName string\n\tArguments string\n\tResult string\n\tError bool\n}\n\n// block is one self-contained, already-complete unit of job output: a\n// finished reasoning or assistant-message block, a finished tool call,\n// or a misc system note. Unlike the old flat log view, one store.LogLine\n// maps to exactly one block β grouping/streaming happens upstream, when","start_line":1,"end_line":200,"total_lines":374,"truncated":true}
{"end_line":200,"path":"internal/livelog/hub.go","start_line":1}
{"path":"internal/livelog/hub.go","content":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish\n// fine-grained, not-yet-persisted events (reasoning/content deltas, tool\n// calls) for a running job, and lets the web dashboard subscribe to them\n// live over SSE. It carries only the tail of a run that hasn't made it\n// into SQLite yet β see Hub.Checkpoint.\npackage livelog\n\nimport \"sync\"\n\ntype Type string\n\nconst (\n\tReasoningStart Type = \"reasoning_start\"\n\tReasoningDelta Type = \"reasoning_delta\"\n\tReasoningEnd Type = \"reasoning_end\"\n\tContentStart Type = \"content_start\"\n\tContentDelta Type = \"content_delta\"\n\tContentEnd Type = \"content_end\"\n\tTool Type = \"tool\"\n\tSystem Type = \"system\"\n\tStatus Type = \"status\"\n)\n\ntype Event struct {\n\tType Type `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\tArguments string `json:\"arguments,omitempty\"`\n\tResult string `json:\"result,omitempty\"`\n\tError bool `json:\"error,omitempty\"`\n\tStatus string `json:\"status,omitempty\"`\n}\n\n// subChanBuffer bounds how many events a slow subscriber can lag behind\n// by before new events are dropped for it. Generous for a single-user\n// local dashboard; a dropped event just means a subscriber's browser\n// tab misses a chunk and catches up on the next one, never a hang.\nconst subChanBuffer = 256\n\n// maxBufferedEvents caps the per-job replay buffer as a safety net\n// against unbounded growth if a caller forgets to Checkpoint. Ordinary\n// operation clears the buffer far below this on every persisted block.\nconst maxBufferedEvents = 10000\n\ntype stream struct {\n\tmu sync.Mutex\n\tbuf []Event\n\tsubs map[chan Event]struct{}\n}\n\n// Hub fans out per-job Events to any number of live subscribers.\ntype Hub struct {\n\tmu sync.Mutex\n\tjobs map[string]*stream\n}\n\nfunc NewHub() *Hub {\n\treturn \u0026Hub{jobs: make(map[string]*stream)}\n}\n\nfunc (h *Hub) get(jobID string) *stream {\n\th.mu.Lock()\n\tdefer h.mu.Unlock()\n\n\ts, ok := h.jobs[jobID]\n\tif !ok {\n\t\ts = \u0026stream{subs: make(map[chan Event]struct{})}\n\t\th.jobs[jobID] = s\n\t}\n\n\treturn s\n}\n\n// Publish appends ev to jobID's replay buffer and fans it out to every\n// current subscriber. Never blocks: a subscriber whose channel is full\n// simply misses this event.\nfunc (h *Hub) Publish(jobID string, ev Event) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.buf = append(s.buf, ev)\n\tif len(s.buf) \u003e maxBufferedEvents {\n\t\ts.buf = s.buf[len(s.buf)-maxBufferedEvents:]\n\t}\n\n\tfor ch := range s.subs {\n\t\tselect {\n\t\tcase ch \u003c- ev:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// Checkpoint clears jobID's replay buffer. Call this right after a\n// block derived from buffered events has been durably written to\n// storage, so a subscriber that connects afterward β and therefore\n// already sees that block via the persisted history β is never replayed\n// it a second time over the live channel.\nfunc (h *Hub) Checkpoint(jobID string) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.buf = nil\n}\n\n// Subscribe returns the events currently buffered for jobID (i.e. not\n// yet checkpointed) plus a channel of everything published from this\n// point on. The returned cancel func must be called when the caller is\n// done reading, to unregister and close the channel.\nfunc (h *Hub) Subscribe(jobID string) (buffered []Event, ch \u003c-chan Event, cancel func()) {\n\ts := h.get(jobID)\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tbuffered = append([]Event(nil), s.buf...)\n\n\tc := make(chan Event, subChanBuffer)\n\ts.subs[c] = struct{}{}\n\n\tvar once sync.Once\n\n\tcancelFn := func() {\n\t\tonce.Do(func() {\n\t\t\ts.mu.Lock()\n\t\t\tdefer s.mu.Unlock()\n\n\t\t\tif _, ok := s.subs[c]; ok {\n\t\t\t\tdelete(s.subs, c)\n\t\t\t\tclose(c)\n\t\t\t}\n\t\t})\n\t}\n\n\treturn buffered, c, cancelFn\n}\n\n// Close publishes a terminal Status event, disconnects every current\n// subscriber, and drops jobID's state. Call this exactly once, when the\n// job's run has finished.\nfunc (h *Hub) Close(jobID string) {\n\th.Publish(jobID, Event{Type: Status, Status: \"done\"})\n\n\th.mu.Lock()\n\ts, ok := h.jobs[jobID]\n\tdelete(h.jobs, jobID)\n\th.mu.Unlock()\n\n\tif !ok {\n\t\treturn\n\t}\n\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tfor ch := range s.subs {\n\t\tclose(ch)\n\t}\n\n\ts.subs = nil\n}","start_line":1,"end_line":165,"total_lines":165,"truncated":false}
{"end_line":374,"path":"internal/web/web.go","start_line":200}
{"path":"internal/web/web.go","content":"// maps to exactly one block β grouping/streaming happens upstream, when\n// agentrun persists the row.\ntype block struct {\n\tKind string // \"reasoning\" | \"content\" | \"tool\" | \"system\"\n\tText string\n\tTool *toolBlock\n}\n\nfunc buildBlocks(logs []store.LogLine) []block {\n\tblocks := make([]block, 0, len(logs))\n\n\tfor _, l := range logs {\n\t\tswitch l.Stream {\n\t\tcase \"reasoning\", \"content\":\n\t\t\t// Model output routinely starts/ends with newlines. The block\n\t\t\t// body renders with white-space: pre-wrap, so those would show\n\t\t\t// up as visible blank lines inflating the block's height. Trim\n\t\t\t// them for display (internal newlines are kept) and drop\n\t\t\t// blocks that are nothing but whitespace.\n\t\t\ttext := strings.TrimSpace(l.Line)\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tblocks = append(blocks, block{Kind: l.Stream, Text: text})\n\n\t\tcase \"tool\":\n\t\t\tvar entry store.ToolLogEntry\n\t\t\tif err := json.Unmarshal([]byte(l.Line), \u0026entry); err != nil {\n\t\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\n\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tblocks = append(blocks, block{Kind: \"tool\", Tool: \u0026toolBlock{\n\t\t\t\tName: entry.Name,\n\t\t\t\tArguments: entry.Arguments,\n\t\t\t\tResult: entry.Result,\n\t\t\t\tError: entry.Error,\n\t\t\t}})\n\n\t\tdefault:\n\t\t\tblocks = append(blocks, block{Kind: \"system\", Text: l.Line})\n\t\t}\n\t}\n\n\treturn blocks\n}\n\nfunc (s *Server) handleJobDetail(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tjob, err := s.store.GetJob(r.Context(), id)\n\tif err != nil {\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\n\n\t\treturn\n\t}\n\n\tlogs, err := s.store.TailLogs(r.Context(), id, -1)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\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)})\n}\n\n// handleJobEvents streams job jobID's live output as Server-Sent\n// Events. It only ever carries events for the current, not-yet-persisted\n// block (see livelog.Hub.Checkpoint) β everything already written to\n// the store is rendered once, statically, by handleJobDetail. If the\n// job isn't live, the response just ends immediately and the client\n// falls back to the static page.\nfunc (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {\n\tid := r.PathValue(\"id\")\n\n\tjob, err := s.store.GetJob(r.Context(), id)\n\tif err != nil {\n\t\thttp.Error(w, \"job not found\", http.StatusNotFound)\n\n\t\treturn\n\t}\n\n\tif job.Status != store.JobPending \u0026\u0026 job.Status != store.JobRunning {\n\t\treturn\n\t}\n\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\thttp.Error(w, \"streaming unsupported\", http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tbuffered, ch, cancel := s.hub.Subscribe(id)\n\tdefer cancel()\n\n\twrite := func(ev livelog.Event) bool {\n\t\tdata, err := json.Marshal(ev)\n\t\tif err != nil {\n\t\t\treturn true\n\t\t}\n\n\t\tif _, err := fmt.Fprintf(w, \"data: %s\\n\\n\", data); err != nil {\n\t\t\treturn false\n\t\t}\n\n\t\tflusher.Flush()\n\n\t\treturn true\n\t}\n\n\tfor _, ev := range buffered {\n\t\tif !write(ev) {\n\t\t\treturn\n\t\t}\n\t}\n\n\theartbeat := time.NewTicker(15 * time.Second)\n\tdefer heartbeat.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := \u003c-ch:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !write(ev) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif ev.Type == livelog.Status {\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase \u003c-heartbeat.C:\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase \u003c-r.Context().Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {\n\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\n\t// Every page here reflects live, fast-changing state (job status, log\n\t// blocks). Without this, browsers are free to serve a stale cached\n\t// copy of the job detail page on a same-URL navigation β including\n\t// the auto location.reload() a job's SSE stream triggers when it\n\t// finishes β which looks exactly like blocks that were visible while\n\t// streaming vanishing once the page reloads.\n\tw.Header().Set(\"Cache-Control\", \"no-store\")\n\n\tif err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t}\n}","start_line":200,"end_line":374,"total_lines":374,"truncated":false}
{"end_line":150,"path":"internal/web/templates.go","start_line":1}
{"path":"internal/web/templates.go","content":"package web\n\nconst templates = `\n{{define \"layout_head\"}}\n\u003c!doctype html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n\u003cmeta charset=\"utf-8\"\u003e\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\n\u003ctitle\u003ezoo\u003c/title\u003e\n\u003cstyle\u003e\n :root {\n --bg: #0f0f13;\n --bg-surface: #1a1a24;\n --bg-surface-hover: #22222e;\n --bg-code: #12121a;\n --border: #2a2a3a;\n --text: #e4e4e8;\n --text-muted: #8888a0;\n --accent: #7c6aef;\n --accent-glow: rgba(124, 106, 239, 0.15);\n --radius: 12px;\n --radius-sm: 8px;\n --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n --mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;\n }\n\n * { margin: 0; padding: 0; box-sizing: border-box; }\n\n body {\n font-family: var(--font);\n background: var(--bg);\n color: var(--text);\n line-height: 1.6;\n min-height: 100vh;\n }\n\n /* ββ Navigation βββββββββββββββββββββββββββββββ */\n nav {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 2rem;\n height: 60px;\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n backdrop-filter: blur(12px);\n }\n\n nav .brand {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--text);\n text-decoration: none;\n letter-spacing: -0.02em;\n }\n\n nav .brand .logo {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border-radius: var(--radius-sm);\n background: linear-gradient(135deg, var(--accent), #a78bfa);\n color: #fff;\n font-size: 1rem;\n font-weight: 800;\n }\n\n nav .links {\n display: flex;\n gap: 0.25rem;\n }\n\n nav .links a {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n padding: 0.5rem 1rem;\n border-radius: var(--radius-sm);\n color: var(--text-muted);\n text-decoration: none;\n font-size: 0.9rem;\n font-weight: 500;\n transition: all 0.15s ease;\n }\n\n nav .links a:hover {\n color: var(--text);\n background: var(--bg-surface-hover);\n }\n\n nav .links a.active {\n color: var(--accent);\n background: var(--accent-glow);\n }\n\n /* ββ Main container βββββββββββββββββββββββββββ */\n .container {\n max-width: 1200px;\n margin: 0 auto;\n padding: 2rem;\n }\n\n /* ββ Page header ββββββββββββββββββββββββββββββ */\n .page-header {\n margin-bottom: 2rem;\n }\n\n h1 {\n font-size: 2rem;\n font-weight: 700;\n letter-spacing: -0.03em;\n margin-bottom: 0.25rem;\n background: linear-gradient(135deg, var(--text), var(--text-muted));\n -webkit-background-clip: text;\n -webkit-text-fill-color: transparent;\n background-clip: text;\n }\n\n .page-header p {\n color: var(--text-muted);\n font-size: 0.95rem;\n }\n\n h2 {\n font-size: 1.15rem;\n font-weight: 600;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.06em;\n margin: 2rem 0 1rem;\n padding-bottom: 0.5rem;\n border-bottom: 1px solid var(--border);\n }\n\n /* ββ Cards ββββββββββββββββββββββββββββββββββββ */\n .card {\n background: var(--bg-surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n transition: border-color 0.2s ease;","start_line":1,"end_line":150,"total_lines":1076,"truncated":true}
{"context_after":60,"query":"define \"index\""}
/project/internal/web/templates.go:695:{{define "index"}}
/project/internal/web/templates.go-696-{{template "layout_head" .}}
/project/internal/web/templates.go-697-<div class="container">
/project/internal/web/templates.go-698- <div class="page-header">
/project/internal/web/templates.go-699- <h1>Dashboard</h1>
/project/internal/web/templates.go-700- <p>Overview of your zoo configuration and running agents.</p>
/project/internal/web/templates.go-701- </div>
/project/internal/web/templates.go-702-
/project/internal/web/templates.go-703- {{if .ActiveJobs}}
/project/internal/web/templates.go-704- <h2>Running Jobs</h2>
/project/internal/web/templates.go-705- <div class="job-cards">
/project/internal/web/templates.go-706- {{range .ActiveJobs}}
/project/internal/web/templates.go-707- <div class="job-card">
/project/internal/web/templates.go-708- <div class="job-card-header">
/project/internal/web/templates.go-709- <span class="badge badge-{{.Status}}">
/project/internal/web/templates.go-710- <span class="dot"></span>
/project/internal/web/templates.go-711- {{.Status}}
/project/internal/web/templates.go-712- </span>
/project/internal/web/templates.go-713- <a href="/jobs/{{.ID}}" class="job-card-link" title="View job details">β</a>
/project/internal/web/templates.go-714- </div>
/project/internal/web/templates.go-715- <div class="job-card-body">
/project/internal/web/templates.go-716- <h3 class="job-card-title">
/project/internal/web/templates.go-717- {{if .Title}}{{.Title}}{{else}}Issue #{{.IssueIndex}}{{end}}
/project/internal/web/templates.go-718- </h3>
/project/internal/web/templates.go-719- <p class="job-card-meta">
/project/internal/web/templates.go-720- <code>{{.Owner}}/{{.Repo}}#{{.IssueIndex}}</code>
/project/internal/web/templates.go-721- </p>
/project/internal/web/templates.go-722- <div class="job-card-agent">
/project/internal/web/templates.go-723- {{if .AvatarURL}}<img class="job-card-avatar" src="{{.AvatarURL}}" alt="{{.Agent}}" title="{{.Agent}}" loading="lazy">{{end}}
/project/internal/web/templates.go-724- <span class="job-card-agent-name">{{.Agent}}</span>
/project/internal/web/templates.go-725- </div>
/project/internal/web/templates.go-726- </div>
/project/internal/web/templates.go-727- </div>
/project/internal/web/templates.go-728- {{end}}
/project/internal/web/templates.go-729- </div>
/project/internal/web/templates.go-730- {{end}}
/project/internal/web/templates.go-731-
/project/internal/web/templates.go-732- <h2>LLMs</h2>
/project/internal/web/templates.go-733- <div class="table-wrap">
/project/internal/web/templates.go-734- <table>
/project/internal/web/templates.go-735- <thead><tr><th>Name</th><th>Endpoint</th><th>Model</th></tr></thead>
/project/internal/web/templates.go-736- <tbody>
/project/internal/web/templates.go-737- {{range .LLMs}}
/project/internal/web/templates.go-738- <tr>
/project/internal/web/templates.go-739- <td><strong>{{.Name}}</strong></td>
/project/internal/web/templates.go-740- <td><code>{{.OpenAI}}</code></td>
/project/internal/web/templates.go-741- <td>{{.Model}}</td>
/project/internal/web/templates.go-742- </tr>
/project/internal/web/templates.go-743- {{end}}
/project/internal/web/templates.go-744- </tbody>
/project/internal/web/templates.go-745- </table>
/project/internal/web/templates.go-746- </div>
/project/internal/web/templates.go-747-
/project/internal/web/templates.go-748- <h2>Agents</h2>
/project/internal/web/templates.go-749- <div class="table-wrap">
/project/internal/web/templates.go-750- <table>
/project/internal/web/templates.go-751- <thead><tr><th>Name</th><th>LLM</th></tr></thead>
/project/internal/web/templates.go-752- <tbody>
/project/internal/web/templates.go-753- {{range .Agents}}
/project/internal/web/templates.go-754- <tr>
/project/internal/web/templates.go-755- <td><strong>{{.Name}}</strong></td>
{"context_after":40,"query":"EventSource"}
/project/internal/web/templates.go:1027: var es = new EventSource("/jobs/" + jobID + "/events");
/project/internal/web/templates.go-1028-
/project/internal/web/templates.go-1029- es.onmessage = function(e) {
/project/internal/web/templates.go-1030- var ev = JSON.parse(e.data);
/project/internal/web/templates.go-1031-
/project/internal/web/templates.go-1032- switch (ev.type) {
/project/internal/web/templates.go-1033- case "reasoning_start":
/project/internal/web/templates.go-1034- reasoningBody = newBlock("reasoning", "Thinking");
/project/internal/web/templates.go-1035- break;
/project/internal/web/templates.go-1036- case "reasoning_delta":
/project/internal/web/templates.go-1037- if (reasoningBody) reasoningBody.textContent += ev.text;
/project/internal/web/templates.go-1038- break;
/project/internal/web/templates.go-1039- case "reasoning_end":
/project/internal/web/templates.go-1040- // Model output routinely starts/ends with newlines; drop them
/project/internal/web/templates.go-1041- // so the finished block matches the static (trimmed) view.
/project/internal/web/templates.go-1042- if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();
/project/internal/web/templates.go-1043- reasoningBody = null;
/project/internal/web/templates.go-1044- break;
/project/internal/web/templates.go-1045- case "content_start":
/project/internal/web/templates.go-1046- contentBody = newBlock("content", null);
/project/internal/web/templates.go-1047- break;
/project/internal/web/templates.go-1048- case "content_delta":
/project/internal/web/templates.go-1049- if (contentBody) contentBody.textContent += ev.text;
/project/internal/web/templates.go-1050- break;
/project/internal/web/templates.go-1051- case "content_end":
/project/internal/web/templates.go-1052- if (contentBody) contentBody.textContent = contentBody.textContent.trim();
/project/internal/web/templates.go-1053- contentBody = null;
/project/internal/web/templates.go-1054- break;
/project/internal/web/templates.go-1055- case "tool":
/project/internal/web/templates.go-1056- newToolBlock(ev);
/project/internal/web/templates.go-1057- break;
/project/internal/web/templates.go-1058- case "system":
/project/internal/web/templates.go-1059- newBlock("system", null).textContent = ev.text;
/project/internal/web/templates.go-1060- break;
/project/internal/web/templates.go-1061- case "status":
/project/internal/web/templates.go-1062- es.close();
/project/internal/web/templates.go-1063- location.reload();
/project/internal/web/templates.go-1064- return;
/project/internal/web/templates.go-1065- }
/project/internal/web/templates.go-1066-
/project/internal/web/templates.go-1067- follow();
--
/project/internal/web/web_test.go:206: if b := r.Body.String(); strings.Contains(b, `id="log-jump"`) || strings.Contains(b, "EventSource") {
/project/internal/web/web_test.go-207- t.Fatalf("finished job detail page should not carry live streaming controls:\n%s", b)
/project/internal/web/web_test.go-208- }
/project/internal/web/web_test.go-209-}
/project/internal/web/web_test.go-210-
/project/internal/web/web_test.go-211-func TestAuthGate(t *testing.T) {
/project/internal/web/web_test.go-212- st, err := store.Open(filepath.Join(t.TempDir(), "zoo.db"))
/project/internal/web/web_test.go-213- if err != nil {
/project/internal/web/web_test.go-214- t.Fatal(err)
/project/internal/web/web_test.go-215- }
/project/internal/web/web_test.go-216- defer st.Close()
/project/internal/web/web_test.go-217-
/project/internal/web/web_test.go-218- cfg := &config.Config{Web: &config.Web{Token: "secret"}}
/project/internal/web/web_test.go-219- s := New(cfg, st, livelog.NewHub(), nil)
/project/internal/web/web_test.go-220-
/project/internal/web/web_test.go-221- rr := httptest.NewRecorder()
/project/internal/web/web_test.go-222- s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/jobs", nil))
/project/internal/web/web_test.go-223- if rr.Code != http.StatusUnauthorized {
/project/internal/web/web_test.go-224- t.Fatalf("expected 401 without token, got %d", rr.Code)
/project/internal/web/web_test.go-225- }
/project/internal/web/web_test.go-226-
/project/internal/web/web_test.go-227- req := httptest.NewRequest(http.MethodGet, "/jobs", nil)
/project/internal/web/web_test.go-228- req.Header.Set("Authorization", "Bearer secret")
/project/internal/web/web_test.go-229- rr = httptest.NewRecorder()
/project/internal/web/web_test.go-230- s.Handler().ServeHTTP(rr, req)
/project/internal/web/web_test.go-231- if rr.Code != http.StatusOK {
/project/internal/web/web_test.go-232- t.Fatalf("expected 200 with correct token, got %d", rr.Code)
/project/internal/web/web_test.go-233- }
/project/internal/web/web_test.go-234-}
/project/internal/web/web_test.go-235-
/project/internal/web/web_test.go-236-// TestJobsShowAgentAvatars verifies the jobs pages render each agent's
/project/internal/web/web_test.go-237-// Forgejo avatar, and that an unresolvable avatar (unknown user) degrades
/project/internal/web/web_test.go-238-// to no image instead of breaking the page.
/project/internal/web/web_test.go-239-func TestJobsShowAgentAvatars(t *testing.T) {
/project/internal/web/web_test.go-240- // Minimal in-test Forgejo API: the version probe the SDK makes when
/project/internal/web/web_test.go-241- // the client is constructed, plus per-user profile lookups.
/project/internal/web/web_test.go-242- avatars := map[string]string{
/project/internal/web/web_test.go-243- "leon": "https://forgejo.example/avatars/leon",
/project/internal/web/web_test.go-244- }
/project/internal/web/web_test.go-245-
/project/internal/web/web_test.go-246- api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
{"context_after":5,"query":"hub\\.(Publish|Close|Subscribe|Checkpoint)"}
/project/internal/web/web_test.go:80: s.hub.Publish("job-1", livelog.Event{Type: livelog.ContentDelta, Text: "streaming"})
/project/internal/web/web_test.go-81-
/project/internal/web/web_test.go-82- evCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
/project/internal/web/web_test.go-83- defer cancel()
/project/internal/web/web_test.go-84-
/project/internal/web/web_test.go-85- rr = httptest.NewRecorder()
--
/project/internal/web/web.go:275:// block (see livelog.Hub.Checkpoint) β everything already written to
/project/internal/web/web.go-276-// the store is rendered once, statically, by handleJobDetail. If the
/project/internal/web/web.go-277-// job isn't live, the response just ends immediately and the client
/project/internal/web/web.go-278-// falls back to the static page.
/project/internal/web/web.go-279-func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
/project/internal/web/web.go-280- id := r.PathValue("id")
--
/project/internal/web/web.go:305: buffered, ch, cancel := s.hub.Subscribe(id)
/project/internal/web/web.go-306- defer cancel()
/project/internal/web/web.go-307-
/project/internal/web/web.go-308- write := func(ev livelog.Event) bool {
/project/internal/web/web.go-309- data, err := json.Marshal(ev)
/project/internal/web/web.go-310- if err != nil {
--
/project/internal/livelog/hub.go:5:// into SQLite yet β see Hub.Checkpoint.
/project/internal/livelog/hub.go-6-package livelog
/project/internal/livelog/hub.go-7-
/project/internal/livelog/hub.go-8-import "sync"
/project/internal/livelog/hub.go-9-
/project/internal/livelog/hub.go-10-type Type string
--
/project/internal/agentrun/run_test.go:26: buffered, ch, cancel := hub.Subscribe(jobID)
/project/internal/agentrun/run_test.go-27- defer cancel()
/project/internal/agentrun/run_test.go-28-
/project/internal/agentrun/run_test.go-29- fn()
/project/internal/agentrun/run_test.go-30-
/project/internal/agentrun/run_test.go-31- events := append([]livelog.Event(nil), buffered...)
--
/project/internal/agentrun/run.go:307: r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
/project/internal/agentrun/run.go-308- }
/project/internal/agentrun/run.go-309-
/project/internal/agentrun/run.go:310: r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
/project/internal/agentrun/run.go-311- },
/project/internal/agentrun/run.go-312- OnContentDelta: func(delta string) {
/project/internal/agentrun/run.go-313- contentBuf.WriteString(delta)
/project/internal/agentrun/run.go-314-
/project/internal/agentrun/run.go-315- if !contentOpen && strings.TrimSpace(contentBuf.String()) != "" {
--
/project/internal/agentrun/run.go:317: r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
/project/internal/agentrun/run.go-318- }
/project/internal/agentrun/run.go-319-
/project/internal/agentrun/run.go:320: r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
/project/internal/agentrun/run.go-321- },
/project/internal/agentrun/run.go-322- OnTurnEnd: func() {
/project/internal/agentrun/run.go-323- if reasoningOpen {
/project/internal/agentrun/run.go:324: r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
/project/internal/agentrun/run.go-325- logAppend("reasoning", reasoningBuf.String())
/project/internal/agentrun/run.go:326: r.hub.Checkpoint(jobID)
/project/internal/agentrun/run.go-327- }
/project/internal/agentrun/run.go-328-
/project/internal/agentrun/run.go-329- if contentOpen {
/project/internal/agentrun/run.go:330: r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
/project/internal/agentrun/run.go-331- logAppend("content", contentBuf.String())
/project/internal/agentrun/run.go:332: r.hub.Checkpoint(jobID)
/project/internal/agentrun/run.go-333- }
/project/internal/agentrun/run.go-334-
/project/internal/agentrun/run.go-335- // Each turn starts from a clean slate. The buffers were
/project/internal/agentrun/run.go-336- // previously reset when a block opened, but with lazy open a
/project/internal/agentrun/run.go-337- // whitespace-only turn never opens one, so reset here instead
--
/project/internal/agentrun/run.go:349: r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
/project/internal/agentrun/run.go-350- logAppend("system", note)
/project/internal/agentrun/run.go:351: r.hub.Checkpoint(jobID)
/project/internal/agentrun/run.go-352- },
/project/internal/agentrun/run.go-353- OnRetry: func(err error, attempt int, delay time.Duration) {
/project/internal/agentrun/run.go-354- // The endpoint is down or refusing; say so in the job log so
/project/internal/agentrun/run.go-355- // a run that stalls for a minute doesn't look like a hang.
/project/internal/agentrun/run.go-356- note := fmt.Sprintf("llm request failed (attempt %d/%d), retrying in %s: %s",
--
/project/internal/agentrun/run.go:359: r.hub.Publish(jobID, livelog.Event{Type: livelog.System, Text: note})
/project/internal/agentrun/run.go-360- logAppend("system", note)
/project/internal/agentrun/run.go:361: r.hub.Checkpoint(jobID)
/project/internal/agentrun/run.go-362- },
/project/internal/agentrun/run.go-363- OnTool: func(name, arguments, result string, toolErr bool) {
/project/internal/agentrun/run.go:364: r.hub.Publish(jobID, livelog.Event{
/project/internal/agentrun/run.go-365- Type: livelog.Tool,
/project/internal/agentrun/run.go-366- Name: name,
/project/internal/agentrun/run.go-367- Arguments: arguments,
/project/internal/agentrun/run.go-368- Result: result,
/project/internal/agentrun/run.go-369- Error: toolErr,
--
/project/internal/agentrun/run.go:379: r.hub.Checkpoint(jobID)
/project/internal/agentrun/run.go-380- },
/project/internal/agentrun/run.go-381- }
/project/internal/agentrun/run.go-382-}
/project/internal/agentrun/run.go-383-
/project/internal/agentrun/run.go-384-func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {
--
/project/internal/scheduler/scheduler.go:217: s.hub.Close(jobID)
/project/internal/scheduler/scheduler.go-218-}
/project/internal/scheduler/scheduler.go-219-
/project/internal/scheduler/scheduler.go-220-// reportFailure implements the locked-in failure policy: comment on the
/project/internal/scheduler/scheduler.go-221-// triggering issue/PR with an error summary, and apply FailureLabel.
{"end_line":120,"path":"internal/scheduler/scheduler.go","start_line":1}
{"path":"internal/scheduler/scheduler.go","content":"// Package scheduler resolves incoming Forgejo events to configured\n// agents and runs them, bounded by max_live_agents.\npackage scheduler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"sync\"\n\n\t\"github.com/google/uuid\"\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\n// forgejoActions is the narrow slice of Client the scheduler needs for\n// its own failure-reporting side effects (defined here, not in\n// internal/forgejo, so tests can inject a fake).\ntype forgejoActions interface {\n\tCreateIssueComment(owner, repo string, index int64, body string) error\n\tAddLabel(owner, repo string, index int64, name string) error\n}\n\n// FailureLabel is applied to the triggering issue/PR, alongside a\n// comment, whenever an agent run fails or times out.\nconst FailureLabel = \"zoo:failed\"\n\n// Runner runs a single agent invocation to completion. Implemented by\n// internal/agentrun.Run; a narrow interface here so the scheduler is\n// testable without Docker.\ntype Runner interface {\n\tRun(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error\n}\n\ntype Scheduler struct {\n\tcfg *config.Config\n\tstore *store.Store\n\tforgejo forgejoActions\n\trunner Runner\n\thub *livelog.Hub\n\tlogger *slog.Logger\n\n\tsem chan struct{}\n\twg sync.WaitGroup\n}\n\nfunc New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {\n\treturn \u0026Scheduler{\n\t\tcfg: cfg,\n\t\tstore: st,\n\t\tforgejo: fg,\n\t\trunner: runner,\n\t\thub: hub,\n\t\tlogger: logger,\n\t\tsem: make(chan struct{}, cfg.Environment.MaxLive),\n\t}\n}\n\n// resolveAgent returns the name of the agent that should handle ev, if\n// any. Two kinds resolve dynamically: issue:assigned to the agent whose\n// config label matches the Forgejo assignee's username, and pr:review to\n// the agent whose config label matches the pull request author's\n// username (the agent that opened the PR reacts to the review of it).\n// Every other event kind uses the static event-\u003eagent mapping from\n// config.\nfunc resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {\n\tswitch ev.Kind {\n\tcase config.EventIssueAssigned:\n\t\tif _, ok := cfg.AgentByName(ev.Assignee); ok {\n\t\t\treturn ev.Assignee, true\n\t\t}\n\n\t\treturn \"\", false\n\n\tcase config.EventPRReview:\n\t\tif _, ok := cfg.AgentByName(ev.PRAuthor); ok {\n\t\t\treturn ev.PRAuthor, true\n\t\t}\n\n\t\treturn \"\", false\n\t}\n\n\treturn cfg.EventAgent(ev.Kind)\n}\n\n// Run consumes events until ctx is canceled or the channel closes,\n// dispatching each to its resolved agent and blocking on the\n// max_live_agents semaphore before starting a run.\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\n\t\tcase ev, ok := \u003c-events:\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ts.handle(ctx, ev)\n\t\t}\n\t}\n}\n\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\n\tagentName, ok := resolveAgent(s.cfg, ev)\n\tif !ok {\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\n\t\treturn\n\t}\n\n\t// An agent's own actions (e.g. a comment posted via the `comment`\n\t// tool, authenticated with its own per-agent token) can themselves\n\t// show up as new events. Don't let an agent trigger itself off its\n\t// own activity β that's a self-reinforcing loop, not new work.\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {","start_line":1,"end_line":120,"total_lines":252,"truncated":true}
{"end_line":252,"path":"internal/scheduler/scheduler.go","start_line":120}
{"path":"internal/scheduler/scheduler.go","content":"\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\n\t\treturn\n\t}\n\n\tagent, ok := s.cfg.AgentByName(agentName)\n\tif !ok {\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\n\t\treturn\n\t}\n\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\n\tif !ok {\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\n\t\treturn\n\t}\n\n\tjobID := uuid.NewString()\n\n\tif err := s.store.CreateJob(ctx, store.Job{\n\t\tID: jobID,\n\t\tEventKind: ev.Kind,\n\t\tAgent: agentName,\n\t\tOwner: ev.Owner,\n\t\tRepo: ev.Repo,\n\t\tIssueIndex: ev.Index,\n\t\tTitle: ev.Title,\n\t}); err != nil {\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\n\t\treturn\n\t}\n\n\tselect {\n\tcase s.sem \u003c- struct{}{}:\n\n\tcase \u003c-ctx.Done():\n\t\treturn\n\t}\n\n\ts.wg.Add(1)\n\n\tgo func() {\n\t\tdefer s.wg.Done()\n\t\tdefer func() { \u003c-s.sem }()\n\n\t\ts.run(ctx, jobID, agent, llm, ev)\n\t}()\n}\n\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\n\n\t// Job status writes use a context detached from ctx, not ctx itself:\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\n\t// leaving the job stuck at \"running\" forever even though the process\n\t// has exited.\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\n\t}\n\n\tlogger.Info(\"agent run starting\")\n\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\n\n\tstatus := store.JobSucceeded\n\terrMsg := \"\"\n\n\tif err != nil {\n\t\terrMsg = err.Error()\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\n\t\t// internally); a timed-out run surfaces as a wrapped\n\t\t// context.DeadlineExceeded in the returned error instead.\n\t\tif errors.Is(err, context.DeadlineExceeded) {\n\t\t\tstatus = store.JobTimedOut\n\t\t} else {\n\t\t\tstatus = store.JobFailed\n\t\t}\n\n\t\tlogger.Error(\"agent run failed\", \"status\", status, \"error\", err)\n\t\ts.reportFailure(ev, status, err)\n\t} else {\n\t\tlogger.Info(\"agent run succeeded\")\n\t}\n\n\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\n\t}\n\n\t// Closing the hub here, only after the terminal status is durably\n\t// written, is what lets a dashboard tab's auto-reload (triggered by\n\t// this Close) see the finished status immediately. Closing it inside\n\t// Runner.Run instead β before this status write β raced a fast\n\t// reload against MarkJobFinished: the reloaded page could still see\n\t// \"running\", open a fresh SSE subscription against a hub entry that\n\t// no longer exists, and be stuck showing a stale status forever.\n\ts.hub.Close(jobID)\n}\n\n// reportFailure implements the locked-in failure policy: comment on the\n// triggering issue/PR with an error summary, and apply FailureLabel.\nfunc (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {\n\tbody := fmt.Sprintf(\"zoo agent run %s: %s\", status, runErr.Error())\n\n\tif err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {\n\t\ts.logger.Error(\"failed to post failure comment\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\n\t}\n\n\tif err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {\n\t\ts.logger.Error(\"failed to add failure label\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\n\t}\n}\n\n// Shutdown stops accepting new work is the caller's responsibility (stop\n// feeding Run's channel); Shutdown just waits for in-flight runs, up to\n// ctx's deadline.\nfunc (s *Scheduler) Shutdown(ctx context.Context) error {\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\ts.wg.Wait()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase \u003c-done:\n\t\treturn nil\n\n\tcase \u003c-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n}","start_line":120,"end_line":252,"total_lines":252,"truncated":false}
{"end_line":200,"path":"cmd/zoo/main.go","start_line":1}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\n// issue/PR events, dispatches them to configured AI agents running in\n// Docker containers, and serves a small dashboard over the result.\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/agentrun\"\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/scheduler\"\n\t\"github.com/abrander/zoo/internal/store\"\n\t\"github.com/abrander/zoo/internal/web\"\n)\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\n\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run() error {\n\tvar (\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\n\t)\n\n\tflag.Parse()\n\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\n\tcfg, err := config.Load(*configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"load config: %w\", err)\n\t}\n\n\tst, err := store.Open(*dbPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open store: %w\", err)\n\t}\n\tdefer st.Close()\n\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\n\t} else if n \u003e 0 {\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\n\t}\n\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}\n\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\n\tdashboard := web.New(cfg, st, hub, fgClient)\n\n\tmux := http.NewServeMux()\n\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo watcher.Run(ctx)\n\tgo sched.Run(ctx, watcher.Events())\n\n\tserveErr := make(chan error, 1)\n\n\tgo func() {\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\n\t\t\tserveErr \u003c- err\n\t\t}\n\t}()\n\n\tselect {\n\tcase \u003c-ctx.Done():\n\t\tlogger.Info(\"shutting down\")\n\n\tcase err := \u003c-serveErr:\n\t\tstop()\n\n\t\tlogger.Error(\"http server failed\", \"error\", err)\n\t}\n\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := server.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\n\t}\n\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\n\t}\n\n\treturn nil\n}","start_line":1,"end_line":136,"total_lines":136,"truncated":false}
{"end_line":120,"path":"internal/web/web_test.go","start_line":1}
{"path":"internal/web/web_test.go","content":"package web\n\nimport (\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)\n\nfunc testServer(t *testing.T) (*Server, *store.Store) {\n\tt.Helper()\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\tcfg := \u0026config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Model: \"qwen3\"}},\n\t\tAgents: []config.Agent{{Name: \"leon\", LLM: \"qwen\"}},\n\t\tEvents: []config.Event{{Kind: config.EventIssueNew, Agent: \"leon\"}},\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\", MaxLive: 2},\n\t}\n\n\t// No Forgejo client: pages render without avatars.\n\treturn New(cfg, st, livelog.NewHub(), nil), st\n}\n\nfunc TestIndexRenders(t *testing.T) {\n\ts, _ := testServer(t)\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/\", nil))\n\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n\tif !strings.Contains(rr.Body.String(), \"leon\") {\n\t\tt.Fatalf(\"expected agent name in output, got %s\", rr.Body.String())\n\t}\n}\n\nfunc TestJobsAndDetail(t *testing.T) {\n\ts, st := testServer(t)\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\tif err := st.AppendLog(ctx, \"job-1\", \"stdout\", \"hello\"); 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 || !strings.Contains(rr.Body.String(), \"job-1\") {\n\t\tt.Fatalf(\"unexpected jobs page: %d %s\", rr.Code, rr.Body.String())\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(), \"hello\") {\n\t\tt.Fatalf(\"unexpected job detail page: %d %s\", rr.Code, rr.Body.String())\n\t}\n\n\t// job-1 was never marked started/finished, so it's still \"live\":\n\t// hitting /events should stream whatever's currently buffered in the\n\t// hub (not yet checkpointed/persisted) and then wait for more, until\n\t// the request context is done.\n\ts.hub.Publish(\"job-1\", livelog.Event{Type: livelog.ContentDelta, Text: \"streaming\"})\n\n\tevCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)\n\tdefer cancel()\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-1/events\", nil).WithContext(evCtx))\n\tif rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), \"streaming\") {\n\t\tt.Fatalf(\"unexpected events response: %d %s\", rr.Code, rr.Body.String())\n\t}\n\n\t// A finished job isn't live: /events should return immediately with\n\t// nothing to stream, so the client falls back to the static page.\n\tif err := st.CreateJob(ctx, store.Job{ID: \"job-2\", EventKind: \"issue:new\", Agent: \"leon\", Owner: \"acme\", Repo: \"widgets\", IssueIndex: 2}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.MarkJobFinished(ctx, \"job-2\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/jobs/job-2/events\", nil))\n\tif rr.Code != http.StatusOK || rr.Body.Len() != 0 {\n\t\tt.Fatalf(\"expected empty response for a non-live job, got %d %q\", rr.Code, rr.Body.String())\n\t}\n}\n\n// TestJobDetailTrimsBlockWhitespace verifies that reasoning/content\n// blocks are rendered without the leading/trailing newlines the model\n// routinely emits: the block body uses white-space: pre-wrap, so those\n// would show up as visible blank lines inflating the block's height\n// (issue #53). Internal newlines must be preserved.\nfunc TestJobDetailTrimsBlockWhitespace(t *testing.T) {\n\ts, st := testServer(t)\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\tif err := st.MarkJobFinished(ctx, \"job-1\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)","start_line":1,"end_line":120,"total_lines":310,"truncated":true}
{"end_line":1076,"path":"internal/web/templates.go","start_line":980}
{"path":"internal/web/templates.go","content":"\n var summary = document.createElement(\"summary\");\n\n var badge = document.createElement(\"span\");\n badge.className = \"tool-badge\";\n badge.textContent = \"Tool\";\n\n var text = document.createElement(\"span\");\n text.className = \"tool-summary-text\";\n\n var name = document.createElement(\"span\");\n name.className = \"tool-name\";\n name.textContent = \"π§ \" + ev.name;\n\n var preview = document.createElement(\"span\");\n preview.className = \"tool-args-preview\";\n preview.textContent = ev.arguments;\n\n text.appendChild(name);\n text.appendChild(preview);\n summary.appendChild(badge);\n summary.appendChild(text);\n details.appendChild(summary);\n\n var body = document.createElement(\"div\");\n body.className = \"block-body\";\n\n var argsLabel = document.createElement(\"div\");\n argsLabel.className = \"tool-section-label\";\n argsLabel.textContent = \"Arguments\";\n var argsPre = document.createElement(\"pre\");\n argsPre.textContent = ev.arguments;\n\n var resultLabel = document.createElement(\"div\");\n resultLabel.className = \"tool-section-label\";\n resultLabel.textContent = \"Result\";\n var resultPre = document.createElement(\"pre\");\n resultPre.textContent = ev.result;\n\n body.appendChild(argsLabel);\n body.appendChild(argsPre);\n body.appendChild(resultLabel);\n body.appendChild(resultPre);\n details.appendChild(body);\n log.appendChild(details);\n }\n\n var es = new EventSource(\"/jobs/\" + jobID + \"/events\");\n\n es.onmessage = function(e) {\n var ev = JSON.parse(e.data);\n\n switch (ev.type) {\n case \"reasoning_start\":\n reasoningBody = newBlock(\"reasoning\", \"Thinking\");\n break;\n case \"reasoning_delta\":\n if (reasoningBody) reasoningBody.textContent += ev.text;\n break;\n case \"reasoning_end\":\n // Model output routinely starts/ends with newlines; drop them\n // so the finished block matches the static (trimmed) view.\n if (reasoningBody) reasoningBody.textContent = reasoningBody.textContent.trim();\n reasoningBody = null;\n break;\n case \"content_start\":\n contentBody = newBlock(\"content\", null);\n break;\n case \"content_delta\":\n if (contentBody) contentBody.textContent += ev.text;\n break;\n case \"content_end\":\n if (contentBody) contentBody.textContent = contentBody.textContent.trim();\n contentBody = null;\n break;\n case \"tool\":\n newToolBlock(ev);\n break;\n case \"system\":\n newBlock(\"system\", null).textContent = ev.text;\n break;\n case \"status\":\n es.close();\n location.reload();\n return;\n }\n\n follow();\n };\n })();\n \u003c/script\u003e\n {{end}}\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n`","start_line":980,"end_line":1076,"total_lines":1076,"truncated":false}
{"end_line":120,"path":"internal/scheduler/scheduler_test.go","start_line":1}
{"path":"internal/scheduler/scheduler_test.go","content":"package scheduler\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"path/filepath\"\n\t\"sync\"\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)\n\nfunc testConfig() *config.Config {\n\treturn \u0026config.Config{\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Token: \"tok\", Model: \"qwen3\"}},\n\t\tForgejo: config.Forgejo{URL: \"https://example.com\", Token: \"tok\"},\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\", MaxLive: 1},\n\t\tAgents: []config.Agent{\n\t\t\t{Name: \"leon\", LLM: \"qwen\"},\n\t\t\t{Name: \"greg\", LLM: \"qwen\"},\n\t\t},\n\t\tEvents: []config.Event{\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueAssigned},\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\n\t\t\t{Kind: config.EventPRReview},\n\t\t},\n\t}\n}\n\nfunc TestResolveAgentStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\n\tif !ok || name != \"leon\" {\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\n\t}\n}\n\nfunc TestResolveAgentPRReviewByAuthor(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventPRReview, PRAuthor: \"greg\", ReviewID: 42})\n\tif !ok || name != \"greg\" {\n\t\tt.Fatalf(\"expected greg (the PR author), got %q, %v\", name, ok)\n\t}\n}\n\nfunc TestResolveAgentPRReviewNoAgentAuthor(t *testing.T) {\n\tcfg := testConfig()\n\n\t_, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventPRReview, PRAuthor: \"not-an-agent\", ReviewID: 42})\n\tif ok {\n\t\tt.Fatal(\"expected no agent to resolve for a non-agent PR author\")\n\t}\n}\n\nfunc TestResolveAgentAssignedMatch(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"greg\"})\n\tif !ok || name != \"greg\" {\n\t\tt.Fatalf(\"expected greg, got %q, %v\", name, ok)\n\t}\n}\n\nfunc TestResolveAgentAssignedNoMatch(t *testing.T) {\n\tcfg := testConfig()\n\n\t_, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"not-an-agent\"})\n\tif ok {\n\t\tt.Fatal(\"expected no agent to resolve for a non-agent assignee\")\n\t}\n}\n\ntype fakeForgejo struct {\n\tmu sync.Mutex\n\tcomments []string\n\tlabels []string\n}\n\nfunc (f *fakeForgejo) CreateIssueComment(owner, repo string, index int64, body string) error {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\n\nfunc (f *fakeForgejo) AddLabel(owner, repo string, index int64, name string) error {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tf.labels = append(f.labels, name)\n\treturn nil\n}\n\ntype fakeRunner struct {\n\tmu sync.Mutex\n\trunning int\n\tmaxSeen int\n\tfail bool\n\ttimeoutErr bool\n\tblockUntil chan struct{}\n}\n\nfunc (f *fakeRunner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error {\n\tf.mu.Lock()\n\tf.running++\n\tif f.running \u003e f.maxSeen {\n\t\tf.maxSeen = f.running\n\t}\n\tf.mu.Unlock()\n\n\tif f.blockUntil != nil {\n\t\t\u003c-f.blockUntil","start_line":1,"end_line":120,"total_lines":456,"truncated":true}
{"end_line":100,"path":"internal/livelog/hub_test.go","start_line":1}
{"path":"internal/livelog/hub_test.go","content":"package livelog\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc recv(t *testing.T, ch \u003c-chan Event) Event {\n\tt.Helper()\n\n\tselect {\n\tcase ev, ok := \u003c-ch:\n\t\tif !ok {\n\t\t\tt.Fatal(\"channel closed unexpectedly\")\n\t\t}\n\n\t\treturn ev\n\tcase \u003c-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for event\")\n\t}\n\n\treturn Event{}\n}\n\nfunc TestSubscribeReplaysBufferedEvents(t *testing.T) {\n\th := NewHub()\n\n\th.Publish(\"job-1\", Event{Type: ContentStart})\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n\n\tbuffered, ch, cancel := h.Subscribe(\"job-1\")\n\tdefer cancel()\n\n\tif len(buffered) != 2 {\n\t\tt.Fatalf(\"expected 2 buffered events, got %d\", len(buffered))\n\t}\n\tif buffered[1].Text != \"hi\" {\n\t\tt.Fatalf(\"unexpected buffered event: %+v\", buffered[1])\n\t}\n\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \" there\"})\n\n\tev := recv(t, ch)\n\tif ev.Text != \" there\" {\n\t\tt.Fatalf(\"expected live delta %q, got %+v\", \" there\", ev)\n\t}\n}\n\nfunc TestCheckpointClearsBuffer(t *testing.T) {\n\th := NewHub()\n\n\th.Publish(\"job-1\", Event{Type: ContentStart})\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n\th.Checkpoint(\"job-1\")\n\n\tbuffered, _, cancel := h.Subscribe(\"job-1\")\n\tdefer cancel()\n\n\tif len(buffered) != 0 {\n\t\tt.Fatalf(\"expected empty buffer after checkpoint, got %d events\", len(buffered))\n\t}\n}\n\nfunc TestCloseTerminatesSubscribers(t *testing.T) {\n\th := NewHub()\n\n\t_, ch, cancel := h.Subscribe(\"job-1\")\n\tdefer cancel()\n\n\th.Close(\"job-1\")\n\n\tev := recv(t, ch)\n\tif ev.Type != Status {\n\t\tt.Fatalf(\"expected terminal status event, got %+v\", ev)\n\t}\n\n\tselect {\n\tcase _, ok := \u003c-ch:\n\t\tif ok {\n\t\t\tt.Fatal(\"expected channel to be closed after status event\")\n\t\t}\n\tcase \u003c-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for channel close\")\n\t}\n}\n\nfunc TestSubscribeAfterCloseGetsEmptyBuffer(t *testing.T) {\n\th := NewHub()\n\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n\th.Close(\"job-1\")\n\n\tbuffered, ch, cancel := h.Subscribe(\"job-1\")\n\tdefer cancel()\n\n\tif len(buffered) != 0 {\n\t\tt.Fatalf(\"expected fresh empty state for a job re-subscribed after close, got %d events\", len(buffered))\n\t}\n\n\tselect {","start_line":1,"end_line":100,"total_lines":124,"truncated":true}
{"end_line":300,"path":"internal/scheduler/scheduler_test.go","start_line":120}
{"path":"internal/scheduler/scheduler_test.go","content":"\t\t\u003c-f.blockUntil\n\t}\n\n\tf.mu.Lock()\n\tf.running--\n\tf.mu.Unlock()\n\n\tif f.timeoutErr {\n\t\treturn fmt.Errorf(\"agent loop: %w\", context.DeadlineExceeded)\n\t}\n\tif f.fail {\n\t\treturn fmt.Errorf(\"boom\")\n\t}\n\treturn nil\n}\n\nfunc newStore(t *testing.T) *store.Store {\n\tt.Helper()\n\ts, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { s.Close() })\n\treturn s\n}\n\nfunc TestSchedulerDispatchAndSucceed(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 5}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 1 || jobs[0].Status != store.JobSucceeded {\n\t\tt.Fatalf(\"unexpected jobs: %+v\", jobs)\n\t}\n\tif len(fg.comments) != 0 || len(fg.labels) != 0 {\n\t\tt.Fatalf(\"expected no failure reporting on success, got comments=%v labels=%v\", fg.comments, fg.labels)\n\t}\n}\n\nfunc TestSchedulerReportsFailure(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{fail: true}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents \u003c- forgejo.Event{Kind: config.EventPRNew, Owner: \"acme\", Repo: \"widgets\", Index: 9}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 1 || jobs[0].Status != store.JobFailed {\n\t\tt.Fatalf(\"unexpected jobs: %+v\", jobs)\n\t}\n\tif len(fg.comments) != 1 || len(fg.labels) != 1 || fg.labels[0] != FailureLabel {\n\t\tt.Fatalf(\"expected failure comment+label, got comments=%v labels=%v\", fg.comments, fg.labels)\n\t}\n}\n\nfunc TestSchedulerClassifiesTimeout(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{timeoutErr: true}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 3}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 1 || jobs[0].Status != store.JobTimedOut {\n\t\tt.Fatalf(\"expected timed_out status, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDropsUnresolvedEvent(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"nobody\", Owner: \"acme\", Repo: \"widgets\", Index: 1}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 0 {\n\t\tt.Fatalf(\"expected no job for unresolved event, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDropsEventAuthoredByTargetAgent(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueComment, Author: \"leon\", Owner: \"acme\", Repo: \"widgets\", Index: 1}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 0 {\n\t\tt.Fatalf(\"expected leon's own comment not to trigger leon, got %+v\", jobs)\n\t}\n}\n\n// A review greg submits on a PR greg opened must not trigger greg: the\n// event's author (the reviewer) is the resolved agent (the PR author).\nfunc TestSchedulerDropsPRReviewAuthoredByTargetAgent(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := \u0026fakeForgejo{}\n\trunner := \u0026fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n","start_line":120,"end_line":300,"total_lines":456,"truncated":true}
{"end_line":800,"path":"internal/web/templates.go","start_line":748}
{"path":"internal/web/templates.go","content":" \u003ch2\u003eAgents\u003c/h2\u003e\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\n \u003cthead\u003e\u003ctr\u003e\u003cth\u003eName\u003c/th\u003e\u003cth\u003eLLM\u003c/th\u003e\u003c/tr\u003e\u003c/thead\u003e\n \u003ctbody\u003e\n {{range .Agents}}\n \u003ctr\u003e\n \u003ctd\u003e\u003cstrong\u003e{{.Name}}\u003c/strong\u003e\u003c/td\u003e\n \u003ctd\u003e{{.LLM}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\n \u003ch2\u003eEvent Mappings\u003c/h2\u003e\n \u003cdiv class=\"table-wrap\"\u003e\n \u003ctable\u003e\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\n \u003ctbody\u003e\n {{range .Events}}\n \u003ctr\u003e\n \u003ctd\u003e\u003ccode\u003e{{.Kind}}\u003c/code\u003e\u003c/td\u003e\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\n \u003ctd\u003e{{.Instructions}}\u003c/td\u003e\n \u003c/tr\u003e\n {{end}}\n \u003c/tbody\u003e\n \u003c/table\u003e\n \u003c/div\u003e\n\n \u003ch2\u003eEnvironment\u003c/h2\u003e\n \u003cdiv class=\"info-grid\"\u003e\n \u003cdiv class=\"info-item\"\u003e\n \u003cspan class=\"label\"\u003eDocker Image\u003c/span\u003e\n \u003cspan class=\"value\"\u003e\u003ccode\u003e{{.Environment.DockerImage}}\u003c/code\u003e\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"info-item\"\u003e\n \u003cspan class=\"label\"\u003eMax Live Agents\u003c/span\u003e\n \u003cspan class=\"value\"\u003e{{.Environment.MaxLive}}\u003c/span\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n{{end}}\n\n{{define \"jobs\"}}\n{{template \"layout_head\" .}}\n\u003cdiv class=\"container\"\u003e\n \u003cdiv class=\"page-header\"\u003e\n \u003ch1\u003eJobs\u003c/h1\u003e\n \u003cp\u003eAll agent runs and their current status.\u003c/p\u003e","start_line":748,"end_line":800,"total_lines":1076,"truncated":true}
{"context_after":8,"query":"func (s \\*Store) (CreateJob|MarkJobStarted|MarkJobFinished|ListJobs)"}
No matches found.
{"context_after":4,"glob":"internal/store/store.go","query":"^func \\(s \\*Store\\)"}
No matches found.
{"context_after":3,"glob":"internal/store/store.go","query":"func \\("}
No matches found.
{"end_line":120,"path":"internal/store/store.go","start_line":1}
{"path":"internal/store/store.go","content":"// Package store provides zoo's embedded persistence: the Forgejo\n// poll watermark, event dedup, and job/log history for the dashboard.\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t_ \"embed\"\n\t\"fmt\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n//go:embed schema.sql\nvar schema string\n\ntype Store struct {\n\tdb *sql.DB\n}\n\n// Open opens (creating if necessary) the SQLite database at path and\n// applies the schema.\nfunc Open(path string) (*Store, error) {\n\tdb, err := sql.Open(\"sqlite\", path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"open database: %w\", err)\n\t}\n\n\t// SQLite only supports one writer at a time; serialize access rather\n\t// than fighting SQLITE_BUSY errors under concurrent agent runs.\n\tdb.SetMaxOpenConns(1)\n\n\tif _, err := db.Exec(schema); err != nil {\n\t\tdb.Close()\n\n\t\treturn nil, fmt.Errorf(\"apply schema: %w\", err)\n\t}\n\n\treturn \u0026Store{db: db}, nil\n}\n\nfunc (s *Store) Close() error {\n\treturn s.db.Close()\n}\n\n// MarkSeen records that event id has been processed. It returns false if\n// the event was already seen (by webhook or poll), so callers can dedupe\n// regardless of source.\nfunc (s *Store) MarkSeen(ctx context.Context, id string) (isNew bool, err error) {\n\tres, err := s.db.ExecContext(ctx,\n\t\t`INSERT OR IGNORE INTO seen_events (id, seen_at) VALUES (?, ?)`,\n\t\tid, time.Now().UTC())\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\n\t}\n\n\tn, err := res.RowsAffected()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\n\t}\n\n\treturn n \u003e 0, nil\n}\n\n// SyncAssignees records assignees as the current assignee set for the\n// issue and returns the subset that wasn't already recorded β i.e. the\n// assignments that happened since the last call. Assignees that have\n// gone away are forgotten, so assigning the same user again later counts\n// as a fresh assignment rather than being swallowed as already-known.\n//\n// The poller uses this because listing issues only ever shows current\n// state: without it, every unrelated update to an assigned issue (a\n// comment, an edit) looks exactly like a new assignment.\nfunc (s *Store) SyncAssignees(ctx context.Context, issueID int64, assignees []string) (added []string, err error) {\n\ttx, err := s.db.BeginTx(ctx, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\n\t}\n\tdefer tx.Rollback()\n\n\trows, err := tx.QueryContext(ctx, `SELECT assignee FROM issue_assignees WHERE issue_id = ?`, issueID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\n\t}\n\n\tknown := map[string]bool{}\n\n\tfor rows.Next() {\n\t\tvar name string\n\n\t\tif err := rows.Scan(\u0026name); err != nil {\n\t\t\trows.Close()\n\n\t\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\n\t\t}\n\n\t\tknown[name] = true\n\t}\n\n\trows.Close()\n\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\n\t}\n\n\tcurrent := map[string]bool{}\n\n\tfor _, name := range assignees {\n\t\tif current[name] {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrent[name] = true\n\n\t\tif !known[name] {\n\t\t\tadded = append(added, name)\n\t\t}\n\t}\n","start_line":1,"end_line":120,"total_lines":360,"truncated":true}
{"context_after":6,"glob":"README.md","query":"dashboard|Dashboard|live"}
/project/README.md:15:- **Live dashboard** β Real-time web UI showing active agents, logs, and job history. /project/README.md-16-- **Webhook & polling support** β React to events instantly via webhooks, or fall back to polling. /project/README.md-17- /project/README.md-18---- /project/README.md-19- /project/README.md-20-## π Quick Start /project/README.md-21- -- /project/README.md:48:The daemon starts on port `:8080` by default. Open your browser to see the dashboard. /project/README.md-49- /project/README.md-50---- /project/README.md-51- /project/README.md-52-## π₯ Meet the Agents /project/README.md-53- /project/README.md-54-The example configuration includes four agents, each with a distinct role: -- /project/README.md:69:All settings live in a single HCL file (`zoo.hcl`). Here's what each section controls: /project/README.md-70- /project/README.md-71-### LLM Definitions /project/README.md-72- /project/README.md-73-Define one or more LLM endpoints. Agents reference these by name. /project/README.md-74- /project/README.md-75-```hcl -- /project/README.md:98: max_live_agents = 5 # concurrent agent limit /project/README.md-99-} /project/README.md-100-``` /project/README.md-101- /project/README.md-102-### Agent Definition /project/README.md-103- /project/README.md-104-```hcl -- /project/README.md:160:β βββ livelog/ # Real-time log streaming hub /project/README.md-161-β βββ llm/ # LLM client & streaming /project/README.md-162-β βββ scheduler/ # Event-to-agent dispatcher /project/README.md-163-β βββ store/ # SQLite state management /project/README.md-164-β βββ tools/ # Agent tool registry /project/README.md:165:β βββ web/ # Dashboard HTTP server /project/README.md-166-βββ zoo.hcl.example # Configuration template /project/README.md-167-``` /project/README.md-168- /project/README.md-169---- /project/README.md-170- /project/README.md-171-## π Development
{"context_after":10,"glob":"internal/store/store.go","query":"JobPending"}
No matches found.
{"context_after":8,"query":"JobPending\\s+(JobStatus)?\\s*="}
/project/internal/store/store.go:177: JobPending JobStatus = "pending"
/project/internal/store/store.go-178- JobRunning JobStatus = "running"
/project/internal/store/store.go-179- JobSucceeded JobStatus = "succeeded"
/project/internal/store/store.go-180- JobFailed JobStatus = "failed"
/project/internal/store/store.go-181- JobTimedOut JobStatus = "timed_out"
/project/internal/store/store.go-182-)
/project/internal/store/store.go-183-
/project/internal/store/store.go-184-type Job struct {
/project/internal/store/store.go-185- ID string
{"find":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish\n// fine-grained, not-yet-persisted events (reasoning/content deltas, tool\n// calls) for a running job, and lets the web dashboard subscribe to them\n// live over SSE. It carries only the tail of a run that hasn't made it\n// into SQLite yet β see Hub.Checkpoint.\npackage livelog","path":"internal/livelog/hub.go","replace":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish\n// fine-grained, not-yet-persisted events (reasoning/content deltas, tool\n// calls) for a running job, and lets the web dashboard subscribe to them\n// live over SSE. It carries only the tail of a run that hasn't made it\n// into SQLite yet β see Hub.Checkpoint. It also broadcasts a global\n// jobs-changed signal (Hub.JobsChanged) so the dashboard can refresh\n// itself when the set of active jobs changes.\npackage livelog"}
1 occurrences replaced
{"find":"// Hub fans out per-job Events to any number of live subscribers.\ntype Hub struct {\n\tmu sync.Mutex\n\tjobs map[string]*stream\n}\n\nfunc NewHub() *Hub {\n\treturn &Hub{jobs: make(map[string]*stream)}\n}","path":"internal/livelog/hub.go","replace":"// Hub fans out per-job Events to any number of live subscribers, and\n// broadcasts a global jobs-changed signal to any number of global\n// subscribers.\ntype Hub struct {\n\tmu sync.Mutex\n\tjobs map[string]*stream\n\tjobsSubs map[chan struct{}]struct{}\n}\n\nfunc NewHub() *Hub {\n\treturn &Hub{\n\t\tjobs: make(map[string]*stream),\n\t\tjobsSubs: make(map[chan struct{}]struct{}),\n\t}\n}"}
1 occurrences replaced
{"find":"// Close publishes a terminal Status event, disconnects every current\n// subscriber, and drops jobID's state. Call this exactly once, when the\n// job's run has finished.\nfunc (h *Hub) Close(jobID string) {","path":"internal/livelog/hub.go","replace":"// JobsChanged signals every global subscriber that the set of active\n// jobs may have changed (a job was created, started, or finished). The\n// signal carries no state: subscribers are expected to re-read the\n// source of truth (the store) and re-render. Never blocks: a subscriber\n// whose channel is full simply misses this signal and stays stale until\n// the next one.\nfunc (h *Hub) JobsChanged() {\n\th.mu.Lock()\n\tsubs := make([]chan struct{}, 0, len(h.jobsSubs))\n\tfor ch := range h.jobsSubs {\n\t\tsubs = append(subs, ch)\n\t}\n\th.mu.Unlock()\n\n\tfor _, ch := range subs {\n\t\tselect {\n\t\tcase ch <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// SubscribeJobsChanged returns a channel that receives a value every\n// time JobsChanged is called, plus a cancel func that must be called\n// when the caller is done reading, to unregister and close the channel.\nfunc (h *Hub) SubscribeJobsChanged() (ch <-chan struct{}, cancel func()) {\n\th.mu.Lock()\n\tc := make(chan struct{}, subChanBuffer)\n\th.jobsSubs[c] = struct{}{}\n\th.mu.Unlock()\n\n\tvar once sync.Once\n\n\tcancelFn := func() {\n\t\tonce.Do(func() {\n\t\t\th.mu.Lock()\n\t\t\tdefer h.mu.Unlock()\n\n\t\t\tif _, ok := h.jobsSubs[c]; ok {\n\t\t\t\tdelete(h.jobsSubs, c)\n\t\t\t\tclose(c)\n\t\t\t}\n\t\t})\n\t}\n\n\treturn c, cancelFn\n}\n\n// Close publishes a terminal Status event, disconnects every current\n// subscriber, and drops jobID's state. Call this exactly once, when the\n// job's run has finished.\nfunc (h *Hub) Close(jobID string) {"}
1 occurrences replaced
{"find":"\tif err := s.store.CreateJob(ctx, store.Job{\n\t\tID: jobID,\n\t\tEventKind: ev.Kind,\n\t\tAgent: agentName,\n\t\tOwner: ev.Owner,\n\t\tRepo: ev.Repo,\n\t\tIssueIndex: ev.Index,\n\t\tTitle: ev.Title,\n\t}); err != nil {\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\n\t\treturn\n\t}","path":"internal/scheduler/scheduler.go","replace":"\tif err := s.store.CreateJob(ctx, store.Job{\n\t\tID: jobID,\n\t\tEventKind: ev.Kind,\n\t\tAgent: agentName,\n\t\tOwner: ev.Owner,\n\t\tRepo: ev.Repo,\n\t\tIssueIndex: ev.Index,\n\t\tTitle: ev.Title,\n\t}); err != nil {\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\n\t\treturn\n\t}\n\n\t// A new pending job is now on the dashboard; tell live viewers.\n\ts.hub.JobsChanged()"}
1 occurrences replaced
{"find":"\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\n\t}","path":"internal/scheduler/scheduler.go","replace":"\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\n\t} else {\n\t\t// pending β running changes the dashboard's badge; tell live\n\t\t// viewers.\n\t\ts.hub.JobsChanged()\n\t}"}
1 occurrences replaced
{"find":"\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\n\t}\n\n\t// Closing the hub here, only after the terminal status is durably","path":"internal/scheduler/scheduler.go","replace":"\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\n\t} else {\n\t\t// The job has left the dashboard's active list; tell live\n\t\t// viewers. This must happen after the terminal status is\n\t\t// durably written, for the same reason as the hub Close below:\n\t\t// a viewer's reload has to see the finished status, not a job\n\t\t// that is still \"running\" in the store.\n\t\ts.hub.JobsChanged()\n\t}\n\n\t// Closing the hub here, only after the terminal status is durably"}
1 occurrences replaced
{"find":"\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)","path":"internal/web/web.go","replace":"\tmux.HandleFunc(\"GET /{$}\", s.handleIndex)\n\tmux.HandleFunc(\"GET /jobs\", s.handleJobs)\n\tmux.HandleFunc(\"GET /jobs/{id}\", s.handleJobDetail)\n\tmux.HandleFunc(\"GET /jobs/{id}/events\", s.handleJobEvents)\n\tmux.HandleFunc(\"GET /events\", s.handleEvents)"}
1 occurrences replaced
{"find":"func (s *Server) render(w http.ResponseWriter, name string, data any) {","path":"internal/web/web.go","replace":"// handleEvents streams job-set changes as Server-Sent Events: the\n// dashboard page subscribes once and reloads itself whenever a job is\n// created, started, or finished, so new jobs appear (and finished ones\n// disappear) without a manual refresh. Like handleJobEvents, it carries\n// no state of its own β the reload re-renders the page from the store,\n// which is the single source of truth.\nfunc (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\thttp.Error(w, \"streaming unsupported\", http.StatusInternalServerError)\n\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\tw.WriteHeader(http.StatusOK)\n\n\tch, cancel := s.hub.SubscribeJobsChanged()\n\tdefer cancel()\n\n\t// A comment, not a data event: it confirms the stream is open\n\t// without making the client reload on connect.\n\tif _, err := fmt.Fprint(w, \": connected\\n\\n\"); err != nil {\n\t\treturn\n\t}\n\n\tflusher.Flush()\n\n\theartbeat := time.NewTicker(15 * time.Second)\n\tdefer heartbeat.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ch:\n\t\t\tif _, err := fmt.Fprint(w, \"data: {}\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase <-heartbeat.C:\n\t\t\tif _, err := fmt.Fprint(w, \": heartbeat\\n\\n\"); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tflusher.Flush()\n\n\t\tcase <-r.Context().Done():\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (s *Server) render(w http.ResponseWriter, name string, data any) {"}
1 occurrences replaced
{"find":" <div class=\"info-item\">\n <span class=\"label\">Max Live Agents</span>\n <span class=\"value\">{{.Environment.MaxLive}}</span>\n </div>\n </div>\n</div>\n</body>\n</html>\n{{end}}\n\n{{define \"jobs\"}}","path":"internal/web/templates.go","replace":" <div class=\"info-item\">\n <span class=\"label\">Max Live Agents</span>\n <span class=\"value\">{{.Environment.MaxLive}}</span>\n </div>\n </div>\n\n <script>\n // Live dashboard: the server pushes a signal whenever the set of\n // active jobs changes (job created, started, or finished). The page\n // keeps no state of its own, so the right response to any signal is\n // to re-render from scratch β the same pattern the job detail page\n // uses when a run finishes.\n (function () {\n var es = new EventSource(\"/events\");\n\n es.onmessage = function () {\n es.close();\n location.reload();\n };\n })();\n </script>\n</div>\n</body>\n</html>\n{{end}}\n\n{{define \"jobs\"}}"}
1 occurrences replaced
{"end_line":124,"path":"internal/livelog/hub_test.go","start_line":100}
{"path":"internal/livelog/hub_test.go","content":"\tselect {\n\tcase ev := \u003c-ch:\n\t\tt.Fatalf(\"did not expect any live event, got %+v\", ev)\n\tcase \u003c-time.After(50 * time.Millisecond):\n\t}\n}\n\nfunc TestCancelUnregistersSubscriber(t *testing.T) {\n\th := NewHub()\n\n\t_, ch, cancel := h.Subscribe(\"job-1\")\n\tcancel()\n\n\tselect {\n\tcase _, ok := \u003c-ch:\n\t\tif ok {\n\t\t\tt.Fatal(\"expected channel to be closed after cancel\")\n\t\t}\n\tcase \u003c-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for channel close after cancel\")\n\t}\n\n\t// Publishing after cancel must not panic or block.\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n}","start_line":100,"end_line":124,"total_lines":124,"truncated":false}
{"find":"func TestCancelUnregistersSubscriber(t *testing.T) {\n\th := NewHub()\n\n\t_, ch, cancel := h.Subscribe(\"job-1\")\n\tcancel()\n\n\tselect {\n\tcase _, ok := <-ch:\n\t\tif ok {\n\t\t\tt.Fatal(\"expected channel to be closed after cancel\")\n\t\t}\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for channel close after cancel\")\n\t}\n\n\t// Publishing after cancel must not panic or block.\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n}","path":"internal/livelog/hub_test.go","replace":"func TestCancelUnregistersSubscriber(t *testing.T) {\n\th := NewHub()\n\n\t_, ch, cancel := h.Subscribe(\"job-1\")\n\tcancel()\n\n\tselect {\n\tcase _, ok := <-ch:\n\t\tif ok {\n\t\t\tt.Fatal(\"expected channel to be closed after cancel\")\n\t\t}\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for channel close after cancel\")\n\t}\n\n\t// Publishing after cancel must not panic or block.\n\th.Publish(\"job-1\", Event{Type: ContentDelta, Text: \"hi\"})\n}\n\nfunc recvJobsChanged(t *testing.T, ch <-chan struct{}) {\n\tt.Helper()\n\n\tselect {\n\tcase <-ch:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for jobs-changed signal\")\n\t}\n}\n\nfunc TestJobsChangedFansOutToAllSubscribers(t *testing.T) {\n\th := NewHub()\n\n\tch1, cancel1 := h.SubscribeJobsChanged()\n\tdefer cancel1()\n\tch2, cancel2 := h.SubscribeJobsChanged()\n\tdefer cancel2()\n\n\th.JobsChanged()\n\n\trecvJobsChanged(t, ch1)\n\trecvJobsChanged(t, ch2)\n\n\t// A second signal reaches both again.\n\th.JobsChanged()\n\n\trecvJobsChanged(t, ch1)\n\trecvJobsChanged(t, ch2)\n}\n\nfunc TestJobsChangedWithoutSubscribers(t *testing.T) {\n\th := NewHub()\n\n\t// Must not panic or block with nobody listening.\n\th.JobsChanged()\n}\n\nfunc TestJobsChangedCancelUnsubscribes(t *testing.T) {\n\th := NewHub()\n\n\tch, cancel := h.SubscribeJobsChanged()\n\tcancel()\n\n\tselect {\n\tcase _, ok := <-ch:\n\t\tif ok {\n\t\t\tt.Fatal(\"expected channel to be closed after cancel\")\n\t\t}\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timed out waiting for channel close after cancel\")\n\t}\n\n\t// Signaling after cancel must not panic or block on the dead\n\t// subscriber.\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\th.JobsChanged()\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"JobsChanged blocked on a canceled subscriber\")\n\t}\n}"}
1 occurrences replaced
{"end_line":210,"path":"internal/web/web_test.go","start_line":120}
{"path":"internal/web/web_test.go","content":"\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-1\", \"content\", \"\\nNow the remaining tool callers:\\n\\n\\n\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// A block that is nothing but whitespace should not render at all.\n\tif err := st.AppendLog(ctx, \"job-1\", \"content\", \" \\n\\t \"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := st.AppendLog(ctx, \"job-1\", \"reasoning\", \" \\ninner\\nlines\\nkept\\n \"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\n\tbody := r.Body.String()\n\tif !strings.Contains(body, `\u003cdiv class=\"block-body\"\u003eNow the remaining tool callers:\u003c/div\u003e`) {\n\t\tt.Fatalf(\"expected trimmed content block, got: %s\", body)\n\t}\n\tif strings.Contains(body, \"\u003cdiv class=\\\"block-body\\\"\u003e\\n\") {\n\t\tt.Fatalf(\"block body still starts with a newline: %s\", body)\n\t}\n\tif got := strings.Count(body, `class=\"block block-content\"`); got != 1 {\n\t\tt.Fatalf(\"expected exactly one content block (whitespace-only one dropped), got %d: %s\", got, body)\n\t}\n\tif !strings.Contains(body, \"inner\\nlines\\nkept\") {\n\t\tt.Fatalf(\"expected internal newlines to be preserved, got: %s\", body)\n\t}\n}\n\n// TestJobDetailAutoScroll verifies the live job detail page ships the\n// tail-following controls (issue #57): the log box is wrapped so the\n// jump-to-bottom button can float over it, and the streaming script does\n// its scroll math against the log container (the actual scrollable\n// element) instead of the window. A finished job is static and must not\n// carry either the button or the streaming script.\nfunc TestJobDetailAutoScroll(t *testing.T) {\n\ts, st := testServer(t)\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\n\tr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\n\tbody := r.Body.String()\n\tfor _, want := range []string{\n\t\t`class=\"log-wrap\"`,\n\t\t`id=\"log-jump\"`,\n\t\t`log.scrollHeight - log.scrollTop - log.clientHeight`,\n\t\t`log.scrollTop = log.scrollHeight`,\n\t} {\n\t\tif !strings.Contains(body, want) {\n\t\t\tt.Fatalf(\"expected %q in live job detail page, got:\\n%s\", want, body)\n\t\t}\n\t}\n\t// The old window-based scroll math must be gone: it never worked,\n\t// because the log box (not the window) is what scrolls.\n\tfor _, gone := range []string{\n\t\t\"window.scrollY\",\n\t\t\"window.scrollTo\",\n\t} {\n\t\tif strings.Contains(body, gone) {\n\t\t\tt.Fatalf(\"live job detail page still uses window scroll math %q:\\n%s\", gone, body)\n\t\t}\n\t}\n\n\t// A finished job is static: no jump button, no streaming script.\n\tif err := st.MarkJobFinished(ctx, \"job-1\", store.JobSucceeded, \"\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tr = httptest.NewRecorder()\n\ts.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, \"/jobs/job-1\", nil))\n\tif r.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200, got %d: %s\", r.Code, r.Body.String())\n\t}\n\tif b := r.Body.String(); strings.Contains(b, `id=\"log-jump\"`) || strings.Contains(b, \"EventSource\") {\n\t\tt.Fatalf(\"finished job detail page should not carry live streaming controls:\\n%s\", b)\n\t}\n}\n","start_line":120,"end_line":210,"total_lines":310,"truncated":true}
{"find":"func TestAuthGate(t *testing.T) {","path":"internal/web/web_test.go","replace":"// TestIndexLiveEvents verifies the dashboard updates itself without a\n// manual refresh (issue #64): the index page ships the EventSource\n// client, and /events pushes a data event when the job set changes,\n// staying open until the request context ends.\nfunc TestIndexLiveEvents(t *testing.T) {\n\ts, _ := testServer(t)\n\n\trr := httptest.NewRecorder()\n\ts.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, \"/\", nil))\n\tif rr.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected 200 for /, got %d: %s\", rr.Code, rr.Body.String())\n\t}\n\tif !strings.Contains(rr.Body.String(), `EventSource(\"/events\")`) {\n\t\tt.Fatalf(\"index page should open the /events stream, got:\\n%s\", rr.Body.String())\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\treq := httptest.NewRequest(http.MethodGet, \"/events\", nil).WithContext(ctx)\n\trr = httptest.NewRecorder()\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\ts.Handler().ServeHTTP(rr, req)\n\t\tclose(done)\n\t}()\n\n\t// Give the handler a moment to subscribe, then signal a job-set\n\t// change.\n\ttime.Sleep(100 * time.Millisecond)\n\ts.hub.JobsChanged()\n\n\t// Let the event flush, then end the stream.\n\ttime.Sleep(100 * time.Millisecond)\n\tcancel()\n\t<-done\n\n\tbody := rr.Body.String()\n\tif !strings.Contains(rr.Header().Get(\"Content-Type\"), \"text/event-stream\") {\n\t\tt.Fatalf(\"expected text/event-stream, got %q\", rr.Header().Get(\"Content-Type\"))\n\t}\n\tif !strings.Contains(body, \"data: {}\") {\n\t\tt.Fatalf(\"expected a jobs-changed data event, got:\\n%s\", body)\n\t}\n}\n\nfunc TestAuthGate(t *testing.T) {"}
1 occurrences replaced
{"find":"func TestSchedulerReportsFailure(t *testing.T) {","path":"internal/scheduler/scheduler_test.go","replace":"// TestSchedulerSignalsJobChanges verifies the dashboard's live-update\n// signal (issue #64): a single successful run changes the set of active\n// jobs exactly three times β created (pending), started (running),\n// finished (succeeded) β and each transition is broadcast.\nfunc TestSchedulerSignalsJobChanges(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := &fakeForgejo{}\n\trunner := &fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\thub := livelog.NewHub()\n\n\t// Subscribe before the run so no signal can be missed.\n\tch, cancel := hub.SubscribeJobsChanged()\n\tdefer cancel()\n\n\tsched := New(cfg, st, fg, runner, hub, logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents <- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 5}\n\tclose(events)\n\n\tctx, cancelCtx := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancelCtx()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\t// All signals are published before Shutdown returns; drain them,\n\t// stopping after a quiet period.\n\tgot := 0\n\n\tfor {\n\t\tselect {\n\t\tcase <-ch:\n\t\t\tgot++\n\n\t\tcase <-time.After(100 * time.Millisecond):\n\t\t\tgoto drained\n\t\t}\n\t}\n\ndrained:\n\tif got != 3 {\n\t\tt.Fatalf(\"expected 3 jobs-changed signals (created, started, finished), got %d\", got)\n\t}\n}\n\nfunc TestSchedulerReportsFailure(t *testing.T) {"}
1 occurrences replaced
{"command":"cd /project && go build ./... && go vet ./... 2>&1 | head -30","timeout":180}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
{"command":"cd /project && go build ./... && echo BUILD_OK && go vet ./... && echo VET_OK","timeout":300}
BUILD_OK VET_OK
{"command":"cd /project && go test ./internal/livelog/ ./internal/scheduler/ ./internal/web/ -count=1 -race 2>&1 | tail -20","timeout":300}
ok github.com/abrander/zoo/internal/livelog 1.060s ok github.com/abrander/zoo/internal/scheduler 3.917s ok github.com/abrander/zoo/internal/web 3.366s
{"command":"cd /project && go test ./... -count=1 2>&1 | tail -15","timeout":300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.025s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.017s 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.977s ok github.com/abrander/zoo/internal/store 1.790s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.502s
{"end_line":60,"path":"zoo.hcl.example","start_line":1}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\n#\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\n\nllm \"Qwen 3.6\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.6\"\n}\n\nllm \"Qwen 3.8\" {\n openai = \"https://code.stdio.dk\"\n token = \"TOKEN\"\n model = \"Qwen3.8\"\n}\n\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n\n # Which repositories to watch. Events from every other repository\n # are ignored. Each entry is an \"owner/repo\" pair with glob\n # wildcards:\n #\n # repos = [\"acme/*\"] # everything in the acme org\n # repos = [\"acme/widgets\"] # just that one repository\n # repos = [\"acme/web*\"] # repos in acme whose name starts with \"web\"\n # repos = [\"acme\", \"bob\"] # bare names mean \"everything in that org\"\n # repos = [\"*\"] # everything on the instance\n #\n # Owner names match case-insensitively (like Forgejo usernames);\n # repository names match case-sensitively. Leave the list out (or\n # empty) to watch everything the token can see.\n # repos = [\"abrander/*\", \"acme/widgets\"]\n}\n\nenvironment {\n docker_image = \"golang:latest\"\n max_live_agents = 5\n}\n\nagent \"anna\" {\n llm = \"Qwen 3.6\"\n token = \"ANNA_FORGEJO_TOKEN\"\n}\n\nagent \"greg\" {\n llm = \"Qwen 3.8\"\n token = \"GREG_FORGEJO_TOKEN\"\n}\n\n# \"leon\" doubles as the expected Forgejo assignee username for\n# issue:assigned events - see the event block below.\nagent \"leon\" {\n llm = \"Qwen 3.8\"\n token = \"LEON_FORGEJO_TOKEN\"\n}","start_line":1,"end_line":60,"total_lines":98,"truncated":true}
{"context_after":25,"glob":"internal/forgejo/client.go","query":"func NewClient"}
No matches found.
{"context_after":30,"glob":"internal/forgejo/*.go","query":"func NewClient"}
No matches found.
{"context_after":20,"query":"NewClient\\(cfg"}
/project/cmd/zoo/main.go:64: fgClient, err := forgejo.NewClient(cfg.Forgejo)
/project/cmd/zoo/main.go-65- if err != nil {
/project/cmd/zoo/main.go-66- return fmt.Errorf("forgejo client: %w", err)
/project/cmd/zoo/main.go-67- }
/project/cmd/zoo/main.go-68-
/project/cmd/zoo/main.go-69- repoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)
/project/cmd/zoo/main.go-70- if err != nil {
/project/cmd/zoo/main.go-71- return fmt.Errorf("forgejo repos: %w", err)
/project/cmd/zoo/main.go-72- }
/project/cmd/zoo/main.go-73-
/project/cmd/zoo/main.go-74- watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With("component", "forgejo"))
/project/cmd/zoo/main.go-75-
/project/cmd/zoo/main.go-76- hub := livelog.NewHub()
/project/cmd/zoo/main.go-77-
/project/cmd/zoo/main.go-78- runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
/project/cmd/zoo/main.go-79- if err != nil {
/project/cmd/zoo/main.go-80- return fmt.Errorf("agent runner: %w", err)
/project/cmd/zoo/main.go-81- }
/project/cmd/zoo/main.go-82-
/project/cmd/zoo/main.go-83- sched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With("component", "scheduler"))
/project/cmd/zoo/main.go-84- dashboard := web.New(cfg, st, hub, fgClient)
--
/project/internal/llm/client.go:21:func NewClient(cfg config.LLM) *Client {
/project/internal/llm/client.go-22- return &Client{
/project/internal/llm/client.go-23- endpoint: cfg.OpenAI,
/project/internal/llm/client.go-24- token: cfg.Token,
/project/internal/llm/client.go-25- model: cfg.Model,
/project/internal/llm/client.go-26- httpClient: http.DefaultClient,
/project/internal/llm/client.go-27- }
/project/internal/llm/client.go-28-}
/project/internal/llm/client.go-29-
/project/internal/llm/client.go-30-func (c *Client) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*Streamer, error) {
/project/internal/llm/client.go-31- req2 := *req
/project/internal/llm/client.go-32-
/project/internal/llm/client.go-33- if req2.Model == "" {
/project/internal/llm/client.go-34- req2.Model = c.model
/project/internal/llm/client.go-35- }
/project/internal/llm/client.go-36-
/project/internal/llm/client.go-37- req2.Stream = true
/project/internal/llm/client.go-38-
/project/internal/llm/client.go-39- // No seed on purpose: identical requests must be able to sample
/project/internal/llm/client.go-40- // differently, so the agent loop can discard a broken turn and get a
/project/internal/llm/client.go-41- // genuinely new one by asking again.
--
/project/internal/forgejo/client.go:24:func NewClient(cfg config.Forgejo) (*Client, error) {
/project/internal/forgejo/client.go:25: c, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))
/project/internal/forgejo/client.go-26- if err != nil {
/project/internal/forgejo/client.go-27- return nil, fmt.Errorf("forgejo client: %w", err)
/project/internal/forgejo/client.go-28- }
/project/internal/forgejo/client.go-29-
/project/internal/forgejo/client.go-30- return &Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil
/project/internal/forgejo/client.go-31-}
/project/internal/forgejo/client.go-32-
/project/internal/forgejo/client.go-33-// Token returns the shared zoo Forgejo identity's token, e.g. for
/project/internal/forgejo/client.go-34-// authenticating a host-side git clone/push against Forgejo (see
/project/internal/forgejo/client.go-35-// internal/agentrun) without ever writing the credential into a working
/project/internal/forgejo/client.go-36-// tree an agent's container can read.
/project/internal/forgejo/client.go-37-func (c *Client) Token() string {
/project/internal/forgejo/client.go-38- return c.token
/project/internal/forgejo/client.go-39-}
/project/internal/forgejo/client.go-40-
/project/internal/forgejo/client.go-41-// As returns a new Client that authenticates as the given token.
/project/internal/forgejo/client.go-42-// This is used to create per-agent clients so each agent acts as
/project/internal/forgejo/client.go-43-// themselves on Forgejo, without needing a global token with sudo
/project/internal/forgejo/client.go-44-// privileges.
/project/internal/forgejo/client.go-45-func (c *Client) As(token string) *Client {
{"end_line":80,"path":"internal/forgejo/webhook.go","start_line":1}
{"path":"internal/forgejo/webhook.go","content":"package forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n)\n\ntype issuePayload struct {\n\tAction string `json:\"action\"`\n\tIssue *sdk.Issue `json:\"issue\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype issueCommentPayload struct {\n\tAction string `json:\"action\"`\n\tIssue *sdk.Issue `json:\"issue\"`\n\tComment *sdk.Comment `json:\"comment\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype pullRequestPayload struct {\n\tAction string `json:\"action\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype pullRequestReviewPayload struct {\n\tAction string `json:\"action\"`\n\tReview *sdk.PullReview `json:\"review\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\n// WebhookHandler returns the http.Handler to mount at (e.g.)\n// /webhooks/forgejo. If secret is non-empty, deliveries are verified via\n// the SDK's X-Forgejo-Signature middleware; callers should always set a\n// secret for anything reachable off localhost.\nfunc WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tkind := r.Header.Get(\"X-Forgejo-Event\")\n\t\tif kind == \"\" {\n\t\t\tkind = r.Header.Get(\"X-Gitea-Event\")\n\t\t}\n\n\t\tev, ok, err := decodeWebhookEvent(kind, body)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"failed to decode webhook payload\", \"event\", kind, \"error\", err)\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif ok {\n\t\t\temit(ev)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tif secret == \"\" {\n\t\tlogger.Warn(\"forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated\")\n\n\t\treturn handler\n\t}\n\n\treturn sdk.VerifyWebhookSignatureMiddleware(secret)(handler)\n}\n\nfunc decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {","start_line":1,"end_line":80,"total_lines":294,"truncated":true}
{"end_line":98,"path":"zoo.hcl.example","start_line":60}
{"path":"zoo.hcl.example","content":"}\n\nagent \"mika\" {\n llm = \"Qwen 3.6\"\n token = \"MIKA_FORGEJO_TOKEN\"\n}\n\nevent \"issue:new\" {\n agent = \"leon\"\n}\n\nevent \"issue:assigned\" {\n // No `agent` here: the agent whose name matches the Forgejo\n // assignee's username is triggered.\n instructions = \"Please handle this issue.\"\n}\n\nevent \"issue:comment\" {\n agent = \"leon\"\n instructions = \"Please review what this comment says and respond appropriately.\"\n}\n\nevent \"pr:new\" {\n agent = \"greg\"\n instructions = \"Please review this pull request and respond appropriately.\"\n}\n\n# A review was submitted on a pull request (approved, changes requested,\n# or comment). The agent that opened the PR is triggered to handle it,\n# like issue:assigned: its working tree is checked out on the PR's head\n# branch, so changes it pushes update the PR directly. It should address\n# the feedback and reply to the review with a summary of what it\n# addressed. (Forgejo has no API to mark review comments resolved, so\n# that stays with the reviewer.)\nevent \"pr:review\" {\n // No `agent` here: the agent whose name matches the pull request\n // author's username is triggered.\n instructions = \"A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, and reply to the review with a summary of what you addressed.\"\n}","start_line":60,"end_line":98,"total_lines":98,"truncated":false}
{"end_line":160,"path":"internal/forgejo/webhook.go","start_line":80}
{"path":"internal/forgejo/webhook.go","content":"func decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {\n\tswitch kind {\n\tcase \"issues\":\n\t\tvar p issuePayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn issueEvent(p, body)\n\n\tcase \"issue_comment\":\n\t\tvar p issueCommentPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn issueCommentEvent(p, body)\n\n\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tcase \"pull_request_review\":\n\t\tvar p pullRequestReviewPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestReviewEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}\n\nfunc issueEvent(p issuePayload, raw []byte) (Event, bool, error) {\n\tif p.Issue == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\towner := repoOwner(p.Repository)\n\n\tswitch p.Action {\n\tcase \"opened\":\n\t\treturn Event{\n\t\t\tKind: EventIssueNew,\n\t\t\tID: issueNewID(p.Issue.ID),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,\n\t\t\tTitle: p.Issue.Title,\n\t\t\tBody: p.Issue.Body,\n\t\t\tAuthor: posterName(p.Issue.Poster),\n\t\t\tRaw: raw,\n\t\t}, true, nil\n\n\tcase \"assigned\":\n\t\tif len(p.Issue.Assignees) == 0 {\n\t\t\treturn Event{}, false, nil\n\t\t}\n\n\t\t// Webhook payloads only carry the single latest assignment as a\n\t\t// distinct field on some Gitea/Forgejo versions; using the last\n\t\t// entry in the current assignee list is the closest stable\n\t\t// approximation available from the Issue object alone.\n\t\tassignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]\n\n\t\treturn Event{\n\t\t\tKind: EventIssueAssigned,\n\t\t\tID: issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,","start_line":80,"end_line":160,"total_lines":294,"truncated":true}
{"command":"cd /project && find $(go env GOMODCACHE) -path '*forgejo-sdk*' -name 'issue.go' 2>/dev/null | head -3"}
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go
{"command":"cd /project && sed -n '1,80p' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go | grep -n 'json:' | head -40"}
23: HasMerged bool `json:"merged"` 24: Merged *time.Time `json:"merged_at"` 29: ID int64 `json:"id"` 30: Name string `json:"name"` 31: Owner string `json:"owner"` 32: FullName string `json:"full_name"` 37: ID int64 `json:"id"` 38: URL string `json:"url"` 39: HTMLURL string `json:"html_url"` 40: Index int64 `json:"number"` 41: Poster *User `json:"user"` 42: OriginalAuthor string `json:"original_author"` 43: OriginalAuthorID int64 `json:"original_author_id"` 44: Title string `json:"title"` 45: Body string `json:"body"` 46: Ref string `json:"ref"` 47: Labels []*Label `json:"labels"` 48: Milestone *Milestone `json:"milestone"` 49: Assignees []*User `json:"assignees"` 51: State StateType `json:"state"` 52: IsLocked bool `json:"is_locked"` 53: Comments int `json:"comments"` 54: Created time.Time `json:"created_at"` 55: Updated time.Time `json:"updated_at"` 56: Closed *time.Time `json:"closed_at"` 57: Deadline *time.Time `json:"due_date"` 58: PullRequest *PullRequestMeta `json:"pull_request"` 59: Repository *RepositoryMeta `json:"repository"`
{"command":"cd /project && grep -n 'json:\"login\"' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go | head -3"}
23: UserName string `json:"login"`
{"command":"cd /project && go build -o /tmp/zoo-e2e/zoo ./cmd/zoo && mkdir -p /tmp/zoo-e2e && cat > /tmp/zoo-e2e/zoo.hcl <<'EOF'\nllm \"test\" {\n openai = \"http://127.0.0.1:9\"\n token = \"tok\"\n model = \"test\"\n}\n\nforgejo {\n url = \"http://127.0.0.1:9\"\n token = \"tok\"\n}\n\nenvironment {\n docker_image = \"debian:unstable\"\n max_live_agents = 1\n}\n\nagent \"greg\" {\n llm = \"test\"\n token = \"tok\"\n}\n\nevent \"issue:new\" {\n agent = \"greg\"\n}\nEOF\nwhich docker || echo \"no docker\"","timeout":120}
no docker
{"command":"cd /tmp/zoo-e2e && (./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &) && sleep 1.5 && echo \"--- index page (live client check) ---\" && curl -s http://127.0.0.1:18080/ | grep -c 'EventSource(\"/events\")' && echo \"--- /events stream (background) ---\" && (curl -sN http://127.0.0.1:18080/events > events.log 2>&1 &) && sleep 0.5 && echo \"--- POST webhook (issue opened) ---\" && curl -s -o /dev/null -w '%{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":\"acme\"}}'","timeout":60}
ERROR CALLING TOOL: command exited with status 1: --- index page (live client check) --- 0
{"command":"cd /tmp/zoo-e2e && cat daemon.log | head -20 && echo \"---\" && curl -s http://127.0.0.1:18080/ | head -30","timeout":30}
zoo: forgejo client: forgejo client: Get "http://127.0.0.1:9/api/v1/version": dial tcp 127.0.0.1:9: connect: connection refused ---
{"command":"grep -rn 'api/v1/version' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/*.go | head -5"}
{"command":"grep -rn 'version' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/client.go | head -10"}
25: version "github.com/hashicorp/go-version"
30:// Version return the library version
49: serverVersion *version.Version
80: if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
136: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
159: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
{"command":"grep -rn 'func.*[Vv]ersion\\|/version\\|VersionInfo\\|type.*Version' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/*.go | grep -iv 'serverVersion\\|library\\|checkServer' | head -20"}
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/client.go:31:func Version() string {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/package.go:71:func (c *Client) GetPackage(owner, packageType, name, version string) (*Package, *Response, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/package.go:81:func (c *Client) DeletePackage(owner, packageType, name, version string) (*Response, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/package.go:90:func (c *Client) ListPackageFiles(owner, packageType, name, version string) ([]*PackageFile, *Response, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_file.go:269:func (c *Client) setDefaultBranchForOldVersions(owner, repo, branch string) (string, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go:23: resp, err := c.getParsedResponse("GET", "/version", nil, nil, &v)
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go:50:func SetForgejoVersion(v string) ClientOption {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go:82:type ErrUnknownVersion struct {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go:87:func (e *ErrUnknownVersion) Error() string {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go:91:func (*ErrUnknownVersion) Is(target error) bool {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version_test.go:19:func TestVersion(t *testing.T) {
{"command":"sed -n '1,60p' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/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)
})
{"command":"cat > /tmp/zoo-e2e/stub.py <<'EOF'\nimport json\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass H(BaseHTTPRequestHandler):\n def _send(self, code, body=b\"\", ctype=\"application/json\"):\n self.send_response(code)\n self.send_header(\"Content-Type\", ctype)\n self.send_header(\"Content-Length\", str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n\n def do_GET(self):\n if self.path.startswith(\"/api/v1/version\"):\n self._send(200, json.dumps({\"version\": \"1.22.0\"}).encode())\n else:\n self._send(404, b\"{}\")\n\n def do_POST(self):\n n = int(self.headers.get(\"Content-Length\", 0))\n self.rfile.read(n)\n self._send(200, b\"{}\")\n\n def log_message(self, *a):\n pass\n\nHTTPServer((\"127.0.0.1\", 18009), H).serve_forever()\nEOF\npython3 /tmp/zoo-e2e/stub.py & sleep 0.5 && curl -s http://127.0.0.1:18009/api/v1/version","timeout":30}
{"version": "1.22.0"}
{"command":"cd /tmp/zoo-e2e && sed -i 's|http://127.0.0.1:9|http://127.0.0.1:18009|g' zoo.hcl && rm -f zoo.db events.log && (./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &) && sleep 1.5 && echo \"--- index page has live client: $(curl -s http://127.0.0.1:18080/ | grep -c 'EventSource(\"/events\")') ---\" && (curl -sN http://127.0.0.1:18080/events > events.log 2>&1 &) && sleep 0.5 && echo \"--- index before job (active cards): $(curl -s http://127.0.0.1:18080/ | grep -c 'job-card') ---\" && curl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":\"acme\"}}' && sleep 0.3 && echo \"--- index during run (should show E2E job): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && sleep 4 && echo \"--- index after run (job gone): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && echo \"--- /events stream received: ---\" && cat events.log","timeout":60}
--- index page has live client: 1 --- --- index before job (active cards): 13 --- webhook: 400 --- index during run (should show E2E job): 0 --- --- index after run (job gone): 0 --- --- /events stream received: --- : connected
{"command":"cd /tmp/zoo-e2e && tail -5 daemon.log"}
time=2026-08-26T11:40:52.107Z level=WARN msg="forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated" component=forgejo
time=2026-08-26T11:40:52.107Z level=INFO msg=listening addr=127.0.0.1:18080
time=2026-08-26T11:40:53.998Z level=WARN msg="failed to decode webhook payload" component=forgejo event=issues error="json: cannot unmarshal string into Go struct field Repository.repository.owner of type forgejo.User"
time=2026-08-26T11:41:02.110Z level=WARN msg="poll issues failed" component=forgejo error="list issues: 404 Not Found: {}"
time=2026-08-26T11:41:02.111Z level=WARN msg="poll pull requests failed" component=forgejo error="list pull requests: 404 Not Found: {}"
{"command":"cd /tmp/zoo-e2e && curl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}' && sleep 0.3 && echo \"--- index during run (should show E2E job): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && sleep 4 && echo \"--- index after run (job gone): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && echo \"--- /events stream received: ---\" && cat events.log && echo \"--- daemon log (job lifecycle): ---\" && grep -E 'agent run|record job|mark job' daemon.log | tail -6","timeout":60}
webhook: 200
--- index during run (should show E2E job): 0 ---
--- index after run (job gone): 0 ---
--- /events stream received: ---
: connected
: heartbeat
data: {}
data: {}
data: {}
--- daemon log (job lifecycle): ---
time=2026-08-26T11:41:10.704Z level=INFO msg="agent run starting" component=scheduler job=0deeb831-9035-45b0-bbe4-556c7eafba45 agent=greg event=issue:new owner=acme repo=widgets index=42
time=2026-08-26T11:41:10.704Z level=ERROR msg="agent run failed" component=scheduler job=0deeb831-9035-45b0-bbe4-556c7eafba45 agent=greg event=issue:new owner=acme repo=widgets index=42 status=failed error="look up repository: get repo acme/widgets: 404 Not Found: {}"
{"command":"pkill -f 'zoo -config' ; pkill -f stub.py; sleep 0.3; cat > /tmp/zoo-e2e/stub.py <<'EOF'\nimport json, time\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass H(BaseHTTPRequestHandler):\n def _send(self, code, body=b\"\", ctype=\"application/json\"):\n self.send_response(code)\n self.send_header(\"Content-Type\", ctype)\n self.send_header(\"Content-Length\", str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n\n def do_GET(self):\n if self.path.startswith(\"/api/v1/version\"):\n self._send(200, json.dumps({\"version\": \"1.22.0\"}).encode())\n elif self.path.startswith(\"/api/v1/repos/\"):\n time.sleep(3) # hold the job in \"running\" long enough to observe\n self._send(404, b\"{}\")\n else:\n self._send(404, b\"{}\")\n\n def do_POST(self):\n n = int(self.headers.get(\"Content-Length\", 0))\n self.rfile.read(n)\n self._send(200, b\"{}\")\n\n def log_message(self, *a):\n pass\n\nHTTPServer((\"127.0.0.1\", 18009), H).serve_forever()\nEOF\npython3 /tmp/zoo-e2e/stub.py & sleep 0.3 && cd /tmp/zoo-e2e && rm -f zoo.db events.log && (./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &) && sleep 1.5 && (curl -sN http://127.0.0.1:18080/events > events.log 2>&1 &) && sleep 0.5 && curl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}' && sleep 1 && echo \"--- index during run (should show E2E job): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && sleep 5 && echo \"--- index after run (job gone): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && echo \"--- /events stream received: ---\" && grep -c 'data: {}' events.log","timeout":90}
ERROR CALLING TOOL: command exited with status 143
{"command":"pgrep -af 'zoo-e2e' | grep -v pgrep; echo \"---\"; pgrep -af 'stub.p[y]'"}
8747 python3 /tmp/zoo-e2e/stub.py --- 8747 python3 /tmp/zoo-e2e/stub.py
{"command":"pkill -f 'stub.p[y]'; sleep 0.3; python3 /tmp/zoo-e2e/stub.py & sleep 0.3 && cd /tmp/zoo-e2e && rm -f zoo.db events.log && (./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &) && sleep 1.5 && (curl -sN http://127.0.0.1:18080/events > events.log 2>&1 &) && sleep 0.5 && curl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}' && sleep 1 && echo \"--- index during run (should show E2E job): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && sleep 5 && echo \"--- index after run (job gone): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue') ---\" && echo \"--- /events data events: $(grep -c 'data: {}' events.log) ---\"","timeout":90}
ERROR CALLING TOOL: command exited with status 143
{"command":"kill 8747 2>/dev/null; sleep 0.3; pgrep -af stub || echo \"stub stopped\""}
8834 sh -c kill 8747 2>/dev/null; sleep 0.3; pgrep -af stub || echo "stub stopped"
{"command":"cat > /tmp/zoo-e2e/run.sh <<'EOF'\n#!/bin/bash\ncd /tmp/zoo-e2e\npython3 stub.py &\nSTUB=$!\nsleep 0.3\nrm -f zoo.db events.log\n./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &\nDAEMON=$!\nsleep 1.5\ncurl -sN http://127.0.0.1:18080/events > events.log 2>&1 &\nSSE=$!\nsleep 0.5\ncurl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo \\\n -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' \\\n -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}'\nsleep 1\necho \"index during run (want >=1): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue')\"\nsleep 5\necho \"index after run (want 0): $(curl -s http://127.0.0.1:18080/ | grep -c 'E2E test issue')\"\necho \"events data count (want 3): $(grep -c 'data: {}' events.log)\"\nkill $DAEMON $STUB $SSE 2>/dev/null\nEOF\nchmod +x /tmp/zoo-e2e/run.sh && /tmp/zoo-e2e/run.sh","timeout":90}
webhook: 200 index during run (want >=1): 0 index after run (want 0): 0 events data count (want 3): 3
{"command":"cd /tmp/zoo-e2e && grep -E 'agent run|record job' daemon.log && echo '---' && grep -c 'E2E' daemon.log"}
ERROR CALLING TOOL: command exited with status 1: time=2026-08-26T11:42:35.559Z level=INFO msg="agent run starting" component=scheduler job=8fe7bfc9-8331-491d-a414-460791e7a4b2 agent=greg event=issue:new owner=acme repo=widgets index=42
time=2026-08-26T11:42:35.559Z level=ERROR msg="agent run failed" component=scheduler job=8fe7bfc9-8331-491d-a414-460791e7a4b2 agent=greg event=issue:new owner=acme repo=widgets index=42 status=failed error="look up repository: get repo acme/widgets: 404 Not Found: {}"
---
0
{"command":"grep -c 'time.sleep' /tmp/zoo-e2e/stub.py"}
ERROR CALLING TOOL: command exited with status 1: 0
{"command":"cat > /tmp/zoo-e2e/stub.py <<'EOF'\nimport json, time\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass H(BaseHTTPRequestHandler):\n def _send(self, code, body=b\"\", ctype=\"application/json\"):\n self.send_response(code)\n self.send_header(\"Content-Type\", ctype)\n self.send_header(\"Content-Length\", str(len(body)))\n self.end_headers()\n self.wfile.write(body)\n\n def do_GET(self):\n if self.path.startswith(\"/api/v1/version\"):\n self._send(200, json.dumps({\"version\": \"1.22.0\"}).encode())\n elif self.path.startswith(\"/api/v1/repos/\"):\n time.sleep(3)\n self._send(404, b\"{}\")\n else:\n self._send(404, b\"{}\")\n\n def do_POST(self):\n n = int(self.headers.get(\"Content-Length\", 0))\n self.rfile.read(n)\n self._send(200, b\"{}\")\n\n def log_message(self, *a):\n pass\n\nHTTPServer((\"127.0.0.1\", 18009), H).serve_forever()\nEOF\ngrep -c 'time.sleep' /tmp/zoo-e2e/stub.py && /tmp/zoo-e2e/run.sh","timeout":90}
1 webhook: 200 index during run (want >=1): 0 index after run (want 0): 0 events data count (want 3): 3
{"command":"cd /tmp/zoo-e2e && grep -E 'agent run' daemon.log"}
time=2026-08-26T11:43:16.589Z level=INFO msg="agent run starting" component=scheduler job=004140e9-8519-4949-bb6d-976a25850b81 agent=greg event=issue:new owner=acme repo=widgets index=42
time=2026-08-26T11:43:19.589Z level=ERROR msg="agent run failed" component=scheduler job=004140e9-8519-4949-bb6d-976a25850b81 agent=greg event=issue:new owner=acme repo=widgets index=42 status=failed error="look up repository: get repo acme/widgets: 404 Not Found: {}"
{"command":"cat > /tmp/zoo-e2e/debug.sh <<'EOF'\n#!/bin/bash\ncd /tmp/zoo-e2e\npython3 stub.py &\nSTUB=$!\nsleep 0.3\nrm -f zoo.db events.log\n./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &\nDAEMON=$!\nsleep 1.5\ncurl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo \\\n -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' \\\n -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}'\nsleep 1\necho '=== index during run (job cards section) ==='\ncurl -s http://127.0.0.1:18080/ | sed -n '/Running Jobs/,/LLMs/p' | head -30\nkill $DAEMON $STUB 2>/dev/null\nEOF\nchmod +x /tmp/zoo-e2e/debug.sh && /tmp/zoo-e2e/debug.sh","timeout":90}
webhook: 200
=== index during run (job cards section) ===
<h2>Running Jobs</h2>
<div class="job-cards">
<div class="job-card">
<div class="job-card-header">
<span class="badge badge-running">
<span class="dot"></span>
running
</span>
<a href="/jobs/cafdffef-a2a3-4c4b-8f59-8ebfadb66db4" class="job-card-link" title="View job details">β</a>
</div>
<div class="job-card-body">
<h3 class="job-card-title">
Issue #42
</h3>
<p class="job-card-meta">
<code>acme/widgets#42</code>
</p>
<div class="job-card-agent">
<span class="job-card-agent-name">greg</span>
</div>
</div>
</div>
</div>
<h2>LLMs</h2>
{"end_line":200,"path":"internal/forgejo/webhook.go","start_line":100}
{"path":"internal/forgejo/webhook.go","content":"\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tcase \"pull_request_review\":\n\t\tvar p pullRequestReviewPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestReviewEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}\n\nfunc issueEvent(p issuePayload, raw []byte) (Event, bool, error) {\n\tif p.Issue == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\towner := repoOwner(p.Repository)\n\n\tswitch p.Action {\n\tcase \"opened\":\n\t\treturn Event{\n\t\t\tKind: EventIssueNew,\n\t\t\tID: issueNewID(p.Issue.ID),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,\n\t\t\tTitle: p.Issue.Title,\n\t\t\tBody: p.Issue.Body,\n\t\t\tAuthor: posterName(p.Issue.Poster),\n\t\t\tRaw: raw,\n\t\t}, true, nil\n\n\tcase \"assigned\":\n\t\tif len(p.Issue.Assignees) == 0 {\n\t\t\treturn Event{}, false, nil\n\t\t}\n\n\t\t// Webhook payloads only carry the single latest assignment as a\n\t\t// distinct field on some Gitea/Forgejo versions; using the last\n\t\t// entry in the current assignee list is the closest stable\n\t\t// approximation available from the Issue object alone.\n\t\tassignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]\n\n\t\treturn Event{\n\t\t\tKind: EventIssueAssigned,\n\t\t\tID: issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,\n\t\t\tTitle: p.Issue.Title,\n\t\t\tBody: p.Issue.Body,\n\t\t\tAuthor: posterName(p.Issue.Poster),\n\t\t\tAssignee: assignee.UserName,\n\t\t\tRaw: raw,\n\t\t}, true, nil\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}\n\nfunc issueCommentEvent(p issueCommentPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"created\" || p.Issue == nil || p.Comment == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\t// Comments on pull requests arrive on this same event in\n\t// Gitea/Forgejo (PRs are issues under the hood); pr:comment is out\n\t// of scope for v1.\n\tif p.Issue.PullRequest != nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind: EventIssueComment,\n\t\tID: issueCommentID(p.Comment.ID),\n\t\tOwner: repoOwner(p.Repository),\n\t\tRepo: p.Repository.Name,\n\t\tIndex: p.Issue.Index,\n\t\tTitle: p.Issue.Title,\n\t\tBody: p.Issue.Body,\n\t\tAuthor: posterName(p.Comment.Poster),\n\t\tComment: p.Comment.Body,\n\t\tRaw: raw,\n\t}, true, nil\n}\n\nfunc pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {","start_line":100,"end_line":200,"total_lines":294,"truncated":true}
{"command":"cd /tmp/zoo-e2e && python3 -c \"\nimport sqlite3\ndb = sqlite3.connect('zoo.db')\nfor row in db.execute('SELECT id, title, status FROM jobs'):\n print(row)\n\""}
('cafdffef-a2a3-4c4b-8f59-8ebfadb66db4', '', 'failed')
{"command":"sed -n '30,70p' /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go"}
Name string `json:"name"`
Owner string `json:"owner"`
FullName string `json:"full_name"`
}
// Issue represents an issue in a repository
type Issue struct {
ID int64 `json:"id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
Index int64 `json:"number"`
Poster *User `json:"user"`
OriginalAuthor string `json:"original_author"`
OriginalAuthorID int64 `json:"original_author_id"`
Title string `json:"title"`
Body string `json:"body"`
Ref string `json:"ref"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Assignees []*User `json:"assignees"`
// Whether the issue is open or closed
State StateType `json:"state"`
IsLocked bool `json:"is_locked"`
Comments int `json:"comments"`
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
Closed *time.Time `json:"closed_at"`
Deadline *time.Time `json:"due_date"`
PullRequest *PullRequestMeta `json:"pull_request"`
Repository *RepositoryMeta `json:"repository"`
}
// ListIssueOption list issue options
type ListIssueOption struct {
ListOptions
State StateType
Type IssueType
Labels []string
Milestones []string
KeyWord string
Since time.Time
{"context_after":15,"glob":"internal/store/store.go","query":"func \\(s \\*Store\\) CreateJob"}
No matches found.
{"end_line":280,"path":"internal/store/store.go","start_line":184}
{"path":"internal/store/store.go","content":"type Job struct {\n\tID string\n\tEventKind string\n\tAgent string\n\tOwner string\n\tRepo string\n\tIssueIndex int64\n\tTitle string\n\tStatus JobStatus\n\tCreatedAt time.Time\n\tStartedAt sql.NullTime\n\tFinishedAt sql.NullTime\n\tError string\n}\n\n// CreateJob inserts a new job in JobPending status.\nfunc (s *Store) CreateJob(ctx context.Context, j Job) error {\n\tif j.Status == \"\" {\n\t\tj.Status = JobPending\n\t}\n\n\tif j.CreatedAt.IsZero() {\n\t\tj.CreatedAt = time.Now().UTC()\n\t}\n\n\t_, err := s.db.ExecContext(ctx,\n\t\t`INSERT INTO jobs (id, event_kind, agent, owner, repo, issue_index, status, created_at, error)\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')`,\n\t\tj.ID, j.EventKind, j.Agent, j.Owner, j.Repo, j.IssueIndex, j.Status, j.CreatedAt)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create job: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ReapOrphanedJobs transitions any job still in JobRunning status to\n// JobFailed. It's meant to be called once at daemon startup: a job left\n// \"running\" in the database can only be one abandoned by a previous\n// process instance that died (crash, OOM kill, host restart) before it\n// could record a terminal status β no process in the current instance is\n// running it. Returns the number of jobs reaped.\nfunc (s *Store) ReapOrphanedJobs(ctx context.Context) (int64, error) {\n\tres, err := s.db.ExecContext(ctx,\n\t\t`UPDATE jobs SET status = ?, finished_at = ?, error = ? WHERE status = ?`,\n\t\tJobFailed, time.Now().UTC(), \"orphaned: process restarted while job was running\", JobRunning)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"reap orphaned jobs: %w\", err)\n\t}\n\n\tn, err := res.RowsAffected()\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"reap orphaned jobs: %w\", err)\n\t}\n\n\treturn n, nil\n}\n\n// MarkJobStarted transitions a job to JobRunning and records the start time.\nfunc (s *Store) MarkJobStarted(ctx context.Context, id string) error {\n\t_, err := s.db.ExecContext(ctx,\n\t\t`UPDATE jobs SET status = ?, started_at = ? WHERE id = ?`,\n\t\tJobRunning, time.Now().UTC(), id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"mark job started: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// MarkJobFinished transitions a job to a terminal status and records the\n// finish time plus an optional error message.\nfunc (s *Store) MarkJobFinished(ctx context.Context, id string, status JobStatus, errMsg string) error {\n\t_, err := s.db.ExecContext(ctx,\n\t\t`UPDATE jobs SET status = ?, finished_at = ?, error = ? WHERE id = ?`,\n\t\tstatus, time.Now().UTC(), errMsg, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"mark job finished: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetJob returns a single job by id.\nfunc (s *Store) GetJob(ctx context.Context, id string) (Job, error) {\n\tvar j Job\n\n\terr := s.db.QueryRowContext(ctx,\n\t\t`SELECT id, event_kind, agent, owner, repo, issue_index, status, created_at, started_at, finished_at, error\n\t\t FROM jobs WHERE id = ?`, id).Scan(\n\t\t\u0026j.ID, \u0026j.EventKind, \u0026j.Agent, \u0026j.Owner, \u0026j.Repo, \u0026j.IssueIndex, \u0026j.Status,\n\t\t\u0026j.CreatedAt, \u0026j.StartedAt, \u0026j.FinishedAt, \u0026j.Error)\n\tif err != nil {\n\t\treturn Job{}, fmt.Errorf(\"get job: %w\", err)\n\t}\n\n\treturn j, nil","start_line":184,"end_line":280,"total_lines":360,"truncated":true}
{"end_line":60,"path":"internal/store/schema.sql","start_line":1}
{"path":"internal/store/schema.sql","content":"CREATE TABLE IF NOT EXISTS watermark (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS seen_events (\n id TEXT PRIMARY KEY,\n seen_at DATETIME NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n event_kind TEXT NOT NULL,\n agent TEXT NOT NULL,\n owner TEXT NOT NULL,\n repo TEXT NOT NULL,\n issue_index INTEGER NOT NULL,\n title TEXT NOT NULL DEFAULT '',\n status TEXT NOT NULL,\n created_at DATETIME NOT NULL,\n started_at DATETIME,\n finished_at DATETIME,\n error TEXT NOT NULL DEFAULT ''\n);\n\nCREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at DESC);\n\nCREATE TABLE IF NOT EXISTS job_logs (\n job_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n stream TEXT NOT NULL,\n line TEXT NOT NULL,\n ts DATETIME NOT NULL,\n PRIMARY KEY (job_id, seq)\n);\n\n-- Last-known assignee set per issue, used by the poller to tell a real\n-- (re-)assignment apart from any other update to an already-assigned\n-- issue. Webhooks say \"assigned\" outright; polling only ever sees state.\nCREATE TABLE IF NOT EXISTS issue_assignees (\n issue_id INTEGER NOT NULL,\n assignee TEXT NOT NULL,\n PRIMARY KEY (issue_id, assignee)\n);","start_line":1,"end_line":44,"total_lines":44,"truncated":false}
{"command":"cat > /tmp/zoo-e2e/run.sh <<'EOF'\n#!/bin/bash\ncd /tmp/zoo-e2e\npython3 stub.py &\nSTUB=$!\nsleep 0.3\nrm -f zoo.db events.log\n./zoo -config zoo.hcl -db zoo.db -listen 127.0.0.1:18080 > daemon.log 2>&1 &\nDAEMON=$!\nsleep 1.5\ncurl -sN http://127.0.0.1:18080/events > events.log 2>&1 &\nSSE=$!\nsleep 0.5\ncurl -s -o /dev/null -w 'webhook: %{http_code}\\n' -X POST http://127.0.0.1:18080/webhooks/forgejo \\\n -H 'X-Forgejo-Event: issues' -H 'Content-Type: application/json' \\\n -d '{\"action\":\"opened\",\"issue\":{\"id\":1,\"number\":42,\"title\":\"E2E test issue\",\"body\":\"\",\"user\":{\"login\":\"abrander\"}},\"repository\":{\"id\":1,\"name\":\"widgets\",\"owner\":{\"login\":\"acme\"}}}'\nsleep 1\necho \"index during run, job card present (want 1): $(curl -s http://127.0.0.1:18080/ | grep -c 'acme/widgets#42')\"\necho \"index during run, badge (want running): $(curl -s http://127.0.0.1:18080/ | grep -c 'badge-running')\"\nsleep 5\necho \"index after run, job card present (want 0): $(curl -s http://127.0.0.1:18080/ | grep -c 'acme/widgets#42')\"\necho \"events data count (want 3): $(grep -c 'data: {}' events.log)\"\nkill $DAEMON $STUB $SSE 2>/dev/null\nEOF\n/tmp/zoo-e2e/run.sh","timeout":90}
webhook: 200 index during run, job card present (want 1): 1 index during run, badge (want running): 3 index after run, job card present (want 0): 0 events data count (want 3): 3
{"command":"cd /project && gofmt -l . && go test ./... -count=1 -race 2>&1 | tail -12","timeout":300}
internal/livelog/hub.go ? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 1.056s ok github.com/abrander/zoo/internal/config 1.021s ok github.com/abrander/zoo/internal/forgejo 1.872s ok github.com/abrander/zoo/internal/livelog 1.060s ok github.com/abrander/zoo/internal/llm 1.015s ok github.com/abrander/zoo/internal/scheduler 3.790s ok github.com/abrander/zoo/internal/store 2.781s ok github.com/abrander/zoo/internal/tools 1.015s ok github.com/abrander/zoo/internal/web 2.611s
{"command":"cd /project && gofmt -w internal/livelog/hub.go && gofmt -l . && echo FMT_CLEAN && git diff --stat"}
FMT_CLEAN internal/livelog/hub.go | 66 +++++++++++++++++++++++++++++++--- internal/livelog/hub_test.go | 68 ++++++++++++++++++++++++++++++++++++ internal/scheduler/scheduler.go | 14 ++++++++ internal/scheduler/scheduler_test.go | 48 +++++++++++++++++++++++++ internal/web/templates.go | 16 +++++++++ internal/web/web.go | 56 +++++++++++++++++++++++++++++ internal/web/web_test.go | 47 +++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 5 deletions(-)
{"command":"cd /project && git diff internal/livelog/hub.go internal/scheduler/scheduler.go"}
diff --git a/internal/livelog/hub.go b/internal/livelog/hub.go
index 4342250..d4d19f9 100644
--- a/internal/livelog/hub.go
+++ b/internal/livelog/hub.go
@@ -2,7 +2,9 @@
// fine-grained, not-yet-persisted events (reasoning/content deltas, tool
// calls) for a running job, and lets the web dashboard subscribe to them
// live over SSE. It carries only the tail of a run that hasn't made it
-// into SQLite yet β see Hub.Checkpoint.
+// into SQLite yet β see Hub.Checkpoint. It also broadcasts a global
+// jobs-changed signal (Hub.JobsChanged) so the dashboard can refresh
+// itself when the set of active jobs changes.
package livelog
import "sync"
@@ -48,14 +50,20 @@ type stream struct {
subs map[chan Event]struct{}
}
-// Hub fans out per-job Events to any number of live subscribers.
+// Hub fans out per-job Events to any number of live subscribers, and
+// broadcasts a global jobs-changed signal to any number of global
+// subscribers.
type Hub struct {
- mu sync.Mutex
- jobs map[string]*stream
+ mu sync.Mutex
+ jobs map[string]*stream
+ jobsSubs map[chan struct{}]struct{}
}
func NewHub() *Hub {
- return &Hub{jobs: make(map[string]*stream)}
+ return &Hub{
+ jobs: make(map[string]*stream),
+ jobsSubs: make(map[chan struct{}]struct{}),
+ }
}
func (h *Hub) get(jobID string) *stream {
@@ -139,6 +147,54 @@ func (h *Hub) Subscribe(jobID string) (buffered []Event, ch <-chan Event, cancel
return buffered, c, cancelFn
}
+// JobsChanged signals every global subscriber that the set of active
+// jobs may have changed (a job was created, started, or finished). The
+// signal carries no state: subscribers are expected to re-read the
+// source of truth (the store) and re-render. Never blocks: a subscriber
+// whose channel is full simply misses this signal and stays stale until
+// the next one.
+func (h *Hub) JobsChanged() {
+ h.mu.Lock()
+ subs := make([]chan struct{}, 0, len(h.jobsSubs))
+ for ch := range h.jobsSubs {
+ subs = append(subs, ch)
+ }
+ h.mu.Unlock()
+
+ for _, ch := range subs {
+ select {
+ case ch <- struct{}{}:
+ default:
+ }
+ }
+}
+
+// SubscribeJobsChanged returns a channel that receives a value every
+// time JobsChanged is called, plus a cancel func that must be called
+// when the caller is done reading, to unregister and close the channel.
+func (h *Hub) SubscribeJobsChanged() (ch <-chan struct{}, cancel func()) {
+ h.mu.Lock()
+ c := make(chan struct{}, subChanBuffer)
+ h.jobsSubs[c] = struct{}{}
+ h.mu.Unlock()
+
+ var once sync.Once
+
+ cancelFn := func() {
+ once.Do(func() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if _, ok := h.jobsSubs[c]; ok {
+ delete(h.jobsSubs, c)
+ close(c)
+ }
+ })
+ }
+
+ return c, cancelFn
+}
+
// Close publishes a terminal Status event, disconnects every current
// subscriber, and drops jobID's state. Call this exactly once, when the
// job's run has finished.
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index 73a2fbb..6bc26ce 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -149,6 +149,9 @@ func (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {
return
}
+ // A new pending job is now on the dashboard; tell live viewers.
+ s.hub.JobsChanged()
+
select {
case s.sem <- struct{}{}:
@@ -176,6 +179,10 @@ func (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentCon
// has exited.
if err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {
logger.Error("failed to mark job started", "error", err)
+ } else {
+ // pending β running changes the dashboard's badge; tell live
+ // viewers.
+ s.hub.JobsChanged()
}
logger.Info("agent run starting")
@@ -205,6 +212,13 @@ func (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentCon
if err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {
logger.Error("failed to mark job finished", "error", err)
+ } else {
+ // The job has left the dashboard's active list; tell live
+ // viewers. This must happen after the terminal status is
+ // durably written, for the same reason as the hub Close below:
+ // a viewer's reload has to see the finished status, not a job
+ // that is still "running" in the store.
+ s.hub.JobsChanged()
}
// Closing the hub here, only after the terminal status is durably
{"command":"cd /project && git diff internal/web/web.go internal/web/templates.go"}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index d893bf2..9ab7ff0 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -787,6 +787,22 @@ const templates = `
<span class="value">{{.Environment.MaxLive}}</span>
</div>
</div>
+
+ <script>
+ // Live dashboard: the server pushes a signal whenever the set of
+ // active jobs changes (job created, started, or finished). The page
+ // keeps no state of its own, so the right response to any signal is
+ // to re-render from scratch β the same pattern the job detail page
+ // uses when a run finishes.
+ (function () {
+ var es = new EventSource("/events");
+
+ es.onmessage = function () {
+ es.close();
+ location.reload();
+ };
+ })();
+ </script>
</div>
</body>
</html>
diff --git a/internal/web/web.go b/internal/web/web.go
index 55ab97f..c97b950 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -59,6 +59,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /jobs", s.handleJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleJobDetail)
mux.HandleFunc("GET /jobs/{id}/events", s.handleJobEvents)
+ mux.HandleFunc("GET /events", s.handleEvents)
return s.authMiddleware(mux)
}
@@ -357,6 +358,61 @@ func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
}
}
+// handleEvents streams job-set changes as Server-Sent Events: the
+// dashboard page subscribes once and reloads itself whenever a job is
+// created, started, or finished, so new jobs appear (and finished ones
+// disappear) without a manual refresh. Like handleJobEvents, it carries
+// no state of its own β the reload re-renders the page from the store,
+// which is the single source of truth.
+func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.WriteHeader(http.StatusOK)
+
+ ch, cancel := s.hub.SubscribeJobsChanged()
+ defer cancel()
+
+ // A comment, not a data event: it confirms the stream is open
+ // without making the client reload on connect.
+ if _, err := fmt.Fprint(w, ": connected\n\n"); err != nil {
+ return
+ }
+
+ flusher.Flush()
+
+ heartbeat := time.NewTicker(15 * time.Second)
+ defer heartbeat.Stop()
+
+ for {
+ select {
+ case <-ch:
+ if _, err := fmt.Fprint(w, "data: {}\n\n"); err != nil {
+ return
+ }
+
+ flusher.Flush()
+
+ case <-heartbeat.C:
+ if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
+ return
+ }
+
+ flusher.Flush()
+
+ case <-r.Context().Done():
+ return
+ }
+ }
+}
+
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
{"command":"cd /project && git add -A && git commit -m \"web: make the dashboard at / live via a jobs-changed SSE stream\n\nNew jobs didn't appear (or disappear) on the dashboard until the user\nrefreshed. The job detail page already solved this for a single job\n(SSE + reload on terminal status), so extend the same pattern to the\njob set as a whole:\n\n- livelog.Hub: add a global jobs-changed broadcast\n (JobsChanged/SubscribeJobsChanged), non-blocking like the per-job\n streams.\n- scheduler: signal after each dashboard-visible transition β job\n created (pending), started (running), and finished (after the\n terminal status is durably written, so a viewer's reload sees the\n finished job, mirroring the existing hub.Close ordering).\n- web: new GET /events SSE endpoint; the index page opens an\n EventSource and reloads on any signal. The page keeps no state of\n its own, so a full re-render from the store is always correct.\" && git log --oneline -2"}
[issue-64-greg bf26c51] web: make the dashboard at / live via a jobs-changed SSE stream 7 files changed, 310 insertions(+), 5 deletions(-) bf26c51 web: make the dashboard at / live via a jobs-changed SSE stream 081d4bd Merge pull request 'livelog: open reasoning/content blocks lazily, on first real text' (#63) from issue-62-greg into main
{"command":"cd /project && git push origin issue-64-greg 2>&1 | tail -3","timeout":60}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-64-greg -> issue-64-greg
{"base":"main","body":"## What\n\nThe dashboard at `/` was static: new jobs didn't appear (or disappear) until a manual refresh (issue #64). This makes it live using the same SSE + reload pattern the job detail page already uses.\n\n## How\n\n- **`livelog.Hub`** β new global jobs-changed broadcast (`JobsChanged` / `SubscribeJobsChanged`), non-blocking fan-out exactly like the existing per-job streams.\n- **`scheduler`** β signals after each dashboard-visible transition:\n - job created (appears as `pending`),\n - job started (`pending β running` badge change),\n - job finished (leaves the active list) β deliberately *after* `MarkJobFinished` is durably written, so a viewer's reload sees the finished status (same ordering rationale as the existing `hub.Close`).\n- **`web`** β new `GET /events` SSE endpoint (15 s heartbeat, `: connected` comment on open). The index page opens an `EventSource` and does `location.reload()` on any signal. The page keeps no client-side state, so a full re-render from the store is always correct β no DOM patching to keep in sync.\n\n## Verification\n\n- `go build`, `go vet`, `gofmt` clean; `go test ./... -race` all green.\n- New tests: hub fan-out/cancel semantics, scheduler emits exactly 3 signals for one successful run (created/started/finished), `/events` streams a data event on change and the index page ships the client.\n- End-to-end with the real binary + a stub Forgejo: fired an `issues/opened` webhook and watched β job card appeared on `/` with a `running` badge within a second, disappeared when the run finished, and the `/events` stream delivered exactly 3 signals.\n\n## Notes\n\n- Pre-existing, unrelated: `store.CreateJob` never persists `Job.Title` (the column exists but the INSERT/SELECTs omit it), so job cards fall back to \"Issue #N\". Happy to fix in a follow-up if wanted.\n- With a `web.token` set, `EventSource` can't send an `Authorization` header, so live updates (like the existing job-detail stream) only work without a token β pre-existing limitation, unchanged.","head":"issue-64-greg","title":"Live dashboard at /: jobs-changed SSE stream"}
Pull request opened
{"body":"Done β the dashboard at `/` is now live. PR opened against `main`.\n\n**How it works:** the job detail page already solved \"live\" for a single job (SSE + reload on terminal status), so I extended the same pattern to the job set as a whole:\n\n1. `livelog.Hub` gained a global jobs-changed broadcast (`JobsChanged`/`SubscribeJobsChanged`) β non-blocking fan-out, same semantics as the existing per-job streams.\n2. The scheduler signals after each dashboard-visible transition: job **created** (appears as `pending`), job **started** (badge flips to `running`), job **finished** (leaves the active list β signaled only after the terminal status is durably written, so a viewer's reload never sees a stale \"running\").\n3. New `GET /events` SSE endpoint; the index page opens an `EventSource` and reloads on any signal. The page keeps no client-side state, so a full re-render from the store is always correct β no DOM patching to drift out of sync.\n\n**Verified end-to-end** with the real binary and a stub Forgejo: fired an `issues/opened` webhook, the job card appeared on `/` with a `running` badge within a second, disappeared when the run finished, and the stream delivered exactly the 3 lifecycle signals. `go test ./... -race` is green, including new tests for the hub fan-out, the scheduler's 3-signal lifecycle, and the `/events` endpoint.\n\nTwo observations, both pre-existing and left alone (happy to take them as follow-ups):\n- `store.CreateJob` never persists `Job.Title` (the column exists but the INSERT/SELECTs omit it), so job cards fall back to \"Issue #N\" instead of showing the issue title.\n- With a `web.token` set, `EventSource` can't send an `Authorization` header, so live updates only work without a token β same limitation the job-detail stream already has."}
Comment posted