From 94632eef3182902a68540a6720d518c32acccace Mon Sep 17 00:00:00 2001 From: "coding-agent-loop[bot]" Date: Tue, 25 Aug 2026 19:35:36 -0400 Subject: [PATCH] Add embedded web interface for the control API Adds a dependency-free browser console (internal/web, go:embed'd, no build step) mounted at /ui by the existing Fiber control API, with / redirecting to it. Adds GET /config, GET /models, and POST /poll to the API, an Orchestrator.Poll() for on-demand discovery passes, and an Origin/Referer check on mutating routes so a page loaded from another site can't drive the API from a visitor's browser. server.New now takes an Options struct to carry the extra config/registry dependencies the UI needs. server.ui (default true) toggles the mount. Closes #11. Co-Authored-By: Claude Sonnet 5 --- README.md | 35 +- cmd/agent.go | 5 +- config.example.json | 3 +- internal/config/config.go | 6 +- internal/orchestrator/loop.go | 20 + internal/server/server.go | 165 ++++++- internal/server/server_test.go | 159 ++++++- internal/web/assets/app.css | 548 ++++++++++++++++++++++ internal/web/assets/app.js | 820 +++++++++++++++++++++++++++++++++ internal/web/assets/index.html | 46 ++ internal/web/web.go | 27 ++ internal/web/web_test.go | 32 ++ 12 files changed, 1842 insertions(+), 24 deletions(-) create mode 100644 internal/web/assets/app.css create mode 100644 internal/web/assets/app.js create mode 100644 internal/web/assets/index.html create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go diff --git a/README.md b/README.md index 9358fab..51ab3d0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ gh search issues --label agent-ready gh search prs --author - [Embedded defaults](#embedded-defaults) - [When it stops](#when-it-stops) - [Control API](#control-api) +- [Web interface](#web-interface) - [Discord notifications](#discord-notifications) - [Safety boundaries](#safety-boundaries) - [Deploying with systemd](#deploying-with-systemd) @@ -375,7 +376,8 @@ This repository's own `config.json` is also **compiled into the binary** at buil "env": { "PATH": "/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin" } }, "server": { - "addr": "127.0.0.1:8787" + "addr": "127.0.0.1:8787", + "ui": true }, "store": { "path": "~/.agent-loop/state.db" @@ -427,6 +429,7 @@ This repository's own `config.json` is also **compiled into the binary** at buil | `verify.commands` | per-repo override, keyed `"owner/name": "shell command"` | | `verify.env` | extra environment for the test command — mainly `PATH`, so the daemon can find language toolchains (see [Verification](#verification)) | | `server.addr` | control API bind address; keep loopback-only | +| `server.ui` | mount the web interface at `/ui` (and redirect `/` to it); see [Web interface](#web-interface) | | `store.path` | SQLite database path | | `discord.enabled` | turn on Discord status notifications (see [Discord notifications](#discord-notifications)) | | `discord.webhook_url` | the channel's incoming webhook URL; **required** if `discord.enabled` is true | @@ -548,8 +551,37 @@ Loopback-only by default. It can pause and cancel work, so do not expose it. | `GET /runs/{id}` | one run plus its event timeline | | `GET /runs/{id}/log` | the raw JSONL transcript of the Claude run | | `GET /sessions?repo=&issue=&limit=` | Claude session IDs recorded per repo/issue, newest first | +| `GET /config` | current configuration, with the Discord webhook URL redacted | +| `GET /models` | the model registry plus the plan/implement ladders and which models are cooled down | | `POST /pause` `POST /resume` | stop / resume claiming new work | | `POST /runs/{id}/cancel` | cancel an in-flight run | +| `POST /poll` | run a discovery pass now, instead of waiting for `github.poll_interval` | +| `GET /` | redirects to `/ui/` when `server.ui` is enabled | +| `GET /ui/` | the web interface (see [Web interface](#web-interface)) | + +Mutating routes (`POST`) reject a request whose `Origin` or `Referer` header names a non-loopback +host, so a page loaded from another site cannot drive this API from a visitor's browser. A request +with neither header — `curl`, scripts, anything hitting the API directly — is unaffected; that +remains the documented way to use it. + +## Web interface + +A small browser console for the control API above, compiled into the binary via `go:embed` (see +`internal/web`) — no separate install, no network access at runtime, no build tooling required. +Open `http://127.0.0.1:8787/` (redirects to `/ui/`) once the daemon is running. + +It has five views: a **dashboard** (gate state, active claims, in-flight runs with a Cancel button, +pause/resume, and a "Run discovery now" button), **runs** (filterable history with cost, tokens, +verification outcome, and PR links), a **run detail** page (full metrics, event timeline, and a +lazily-loaded transcript viewer — the transcript is not fetched until you ask for it, since it can +run to megabytes), **sessions** (Claude session IDs per repo/issue, with copy-to-clipboard), and a +read-only **config** view (current settings and the model ladders, with cooled-down models struck +through). It polls `/status` every few seconds (configurable, pauses automatically when the tab is +hidden) rather than holding a persistent connection. + +It shares the same posture as the rest of the control API: **loopback-only and unauthenticated by +default.** Anything with local access to the port has full control, same as `curl`. Set +`"server": {"ui": false}` to disable the mount entirely and keep only the JSON API. ## Discord notifications @@ -741,6 +773,7 @@ account, or with the same `--config`) so it resolves the same account and config | `internal/verify` | detect and run the repo's tests | | `internal/orchestrator` | the loop, prompts, PR reports | | `internal/server` | Fiber v3 control API | +| `internal/web` | the browser console (`go:embed`), served by `internal/server` at `/ui` | | `internal/proc` | process-group isolation | | `internal/install` | embedded systemd unit, `--install`/`--uninstall` | diff --git a/cmd/agent.go b/cmd/agent.go index 37ec655..be5f139 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -229,7 +229,10 @@ func run(f flags) error { errCh := make(chan error, 2) if !f.noServer { - srv := server.New(cfg.Server.Addr, st, gateway, orch, log, notifier) + srv := server.New(server.Options{ + Addr: cfg.Server.Addr, Store: st, Gate: gateway, Ctrl: orch, + Log: log, Discord: notifier, Config: cfg, Registry: registry, + }) go func() { errCh <- srv.Listen(ctx) }() } go func() { errCh <- orch.Run(ctx) }() diff --git a/config.example.json b/config.example.json index 3caaf20..834ba89 100644 --- a/config.example.json +++ b/config.example.json @@ -60,7 +60,8 @@ } }, "server": { - "addr": "127.0.0.1:8787" + "addr": "127.0.0.1:8787", + "ui": true }, "store": { "path": "~/.agent-loop/state.db" diff --git a/internal/config/config.go b/internal/config/config.go index 67e0b34..dcc333c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -174,6 +174,10 @@ type VerifyConfig struct { type ServerConfig struct { // Addr should stay on loopback: this API can pause and cancel work. Addr string `json:"addr"` + // UI mounts the browser console at /ui (and redirects / to it). It shares + // the same loopback-only, no-authentication posture as the rest of the + // control API. + UI bool `json:"ui"` } type StoreConfig struct { @@ -243,7 +247,7 @@ func Default() Config { UsageCachePath: "~/.agent-loop/usage-cache.json", }, Verify: VerifyConfig{AutoDetect: true, Commands: map[string]string{}}, - Server: ServerConfig{Addr: "127.0.0.1:8787"}, + Server: ServerConfig{Addr: "127.0.0.1:8787", UI: true}, Store: StoreConfig{Path: "~/.agent-loop/state.db"}, Discord: DiscordConfig{Enabled: false}, Git: GitConfig{ diff --git a/internal/orchestrator/loop.go b/internal/orchestrator/loop.go index b880882..6bca1a4 100644 --- a/internal/orchestrator/loop.go +++ b/internal/orchestrator/loop.go @@ -68,6 +68,9 @@ type Orchestrator struct { // botLogin caches the daemon's own GitHub login, used to scope PR // discovery and to make sure the daemon never reacts to its own comments. botLogin string + + // poll carries operator-requested discovery passes in from Poll(), see Run. + poll chan struct{} } type repoMeta struct { @@ -89,6 +92,7 @@ func New(opts Options) *Orchestrator { activeRepos: map[string]bool{}, cancels: map[string]context.CancelFunc{}, repoInfo: map[string]repoMeta{}, + poll: make(chan struct{}, 1), } } @@ -116,10 +120,26 @@ func (o *Orchestrator) Run(ctx context.Context) error { return nil case <-ticker.C: o.tick(ctx) + case <-o.poll: + o.log.Info("discovery pass requested by operator") + o.tick(ctx) } } } +// Poll asks for a discovery pass as soon as Run's loop is next free, without +// waiting for the regular poll interval. It reports false when a pass is +// already queued, so an operator mashing the button in the web console does +// not pile up redundant ticks. +func (o *Orchestrator) Poll() bool { + select { + case o.poll <- struct{}{}: + return true + default: + return false + } +} + // RunOnce does a single discovery pass and waits for whatever it started. func (o *Orchestrator) RunOnce(ctx context.Context) error { o.tick(ctx) diff --git a/internal/server/server.go b/internal/server/server.go index df45648..55e7c10 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,15 +10,22 @@ import ( "context" "errors" "log/slog" + "net" "net/http" + "net/url" "os" + "strings" "time" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/static" + "github.com/ableinc/coding-agent-loop/internal/config" "github.com/ableinc/coding-agent-loop/internal/discord" "github.com/ableinc/coding-agent-loop/internal/gate" + "github.com/ableinc/coding-agent-loop/internal/models" "github.com/ableinc/coding-agent-loop/internal/store" + "github.com/ableinc/coding-agent-loop/internal/web" ) // Controller is the slice of the orchestrator the API needs. Keeping it an @@ -27,40 +34,62 @@ import ( type Controller interface { Cancel(runID string) bool ActiveRepos() []string + // Poll asks for a discovery pass now, reporting false if one is already + // queued. + Poll() bool +} + +// Options are the Server's dependencies. +type Options struct { + Addr string + Store *store.Store + Gate *gate.Gate + Ctrl Controller + Log *slog.Logger + Discord *discord.Notifier + Config config.Config + Registry *models.Registry } // Server wraps the Fiber app. type Server struct { - app *fiber.App - addr string - store *store.Store - gate *gate.Gate - ctrl Controller - log *slog.Logger - start time.Time - discord *discord.Notifier + app *fiber.App + addr string + store *store.Store + gate *gate.Gate + ctrl Controller + log *slog.Logger + start time.Time + discord *discord.Notifier + cfg config.Config + registry *models.Registry } // New builds the API. -func New(addr string, st *store.Store, g *gate.Gate, ctrl Controller, log *slog.Logger, d *discord.Notifier) *Server { +func New(o Options) *Server { + log := o.Log if log == nil { log = slog.Default() } s := &Server{ - app: fiber.New(fiber.Config{AppName: "coding-agent-loop"}), - addr: addr, - store: st, - gate: g, - ctrl: ctrl, - log: log, - start: time.Now(), - discord: d, + app: fiber.New(fiber.Config{AppName: "coding-agent-loop"}), + addr: o.Addr, + store: o.Store, + gate: o.Gate, + ctrl: o.Ctrl, + log: log, + start: time.Now(), + discord: o.Discord, + cfg: o.Config, + registry: o.Registry, } s.routes() return s } func (s *Server) routes() { + s.app.Use(s.sameOrigin) + s.app.Get("/healthz", s.health) s.app.Get("/status", s.status) s.app.Get("/runs", s.listRuns) @@ -70,6 +99,60 @@ func (s *Server) routes() { s.app.Post("/pause", s.pause) s.app.Post("/resume", s.resume) s.app.Post("/runs/:id/cancel", s.cancelRun) + s.app.Get("/config", s.getConfig) + s.app.Get("/models", s.getModels) + s.app.Post("/poll", s.pollNow) + + if s.cfg.Server.UI { + s.app.Get("/", func(c fiber.Ctx) error { return c.Redirect().Status(http.StatusFound).To("/ui/") }) + s.app.Use("/ui", static.New("", static.Config{ + FS: web.Assets, + IndexNames: []string{"index.html"}, + CacheDuration: -1, // no stale console after an upgrade + })) + } +} + +// sameOrigin rejects a mutating request whose Origin (or, lacking that, +// Referer) header names a non-loopback host. A page loaded from any other +// origin can still fire a simple cross-origin POST with no preflight, and +// shipping a browser console here makes it worth closing that off. A request +// with neither header — curl, a script, an operator's own tooling — is +// unaffected: those are the documented way to drive this API and carry no +// Origin at all. +func (s *Server) sameOrigin(c fiber.Ctx) error { + if c.Method() == fiber.MethodGet || c.Method() == fiber.MethodHead { + return c.Next() + } + origin := c.Get(fiber.HeaderOrigin) + if origin == "" { + origin = c.Get(fiber.HeaderReferer) + } + if origin == "" { + return c.Next() + } + if !isLoopbackOrigin(origin, s.addr) { + return s.fail(c, http.StatusForbidden, errors.New("cross-origin request refused")) + } + return c.Next() +} + +// isLoopbackOrigin reports whether origin (an Origin or Referer header value) +// names localhost, a loopback IP, or the host this server itself is bound to. +func isLoopbackOrigin(origin, addr string) bool { + u, err := url.Parse(origin) + if err != nil || u.Hostname() == "" { + return false + } + host := strings.ToLower(u.Hostname()) + switch host { + case "localhost", "127.0.0.1", "::1": + return true + } + if addrHost, _, err := net.SplitHostPort(addr); err == nil && addrHost != "" && strings.EqualFold(addrHost, host) { + return true + } + return false } // Listen serves until ctx is cancelled, then shuts down gracefully. @@ -268,6 +351,54 @@ func (s *Server) cancelRun(c fiber.Ctx) error { return c.JSON(fiber.Map{"cancelled": true, "run": id}) } +// getConfig returns the daemon's configuration for the web console, with the +// Discord webhook URL blanked: it is a secret, everything else here is not. +func (s *Server) getConfig(c fiber.Ctx) error { + cfg := s.cfg + webhookSet := cfg.Discord.WebhookURL != "" + cfg.Discord.WebhookURL = "" + return c.JSON(fiber.Map{ + "config": cfg, + "discord_webhook_set": webhookSet, + }) +} + +// getModels reports the full model registry plus which models are currently +// cooled down, so the console can show the plan/implement ladders with +// sidelined models struck through rather than silently missing. +func (s *Server) getModels(c fiber.Ctx) error { + if s.registry == nil { + return c.JSON(fiber.Map{"models": []models.Model{}, "cooled_down": []string{}, "plan": []models.Model{}, "implement": []models.Model{}}) + } + cooled, err := s.store.CooledDownModels(c.Context()) + if err != nil { + s.log.Warn("cooldown lookup failed", "error", err) + cooled = nil + } + cooledList := make([]string, 0, len(cooled)) + for id := range cooled { + cooledList = append(cooledList, id) + } + return c.JSON(fiber.Map{ + "models": s.registry.Models, + "cooled_down": cooledList, + "plan": s.registry.Ladder(models.RolePlan, cooled), + "implement": s.registry.Ladder(models.RoleImplement, cooled), + }) +} + +// pollNow asks the orchestrator for a discovery pass right away, rather than +// waiting for the next tick of github.poll_interval. +func (s *Server) pollNow(c fiber.Ctx) error { + if s.ctrl == nil { + return s.fail(c, http.StatusServiceUnavailable, errors.New("no controller attached")) + } + if !s.ctrl.Poll() { + return s.fail(c, http.StatusConflict, errors.New("a discovery pass is already queued")) + } + return c.JSON(fiber.Map{"queued": true}) +} + func (s *Server) fail(c fiber.Ctx, code int, err error) error { if code >= 500 { s.log.Error("api error", "path", c.Path(), "error", err) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4888e28..343023c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -22,6 +23,8 @@ type fakeController struct { cancelled []string cancelOK bool activeRepo []string + pollOK bool + polled int } func (f *fakeController) Cancel(runID string) bool { @@ -31,7 +34,17 @@ func (f *fakeController) Cancel(runID string) bool { func (f *fakeController) ActiveRepos() []string { return f.activeRepo } +func (f *fakeController) Poll() bool { + f.polled++ + return f.pollOK +} + func testServer(t *testing.T) (*Server, *store.Store, *fakeController) { + t.Helper() + return testServerWithConfig(t, config.Default()) +} + +func testServerWithConfig(t *testing.T, cfg config.Config) (*Server, *store.Store, *fakeController) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "state.db")) if err != nil { @@ -39,11 +52,14 @@ func testServer(t *testing.T) (*Server, *store.Store, *fakeController) { } t.Cleanup(func() { st.Close() }) - cfg := config.Default() cfg.Claude.CredentialsPath = filepath.Join(t.TempDir(), "absent.json") g := gate.New(st, cfg.Claude, nil) - ctrl := &fakeController{cancelOK: true, activeRepo: []string{"acme/widgets"}} - return New("127.0.0.1:0", st, g, ctrl, nil, discord.New(false, "", nil)), st, ctrl + ctrl := &fakeController{cancelOK: true, pollOK: true, activeRepo: []string{"acme/widgets"}} + s := New(Options{ + Addr: "127.0.0.1:0", Store: st, Gate: g, Ctrl: ctrl, + Discord: discord.New(false, "", nil), Config: cfg, + }) + return s, st, ctrl } func do(t *testing.T, s *Server, method, target string, body io.Reader) (int, map[string]any) { @@ -269,3 +285,140 @@ func TestListSessions(t *testing.T) { t.Fatalf("unexpected session %v", id) } } + +func TestUIServedAndRootRedirects(t *testing.T) { + s, _, _ := testServer(t) // config.Default() has server.ui = true + + req := httptest.NewRequest(http.MethodGet, "/ui/", nil) + resp, err := s.app.Test(req, fiberTimeout) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /ui/ = %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Fatalf("GET /ui/ content-type = %q", ct) + } + if !strings.Contains(string(raw), "app.js") { + t.Fatalf("GET /ui/ body does not reference app.js: %s", raw) + } + + req = httptest.NewRequest(http.MethodGet, "/ui/app.js", nil) + resp, err = s.app.Test(req, fiberTimeout) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /ui/app.js = %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") { + t.Fatalf("GET /ui/app.js content-type = %q", ct) + } + + if code, _ := do(t, s, http.MethodGet, "/ui/nope.js", nil); code != http.StatusNotFound { + t.Fatalf("GET /ui/nope.js = %d, want 404", code) + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + resp, err = s.app.Test(req, fiberTimeout) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("GET / = %d, want 302", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/ui/" { + t.Fatalf("GET / Location = %q, want /ui/", loc) + } + + // The static mount must not shadow the JSON API. + code, body := do(t, s, http.MethodGet, "/status", nil) + if code != http.StatusOK || body["claiming_work"] != true { + t.Fatalf("GET /status after UI mount = %d %v", code, body) + } +} + +func TestUIDisabled(t *testing.T) { + cfg := config.Default() + cfg.Server.UI = false + s, _, _ := testServerWithConfig(t, cfg) + + if code, _ := do(t, s, http.MethodGet, "/ui/", nil); code != http.StatusNotFound { + t.Fatalf("GET /ui/ with server.ui=false = %d, want 404", code) + } + code, body := do(t, s, http.MethodGet, "/status", nil) + if code != http.StatusOK || body["claiming_work"] != true { + t.Fatalf("GET /status with server.ui=false = %d %v", code, body) + } +} + +func TestSameOriginGuard(t *testing.T) { + s, _, _ := testServer(t) + + post := func(origin string) int { + req := httptest.NewRequest(http.MethodPost, "/pause", nil) + if origin != "" { + req.Header.Set("Origin", origin) + } + resp, err := s.app.Test(req, fiberTimeout) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp.StatusCode + } + + if code := post("https://evil.example"); code != http.StatusForbidden { + t.Fatalf("cross-origin POST = %d, want 403", code) + } + if code := post(""); code != http.StatusOK { + t.Fatalf("no-Origin POST (curl-style) = %d, want 200", code) + } + // Resume so the next assertion isn't affected by the pause above. + do(t, s, http.MethodPost, "/resume", nil) + if code := post("http://127.0.0.1:8787"); code != http.StatusOK { + t.Fatalf("loopback-origin POST = %d, want 200", code) + } +} + +func TestGetConfigRedactsWebhook(t *testing.T) { + cfg := config.Default() + cfg.Discord.Enabled = true + cfg.Discord.WebhookURL = "https://discord.com/api/webhooks/super-secret" + s, _, _ := testServerWithConfig(t, cfg) + + code, body := do(t, s, http.MethodGet, "/config", nil) + if code != http.StatusOK { + t.Fatalf("GET /config = %d", code) + } + if body["discord_webhook_set"] != true { + t.Fatalf("discord_webhook_set = %v, want true", body["discord_webhook_set"]) + } + cfgBody, _ := body["config"].(map[string]any) + discord, _ := cfgBody["discord"].(map[string]any) + if discord["webhook_url"] != "" { + t.Fatalf("webhook_url leaked: %v", discord["webhook_url"]) + } +} + +func TestPollNow(t *testing.T) { + s, _, ctrl := testServer(t) + + code, body := do(t, s, http.MethodPost, "/poll", nil) + if code != http.StatusOK || body["queued"] != true { + t.Fatalf("poll = %d %v", code, body) + } + if ctrl.polled != 1 { + t.Fatalf("controller.Poll() calls = %d, want 1", ctrl.polled) + } + + ctrl.pollOK = false + if code, _ := do(t, s, http.MethodPost, "/poll", nil); code != http.StatusConflict { + t.Fatalf("poll when already queued = %d, want 409", code) + } +} diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css new file mode 100644 index 0000000..96e6618 --- /dev/null +++ b/internal/web/assets/app.css @@ -0,0 +1,548 @@ +:root { + color-scheme: light dark; + --bg: #f5f6f8; + --surface: #ffffff; + --surface-alt: #eef0f3; + --border: #dde1e6; + --text: #1a1d21; + --text-muted: #5c6470; + --accent: #2f6fed; + --accent-contrast: #ffffff; + --ok: #1a7f37; + --ok-bg: #e3f6e8; + --warn: #9a6700; + --warn-bg: #fff3d6; + --danger: #c4321e; + --danger-bg: #fbe3df; + --neutral-bg: #e6e8eb; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.06); + --radius: 8px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161a; + --surface: #1c1f24; + --surface-alt: #23262c; + --border: #33373f; + --text: #e7e9ec; + --text-muted: #9aa2ad; + --accent: #5b8dff; + --accent-contrast: #0b0d10; + --ok: #4bcf7a; + --ok-bg: #113322; + --warn: #e3b23c; + --warn-bg: #3a2e0d; + --danger: #ff6b57; + --danger-bg: #3a1a15; + --neutral-bg: #2a2e35; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + } +} + +html[data-theme="light"] { + --bg: #f5f6f8; + --surface: #ffffff; + --surface-alt: #eef0f3; + --border: #dde1e6; + --text: #1a1d21; + --text-muted: #5c6470; + --accent: #2f6fed; + --accent-contrast: #ffffff; + --ok: #1a7f37; + --ok-bg: #e3f6e8; + --warn: #9a6700; + --warn-bg: #fff3d6; + --danger: #c4321e; + --danger-bg: #fbe3df; + --neutral-bg: #e6e8eb; +} + +html[data-theme="dark"] { + --bg: #14161a; + --surface: #1c1f24; + --surface-alt: #23262c; + --border: #33373f; + --text: #e7e9ec; + --text-muted: #9aa2ad; + --accent: #5b8dff; + --accent-contrast: #0b0d10; + --ok: #4bcf7a; + --ok-bg: #113322; + --warn: #e3b23c; + --warn-bg: #3a2e0d; + --danger: #ff6b57; + --danger-bg: #3a1a15; + --neutral-bg: #2a2e35; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.45; +} + +a { + color: var(--accent); +} + +button:focus-visible, +a:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.topbar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.brand { + font-weight: 700; +} + +.tabs { + display: flex; + gap: 4px; + flex: 1 1 auto; +} + +.tabs a { + padding: 6px 10px; + border-radius: var(--radius); + text-decoration: none; + color: var(--text-muted); + font-weight: 600; +} + +.tabs a.active, +.tabs a:hover { + background: var(--surface-alt); + color: var(--text); +} + +.topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.btn { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 6px 12px; + border-radius: var(--radius); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.btn:hover { + background: var(--surface-alt); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.btn-danger { + border-color: var(--danger); + color: var(--danger); +} + +.btn-icon { + padding: 6px 8px; +} + +.select { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 6px 8px; + border-radius: var(--radius); + font: inherit; +} + +main { + padding: 16px; + max-width: 1200px; + margin: 0 auto; +} + +.view[hidden] { + display: none; +} + +h1 { + font-size: 20px; + margin: 0 0 12px; +} + +h2 { + font-size: 16px; + margin: 24px 0 10px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.stat-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + box-shadow: var(--shadow); +} + +.stat-tile .label { + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-tile .value { + font-size: 22px; + font-weight: 700; + margin-top: 4px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + box-shadow: var(--shadow); +} + +.card .title { + font-weight: 700; + margin-bottom: 4px; +} + +.card .muted { + color: var(--text-muted); + font-size: 12px; +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +th, +td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +th { + background: var(--surface-alt); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted); +} + +tbody tr:last-child td { + border-bottom: none; +} + +tbody tr:hover { + background: var(--surface-alt); +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.filters input, +.filters select { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 6px 8px; + border-radius: var(--radius); + font: inherit; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.pill-ok { + background: var(--ok-bg); + color: var(--ok); +} + +.pill-warn { + background: var(--warn-bg); + color: var(--warn); +} + +.pill-danger { + background: var(--danger-bg); + color: var(--danger); +} + +.pill-neutral { + background: var(--neutral-bg); + color: var(--text-muted); +} + +.mono { + font-family: var(--mono); + font-size: 12.5px; +} + +.muted { + color: var(--text-muted); +} + +.error-banner { + background: var(--danger-bg); + color: var(--danger); + padding: 10px 16px; + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.error-banner[hidden] { + display: none; +} + +.back-link { + display: inline-block; + margin-bottom: 10px; + text-decoration: none; + font-weight: 600; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.detail-grid .field .label { + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.detail-grid .field .value { + font-weight: 600; + word-break: break-word; +} + +.error-block { + background: var(--danger-bg); + color: var(--danger); + border-radius: var(--radius); + padding: 10px 12px; + white-space: pre-wrap; + font-family: var(--mono); + font-size: 12.5px; + margin-bottom: 16px; +} + +.timeline { + list-style: none; + margin: 0 0 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); +} + +.timeline li { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.timeline li:last-child { + border-bottom: none; +} + +.timeline .kind { + font-weight: 700; + min-width: 110px; +} + +.timeline .at { + color: var(--text-muted); + font-size: 12px; +} + +.transcript-record { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + background: var(--surface); +} + +.transcript-record summary { + cursor: pointer; + padding: 8px 12px; + font-weight: 600; + list-style: none; +} + +.transcript-record summary::-webkit-details-marker { + display: none; +} + +.transcript-record pre { + margin: 0; + padding: 10px 12px; + border-top: 1px solid var(--border); + overflow-x: auto; + font-family: var(--mono); + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.config-dump { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.empty-state { + color: var(--text-muted); + padding: 24px; + text-align: center; + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +.strike { + text-decoration: line-through; + color: var(--text-muted); +} + +.section-actions { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; +} + +@media (max-width: 720px) { + main { + padding: 10px; + } + + table, + thead, + tbody, + th, + td, + tr { + display: block; + } + + table thead { + display: none; + } + + table tbody tr { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 10px; + padding: 6px 0; + } + + table td { + border-bottom: none; + padding: 4px 12px; + display: flex; + justify-content: space-between; + gap: 10px; + text-align: right; + } + + table td::before { + content: attr(data-label); + font-weight: 700; + color: var(--text-muted); + text-align: left; + } + + .topbar { + justify-content: center; + } + + .tabs { + order: 3; + width: 100%; + justify-content: center; + } +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..be4299c --- /dev/null +++ b/internal/web/assets/app.js @@ -0,0 +1,820 @@ +// Vanilla ES module console for the control API. No build step, no +// dependencies: this ships as static files embedded straight into the +// daemon binary, so it has to run as-is in a browser. + +// --- API client -------------------------------------------------------- + +const errorBanner = document.getElementById("error-banner"); + +let errorTimer = null; +function showError(message) { + errorBanner.textContent = message; + errorBanner.hidden = false; + clearTimeout(errorTimer); + errorTimer = setTimeout(() => { + errorBanner.hidden = true; + }, 6000); +} + +async function apiFetch(path, options) { + let res; + try { + res = await fetch(path, options); + } catch (err) { + showError(`Network error calling ${path}: ${err.message}`); + throw err; + } + let body = null; + const text = await res.text(); + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + if (!res.ok) { + const message = (body && body.error) || res.statusText || `HTTP ${res.status}`; + showError(`${path}: ${message}`); + const err = new Error(message); + err.status = res.status; + err.body = body; + throw err; + } + return body; +} + +const api = { + get: (path) => apiFetch(path), + post: (path, body) => + apiFetch(path, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }), +}; + +// --- Formatters --------------------------------------------------------- + +const ZERO_TIME = "0001-01-01T00:00:00Z"; + +function fmtTime(value) { + if (!value || value === ZERO_TIME) return "—"; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(); +} + +function fmtRelative(value) { + if (!value || value === ZERO_TIME) return "—"; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return "—"; + const diffMs = d.getTime() - Date.now(); + const abs = Math.abs(diffMs); + const mins = Math.round(abs / 60000); + let text; + if (mins < 1) text = "now"; + else if (mins < 60) text = `${mins}m`; + else if (mins < 60 * 24) text = `${Math.round(mins / 60)}h`; + else text = `${Math.round(mins / (60 * 24))}d`; + if (text === "now") return "now"; + return diffMs < 0 ? `${text} ago` : `in ${text}`; +} + +function fmtDuration(startISO, endISO) { + if (!startISO || startISO === ZERO_TIME) return "—"; + const start = new Date(startISO).getTime(); + const end = endISO && endISO !== ZERO_TIME ? new Date(endISO).getTime() : Date.now(); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return "—"; + const secs = Math.round((end - start) / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ${secs % 60}s`; + const hrs = Math.floor(mins / 60); + return `${hrs}h ${mins % 60}m`; +} + +function fmtUSD(value) { + if (value === undefined || value === null) return "—"; + return `$${Number(value).toFixed(4)}`; +} + +function fmtTokens(value) { + if (value === undefined || value === null) return "—"; + const n = Number(value); + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +const STATUS_KIND = { + pr_open: "ok", + addressed: "ok", + planned: "ok", + failed: "danger", + canceled: "warn", + deferred: "warn", + abandoned: "warn", + claimed: "neutral", + working: "neutral", + verifying: "neutral", + pushed: "neutral", +}; + +function statusBadge(status) { + const kind = STATUS_KIND[status] || "neutral"; + const span = document.createElement("span"); + span.className = `pill pill-${kind}`; + span.textContent = status || "unknown"; + return span; +} + +function gateBadge(blocking) { + const span = document.createElement("span"); + span.className = `pill pill-${blocking ? "danger" : "warn"}`; + span.textContent = blocking ? "blocking" : "cooldown"; + return span; +} + +function issueURL(repo, issue) { + if (!repo || !issue) return null; + return `https://github.com/${repo}/issues/${issue}`; +} + +function repoIssueLink(repo, issue) { + const url = issueURL(repo, issue); + if (!url) return document.createTextNode(repo || "—"); + const a = document.createElement("a"); + a.href = url; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = `${repo}#${issue}`; + return a; +} + +// --- DOM helpers --------------------------------------------------------- + +function el(tag, attrs, children) { + const node = document.createElement(tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + } + for (const child of children || []) { + if (child === null || child === undefined) continue; + node.appendChild(typeof child === "string" ? document.createTextNode(child) : child); + } + return node; +} + +function emptyState(text) { + return el("div", { class: "empty-state", text }); +} + +function td(label, content) { + const cell = el("td", { "data-label": label }); + if (content instanceof Node) cell.appendChild(content); + else cell.textContent = content ?? "—"; + return cell; +} + +// --- Theme --------------------------------------------------------------- + +const THEME_KEY = "cal.theme"; +function applyTheme(theme) { + document.documentElement.setAttribute("data-theme", theme); + document.getElementById("theme-toggle-btn").textContent = theme === "dark" ? "☀" : "☽"; +} +function initTheme() { + const stored = localStorage.getItem(THEME_KEY); + const theme = stored || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + applyTheme(theme); +} +document.getElementById("theme-toggle-btn").addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + localStorage.setItem(THEME_KEY, next); + applyTheme(next); +}); +initTheme(); + +// --- Status pill / pause-resume / poll now ------------------------------- + +const statusPill = document.getElementById("status-pill"); +const pauseResumeBtn = document.getElementById("pause-resume-btn"); +const pollNowBtn = document.getElementById("poll-now-btn"); + +let lastStatus = null; + +function renderStatusPill(status) { + statusPill.className = "pill " + (status.claiming_work ? "pill-ok" : "pill-warn"); + statusPill.textContent = status.claiming_work ? "Claiming work" : "Paused"; + pauseResumeBtn.textContent = status.claiming_work ? "Pause" : "Resume"; +} + +async function refreshStatus() { + try { + const status = await api.get("/status"); + lastStatus = status; + renderStatusPill(status); + if (currentRoute && currentRoute.name === "dashboard") { + renderDashboard(status); + } + } catch { + statusPill.className = "pill pill-danger"; + statusPill.textContent = "Unreachable"; + } +} + +pauseResumeBtn.addEventListener("click", async () => { + pauseResumeBtn.disabled = true; + try { + if (lastStatus && lastStatus.claiming_work) { + await api.post("/pause", { reason: "operator requested via web console" }); + } else { + await api.post("/resume"); + } + await refreshStatus(); + } finally { + pauseResumeBtn.disabled = false; + } +}); + +pollNowBtn.addEventListener("click", async () => { + pollNowBtn.disabled = true; + try { + await api.post("/poll"); + } finally { + setTimeout(() => { + pollNowBtn.disabled = false; + }, 1500); + } +}); + +// --- Poller ---------------------------------------------------------------- + +const POLL_KEY = "cal.pollInterval"; +const pollSelect = document.getElementById("poll-interval"); +let pollTimer = null; + +function schedulePoll() { + clearInterval(pollTimer); + const ms = Number(pollSelect.value); + if (!ms || document.hidden) return; + pollTimer = setInterval(tick, ms); +} + +function tick() { + refreshStatus(); // already re-renders the dashboard itself when it's the active route + if (currentRoute && currentRoute.name !== "dashboard") { + renderRoute(currentRoute, { silent: true }); + } +} + +pollSelect.addEventListener("change", () => { + localStorage.setItem(POLL_KEY, pollSelect.value); + schedulePoll(); +}); +document.addEventListener("visibilitychange", schedulePoll); + +const storedInterval = localStorage.getItem(POLL_KEY); +if (storedInterval) pollSelect.value = storedInterval; + +// --- Router ------------------------------------------------------------ + +const views = { + dashboard: document.getElementById("view-dashboard"), + runs: document.getElementById("view-runs"), + runDetail: document.getElementById("view-run-detail"), + sessions: document.getElementById("view-sessions"), + config: document.getElementById("view-config"), +}; + +let currentRoute = null; + +function parseHash() { + const hash = location.hash.replace(/^#/, "") || "/"; + const runMatch = hash.match(/^\/runs\/([^/?]+)/); + if (runMatch) return { name: "runDetail", id: decodeURIComponent(runMatch[1]) }; + const base = hash.split("?")[0]; + switch (base) { + case "/runs": + return { name: "runs" }; + case "/sessions": + return { name: "sessions" }; + case "/config": + return { name: "config" }; + default: + return { name: "dashboard" }; + } +} + +function setActiveTab(name) { + const routeForTab = { dashboard: "/", runs: "/runs", runDetail: "/runs", sessions: "/sessions", config: "/config" }; + document.querySelectorAll(".tabs a").forEach((a) => { + a.classList.toggle("active", a.dataset.route === routeForTab[name]); + }); +} + +function showView(name) { + for (const [key, node] of Object.entries(views)) { + node.hidden = key !== name; + } +} + +async function renderRoute(route, opts) { + setActiveTab(route.name); + showView(route.name); + const silent = opts && opts.silent; + try { + if (route.name === "dashboard") { + await renderDashboard(lastStatus); + } else if (route.name === "runs") { + await renderRuns(silent); + } else if (route.name === "runDetail") { + await renderRunDetail(route.id, silent); + } else if (route.name === "sessions") { + await renderSessions(silent); + } else if (route.name === "config") { + await renderConfig(silent); + } + } catch (err) { + if (!silent) { + views[route.name].innerHTML = ""; + views[route.name].appendChild(emptyState(`Could not load this view: ${err.message}`)); + } + } +} + +window.addEventListener("hashchange", () => { + currentRoute = parseHash(); + renderRoute(currentRoute); +}); + +// --- Dashboard ----------------------------------------------------------- + +async function renderDashboard(status) { + const container = views.dashboard; + if (!status) { + status = await api.get("/status"); + lastStatus = status; + } + + let runs = { runs: [] }; + try { + runs = await api.get("/runs?limit=200"); + } catch { + // status still renders without the 24h spend tile + } + const dayAgo = Date.now() - 24 * 60 * 60 * 1000; + const spend24h = (runs.runs || []) + .filter((r) => r.StartedAt && new Date(r.StartedAt).getTime() >= dayAgo) + .reduce((sum, r) => sum + (r.CostUSD || 0), 0); + + container.innerHTML = ""; + + const stats = el("div", { class: "stat-grid" }, [ + statTile("In-flight runs", String((status.in_flight || []).length)), + statTile("Active repos", String((status.active_repos || []).length)), + statTile("Usage", status.usage && status.usage.available ? `${status.usage.percent.toFixed(0)}%` : "—"), + statTile("Spend (24h)", fmtUSD(spend24h)), + ]); + container.appendChild(stats); + + container.appendChild(el("h2", { text: "Gates" })); + const gates = status.gates || []; + if (gates.length === 0) { + container.appendChild(emptyState("No active gates.")); + } else { + container.appendChild( + el( + "div", + { class: "card-grid" }, + gates.map((g) => + el("div", { class: "card" }, [ + el("div", { class: "title" }, [gateBadge(g.blocking), document.createTextNode(" " + g.kind)]), + el("div", { class: "muted", text: g.reason || "" }), + el("div", { class: "muted", text: `until ${fmtTime(g.blocked_until)} (${fmtRelative(g.blocked_until)})` }), + ]) + ) + ) + ); + } + + container.appendChild(el("h2", { text: "Active claims" })); + const claims = status.claims || []; + if (claims.length === 0) { + container.appendChild(emptyState("No active claims.")); + } else { + container.appendChild(claimsTable(claims)); + } + + container.appendChild(el("h2", { text: "In-flight runs" })); + const inFlight = status.in_flight || []; + if (inFlight.length === 0) { + container.appendChild(emptyState("Nothing in flight.")); + } else { + container.appendChild(inFlightTable(inFlight)); + } +} + +function statTile(label, value) { + return el("div", { class: "stat-tile" }, [el("div", { class: "label", text: label }), el("div", { class: "value", text: value })]); +} + +function claimsTable(claims) { + const table = el("table", {}, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "Repo" }), el("th", { text: "Worker" }), el("th", { text: "Leased until" })])]), + ]); + const tbody = el("tbody"); + for (const c of claims) { + tbody.appendChild( + el("tr", {}, [td("Repo", repoIssueLink(c.Repo, c.Issue)), td("Worker", c.Worker), td("Leased until", fmtTime(c.LeasedUntil))]) + ); + } + table.appendChild(tbody); + return table; +} + +function inFlightTable(runs) { + const table = el("table", {}, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", { text: "Repo" }), + el("th", { text: "Status" }), + el("th", { text: "Model" }), + el("th", { text: "Started" }), + el("th", { text: "" }), + ]), + ]), + ]); + const tbody = el("tbody"); + for (const r of runs) { + const cancelBtn = el("button", { class: "btn btn-danger", type: "button" }, ["Cancel"]); + cancelBtn.addEventListener("click", async () => { + cancelBtn.disabled = true; + try { + await api.post(`/runs/${encodeURIComponent(r.ID)}/cancel`); + await refreshStatus(); + } finally { + cancelBtn.disabled = false; + } + }); + const link = el("a", { href: `#/runs/${encodeURIComponent(r.ID)}`, text: `${r.Repo}#${r.Issue}` }); + tbody.appendChild( + el("tr", {}, [ + td("Repo", link), + td("Status", statusBadge(r.Status)), + td("Model", r.ModelID || "—"), + td("Started", fmtTime(r.StartedAt)), + td("", cancelBtn), + ]) + ); + } + table.appendChild(tbody); + return table; +} + +// --- Runs list ------------------------------------------------------------- + +let runsFilters = { repo: "", limit: 50, status: "", kind: "" }; + +async function renderRuns(silent) { + const container = views.runs; + const data = await api.get(`/runs?limit=${encodeURIComponent(runsFilters.limit)}&repo=${encodeURIComponent(runsFilters.repo)}`); + let runs = data.runs || []; + if (runsFilters.status) runs = runs.filter((r) => r.Status === runsFilters.status); + if (runsFilters.kind) runs = runs.filter((r) => r.Kind === runsFilters.kind); + + if (silent && container.dataset.rendered === "1") { + const tbody = container.querySelector("tbody"); + if (tbody) { + tbody.replaceWith(runsTableBody(runs)); + return; + } + } + + container.innerHTML = ""; + container.dataset.rendered = "1"; + container.appendChild(el("h1", { text: "Runs" })); + + const repoInput = el("input", { type: "text", placeholder: "owner/repo", value: runsFilters.repo }); + const limitInput = el("input", { type: "number", min: "1", max: "500", value: String(runsFilters.limit) }); + const statusSelect = el( + "select", + {}, + ["", "claimed", "working", "verifying", "pushed", "pr_open", "failed", "abandoned", "canceled", "deferred", "planned", "addressed"].map( + (s) => el("option", { value: s, text: s || "All statuses" }) + ) + ); + statusSelect.value = runsFilters.status; + const kindSelect = el( + "select", + {}, + ["", "issue", "pr_comment"].map((k) => el("option", { value: k, text: k || "All kinds" })) + ); + kindSelect.value = runsFilters.kind; + const applyBtn = el("button", { class: "btn", type: "button", text: "Apply" }); + applyBtn.addEventListener("click", () => { + runsFilters = { + repo: repoInput.value.trim(), + limit: Number(limitInput.value) || 50, + status: statusSelect.value, + kind: kindSelect.value, + }; + renderRuns(false); + }); + + container.appendChild(el("div", { class: "filters" }, [repoInput, limitInput, statusSelect, kindSelect, applyBtn])); + + if (runs.length === 0) { + container.appendChild(emptyState("No runs match these filters.")); + return; + } + + const table = el("table", {}, [ + el("thead", {}, [ + el( + "tr", + {}, + ["Repo", "Status", "Kind", "Model", "Attempt", "Cost", "Tokens", "Verify", "Duration", "PR"].map((h) => el("th", { text: h })) + ), + ]), + ]); + table.appendChild(runsTableBody(runs)); + container.appendChild(table); +} + +function runsTableBody(runs) { + const tbody = el("tbody"); + for (const r of runs) { + const link = el("a", { href: `#/runs/${encodeURIComponent(r.ID)}`, text: `${r.Repo}#${r.Issue}` }); + const prCell = r.PRURL ? el("a", { href: r.PRURL, target: "_blank", rel: "noopener noreferrer", text: "PR" }) : "—"; + tbody.appendChild( + el("tr", {}, [ + td("Repo", link), + td("Status", statusBadge(r.Status)), + td("Kind", r.Kind || "issue"), + td("Model", r.ModelID || "—"), + td("Attempt", String(r.Attempt ?? "—")), + td("Cost", fmtUSD(r.CostUSD)), + td("Tokens", `${fmtTokens(r.TokensIn)} in / ${fmtTokens(r.TokensOut)} out`), + td("Verify", r.VerifyStatus || "—"), + td("Duration", fmtDuration(r.StartedAt, r.EndedAt)), + td("PR", prCell), + ]) + ); + } + return tbody; +} + +// --- Run detail -------------------------------------------------------- + +async function renderRunDetail(id, silent) { + const container = views.runDetail; + // A silent poll tick must not blow away a loaded transcript or an + // operator's expanded event details; the dashboard already covers + // "is anything still in flight" for the polling use case. + if (silent) return; + container.innerHTML = ""; + const data = await api.get(`/runs/${encodeURIComponent(id)}`); + const run = data.run; + const events = data.events || []; + + container.appendChild(el("a", { class: "back-link", href: "#/runs", text: "← Back to runs" })); + container.appendChild( + el("h1", {}, [document.createTextNode(`${run.Repo}#${run.Issue} `), statusBadge(run.Status)]) + ); + + const fields = [ + ["Run ID", run.ID], + ["Kind", run.Kind || "issue"], + ["Attempt", run.Attempt], + ["Model", run.ModelID || "—"], + ["Branch", run.Branch || "—"], + ["Session", run.SessionID || "—"], + ["Cost", fmtUSD(run.CostUSD)], + ["Tokens in", fmtTokens(run.TokensIn)], + ["Tokens out", fmtTokens(run.TokensOut)], + ["Turns", run.NumTurns], + ["Verify", run.VerifyStatus || "—"], + ["Started", fmtTime(run.StartedAt)], + ["Ended", fmtTime(run.EndedAt)], + ["Duration", fmtDuration(run.StartedAt, run.EndedAt)], + ]; + container.appendChild( + el( + "div", + { class: "detail-grid" }, + fields.map(([label, value]) => + el("div", { class: "field" }, [el("div", { class: "label", text: label }), el("div", { class: "value", text: String(value ?? "—") })]) + ) + ) + ); + + if (run.PRURL) { + container.appendChild(el("p", {}, [el("a", { href: run.PRURL, target: "_blank", rel: "noopener noreferrer", text: "Open pull request →" })])); + } + + if (run.Error) { + container.appendChild(el("h2", { text: "Error" })); + container.appendChild(el("div", { class: "error-block", text: run.Error })); + } + + container.appendChild(el("h2", { text: "Event timeline" })); + if (events.length === 0) { + container.appendChild(emptyState("No events recorded.")); + } else { + container.appendChild( + el( + "ul", + { class: "timeline" }, + events.map((e) => + el("li", {}, [ + el("span", { class: "kind", text: e.Kind }), + el("span", { text: e.Detail || "" }), + el("span", { class: "at", text: fmtTime(e.At) }), + ]) + ) + ) + ); + } + + container.appendChild(el("h2", { text: "Transcript" })); + const transcriptHolder = el("div"); + container.appendChild(transcriptHolder); + if (!run.LogPath) { + transcriptHolder.appendChild(emptyState("This run has no transcript.")); + } else { + const loadBtn = el("button", { class: "btn", type: "button", text: "Load transcript" }); + transcriptHolder.appendChild(loadBtn); + loadBtn.addEventListener("click", async () => { + loadBtn.disabled = true; + loadBtn.textContent = "Loading…"; + try { + const res = await fetch(`/runs/${encodeURIComponent(id)}/log`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + transcriptHolder.innerHTML = ""; + transcriptHolder.appendChild(renderTranscript(text)); + } catch (err) { + showError(`Could not load transcript: ${err.message}`); + loadBtn.disabled = false; + loadBtn.textContent = "Load transcript"; + } + }); + } +} + +function renderTranscript(text) { + const wrap = el("div"); + const lines = text.split("\n").filter((l) => l.trim() !== ""); + if (lines.length === 0) { + wrap.appendChild(emptyState("Transcript is empty.")); + return wrap; + } + for (const line of lines) { + let record; + try { + record = JSON.parse(line); + } catch { + record = null; + } + const summaryText = record ? record.type || record.subtype || "record" : "unparsed line"; + const details = el("pre", { text: record ? JSON.stringify(record, null, 2) : line }); + wrap.appendChild(el("details", { class: "transcript-record" }, [el("summary", { text: summaryText }), details])); + } + return wrap; +} + +// --- Sessions ------------------------------------------------------------ + +let sessionsFilters = { repo: "", issue: "", limit: 50 }; + +async function renderSessions(silent) { + const container = views.sessions; + const qs = new URLSearchParams(); + qs.set("limit", String(sessionsFilters.limit)); + if (sessionsFilters.repo) qs.set("repo", sessionsFilters.repo); + if (sessionsFilters.issue) qs.set("issue", sessionsFilters.issue); + const data = await api.get(`/sessions?${qs.toString()}`); + const sessions = data.sessions || []; + + if (silent && container.dataset.rendered === "1") { + const tbody = container.querySelector("tbody"); + if (tbody) { + tbody.replaceWith(sessionsTableBody(sessions)); + return; + } + } + + container.innerHTML = ""; + container.dataset.rendered = "1"; + container.appendChild(el("h1", { text: "Sessions" })); + + const repoInput = el("input", { type: "text", placeholder: "owner/repo", value: sessionsFilters.repo }); + const issueInput = el("input", { type: "number", placeholder: "issue #", value: sessionsFilters.issue }); + const applyBtn = el("button", { class: "btn", type: "button", text: "Apply" }); + applyBtn.addEventListener("click", () => { + sessionsFilters = { repo: repoInput.value.trim(), issue: issueInput.value.trim(), limit: 50 }; + renderSessions(false); + }); + container.appendChild(el("div", { class: "filters" }, [repoInput, issueInput, applyBtn])); + + if (sessions.length === 0) { + container.appendChild(emptyState("No sessions recorded.")); + return; + } + + const table = el("table", {}, [ + el("thead", {}, [el("tr", {}, ["Session", "Repo", "Model", "Run", "Created"].map((h) => el("th", { text: h })))]), + ]); + table.appendChild(sessionsTableBody(sessions)); + container.appendChild(table); +} + +function sessionsTableBody(sessions) { + const tbody = el("tbody"); + for (const s of sessions) { + const copyBtn = el("button", { class: "btn btn-icon mono", type: "button", text: s.SessionID }); + copyBtn.title = "Copy session ID"; + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(s.SessionID); + copyBtn.textContent = "Copied!"; + setTimeout(() => (copyBtn.textContent = s.SessionID), 1000); + } catch { + showError("Clipboard access was denied by the browser."); + } + }); + tbody.appendChild( + el("tr", {}, [ + td("Session", copyBtn), + td("Repo", repoIssueLink(s.Repo, s.Issue)), + td("Model", s.ModelID || "—"), + td("Run", el("a", { href: `#/runs/${encodeURIComponent(s.RunID)}`, text: s.RunID })), + td("Created", fmtTime(s.CreatedAt)), + ]) + ); + } + return tbody; +} + +// --- Config ---------------------------------------------------------------- + +async function renderConfig(silent) { + const container = views.config; + if (silent && container.dataset.rendered === "1") return; + + const [cfgResp, models] = await Promise.all([api.get("/config"), api.get("/models")]); + + container.innerHTML = ""; + container.dataset.rendered = "1"; + container.appendChild(el("h1", { text: "Configuration" })); + container.appendChild( + el("p", { + class: "muted", + text: `Read-only. Discord webhook URL is redacted (${cfgResp.discord_webhook_set ? "currently set" : "not set"}).`, + }) + ); + + container.appendChild(el("h2", { text: "Config" })); + container.appendChild(el("pre", { class: "mono config-dump" }, [document.createTextNode(JSON.stringify(cfgResp.config, null, 2))])); + + const cooled = new Set(models.cooled_down || []); + const allModels = (models.models || []).slice().sort((a, b) => a.priority - b.priority); + + container.appendChild(el("h2", { text: "Plan ladder" })); + container.appendChild(ladderTable(allModels.filter((m) => roleServed(m, "plan")), cooled)); + + container.appendChild(el("h2", { text: "Implement ladder" })); + container.appendChild(ladderTable(allModels.filter((m) => roleServed(m, "implement")), cooled)); +} + +function roleServed(model, role) { + return !model.roles || model.roles.length === 0 || model.roles.includes(role); +} + +function ladderTable(ladder, cooled) { + if (ladder.length === 0) return emptyState("No models on this ladder."); + const table = el("table", {}, [el("thead", {}, [el("tr", {}, ["Priority", "ID", "Alias"].map((h) => el("th", { text: h })))])]); + const tbody = el("tbody"); + for (const m of ladder) { + const isCooled = cooled.has(m.id); + const idCell = el("span", { class: isCooled ? "strike" : "", text: m.id + (isCooled ? " (cooling down)" : "") }); + tbody.appendChild(el("tr", {}, [td("Priority", String(m.priority)), td("ID", idCell), td("Alias", m.alias || "—")])); + } + table.appendChild(tbody); + return table; +} + +// --- Boot -------------------------------------------------------------- + +schedulePoll(); +refreshStatus(); +currentRoute = parseHash(); +renderRoute(currentRoute); diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html new file mode 100644 index 0000000..0ba304a --- /dev/null +++ b/internal/web/assets/index.html @@ -0,0 +1,46 @@ + + + + + + coding-agent-loop + + + + + +
+
+ coding-agent-loop + connecting… +
+ +
+ + + + +
+
+ +
+ + + + + +
+ + + + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..c8ecbea --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,27 @@ +// Package web is the browser console for the control API, compiled into the +// binary so an installed daemon — which is only the executable, see +// internal/install — still serves it with no files on disk. +// +// The embed directive cannot reach outside the directory of the file that +// declares it, which is why the assets live under this package rather than +// being embedded from internal/server. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed assets +var FS embed.FS + +// Assets is FS rooted at the asset directory, so callers see "index.html", +// "app.css", "app.js" directly rather than "assets/index.html". +var Assets = must(fs.Sub(FS, "assets")) + +func must(f fs.FS, err error) fs.FS { + if err != nil { + panic(err) + } + return f +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..31613ab --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,32 @@ +package web + +import ( + "io/fs" + "strings" + "testing" +) + +// TestAssetsPresent guards against a rename that would silently ship a +// console with a missing script or stylesheet: go:embed only fails the +// build if the whole "assets" directory is missing, not if one file inside +// it is renamed out from under index.html's references. +func TestAssetsPresent(t *testing.T) { + for _, name := range []string{"index.html", "app.css", "app.js"} { + if _, err := fs.Stat(Assets, name); err != nil { + t.Fatalf("expected asset %q to exist: %v", name, err) + } + } +} + +func TestIndexReferencesOtherAssets(t *testing.T) { + data, err := fs.ReadFile(Assets, "index.html") + if err != nil { + t.Fatal(err) + } + html := string(data) + for _, ref := range []string{"app.css", "app.js"} { + if !strings.Contains(html, ref) { + t.Errorf("index.html does not reference %q", ref) + } + } +}