diff --git a/README.md b/README.md index 8862ffa..dbf4b92 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ docs/devteam/ **What matters most.** The supervisor judges each goal's impact against the project's strategy and rates every question it asks (how much rides on the answer, how soon, and which other questions an answer would settle). The kit counts the rest (tickets held up, a default) and orders the Inbox from it, with the next best step on top and the reason on every card; `devteam.mjs next` prints the same. The order is computed on every read and stored nowhere. The owner corrects a goal's impact with their own value, and it stands. -**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. The hub keeps no state and takes no action: each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub. A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read. +**Many projects, one machine.** Each project has its own tmux session (`devteam--<6 hex of its path>`, saved in `run/tmux-session`) and its own dashboard port: the next free one in 4321–4345, saved in `run/dashboard-port` and reused. Any running dashboard also serves the hub at http://127.0.0.1:4320: a tab per instance on the machine (its name, with a badge when its Inbox has something), that instance's own dashboard under the selected tab, and one "Notify me" for new Inbox items across all of them. It opens on its **Overview** tab: a card per project saying whether it is building, moving, fully blocked on your answers (or partly: some tickets wait, the rest can move), stalled, idle or parked, with its kit version (amber when behind the newest one running here), Inbox count, workers, the goal it is on, tickets merged of planned, what needs you, and twelve weeks of activity. Each card has **Run**, **Drain** and **Park** for that project's fleet, and the page has **Park all** and **Resume all**; Park asks twice. The hub's server keeps no state and takes no action: for Run, Drain and Park the page asks that project's dashboard, in its frame, and the dashboard does it as a click there would, for its parent page only. Each dashboard, served by its own repo's kit, stays on its own address inside the hub's frame, and may be framed only by itself and the hub, so whatever is served at the hub's address can steer every fleet on the machine: an owner who finds something else on port 4320 should not open it (a dashboard notes it in `run/dashboard.log`). A dashboard answers only on the address it serves (`127.0.0.1` and `localhost`, or the `--host` it was started with), and the hub only on its own: a page elsewhere gets nothing, not even a read. **Artifacts.** Any session can draw an artifact, a page that shows the owner an idea instead of describing it: a UI mock, a flow, a mock docs site, a 3D model. It lives in `project/artifacts//` and lands with the PR it was drawn in; the dashboard's **Library** lists landed ones and live drafts from every worktree beside the link registry, and opens each in a centered viewer or its own tab. An artifact loads its own files and pinned libraries from cdnjs, jsdelivr and unpkg, runs sandboxed with no other network and no reach into the dashboard, and sends nothing back: a decision on one is a question that links it, by name and inline (`[[name]]`). The owner can ask for one ("draw me a mock docs site"), and planners draw them for any goal with UI or a flow. `devteam.mjs artifact new|check`, `artifacts`. On a cloud box the owner can also publish a landed artifact to anyone, at `public.box.example.com///` (below). @@ -163,7 +163,7 @@ Install [claude-swap](https://github.com/realiti4/claude-swap) and add your acco Setting up a fleet needs none of this, and a project never copies it: `cloud/` sits outside `docs/devteam/`. It is for an owner who wants their dev box in the cloud: one container (Render, fly.io or any Docker host with a disk) holding the repos, the tmux server where every supervisor and worker runs, and the hub, reached at a URL of the owner's (say `box.example.com`, plus `*.box.example.com`, since each dashboard keeps an origin of its own on a subdomain, and `public.box.example.com` serves what the owner publishes). `cloud/Dockerfile` says how to deploy it. - **Claiming it.** On first boot the log prints a claim link, good for a day. Opening it makes a passkey (1Password or the browser saves it), and from then on only a passkey registered on the box opens it; more can be added from the hub's Passkeys link. Signing in is at `box.example.com/auth`, and passkeys belong to `box.example.com`: pick the box's name before claiming, since changing it invalidates them. Lost every one: `devteam-reclaim` in a shell on the box prints a new link. A box claimed before sign-in moved from `auth.box.example.com` to `/auth` needs that once too: its passkeys belong to the old name (the gateway's log says so). -- **The hub** is the box's home page, with a **Terminal** tab first (`term.box.example.com`): a terminal on the box's tmux server, where the owner logs in to `claude` and `gh`, clones a repo and starts its supervisor (the notes it prints say how), and switches between every session (`C-b s`). Each repo's dashboard is a tab, as on a laptop. The image carries its own copy of the hub page and of the terminal's page, because every agent can change a checkout or take a port: pages on the box's name and the `term.` name are only ever the gateway's, and an agent's page can exist only on an instance's name. +- **The hub** is the box's home page, with a **Terminal** tab first (`term.box.example.com`): a terminal on the box's tmux server, where the owner logs in to `claude` and `gh`, clones a repo and starts its supervisor (the notes it prints say how), and switches between every session (`C-b s`). The **Overview** tab comes next (every project on one page, with Run, Drain and Park for each fleet and for all of them), then each repo's dashboard as a tab, as on a laptop. The image carries its own copy of the hub page and of the terminal's page, because every agent can change a checkout or take a port: pages on the box's name and the `term.` name are only ever the gateway's, and an agent's page can exist only on an instance's name. - **Notifications.** The gateway watches every Inbox itself and sends the hub's notifications by Web Push, so they arrive with no page open. Turn them on with the hub's "Notify me of new Inbox items", once per browser; tap that text afterwards to send a test notification to that device. On an iPhone (iOS 16.4 or later), open the hub in Safari, Share > Add to Home Screen, open it from there and sign in (it doesn't share Safari's sign-in), then turn them on. They reach the Lock Screen. Removing a passkey or running `devteam-reclaim` ends every device's notifications. - **Dropping files on the terminal.** Drag files from your desktop onto the Terminal tab and each is uploaded to `/data/uploads/-/`, then its path is pasted at the focused pane, as a native terminal does, so the operator or supervisor there picks it up; press Enter when you've said what to do with it. An image arrives as an attachment (Safari sends a HEIC photo as a JPEG; elsewhere it arrives as a path). If the terminal has lost its connection, the path stays on screen and pastes when it's back. Up to 512 MB a file and 4 GB in all, kept for 7 days. The name is reduced to letters, digits, `.`, `-` and `_`. Only the gateway writes there; agents can read it. - **It's a general-purpose dev machine,** with no container per project. Everything outside `/data` resets on a deploy, so `$HOME` is the machine: languages and tools install per project with `mise` (it reads `.tool-versions`, and `.ruby-version`, `.python-version` and `.nvmrc` too; `asdf` is an alias for it), and global tools are in `~/.config/mise/config.toml`. The image holds only what needs root: build libraries, `ffmpeg`, Chromium, and Postgres 14, 16 and 17 with `pgvector`. `box-pg 16` starts another Postgres (17 is on the default port, the others on 5400 + major); `~/.box/boot.sh` is what starts at boot, the box's `brew services`. There is no Docker on the box. Expo works without it: `npx expo start --tunnel` for your phone, `eas build` for builds. The Heroku CLI is a global tool too, the standalone build pinned by version and checksum, with its own Node and only `heroku` on `PATH` (the npm one runs on whatever Node a repo pins, and refuses one older than 20). Log in once with `heroku login -i`, giving a token from `heroku authorizations:create --expires-in --scope ` on your laptop as the password: the browser login fails, since the browser isn't on the box's IP. The token lands in `~/.netrc`, on the disk, readable by every agent on the box, so give it the narrowest scope and shortest life that does the job. diff --git a/docs/devteam/dashboard/hub.html b/docs/devteam/dashboard/hub.html index dc54c2e..bed4bd3 100644 --- a/docs/devteam/dashboard/hub.html +++ b/docs/devteam/dashboard/hub.html @@ -53,6 +53,74 @@ @media (max-width: 640px) { header { flex-wrap: wrap; } nav { order: 3; flex-basis: 100%; } } #drop-hint { position: fixed; left: 50%; top: 52px; transform: translateX(-50%); z-index: 10; padding: 8px 14px; border-radius: 6px; background: var(--fg); color: var(--bg); font-size: 13px; pointer-events: none; } #empty { margin: 48px auto; max-width: 520px; padding: 0 16px; color: var(--fg-2); text-align: center; } + /* the Overview: a card per project, read at a glance */ + #overview { position: absolute; inset: 0; overflow: auto; padding: 20px 16px 40px; } + #overview[hidden] { display: none; } + .ov-head { max-width: 1200px; margin: 0 auto 16px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 16px; } + .ov-head h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -.01em; } + .ov-head .sum { color: var(--fg-2); } + .ov-grid { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; } + .ov-card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; box-shadow: var(--shadow); } + .ov-card.bad { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); } + .ov-card.warn { border-color: color-mix(in srgb, var(--amber) 55%, var(--line)); } + .ov-top { display: flex; align-items: center; gap: 8px; } + .ov-name { font: 600 15px/1.3 var(--sans); color: var(--fg); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; } + .ov-name:hover { text-decoration: underline; } + .pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; background: var(--well); color: var(--fg-2); } + .pill::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } + .pill.green { color: var(--green-fg); background: color-mix(in srgb, var(--green) 14%, transparent); } + .pill.blue { color: var(--blue-fg); background: color-mix(in srgb, var(--blue) 12%, transparent); } + .pill.amber { color: var(--amber-fg); background: color-mix(in srgb, var(--amber) 16%, transparent); } + .pill.red { color: var(--red-fg); background: color-mix(in srgb, var(--red) 12%, transparent); } + .ov-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } + .ov-stat { display: flex; flex-direction: column; min-width: 0; } + .ov-stat .k { color: var(--muted); font-size: 11px; } + .ov-stat .v { font: 500 14px/1.3 var(--sans); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ov-stat .v.red { color: var(--red-fg); } + .ov-stat .v.amber { color: var(--amber-fg); } + .ov-stat button.v { background: none; border: 0; padding: 0; text-align: left; cursor: pointer; font: inherit; font: 500 14px/1.3 var(--sans); } + .ov-bar { height: 6px; border-radius: 3px; background: var(--well); overflow: hidden; } + .ov-bar { display: flex; } + .ov-bar > span { display: block; height: 100%; background: var(--green); } + .ov-bar > span.held { background: var(--amber); } + .ov-pills { display: flex; gap: 6px; flex: 0 0 auto; } + .ov-goal { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; min-width: 0; } + .ov-goal .k { color: var(--muted); font-size: 11px; flex: 0 0 auto; } + .ov-goal .t { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .ov-goal .n { color: var(--fg-2); flex: 0 0 auto; font-variant-numeric: tabular-nums; } + .seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 7px; overflow: hidden; flex: 0 0 auto; } + .seg button { font: 500 12px/1 var(--sans); color: var(--fg-2); background: none; border: 0; padding: 6px 10px; cursor: pointer; } + .seg button + button { border-left: 1px solid var(--line-2); } + .seg button:hover:not(:disabled) { background: var(--hover); color: var(--fg); } + .seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); } + .seg button.confirm { background: var(--red); color: #fff; } + .seg button:disabled { cursor: default; opacity: .6; } + .seg button[aria-pressed="true"]:disabled { opacity: 1; } + .ov-ctl { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } + .ov-ctl .msg { font-size: 12px; color: var(--fg-2); } + .ov-ctl .msg.err { color: var(--red-fg); } + .ov-head .acts { margin-left: auto; display: flex; gap: 8px; } + .ov-head .acts button { font: 500 12.5px/1 var(--sans); padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line-2); background: var(--surface); color: var(--fg); cursor: pointer; } + .ov-head .acts button:hover { background: var(--hover); } + .ov-head .acts button.confirm { background: var(--red); border-color: var(--red); color: #fff; } + .ov-prog { display: flex; flex-direction: column; gap: 4px; } + .ov-prog .k { color: var(--fg-2); font-size: 12px; } + .ov-att { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; } + .ov-att li { position: relative; padding-left: 14px; font-size: 12.5px; color: var(--fg); } + .ov-att li::before { content: ''; position: absolute; left: 2px; top: 7px; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); } + .ov-att li.red::before { background: var(--red); } + .ov-ok { font-size: 12.5px; color: var(--fg-2); } + .ov-mosaic { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 9px); grid-auto-columns: 9px; gap: 2px; } + .ov-mosaic i { border-radius: 2px; background: var(--well); } + .ov-mosaic i.l1 { background: color-mix(in srgb, var(--green) 30%, var(--well)); } + .ov-mosaic i.l2 { background: color-mix(in srgb, var(--green) 55%, var(--well)); } + .ov-mosaic i.l3 { background: color-mix(in srgb, var(--green) 78%, var(--well)); } + .ov-mosaic i.l4 { background: var(--green); } + .ov-mosaic i.pad { background: none; } + .ov-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; } + .ov-foot .k { color: var(--muted); font-size: 11px; } + .ov-none { color: var(--fg-2); font-size: 12.5px; } + @media (max-width: 640px) { .ov-stat .v { font-size: 13px; } #overview { padding: 14px 12px 32px; } .ov-grid { grid-template-columns: 1fr; } } @@ -61,7 +129,7 @@ -

Looking for devteam dashboards on this machine…

+

Looking for devteam dashboards on this machine…

diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index d7f9978..f82ec1a 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -5593,6 +5593,33 @@

${k.plain ? esc(k.plain) : linkify(k.statement)}

}); if (VIEWER) viewerBoot(); else { loadSchemaDocs(); load(); setInterval(load, 10000); } +// The hub's Overview: Run, Drain and Park for this project, from the hub page that frames this one. +// The hub never posts an owner action itself (it only lists); it asks this page, and this page +// posts it to its own origin as a click here would. Who asks: only this window's parent, and only +// when the parent is not this origin. frame-ancestors lets only this origin and the hub frame this +// page, so a parent on another origin is the hub. Never in the viewer: on a box its parent may be +// the owner's wrapper on the public name, which is not the hub (it runs no script, and this does +// not rest on that). And only straight under the top page, as the hub holds it. Three ops, each +// once per nonce; the answer goes back to that parent alone. +if (!VIEWER && window.parent === window.top) { + const HUB_OPS = { park: 'park', drain: 'drain', run: 'resume' }; + const seen = new Set(); + window.addEventListener('message', async (ev) => { + if (window.parent === window || ev.source !== window.parent || ev.origin === location.origin || ev.origin === 'null') return; + const m = ev.data; + if (!m || typeof m !== 'object' || m.devteam !== 'hub-fleet' || !Object.hasOwn(HUB_OPS, m.mode) || typeof m.nonce !== 'string' || !/^[a-z0-9]{8,40}$/.test(m.nonce)) return; + const reply = (o) => { try { ev.source.postMessage({ devteam: 'hub-fleet-done', nonce: m.nonce, ...o }, ev.origin); } catch {} }; + if (seen.has(m.nonce)) return; + seen.add(m.nonce); + try { + const r = await fetch('/api/fleet', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op: HUB_OPS[m.mode], ...(m.mode === 'run' ? {} : { reason: m.all ? `${m.mode === 'park' ? 'parked' : 'drained'} with every project, from the hub's Overview` : `${m.mode === 'park' ? 'parked' : 'drained'} from the hub's Overview` }) }) }); + const j = await r.json().catch(() => ({})); + // "nothing to resume" is the fleet already running: what was asked for holds + if (r.ok || (m.mode === 'run' && /nothing to resume/.test(String(j.error ?? '')))) { reply({ ok: true }); if (typeof loadFleet === 'function') loadFleet(); } + else reply({ ok: false, error: String(j.error ?? `the dashboard answered ${r.status}`).slice(0, 300) }); + } catch (e) { reply({ ok: false, error: String(e?.message ?? e).slice(0, 300) }); } + }); +} diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 7c9fb36..bb5014f 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -11394,7 +11394,9 @@ async function pickDashboardPort({ skip = [] } = {}) { // instance in the block by asking each port /api/hello: a tab per instance, with that instance's // own dashboard in a sandboxed frame on its own address. It owns no state and does nothing for the // owner: GET only, no proxy, its own Host only, a page that fetches only from itself and frames only -// the block. The others retry, so the hub moves when its dashboard stops. Something +// the block. The page's Overview can run, drain and park a fleet, but not through this server: it +// asks that project's dashboard, in its frame, and the dashboard posts to itself (index.html, "The +// hub's Overview"). The others retry, so the hub moves when its dashboard stops. Something // that isn't a devteam hub on the port: logged once, and this dashboard stops trying. function startHub(log) { const page = path.join(kitDir, 'dashboard', 'hub.html'); @@ -12906,7 +12908,7 @@ async function cmdDashboard() { }; refreshHello(); setInterval(refreshHello, 10000).unref(); - const helloBody = () => JSON.stringify({ devteam: 1, root: fs.realpathSync(mainRoot), name: loadSettings().project ?? path.basename(mainRoot), port, kit, viewer: 1, inbox: hello?.inbox ?? null, fleet: hello?.fleet ?? null, ...(hello?.alert ? { alert: hello.alert } : {}), previews: readPreviews().slice(0, PREVIEW_MAX).map((r) => previewHelloRow(r, previewUp.get(r.name) ?? null)) }); + const helloBody = () => JSON.stringify({ devteam: 1, root: fs.realpathSync(mainRoot), name: loadSettings().project ?? path.basename(mainRoot), port, kit, viewer: 1, inbox: hello?.inbox ?? null, fleet: hello?.fleet ?? null, ...(hello?.alert ? { alert: hello.alert } : {}), pulse: hello?.pulse ?? null, previews: readPreviews().slice(0, PREVIEW_MAX).map((r) => previewHelloRow(r, previewUp.get(r.name) ?? null)) }); // Previews: the tick's probes feed hello and the Library, one run at a time so a tmux that has // stalled never stacks them up. A file in run/previews that isn't a preview is named once. const previewsNamedBad = new Set(); @@ -13548,6 +13550,8 @@ async function cmdDashboard() { if (args && args.some((a) => typeof a !== 'string' || a.includes('\0'))) args = null; if (!args) return send(res, 400, 'application/json', JSON.stringify({ error: 'missing or invalid fields' })); const r = await runAction(args, { timeout: ACTION_MS[name] ?? 0 }); + // what the hub lists (the Inbox count, the fleet's mode) follows an owner action at once + if (r.ok) refreshHello(); send(res, r.ok ? 200 : 400, 'application/json', JSON.stringify(r.ok ? { ok: true, out: r.stdout.trim() } : { error: r.stderr.split('\n').filter((l) => l && !l.startsWith('devteam: warning:')).join(' ').replace(/^devteam: /, '') })); }); }); @@ -16387,14 +16391,108 @@ function boxAlert() { } catch { return null; } } const helloAlert = () => [accountsState()?.supervisor_limit ? 'The supervisor stopped at a usage limit: type "continue" in its terminal' : null, boxAlert()].filter(Boolean).join(' · ') || null; +// The hub's Overview: is this project moving, and if not, why. From the log in one pass; from git, +// only whether each ticket has merged (main's log and the refs, one call each under cmdHello's +// read cache, never one per ticket), so hello stays cheap at every 10 s. +// Blocked on the owner, per open ticket that could move (its goal not parked or let go): named in +// the --blocks of an open question with no default, or depending on one that is, however far down. +// blocking: 'full' when every such ticket is, 'partial' when some are and the rest can move. +// state: parked (every goal held), blocked (fully: nothing can move until the owner answers), +// waiting (an approved goal with nothing planned yet, and a question with no default open on it), +// building (a worker mid-ticket and the fleet wrote in the last hour), stalled (open work and +// nothing written by the fleet for 2 h), idle (no open work: no ticket that could move, no +// approved goal still to plan), moving (anything else: the supervisor planning, reviewing, merging). activity: the fleet's log lines per day, oldest first, +// the last 84 days ending today (UTC). +const PULSE_DAYS = 84; +function helloPulse() { + const now = Date.now(); + const lines = readLog().lines; + const day0 = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()); + const activity = new Array(PULSE_DAYS).fill(0); + const lastBy = new Map(); + const phaseBy = new Map(); + let last = 0; + for (const l of lines) { + if (l.by === 'owner') continue; + const t = Date.parse(l.at); + if (!(t > 0)) continue; + if (t > last) last = t; + if (t > (lastBy.get(l.by) ?? 0)) lastBy.set(l.by, t); + if (l.type === 'phase' && (!phaseBy.has(l.by) || l.at >= phaseBy.get(l.by).at)) phaseBy.set(l.by, l); + const back = Math.floor((day0 - t) / 86400000) + 1; + if (t >= day0) activity[PULSE_DAYS - 1]++; + else if (back < PULSE_DAYS) activity[PULSE_DAYS - 1 - back]++; + } + const ts = [...tickets().values()].filter(isActive); + const done = ts.filter((t) => mergeShaFor(t.id)).length; + const open = ts.length - done; + const working = ts.filter((t) => !mergeShaFor(t.id) && phaseBy.has(t.id) && !['done', 'parked'].includes(phaseBy.get(t.id).to)); + const quietMin = last ? Math.round((now - last) / 60000) : null; + const silent = working.filter((t) => now - (lastBy.get(t.id) ?? 0) > 3600000); + const qs = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go'); + const holdingQs = qs.filter((q) => (q.blocks ?? []).length && q.default == null); + const named = new Set(holdingQs.flatMap((q) => q.blocks)); + const openTs = ts.filter((t) => !mergeShaFor(t.id)); + // what could move: a ticket whose goal the owner parked or let go waits by choice, so it is + // neither stalled nor blocked, and it isn't what a question holds up + const live = openTs.filter((t) => !['park', 'let-go'].includes(ticketMode(t))); + const liveIds = new Set(live.map((t) => t.id)); + const held = new Set(live.filter((t) => named.has(t.id)).map((t) => t.id)); + // and whatever depends on a held ticket, however far down + for (let grew = true; grew;) { + grew = false; + for (const t of live) if (!held.has(t.id) && (t.depends_on ?? []).some((d) => held.has(d))) { held.add(t.id); grew = true; } + } + // the questions that hold tickets, and the ones that hold something else (an incident, work not + // planned yet) + const holding = holdingQs.filter((q) => q.blocks.some((b) => liveIds.has(b))).length; + const holdingElse = holdingQs.length - holding; + const blocking = !held.size ? null : held.size === live.length ? 'full' : 'partial'; + const mode = fleetMode() ?? 'run'; + // what it is working on: the approved goal first in priority (one that is running before one on + // hold), and its tickets merged of planned + const goals = logObjects('goal').filter((g) => g.status === 'ready').sort((a, b) => (goalMode(a) === 'park') - (goalMode(b) === 'park') || (a.priority ?? Infinity) - (b.priority ?? Infinity)); + const goalTs = (g) => ts.filter((t) => ticketGoals(t).some((x) => x.id === g.id)); + // an approved goal, running, with no ticket yet: planning is owed, so the project is not idle + const unplanned = goals.filter((g) => goalMode(g) !== 'park' && !goalTs(g).length).length; + const work = live.length + unplanned; + const state = mode === 'park' ? 'parked' + : blocking === 'full' ? 'blocked' + : !live.length && unplanned && holdingElse ? 'waiting' + : working.length > silent.length && quietMin !== null && quietMin < 60 ? 'building' + : work && (quietMin === null || quietMin >= 120) ? 'stalled' + : !work ? 'idle' + : 'moving'; + const ago = (m) => (m < 120 ? `${m} min` : m < 2880 ? `${Math.round(m / 60)} h` : `${Math.round(m / 1440)} days`); + const incidents = (() => { try { return incidentList().filter((p) => p.status !== 'resolved').length; } catch { return 0; } })(); + const answers = (n) => (n === 1 ? 'your answer to 1 question' : `your answers to ${n} questions`); + const onHold = openTs.length - live.length; + const attention = [ + blocking === 'full' ? `Fully blocked: all ${live.length} open ticket${live.length === 1 ? '' : 's'} wait on ${answers(holding)}` + : blocking === 'partial' ? `Partly blocked: ${held.size} of ${live.length} open tickets wait on ${answers(holding)}; the rest can move` + : null, + holdingElse ? `${holdingElse} question${holdingElse === 1 ? '' : 's'} with no default ${holdingElse === 1 ? 'is' : 'are'} holding up work that is not an open ticket` : null, + state === 'stalled' ? (quietMin === null ? 'Open work, and the fleet has never written to the log' : `${live.length ? 'Open work' : 'An approved goal with no tickets'}, and nothing has moved in ${ago(quietMin)}`) : null, + silent.length ? `${silent.length} worker${silent.length === 1 ? ' has' : 's have'} been silent for over an hour` : null, + incidents ? `${incidents} open production incident${incidents === 1 ? '' : 's'}` : null, + mode === 'drain' ? 'Draining: workers finish what they have, nothing new starts' : null, + onHold && mode !== 'park' ? `${onHold} open ticket${onHold === 1 ? ' is' : 's are'} on hold with ${onHold === 1 ? 'its' : 'their'} goal` : null, + ].filter(Boolean); + const top = goals[0] ?? null; + const topTs = top ? goalTs(top) : []; + const goal = top ? { title: String(top.title ?? top.id).slice(0, 120), done: topTs.filter((t) => mergeShaFor(t.id)).length, total: topTs.length, mode: goalMode(top) } : null; + return { state, blocking, blocked_tickets: held.size, goal, goals_open: goals.length, last_activity: last ? new Date(last).toISOString() : null, working: working.length, open_tickets: open, done_tickets: done, holding: holdingQs.length, attention, activity }; +} function cmdHello() { + // read-only, and the pulse asks whether each open ticket has merged: every ref in one git call + gitReadCache = new Map(); const s = loadSettings(); // the Inbox as the dashboard counts it: open questions and the lessons sent to the owner to keep const inbox = questions().filter((q) => q.status === 'open' && q.goal_hold !== 'let-go').length + logObjects('knowledge').filter(forOwner).length; const workers = ourWindows(tmuxSession()).length; // --pane: whether the supervisor has a pane the kit can type into, for the dashboard's 10 s run of // this (its threads' "hasn't replied yet"), so the dashboard's own process never asks tmux itself - const r = { devteam: 1, root: fs.realpathSync(mainRoot), name: s.project ?? path.basename(mainRoot), kit: flags['no-kit'] ? null : kitVersion(), inbox, fleet: { mode: fleetMode() ?? 'run', workers }, ...(helloAlert() ? { alert: helloAlert() } : {}), ...(flags.pane ? { supervisor_pane: !resolvePane(supervisorTarget()).problem } : {}) }; + const r = { devteam: 1, root: fs.realpathSync(mainRoot), name: s.project ?? path.basename(mainRoot), kit: flags['no-kit'] ? null : kitVersion(), inbox, fleet: { mode: fleetMode() ?? 'run', workers }, ...(helloAlert() ? { alert: helloAlert() } : {}), pulse: helloPulse(), ...(flags.pane ? { supervisor_pane: !resolvePane(supervisorTarget()).problem } : {}) }; return flags.json ? json(r) : out(`${r.name} at ${r.root}: kit ${r.kit}, ${inbox} open question(s), fleet ${r.fleet.mode}, ${workers} session(s)`); } // The kit's release (settings.upstream_base), +local when the kit that is running differs from the diff --git a/test/hub-overview.test.mjs b/test/hub-overview.test.mjs new file mode 100644 index 0000000..09368b1 --- /dev/null +++ b/test/hub-overview.test.mjs @@ -0,0 +1,211 @@ +// The hub's Overview reads each project's hello: its pulse says whether the project is moving, how +// far along it is, what needs the owner, and what the fleet did each day for twelve weeks. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { PORTS, bareProject, fleetFixture, json, line, startDashboard, until } from './lib/kit.mjs'; +import { canDrive, openChrome } from './lib/chrome.mjs'; + +const SUP = { DEVTEAM_ROLE: 'supervisor' }; + +test('hello carries a pulse: idle, moving, fully and partly blocked, building', () => { + const F = fleetFixture('hub-pulse'); + const { old, ok } = F; + const pulse = () => json(ok(old, ['hello', '--json', '--no-kit'])).pulse; + try { + let p = pulse(); + assert.equal(p.state, 'idle', 'nothing open'); + assert.equal(p.activity.length, 84, 'twelve weeks, a day each'); + assert.ok(p.activity.every((n) => Number.isInteger(n) && n >= 0)); + assert.deepEqual(p.attention, []); + + // a goal, a feature and a ticket: open work + const G = ok(old, ['goal', 'add', '--title', 'Billing', '--outcome', 'it works', '--priority', '1'], SUP); + ok(old, ['feature', 'add', 'bill', '--json', JSON.stringify({ title: 'bill', goals: [G], stories: [{ id: 'BILL-S1', text: 'it works', release: 'v1.0.0' }] })], SUP); + ok(old, ['ticket', 'add', 'app-01-bill', '--json', JSON.stringify({ title: 'Billing page', feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['src/bill/**'], done_when: ['its test passes in CI'] })], SUP); + p = pulse(); + assert.equal(p.goal, null, 'a draft goal is not what it is working on'); + assert.equal(p.goals_open, 0); + assert.equal(p.open_tickets, 1); + assert.equal(p.done_tickets, 0); + assert.equal(p.state, 'moving', 'open work, and the fleet wrote just now'); + assert.ok(p.activity.at(-1) >= 3, 'what the supervisor just wrote counts as today\'s activity'); + assert.ok(p.last_activity && Date.now() - Date.parse(p.last_activity) < 60000); + + // a question with no default holding up the only ticket: fully blocked + ok(old, ['ask', '--question', 'Charge per seat or per team?', '--blocks', 'app-01-bill', '--recommendation', 'Per team'], SUP); + p = pulse(); + assert.equal(p.state, 'blocked'); + assert.equal(p.blocking, 'full'); + assert.equal(p.blocked_tickets, 1); + assert.match(p.attention[0], /^Fully blocked: all 1 open ticket wait on your answer to 1 question/); + + // a ticket that depends on it waits too; one that doesn't can move: partly blocked + const add = (id, deps) => ok(old, ['ticket', 'add', id, '--json', JSON.stringify({ title: id, feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: deps, touches: [`src/${id}/**`], done_when: ['its test passes in CI'] })], SUP); + add('app-02-invoices', ['app-01-bill']); + p = pulse(); + assert.equal(p.blocking, 'full', 'what depends on a held ticket is held too'); + assert.equal(p.blocked_tickets, 2); + add('app-03-receipts', []); + p = pulse(); + assert.equal(p.blocking, 'partial'); + assert.equal(p.blocked_tickets, 2); + assert.equal(p.open_tickets, 3); + assert.notEqual(p.state, 'blocked'); + assert.match(p.attention[0], /^Partly blocked: 2 of 3 open tickets wait on your answer to 1 question; the rest can move/); + + // a worker mid-ticket, writing now: building, and still partly blocked + const ym = new Date().toISOString().slice(0, 7); + fs.appendFileSync(path.join(old, 'docs/devteam/run/log', 'app-03-receipts.jsonl'), line({ v: 1, id: `h-${ym.replace('-', '')}01-000000000-app-03-receipts`, type: 'phase', op: 'new', at: new Date().toISOString(), by: 'app-03-receipts', from: null, to: 'build' })); + p = pulse(); + assert.equal(p.working, 1); + assert.equal(p.state, 'building'); + assert.equal(p.blocking, 'partial'); + + + // the owner approves the goal: the card names it, with its own tickets merged of planned + fs.appendFileSync(path.join(old, 'docs/devteam/run/log', `owner-${ym}.jsonl`), line({ id: G, type: 'goal', op: 'set', status: 'ready', at: new Date().toISOString(), by: 'owner', channel: 'cli' })); + p = pulse(); + assert.deepEqual([p.goal?.title, p.goal?.done, p.goal?.total, p.goals_open], ['Billing', 0, 3, 1]); + } finally { F.done(); } +}); + +test('the pulse: an approved goal with nothing planned is not idle; a goal the owner parked is neither moving nor stalled', () => { + const F = fleetFixture('hub-pulse-goals'); + const { old, ok } = F; + const pulse = () => json(ok(old, ['hello', '--json', '--no-kit'])).pulse; + const dir = path.join(old, 'docs/devteam/run/log'); + // every line written so far, as if written that long ago + const age = (ms) => { for (const f of fs.readdirSync(dir)) fs.writeFileSync(path.join(dir, f), fs.readFileSync(path.join(dir, f), 'utf8').replace(/"at":"([^"]+)"/g, (all, t) => `"at":"${new Date(Date.parse(t) - ms).toISOString()}"`)); }; + try { + const G = ok(old, ['goal', 'add', '--title', 'Billing', '--outcome', 'it works', '--priority', '1'], SUP); + fs.appendFileSync(path.join(dir, `owner-${new Date().toISOString().slice(0, 7)}.jsonl`), line({ id: G, type: 'goal', op: 'set', status: 'ready', at: new Date().toISOString(), by: 'owner', channel: 'cli' })); + let p = pulse(); + assert.equal(p.state, 'moving', 'approved and not planned yet: planning is owed'); + assert.equal(p.open_tickets, 0); + age(3 * 3600000); + p = pulse(); + assert.equal(p.state, 'stalled', 'and nobody planning it for hours is a stall, not "waiting for a new goal"'); + assert.match(p.attention.join('\n'), /An approved goal with no tickets, and nothing has moved in 3 h/); + + ok(old, ['feature', 'add', 'bill', '--json', JSON.stringify({ title: 'bill', goals: [G], stories: [{ id: 'BILL-S1', text: 'it works', release: 'v1.0.0' }] })], SUP); + ok(old, ['ticket', 'add', 'app-01-bill', '--json', JSON.stringify({ title: 'Billing page', feature: 'bill', component: 'app', kind: 'build', size: 'S', risk: 'normal', serves: ['BILL-S1'], depends_on: [], touches: ['src/bill/**'], done_when: ['its test passes in CI'] })], SUP); + assert.equal(pulse().state, 'moving'); + // the owner parks that one goal: Upkeep still runs, so the fleet is not parked + ok(old, ['goal', 'hold', G, 'park', '--reason', 'later', '--owner'], { DEVTEAM_CHANNEL: 'dashboard' }); + age(3 * 86400000); + p = pulse(); + assert.equal(json(ok(old, ['hello', '--json', '--no-kit'])).fleet.mode, 'run'); + assert.equal(p.state, 'idle', 'its ticket waits by the owner\'s choice: nothing is stalled'); + assert.equal(p.open_tickets, 1, 'still counted as not merged'); + assert.deepEqual(p.attention, ['1 open ticket is on hold with its goal']); + assert.equal(p.goal.mode, 'park'); + // a question on it blocks nothing that could move + ok(old, ['ask', '--question', 'Charge per seat or per team?', '--blocks', 'app-01-bill', '--recommendation', 'Per team'], SUP); + p = pulse(); + assert.equal(p.blocking, null); + assert.notEqual(p.state, 'blocked'); + } finally { F.done(); } +}); + +// The hub can't post an owner action. Its Overview asks the project's dashboard, in its frame, and +// that page posts to itself: only for its parent, only when that parent is another origin (the hub, +// by frame-ancestors), never in the viewer, never opened on its own. +test('the Overview in Chrome: Park by keyboard, Park all and Resume all through each dashboard; nothing but the hub above a dashboard is heard', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 240000 }, async (t) => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-hub-ov-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + const HUB = PORTS + 60; + const P = PORTS + 61; + const env = { ...process.env, DEVTEAM_HUB_PORT: String(HUB), DEVTEAM_PORT_BASE: String(P), DEVTEAM_HUB_RETRY_MS: '500', CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + const A = bareProject(base, 'alpha'); + const B = bareProject(base, 'beta'); + for (const r of [A, B]) execFileSync('sh', ['-c', 'git add -A && git -c user.email=t@t -c user.name=t commit -qm init'], { cwd: r }); + // the fleet's mode as the project itself says it now (the hub's list is up to 10 s behind) + const mode = (root) => { const r = spawnSync(process.execPath, [path.join(root, 'docs/devteam/scripts/devteam.mjs'), 'hello', '--json', '--no-kit'], { cwd: root, env, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return json(r.stdout).fleet.mode; }; + const a = startDashboard(A, env); + t.after(() => a.stop()); + await until(() => a.out().includes(`127.0.0.1:${P}`), "alpha's dashboard"); + const b = startDashboard(B, env); + t.after(() => b.stop()); + await until(async () => { try { const l = (await (await fetch(`http://127.0.0.1:${HUB}/instances.json`)).json()).instances; return l.length === 2 && l.every((x) => x.pulse); } catch { return false; } }, 'both projects listed with a pulse'); + const chrome = await openChrome(); + t.after(() => chrome.close()); + const p = await chrome.newPage(); + t.after(() => p.close()); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Page.navigate', { url: `http://127.0.0.1:${HUB}/` }); + const page = (what, expr) => until(() => p.ev(expr).catch(() => false), what); + const card = (name) => `[...document.querySelectorAll('.ov-card')].find((c) => c.querySelector('.ov-name')?.textContent === '${name}')`; + const seg = (name, label) => `[...${card(name)}.querySelectorAll('.seg button')].find((x) => x.textContent === '${label}')`; + await page('a card each', `document.querySelectorAll('.ov-card').length === 2 && document.querySelectorAll('.seg').length === 2`); + assert.equal(await p.ev(`document.querySelector('[role=tab][aria-selected=true]').textContent`), 'Overview', 'the page opens on the Overview'); + assert.equal(await p.ev(`location.hash`), '#overview'); + assert.equal(await p.ev(`document.querySelectorAll('iframe').length`), 0, 'no dashboard is loaded until one is opened or asked'); + + // Park, by keyboard: two presses of the space bar on one button, across the redraw between them + await p.ev(`${seg('alpha', 'Park')}.focus()`); + await p.key(' '); + await page('the press asking to be confirmed', `document.activeElement?.textContent === 'Confirm park'`); + assert.equal(mode(A), 'run', 'one press parks nothing'); + await p.key(' '); + await until(() => mode(A) === 'park', 'alpha parked'); + assert.equal(mode(B), 'run', 'and only alpha'); + // the card says so as soon as alpha's dashboard answers, not a hello later + await page('the card showing Park pressed', `${seg('alpha', 'Park')}?.getAttribute('aria-pressed') === 'true' && ${card('alpha')}.querySelector('.pill:last-child').textContent === 'Parked'`); + await page('the frame made to ask, gone again', `document.querySelectorAll('iframe').length === 0`); + const held = spawnSync(process.execPath, [path.join(A, 'docs/devteam/scripts/devteam.mjs'), 'fleet', 'status'], { cwd: A, env, encoding: 'utf8' }).stdout; + assert.match(held, /parked from the hub's Overview/, 'the reason says where it came from'); + + // every project at once, and back + await page('Park all offered', `[...document.querySelectorAll('.ov-head .acts button')].some((x) => x.textContent === 'Park all' && !x.disabled)`); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => x.textContent === 'Park all').click()`); + assert.equal(mode(B), 'run', 'Park all asks first'); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => /^Confirm: park 1 project$/.test(x.textContent)).click()`); + await until(() => mode(B) === 'park', 'beta parked'); + await page('Resume all offered for both', `[...document.querySelectorAll('.ov-head .acts button')].some((x) => x.textContent === 'Resume all (2)' && !x.disabled)`); + await p.ev(`[...document.querySelectorAll('.ov-head .acts button')].find((x) => x.textContent === 'Resume all (2)').click()`); + await until(() => mode(A) === 'run' && mode(B) === 'run', 'both running again'); + + // a project's tab after all that: its dashboard, shown + await p.ev(`[...document.querySelectorAll('[role=tab]')].find((x) => x.textContent.startsWith('alpha')).click()`); + await page("alpha's dashboard in its frame", `document.querySelectorAll('iframe:not([hidden])').length === 1 && document.getElementById('overview').hidden`); + await until(() => p.evIn(`http://127.0.0.1:${P}`, `document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "alpha's page running"); + + // Who is not heard. Each is given 2 s to park beta, which would take well under one. + const ask = (nonce) => JSON.stringify({ devteam: 'hub-fleet', mode: 'park', nonce }); + const quiet = async (why) => { await new Promise((r) => setTimeout(r, 2000)); assert.equal(mode(B), 'run', why); }; + // the dashboard's own page, to itself (its parent is itself: any script of its own origin) + await p.evIn(`http://127.0.0.1:${P}`, `window.postMessage(${ask('aaaaaaaa01')}, '*')`); + // the viewer under the hub: the same page, but never the hub's to steer + await p.ev(`(() => { const f = document.createElement('iframe'); f.id = 'viewer'; f.src = 'http://127.0.0.1:${P + 1}/view/artifact/some-page'; document.getElementById('frames').append(f); return new Promise((r) => f.addEventListener('load', r)); })()`); + await until(() => p.evIn(`http://127.0.0.1:${P + 1}`, `document.readyState === 'complete' && typeof VIEWER === 'object' && VIEWER !== null`).catch(() => false), "beta's viewer running"); + await p.ev(`document.getElementById('viewer').contentWindow.postMessage(${ask('aaaaaaaa02')}, '*')`); + await quiet('the viewer does not listen'); + // another page on this machine can't hold a dashboard at all, so it has no frame to ask + const other = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end(``); }); + await new Promise((r) => other.listen(PORTS + 90, '127.0.0.1', r)); + t.after(() => other.close()); + const q = await chrome.newPage(); + t.after(() => q.close()); + await q.send('Page.enable'); + await q.send('Runtime.enable'); + await q.send('Page.navigate', { url: `http://127.0.0.1:${PORTS + 90}/` }); + await until(() => q.ev(`document.getElementById('d') !== null`).catch(() => false), 'the other page'); + await quiet('a page that is not the hub frames no dashboard'); + // and a dashboard in a tab of its own hears nobody: it has no parent + await q.send('Page.navigate', { url: `http://127.0.0.1:${P + 1}/` }); + await until(() => q.ev(`location.port === '${P + 1}' && document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "beta's dashboard on its own"); + await q.ev(`window.postMessage(${ask('aaaaaaaa04')}, '*')`); + await quiet('a dashboard opened on its own hears nobody'); + // the hub itself still is: the same ask from the page above beta's dashboard parks it + await p.ev(`[...document.querySelectorAll('[role=tab]')].find((x) => x.textContent.startsWith('beta')).click()`); + await until(() => p.evIn(`http://127.0.0.1:${P + 1}`, `location.pathname === '/' && document.readyState === 'complete' && typeof loadFleet === 'function'`).catch(() => false), "beta's dashboard under the hub"); + await p.ev(`[...document.querySelectorAll('iframe')].find((f) => f.title === 'beta').contentWindow.postMessage(${ask('aaaaaaaa05')}, 'http://127.0.0.1:${P + 1}')`); + await until(() => mode(B) === 'park', 'beta parked by its parent, the hub'); +});