Public: anyone can read this; the crew can change it.

The studio tells a visitor that a person's change was made by "the crew" #245

Merged opened this

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

Merged

mason/forge:mason/206-the-studio-tells-a-visitor-that-a-person → main 5 commits fork

  • Merged. mason/206-the-studio-tells-a-visitor-that-a-person was merged into main as fdb5cb6.

Review

Sign in to review this change.
8 files changed +359 −39
docs/STUDIO.md +11
@@ -127,6 +127,17 @@ coming up — but by "the crew" rather than an agent's own name, and never
127127 with a run's cost, turns or steer box. A private repository is unaffected:
128128 it already answers only to people who can read it at all.
129129
130 **Whose name a row carries** is one rule, in one place — `voiceFor`
131 (`src/web/studio/voice.ts`), which the app's page, a run's own page and the
132 board all sign their rows with. An agent's name is *not* a secret: a
133 change's own page and `/changes` name the agent that opened it to anyone
134 who can read the repository. "The crew" is a voice, not a redaction, and it
135 is chosen by **what the actor is** first: a person is named to every viewer
136 who can see the row at all, so one visitor reads one name for one change on
137 all three pages, avatar included; only an agent's own work — a run, a
138 change it opened, a tell it holds — reads as the crew's for a viewer who
139 cannot see the roster. Nothing here decides whether a row is visible.
140
130141 **The terminal** is the same operator's door, so it sits beside the same
131142 facts: a writer sees the exact `ssh …` command production answers to,
132143 copyable, with the forge's host key fingerprint beside it, or one line
src/web/pages/board.ts +16 −8
@@ -30,6 +30,7 @@ import { boardJs, liveRegionsJs } from "../client.ts";
3030 import { html, when, type Html } from "../html.ts";
3131 import { page } from "../layout.ts";
3232 import { avatar, icon, time } from "../ui.ts";
33 import { voiceFor } from "../studio/voice.ts";
3334 import { lookUrl, thumbLook } from "./looks.ts";
3435
3536 // ---- words ----
@@ -210,13 +211,17 @@ function working(ctx: Ctx, scope: Scope): Html {
210211 );
211212 const rows = runs.map((run) => {
212213 const repo = scope.byId.get(run.repoId)!;
213 // Who is on the crew is an owner's fact — a repo this viewer can only
214 // read reads here as the app's own story, never by the agent's name.
215 const actorName =
216 run.actorId === null
217 ? "crew"
218 : (actors.get(run.actorId)?.username ?? "crew");
219 const who = scope.ownerFacts.has(repo.id) ? actorName : "the crew";
214 // The same rule the app's page signs its rows by, `voiceFor`
215 // (`studio/voice.ts`): the crew's own work reads as the app's own story
216 // for a viewer who cannot see the roster; a person is always named.
217 const actor = run.actorId === null ? undefined : actors.get(run.actorId);
218 const who = voiceFor(
219 {
220 name: actor?.username,
221 isAgent: actor === undefined || actor.kind === "agent",
222 },
223 scope.ownerFacts.has(repo.id),
224 );
220225 const issue = run.issueId === null ? undefined : about.get(run.issueId);
221226 const word = stateWord(run);
222227 return html`<li>
@@ -284,7 +289,10 @@ function landed(ctx: Ctx, scope: Scope): Html {
284289 const rows = pulls.map((pull: Pull) => {
285290 const repo = scope.byId.get(pull.repoId)!;
286291 const picture = pictureOf(repo);
287 const author = scope.ownerFacts.has(repo.id) ? pull.authorName : "the crew";
292 const author = voiceFor(
293 { name: pull.authorName, isAgent: pull.authorIsAgent },
294 scope.ownerFacts.has(repo.id),
295 );
288296 return html`<li>
289297 <a
290298 class="board-card board-landed"
src/web/studio/app.ts +30 −15
@@ -23,6 +23,7 @@ import {
2323 listRuns,
2424 previewLatency,
2525 type PreviewLatency,
26 type Run,
2627 } from "../../core/runs.ts";
2728 import { getUsersByIds } from "../../core/users.ts";
2829 import { autonomyStatus } from "../../crew/autonomy.ts";
@@ -56,6 +57,7 @@ import { LOG_TAIL_CAP, logBox } from "./log.ts";
5657 import { appPanels } from "./previews.ts";
5758 import { stopForm } from "./run.ts";
5859 import { tellWord } from "./tell.ts";
60 import { voiceFor, type Actor } from "./voice.ts";
5961 import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts";
6062
6163 /**
@@ -290,10 +292,17 @@ export async function studioApp(
290292 (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx,
291293 )
292294 .slice(0, 6);
293 // Who is on the crew is an owner's fact same as its roster card — a
294 // viewer who cannot write here reads "Now" as the app's own story, never
295 // by the agent's name.
296 const nameFor = (name: string): string => (ownerFacts ? name : "the crew");
295 // Who signs a "Now" row is the one rule for it, `voiceFor`
296 // (`studio/voice.ts`): a person is named to anyone who can see the row —
297 // the same name `/changes` and the change's own page give — and only the
298 // crew's own work reads as the app's own story for a viewer who cannot
299 // see the roster. The avatar takes the same string, so its initials can
300 // never disagree with the name beside them.
301 const runActor = (r: Run): Actor => ({
302 name: r.actorId === null ? undefined : actors.get(r.actorId)?.username,
303 // A run with nobody on it is the crew's own, never a person's.
304 isAgent: r.actorId === null || actors.get(r.actorId)?.kind === "agent",
305 });
297306
298307 // The one run actually going, if any: its row gets a tail of its own
299308 // log — the last handful of lines, streamed live — beside it. Only one,
@@ -304,11 +313,7 @@ export async function studioApp(
304313 const activeTail = activeRun
305314 ? logBox({
306315 id: "app-run-tail",
307 who: nameFor(
308 activeRun.actorId === null
309 ? "crew"
310 : (actors.get(activeRun.actorId)?.username ?? "crew"),
311 ),
316 who: voiceFor(runActor(activeRun), ownerFacts),
312317 cap: LOG_TAIL_CAP,
313318 lines: readRunLogTail(ctx.app.config.dataDir, activeRun.id, LOG_TAIL_CAP)
314319 .lines,
@@ -320,9 +325,7 @@ export async function studioApp(
320325 const runRows: Html[] = [];
321326 for (const r of rowsNow) {
322327 const { word, note } = await runWord(wordCtx, r);
323 const who = nameFor(
324 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
325 );
328 const who = voiceFor(runActor(r), ownerFacts);
326329 const about = r.issueId === null ? undefined : issues.get(r.issueId);
327330 const stop = stopForm(ctx, repo, r);
328331 runRows.push(
@@ -351,7 +354,10 @@ export async function studioApp(
351354 ...(await Promise.all(
352355 openPulls.map(async (p) => {
353356 const { word, note } = await pullWord(wordCtx, p);
354 const who = nameFor(p.authorName);
357 const who = voiceFor(
358 { name: p.authorName, isAgent: p.authorIsAgent },
359 ownerFacts,
360 );
355361 return row({
356362 href: changeHref(base, p.number),
357363 nav: true,
@@ -364,7 +370,10 @@ export async function studioApp(
364370 )),
365371 ...runRows,
366372 ...waiting.map((i) => {
367 const who = nameFor(i.assigneeName ?? "crew");
373 const who = voiceFor(
374 { name: i.assigneeName ?? undefined, isAgent: i.assigneeIsAgent },
375 ownerFacts,
376 );
368377 return row({
369378 href: tellHref(base, i.number),
370379 nav: true,
@@ -378,6 +387,8 @@ export async function studioApp(
378387 // Held by a person: `tellWord` — the one place a tell's word is
379388 // decided (`studio/tell.ts`) — says so honestly, the same word and
380389 // note the tell's own page and "Everything told" already show for it.
390 // `personHeld` has already established the holder is a person, so
391 // `voiceFor` names them to every viewer who can see the row.
381392 ...(await Promise.all(
382393 held.map(async (i) => {
383394 const { word, note } =
@@ -385,7 +396,11 @@ export async function studioApp(
385396 word: "told" as const,
386397 note: "held",
387398 };
388 const who = nameFor(i.assigneeName ?? "someone");
399 const who = voiceFor(
400 { name: i.assigneeName ?? undefined, isAgent: false },
401 ownerFacts,
402 "someone",
403 );
389404 return row({
390405 href: tellHref(base, i.number),
391406 nav: true,
src/web/studio/log.ts +8 −7
@@ -9,23 +9,24 @@
99 import type { RunKind } from "../../core/runs.ts";
1010 import { logLine } from "../pages/runs.ts";
1111 import { html, when, type Html } from "../html.ts";
12 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
1213
1314 /**
1415 * Who speaks a run's own transcript: a deploy or a check speaks for itself
15 * — never "an agent", which is a name for nobody — and only an agent's run
16 * is named for the agent on it, or "the crew" for a reader who cannot see
17 * who that is. The one place this is decided, so a run's own page and the
18 * tail beside a running row (`studio/previews.ts`) never choose two voices
19 * for the same lines.
16 * — never "an agent", which is a name for nobody — and anything else is
17 * named by the one rule for it, `voiceFor` (`studio/voice.ts`), which says
18 * who is named and why. The one place a *run* applies it, so a run's own
19 * page and the tail beside a running row (`studio/previews.ts`) never
20 * choose two voices for the same lines.
2021 */
2122 export function runVoice(
2223 kind: RunKind,
2324 writer: boolean,
24 actorName: string | undefined,
25 actor: Actor,
2526 ): string {
2627 if (kind === "deploy") return "the deploy";
2728 if (kind === "check") return "the check";
28 return writer ? (actorName ?? "an agent") : "the crew";
29 return voiceFor(actor, writer, unnamedRun(writer));
2930 }
3031
3132 /** At most this many of a run's own lines held in a full transcript at
src/web/studio/previews.ts +1 −1
@@ -176,7 +176,7 @@ function deployTail(
176176 if (agentBusy) return undefined;
177177 return logBox({
178178 id: "app-run-tail",
179 who: runVoice("deploy", true, undefined),
179 who: runVoice("deploy", true, { name: undefined, isAgent: true }),
180180 cap: LOG_TAIL_CAP,
181181 lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP)
182182 .lines,
src/web/studio/run.ts +12 −8
@@ -30,6 +30,7 @@ import { previewCard } from "../pages/apps.ts";
3030 import { costText, durationText, runTitle } from "../pages/runs.ts";
3131 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3232 import { changeHref, tellHref } from "./nouns.ts";
33 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
3334 import { runWord, wordBadge, type WordCtx } from "./words.ts";
3435
3536 /**
@@ -77,14 +78,17 @@ export async function studioRun(
7778 run.issueId === null ? undefined : getIssueById(db, run.issueId);
7879 // A deploy or a check speaks for itself (`runVoice`, shared with the tail
7980 // `studio/previews.ts` opens for the same lines while production comes
80 // up) — only an agent run is named for who is on it, and who is on the
81 // crew is an owner's fact, same as the app's own page: a viewer who
82 // cannot write here reads the transcript as the crew's own work, never by
83 // the agent's name (title, every line, the live stream's `data-who`, the
84 // running pulse). The steer box below is writer-only already, so it only
85 // ever renders with the real name.
86 const who = runVoice(run.kind, writer, actor?.username);
87 const title = runTitle(run, writer ? actor?.username : "the crew", issue);
81 // up); anything else is named by the one rule for it — `voiceFor`
82 // (`studio/voice.ts`) says who is named and why. The title and the
83 // transcript take the same name, so the page never carries two. The
84 // steer box below is writer-only already, so it only ever renders with
85 // the real name.
86 const by: Actor = {
87 name: actor?.username,
88 isAgent: actor?.kind === "agent",
89 };
90 const who = runVoice(run.kind, writer, by);
91 const title = runTitle(run, voiceFor(by, writer, unnamedRun(writer)), issue);
8892 const wordCtx: WordCtx = {
8993 db,
9094 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
src/web/studio/voice.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * Whose name a studio row carries — the one place that decides it, so the
3 * app's page, a run's own page and the front door never sign the same piece
4 * of work to two different people.
5 *
6 * The decision, once: **an agent's name is not a secret.** A change's own
7 * page and `/changes` already name the agent that opened it to anyone who
8 * can read the repository, and nothing here could take that back. What
9 * "the crew" stands in for is narrower — who is on the crew is part of the
10 * roster, the ceiling and the ledger, the owner's own book-keeping
11 * (`docs/STUDIO.md`, _The owner's facts and the public's_) — and it is a
12 * matter of voice, not of secrecy: a visitor reads a *run* of the crew's
13 * as the app's own story rather than as an unfamiliar name's.
14 *
15 * So masking asks two questions and needs both, and the first is about the
16 * actor rather than the viewer:
17 *
18 * 1. Is the actor an agent? A person is named to every viewer that can see
19 * the row at all — their authorship is theirs, and the app page must
20 * say what `/changes` and the change's own page say.
21 * 2. Can this viewer see the owner's facts? A writer reads the real name.
22 *
23 * Nothing here decides *whether* a row is visible: a viewer who cannot see
24 * a change still cannot see it. This only names the one they can already
25 * see.
26 */
27
28 /** The name a run, a change or a tell is signed with when no name is shown. */
29 export const CREW = "the crew";
30
31 /** Who did the thing: a name, and whether that name is a crew member. */
32 export interface Actor {
33 /** The username, or undefined when the row names nobody at all. */
34 readonly name: string | undefined;
35 /** Whether that actor is an agent on the crew rather than a person. */
36 readonly isAgent: boolean;
37 }
38
39 /**
40 * The name to print beside a piece of work, and to draw the avatar from, so
41 * the initials always match the name beside them.
42 *
43 * `unnamed` is what a row that names nobody reads as — "the crew" on the
44 * app's page and the front door, `unnamedRun` on a run's own.
45 */
46 export function voiceFor(
47 actor: Actor,
48 writer: boolean,
49 unnamed: string = CREW,
50 ): string {
51 if (actor.name === undefined) return unnamed;
52 if (!actor.isAgent) return actor.name;
53 return writer ? actor.name : CREW;
54 }
55
56 /**
57 * What a run with no actor on it reads as: "an agent" is a name for nobody,
58 * honest enough for a writer who can see the roster anyway and would
59 * otherwise read the run as the whole crew's, and "the crew" for everyone
60 * else — the voice they read every other run of the crew's in.
61 */
62 export const unnamedRun = (writer: boolean): string =>
63 writer ? "an agent" : CREW;
test/studio.test.ts +218
@@ -6970,3 +6970,221 @@ describe("the front door asks about a person's own change too (#208)", () => {
69706970 expect(setIssueState(app, alice, repo, b, "closed").ok).toBe(true);
69716971 });
69726972 });
6973
6974 /**
6975 * Issue #206: one public app, one open change, one anonymous visitor, three
6976 * studio pages — and they must name the same author. Who signs a row is
6977 * `voiceFor` (`src/web/studio/voice.ts`): a person is named to anyone who
6978 * can see the row at all, and only the crew's own work reads as the app's
6979 * own story for a viewer who cannot see the roster.
6980 */
6981 describe("the studio names a person on the app's page, and the crew only for the crew's own work", () => {
6982 let dataDir: string;
6983 let app: App;
6984 let servers: Servers;
6985 let base: string;
6986 let repo: Repo;
6987 let alice: User;
6988 let ownerCookie: string;
6989 let personPull: Pull;
6990 let agentPull: Pull;
6991 let agentRun: Run;
6992 let waitingTell: Issue;
6993 let heldTell: Issue;
6994
6995 const signIn = async (username: string): Promise<string> =>
6996 (
6997 (
6998 await fetch(`${base}/signin`, {
6999 method: "POST",
7000 body: new URLSearchParams({
7001 username,
7002 password: "a-good-passphrase",
7003 }),
7004 redirect: "manual",
7005 })
7006 ).headers.get("set-cookie") ?? ""
7007 ).split(";")[0]!;
7008
7009 /** Signed out, in studio mode: the visitor the issue walked as. */
7010 const anon = async (path: string): Promise<string> =>
7011 (
7012 await fetch(`${base}${path}`, {
7013 headers: { cookie: "forge_mode=studio" },
7014 redirect: "manual",
7015 })
7016 ).text();
7017
7018 const owner = async (path: string): Promise<string> =>
7019 (
7020 await fetch(`${base}${path}`, {
7021 headers: { cookie: `${ownerCookie}; forge_mode=studio` },
7022 redirect: "manual",
7023 })
7024 ).text();
7025
7026 /** The one `<li>` a link sits in — one row, not the whole page. */
7027 const rowWith = (page: string, needle: string): string => {
7028 const at = page.indexOf(needle);
7029 expect(at).toBeGreaterThan(-1);
7030 const start = page.lastIndexOf("<li", at);
7031 const end = page.indexOf("</li>", at);
7032 return page.slice(start, end);
7033 };
7034
7035 /** What the avatar beside a name says, so initials can be held to it. */
7036 const initialsIn = (row: string): string =>
7037 row.match(/class="avatar"[\s\S]*?>([^<]*)<\/span/)?.[1]?.trim() ?? "";
7038
7039 const branchAndPull = async (
7040 who: User,
7041 name: string,
7042 title: string,
7043 ): Promise<Pull> => {
7044 const branch = await commitToBranch(app, alice, repo, {
7045 branch: name,
7046 base: "main",
7047 message: title,
7048 files: [{ path: `${name.replace(/\//g, "-")}.md`, content: `${name}\n` }],
7049 });
7050 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
7051 const opened = await openPull(app, who, repo, {
7052 title,
7053 body: "",
7054 baseBranch: "main",
7055 headBranch: name,
7056 });
7057 if (!opened.ok) throw new Error(String(opened.error));
7058 return opened.value;
7059 };
7060
7061 beforeAll(async () => {
7062 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-voice-"));
7063 app = createApp({ dataDir, port: 0 });
7064 servers = await serve(app);
7065 base = servers.url;
7066 const a = await createUser(app.db, {
7067 username: "ivy",
7068 email: "[email protected]",
7069 password: "a-good-passphrase",
7070 adminIfFirst: true,
7071 });
7072 if (!a.ok) throw new Error(a.error);
7073 alice = a.value;
7074 ownerCookie = await signIn("ivy");
7075
7076 const made = await createRepo(app, alice, {
7077 name: "pub",
7078 visibility: "public",
7079 description: "a public house",
7080 });
7081 if (!made.ok) throw new Error(String(made.error));
7082 repo = made.value;
7083 const seeded = await commitToBranch(app, alice, repo, {
7084 message: "Start",
7085 files: [{ path: "README.md", content: "pub\n" }],
7086 });
7087 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
7088 const enabled = await enableApp(app, alice, repo, { slug: "pub" });
7089 if (!enabled.ok) throw new Error(String(enabled.error));
7090 const quill = await createAgent(app, alice, { username: "quill" });
7091 if (!quill.ok) throw new Error(String(quill.error));
7092 expect(addToCrew(app, alice, repo, "quill").ok).toBe(true);
7093 const quillUser = getUserByName(app.db, "quill")!;
7094 const quillId = quillUser.id;
7095
7096 // The change ivy opened herself, through the web editor.
7097 personPull = await branchAndPull(alice, "ivy/subtitle", "Add a subtitle");
7098 // One the crew opened, so the masking that was meant has something to
7099 // mask on the very same page.
7100 agentPull = await branchAndPull(quillUser, "quill/lamp", "Hang a lamp");
7101 // A run of the crew's, on a tell of its own so it keeps its own row.
7102 const told = openIssue(app, alice, repo, { title: "Sweep the step" });
7103 if (!told.ok) throw new Error(String(told.error));
7104 agentRun = createRun(app.db, {
7105 repoId: repo.id,
7106 kind: "agent",
7107 issueId: told.value.id,
7108 actorId: quillId,
7109 reason: "assigned",
7110 });
7111 claimRun(app.db, agentRun.id);
7112 agentRun =
7113 finishRun(app.db, agentRun.id, {
7114 state: "ok",
7115 summary: "swept",
7116 costUsd: 3.5,
7117 turns: 2,
7118 }) ?? agentRun;
7119
7120 // And one nobody has picked up yet: a waiting row, the crew's to take.
7121 const waiting = openIssue(app, alice, repo, { title: "Wipe the bar" });
7122 if (!waiting.ok) throw new Error(String(waiting.error));
7123 expect(assignIssue(app, alice, repo, waiting.value, quillUser).ok).toBe(
7124 true,
7125 );
7126 waitingTell = getIssue(app.db, repo.id, waiting.value.number)!;
7127
7128 // And one ivy took herself — a person holds it (#226's own row on the
7129 // panel), so the panel owes her name to whoever can see the row.
7130 const held = openIssue(app, alice, repo, { title: "Polish the sign" });
7131 if (!held.ok) throw new Error(String(held.error));
7132 expect(assignIssue(app, alice, repo, held.value, alice).ok).toBe(true);
7133 heldTell = getIssue(app.db, repo.id, held.value.number)!;
7134 });
7135
7136 afterAll(async () => {
7137 servers.stop();
7138 app.close();
7139 await rm(dataDir, { recursive: true, force: true });
7140 });
7141
7142 test("an anonymous visitor reads one author for one change on all three pages, avatar included", async () => {
7143 const appPage = await anon("/ivy/pub");
7144 const changes = await anon("/ivy/pub/changes");
7145 const change = await anon(`/ivy/pub/changes/${personPull.number}`);
7146
7147 const onApp = rowWith(appPage, `/changes/${personPull.number}`);
7148 const onList = rowWith(changes, `/changes/${personPull.number}`);
7149 expect(onApp).toContain("ivy");
7150 expect(onApp).not.toContain("the crew");
7151 // The same name the list gives, and the same initials beside it.
7152 expect(initialsIn(onApp)).toBe(initialsIn(onList));
7153 expect(initialsIn(onApp)).toBe("i");
7154 expect(change).toContain("ivy");
7155
7156 // A tell she took herself is hers on the same panel, by the same rule.
7157 const held = rowWith(appPage, `/tells/${heldTell.number}`);
7158 expect(held).toContain("ivy");
7159 expect(held).not.toContain("the crew");
7160 expect(initialsIn(held)).toBe("i");
7161 });
7162
7163 test("the crew's own change and the crew's own run still read as the crew's to that visitor", async () => {
7164 const appPage = await anon("/ivy/pub");
7165 const onApp = rowWith(appPage, `/changes/${agentPull.number}`);
7166 expect(onApp).toContain("the crew");
7167 expect(onApp).not.toContain("quill");
7168 expect(initialsIn(onApp)).toBe("tc");
7169
7170 const runRow = rowWith(appPage, `/runs/${agentRun.id}`);
7171 expect(runRow).toContain("the crew");
7172 expect(runRow).not.toContain("quill");
7173
7174 const tellRow = rowWith(appPage, `/tells/${waitingTell.number}`);
7175 expect(tellRow).toContain("the crew");
7176 expect(tellRow).not.toContain("quill");
7177
7178 // Nothing the owner keeps to herself came along with the name.
7179 expect(appPage).not.toContain("quill");
7180 expect(appPage).not.toMatch(/\$\d/);
7181 expect(appPage).not.toContain(">Crew<");
7182 });
7183
7184 test("the owner still reads the crew by name, and her own change as her own", async () => {
7185 const appPage = await owner("/ivy/pub");
7186 expect(rowWith(appPage, `/changes/${agentPull.number}`)).toContain("quill");
7187 expect(rowWith(appPage, `/runs/${agentRun.id}`)).toContain("quill");
7188 expect(rowWith(appPage, `/changes/${personPull.number}`)).toContain("ivy");
7189 });
7190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs