diff --git a/AGENTS.md b/AGENTS.md index 7298a2e..b712529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,105 @@ representation. - **A2A** — deliberately not implemented. Cross-org agent meshes are a different layer; revisit only if a concrete need appears. +## Development hub + +`harness hub` is a local, single-operator control surface over a FLEET of +`harness serve` boxes — a fleet dashboard for "what are my agents +doing right now" and for dispatching new goal-supervised sessions, not a +deployed product. It serves one embedded, single-file page +(`tools/hub/index.html`, `go:embed`) on +`localhost:7777` by default (`-addr` to change it). + +- **No server-side state.** The hub keeps no registry and reads no config + file: every box (name, base URL, run token) and the current selection + live only in that browser tab's URL fragment, base64-encoded JSON + (`#s=...`), kept in sync via `history.replaceState`. That makes a hub URL + bookmarkable and shareable between local tabs with zero persistence code + — and means **run tokens ride the URL by design**; treat a hub link like + a secret. +- **The page talks to boxes directly** from the browser, over each box's + normal HTTP+SSE API (`server/openapi.yaml`) — never proxied through the + hub's own server. Every box must therefore be started with `-cors-origin` + set to the hub's origin (or `*` for local hacking), e.g. `harness serve + -cors-origin http://localhost:7777`; a box without it will look + permanently unreachable from the hub. +- **The Go side is minimal on purpose**, exactly one API: `POST /spawn`. + It execs the command given by `-spawn-command` (or `$HARNESS_HUB_SPAWN`) + via `sh -c` and streams its combined stdout+stderr live to the page over + SSE. The **spawn-command contract** — the only coupling between this repo + and any deployment-specific provisioning tool — is plain lines anywhere + in that output: `TUNNEL_URL=` and `RUN_TOKEN=` (required to + add the box), and any number of `PORT_URL_=` lines (optional — + one per exposed port's own tunnel/preview URL, collected into a + `port_urls` map; see the process strip in `tools/hub/index.html`'s header + comment). Once the command exits, the stream ends with a summary carrying + those values (if found) and the exit code; the page adds the new box to + its own URL state itself. Nothing box-provisioning-specific lives in this + repo. + - **Box name passthrough.** `POST /spawn`'s JSON body optionally carries + `{"name": "..."}` — the page's generated (or, on a Respawn/ADOPT, reused) + box name. The Go handler sets it as `HARNESS_HUB_BOX_NAME` in the spawn + command's own environment (`tools/hub/spawn.go`'s `runSpawn`), exactly + the deployment-environment contract `docs/design/fleet-model.md` §8 + specifies: deployment tooling invoked by `-spawn-command` reads this + variable to derive per-name storage (typically setting + `HARNESS_SESSION_DIR` from it before `harness serve` starts) — harness's + own code never reads `HARNESS_HUB_BOX_NAME` at all. A request with no + body, or no `name` field, spawns exactly as before (no env var set). +- The hub binds loopback-only by default (`resolveAddr` in `tools/hub/hub.go`). +- **Browser-security hardening** (both in `tools/hub/hub.go`, tested in + `tools/hub/hub_test.go`). `POST /spawn` execs a real, costly provision + command, so `handleSpawn` rejects a browser cross-origin request before any + exec: if an `Origin` header is present it must match the request's `Host` + (OWASP verify-origin). Loopback binding alone does not stop this — any page + the operator visits can `fetch("http://localhost:7777/spawn",{method: + "POST"})` as a no-preflight CORS simple request — but the page's own + same-origin `fetch("/spawn")` (Origin == Host) and non-browser clients (no + Origin, so not a CSRF vector) pass unchanged. The served page also carries + a strict `Content-Security-Policy` (`default-src 'none'` + `'unsafe-inline'` + script/style — the page is a single no-build `go:embed`'d file with no + external resources and no per-response nonce hook — + `connect-src *`, + required because it fetches/streams from arbitrary operator-added box + origins the stateless hub cannot enumerate, + `frame-ancestors`/`base-uri`/ + `form-action` pinned to `'none'`): defense-in-depth for a page holding run + tokens in its URL fragment. +- **Pure hub logic is unit-tested** by `tools/hub/hub_test.mjs` (run: + `node --test tools/hub/*_test.mjs`). **End-to-end, against a real backend** + is `tools/hub/e2e` (see its README): a `go test -race ./...` subtree that + starts an actual `server.Server` + `hub.NewHandler` and drives the real, + served `index.html` with Node + jsdom and an unmocked `fetch` — no manual + setup step; it installs its own `npm` dependency on first run. + +### UI design language + +The hub is styled as **tactical telemetry** — a committed dark-only +brutalist archetype (derived from the public +[taste-skill](https://github.com/Leonxlnx/taste-skill) brutalist + +anti-slop skills). Any new hub UI — and future passes on the inspector, +which still wears the older soft theme — follows these rules: + +- **One substrate, no theme toggle**: `#0a0a0a` background, `#eaeaea` + phosphor foreground, `#2a2a2a` hairline borders. Never reintroduce a + light mode here; pick-one-and-commit is the point. +- **Two semantic colors only.** Hazard red (`--accent`, `#ff2a2a`) means + trouble or destructive action, nothing else. Terminal green (`--ok`, + `#4af626`) is reserved for exactly one semantic: live or succeeded goal + execution. Everything else is monochrome. +- **Monospace dominance**: body text is the `ui-monospace` stack; + headers are heavy uppercase system-ui. Micro-labels are uppercase with + `.06–.1em` tracking. No webfonts — the page is CSP-self-contained. +- **Geometry**: `border-radius: 0` absolutely everywhere; square status + markers; 1px compartment borders; inverted-video hover + (foreground/background swap). No gradients, soft shadows, or + translucency. The scanline overlay is static — motion requires a + stated purpose. +- **Copy discipline**: no emoji in UI strings, no em-dashes anywhere, and + every piece of "telemetry" displayed must be real data (vcs revisions, + seqs, PIDs, token counts) — never decorative or fabricated metadata. +- **Selectors are load-bearing**: the renderers create elements by class + name (`.sess`, `.box-card`, `.dot`, `.goalnarr`, …). Restyle classes; + never rename them in a styling pass. + ## Fleet model (the deploy story) The full build spec lives in `docs/design/fleet-model.md` — read it before @@ -215,15 +314,14 @@ record) rather than a false "still running" reading. `parent_session` re-dispatch to the task it continues from, so a fleet UI can group a box's history by task across boxes. -**Hub spawn contract:** a hub (external orchestrator, not implemented in -this repo) that spawns boxes passes the generated box NAME to the spawn -command's environment as `HARNESS_HUB_BOX_NAME`, so deployment scripts can -derive per-name storage (e.g. mount/create a volume named after it) without -the hub and the box needing any other side channel to agree on identity. -Harness itself never reads this variable — it is a contract between the hub -and deployment tooling, documented in `docs/design/fleet-model.md` §8. Its -implementation is out of scope here; do not add code that reads or sets it -without checking whether that design has landed first. +**Hub spawn contract:** the hub that spawns boxes — `harness hub`, now +implemented in `tools/hub/` (see the Development hub above) — passes the +generated box NAME to the spawn command's environment as +`HARNESS_HUB_BOX_NAME`, so deployment scripts can derive per-name storage +(e.g. mount/create a volume named after it) without the hub and the box +needing any other side channel to agree on identity. Harness itself never +reads this variable — it is a contract between the hub and deployment +tooling, documented in `docs/design/fleet-model.md` §8. ## Startup Speed Rules diff --git a/cmd/harness/main.go b/cmd/harness/main.go index ce83b38..bbb27bf 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -32,6 +32,7 @@ import ( "github.com/majorcontext/harness/provider/openai" "github.com/majorcontext/harness/provider/openaicompat" "github.com/majorcontext/harness/server" + "github.com/majorcontext/harness/tools/hub" ) // defaultOpenRouterName is the providers map key that gets a built-in @@ -83,6 +84,11 @@ func main() { fmt.Fprintln(os.Stderr, "harness:", err) os.Exit(1) } + case "hub": + if err := hub.Run(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "harness:", err) + os.Exit(1) + } default: usage() os.Exit(2) @@ -101,6 +107,9 @@ func usage() { harness plugin probe re-probe configured plugins and refresh the manifest cache harness sessions [--json] list persisted sessions + harness hub [-addr host:port] [-spawn-command cmd] + serve the local fleet hub UI (see + AGENTS.md's "Development hub" section) harness version print version run flags: diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/server/goal_paused_test.go b/server/goal_paused_test.go index 51a745e..fff7689 100644 --- a/server/goal_paused_test.go +++ b/server/goal_paused_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "fmt" "net/http" @@ -142,6 +143,14 @@ func TestGoalPausedRestartYieldsIdleAndUsable(t *testing.T) { if resp.StatusCode != 202 { t.Fatalf("prompt_async on a paused/idle session status = %d, want 202: %s", resp.StatusCode, data) } + + // The prompt above is admitted asynchronously (202): srv2 spawns a + // runPrompt goroutine, tracked by srv2.wg, that keeps writing the session + // journal into dir (== t.TempDir()). Drain blocks on wg.Wait until that + // goroutine finishes, so no writer survives into t.TempDir()'s RemoveAll + // cleanup — otherwise a journal write races the directory removal and the + // cleanup fails with "directory not empty" under load. + srv2.Drain(context.Background()) } // TestGoalStalledProviderBackoffSurfacesPaused is deliverable 2(b)'s wire diff --git a/tools/hub/e2e/.gitignore b/tools/hub/e2e/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tools/hub/e2e/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tools/hub/e2e/README.md b/tools/hub/e2e/README.md new file mode 100644 index 0000000..89ca2a0 --- /dev/null +++ b/tools/hub/e2e/README.md @@ -0,0 +1,65 @@ +# tools/hub/e2e — real-backend verification for the hub page + +`tools/hub/index.html` is a single, build-free HTML file with zero +dependencies (see its own header comment) — that does not change here. +This directory is a separate, isolated, npm-based verification *tool* that +proves the page's incremental-rendering behavior against a **real** running +harness backend, not hand-rolled mocks. + +## What it checks + +`real_e2e.mjs`, driven by `e2e_test.go`, starts: + +- a real `server.Server` (`stub.go`'s `Start`, the same wiring as + `harness serve`) backed by a small scripted provider (no API key needed), + and +- a real `hub.NewHandler` (the same wiring as `harness hub`), serving the + *actual* embedded `tools/hub/index.html`, + +then loads that real page in [jsdom](https://github.com/jsdom/jsdom) with +Node's own, **unmocked** `fetch` — real HTTP requests, real SSE streams, +real engine turns. It confirms: + +1. the hub server serves `tools/hub/index.html` byte-for-byte (production + wiring, not a stale copy); +2. a populated URL fragment renders its box skeleton synchronously — no + "no boxes yet" flash; +3. a real `/health`+`/session` poll resolves to a healthy dot and a real + `vcs_revision`, and the box card's DOM node survives a real session + being created (no needless rebuild); +4. a real engine turn renders as a keyed, durable message; expanding its + reasoning block survives a **second** real, server-driven turn — the + keyed, append-only timeline, exercised over an actual SSE stream, not a + simulated one; +5. a scrolled-up timeline is left alone by a real subsequent render + (pinned-tail autoscroll). + +## Running it + +No manual setup step is required. Just run the same command already used to +verify this repo: + +```sh +go test -race ./... # or narrower: go test ./tools/hub/e2e/... +``` + +`TestRealEndToEnd` installs its own dependency (`npm ci`, using the +package-lock.json committed here) the first time it runs if jsdom isn't +already present in this directory, then drives the real check. `node` (and +therefore `npm`, which ships with it) is already a hard requirement of this +repo's `node --test tools/hub/*_test.mjs` check, so this test only skips in +the one case where that other required command would ALSO be unrunnable — +no Node toolchain on `PATH` at all. It fails loudly (not a silent skip) if +`node`/`npm` ARE present but the dependency install itself fails (e.g. no +network access to npm's registry on first run) — an offline environment is +a real verification gap, not something to paper over. + +To drive it by hand instead (e.g. to poke at the real backend from an +actual browser): + +```sh +cd tools/hub/e2e && npm ci # only needed once, if not already run by the test above +go run ./tools/hub/e2e/hubverify # prints {"box_base":...,"hub_base":...,"token":...}, then blocks +node tools/hub/e2e/real_e2e.mjs # in another shell +# or open hub_base in a real browser and "+ Add box" with box_base + token by hand +``` diff --git a/tools/hub/e2e/e2e_test.go b/tools/hub/e2e/e2e_test.go new file mode 100644 index 0000000..5173eb4 --- /dev/null +++ b/tools/hub/e2e/e2e_test.go @@ -0,0 +1,121 @@ +package e2e + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +// TestRealEndToEnd starts a REAL box server + REAL hub server (see Start) +// and drives the ACTUAL tools/hub/index.html against them with Node + +// jsdom, using Node's own unmocked fetch — real HTTP, real SSE, real engine +// turns. This is the automated counterpart to the header comment's +// hand-test checklist: it exists so plain `go test -race ./...` — the exact +// command already used to verify this repo, no extra step required — +// checks, without any manual browser session, that: +// - the hub server serves tools/hub/index.html byte-for-byte (production +// wiring, not a stale copy); +// - a populated URL fragment renders its box skeleton synchronously, no +// "no boxes yet" flash; +// - a real health/session poll resolves to a healthy dot, and the box +// card's DOM node survives a real session being created; +// - a real engine turn (via the scripted provider in this package) renders +// as a keyed durable message; expanding its reasoning block survives a +// second real, server-driven turn — the actual keyed append-only +// timeline behavior, exercised over a real SSE stream; +// - a scrolled-up viewport is left alone by a real subsequent render +// (pinned-tail autoscroll). +// +// Dependency setup is automatic, not a documented manual prerequisite: if +// jsdom isn't already installed in this directory, the test runs +// `npm ci` (falling back to `npm install` if there is no lockfile-clean +// install available) itself before driving real_e2e.mjs, using the +// package.json/package-lock.json committed alongside this file. `node` (and +// therefore `npm`, which ships with it) is already a hard requirement of +// this repo's own `node --test tools/hub/*_test.mjs` check, so the only way +// this test skips is the one case where that other required command would +// ALSO be unrunnable: no Node toolchain on PATH at all. Any environment +// that can run the three documented verification commands runs this test +// for real, every time. +func TestRealEndToEnd(t *testing.T) { + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("node not found on PATH — this environment could not run `node --test tools/hub/*_test.mjs` either; skipping real end-to-end hub verification") + } + npmPath, err := exec.LookPath("npm") + if err != nil { + t.Skip("npm not found on PATH (normally ships with node); skipping real end-to-end hub verification") + } + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not determine tools/hub/e2e directory") + } + dir := filepath.Dir(thisFile) + script := filepath.Join(dir, "real_e2e.mjs") + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "jsdom")); err != nil { + installDeps(t, npmPath, dir) + } + + stub, err := Start() + if err != nil { + t.Fatalf("starting the real box/hub stub servers: %v", err) + } + defer stub.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, nodePath, script, stub.BoxBase, stub.HubBase, stub.Token) + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + runErr := cmd.Run() + t.Log(out.String()) + if runErr != nil { + t.Fatalf("real_e2e.mjs failed: %v", runErr) + } +} + +// installDeps runs `npm ci` (a clean, lockfile-exact install, using the +// package-lock.json committed in this directory) to fetch jsdom before the +// real end-to-end check needs it, so a fresh clone requires no manual setup +// step beyond having node/npm on PATH. Falls back to `npm install` if `npm +// ci` itself is unavailable in this npm version (older npm predates it). +// Requires network access to npm's registry; a genuinely offline CI run +// fails loudly here (t.Fatalf) rather than silently skipping the real +// check — an offline environment is a real gap in verification, not a +// reason to pretend everything passed. +func installDeps(t *testing.T, npmPath, dir string) { + t.Helper() + t.Logf("jsdom not present in %s; running npm ci to install it (see package.json/package-lock.json)", dir) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, npmPath, "ci", "--no-audit", "--no-fund") + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + t.Logf("npm ci failed (%v), output:\n%s\nfalling back to npm install", err, out.String()) + cmd = exec.CommandContext(ctx, npmPath, "install", "--no-audit", "--no-fund") + cmd.Dir = dir + out.Reset() + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + t.Fatalf("npm install failed too (%v); real end-to-end hub verification requires network access to npm's registry on first run:\n%s", err, out.String()) + } + } + t.Log(out.String()) + if _, err := os.Stat(filepath.Join(dir, "node_modules", "jsdom")); err != nil { + t.Fatalf("jsdom still missing from %s/node_modules after npm install; something is wrong with the dependency install", dir) + } +} diff --git a/tools/hub/e2e/hubverify/main.go b/tools/hub/e2e/hubverify/main.go new file mode 100644 index 0000000..58c6e68 --- /dev/null +++ b/tools/hub/e2e/hubverify/main.go @@ -0,0 +1,42 @@ +// Command hubverify starts the REAL box + hub servers from tools/hub/e2e +// (see that package's doc comment) and prints their addresses/token as one +// line of JSON, then blocks forever. It exists for manual, by-hand +// verification of the hub page against a real backend — e.g. to run +// real_e2e.mjs directly, or to open a browser at the printed hub_base and +// click around by hand: +// +// go run ./tools/hub/e2e/hubverify +// # -> {"box_base":"http://127.0.0.1:NNNNN","hub_base":"http://127.0.0.1:MMMMM","token":"verify-token"} +// # open hub_base in a browser, "+ Add box" with box_base + token. +// +// e2e_test.go is the automated counterpart: it starts the same stub +// in-process (via e2e.Start, no subprocess) and drives the hub page with +// Node + jsdom itself, so `go test ./tools/hub/e2e/...` (part of the +// standard `go test -race ./...`) exercises this without any manual step. +package main + +import ( + "encoding/json" + "os" + + "github.com/majorcontext/harness/tools/hub/e2e" +) + +func main() { + stub, err := e2e.Start() + if err != nil { + panic(err) + } + defer stub.Close() + + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(map[string]string{ + "box_base": stub.BoxBase, + "hub_base": stub.HubBase, + "token": stub.Token, + }); err != nil { + panic(err) + } + select {} // block forever; kill the process to stop +} diff --git a/tools/hub/e2e/package-lock.json b/tools/hub/e2e/package-lock.json new file mode 100644 index 0000000..9ce7405 --- /dev/null +++ b/tools/hub/e2e/package-lock.json @@ -0,0 +1,477 @@ +{ + "name": "harness-hub-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harness-hub-e2e", + "dependencies": { + "jsdom": "^29.1.1" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + } + } +} diff --git a/tools/hub/e2e/package.json b/tools/hub/e2e/package.json new file mode 100644 index 0000000..f77cdfd --- /dev/null +++ b/tools/hub/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "harness-hub-e2e", + "private": true, + "description": "Isolated verification tooling for tools/hub/index.html's real-backend end-to-end check (real_e2e.mjs, driven by e2e_test.go). Deliberately kept in its own package.json, separate from the shipped hub page itself, which stays a single build-free HTML file with zero dependencies — see tools/hub/index.html's header comment.", + "type": "module", + "scripts": { + "e2e": "node real_e2e.mjs" + }, + "dependencies": { + "jsdom": "^29.1.1" + } +} diff --git a/tools/hub/e2e/real_e2e.mjs b/tools/hub/e2e/real_e2e.mjs new file mode 100644 index 0000000..e54460c --- /dev/null +++ b/tools/hub/e2e/real_e2e.mjs @@ -0,0 +1,194 @@ +// REAL end-to-end verification of tools/hub/index.html's incremental +// rendering (see the header comment in index.html and AGENTS.md's +// "Development hub" section for the behaviors this checks): drives the +// ACTUAL page served by a REAL hub HTTP handler (byte-identical to +// tools/hub/index.html — see the diff check below) against a REAL running +// harness server (tools/hub/e2e's Stub — same wiring as `harness serve` and +// `harness hub`), using jsdom + Node's own, UNMOCKED fetch. Nothing in this +// file simulates HTTP/SSE traffic; every request below is a real socket +// round-trip to the servers e2e_test.go (or hubverify) started. +// +// Expects three arguments: (see +// tools/hub/e2e/stub.go's Start / hubverify's printed JSON). Exits non-zero +// on any failed assertion, printing the failure to stderr. +// +// Requires "jsdom" (see tools/hub/e2e/package.json — `npm install` once in +// this directory). Run directly with: +// go run ./tools/hub/e2e/hubverify # prints {"box_base":...,"hub_base":...,"token":...} +// node tools/hub/e2e/real_e2e.mjs +// or let `go test ./tools/hub/e2e/...` drive both automatically. +import { JSDOM } from "jsdom"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const [, , boxBase, hubBase, token] = process.argv; +if (!boxBase || !hubBase || !token) { + console.error("usage: node real_e2e.mjs "); + process.exit(2); +} +console.error("box:", boxBase, "hub:", hubBase); + +const here = dirname(fileURLToPath(import.meta.url)); +const committedIndexHTML = readFileSync(join(here, "..", "index.html"), "utf8"); + +// jsdom does not implement the HTML Popover API (showPopover/hidePopover/ +// togglePopover and :popover-open). The hub's card overflow menu calls +// hidePopover() at the TOP of its item handlers (index.html's card menu), +// so without these methods the real page throws mid-handler and the item's +// real work (e.g. quickNewSession) never runs. Install minimal stubs that +// track open state via the popover-open attribute, exactly as a real browser +// would toggle it, so the served page runs unmodified. Same category of fix +// as the fetch/requestAnimationFrame/AbortController patches below: supply a +// browser capability jsdom lacks, never alter the product code to suit it. +function installPopoverPolyfill(w) { + const proto = w.HTMLElement.prototype; + if (typeof proto.showPopover === "function") return; + proto.showPopover = function () { this.setAttribute("popover-open", ""); }; + proto.hidePopover = function () { this.removeAttribute("popover-open"); }; + proto.togglePopover = function (force) { + const next = force === undefined ? !this.hasAttribute("popover-open") : !!force; + if (next) this.showPopover(); else this.hidePopover(); + return next; + }; +} + +async function main() { + // ---- 0. The hub server must be serving the EXACT committed file (proves + // this isn't drifted/stale wiring — the production `harness hub` binary + // embeds this same tools/hub/index.html via go:embed). ---- + const servedHTML = await (await fetch(hubBase + "/")).text(); + assert.equal(servedHTML, committedIndexHTML, "the hub server must serve tools/hub/index.html byte-for-byte"); + console.error("PASS: hub server serves the committed index.html byte-for-byte"); + + // ---- 1. Build a URL fragment with this real box, then load the real + // page with that fragment, using jsdom's real (Node global) fetch + // throughout, and a real AbortController (jsdom's own AbortController + // produces AbortSignal instances real undici fetch rejects as foreign). ---- + const bootDom = new JSDOM(servedHTML, { + url: hubBase + "/", + runScripts: "dangerously", resources: "usable", pretendToBeVisual: true, + beforeParse(w) { w.fetch = fetch; w.requestAnimationFrame = (cb) => setTimeout(cb, 0); }, + }); + const encoded = bootDom.window.encodeHubState({ + boxes: [{ id: "b1", name: "real-box", base: boxBase, token }], + view: {}, notify: false, + }); + bootDom.window.close(); + + const dom = new JSDOM(servedHTML, { + url: hubBase + "/#" + encoded, + runScripts: "dangerously", resources: "usable", pretendToBeVisual: true, + beforeParse(w) { + w.fetch = fetch; + w.requestAnimationFrame = (cb) => setTimeout(cb, 0); + w.AbortController = AbortController; // real Node AbortController, compatible with real fetch + installPopoverPolyfill(w); + }, + }); + const w = dom.window; + const doc = w.document; + + // ---- 2. Synchronous first paint: no empty-state flash with a populated fragment. ---- + assert.ok(!doc.getElementById("fleet").textContent.includes("no boxes yet"), "must not flash empty state"); + assert.ok(doc.querySelector(".box-card"), "box card must render synchronously"); + console.error("PASS: real page, synchronous skeleton on load, no empty-state flash"); + + // ---- 3. Real health/session poll lands; dot turns healthy. ---- + await new Promise((r) => setTimeout(r, 300)); + const dot = doc.querySelector(".dot"); + assert.ok(dot.classList.contains("on"), "dot should be healthy after the real /health poll: " + dot.className); + // vcs_revision comes from Go's build-info VCS stamping, which only embeds + // when the module's working tree is clean at build time (go help + // buildmode's -buildvcs). That's an artifact of running this check from a + // dirty tree mid-development, not a hub behavior under test — accept + // either a real hex prefix (clean tree) or the "…" placeholder the hub + // renders for a healthy box with no reported revision, as long as it's + // not still the loading ellipsis's sibling "unreachable"/stale state. + const meta = doc.querySelector(".box-meta").textContent; + assert.ok(/^[0-9a-f]{10}$/.test(meta) || meta === "\u2026", "box-meta should show a real vcs_revision or the no-revision placeholder, got: " + meta); + console.error("PASS: real /health poll resolved, dot healthy, box-meta:", meta); + + const cardBeforeSessionCreate = doc.querySelector(".box-card"); + + // ---- 4. Create a real session via the real "+ New session" button. ---- + const buttons = [...doc.querySelectorAll(".box-actions button")]; + const newSessionBtn = buttons.find((b) => b.textContent.includes("New session")); + newSessionBtn.click(); + await new Promise((r) => setTimeout(r, 300)); + const sessRow = doc.querySelector(".sess"); + assert.ok(sessRow, "a real session row should appear"); + assert.strictEqual(doc.querySelector(".box-card"), cardBeforeSessionCreate, "box card DOM node must survive a real session being added"); + console.error("PASS: real session created, box card DOM node stable"); + + // ---- 5. Timeline: send a real prompt to the scripted provider, expand + // its reasoning block, send a second real prompt, confirm the first + // message's node + expand state survive (keyed append-only against a + // REAL server-driven SSE stream, not a mocked one). ---- + const promptBox = doc.getElementById("promptBox"); + const sendBtn = doc.getElementById("sendBtn"); + promptBox.value = "hello"; + sendBtn.click(); + + let reasoningDetails = null; + for (let i = 0; i < 60 && !reasoningDetails; i++) { + await new Promise((r) => setTimeout(r, 150)); + reasoningDetails = doc.querySelector("#timeline .tl-messages details.reason"); + } + assert.ok(reasoningDetails, "a real reasoning block should render in the durable message"); + const firstMsgTexts = [...doc.querySelectorAll("#timeline .tl-messages .msg .text")]; + const firstMsgText = firstMsgTexts.find((n) => /reply number \d+/.test(n.textContent)); + assert.ok(firstMsgText, "the real scripted provider's first reply should render among: " + firstMsgTexts.map((n) => n.textContent).join(" | ")); + const firstReplyNum = firstMsgText.textContent.match(/reply number (\d+)/)[1]; + reasoningDetails.open = true; + console.error("PASS: real turn " + firstReplyNum + " rendered (reasoning block + text), expanded it"); + + for (let i = 0; i < 60 && sendBtn.disabled; i++) await new Promise((r) => setTimeout(r, 100)); + promptBox.value = "again"; + sendBtn.click(); + let secondMsg = null; + const secondReplyNum = String(Number(firstReplyNum) + 1); + for (let i = 0; i < 60 && !secondMsg; i++) { + await new Promise((r) => setTimeout(r, 150)); + const texts = [...doc.querySelectorAll("#timeline .tl-messages .msg .text")]; + secondMsg = texts.find((n) => n.textContent.includes("reply number " + secondReplyNum)); + } + assert.ok(secondMsg, "a real second reply (number " + secondReplyNum + ") should render"); + const reasoningDetailsAfter = doc.querySelector("#timeline .tl-messages details.reason"); + assert.strictEqual(reasoningDetailsAfter, reasoningDetails, "the first message's reasoning node must be the SAME DOM node after a second real turn (keyed append-only, not a rebuild)"); + assert.equal(reasoningDetailsAfter.open, true, "the first message's expanded reasoning block must survive a second real server-driven render"); + console.error("PASS: real second turn appended without disturbing the first message's node/expand state"); + + // ---- 6. Pinned-tail autoscroll against real renders: scroll up, confirm + // a real subsequent render does not yank the viewport back down. ---- + const tl = doc.getElementById("timeline"); + Object.defineProperty(tl, "scrollHeight", { value: 2000, configurable: true }); + Object.defineProperty(tl, "clientHeight", { value: 100, configurable: true }); + tl.scrollTop = 0; + // jsdom does not synthesize a "scroll" event from a plain property write + // the way a real browser's layout engine does (there is no real layout + // here at all) — dispatch one explicitly so the page's own scroll + // listener (index.html's renderTimeline) sees the "user scrolled up" + // signal exactly as it would from a real user action, and flips + // tlDom.stick accordingly. + tl.dispatchEvent(new w.Event("scroll")); + for (let i = 0; i < 60 && sendBtn.disabled; i++) await new Promise((r) => setTimeout(r, 100)); + promptBox.value = "third"; + sendBtn.click(); + await new Promise((r) => setTimeout(r, 800)); + assert.equal(tl.scrollTop, 0, "a scrolled-up viewport must not be moved by a real subsequent render"); + console.error("PASS: scrolled-up position survives real new messages"); + + dom.window.close(); + console.error("ALL REAL END-TO-END CHECKS PASSED"); +} + +// The hub page's own poll/reconnect intervals (see index.html's +// HUB_POLL_MS and connectBoxStream backoff) keep timers pending in jsdom's +// window even after dom.window.close(), which would otherwise leave this +// process alive indefinitely — force a clean, explicit exit instead of +// waiting on the event loop to drain. +main() + .then(() => process.exit(0)) + .catch((e) => { console.error(e); process.exit(1); }); diff --git a/tools/hub/e2e/stub.go b/tools/hub/e2e/stub.go new file mode 100644 index 0000000..0d43f0b --- /dev/null +++ b/tools/hub/e2e/stub.go @@ -0,0 +1,195 @@ +// Package e2e provides a REAL, non-mocked backend for verifying tools/hub's +// incremental-rendering behavior end-to-end: a real server.Server (the same +// wiring as `harness serve`) fed by a small scripted provider (no API key +// needed), and a real hub.NewHandler serving the ACTUAL embedded +// tools/hub/index.html (the same wiring as `harness hub`). Nothing here +// mocks the HTTP/SSE transport — the page's own fetch/EventSource-style +// reader loop talks to a real net/http server over a real loopback socket. +// +// This exists specifically to answer "does the incremental-rendering fix +// actually work against a real harness box, or only against hand-rolled +// mocks in a JS unit test?" — see e2e_test.go, which drives the real page +// (via Node + jsdom, since this repo's UI has no other DOM available) with +// Node's own, unmocked fetch against the servers Start returns. +package e2e + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "sync" + + "github.com/majorcontext/harness/engine" + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" + "github.com/majorcontext/harness/server" + "github.com/majorcontext/harness/tools/hub" +) + +// RunToken is the fixed run token the stub box authenticates with. +const RunToken = "verify-token" + +// ProviderName is the scripted provider's family, used as the model's +// provider segment (e.g. "verify/m1"). +const ProviderName = "verify" + +// scriptedProvider serves one canned turn per call: a reasoning delta, a +// text delta, then a done event carrying the fully assembled message — +// enough to exercise the hub's reasoning-block + streaming-draft + +// durable-message rendering without needing a real model API key. Turns are +// numbered from 1 and are distinguishable in the rendered text ("reply +// number N"), so a test can tell turns apart without needing exact counts. +type scriptedProvider struct { + mu sync.Mutex + call int +} + +func (p *scriptedProvider) Name() string { return ProviderName } + +func (p *scriptedProvider) Stream(_ context.Context, _ *provider.Request) (provider.Stream, error) { + p.mu.Lock() + n := p.call + p.call++ + p.mu.Unlock() + text := fmt.Sprintf("reply number %d", n+1) + reasoning := fmt.Sprintf("thinking about turn %d", n+1) + msg := &message.Message{ + ID: message.ProviderCallID("m", text, 16), + Role: message.RoleAssistant, + Parts: message.Parts{ + &message.Reasoning{Text: reasoning}, + &message.Text{Text: text}, + }, + } + events := []provider.Event{ + {Type: provider.EventReasoningDelta, Text: reasoning}, + {Type: provider.EventTextDelta, Text: text}, + {Type: provider.EventDone, Message: msg, StopReason: provider.StopEndTurn}, + } + return &scriptedStream{events: events}, nil +} + +type scriptedStream struct { + events []provider.Event + i int +} + +func (s *scriptedStream) Next() (provider.Event, error) { + if s.i >= len(s.events) { + return provider.Event{}, io.EOF + } + ev := s.events[s.i] + s.i++ + return ev, nil +} + +func (s *scriptedStream) Close() error { return nil } + +// Stub is a running (box server, hub server) pair plus its teardown. +type Stub struct { + BoxBase string // e.g. "http://127.0.0.1:54321" — a real harness serve-equivalent + HubBase string // e.g. "http://127.0.0.1:54322" — a real harness hub-equivalent, serving the real index.html + Token string + + boxLn net.Listener + hubLn net.Listener + srv *server.Server + sessionDir string +} + +// Start builds and starts a real box server (server.New, scripted provider, +// CORS wide open) and a real hub server (hub.NewHandler, the actual embedded +// index.html) on loopback, each on an OS-assigned port. Close tears both +// down. SessionDir is a fresh temp directory (real on-disk journal, same as +// production), removed by Close. +func Start() (*Stub, error) { + dir, err := os.MkdirTemp("", "hub-e2e-sessions") + if err != nil { + return nil, err + } + reg := provider.Registry{ProviderName: &scriptedProvider{}} + model := message.ModelRef{Provider: ProviderName, Model: "m1"} + + var srv *server.Server + mkCfg := func(m message.ModelRef) engine.Config { + return engine.Config{ + Providers: reg, + Model: m, + WorkDir: dir, + SessionDir: dir, + OnEvent: func(ev engine.Event) { srv.Publish(ev) }, + } + } + srv, err = server.New(server.Options{ + SessionDir: dir, + RunToken: RunToken, + Version: "hub-e2e", + CORSOrigin: "*", + NewSession: func(m message.ModelRef, workDir string, parentSession string) (*engine.Session, error) { + if m.Provider == "" { + m = model + } + cfg := mkCfg(m) + cfg.WorkDir = workDir + cfg.ParentSession = parentSession + return engine.NewSession(cfg), nil + }, + LoadSession: func(id string) (*engine.Session, error) { + return engine.LoadSession(mkCfg(model), id) + }, + }) + if err != nil { + os.RemoveAll(dir) //nolint:errcheck + return nil, err + } + + boxLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + srv.Close() //nolint:errcheck + os.RemoveAll(dir) + return nil, err + } + hubLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + boxLn.Close() //nolint:errcheck + srv.Close() //nolint:errcheck + os.RemoveAll(dir) + return nil, err + } + + go http.Serve(boxLn, srv) //nolint:errcheck + go http.Serve(hubLn, hub.NewHandler(hub.Options{})) //nolint:errcheck + + return &Stub{ + BoxBase: "http://" + boxLn.Addr().String(), + HubBase: "http://" + hubLn.Addr().String(), + Token: RunToken, + boxLn: boxLn, + hubLn: hubLn, + srv: srv, + sessionDir: dir, + }, nil +} + +// Close tears down both listeners, closes the server, and removes the +// temporary session directory. Safe to defer immediately after Start. +func (s *Stub) Close() { + if s == nil { + return + } + if s.boxLn != nil { + s.boxLn.Close() //nolint:errcheck + } + if s.hubLn != nil { + s.hubLn.Close() //nolint:errcheck + } + if s.srv != nil { + s.srv.Close() //nolint:errcheck + } + if s.sessionDir != "" { + os.RemoveAll(s.sessionDir) //nolint:errcheck + } +} diff --git a/tools/hub/hub.go b/tools/hub/hub.go new file mode 100644 index 0000000..e667ad0 --- /dev/null +++ b/tools/hub/hub.go @@ -0,0 +1,229 @@ +package hub + +import ( + "bufio" + "context" + _ "embed" + "encoding/json" + "flag" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "syscall" + "time" +) + +//go:embed index.html +var indexHTML []byte + +// defaultAddr is deliberately loopback-only: the hub is a local, single- +// operator dev tool (see AGENTS.md, "Development hub") and never listens on +// every interface by default. +const defaultAddr = "localhost:7777" + +// spawnCommandEnv is the environment-variable fallback for -spawn-command, +// so a wrapper script can configure the hub without a flag. +const spawnCommandEnv = "HARNESS_HUB_SPAWN" + +// contentSecurityPolicy hardens the served page (defense-in-depth for a page +// that carries run tokens in its URL fragment). The hub is a single inline +// file that loads NO external resources, so default-src 'none' blocks every +// external fetch/script/style/image/frame; the page's own inline script and +// inline style attributes are permitted with 'unsafe-inline' (a per-response +// nonce/hash is not viable on a byte-for-byte go:embed'd, no-build file); +// connect-src * is required because the page fetches/streams from arbitrary, +// operator-added box origins the hub cannot enumerate (it keeps no state). +// frame-ancestors/base-uri/form-action are pinned to 'none' explicitly since +// they do not inherit from default-src. +const contentSecurityPolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src *; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" + +// Options configures a hub server. The zero value is not directly useful; +// Run below builds one from flags/env for the `harness hub` subcommand, but +// tests construct Options directly to avoid touching flags or the process +// environment. +type Options struct { + // SpawnCommand is executed via `sh -c` by POST /spawn. Empty disables + // spawning: the endpoint reports the "no spawn command configured" + // error from runSpawn rather than failing to start the hub itself — a + // hub with no spawn command configured is still useful for driving + // boxes added by hand. + SpawnCommand string +} + +// NewHandler builds the hub's HTTP handler: the embedded page at "/" and +// the single POST /spawn API described in AGENTS.md. Everything else the +// page needs (session state, box CRUD) is client-side — see index.html. +func NewHandler(opts Options) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/spawn", handleSpawn(opts)) + return mux +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", contentSecurityPolicy) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + w.Write(indexHTML) //nolint:errcheck + } +} + +// spawnRequest is POST /spawn's optional JSON body: {"name": "..."} passes +// the box name the page generated (or is re-using for a Respawn/ADOPT) — +// see AGENTS.md's spawn contract section and runSpawn's `name` parameter. +// A missing or empty body is fine (no name passthrough), matching every +// spawn command that predates this field. +type spawnRequest struct { + Name string `json:"name"` +} + +// isCrossOrigin reports whether r is a browser cross-origin request: an +// Origin header is present and names a host other than the one the request +// was addressed to (r.Host). This is the CSRF guard for the state-changing +// POST /spawn, which execs the deployment provision command. A browser +// attaches Origin to every cross-origin POST, so an attacker page's +// fetch("http://localhost:7777/spawn") is rejected (its Origin is the +// attacker's, not the hub's). The hub page's own same-origin fetch sends +// Origin == Host and passes; a request with no Origin at all (curl, scripts, +// server-side callers — no ambient browser credentials, so not a CSRF +// vector) also passes. An Origin we cannot parse, or the opaque "null" +// origin a sandboxed context sends, is treated as cross-origin. +func isCrossOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return true + } + return u.Host != r.Host +} + +// handleSpawn streams runSpawn's events to the client as an SSE response. +// The request context is what runSpawn's exec.CommandContext keys off of, +// so a client disconnect kills the spawn process directly — see spawn.go. +func handleSpawn(opts Options) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // CSRF guard: reject a browser cross-origin POST before any exec. + if isCrossOrigin(r) { + http.Error(w, "cross-origin request rejected", http.StatusForbidden) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + var req spawnRequest + if r.Body != nil { + // A body is entirely optional (an empty POST is the pre-existing, + // still-supported contract): only a non-EOF decode error is a real + // problem, and even then we don't fail the request over it — a + // malformed body just means no name passthrough, same as none. + _ = json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + bw := bufio.NewWriter(w) + runSpawn(r.Context(), opts.SpawnCommand, req.Name, func(ev spawnEvent) { + bw.Write(ev.marshal()) //nolint:errcheck + bw.Flush() //nolint:errcheck + flusher.Flush() + }) + } +} + +// resolveAddr applies -addr's default and documents the loopback-by-default +// promise: an address with an empty or unspecified host (e.g. ":7777" or +// "0.0.0.0:7777") is passed through as given — the operator asked for it +// explicitly by supplying -addr — but the flag's own default is always the +// loopback address, so doing nothing at all stays local. +func resolveAddr(addr string) string { + if addr == "" { + return defaultAddr + } + return addr +} + +// spawnCommandFromEnv resolves -spawn-command's fallback: the +// HARNESS_HUB_SPAWN environment variable, consulted only when the flag was +// not passed at all (flagSet, not merely flagValue == "", so an explicit +// -spawn-command ” can still disable spawning without env clobbering it). +func spawnCommandFromEnv(flagValue string, flagSet bool, getenv func(string) string) string { + if flagSet { + return flagValue + } + if v := getenv(spawnCommandEnv); v != "" { + return v + } + return flagValue +} + +// Run implements the `harness hub` subcommand: parse flags, build the +// handler, serve until interrupted. It is the only network-facing entry +// point in this package — NewHandler/Options above are what tests exercise +// directly. +func Run(args []string) error { + fs := flag.NewFlagSet("hub", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var addr string + fs.StringVar(&addr, "addr", defaultAddr, "listen address (loopback by default — this is a local, single-operator tool)") + var spawnCommand string + fs.StringVar(&spawnCommand, "spawn-command", "", "shell command (run via `sh -c`) that POST /spawn execs to bring up a new box; falls back to $"+spawnCommandEnv+"; see AGENTS.md's spawn-command contract") + if err := fs.Parse(args); err != nil { + return err + } + var spawnFlagSet bool + fs.Visit(func(f *flag.Flag) { + if f.Name == "spawn-command" { + spawnFlagSet = true + } + }) + spawnCommand = spawnCommandFromEnv(spawnCommand, spawnFlagSet, os.Getenv) + + handler := NewHandler(Options{SpawnCommand: spawnCommand}) + httpSrv := &http.Server{Addr: resolveAddr(addr), Handler: handler} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + ln, err := net.Listen("tcp", httpSrv.Addr) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "harness hub listening on http://%s\n", ln.Addr()) + + errc := make(chan error, 1) + go func() { errc <- httpSrv.Serve(ln) }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return httpSrv.Shutdown(shutCtx) + } +} diff --git a/tools/hub/hub_test.go b/tools/hub/hub_test.go new file mode 100644 index 0000000..93385d5 --- /dev/null +++ b/tools/hub/hub_test.go @@ -0,0 +1,333 @@ +package hub + +import ( + "bufio" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleIndexServesEmbeddedPage(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html prefix", ct) + } +} + +// TestHandleIndexSetsCSP asserts the served page carries a restrictive +// Content-Security-Policy: defense-in-depth for a page that holds run tokens +// in its URL fragment. The policy must still permit the page's own inline +// script/style and its fetch/SSE to arbitrary box origins (connect-src *), +// while blocking framing and every external resource load. +func TestHandleIndexSetsCSP(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + csp := resp.Header.Get("Content-Security-Policy") + if csp == "" { + t.Fatal("no Content-Security-Policy header on the served page") + } + for _, want := range []string{ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + "connect-src *", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", + } { + if !strings.Contains(csp, want) { + t.Errorf("CSP %q missing directive %q", csp, want) + } + } +} + +func TestHandleIndexRejectsUnknownPaths(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/nope") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestHandleIndexRejectsNonGet(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/", "text/plain", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } +} + +func TestHandleSpawnRejectsGet(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo hi"})) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/spawn") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } +} + +// TestHandleSpawnRejectsCrossOrigin is the CSRF guard: POST /spawn execs the +// deployment provision command (real side effects and cost), so a browser +// cross-origin request — one whose Origin names a host other than the hub's +// own — must be rejected before any exec. Without this, any page the operator +// visits could fetch("http://localhost:7777/spawn",{method:"POST"}) as a +// no-preflight CORS simple request and trigger a real box spawn. +func TestHandleSpawnRejectsCrossOrigin(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo should-not-run"})) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/spawn", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Origin", "http://evil.example") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin POST status = %d, want 403", resp.StatusCode) + } +} + +// TestHandleSpawnAllowsSameOrigin confirms the CSRF guard is transparent to +// the real page: a same-origin POST (Origin host == request Host, which is +// exactly what the hub page's own fetch sends) runs normally. +func TestHandleSpawnAllowsSameOrigin(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo TUNNEL_URL=https://x.example; echo RUN_TOKEN=tok"})) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/spawn", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Origin", srv.URL) // the served page's origin == the hub's own + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("same-origin POST status = %d, want 200", resp.StatusCode) + } +} + +func TestHandleSpawnStreamsSSEFrames(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo TUNNEL_URL=https://x.example; echo RUN_TOKEN=tok123"})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("Content-Type = %q, want text/event-stream", ct) + } + + body, err := readAllLines(resp.Body) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(body, "\n") + if !strings.Contains(joined, `"tunnel_url":"https://x.example"`) { + t.Errorf("body missing tunnel_url; got:\n%s", joined) + } + if !strings.Contains(joined, `"run_token":"tok123"`) { + t.Errorf("body missing run_token; got:\n%s", joined) + } + if !strings.Contains(joined, `"type":"done"`) { + t.Errorf("body missing done event; got:\n%s", joined) + } +} + +// TestHandleSpawnPassesNameAsBoxNameEnv is the HTTP-level half of +// TestRunSpawnSetsBoxNameEnv (spawn_test.go): POST /spawn's JSON body +// {"name": "..."} must reach the spawn command's own environment as +// HARNESS_HUB_BOX_NAME — see AGENTS.md's spawn contract section. +func TestHandleSpawnPassesNameAsBoxNameEnv(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: `echo "NAME=$HARNESS_HUB_BOX_NAME"`})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", strings.NewReader(`{"name":"amber-otter-07"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := readAllLines(resp.Body) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(body, "\n") + if !strings.Contains(joined, "NAME=amber-otter-07") { + t.Errorf("body missing box name passthrough; got:\n%s", joined) + } +} + +// TestHandleSpawnToleratesMissingBody confirms the pre-existing "no body at +// all" call (every caller before this field existed) still works exactly +// as before. +func TestHandleSpawnToleratesMissingBody(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{SpawnCommand: "echo hi"})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestHandleSpawnNoCommandConfiguredReportsErrorInStream(t *testing.T) { + srv := httptest.NewServer(NewHandler(Options{})) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/spawn", "application/json", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (the error rides inside the SSE stream, not the HTTP status)", resp.StatusCode) + } + body, err := readAllLines(resp.Body) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(body, "\n") + if !strings.Contains(joined, "no spawn command configured") { + t.Errorf("body missing configuration error; got:\n%s", joined) + } +} + +func TestIsCrossOrigin(t *testing.T) { + cases := []struct { + name string + origin string // "" means no Origin header at all + host string + want bool + }{ + {"no origin (non-browser client)", "", "localhost:7777", false}, + {"same origin", "http://localhost:7777", "localhost:7777", false}, + {"different host", "http://evil.example", "localhost:7777", true}, + {"same host different port", "http://localhost:8888", "localhost:7777", true}, + {"opaque null origin", "null", "localhost:7777", true}, + {"unparseable origin", "http://[::1", "localhost:7777", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/spawn", nil) + r.Host = c.host + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + if got := isCrossOrigin(r); got != c.want { + t.Errorf("isCrossOrigin(origin=%q, host=%q) = %v, want %v", c.origin, c.host, got, c.want) + } + }) + } +} + +func readAllLines(r io.Reader) ([]string, error) { + var lines []string + sc := bufio.NewScanner(r) + for sc.Scan() { + lines = append(lines, sc.Text()) + } + if err := sc.Err(); err != nil { + return nil, err + } + return lines, nil +} + +func TestResolveAddrDefaultsToLoopback(t *testing.T) { + if got := resolveAddr(""); got != defaultAddr { + t.Errorf("resolveAddr(\"\") = %q, want %q", got, defaultAddr) + } + if got := resolveAddr("localhost:9999"); got != "localhost:9999" { + t.Errorf("resolveAddr override = %q, want localhost:9999", got) + } +} + +func TestDefaultAddrIsLoopback(t *testing.T) { + host, _, err := net.SplitHostPort(defaultAddr) + if err != nil { + t.Fatal(err) + } + if host != "localhost" && host != "127.0.0.1" { + t.Errorf("default host = %q, want a loopback host", host) + } +} + +func TestSpawnCommandFromEnv(t *testing.T) { + getenv := func(m map[string]string) func(string) string { + return func(k string) string { return m[k] } + } + t.Run("flag set wins over env even when empty", func(t *testing.T) { + got := spawnCommandFromEnv("", true, getenv(map[string]string{spawnCommandEnv: "from-env"})) + if got != "" { + t.Errorf("got %q, want empty (explicit flag wins)", got) + } + }) + t.Run("env used when flag not passed", func(t *testing.T) { + got := spawnCommandFromEnv("", false, getenv(map[string]string{spawnCommandEnv: "from-env"})) + if got != "from-env" { + t.Errorf("got %q, want from-env", got) + } + }) + t.Run("flag value used when env unset", func(t *testing.T) { + got := spawnCommandFromEnv("from-flag", true, getenv(map[string]string{})) + if got != "from-flag" { + t.Errorf("got %q, want from-flag", got) + } + }) + t.Run("no flag, no env: empty", func(t *testing.T) { + got := spawnCommandFromEnv("", false, getenv(map[string]string{})) + if got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} diff --git a/tools/hub/hub_test.mjs b/tools/hub/hub_test.mjs new file mode 100644 index 0000000..86d80c4 --- /dev/null +++ b/tools/hub/hub_test.mjs @@ -0,0 +1,971 @@ +// Unit tests for the pure helpers in tools/hub/index.html. +// +// Same extraction trick as tools/inspector/inspector_test.mjs: read +// index.html, pull out the region between the TESTABLE-BEGIN/END markers, +// and evaluate it in a node:vm sandbox exposing only Date and JSON. Keeps +// the hub a build-free single file while its logic stays unit-tested. +// +// Run: node --test tools/hub/ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import vm from "node:vm"; + +const here = dirname(fileURLToPath(import.meta.url)); +const html = readFileSync(join(here, "index.html"), "utf8"); + +const begin = "/* TESTABLE-BEGIN"; +const end = "/* TESTABLE-END */"; +const bi = html.indexOf(begin); +const ei = html.indexOf(end); +assert.ok(bi >= 0 && ei > bi, "TESTABLE markers must be present in index.html"); +const afterBegin = html.indexOf("*/", bi) + 2; +const source = html.slice(afterBegin, ei); + +const sandbox = { Date, JSON }; +vm.createContext(sandbox); +vm.runInContext(source, sandbox); + +// plain rebuilds a value produced inside the vm sandbox into this module's +// own realm: object/array literals evaluated by vm-compiled source carry +// the sandbox's Object/Array prototypes, which assert's strict deepEqual +// treats as unequal to this-realm literals even when structurally +// identical. A JSON round-trip run in THIS realm (not the sandbox's) +// produces plain, this-realm objects — the same trick tools/inspector's +// test suite uses (see its "collect"/"plain" helpers) for cross-realm +// comparisons. +function plain(v) { + return v === undefined ? v : JSON.parse(JSON.stringify(v)); +} +const { + shortId, + prettyJSON, + partsText, + maxSeq, + createSSEParser, + fmtRelative, + sessionBadge, + goalSnippet, + lastTurnSummary, + fmtTokens, + usageSummary, + reduceGoal, + goalFromSession, + encodeHubState, + decodeHubState, + notifyForEvent, + sortSessions, + sessionTimeRank, + sessionPriorityRank, + countByState, + randomSlug, + createCoalescer, + planAppend, + isPinnedToBottom, + shouldResort, + boxCardSignature, + sessionRowSignature, + buildLineages, + collectActiveGoals, + goalPauseTreatment, + rearmNeeded, + canRedispatch, + normalizeProcesses, + normalizePorts, + processStateBadge, + portLinksFor, + parsePortURLLines, + mergePortURLs, + sanitizePortURLs, +} = sandbox; + +/* ---------- fmtRelative ---------- */ + +test("fmtRelative: just now for sub-10s", () => { + const now = Date.parse("2024-01-01T00:00:10Z"); + assert.equal(fmtRelative("2024-01-01T00:00:05Z", now), "just now"); + assert.equal(fmtRelative("2024-01-01T00:00:10Z", now), "just now"); +}); + +test("fmtRelative: seconds, minutes, hours, days", () => { + const now = Date.parse("2024-01-02T00:00:00Z"); + assert.equal(fmtRelative("2024-01-01T23:59:30Z", now), "30s ago"); + assert.equal(fmtRelative("2024-01-01T23:55:00Z", now), "5m ago"); + assert.equal(fmtRelative("2024-01-01T18:00:00Z", now), "6h ago"); + assert.equal(fmtRelative("2023-12-30T00:00:00Z", now), "3d ago"); +}); + +test("fmtRelative: falls back to a locale date past a week", () => { + const now = Date.parse("2024-02-01T00:00:00Z"); + const got = fmtRelative("2024-01-01T00:00:00Z", now); + assert.doesNotMatch(got, /ago$/); + assert.notEqual(got, ""); +}); + +test("fmtRelative: empty/invalid input yields empty string", () => { + assert.equal(fmtRelative("", 0), ""); + assert.equal(fmtRelative(null, 0), ""); + assert.equal(fmtRelative("not a date", 0), ""); +}); + +test("fmtRelative: future timestamps clamp to just now, never negative", () => { + const now = Date.parse("2024-01-01T00:00:00Z"); + assert.equal(fmtRelative("2024-01-01T00:05:00Z", now), "just now"); +}); + +/* ---------- sessionBadge ---------- */ + +test("sessionBadge: goal.active always wins regardless of status", () => { + assert.equal(sessionBadge({ status: "idle", goal: { active: true } }), "goal-running"); + assert.equal(sessionBadge({ status: "busy", goal: { active: true } }), "goal-running"); +}); + +test("sessionBadge: busy without an active goal", () => { + assert.equal(sessionBadge({ status: "busy" }), "busy"); + assert.equal(sessionBadge({ status: "busy", goal: { active: false } }), "busy"); +}); + +test("sessionBadge: idle default", () => { + assert.equal(sessionBadge({ status: "idle" }), "idle"); + assert.equal(sessionBadge({}), "idle"); + assert.equal(sessionBadge(null), "idle"); +}); + +/* ---------- goalSnippet ---------- */ + +test("goalSnippet: short condition passes through untouched", () => { + assert.equal(goalSnippet("ship it"), "ship it"); +}); + +test("goalSnippet: truncates long conditions with an ellipsis", () => { + const long = "x".repeat(200); + const got = goalSnippet(long, 80); + assert.equal(got.length, 80); + assert.ok(got.endsWith("…")); +}); + +test("goalSnippet: empty/missing condition is empty string", () => { + assert.equal(goalSnippet(""), ""); + assert.equal(goalSnippet(null), ""); +}); + +/* ---------- lastTurnSummary ---------- */ + +test("lastTurnSummary: completed", () => { + assert.equal(lastTurnSummary({ outcome: "completed" }), "completed"); +}); + +test("lastTurnSummary: error includes the error text", () => { + assert.equal(lastTurnSummary({ outcome: "error", error: "boom" }), "error: boom"); +}); + +test("lastTurnSummary: context_exhausted without error text", () => { + assert.equal(lastTurnSummary({ outcome: "context_exhausted" }), "context_exhausted"); +}); + +test("lastTurnSummary: absent/empty last_turn is empty string", () => { + assert.equal(lastTurnSummary(null), ""); + assert.equal(lastTurnSummary({}), ""); +}); + +/* ---------- fmtTokens / usageSummary ---------- */ + +test("fmtTokens: compact thousands/millions", () => { + assert.equal(fmtTokens(0), "0"); + assert.equal(fmtTokens(999), "999"); + assert.equal(fmtTokens(1000), "1k"); + assert.equal(fmtTokens(1234), "1.2k"); + assert.equal(fmtTokens(1500000), "1.5M"); +}); + +test("usageSummary: formats input/output tokens", () => { + assert.equal(usageSummary({ input_tokens: 1234, output_tokens: 567 }), "1.2k in / 567 out"); + assert.equal(usageSummary(null), ""); +}); + +/* ---------- reduceGoal (extended with goal.stalled) ---------- */ + +test("reduceGoal: full lifecycle including a retryable stall", () => { + let g = reduceGoal(null, { type: "goal.set", goal_condition: "ship it" }); + assert.equal(g.condition, "ship it"); + assert.equal(g.active, true); + + g = reduceGoal(g, { + type: "goal.stalled", + goal_reason: "provider overloaded", + goal_attempt: 2, + goal_retryable: true, + goal_retryable_class: "overloaded", + goal_waiting: true, + }); + assert.equal(g.active, true, "a stall is non-terminal"); + assert.equal(g.attempt, 2); + assert.equal(g.retryable, true); + assert.equal(g.retryableClass, "overloaded"); + assert.equal(g.waiting, true); + + g = reduceGoal(g, { type: "goal.achieved", goal_reason: "done", goal_turns: 4 }); + assert.equal(g.active, false); + assert.equal(g.achieved, true); + assert.equal(g.attempt, 0, "achieved resets the stall counter"); + assert.equal(g.turns, 4); +}); + +test("reduceGoal: goal.eval resets stall fields", () => { + let g = reduceGoal(null, { type: "goal.set", goal_condition: "x" }); + g = reduceGoal(g, { type: "goal.stalled", goal_attempt: 1, goal_retryable: true, goal_waiting: true }); + assert.equal(g.attempt, 1); + g = reduceGoal(g, { type: "goal.eval", goal_met: false, goal_reason: "not yet", goal_turn: 1 }); + assert.equal(g.attempt, 0); + assert.equal(g.retryable, false); + assert.equal(g.waiting, false); + assert.equal(g.reason, "not yet"); +}); + +test("reduceGoal: non-goal events are ignored, returning the same reference", () => { + const prev = { condition: "x", active: true }; + assert.equal(reduceGoal(prev, { type: "message" }), prev); +}); + +/* ---------- encodeHubState / decodeHubState ---------- */ + +test("encodeHubState/decodeHubState: round-trips boxes and view", () => { + const s = { + boxes: [{ id: "b1", name: "box one", base: "http://localhost:4096", token: "t0k" }], + view: { box: "b1", session: "ses_1" }, + notify: true, + }; + const frag = encodeHubState(s); + assert.ok(frag.startsWith("s=")); + const decoded = plain(decodeHubState(frag)); + assert.deepEqual(decoded.boxes, s.boxes.map(b => ({ ...b, port_urls: {} }))); + assert.deepEqual(decoded.view, s.view); + assert.equal(decoded.notify, true); +}); + +test("encodeHubState/decodeHubState: round-trips a box's port_urls map", () => { + const s = { + boxes: [{ id: "b1", name: "n", base: "http://x", token: "t", port_urls: { "3000": "https://a.example" } }], + view: {}, notify: false, + }; + const decoded = plain(decodeHubState(encodeHubState(s))); + assert.deepEqual(decoded.boxes[0].port_urls, { "3000": "https://a.example" }); +}); + +test("decodeHubState: a garbage port_urls value (array, non-object, non-string values) sanitizes to {}", () => { + const raw = JSON.stringify({ boxes: [{ id: "b1", base: "http://x", token: "t", port_urls: ["oops"] }] }); + const b64 = Buffer.from(raw, "utf8").toString("base64"); + const decoded = plain(decodeHubState("s=" + b64)); + assert.deepEqual(decoded.boxes[0].port_urls, {}); +}); + +test("encodeHubState/decodeHubState: round-trips through a full #-prefixed hash", () => { + const s = { boxes: [{ id: "b", name: "b", base: "http://x", token: "t" }], view: {}, notify: false }; + const decoded = decodeHubState("#" + encodeHubState(s)); + assert.equal(decoded.boxes.length, 1); + assert.equal(decoded.boxes[0].base, "http://x"); +}); + +test("encodeHubState: strips trailing slashes from base URLs", () => { + const s = { boxes: [{ id: "b", name: "b", base: "http://x/////", token: "t" }], view: {}, notify: false }; + const decoded = decodeHubState(encodeHubState(s)); + assert.equal(decoded.boxes[0].base, "http://x"); +}); + +test("encodeHubState: round-trips a long multi-paragraph unicode condition inside view", () => { + const cond = "ship it 🚀 — with ünïcödé, and\nnewlines, and ".repeat(50); + const s = { boxes: [], view: { draftCondition: cond }, notify: false }; + const decoded = decodeHubState(encodeHubState(s)); + assert.equal(decoded.view.draftCondition, cond); +}); + +test("decodeHubState: tolerant of garbage fragments", () => { + for (const bad of ["", "#", "garbage", "#garbage!!!", "#s=", "#s=not-valid-base64!!!", "#s=" + "%".repeat(20), null, undefined]) { + const decoded = plain(decodeHubState(bad)); + assert.deepEqual(decoded.boxes, []); + assert.deepEqual(decoded.view, {}); + assert.equal(decoded.notify, false); + } +}); + +test("decodeHubState: tolerant of well-formed JSON with the wrong shape", () => { + const frag = encodeHubState(42); + const decoded = plain(decodeHubState(frag)); + assert.deepEqual(decoded.boxes, []); +}); + +test("decodeHubState: drops malformed box entries but keeps good ones", () => { + const raw = JSON.stringify({ + boxes: [ + { id: "b1", name: "good", base: "http://good", token: "t" }, + { id: "b2", name: "missing token" }, + "not an object", + null, + ], + }); + const b64 = Buffer.from(raw, "utf8").toString("base64"); + const decoded = decodeHubState("s=" + b64); + assert.equal(decoded.boxes.length, 1); + assert.equal(decoded.boxes[0].name, "good"); +}); + +/* ---------- notifyForEvent ---------- */ + +test("notifyForEvent: goal achieved", () => { + const n = notifyForEvent({ type: "goal.achieved", goal_reason: "done" }); + assert.ok(n); + assert.match(n.title, /achieved/i); + assert.equal(n.body, "done"); +}); + +test("notifyForEvent: turn.end error", () => { + const n = notifyForEvent({ type: "turn.end", outcome: "error", error: "boom" }); + assert.ok(n); + assert.match(n.title, /fail|error/i); + assert.equal(n.body, "boom"); +}); + +test("notifyForEvent: turn.end completed is not notify-worthy", () => { + assert.equal(notifyForEvent({ type: "turn.end", outcome: "completed" }), null); +}); + +test("notifyForEvent: unrelated events are not notify-worthy", () => { + assert.equal(notifyForEvent({ type: "text.delta" }), null); + assert.equal(notifyForEvent(null), null); +}); + +/* ---------- sortSessions / sessionTimeRank / sessionPriorityRank ---------- + Replaces the old sortByLastActivity, which degenerated to server/ + insertion order whenever last_activity_at was null/absent (new + Date(null) - new Date(null) is NaN, and Array#sort's behavior on a + comparator that returns NaN is unspecified/effectively "leave it alone") + — an operator-reported bug where a box on an older harness binary + returns last_activity_at: null for every session, burying the one + actively running session at the bottom of the fleet list. */ + +test("sortSessions: newest last_activity_at first within the same state, without mutating input", () => { + const input = [ + { id: "a", status: "idle", last_activity_at: "2024-01-01T00:00:00Z" }, + { id: "b", status: "idle", last_activity_at: "2024-03-01T00:00:00Z" }, + { id: "c", status: "idle", last_activity_at: "2024-02-01T00:00:00Z" }, + ]; + const out = sortSessions(input); + assert.deepEqual(plain(out.map(s => s.id)), ["b", "c", "a"]); + assert.equal(input[0].id, "a", "input must not be mutated"); +}); + +test("sortSessions: active states sort before idle regardless of timestamps", () => { + const input = [ + { id: "idle-newer", status: "idle", last_activity_at: "2024-06-01T00:00:00Z" }, + { id: "busy-older", status: "busy", last_activity_at: "2024-01-01T00:00:00Z" }, + { id: "goal-oldest", status: "idle", last_activity_at: "2023-01-01T00:00:00Z", goal: { active: true } }, + ]; + const out = sortSessions(input); + assert.deepEqual(plain(out.map(s => s.id)), ["goal-oldest", "busy-older", "idle-newer"]); +}); + +test("sortSessions: all last_activity_at null falls back to created_at, never degenerating to input order", () => { + // The reported bug: an older harness binary omits last_activity_at for + // every session. The actively running (busy) session must still land at + // the top, not the bottom, purely from its state — with created_at as + // the within-state tiebreaker. + const input = [ + { id: "idle-newer", status: "idle", last_activity_at: null, created_at: "2024-03-01T00:00:00Z" }, + { id: "idle-older", status: "idle", last_activity_at: null, created_at: "2024-01-01T00:00:00Z" }, + { id: "running", status: "busy", last_activity_at: null, created_at: "2024-02-01T00:00:00Z" }, + ]; + const out = sortSessions(input); + assert.deepEqual(plain(out.map(s => s.id)), ["running", "idle-newer", "idle-older"]); +}); + +test("sortSessions: mixed nulls — a session with neither timestamp sorts last within its state", () => { + const input = [ + { id: "no-timestamps", status: "idle" }, + { id: "has-last-activity", status: "idle", last_activity_at: "2024-01-01T00:00:00Z" }, + { id: "has-created-only", status: "idle", created_at: "2023-01-01T00:00:00Z" }, + ]; + const out = sortSessions(input); + assert.deepEqual(plain(out.map(s => s.id)), ["has-last-activity", "has-created-only", "no-timestamps"]); +}); + +test("sortSessions: is a total order — never throws/NaNs on a fully-timestampless, mixed-state fixture", () => { + const fixture = [ + { id: "a", status: "idle" }, + { id: "b", status: "busy" }, + { id: "c", status: "idle", goal: { active: true } }, + { id: "d", status: "busy", last_activity_at: null, created_at: null }, + { id: "e" }, + ]; + const out = sortSessions(fixture); + assert.equal(out.length, fixture.length); + // goal-running first, then busy (stable-ish among busy by whatever + // recency they have), then idle/unknown-status last. + assert.equal(out[0].id, "c"); + assert.deepEqual(plain(out.slice(1, 3).map(s => s.id).sort()), ["b", "d"]); +}); + +test("sessionTimeRank: prefers last_activity_at, falls back to created_at, then -Infinity", () => { + assert.equal(sessionTimeRank({ last_activity_at: "2024-01-01T00:00:00Z" }), Date.parse("2024-01-01T00:00:00Z")); + assert.equal(sessionTimeRank({ created_at: "2023-01-01T00:00:00Z" }), Date.parse("2023-01-01T00:00:00Z")); + assert.equal(sessionTimeRank({}), -Infinity); + assert.equal(sessionTimeRank(null), -Infinity); + assert.equal(sessionTimeRank({ last_activity_at: "not a date" }), -Infinity); +}); + +test("sessionPriorityRank: goal-running < busy < idle", () => { + assert.ok(sessionPriorityRank({ status: "idle", goal: { active: true } }) < sessionPriorityRank({ status: "busy" })); + assert.ok(sessionPriorityRank({ status: "busy" }) < sessionPriorityRank({ status: "idle" })); +}); + +/* ---------- countByState ---------- */ + +test("countByState: tallies sessions by derived badge", () => { + const sessions = [ + { status: "busy" }, + { status: "idle" }, + { status: "idle", goal: { active: true } }, + { status: "busy", goal: { active: true } }, + ]; + assert.deepEqual(plain(countByState(sessions)), { idle: 1, busy: 1, "goal-running": 2 }); +}); + +/* ---------- shared helpers carried over from the inspector ---------- */ + +test("shortId / prettyJSON / partsText / maxSeq behave as in the inspector", () => { + assert.equal(shortId("sess_0123456789abcdef"), "sess_012345"); + assert.equal(prettyJSON({ a: 1 }), '{\n "a": 1\n}'); + assert.equal(partsText([{ type: "text", text: "hi" }]), "hi"); + assert.equal(maxSeq([{ seq: 1 }, { seq: 9 }]), 9); +}); + +/* ---------- SSE parser (shared verbatim with the inspector) ---------- */ + +test("createSSEParser: basic frame", () => { + const frames = []; + const feed = createSSEParser(f => frames.push({ id: f.id, data: f.data })); + feed("data: hello\n\n"); + assert.deepEqual(frames, [{ id: null, data: "hello" }]); +}); + +test("createSSEParser: chunk boundary mid-frame", () => { + const frames = []; + const feed = createSSEParser(f => frames.push({ id: f.id, data: f.data })); + feed("data: hel"); + feed("lo\n\n"); + assert.deepEqual(frames, [{ id: null, data: "hello" }]); +}); + +/* ---------- randomSlug ---------- */ + +test("randomSlug: deterministic under a fixed rand, suffix 00 at rand 0", () => { + // rand always 0 → first adjective, first noun, num 0 → padded "-00". + const a = randomSlug(() => 0); + const b = randomSlug(() => 0); + assert.equal(a, b, "same rand yields same slug"); + assert.match(a, /^[a-z]+-[a-z]+-00$/); +}); + +test("randomSlug: two-digit zero-padded suffix", () => { + // rand just under 1 → last adjective/noun, num 99. + const slug = randomSlug(() => 0.999); + assert.match(slug, /^[a-z]+-[a-z]+-99$/); +}); + +test("randomSlug: always matches the documented shape over many draws", () => { + let seed = 1; + const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; + for (let i = 0; i < 200; i++) { + assert.match(randomSlug(rand), /^[a-z]+-[a-z]+-\d{2}$/); + } +}); + +test("randomSlug: both word segments are lowercase across many draws", () => { + let seed = 7; + const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; + for (let i = 0; i < 200; i++) { + const [adj, noun] = randomSlug(rand).split("-"); + assert.match(adj, /^[a-z]+$/); + assert.match(noun, /^[a-z]+$/); + } +}); + +/* ---------- createCoalescer ---------- */ + +test("createCoalescer: many calls in one tick invoke fn once", () => { + const queued = []; + let runs = 0; + const kick = createCoalescer(cb => queued.push(cb), () => runs++); + for (let i = 0; i < 5000; i++) kick(); + assert.equal(queued.length, 1, "one scheduled tick despite 5000 calls"); + queued.shift()(); + assert.equal(runs, 1); +}); + +test("createCoalescer: re-arms after the scheduled tick runs", () => { + const queued = []; + let runs = 0; + const kick = createCoalescer(cb => queued.push(cb), () => runs++); + kick(); queued.shift()(); + kick(); kick(); queued.shift()(); + assert.equal(runs, 2); + assert.equal(queued.length, 0); +}); + +test("createCoalescer: a call from inside fn schedules a fresh tick", () => { + const queued = []; + let runs = 0; + let kick; + kick = createCoalescer(cb => queued.push(cb), () => { runs++; if (runs === 1) kick(); }); + kick(); + queued.shift()(); // runs fn, which re-kicks + assert.equal(queued.length, 1, "inner kick scheduled a new tick"); + queued.shift()(); + assert.equal(runs, 2); +}); + +/* ---------- planAppend (keyed append-only diff planning) ---------- */ + +test("planAppend: returns only items whose key is unseen, preserving order", () => { + const existing = new Set(["m:1", "m:2"]); + const items = [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }]; + const got = planAppend(existing, items, x => "m:" + x.id); + assert.deepEqual(plain(got.map(x => x.id)), ["3", "4"]); +}); + +test("planAppend: accepts a plain array/iterable of existing keys, not just a Set", () => { + const got = planAppend(["m:1"], [{ id: "1" }, { id: "2" }], x => "m:" + x.id); + assert.deepEqual(plain(got.map(x => x.id)), ["2"]); +}); + +test("planAppend: skips items whose keyFn returns null/undefined", () => { + const got = planAppend([], [{ id: "1" }, { id: null }, { id: "2" }], x => x.id == null ? null : "m:" + x.id); + assert.deepEqual(plain(got.map(x => x.id)), ["1", "2"]); +}); + +test("planAppend: does not mutate the existingKeys Set passed in", () => { + const existing = new Set(["m:1"]); + planAppend(existing, [{ id: "2" }], x => "m:" + x.id); + assert.deepEqual([...existing], ["m:1"]); +}); + +test("planAppend: empty items yields empty output", () => { + assert.deepEqual(plain(planAppend(new Set(), [], x => x)), []); + assert.deepEqual(plain(planAppend(new Set(), null, x => x)), []); +}); + +/* ---------- isPinnedToBottom ---------- */ + +test("isPinnedToBottom: exactly at bottom is pinned", () => { + assert.equal(isPinnedToBottom(100, 200, 100), true); // 200-100-100=0 +}); + +test("isPinnedToBottom: within default threshold counts as pinned", () => { + assert.equal(isPinnedToBottom(90, 200, 100), true); // gap=10 <= 24 +}); + +test("isPinnedToBottom: scrolled well above the threshold is not pinned", () => { + assert.equal(isPinnedToBottom(0, 500, 100), false); // gap=400 +}); + +test("isPinnedToBottom: custom threshold is honored", () => { + assert.equal(isPinnedToBottom(50, 200, 100), false, "gap=50 > default 24"); + assert.equal(isPinnedToBottom(50, 200, 100, 60), true, "gap=50 <= custom 60"); +}); + +/* ---------- shouldResort ---------- */ + +test("shouldResort: always true when there is no prior sort timestamp", () => { + assert.equal(shouldResort(null, 1000), true); + assert.equal(shouldResort(undefined, 1000), true); +}); + +test("shouldResort: false before the damping interval elapses", () => { + assert.equal(shouldResort(1000, 1500), false); // 500ms < 1000ms default + assert.equal(shouldResort(1000, 1999), false); +}); + +test("shouldResort: true once the damping interval has elapsed", () => { + assert.equal(shouldResort(1000, 2000), true); + assert.equal(shouldResort(1000, 5000), true); +}); + +test("shouldResort: honors a custom interval", () => { + assert.equal(shouldResort(1000, 1300, 200), true); + assert.equal(shouldResort(1000, 1100, 200), false); +}); + +/* ---------- boxCardSignature ---------- */ + +test("boxCardSignature: identical inputs produce identical signatures", () => { + const a = { health: "ok", name: "n", base: "http://x", vcsRevision: "abc123", expanded: true, counts: { idle: 1, busy: 0, "goal-running": 0 } }; + const b = { health: "ok", name: "n", base: "http://x", vcsRevision: "abc123", expanded: true, counts: { idle: 1, busy: 0, "goal-running": 0 } }; + assert.equal(boxCardSignature(a), boxCardSignature(b)); +}); + +test("boxCardSignature: differs when health changes", () => { + const base = { health: "ok", name: "n", base: "http://x", vcsRevision: "abc", expanded: false, counts: {} }; + assert.notEqual(boxCardSignature(base), boxCardSignature({ ...base, health: "down" })); +}); + +test("boxCardSignature: differs when expanded toggles", () => { + const base = { health: "ok", name: "n", base: "http://x", vcsRevision: "abc", expanded: false, counts: {} }; + assert.notEqual(boxCardSignature(base), boxCardSignature({ ...base, expanded: true })); +}); + +test("boxCardSignature: differs when counts change", () => { + const base = { health: "ok", name: "n", base: "http://x", vcsRevision: "abc", expanded: false, counts: { idle: 1 } }; + assert.notEqual(boxCardSignature(base), boxCardSignature({ ...base, counts: { idle: 2 } })); +}); + +/* ---------- sessionRowSignature ---------- */ + +test("sessionRowSignature: identical sessions produce identical signatures", () => { + const s = { id: "s1", status: "busy", model: "gpt", usage: { input_tokens: 1, output_tokens: 2 }, last_activity_at: "2024-01-01T00:00:00Z" }; + assert.equal(sessionRowSignature(s, false), sessionRowSignature({ ...s }, false)); +}); + +test("sessionRowSignature: differs when selected state changes", () => { + const s = { id: "s1", status: "idle" }; + assert.notEqual(sessionRowSignature(s, false), sessionRowSignature(s, true)); +}); + +test("sessionRowSignature: differs when badge-relevant fields change", () => { + const s = { id: "s1", status: "idle" }; + assert.notEqual(sessionRowSignature(s, false), sessionRowSignature({ ...s, status: "busy" }, false)); +}); + +test("sessionRowSignature: differs when last_activity_at changes", () => { + const s = { id: "s1", status: "idle", last_activity_at: "2024-01-01T00:00:00Z" }; + assert.notEqual(sessionRowSignature(s, false), sessionRowSignature({ ...s, last_activity_at: "2024-01-01T00:00:05Z" }, false)); +}); + +test("sessionRowSignature: differs when goal condition/active changes", () => { + const s = { id: "s1", status: "idle", goal: { condition: "ship it", active: true } }; + assert.notEqual(sessionRowSignature(s, false), sessionRowSignature({ ...s, goal: { condition: "ship it", active: false } }, false)); +}); + +test("sessionRowSignature: differs when last_turn changes", () => { + const s = { id: "s1", status: "idle", last_turn: { outcome: "completed" } }; + assert.notEqual(sessionRowSignature(s, false), sessionRowSignature({ ...s, last_turn: { outcome: "error", error: "boom" } }, false)); +}); + +/* ---------- buildLineages ---------- */ + +test("buildLineages: a session with no parent_session renders as today (singleton)", () => { + const sessions = [{ id: "s1", created_at: "2024-01-01T00:00:00Z" }]; + const groups = plain(buildLineages(sessions)); + assert.equal(groups.length, 1); + assert.equal(groups[0].tip.id, "s1"); + assert.deepEqual(groups[0].earlier, []); +}); + +test("buildLineages: a chain groups under its most recent tip", () => { + const sessions = [ + { id: "a", created_at: "2024-01-01T00:00:00Z" }, + { id: "b", parent_session: "a", created_at: "2024-01-02T00:00:00Z" }, + { id: "c", parent_session: "b", created_at: "2024-01-03T00:00:00Z" }, + ]; + const groups = plain(buildLineages(sessions)); + assert.equal(groups.length, 1); + const g = groups[0]; + assert.equal(g.tip.id, "c"); + assert.deepEqual(g.earlier.map(s => s.id), ["b", "a"]); +}); + +test("buildLineages: a parent_session pointing outside the given set (cross-box lineage) is an orphan — no grouping", () => { + const sessions = [ + { id: "a", parent_session: "ses_on_another_box", created_at: "2024-01-01T00:00:00Z" }, + { id: "b", created_at: "2024-01-02T00:00:00Z" }, + ]; + const groups = plain(buildLineages(sessions)).sort((x, y) => x.tip.id.localeCompare(y.tip.id)); + assert.equal(groups.length, 2); + assert.deepEqual(groups[0].earlier, []); + assert.deepEqual(groups[1].earlier, []); +}); + +test("buildLineages: tolerates a cycle without hanging or throwing", () => { + const sessions = [ + { id: "x", parent_session: "y", created_at: "2024-01-01T00:00:00Z" }, + { id: "y", parent_session: "x", created_at: "2024-01-02T00:00:00Z" }, + ]; + const groups = plain(buildLineages(sessions)); + assert.equal(groups.length, 1); + assert.equal(groups[0].tip.id, "y"); + assert.deepEqual(groups[0].earlier.map(s => s.id), ["x"]); +}); + +test("buildLineages: a longer 3-cycle is still tolerated", () => { + const sessions = [ + { id: "p", parent_session: "q", created_at: "2024-01-01T00:00:00Z" }, + { id: "q", parent_session: "r", created_at: "2024-01-02T00:00:00Z" }, + { id: "r", parent_session: "p", created_at: "2024-01-03T00:00:00Z" }, + ]; + const groups = plain(buildLineages(sessions)); + assert.equal(groups.length, 1); + assert.equal(groups[0].sessions ? undefined : groups[0].tip.id, "r"); +}); + +test("buildLineages: multiple independent lineages plus loners all present", () => { + const sessions = [ + { id: "a1", created_at: "2024-01-01T00:00:00Z" }, + { id: "a2", parent_session: "a1", created_at: "2024-01-02T00:00:00Z" }, + { id: "loner", created_at: "2024-01-01T00:00:00Z" }, + ]; + const groups = plain(buildLineages(sessions)); + assert.equal(groups.length, 2); + const withEarlier = groups.find(g => g.earlier.length > 0); + assert.ok(withEarlier); + assert.equal(withEarlier.tip.id, "a2"); + const loner = groups.find(g => g.tip.id === "loner"); + assert.ok(loner); + assert.deepEqual(loner.earlier, []); +}); + +test("buildLineages: empty/garbage input yields no groups, never throws", () => { + assert.deepEqual(plain(buildLineages([])), []); + assert.deepEqual(plain(buildLineages(null)), []); + assert.deepEqual(plain(buildLineages([null, { id: "" }, { notAnId: 1 }])), []); +}); + +/* ---------- collectActiveGoals ---------- */ + +test("collectActiveGoals: only sessions with an active goal are collected, across boxes", () => { + const boxes = [ + { id: "b1", name: "box-one", sessions: [ + { id: "s1", goal: { active: true, condition: "ship it" } }, + { id: "s2", goal: { active: false, condition: "done" } }, + { id: "s3" }, + ] }, + { id: "b2", name: "box-two", sessions: [ + { id: "s4", goal: { active: true, condition: "fix bug", paused: true, pause_reason: "provider-backoff" } }, + ] }, + ]; + const got = plain(collectActiveGoals(boxes)); + assert.deepEqual(got.map(g => g.sessionId).sort(), ["s1", "s4"]); + const s4 = got.find(g => g.sessionId === "s4"); + assert.equal(s4.boxId, "b2"); + assert.equal(s4.boxName, "box-two"); + assert.equal(s4.paused, true); + assert.equal(s4.pauseReason, "provider-backoff"); +}); + +test("collectActiveGoals: paused/restart entries sort first (most needs-attention)", () => { + const boxes = [ + { id: "b1", name: "b1", sessions: [ + { id: "running", goal: { active: true, condition: "a" } }, + { id: "needs-rearm", goal: { active: true, condition: "b", paused: true, pause_reason: "restart" } }, + ] }, + ]; + const got = plain(collectActiveGoals(boxes)); + assert.equal(got[0].sessionId, "needs-rearm"); +}); + +test("collectActiveGoals: empty/garbage input yields empty array", () => { + assert.deepEqual(plain(collectActiveGoals([])), []); + assert.deepEqual(plain(collectActiveGoals(null)), []); + assert.deepEqual(plain(collectActiveGoals([{ id: "b1" }, null])), []); +}); + +/* ---------- goalPauseTreatment ---------- */ + +test("goalPauseTreatment: not paused yields null", () => { + assert.equal(goalPauseTreatment(false, ""), null); + assert.equal(goalPauseTreatment(false, "restart"), null); +}); + +test("goalPauseTreatment: provider-backoff is calm, not an error", () => { + const t = goalPauseTreatment(true, "provider-backoff"); + assert.equal(t.calm, true); + assert.match(t.label, /waiting out provider weather/); +}); + +test("goalPauseTreatment: restart is not calm (needs a prominent CTA)", () => { + const t = goalPauseTreatment(true, "restart"); + assert.equal(t.calm, false); + assert.match(t.label, /re-arm/i); +}); + +test("goalPauseTreatment: unrecognized reason still renders (never throws), not calm", () => { + const t = goalPauseTreatment(true, "some-future-reason"); + assert.equal(t.calm, false); + assert.equal(t.reason, "some-future-reason"); +}); + +/* ---------- rearmNeeded ---------- */ + +test("rearmNeeded: no condition at all -> none", () => { + assert.equal(rearmNeeded(false, "", false, ""), "none"); + assert.equal(rearmNeeded(true, "", false, ""), "none"); +}); + +test("rearmNeeded: ended goal (not active) -> normal", () => { + assert.equal(rearmNeeded(false, "ship it", false, ""), "normal"); +}); + +test("rearmNeeded: active, not paused -> none (still running)", () => { + assert.equal(rearmNeeded(true, "ship it", false, ""), "none"); +}); + +test("rearmNeeded: active + paused/restart -> prominent", () => { + assert.equal(rearmNeeded(true, "ship it", true, "restart"), "prominent"); +}); + +test("rearmNeeded: active + paused/provider-backoff -> none (self-clearing, no CTA)", () => { + assert.equal(rearmNeeded(true, "ship it", true, "provider-backoff"), "none"); +}); + +/* ---------- canRedispatch ---------- */ + +test("canRedispatch: idle session (ended/errored) can be re-dispatched", () => { + assert.equal(canRedispatch({ id: "s1", status: "idle" }), true); + assert.equal(canRedispatch({ id: "s1", status: "idle", last_turn: { outcome: "error", error: "boom" } }), true); +}); + +test("canRedispatch: busy or goal-running sessions cannot be re-dispatched", () => { + assert.equal(canRedispatch({ id: "s1", status: "busy" }), false); + assert.equal(canRedispatch({ id: "s1", status: "idle", goal: { active: true, condition: "x" } }), false); +}); + +/* ---------- normalizePorts / normalizeProcesses / processStateBadge / portLinksFor ---------- */ + +test("normalizePorts: absent/null ports render as absent, never crash", () => { + assert.deepEqual(plain(normalizePorts(undefined)), []); + assert.deepEqual(plain(normalizePorts(null)), []); +}); + +test("normalizePorts: tolerates an array of numbers or strings", () => { + assert.deepEqual(plain(normalizePorts([3000, "5432"])), ["3000", "5432"]); +}); + +test("normalizePorts: tolerates an object-keyed shape", () => { + assert.deepEqual(plain(normalizePorts({ "3000": {} })), ["3000"]); +}); + +test("normalizeProcesses: absent/404 (non-array) body yields no strip", () => { + assert.deepEqual(plain(normalizeProcesses(undefined)), []); + assert.deepEqual(plain(normalizeProcesses(null)), []); + assert.deepEqual(plain(normalizeProcesses({ error: "not found" })), []); +}); + +test("normalizeProcesses: reads name/state/ready/exit_code/log off ProcessInfo.status, tolerating a missing ports field", () => { + const list = [ + { name: "dev", origin: "config", command: ["pnpm", "dev"], status: { name: "dev", state: "ready", ready: true, log: "/x/.harness/proc/dev.log" } }, + ]; + const got = plain(normalizeProcesses(list)); + assert.equal(got.length, 1); + assert.equal(got[0].name, "dev"); + assert.equal(got[0].state, "ready"); + assert.equal(got[0].ready, true); + assert.equal(got[0].log, "/x/.harness/proc/dev.log"); + assert.deepEqual(got[0].ports, []); +}); + +test("normalizeProcesses: a ports field (parallel-branch addition), when present, is picked up", () => { + const list = [ + { name: "dev", status: { state: "ready", ports: [3000] } }, + ]; + assert.deepEqual(plain(normalizeProcesses(list))[0].ports, ["3000"]); +}); + +test("normalizeProcesses: skips entries with no name at all rather than throwing", () => { + assert.deepEqual(plain(normalizeProcesses([{}, null, { status: {} }])), []); +}); + +test("processStateBadge: ready is green, starting is amber, exited is red with code", () => { + assert.equal(processStateBadge("ready").cls, "ready"); + assert.equal(processStateBadge("starting").cls, "starting"); + const exited = processStateBadge("exited", 1); + assert.equal(exited.cls, "exited"); + assert.match(exited.label, /1/); +}); + +test("processStateBadge: unknown/empty state never throws", () => { + assert.equal(processStateBadge("").cls, "unknown"); + assert.equal(processStateBadge(undefined).cls, "unknown"); +}); + +test("portLinksFor: only ports with a known URL in the box's port_urls map are returned", () => { + const got = plain(portLinksFor(["3000", "5432"], { "3000": "https://x.example" })); + assert.deepEqual(got, [{ port: "3000", url: "https://x.example" }]); +}); + +test("portLinksFor: no ports reported, or no matching port_urls entry -> empty, never crash", () => { + assert.deepEqual(plain(portLinksFor([], { "3000": "https://x.example" })), []); + assert.deepEqual(plain(portLinksFor(["3000"], undefined)), []); + assert.deepEqual(plain(portLinksFor(undefined, undefined)), []); +}); + +/* ---------- parsePortURLLines / mergePortURLs ---------- */ + +test("parsePortURLLines: parses port=url pairs, one per line", () => { + const got = plain(parsePortURLLines("3000=https://a.example\n5432=https://b.example\n")); + assert.deepEqual(got, { "3000": "https://a.example", "5432": "https://b.example" }); +}); + +test("parsePortURLLines: tolerates blank lines and garbage", () => { + const got = plain(parsePortURLLines("\n \nnotaport\n3000=https://a.example\n=missingport\n")); + assert.deepEqual(got, { "3000": "https://a.example" }); +}); + +test("sanitizePortURLs: a plain string map passes through unchanged", () => { + assert.deepEqual(plain(sanitizePortURLs({ "3000": "https://a.example" })), { "3000": "https://a.example" }); +}); + +test("sanitizePortURLs: garbage (array, null, non-string values) sanitizes to {}", () => { + assert.deepEqual(plain(sanitizePortURLs(["oops"])), {}); + assert.deepEqual(plain(sanitizePortURLs(null)), {}); + assert.deepEqual(plain(sanitizePortURLs({ "3000": 42 })), {}); +}); + +test("mergePortURLs: incoming wins on collision, tolerates null/undefined", () => { + assert.deepEqual(plain(mergePortURLs({ "3000": "old" }, { "3000": "new", "4000": "x" })), { "3000": "new", "4000": "x" }); + assert.deepEqual(plain(mergePortURLs(null, { "3000": "x" })), { "3000": "x" }); + assert.deepEqual(plain(mergePortURLs({ "3000": "x" }, null)), { "3000": "x" }); + assert.deepEqual(plain(mergePortURLs(null, null)), {}); +}); + +/* ---------- reduceGoal: goal.paused + paused/pauseReason fields ---------- */ + +test("reduceGoal: goal.paused (restart, boot-time) sets paused/pauseReason and keeps the goal active", () => { + const g = reduceGoal(null, { type: "goal.paused", goal_condition: "ship it", goal_paused: true, goal_pause_reason: "restart" }); + assert.equal(g.active, true); + assert.equal(g.paused, true); + assert.equal(g.pauseReason, "restart"); + assert.equal(g.condition, "ship it"); +}); + +test("reduceGoal: goal.stalled carrying goal_paused (provider-backoff) sets paused/pauseReason", () => { + const g = reduceGoal(null, { type: "goal.stalled", goal_paused: true, goal_pause_reason: "provider-backoff", goal_retryable: true, goal_waiting: true }); + assert.equal(g.paused, true); + assert.equal(g.pauseReason, "provider-backoff"); +}); + +test("reduceGoal: a non-paused goal.stalled clears any prior pause", () => { + const paused = reduceGoal(null, { type: "goal.stalled", goal_paused: true, goal_pause_reason: "provider-backoff" }); + const cleared = reduceGoal(paused, { type: "goal.stalled", goal_paused: false }); + assert.equal(cleared.paused, false); + assert.equal(cleared.pauseReason, ""); +}); + +test("reduceGoal: goal.set/goal.eval/goal.achieved/goal.cleared reset paused", () => { + const paused = reduceGoal(null, { type: "goal.paused", goal_condition: "x", goal_paused: true, goal_pause_reason: "restart" }); + assert.equal(reduceGoal(paused, { type: "goal.set", goal_condition: "y" }).paused, false); + assert.equal(reduceGoal(paused, { type: "goal.eval", goal_met: false }).paused, false); + assert.equal(reduceGoal(paused, { type: "goal.achieved" }).paused, false); + assert.equal(reduceGoal(paused, { type: "goal.cleared" }).paused, false); +}); + +test("goalFromSession: seeds paused/pauseReason from the session's goal", () => { + const g = goalFromSession({ goal: { condition: "x", active: true, paused: true, pause_reason: "restart" } }); + assert.equal(g.paused, true); + assert.equal(g.pauseReason, "restart"); +}); + +test("goalFromSession: absent paused/pause_reason render as absent, never crash", () => { + const g = goalFromSession({ goal: { condition: "x", active: true } }); + assert.equal(g.paused, false); + assert.equal(g.pauseReason, ""); +}); diff --git a/tools/hub/index.html b/tools/hub/index.html new file mode 100644 index 0000000..a893064 --- /dev/null +++ b/tools/hub/index.html @@ -0,0 +1,3183 @@ + + + + + +harness hub + + + + +
+

