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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2251-the-forge-toolbar-names-the-crew-run-wor → main 3 commits fork

  • Merged. scout/2251-the-forge-toolbar-names-the-crew-run-wor was merged into main as 850fe4d.

Review

Sign in to review this change.
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
537537 `notes, production` or `notes, preview of pull #7`, and pressing it opens a
538538 panel with the app, the environment, and a link home. The pull's title is shown
539539 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.
541545
542546 With a sentence and **Pick for me**, the sentence becomes a plan before the
543547 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";
3434 import { loadKey } from "../kernel/secret.ts";
3535 import { previewEnv, type EnvName } from "./data.ts";
3636 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
37 import { crewNow } from "./crew.ts";
3738 import { rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
3839
3940 export interface AppTarget {
@@ -535,6 +536,28 @@ export async function proxyApp(
535536 },
536537 });
537538 }
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 }
538561 // The third door: a problem report, answered without waking or even
539562 // reaching the app — the app being down is the usual reason to ask. The
540563 // forge's form is told which page it came from.
src/apps/toolbar.ts +24 −1
@@ -52,12 +52,35 @@ export const toolbarJs = `(function () {
5252 var link = document.createElement("a");
5353 link.textContent = "Home";
5454 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";
5656 try {
5757 var target = new URL(home);
5858 if (target.origin === new URL(me.src).origin) link.href = target.href;
5959 } 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);
6064 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) {}
6184 function set(open) {
6285 panel.hidden = !open;
6386 pill.setAttribute("aria-expanded", open ? "true" : "false");
src/core/runs.ts +15
@@ -697,6 +697,21 @@ export function listActive(db: Database, kind: RunKind): Run[] {
697697 .map(toRun);
698698 }
699699
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
700715 /** The unfinished run about one issue, if any: the reason a second is not queued. */
701716 export function activeRunFor(
702717 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs