Status succeeded
Event issue:assigned on abrander/zoo#35
Agent greg greg

Log

Thinking
Let's start by understanding the task. This is a discussion-type issue about Slack integration, and leon has assigned it to me (greg, the senior developer/architect) to provide a technical estimate regarding scope and effort. The issue has the "discussion" label attached. Therefore, the main deliverable is a comment that provides a solid technical estimate based on the current architecture. To make the estimate grounded in reality, I should first check the repository to understand the codebase. Let's explore the project.
Tool πŸ”§ list_files {"path": ".", "recursive": false}
{"path": ".", "recursive": false}
drwxrwxr-x  5 1000 1000  4096 Aug 23 22:16 .
drwxr-xr-x  1 root root  4096 Aug 23 22:16 ..
drwxrwxr-x  8 1000 1000  4096 Aug 23 22:16 .git
-rw-rw-r--  1 1000 1000    22 Aug 23 22:16 .gitignore
drwxrwxr-x  3 1000 1000  4096 Aug 23 22:16 cmd
-rw-rw-r--  1 1000 1000  2628 Aug 23 22:16 go.mod
-rw-rw-r--  1 1000 1000 16578 Aug 23 22:16 go.sum
drwxrwxr-x 11 1000 1000  4096 Aug 23 22:16 internal
-rw-rw-r--  1 1000 1000  1564 Aug 23 22:16 zoo.hcl.example
Tool πŸ”§ bash {"command": "cd /project && git log --oneline -10 && git status"}
{"command": "cd /project && git log --oneline -10 && git status"}
c8784fa (HEAD -> issue-35-greg, origin/main, origin/HEAD, main) Merge pull request 'fix(tools): make grep_search actually use regex (ERE)' (#32) from issue-31-greg into main
2c5b978 fix(tools): make grep_search actually use regex (ERE)
ea073a3 Merge pull request 'Add job overview on dashboard' (#26) from issue-24-anna into main
3b38675 feat(dashboard): add job overview on dashboard
1c33680 Merge pull request 'Move max_live_agents into the environment block' (#23) from issue-22-mika into main
7cf22ff Move max_live_agents into the environment block
94b8ca1 Merge pull request 'Add per-agent Forgejo tokens, deprecate Sudo: header approach' (#21) from issue-16-mika into main
f8c45dc Merge pull request 'web: show agent profile photos on the jobs pages' (#20) from issue-17-greg into main
4a31b24 Add per-agent Forgejo tokens, deprecate Sudo: header approach
e1bf291 web: show agent profile photos on the jobs pages
On branch issue-35-greg
nothing to commit, working tree clean
Thinking
This is a Go project called "zoo" β€” a system that launches agents from Forgejo events. Let's explore the structure to understand the architecture so that I can provide a solid technical estimate for the Slack integration.
Tool πŸ”§ list_files {"path": "internal", "recursive": true}
{"path": "internal", "recursive": true}
/project/internal/agentrun
/project/internal/agentrun/clone.go
/project/internal/agentrun/context.go
/project/internal/agentrun/docker.go
/project/internal/agentrun/docker_integration_test.go
/project/internal/agentrun/forgejo.go
/project/internal/agentrun/loop.go
/project/internal/agentrun/roster.go
/project/internal/agentrun/roster_test.go
/project/internal/agentrun/run.go
/project/internal/agentrun/seed_test.go
/project/internal/agentrun/system.md
/project/internal/config
/project/internal/config/config.go
/project/internal/config/config_test.go
/project/internal/forgejo
/project/internal/forgejo/client.go
/project/internal/forgejo/event.go
/project/internal/forgejo/poll.go
/project/internal/forgejo/watcher.go
/project/internal/forgejo/webhook.go
/project/internal/forgejo/webhook_test.go
/project/internal/livelog
/project/internal/livelog/hub.go
/project/internal/livelog/hub_test.go
/project/internal/llm
/project/internal/llm/client.go
/project/internal/llm/streamer.go
/project/internal/llm/streamer_test.go
/project/internal/llm/types.go
/project/internal/scheduler
/project/internal/scheduler/scheduler.go
/project/internal/scheduler/scheduler_test.go
/project/internal/store
/project/internal/store/schema.sql
/project/internal/store/store.go
/project/internal/store/store_test.go
/project/internal/tools
/project/internal/tools/bash.go
/project/internal/tools/context.go
/project/internal/tools/edit-file.go
/project/internal/tools/forgejo.go
/project/internal/tools/git.go
/project/internal/tools/grep_search.go
/project/internal/tools/list_files.go
/project/internal/tools/move_file.go
/project/internal/tools/read_file.go
/project/internal/tools/registry.go
/project/internal/tools/remove_file.go
/project/internal/tools/shell.go
/project/internal/tools/tools_test.go
/project/internal/tools/write_file.go
/project/internal/web
/project/internal/web/templates.go
/project/internal/web/web.go
/project/internal/web/web_test.go
Tool πŸ”§ list_files {"path": "cmd", "recursive": true}
{"path": "cmd", "recursive": true}
/project/cmd/zoo
/project/cmd/zoo/main.go
Thinking
To understand the architecture, let's take a look at the main files: main.go, config, the forgejo package (events, webhooks, watchers), agentrun, scheduler, store, and livelog. This will allow me to make a well-grounded estimate for the Slack integration.
Tool πŸ”§ read_file {"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 200}
{"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 200}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\r\n// issue/PR events, dispatches them to configured AI agents running in\r\n// Docker containers, and serves a small dashboard over the result.\r\npackage main\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"flag\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"os\"\r\n\t\"os/signal\"\r\n\t\"syscall\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/agentrun\"\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/scheduler\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n\t\"github.com/abrander/zoo/internal/web\"\r\n)\r\n\r\nfunc main() {\r\n\tif err := run(); err != nil {\r\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\r\n\r\n\t\tos.Exit(1)\r\n\t}\r\n}\r\n\r\nfunc run() error {\r\n\tvar (\r\n\t\tconfigPath    = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\r\n\t\tdbPath        = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\r\n\t\tlisten        = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\r\n\t\trunTimeout    = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\r\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\r\n\t)\r\n\r\n\tflag.Parse()\r\n\r\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\r\n\r\n\tcfg, err := config.Load(*configPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"load config: %w\", err)\r\n\t}\r\n\r\n\tst, err := store.Open(*dbPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"open store: %w\", err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\r\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\r\n\t} else if n \u003e 0 {\r\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\r\n\t}\r\n\r\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With(\"component\", \"forgejo\"))\r\n\r\n\thub := livelog.NewHub()\r\n\r\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\r\n\t}\r\n\r\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\r\n\tdashboard := web.New(cfg, st, hub, fgClient)\r\n\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\r\n\r\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t\tw.Write([]byte(\"ok\"))\r\n\t})\r\n\r\n\tmux.Handle(\"/\", dashboard.Handler())\r\n\r\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\r\n\r\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\r\n\tdefer stop()\r\n\r\n\tgo watcher.Run(ctx)\r\n\tgo sched.Run(ctx, watcher.Events())\r\n\r\n\tserveErr := make(chan error, 1)\r\n\r\n\tgo func() {\r\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\r\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\r\n\t\t\tserveErr \u003c- err\r\n\t\t}\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-ctx.Done():\r\n\t\tlogger.Info(\"shutting down\")\r\n\r\n\tcase err := \u003c-serveErr:\r\n\t\tstop()\r\n\r\n\t\tlogger.Error(\"http server failed\", \"error\", err)\r\n\t}\r\n\r\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\tdefer cancel()\r\n\r\n\tif err := server.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\r\n\t}\r\n\r\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":131,"total_lines":131,"truncated":false}
Tool πŸ”§ read_file {"path": "internal/config/config.go", "start_line": 1, "end_line": 120}
{"path": "internal/config/config.go", "start_line": 1, "end_line": 120}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\r\npackage config\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\r\n)\r\n\r\n// Known event kinds. issue:assigned is resolved dynamically (agent name\r\n// must match the Forgejo assignee's username) so it never carries an\r\n// `agent` attribute; the rest map statically to one configured agent.\r\nconst (\r\n\tEventIssueNew      = \"issue:new\"\r\n\tEventIssueComment  = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew         = \"pr:new\"\r\n)\r\n\r\nvar staticEventKinds = map[string]bool{\r\n\tEventIssueNew:     true,\r\n\tEventIssueComment: true,\r\n\tEventPRNew:        true,\r\n}\r\n\r\ntype Config struct {\r\n\tLLMs        []LLM       `hcl:\"llm,block\"`\r\n\tForgejo     Forgejo     `hcl:\"forgejo,block\"`\r\n\tEnvironment Environment `hcl:\"environment,block\"`\r\n\tAgents      []Agent     `hcl:\"agent,block\"`\r\n\tEvents      []Event     `hcl:\"event,block\"`\r\n\tWeb         *Web        `hcl:\"web,block\"`\r\n}\r\n\r\n// Web configures the dashboard's optional bearer-token gate. Leave the\r\n// block out of zoo.hcl entirely to run without one (fine on localhost;\r\n// put a real gate or a proxy in front for anything else).\r\ntype Web struct {\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype LLM struct {\r\n\tName   string `hcl:\"name,label\"`\r\n\tOpenAI string `hcl:\"openai\"`\r\n\tToken  string `hcl:\"token\"`\r\n\tModel  string `hcl:\"model\"`\r\n}\r\n\r\ntype Forgejo struct {\r\n\tURL           string `hcl:\"url\"`\r\n\tToken         string `hcl:\"token\"`\r\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\r\n}\r\n\r\ntype Environment struct {\r\n\tDockerImage string `hcl:\"docker_image\"`\r\n\tMaxLive     int    `hcl:\"max_live_agents\"`\r\n}\r\n\r\ntype Agent struct {\r\n\tName  string `hcl:\"name,label\"`\r\n\tLLM   string `hcl:\"llm\"`\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind         string `hcl:\"name,label\"`\r\n\tAgent        string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r\n\r\n\tif c.Environment.MaxLive \u003c 1 {\r\n\t\treturn fmt.Errorf(\"environment: max_live_agents must be \u003e= 1, got %d\", c.Environment.MaxLive)\r\n\t}\r\n\r\n\tif c.Environment.DockerImage == \"\" {\r\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\r\n\t}\r\n\r\n\tagentNames := make(map[string]bool, len(c.Agents))\r\n\tfor _, a := range c.Agents {\r\n\t\tif !llmNames[a.LLM] {\r\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\r\n\t\t}\r\n\t\tagentNames[a.Name] = true\r\n\t}\r\n\r\n\tseenEventKinds := make(map[string]bool, len(c.Events))\r\n\tfor _, e := range c.Events {\r\n\t\tif seenEventKinds[e.Kind] {\r","start_line":1,"end_line":120,"total_lines":204,"truncated":true}
Thinking
Let's check the rest of config.go, the forgejo package (event.go, webhook.go, watcher.go, poll.go), the scheduler, the store, and the livelog hub to understand the event flow. This is important for estimating the Slack integration.
Tool πŸ”§ read_file {"end_line": 204, "start_line": 120, "path": "internal/config/config.go"}
{"end_line": 204, "start_line": 120, "path": "internal/config/config.go"}
{"path":"internal/config/config.go","content":"\t\tif seenEventKinds[e.Kind] {\r\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\r\n\t\t}\r\n\t\tseenEventKinds[e.Kind] = true\r\n\r\n\t\tswitch {\r\n\t\tcase e.Kind == EventIssueAssigned:\r\n\t\t\tif e.Agent != \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\r\n\t\t\t}\r\n\r\n\t\tcase staticEventKinds[e.Kind]:\r\n\t\t\tif e.Agent == \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\r\n\t\t\t}\r\n\t\t\tif !agentNames[e.Agent] {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\r\n\t\t\t}\r\n\r\n\t\tdefault:\r\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentConfig holds an agent's full configuration including its\r\n// Forgejo token (if configured).\r\ntype AgentConfig struct {\r\n\tName  string\r\n\tLLM   string\r\n\tToken string\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn AgentConfig{\r\n\t\t\t\tName:  a.Name,\r\n\t\t\t\tLLM:   a.LLM,\r\n\t\t\t\tToken: a.Token,\r\n\t\t\t}, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn AgentConfig{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn LLM{}, false\r\n}\r\n\r\n// EventAgent returns the statically configured agent name for the given\r\n// event kind, if one is declared and set.\r\nfunc (c *Config) EventAgent(kind string) (string, bool) {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Agent, e.Agent != \"\"\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\", false\r\n}\r\n\r\n// EventInstructions returns the configured freeform instructions for the\r\n// given event kind, e.g. \"Please review this pull request and respond\r\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\r\nfunc (c *Config) EventInstructions(kind string) string {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Instructions\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\"\r\n}\r","start_line":120,"end_line":204,"total_lines":204,"truncated":false}
Tool πŸ”§ read_file {"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 120}
{"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 120}
{"path":"internal/forgejo/event.go","content":"package forgejo\r\n\r\nimport \"encoding/json\"\r\n\r\n// Event kinds, matching the values used as event block labels in\r\n// config.Config (config avoids importing this package, so the kind\r\n// strings are duplicated by value, not by identity).\r\nconst (\r\n\tEventIssueNew      = \"issue:new\"\r\n\tEventIssueComment  = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew         = \"pr:new\"\r\n)\r\n\r\n// Event is the normalized shape produced by both the webhook receiver and\r\n// the polling fallback, and is what the scheduler dispatches on.\r\ntype Event struct {\r\n\tKind string\r\n\r\n\t// ID uniquely identifies this event for dedup, regardless of whether\r\n\t// it arrived via webhook or polling.\r\n\tID string\r\n\r\n\tOwner, Repo string\r\n\tIndex       int64 // issue or PR number\r\n\r\n\tTitle, Body string\r\n\tAuthor      string\r\n\r\n\t// Assignee is set for EventIssueAssigned: the Forgejo username the\r\n\t// issue was assigned to.\r\n\tAssignee string\r\n\r\n\t// Comment is set for EventIssueComment.\r\n\tComment string\r\n\r\n\t// Raw is the full source payload (webhook body, or a synthesized\r\n\t// equivalent when polling), written to /event in the agent container.\r\n\tRaw json.RawMessage\r\n}\r","start_line":1,"end_line":40,"total_lines":40,"truncated":false}
Thinking
Next, let's look at the scheduler, the livelog hub, and the store to understand the flow of jobs and how notifications could potentially be hooked in.
Tool πŸ”§ read_file {"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 150}
{"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 150}
{"path":"internal/scheduler/scheduler.go","content":"// Package scheduler resolves incoming Forgejo events to configured\r\n// agents and runs them, bounded by max_live_agents.\r\npackage scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"errors\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"sync\"\r\n\r\n\t\"github.com/google/uuid\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n// forgejoActions is the narrow slice of Client the scheduler needs for\r\n// its own failure-reporting side effects (defined here, not in\r\n// internal/forgejo, so tests can inject a fake).\r\ntype forgejoActions interface {\r\n\tCreateIssueComment(owner, repo string, index int64, body string) error\r\n\tAddLabel(owner, repo string, index int64, name string) error\r\n}\r\n\r\n// FailureLabel is applied to the triggering issue/PR, alongside a\r\n// comment, whenever an agent run fails or times out.\r\nconst FailureLabel = \"zoo:failed\"\r\n\r\n// Runner runs a single agent invocation to completion. Implemented by\r\n// internal/agentrun.Run; a narrow interface here so the scheduler is\r\n// testable without Docker.\r\ntype Runner interface {\r\n\tRun(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error\r\n}\r\n\r\ntype Scheduler struct {\r\n\tcfg     *config.Config\r\n\tstore   *store.Store\r\n\tforgejo forgejoActions\r\n\trunner  Runner\r\n\thub     *livelog.Hub\r\n\tlogger  *slog.Logger\r\n\r\n\tsem chan struct{}\r\n\twg  sync.WaitGroup\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {\r\n\treturn \u0026Scheduler{\r\n\t\tcfg:     cfg,\r\n\t\tstore:   st,\r\n\t\tforgejo: fg,\r\n\t\trunner:  runner,\r\n\t\thub:     hub,\r\n\t\tlogger:  logger,\r\n\t\tsem:     make(chan struct{}, cfg.Environment.MaxLive),\r\n\t}\r\n}\r\n\r\n// resolveAgent returns the name of the agent that should handle ev, if\r\n// any. issue:assigned resolves dynamically: the agent whose config label\r\n// matches the Forgejo assignee's username. Every other event kind uses\r\n// the static event-\u003eagent mapping from config.\r\nfunc resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {\r\n\tif ev.Kind == config.EventIssueAssigned {\r\n\t\tif _, ok := cfg.AgentByName(ev.Assignee); ok {\r\n\t\t\treturn ev.Assignee, true\r\n\t\t}\r\n\r\n\t\treturn \"\", false\r\n\t}\r\n\r\n\treturn cfg.EventAgent(ev.Kind)\r\n}\r\n\r\n// Run consumes events until ctx is canceled or the channel closes,\r\n// dispatching each to its resolved agent and blocking on the\r\n// max_live_agents semaphore before starting a run.\r\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase ev, ok := \u003c-events:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\ts.handle(ctx, ev)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\r\n\tagentName, ok := resolveAgent(s.cfg, ev)\r\n\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, authenticated with its own per-agent token) can themselves\r\n\t// show up as new events. Don't let an agent trigger itself off its\r\n\t// own activity β€” that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID:         jobID,\r\n\t\tEventKind:  ev.Kind,\r\n\t\tAgent:      agentName,\r\n\t\tOwner:      ev.Owner,\r\n\t\tRepo:       ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t\tTitle:      ev.Title,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r","start_line":1,"end_line":150,"total_lines":241,"truncated":true}
Tool πŸ”§ read_file {"path": "internal/livelog/hub.go", "start_line": 1, "end_line": 120}
{"path": "internal/livelog/hub.go", "start_line": 1, "end_line": 120}
{"path":"internal/livelog/hub.go","content":"// Package livelog is an in-memory pub/sub hub that lets agentrun publish\r\n// fine-grained, not-yet-persisted events (reasoning/content deltas, tool\r\n// calls) for a running job, and lets the web dashboard subscribe to them\r\n// live over SSE. It carries only the tail of a run that hasn't made it\r\n// into SQLite yet β€” see Hub.Checkpoint.\r\npackage livelog\r\n\r\nimport \"sync\"\r\n\r\ntype Type string\r\n\r\nconst (\r\n\tReasoningStart Type = \"reasoning_start\"\r\n\tReasoningDelta Type = \"reasoning_delta\"\r\n\tReasoningEnd   Type = \"reasoning_end\"\r\n\tContentStart   Type = \"content_start\"\r\n\tContentDelta   Type = \"content_delta\"\r\n\tContentEnd     Type = \"content_end\"\r\n\tTool           Type = \"tool\"\r\n\tStatus         Type = \"status\"\r\n)\r\n\r\ntype Event struct {\r\n\tType      Type   `json:\"type\"`\r\n\tText      string `json:\"text,omitempty\"`\r\n\tName      string `json:\"name,omitempty\"`\r\n\tArguments string `json:\"arguments,omitempty\"`\r\n\tResult    string `json:\"result,omitempty\"`\r\n\tError     bool   `json:\"error,omitempty\"`\r\n\tStatus    string `json:\"status,omitempty\"`\r\n}\r\n\r\n// subChanBuffer bounds how many events a slow subscriber can lag behind\r\n// by before new events are dropped for it. Generous for a single-user\r\n// local dashboard; a dropped event just means a subscriber's browser\r\n// tab misses a chunk and catches up on the next one, never a hang.\r\nconst subChanBuffer = 256\r\n\r\n// maxBufferedEvents caps the per-job replay buffer as a safety net\r\n// against unbounded growth if a caller forgets to Checkpoint. Ordinary\r\n// operation clears the buffer far below this on every persisted block.\r\nconst maxBufferedEvents = 10000\r\n\r\ntype stream struct {\r\n\tmu   sync.Mutex\r\n\tbuf  []Event\r\n\tsubs map[chan Event]struct{}\r\n}\r\n\r\n// Hub fans out per-job Events to any number of live subscribers.\r\ntype Hub struct {\r\n\tmu   sync.Mutex\r\n\tjobs map[string]*stream\r\n}\r\n\r\nfunc NewHub() *Hub {\r\n\treturn \u0026Hub{jobs: make(map[string]*stream)}\r\n}\r\n\r\nfunc (h *Hub) get(jobID string) *stream {\r\n\th.mu.Lock()\r\n\tdefer h.mu.Unlock()\r\n\r\n\ts, ok := h.jobs[jobID]\r\n\tif !ok {\r\n\t\ts = \u0026stream{subs: make(map[chan Event]struct{})}\r\n\t\th.jobs[jobID] = s\r\n\t}\r\n\r\n\treturn s\r\n}\r\n\r\n// Publish appends ev to jobID's replay buffer and fans it out to every\r\n// current subscriber. Never blocks: a subscriber whose channel is full\r\n// simply misses this event.\r\nfunc (h *Hub) Publish(jobID string, ev Event) {\r\n\ts := h.get(jobID)\r\n\r\n\ts.mu.Lock()\r\n\tdefer s.mu.Unlock()\r\n\r\n\ts.buf = append(s.buf, ev)\r\n\tif len(s.buf) \u003e maxBufferedEvents {\r\n\t\ts.buf = s.buf[len(s.buf)-maxBufferedEvents:]\r\n\t}\r\n\r\n\tfor ch := range s.subs {\r\n\t\tselect {\r\n\t\tcase ch \u003c- ev:\r\n\t\tdefault:\r\n\t\t}\r\n\t}\r\n}\r\n\r\n// Checkpoint clears jobID's replay buffer. Call this right after a\r\n// block derived from buffered events has been durably written to\r\n// storage, so a subscriber that connects afterward β€” and therefore\r\n// already sees that block via the persisted history β€” is never replayed\r\n// it a second time over the live channel.\r\nfunc (h *Hub) Checkpoint(jobID string) {\r\n\ts := h.get(jobID)\r\n\r\n\ts.mu.Lock()\r\n\tdefer s.mu.Unlock()\r\n\r\n\ts.buf = nil\r\n}\r\n\r\n// Subscribe returns the events currently buffered for jobID (i.e. not\r\n// yet checkpointed) plus a channel of everything published from this\r\n// point on. The returned cancel func must be called when the caller is\r\n// done reading, to unregister and close the channel.\r\nfunc (h *Hub) Subscribe(jobID string) (buffered []Event, ch \u003c-chan Event, cancel func()) {\r\n\ts := h.get(jobID)\r\n\r\n\ts.mu.Lock()\r\n\tdefer s.mu.Unlock()\r\n\r\n\tbuffered = append([]Event(nil), s.buf...)\r\n\r","start_line":1,"end_line":120,"total_lines":164,"truncated":true}
Thinking
Let's check the rest of the scheduler (the part that handles job completion/failure), the store schema, and the agentrun loop to understand the points where notifications can be hooked in.
Tool πŸ”§ read_file {"end_line": 241, "start_line": 150, "path": "internal/scheduler/scheduler.go"}
{"end_line": 241, "start_line": 150, "path": "internal/scheduler/scheduler.go"}
{"path":"internal/scheduler/scheduler.go","content":"\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r\n\r\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {\r\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\r\n\t// Job status writes use a context detached from ctx, not ctx itself:\r\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\r\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\r\n\t// leaving the job stuck at \"running\" forever even though the process\r\n\t// has exited.\r\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\r\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\r\n\t}\r\n\r\n\tlogger.Info(\"agent run starting\")\r\n\r\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\r\n\r\n\tstatus := store.JobSucceeded\r\n\terrMsg := \"\"\r\n\r\n\tif err != nil {\r\n\t\terrMsg = err.Error()\r\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\r\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\r\n\t\t// internally); a timed-out run surfaces as a wrapped\r\n\t\t// context.DeadlineExceeded in the returned error instead.\r\n\t\tif errors.Is(err, context.DeadlineExceeded) {\r\n\t\t\tstatus = store.JobTimedOut\r\n\t\t} else {\r\n\t\t\tstatus = store.JobFailed\r\n\t\t}\r\n\r\n\t\tlogger.Error(\"agent run failed\", \"status\", status, \"error\", err)\r\n\t\ts.reportFailure(ev, status, err)\r\n\t} else {\r\n\t\tlogger.Info(\"agent run succeeded\")\r\n\t}\r\n\r\n\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\r\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\r\n\t}\r\n\r\n\t// Closing the hub here, only after the terminal status is durably\r\n\t// written, is what lets a dashboard tab's auto-reload (triggered by\r\n\t// this Close) see the finished status immediately. Closing it inside\r\n\t// Runner.Run instead β€” before this status write β€” raced a fast\r\n\t// reload against MarkJobFinished: the reloaded page could still see\r\n\t// \"running\", open a fresh SSE subscription against a hub entry that\r\n\t// no longer exists, and be stuck showing a stale status forever.\r\n\ts.hub.Close(jobID)\r\n}\r\n\r\n// reportFailure implements the locked-in failure policy: comment on the\r\n// triggering issue/PR with an error summary, and apply FailureLabel.\r\nfunc (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {\r\n\tbody := fmt.Sprintf(\"zoo agent run %s: %s\", status, runErr.Error())\r\n\r\n\tif err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {\r\n\t\ts.logger.Error(\"failed to post failure comment\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n\r\n\tif err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {\r\n\t\ts.logger.Error(\"failed to add failure label\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n}\r\n\r\n// Shutdown stops accepting new work is the caller's responsibility (stop\r\n// feeding Run's channel); Shutdown just waits for in-flight runs, up to\r\n// ctx's deadline.\r\nfunc (s *Scheduler) Shutdown(ctx context.Context) error {\r\n\tdone := make(chan struct{})\r\n\r\n\tgo func() {\r\n\t\ts.wg.Wait()\r\n\t\tclose(done)\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-done:\r\n\t\treturn nil\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn ctx.Err()\r\n\t}\r\n}\r","start_line":150,"end_line":241,"total_lines":241,"truncated":false}
Tool πŸ”§ read_file {"path": "internal/store/schema.sql", "start_line": 1, "end_line": 80}
{"path": "internal/store/schema.sql", "start_line": 1, "end_line": 80}
{"path":"internal/store/schema.sql","content":"CREATE TABLE IF NOT EXISTS watermark (\r\n    key   TEXT PRIMARY KEY,\r\n    value TEXT NOT NULL\r\n);\r\n\r\nCREATE TABLE IF NOT EXISTS seen_events (\r\n    id      TEXT PRIMARY KEY,\r\n    seen_at DATETIME NOT NULL\r\n);\r\n\r\nCREATE TABLE IF NOT EXISTS jobs (\r\n    id          TEXT PRIMARY KEY,\r\n    event_kind  TEXT NOT NULL,\r\n    agent       TEXT NOT NULL,\r\n    owner       TEXT NOT NULL,\r\n    repo        TEXT NOT NULL,\r\n    issue_index INTEGER NOT NULL,\r\n    title       TEXT NOT NULL DEFAULT '',\r\n    status      TEXT NOT NULL,\r\n    created_at  DATETIME NOT NULL,\r\n    started_at  DATETIME,\r\n    finished_at DATETIME,\r\n    error       TEXT NOT NULL DEFAULT ''\r\n);\r\n\r\nCREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at DESC);\r\n\r\nCREATE TABLE IF NOT EXISTS job_logs (\r\n    job_id TEXT NOT NULL,\r\n    seq    INTEGER NOT NULL,\r\n    stream TEXT NOT NULL,\r\n    line   TEXT NOT NULL,\r\n    ts     DATETIME NOT NULL,\r\n    PRIMARY KEY (job_id, seq)\r\n);\r\n\r\n-- Last-known assignee set per issue, used by the poller to tell a real\r\n-- (re-)assignment apart from any other update to an already-assigned\r\n-- issue. Webhooks say \"assigned\" outright; polling only ever sees state.\r\nCREATE TABLE IF NOT EXISTS issue_assignees (\r\n    issue_id INTEGER NOT NULL,\r\n    assignee TEXT NOT NULL,\r\n    PRIMARY KEY (issue_id, assignee)\r\n);\r","start_line":1,"end_line":44,"total_lines":44,"truncated":false}
Thinking
Now I have a solid understanding of the architecture: 1. **zoo** is a Go daemon: - Watches Forgejo (webhook + polling fallback) for issue/PR events - Normalizes them into `forgejo.Event` - The scheduler resolves the event to an agent (via config mapping or dynamic assignee match) - Runs the agent in a Docker container (`agentrun`) - Persists jobs/logs to SQLite - Serves a dashboard with live SSE logs (`livelog` hub + `web`) 2. Natural integration points for Slack: - **Outbound notifications** (most likely use case): notify a Slack channel when a job starts/finishes/fails. The cleanest hook is `Scheduler.run` β€” it already has the terminal status and `reportFailure`. A `notifier` interface with a Slack implementation would fit the existing narrow-interface style (see `forgejoActions`). - Config: a new `slack` block in `zoo.hcl` (webhook URL or bot token, channel, per-event-kind enable flags). - Inbound (Slack commands/messages that trigger agents) is much bigger: would need a new webhook endpoint `/webhooks/slack`, signature verification, parsing, mapping to events, and probably a new event kind. Let me quickly check the webhook receiver and poller to see how events flow, and also check the size of agentrun/run.go and loop.go for context. Also check go.mod for dependencies (is there an existing Slack library? No, probably not). Let me check go.mod and webhook.go.
Tool πŸ”§ read_file {"path": "go.mod", "start_line": 1, "end_line": 60}
{"path": "go.mod", "start_line": 1, "end_line": 60}
{"path":"go.mod","content":"module github.com/abrander/zoo\r\n\r\ngo 1.26.4\r\n\r\nrequire (\r\n\tcodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0\r\n\tgithub.com/docker/docker v28.5.2+incompatible\r\n\tgithub.com/google/uuid v1.6.0\r\n\tgithub.com/hashicorp/hcl/v2 v2.24.0\r\n\tmodernc.org/sqlite v1.56.0\r\n)\r\n\r\nrequire (\r\n\tgithub.com/42wim/httpsig v1.2.3 // indirect\r\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\r\n\tgithub.com/agext/levenshtein v1.2.1 // indirect\r\n\tgithub.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect\r\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\r\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\r\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\r\n\tgithub.com/containerd/log v0.1.0 // indirect\r\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\r\n\tgithub.com/distribution/reference v0.6.0 // indirect\r\n\tgithub.com/docker/go-connections v0.8.1 // indirect\r\n\tgithub.com/docker/go-units v0.5.0 // indirect\r\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\r\n\tgithub.com/felixge/httpsnoop v1.1.0 // indirect\r\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\r\n\tgithub.com/go-logr/logr v1.4.4 // indirect\r\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\r\n\tgithub.com/google/go-cmp v0.7.0 // indirect\r\n\tgithub.com/hashicorp/go-version v1.7.0 // indirect\r\n\tgithub.com/mattn/go-isatty v0.0.24 // indirect\r\n\tgithub.com/mitchellh/go-wordwrap v1.0.1 // indirect\r\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\r\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\r\n\tgithub.com/moby/term v0.5.2 // indirect\r\n\tgithub.com/morikuni/aec v1.1.0 // indirect\r\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\r\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\r\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\r\n\tgithub.com/pkg/errors v0.9.1 // indirect\r\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\r\n\tgithub.com/zclconf/go-cty v1.16.3 // indirect\r\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\r\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect\r\n\tgo.opentelemetry.io/otel v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/metric v1.45.0 // indirect\r\n\tgo.opentelemetry.io/otel/trace v1.45.0 // indirect\r\n\tgolang.org/x/crypto v0.39.0 // indirect\r\n\tgolang.org/x/mod v0.37.0 // indirect\r\n\tgolang.org/x/sync v0.22.0 // indirect\r\n\tgolang.org/x/sys v0.47.0 // indirect\r\n\tgolang.org/x/text v0.40.0 // indirect\r\n\tgolang.org/x/time v0.15.0 // indirect\r\n\tgolang.org/x/tools v0.47.0 // indirect\r\n\tgotest.tools/v3 v3.5.2 // indirect\r\n\tmodernc.org/libc v1.74.4 // indirect\r\n\tmodernc.org/mathutil v1.7.1 // indirect\r","start_line":1,"end_line":60,"total_lines":62,"truncated":true}
Tool πŸ”§ read_file {"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 80}
{"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 80}
{"path":"internal/forgejo/webhook.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n)\r\n\r\ntype issuePayload struct {\r\n\tAction     string          `json:\"action\"`\r\n\tIssue      *sdk.Issue      `json:\"issue\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype issueCommentPayload struct {\r\n\tAction     string          `json:\"action\"`\r\n\tIssue      *sdk.Issue      `json:\"issue\"`\r\n\tComment    *sdk.Comment    `json:\"comment\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype pullRequestPayload struct {\r\n\tAction      string           `json:\"action\"`\r\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\r\n\tRepository  *sdk.Repository  `json:\"repository\"`\r\n}\r\n\r\n// WebhookHandler returns the http.Handler to mount at (e.g.)\r\n// /webhooks/forgejo. If secret is non-empty, deliveries are verified via\r\n// the SDK's X-Forgejo-Signature middleware; callers should always set a\r\n// secret for anything reachable off localhost.\r\nfunc WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {\r\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tbody, err := io.ReadAll(r.Body)\r\n\t\tif err != nil {\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tkind := r.Header.Get(\"X-Forgejo-Event\")\r\n\t\tif kind == \"\" {\r\n\t\t\tkind = r.Header.Get(\"X-Gitea-Event\")\r\n\t\t}\r\n\r\n\t\tev, ok, err := decodeWebhookEvent(kind, body)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"failed to decode webhook payload\", \"event\", kind, \"error\", err)\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tif ok {\r\n\t\t\temit(ev)\r\n\t\t}\r\n\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t})\r\n\r\n\tif secret == \"\" {\r\n\t\tlogger.Warn(\"forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated\")\r\n\r\n\t\treturn handler\r\n\t}\r\n\r\n\treturn sdk.VerifyWebhookSignatureMiddleware(secret)(handler)\r\n}\r\n\r\nfunc decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {\r\n\tswitch kind {\r\n\tcase \"issues\":\r\n\t\tvar p issuePayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r","start_line":1,"end_line":80,"total_lines":237,"truncated":true}
Thinking
I've got a solid grasp of the overall architecture. Let me also quickly check the agentrun loop and run.go to see how the agent's output (comments, etc.) gets surfaced, and also check zoo.hcl.example for the configuration format. After that, I'll write up the estimate. Actually, I have enough info to write a well-grounded estimate. Let me take a quick peek at zoo.hcl.example to reference the configuration style.
Tool πŸ”§ read_file {"path": "zoo.hcl.example", "start_line": 1, "end_line": 60}
{"path": "zoo.hcl.example", "start_line": 1, "end_line": 60}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n    openai = \"https://code.stdio.dk\"\r\n    token  = \"TOKEN\"\r\n    model  = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n    openai = \"https://code.stdio.dk\"\r\n    token  = \"TOKEN\"\r\n    model  = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n    url            = \"https://code.stdio.dk\"\r\n    token          = \"ZOO_SERVICE_TOKEN\"\r\n    # Shared secret configured on the Forgejo webhook. Required to accept\r\n    # webhook deliveries; polling works without it.\r\n    webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n}\r\n\r\nenvironment {\r\n    docker_image   = \"golang:latest\"\r\n    max_live_agents = 5\r\n}\r\n\r\nagent \"anna\" {\r\n    llm   = \"Qwen 3.6\"\r\n    token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"greg\" {\r\n    llm   = \"Qwen 3.8\"\r\n    token = \"GREG_FORGEJO_TOKEN\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n    llm   = \"Qwen 3.8\"\r\n    token = \"LEON_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"mika\" {\r\n    llm   = \"Qwen 3.6\"\r\n    token = \"MIKA_FORGEJO_TOKEN\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n    agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n    // No `agent` here: the agent whose name matches the Forgejo\r\n    // assignee's username is triggered.\r\n    instructions = \"Please handle this issue.\"\r\n}\r","start_line":1,"end_line":60,"total_lines":70,"truncated":true}
Tool πŸ”§ bash {"command": "cd /project && wc -l internal/agentrun/*.go internal/web/web.go internal/forgejo/*.go | tail -20"}
{"command": "cd /project && wc -l internal/agentrun/*.go internal/web/web.go internal/forgejo/*.go | tail -20"}
   58 internal/agentrun/clone.go
   54 internal/agentrun/context.go
  168 internal/agentrun/docker.go
  102 internal/agentrun/docker_integration_test.go
   87 internal/agentrun/forgejo.go
  118 internal/agentrun/loop.go
  105 internal/agentrun/roster.go
   72 internal/agentrun/roster_test.go
  310 internal/agentrun/run.go
   28 internal/agentrun/seed_test.go
  364 internal/web/web.go
  259 internal/forgejo/client.go
   40 internal/forgejo/event.go
  238 internal/forgejo/poll.go
   82 internal/forgejo/watcher.go
  237 internal/forgejo/webhook.go
  204 internal/forgejo/webhook_test.go
 2526 total
Thinking
I've got a solid grasp of the overall architecture. Now let me write a well-grounded technical estimate as a comment on the issue. The issue is labeled "discussion" and leon asked me for a scope/effort estimate. This is not a coding task β€” the deliverable is the estimate comment. I shouldn't open a PR for this. Let me structure the estimate: **Architecture context** (what I looked at): - zoo is a Go daemon (~2.5k LOC in internal/), with a clean event flow: `forgejo` (webhook + polling β†’ normalized `Event`) β†’ `scheduler` (event β†’ agent resolution, semaphore, job lifecycle) β†’ `agentrun` (Docker container running the agent) β†’ `store` (SQLite: jobs, job_logs) + `livelog` hub (SSE to the dashboard). - The scheduler already has a narrow interface pattern (`forgejoActions`) for side effects, and `reportFailure` is a precedent for "post-run side effects." - Config is HCL with typed blocks (`llm`, `forgejo`, `environment`, `agent`, `event`, `web`). **Two scopes:** **Option A: Outbound notifications only (recommended first step)** β€” Slack tells you what zoo is doing: - New `slack` config block: webhook URL (or bot token + channel), optional per-event-kind toggles. - New `internal/slack` package: a small client (no third-party SDK needed β€” Slack's incoming webhook is a plain HTTP POST with a JSON payload; ~100–150 LOC). - Hook into `scheduler.run`: notify on start (optional), success, failure/timeout (reuse the same place as `reportFailure`). - Tests: client tests with httptest, scheduler tests with a fake notifier (following the existing `forgejoActions` fake pattern). - Estimate: ~300–400 LOC including tests. Effort: **0.5–1 day** (including Slack app setup + manual verification). **Option B: Inbound (Slack β†’ zoo) as well** β€” sending messages/commands in Slack triggers agents: - New `/webhooks/slack` endpoint: Slack's signature verification (HMAC-SHA256 with signing secret, timestamp), event payload parsing, deduplication (Slack redelivers), mapping to a new event kind (e.g. `slack:message`) or reusing the existing event flow. - New event kind in config + scheduler resolution, plus the self-trigger loop guard (Slack user vs. agent identity β€” agents don't have Slack identities, so the existing `ev.Author == agentName` guard doesn't apply as-is). - Deciding which channel/mention triggers which agent; per-agent Slack identity if you want replies to go back to the originating channel (needs a bot token + chat.postMessage, not just an incoming webhook). - Estimate: ~800–1200 LOC including tests. Effort: **2–4 days**, plus product decisions (trigger semantics, which channel, permissions). **Option C: Generic notification framework** (leon's point 3) β€” an abstraction with Slack as the first implementation: - A `notifier` interface + registry, Slack first; email/Teams later. - Adds ~50–100 LOC of abstraction now; worth it if 2+ channels are actually planned, otherwise YAGNI. I'd recommend starting with A and designing the hook point so a generic interface can be introduced later without rework (the scheduler is the single choke point, so the migration cost is low). **Recommendation:** Start with Option A (notifications only) β€” it's the highest-value, lowest-risk piece, and it directly answers the "visibility" use case. Design the hook in the scheduler so inbound can be added later. Total for A: ~1 day. If the team wants inbound triggers, plan B as a separate follow-up issue. **Risks/notes:** - Slack app setup (workspace admin approval, channel permissions) is external lead time, not code time. - Rate limits: Slack incoming webhooks are 1 req/sec β€” no problem for job lifecycle events. - Secrets: webhook URL/token goes in zoo.hcl (already gitignored). I should also mention that I've verified the architecture by reading the code. Keep the comment concise but substantive. Also, should I change the label? The issue has a "discussion" label β€” that fits. No label change needed. I shouldn't close the issue β€” this is a discussion, and the decision is up to abrander/leon. The instructions say "make surgical changes… use open_pull_request if you have changes ready for review." This time there are no code changes β€” the deliverable is the estimate. I'll just post a comment. Should I commit anything to the branch? No, no changes. No push needed. I'll just post the comment. Let me write the comment in Markdown. Tone: senior architect, direct, well-structured.
I've reviewed the codebase to ground the estimate. Now I'll post my technical assessment on the issue.
Tool πŸ”§ comment {"body": "@abrander @leon β€” I dug through the current architecture to give you a grounded estimate rather than a gut feel. Short version: **it's a small task if we scope it to notifications, a medium task if we also want Slack to *trigger* agents.**\n\n## What the architecture gives us\n\nzoo has a clean, single choke point for job lifecycle: `forgejo` (webhook/poll β†’ normalized `Event`) β†’ `scheduler` (resolves agent, runs job, records terminal status) β†’ `agentrun` (Docker). The scheduler already has a precedent for post-run side effects (`reportFailure` posts a comment + label on failure) and uses narrow interfaces for those side effects, which is exactly the shape a Slack notifier would slot into. Config is HCL with typed blocks, so a `slack` block is a trivial addition. No third-party SDK needed for the outbound direction β€” Slack's incoming webhook is a plain authenticated HTTP POST.\n\n## Option A β€” Outbound notifications (recommended first step)\n\nSlack tells you what zoo is doing: job started (optional), succeeded, failed/timed out, with a link back to the issue/PR.\n\n- New `slack` block in `zoo.hcl` (webhook URL or bot token + channel, optional per-event-kind toggles)\n- Small `internal/slack` client (~100–150 LOC)\n- Hook into `scheduler.run` at the same spot as `reportFailure`\n- Tests following the existing fake-injection pattern (`httptest` for the client, fake notifier for the scheduler)\n\n**~300–400 LOC including tests β†’ ~0.5–1 day of work**, plus Slack app setup on your side (workspace admin approval, channel access β€” that's external lead time, not code time).\n\n## Option B β€” Inbound too (Slack messages/commands trigger agents)\n\nThis is a different animal:\n\n- New `/webhooks/slack` endpoint with Slack's HMAC signature verification and redelivery dedup\n- A new event kind (`slack:message`) in config + scheduler, and the existing self-trigger guard (`ev.Author == agentName`) doesn't apply as-is since agents have no Slack identity β€” needs a fresh loop-prevention design\n- Product decisions: which channels trigger which agents, mention semantics, whether replies go back to the originating channel (that requires a bot token, not just an incoming webhook)\n\n**~800–1,200 LOC including tests β†’ ~2–4 days**, plus the product decisions above.\n\n## On leon's \"generic framework\" point\n\nI'd hold off on a generic notification abstraction for now. The scheduler hook point is small and well-defined, so if we later add email/Teams, refactoring a `notifier` interface in is cheap. Building the abstraction before a second consumer exists is the classic YAGNI trap.\n\n## Recommendation\n\nShip **Option A** as a standalone issue (~1 day), design the hook so Option B can layer on later, and treat Option B as its own issue once we've validated that notifications are actually the use case. That de-risks the maintenance-cost concern leon raised: we commit to a day of effort before committing to the bigger surface.\n\nHappy to pick Option A up as soon as you confirm the use case β€” I'd want to know which events you care about (I'd default to: failure/timeout always, success and start optional) and whether one channel or per-repo channels."}
{"body": "@abrander @leon β€” I dug through the current architecture to give you a grounded estimate rather than a gut feel. Short version: **it's a small task if we scope it to notifications, a medium task if we also want Slack to *trigger* agents.**\n\n## What the architecture gives us\n\nzoo has a clean, single choke point for job lifecycle: `forgejo` (webhook/poll β†’ normalized `Event`) β†’ `scheduler` (resolves agent, runs job, records terminal status) β†’ `agentrun` (Docker). The scheduler already has a precedent for post-run side effects (`reportFailure` posts a comment + label on failure) and uses narrow interfaces for those side effects, which is exactly the shape a Slack notifier would slot into. Config is HCL with typed blocks, so a `slack` block is a trivial addition. No third-party SDK needed for the outbound direction β€” Slack's incoming webhook is a plain authenticated HTTP POST.\n\n## Option A β€” Outbound notifications (recommended first step)\n\nSlack tells you what zoo is doing: job started (optional), succeeded, failed/timed out, with a link back to the issue/PR.\n\n- New `slack` block in `zoo.hcl` (webhook URL or bot token + channel, optional per-event-kind toggles)\n- Small `internal/slack` client (~100–150 LOC)\n- Hook into `scheduler.run` at the same spot as `reportFailure`\n- Tests following the existing fake-injection pattern (`httptest` for the client, fake notifier for the scheduler)\n\n**~300–400 LOC including tests β†’ ~0.5–1 day of work**, plus Slack app setup on your side (workspace admin approval, channel access β€” that's external lead time, not code time).\n\n## Option B β€” Inbound too (Slack messages/commands trigger agents)\n\nThis is a different animal:\n\n- New `/webhooks/slack` endpoint with Slack's HMAC signature verification and redelivery dedup\n- A new event kind (`slack:message`) in config + scheduler, and the existing self-trigger guard (`ev.Author == agentName`) doesn't apply as-is since agents have no Slack identity β€” needs a fresh loop-prevention design\n- Product decisions: which channels trigger which agents, mention semantics, whether replies go back to the originating channel (that requires a bot token, not just an incoming webhook)\n\n**~800–1,200 LOC including tests β†’ ~2–4 days**, plus the product decisions above.\n\n## On leon's \"generic framework\" point\n\nI'd hold off on a generic notification abstraction for now. The scheduler hook point is small and well-defined, so if we later add email/Teams, refactoring a `notifier` interface in is cheap. Building the abstraction before a second consumer exists is the classic YAGNI trap.\n\n## Recommendation\n\nShip **Option A** as a standalone issue (~1 day), design the hook so Option B can layer on later, and treat Option B as its own issue once we've validated that notifications are actually the use case. That de-risks the maintenance-cost concern leon raised: we commit to a day of effort before committing to the bigger surface.\n\nHappy to pick Option A up as soon as you confirm the use case β€” I'd want to know which events you care about (I'd default to: failure/timeout always, success and start optional) and whether one channel or per-repo channels."}
Comment posted
Thinking
The comment has been posted. Since this is a discussion issue, there's nothing to commit or push β€” no code changes are needed. The deliverable (a technical estimate) has been delivered. Should I do anything else? Let me think: - No code changes β†’ no branch push, no PR needed. - The issue stays open for abrander/leon to decide β€” that's correct. This is a "discussion" label, and the decision is theirs. - I've left a comment summarizing what I did. βœ“ I could also verify that the working tree is clean (it is β€” I only read files). Done. Let me write a brief final summary.
I've handled the issue. Since this was a discussion/estimation request (labeled `discussion`, assigned by leon for a technical scope assessment), the deliverable was a grounded estimate rather than code β€” so no branch changes or PR were needed. **What I did:** 1. **Reviewed the actual architecture** to ground the estimate: the event flow (`forgejo` webhook/poll β†’ normalized `Event` β†’ `scheduler` β†’ `agentrun` in Docker β†’ `store`/`livelog`), the existing `reportFailure` side-effect pattern in the scheduler, the narrow-interface test pattern, and the HCL config structure. 2. **Posted a technical estimate** on the issue covering: - **Option A (outbound notifications only)** β€” a `slack` config block, a small `internal/slack` client, and a hook in `scheduler.run` alongside the existing failure reporting. ~300–400 LOC, **0.5–1 day**. - **Option B (inbound triggers too)** β€” new signed webhook endpoint, new event kind, loop-prevention design, product decisions. ~800–1,200 LOC, **2–4 days**. - **Recommendation**: ship Option A first (validates the use case, de-risks maintenance cost per leon's concern), keep the hook point clean so a generic notifier or inbound support can layer on later without rework. - Flagged the one non-code dependency: Slack app setup / workspace admin approval is external lead time. The issue stays open for abrander and leon to confirm the use case (which events, which channels) before anyone picks up implementation.