Public: anyone can read this; the crew can change it.
The forge toolbar names the crew run working on this app or preview now #2264
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2251-the-forge-toolbar-names-the-crew-run-wor → main 3 commits fork
6 files
changed +320
−2
README.md +5 −1
| @@ -537,7 +537,11 @@ The forge pill on an app's pages says where you are: its accessible name reads | ||
| 537 | 537 | `notes, production` or `notes, preview of pull #7`, and pressing it opens a |
| 538 | 538 | panel with the app, the environment, and a link home. The pull's title is shown |
| 539 | 539 | only to people who may read the pull. A tap outside the open panel, or Escape, |
| 540 | closes it. | |
| 540 | closes it. When a crew run is working on the app (production) or on the preview's | |
| 541 | pull, the panel also says `scout is working on this` and links the run; it asks | |
| 542 | `/__forge/crew` once as the pill loads, answers only a signed-in person who may | |
| 543 | read the repository (and, for a run on a sealed issue, that issue), and shows | |
| 544 | nothing when the crew is idle. | |
| 541 | 545 | |
| 542 | 546 | With a sentence and **Pick for me**, the sentence becomes a plan before the |
| 543 | 547 | page comes back: `src/crew/plan.ts` makes one non-interactive call to |
src/apps/crew.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * Who of the crew is working on an app right now, for the forge pill: the | |
| 3 | * running agent run on this app (production) or on this preview's pull, as | |
| 4 | * the name to show and the forge page to link. Only for a person the app | |
| 5 | * has admitted who may read the repository (and the pull); everyone else, | |
| 6 | * and an idle app, get nothing. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import type { AppRecord } from "../core/apps.ts"; | |
| 11 | import { getIssue } from "../core/issues.ts"; | |
| 12 | import { canRead, getRepoById } from "../core/repos.ts"; | |
| 13 | import { workingAgentRuns } from "../core/runs.ts"; | |
| 14 | import { mayView, mayViewId } from "../core/sealed.ts"; | |
| 15 | import { getUserById, isActive } from "../core/users.ts"; | |
| 16 | ||
| 17 | export type CrewNow = { who: string; url: string }; | |
| 18 | ||
| 19 | export function crewNow( | |
| 20 | db: Database, | |
| 21 | forgeUrl: string, | |
| 22 | app: AppRecord, | |
| 23 | env: string, | |
| 24 | viewerId: string | undefined, | |
| 25 | ): CrewNow | undefined { | |
| 26 | if (viewerId === undefined) return undefined; | |
| 27 | const viewer = getUserById(db, viewerId); | |
| 28 | const repo = getRepoById(db, app.repoId); | |
| 29 | if (viewer === undefined || !isActive(viewer) || repo === undefined) return undefined; | |
| 30 | if (!canRead(db, repo, viewer)) return undefined; | |
| 31 | const number = /^pull-(\d+)$/.exec(env)?.[1]; | |
| 32 | let issueId: string | undefined; | |
| 33 | if (number !== undefined) { | |
| 34 | const pull = getIssue(db, repo.id, Number(number)); | |
| 35 | if (pull === undefined || !mayView(db, repo, pull, viewer)) return undefined; | |
| 36 | issueId = pull.id; | |
| 37 | } else if (env !== "production") return undefined; | |
| 38 | // A run on a sealed issue is not the viewer's to know of. | |
| 39 | const run = workingAgentRuns(db, repo.id, issueId).find((r) => | |
| 40 | mayViewId(db, repo, r.issueId, viewer), | |
| 41 | ); | |
| 42 | if (run === undefined) return undefined; | |
| 43 | const actor = run.actorId === null ? undefined : getUserById(db, run.actorId); | |
| 44 | return { | |
| 45 | who: actor?.username ?? "the crew", | |
| 46 | url: new URL(`/${repo.ownerName}/${repo.name}/runs/${run.id}`, forgeUrl).href, | |
| 47 | }; | |
| 48 | } |
src/apps/proxy.ts +23
| @@ -34,6 +34,7 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 34 | 34 | import { loadKey } from "../kernel/secret.ts"; |
| 35 | 35 | import { previewEnv, type EnvName } from "./data.ts"; |
| 36 | 36 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 37 | import { crewNow } from "./crew.ts"; | |
| 37 | 38 | import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| 38 | 39 | |
| 39 | 40 | export interface AppTarget { |
| @@ -535,6 +536,28 @@ export async function proxyApp( | ||
| 535 | 536 | }, |
| 536 | 537 | }); |
| 537 | 538 | } |
| 539 | // The pill's fourth door: who of the crew is working on this app now, | |
| 540 | // for the person the app's own grant names, read once as the pill opens. | |
| 541 | if (url.pathname === "/__forge/crew" && !target.nested) { | |
| 542 | const cookie = cookieValue(request, grantCookieName(target)); | |
| 543 | const signed = | |
| 544 | cookie === undefined | |
| 545 | ? undefined | |
| 546 | : readGrant(runtime.app.config.dataDir, cookie, target); | |
| 547 | const crew = crewNow( | |
| 548 | runtime.app.db, | |
| 549 | runtime.options.forgeUrl, | |
| 550 | target.app, | |
| 551 | target.env, | |
| 552 | signed?.who?.id, | |
| 553 | ); | |
| 554 | return Response.json(crew ?? {}, { | |
| 555 | headers: { | |
| 556 | "cache-control": "no-store", | |
| 557 | "x-content-type-options": "nosniff", | |
| 558 | }, | |
| 559 | }); | |
| 560 | } | |
| 538 | 561 | // The third door: a problem report, answered without waking or even |
| 539 | 562 | // reaching the app — the app being down is the usual reason to ask. The |
| 540 | 563 | // forge's form is told which page it came from. |
src/apps/toolbar.ts +24 −1
| @@ -52,12 +52,35 @@ export const toolbarJs = `(function () { | ||
| 52 | 52 | var link = document.createElement("a"); |
| 53 | 53 | link.textContent = "Home"; |
| 54 | 54 | link.rel = "noopener"; |
| 55 | link.style.cssText = "color:#8ab4ff;text-decoration:none;margin-top:4px"; | |
| 55 | link.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 56 | 56 | try { |
| 57 | 57 | var target = new URL(home); |
| 58 | 58 | if (target.origin === new URL(me.src).origin) link.href = target.href; |
| 59 | 59 | } catch (e) {} |
| 60 | // The crew line arrives late, so it lands in a polite live region. | |
| 61 | var crew = document.createElement("div"); | |
| 62 | crew.setAttribute("aria-live", "polite"); | |
| 63 | panel.appendChild(crew); | |
| 60 | 64 | panel.appendChild(link); |
| 65 | // Who of the crew is working here, asked once as the pill loads, of the | |
| 66 | // app's own origin (same-origin, so its grant cookie goes along); nothing | |
| 67 | // is shown idle. | |
| 68 | try { | |
| 69 | fetch("/__forge/crew", { credentials: "same-origin", cache: "no-store" }) | |
| 70 | .then(function (r) { return r.ok ? r.json() : {}; }) | |
| 71 | .then(function (c) { | |
| 72 | if (!c || typeof c.who !== "string" || typeof c.url !== "string") return; | |
| 73 | var u = new URL(c.url); | |
| 74 | if (u.origin !== new URL(me.src).origin) return; | |
| 75 | var a = document.createElement("a"); | |
| 76 | a.textContent = c.who + " is working on this"; | |
| 77 | a.rel = "noopener"; | |
| 78 | a.href = u.href; | |
| 79 | a.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 80 | crew.appendChild(a); | |
| 81 | }) | |
| 82 | .catch(function () {}); | |
| 83 | } catch (e) {} | |
| 61 | 84 | function set(open) { |
| 62 | 85 | panel.hidden = !open; |
| 63 | 86 | pill.setAttribute("aria-expanded", open ? "true" : "false"); |
src/core/runs.ts +15
| @@ -697,6 +697,21 @@ export function listActive(db: Database, kind: RunKind): Run[] { | ||
| 697 | 697 | .map(toRun); |
| 698 | 698 | } |
| 699 | 699 | |
| 700 | /** The agent runs working now on a repository, or on one of its issues, newest first. */ | |
| 701 | export function workingAgentRuns( | |
| 702 | db: Database, | |
| 703 | repoId: string, | |
| 704 | issueId?: string, | |
| 705 | ): Run[] { | |
| 706 | return db | |
| 707 | .query< | |
| 708 | RunRow, | |
| 709 | [string, string | null] | |
| 710 | >("SELECT * FROM run WHERE state = 'running' AND kind = 'agent' AND repo_id = ?1 AND (?2 IS NULL OR issue_id = ?2) ORDER BY id DESC") | |
| 711 | .all(repoId, issueId ?? null) | |
| 712 | .map(toRun); | |
| 713 | } | |
| 714 | ||
| 700 | 715 | /** The unfinished run about one issue, if any: the reason a second is not queued. */ |
| 701 | 716 | export function activeRunFor( |
| 702 | 717 | db: Database, |
test/apps-crew-pill.test.ts added +205
| @@ -0,0 +1,205 @@ | ||
| 1 | /** | |
| 2 | * The forge pill names the crew run working on the app (#2251): on | |
| 3 | * production any running agent run of the repository the viewer may see, on | |
| 4 | * a preview the one on its pull; gone when the run ends; nothing for someone | |
| 5 | * not signed in, and nothing about a sealed issue to who cannot see it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { crewNow } from "../src/apps/crew.ts"; | |
| 15 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; | |
| 16 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 17 | import { toolbarJs } from "../src/apps/toolbar.ts"; | |
| 18 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 19 | import { createIssue } from "../src/core/issues.ts"; | |
| 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 21 | import { setSealed } from "../src/core/sealed.ts"; | |
| 22 | import { createUser } from "../src/core/users.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const DOMAIN = "apps.test"; | |
| 29 | ||
| 30 | describe("the crew line", () => { | |
| 31 | let dataDir: string; | |
| 32 | let app: App; | |
| 33 | let servers: Servers; | |
| 34 | let runtime: AppsRuntime; | |
| 35 | let alice: string; | |
| 36 | let bob: string; | |
| 37 | let scout: string; | |
| 38 | let record: AppRecord; | |
| 39 | let repoId: string; | |
| 40 | let pullId: string; | |
| 41 | let pullNumber: number; | |
| 42 | let sealedId: string; | |
| 43 | const forge = "http://forge.test"; | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-crew-")); | |
| 47 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 48 | servers = await serve(app); | |
| 49 | runtime = new AppsRuntime(app, { | |
| 50 | domain: DOMAIN, | |
| 51 | scheme: "http", | |
| 52 | publicPort: servers.port, | |
| 53 | forgeUrl: servers.url, | |
| 54 | concurrency: 1, | |
| 55 | }); | |
| 56 | await runtime.start(); | |
| 57 | const person = async (username: string, adminIfFirst?: boolean) => { | |
| 58 | const u = await createUser(app.db, { | |
| 59 | trusted: true, | |
| 60 | username, | |
| 61 | email: `${username}@forge.local`, | |
| 62 | password: "a-good-passphrase", | |
| 63 | ...(adminIfFirst ? { adminIfFirst } : {}), | |
| 64 | }); | |
| 65 | if (!u.ok) throw new Error(u.error); | |
| 66 | return u.value; | |
| 67 | }; | |
| 68 | const a = await person("alice", true); | |
| 69 | alice = a.id; | |
| 70 | bob = (await person("bob")).id; | |
| 71 | scout = (await person("scout")).id; | |
| 72 | const made = await newApp(app, a, { | |
| 73 | name: "notes", | |
| 74 | description: "x", | |
| 75 | visibility: "public", | |
| 76 | }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | repoId = made.value.repo.id; | |
| 79 | record = getAppByRepo(app.db, repoId)!; | |
| 80 | const pull = createIssue(app.db, { | |
| 81 | repoId, | |
| 82 | authorId: alice, | |
| 83 | title: "p", | |
| 84 | kind: "pull", | |
| 85 | }); | |
| 86 | if (!pull.ok) throw new Error(pull.error); | |
| 87 | pullId = pull.value.id; | |
| 88 | pullNumber = pull.value.number; | |
| 89 | const secret = createIssue(app.db, { | |
| 90 | repoId, | |
| 91 | authorId: alice, | |
| 92 | title: "s", | |
| 93 | kind: "issue", | |
| 94 | }); | |
| 95 | if (!secret.ok) throw new Error(secret.error); | |
| 96 | sealedId = secret.value.id; | |
| 97 | setSealed(app.db, sealedId, true); | |
| 98 | }, 120_000); | |
| 99 | ||
| 100 | afterAll(async () => { | |
| 101 | await runtime.stop(); | |
| 102 | servers.stop(); | |
| 103 | app.close(); | |
| 104 | await rm(dataDir, { recursive: true, force: true }); | |
| 105 | }); | |
| 106 | ||
| 107 | const start = (issueId: string) => { | |
| 108 | const run = createRun(app.db, { | |
| 109 | repoId, | |
| 110 | kind: "agent", | |
| 111 | issueId, | |
| 112 | actorId: scout, | |
| 113 | reason: "assigned", | |
| 114 | }); | |
| 115 | claimRun(app.db, run.id); | |
| 116 | return run; | |
| 117 | }; | |
| 118 | const end = (id: string) => finishRun(app.db, id, { state: "ok", summary: "done" }); | |
| 119 | const now = (env: string, ...viewer: [string?]) => | |
| 120 | crewNow(app.db, forge, record, env, viewer.length ? viewer[0] : alice); | |
| 121 | ||
| 122 | test("idle shows nothing, a live run shows its agent and links its page, and ends", () => { | |
| 123 | expect(now("production")).toBeUndefined(); | |
| 124 | const run = start(pullId); | |
| 125 | const seen = now("production")!; | |
| 126 | expect(seen.who).toBe("scout"); | |
| 127 | expect(seen.url).toBe(`${forge}/alice/notes/runs/${run.id}`); | |
| 128 | end(run.id); | |
| 129 | expect(now("production")).toBeUndefined(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a preview shows only the run on its own pull, and it ends", () => { | |
| 133 | const run = start(pullId); | |
| 134 | expect(now(`pull-${pullNumber}`)!.who).toBe("scout"); | |
| 135 | expect(now(`pull-${pullNumber + 1}`)).toBeUndefined(); | |
| 136 | end(run.id); | |
| 137 | expect(now(`pull-${pullNumber}`)).toBeUndefined(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("signed out, or an unknown person, shows nothing", () => { | |
| 141 | const run = start(pullId); | |
| 142 | expect(now("production", undefined)).toBeUndefined(); | |
| 143 | expect(now("production", "nobody")).toBeUndefined(); | |
| 144 | end(run.id); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a run on a sealed issue is named only to who may see that issue", () => { | |
| 148 | const run = start(sealedId); | |
| 149 | expect(now("production", bob)).toBeUndefined(); | |
| 150 | expect(now("production", alice)!.who).toBe("scout"); | |
| 151 | // Another run, on a visible pull, is still named to the one who cannot see the sealed one. | |
| 152 | const open = start(pullId); | |
| 153 | expect(now("production", bob)!.url).toContain(open.id); | |
| 154 | end(open.id); | |
| 155 | end(run.id); | |
| 156 | }); | |
| 157 | ||
| 158 | test("/__forge/crew answers a granted viewer, and {} to no cookie or a bad one", async () => { | |
| 159 | const run = start(pullId); | |
| 160 | const target = { app: record, env: "production" } as AppTarget; | |
| 161 | const cookie = `forge_grant_${record.id.slice(-8)}`; | |
| 162 | const grant = signGrant(dataDir, target, 60_000, Date.now(), { | |
| 163 | id: alice, | |
| 164 | name: "alice", | |
| 165 | role: "admin", | |
| 166 | }); | |
| 167 | const ask = (value?: string) => | |
| 168 | fetch(`${servers.url}/__forge/crew`, { | |
| 169 | headers: { | |
| 170 | host: `notes.${DOMAIN}`, | |
| 171 | ...(value === undefined ? {} : { cookie: `${cookie}=${value}` }), | |
| 172 | }, | |
| 173 | }); | |
| 174 | const ok = await ask(grant); | |
| 175 | expect(ok.status).toBe(200); | |
| 176 | expect(ok.headers.get("cache-control")).toBe("no-store"); | |
| 177 | expect(ok.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 178 | expect(await ok.json()).toEqual({ | |
| 179 | who: "scout", | |
| 180 | url: `${servers.url}/alice/notes/runs/${run.id}`, | |
| 181 | }); | |
| 182 | expect(await (await ask()).json()).toEqual({}); | |
| 183 | expect(await (await ask(`${grant}x`)).json()).toEqual({}); | |
| 184 | // A grant that names nobody is a guest: nothing. | |
| 185 | const guest = signGrant(dataDir, target, 60_000); | |
| 186 | expect(await (await ask(guest)).json()).toEqual({}); | |
| 187 | // A nested host is the guest forge's to answer, not this one's. | |
| 188 | const nested = await fetch(`${servers.url}/__forge/crew`, { | |
| 189 | headers: { host: `notes.other.${DOMAIN}`, cookie: `${cookie}=${grant}` }, | |
| 190 | }); | |
| 191 | expect(nested.headers.get("content-type") ?? "").not.toContain("json"); | |
| 192 | expect(await nested.text()).not.toContain("scout"); | |
| 193 | end(run.id); | |
| 194 | }); | |
| 195 | ||
| 196 | test("the script asks once, same-origin, and never reads the page or its cookies", () => { | |
| 197 | expect(toolbarJs).toContain("/__forge/crew"); | |
| 198 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(1); | |
| 199 | expect(toolbarJs).not.toMatch( | |
| 200 | /document\.cookie|querySelector|getElementById|setInterval|setTimeout/, | |
| 201 | ); | |
| 202 | expect(toolbarJs).toContain('aria-live", "polite"'); | |
| 203 | expect(toolbarJs.length).toBeLessThan(6 * 1024); | |
| 204 | }); | |
| 205 | }); |
new above
offline