harness hub

+ + + + +
+
+
+
+
+
select a session from the fleet on the left
+
+ +
+
+ + + + + + diff --git a/tools/hub/spawn.go b/tools/hub/spawn.go new file mode 100644 index 0000000..4876e83 --- /dev/null +++ b/tools/hub/spawn.go @@ -0,0 +1,145 @@ +// Package hub implements `harness hub`: a local, single-operator control +// surface over a fleet of headless harness boxes. See hub.go for the server +// and index.html for the page itself. +package hub + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" +) + +// boxNameEnv is the environment variable the spawn command's own process +// sees the hub-chosen (or operator-chosen) box NAME in — see AGENTS.md's +// spawn contract section and docs/design/fleet-model.md §8. Deployment +// tooling invoked by -spawn-command reads this to derive per-name storage +// (e.g. HARNESS_SESSION_DIR); harness's own code never reads it. +const boxNameEnv = "HARNESS_HUB_BOX_NAME" + +// spawnEvent is one frame of the /spawn SSE stream, JSON-encoded as the +// `data:` payload. This is the entire spawn-output contract described in +// AGENTS.md: a "stdout" event per line of the spawn command's combined +// stdout+stderr, and exactly one terminal "done" event carrying the exit +// status plus whatever TUNNEL_URL / RUN_TOKEN lines were found along the +// way. The page needs nothing else to add the new box to its own state. +type spawnEvent struct { + Type string `json:"type"` // "stdout" or "done" + Line string `json:"line,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + TunnelURL string `json:"tunnel_url,omitempty"` + RunToken string `json:"run_token,omitempty"` + // PortURLs collects every `PORT_URL_=` line found in the + // spawn command's output (same trim/one-line-only rule as TUNNEL_URL/ + // RUN_TOKEN below), keyed by the port string. Omitted entirely when no + // such line appeared — the page's box state (port_urls) only ever + // gains entries a spawn (or hand-edit) actually produced. See DELIVERABLE + // (3)'s process-strip preview links. + PortURLs map[string]string `json:"port_urls,omitempty"` + Error string `json:"error,omitempty"` +} + +// marshal encodes ev as a single SSE frame ("data: ...\n\n"). It never +// fails in practice (spawnEvent has no unmarshalable fields), but a JSON +// error is folded into the frame rather than panicking or dropping it. +func (ev spawnEvent) marshal() []byte { + b, err := json.Marshal(ev) + if err != nil { + b = []byte(`{"type":"done","error":"internal: encoding spawn event: ` + err.Error() + `"}`) + } + return append(append([]byte("data: "), b...), '\n', '\n') +} + +// tunnelURLPattern and runTokenPattern match the two contract lines +// anywhere in the spawn command's combined stdout+stderr, tolerating +// leading/trailing whitespace and any surrounding log prefix on the same +// line is NOT stripped — the line must consist of exactly "KEY=value" once +// trimmed, so a logger that prefixes timestamps must emit the marker on its +// own line. +var ( + tunnelURLPattern = regexp.MustCompile(`^TUNNEL_URL=(.+)$`) + runTokenPattern = regexp.MustCompile(`^RUN_TOKEN=(.+)$`) + portURLPattern = regexp.MustCompile(`^PORT_URL_([0-9]+)=(.+)$`) +) + +// runSpawn execs command via `sh -c`, streaming each combined stdout/stderr +// line to emit as a "stdout" spawnEvent and scanning every line against the +// TUNNEL_URL=/RUN_TOKEN=/PORT_URL_= contract. It always finishes by +// calling emit exactly once more with a "done" event — carrying the exit +// code and whatever contract values were found, or an Error string if the +// command could not even be started. Canceling ctx kills the process +// (SIGKILL via exec.CommandContext) and runSpawn returns promptly once the +// process actually exits; it does not return early on cancellation, so the +// final "done" event is always sent. +// +// name, when non-empty, is set as HARNESS_HUB_BOX_NAME in the spawned +// command's own environment (see boxNameEnv above) — the page's generated +// slug, or the same name again on a Respawn/ADOPT (see fleet-model.md §4). +// Empty name leaves the environment exactly as os/exec's default (no +// HARNESS_HUB_BOX_NAME at all), not an empty-valued entry. +func runSpawn(ctx context.Context, command, name string, emit func(spawnEvent)) { + if strings.TrimSpace(command) == "" { + emit(spawnEvent{Type: "done", Error: "no spawn command configured (set -spawn-command or HARNESS_HUB_SPAWN)"}) + return + } + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + if name != "" { + cmd.Env = append(os.Environ(), boxNameEnv+"="+name) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + emit(spawnEvent{Type: "done", Error: fmt.Sprintf("spawn: %v", err)}) + return + } + cmd.Stderr = cmd.Stdout // combined stream, in the order it's written + + if err := cmd.Start(); err != nil { + emit(spawnEvent{Type: "done", Error: fmt.Sprintf("spawn: %v", err)}) + return + } + + var tunnelURL, runToken string + var portURLs map[string]string + scanner := bufio.NewScanner(stdout) + // Spawn scripts may print long single lines (progress bars, etc.); grow + // past bufio's 64KiB default rather than truncating or erroring out. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + emit(spawnEvent{Type: "stdout", Line: line}) + trimmed := strings.TrimSpace(line) + if m := tunnelURLPattern.FindStringSubmatch(trimmed); m != nil { + tunnelURL = strings.TrimSpace(m[1]) + } + if m := runTokenPattern.FindStringSubmatch(trimmed); m != nil { + runToken = strings.TrimSpace(m[1]) + } + if m := portURLPattern.FindStringSubmatch(trimmed); m != nil { + if portURLs == nil { + portURLs = make(map[string]string) + } + portURLs[m[1]] = strings.TrimSpace(m[2]) + } + } + scanErr := scanner.Err() + + waitErr := cmd.Wait() + + done := spawnEvent{Type: "done", TunnelURL: tunnelURL, RunToken: runToken, PortURLs: portURLs} + if cmd.ProcessState != nil { + done.ExitCode = cmd.ProcessState.ExitCode() + } + switch { + case scanErr != nil && scanErr != io.EOF: + done.Error = fmt.Sprintf("reading spawn output: %v", scanErr) + case waitErr != nil: + done.Error = waitErr.Error() + } + emit(done) +} diff --git a/tools/hub/spawn_test.go b/tools/hub/spawn_test.go new file mode 100644 index 0000000..89c7f30 --- /dev/null +++ b/tools/hub/spawn_test.go @@ -0,0 +1,204 @@ +package hub + +import ( + "context" + "strings" + "sync" + "testing" +) + +// collectSpawn runs runSpawn to completion and returns every event emitted, +// in order. +func collectSpawn(t *testing.T, ctx context.Context, command string) []spawnEvent { + t.Helper() + return collectSpawnNamed(t, ctx, command, "") +} + +// collectSpawnNamed is collectSpawn plus the box-name passthrough (see +// TestRunSpawnSetsBoxNameEnv below and AGENTS.md's spawn contract section). +func collectSpawnNamed(t *testing.T, ctx context.Context, command, name string) []spawnEvent { + t.Helper() + var mu sync.Mutex + var got []spawnEvent + runSpawn(ctx, command, name, func(ev spawnEvent) { + mu.Lock() + defer mu.Unlock() + got = append(got, ev) + }) + return got +} + +func TestRunSpawnNoCommandConfigured(t *testing.T) { + events := collectSpawn(t, context.Background(), "") + if len(events) != 1 { + t.Fatalf("events = %#v, want exactly one", events) + } + if events[0].Type != "done" || events[0].Error == "" { + t.Fatalf("event = %#v, want a done event carrying an error", events[0]) + } +} + +func TestRunSpawnParsesContractLines(t *testing.T) { + // Feed synthetic spawn output through a real `sh -c` invocation — the + // subprocess machinery (exec.CommandContext, combined-stream scanning) + // is what's under test, so a real process is the right fixture here + // (see AGENTS.md's e2e exception). + script := `echo "booting sandbox..." +echo "TUNNEL_URL=https://box-42.example.dev" +echo "some other progress line" +echo "RUN_TOKEN=sekrit-token-value" +echo "ready" +` + events := collectSpawn(t, context.Background(), script) + if len(events) == 0 { + t.Fatal("no events emitted") + } + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } + if last.Error != "" { + t.Fatalf("done event carried error: %q", last.Error) + } + if last.TunnelURL != "https://box-42.example.dev" { + t.Errorf("TunnelURL = %q, want https://box-42.example.dev", last.TunnelURL) + } + if last.RunToken != "sekrit-token-value" { + t.Errorf("RunToken = %q, want sekrit-token-value", last.RunToken) + } + if last.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", last.ExitCode) + } + + var lines []string + for _, ev := range events { + if ev.Type == "stdout" { + lines = append(lines, ev.Line) + } + } + want := []string{"booting sandbox...", "TUNNEL_URL=https://box-42.example.dev", "some other progress line", "RUN_TOKEN=sekrit-token-value", "ready"} + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Errorf("stdout lines = %#v, want %#v", lines, want) + } +} + +// TestRunSpawnContractLinesToleratesWhitespace verifies the TUNNEL_URL=/ +// RUN_TOKEN= lines are matched after trimming surrounding whitespace, and +// that a value is trimmed too (a spawn script commonly pads with a +// trailing carriage return or spaces from an underlying tool's echo). +func TestRunSpawnContractLinesToleratesWhitespace(t *testing.T) { + script := `printf ' TUNNEL_URL=https://x.example \n' +printf 'RUN_TOKEN=abc123\n' +` + events := collectSpawn(t, context.Background(), script) + last := events[len(events)-1] + if last.TunnelURL != "https://x.example" { + t.Errorf("TunnelURL = %q, want https://x.example", last.TunnelURL) + } + if last.RunToken != "abc123" { + t.Errorf("RunToken = %q, want abc123", last.RunToken) + } +} + +func TestRunSpawnNonZeroExit(t *testing.T) { + events := collectSpawn(t, context.Background(), "echo one; exit 7") + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } + if last.ExitCode != 7 { + t.Errorf("ExitCode = %d, want 7", last.ExitCode) + } + if last.Error == "" { + t.Error("Error is empty, want a non-zero-exit error message") + } +} + +func TestRunSpawnMissingContractLinesLeaveFieldsEmpty(t *testing.T) { + events := collectSpawn(t, context.Background(), "echo hello") + last := events[len(events)-1] + if last.TunnelURL != "" || last.RunToken != "" { + t.Errorf("done = %#v, want empty TunnelURL/RunToken", last) + } +} + +// TestRunSpawnCancelKillsProcess proves context cancellation actually kills +// a hung spawn process rather than runSpawn blocking forever, with no raw +// time.Sleep anywhere: the fixture prints one line and then blocks reading +// stdin (which the test never provides), and the test waits on a channel +// for that exact "stdout" event — the emitted event itself is the +// synchronization signal that the process has started and is now blocked — +// before canceling. runSpawn finishing (the done channel closing) is what +// proves the kill worked; there is nothing to sleep for. +func TestRunSpawnCancelKillsProcess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + ready := make(chan struct{}) + done := make(chan []spawnEvent, 1) + go func() { + var mu sync.Mutex + var events []spawnEvent + var readyOnce sync.Once + runSpawn(ctx, "echo ready; cat", "", func(ev spawnEvent) { + mu.Lock() + events = append(events, ev) + mu.Unlock() + if ev.Type == "stdout" && ev.Line == "ready" { + readyOnce.Do(func() { close(ready) }) + } + }) + mu.Lock() + done <- events + mu.Unlock() + }() + + <-ready + cancel() + events := <-done + if len(events) == 0 { + t.Fatal("no events emitted") + } + last := events[len(events)-1] + if last.Type != "done" { + t.Fatalf("last event type = %q, want done", last.Type) + } +} + +// TestRunSpawnSetsBoxNameEnv verifies the box-name passthrough documented in +// AGENTS.md's spawn contract section and docs/design/fleet-model.md §8: a +// non-empty name is set as HARNESS_HUB_BOX_NAME in the spawn command's own +// environment (not just some sidecar field), visible to the child process +// like any other env var — this is what lets deployment tooling invoked by +// -spawn-command derive HARNESS_SESSION_DIR from it. +func TestRunSpawnSetsBoxNameEnv(t *testing.T) { + events := collectSpawnNamed(t, context.Background(), `echo "NAME=$HARNESS_HUB_BOX_NAME"`, "amber-otter-07") + var lines []string + for _, ev := range events { + if ev.Type == "stdout" { + lines = append(lines, ev.Line) + } + } + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "NAME=amber-otter-07") { + t.Errorf("stdout = %q, want it to contain NAME=amber-otter-07", joined) + } +} + +// TestRunSpawnEmptyNameLeavesEnvUnset confirms the empty-name case (a +// caller that doesn't send one) doesn't inject an empty +// HARNESS_HUB_BOX_NAME= line — the variable is simply absent, same as +// never having set it. +func TestRunSpawnEmptyNameLeavesEnvUnset(t *testing.T) { + events := collectSpawnNamed(t, context.Background(), `env | grep -c '^HARNESS_HUB_BOX_NAME=' || true`, "") + var lines []string + for _, ev := range events { + if ev.Type == "stdout" { + lines = append(lines, ev.Line) + } + } + joined := strings.Join(lines, "\n") + if strings.TrimSpace(joined) != "0" { + t.Errorf("HARNESS_HUB_BOX_NAME grep count = %q, want 0", joined) + } +}