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

Split src/web/pages/studio.ts into modules named for one concept each #100

Merged opened this

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

Merged

mason/forge:mason/63-split-src-web-pages-studio-ts-into-modul → main 3 commits fork

  • Merged. mason/63-split-src-web-pages-studio-ts-into-modul was merged into main as 39df924.

Review

Sign in to review this change.
24 files changed +3061 −2777
CLAUDE.md +4 −1
@@ -118,7 +118,10 @@ src/
118118 string), style/{base,components,content} assembled by style.ts,
119119 markdown, highlight, code, worddiff, diff-view, live, and
120120 pages/{site,repo,issues,pulls,pull,thread,commits,refs,search,
121 org,account,settings}
121 org,account,settings}, and studio/ — the app-as-the-unit
122 surface, one module per concept: words (the vocabulary every
123 page says it in), asks, leans, previews, home, app, change,
124 tell, run, lists, actions, style (the page sheet, assembled)
122125 ssh/ the SSH server: transport (kex/ciphers), auth (publickey),
123126 channels (session only), keys, wire — zero deps, no forwarding
124127 tui/ the terminal UI. term/ is the transport boundary (`Terminal`);
docs/PLANNING.md +1 −1
@@ -252,7 +252,7 @@ material: whether the decider beats the rule is a query, not an opinion.
252252 whether the change, the environment or the base branch failed. When it
253253 is the environment's fault, at 70% or better, the check runs again once
254254 instead of the agent reworking a change that is fine.
255 - **Cards** (`web/pages/studio.ts`): which option on an "asks you" card is
255 - **Cards** (`web/studio/leans.ts`): which option on an "asks you" card is
256256 likely right, so the card leads with it — _likely_, _probably_ or _maybe_
257257 beside the button, the probability on hover. The person still decides.
258258
docs/STUDIO.md +4 −4
@@ -20,7 +20,7 @@ Every word on a studio page is one thing in git, and dev mode shows it.
2020 | **asks you** | a pull the crew stopped on, or a green one for a look |
2121
2222 Every word above is decided in one place (`runWord` and `pullWord`,
23 `src/web/pages/studio.ts`), from facts — never from a run's own summary
23 `src/web/studio/words.ts`), from facts — never from a run's own summary
2424 text, which stays the note beside the word. A run's word comes from its
2525 state, and once it is over, from what it produced: a pull, judged by
2626 `pullWord`, or nothing to show, which is *planned* rather than a guess at
@@ -137,7 +137,7 @@ steered mid-run (see `docs/APPS.md`, _Runs_).
137137 issue numbers, labels, milestones and branch names replaced by the same
138138 five words the rest of the studio speaks. Each is a page of rows, newest
139139 first: who has it (the assignee, the author, or who ran it), when it last
140 moved, its state as `wordBadge` draws it (`src/web/pages/studio.ts`), and
140 moved, its state as `wordBadge` draws it (`src/web/studio/words.ts`), and
141141 one link — the row itself — to the tell, the change or the transcript. The
142142 app page's own footer (`the map · everything told · every change`, and
143143 *Every run* above **Now**) and the repository header (`Told`, `Look`, in
@@ -167,7 +167,7 @@ does yet), what it is waiting on and what waits on it (`dependenciesOf`,
167167 `src/core/dependencies.ts`, each a link, read-only here), and what the
168168 crew has done with it — every agent run, newest first, who ran it, how
169169 long it went, what it cost and its state in the studio's own word
170 (`tellWord`, `src/web/pages/studio.ts`), its title opening the transcript
170 (`tellWord`, `src/web/studio/tell.ts`), its title opening the transcript
171171 — and the pull that closes it, with its check and a link into its
172172 preview, when one exists. The sentence itself, and the reply, are the
173173 conversation dev's page shows too (`conversation`/`composer`,
@@ -201,7 +201,7 @@ mode, renders exactly what it always has.
201201 `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself —
202202 never its diff. The title and the opening post in the author's own words,
203203 who made it and when, the check in the studio's five words (`pullWord`,
204 `src/web/pages/studio.ts`) with its deploy one click away, the preview's
204 `src/web/studio/words.ts`) with its deploy one click away, the preview's
205205 state with the link that opens it (`previewCard`, the same card the aside
206206 carries in dev), and the look at phone and desktop width when one has been
207207 taken (`lookCard`, docs/APPS.md). Below that the same decision an ask card
src/core/decisions.ts +1 −1
@@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite";
1010 import { id as newId } from "../kernel/id.ts";
1111
1212 /** `lean` is which option on a studio ask card a careful owner would pick
13 * — see `web/pages/studio.ts`, which stores `{ card, head }` in `state` so
13 * — see `web/studio/leans.ts`, which stores `{ card, head }` in `state` so
1414 * `latestLean` can tell a fresh answer from one about a head that moved. */
1515 export type DecisionKind = "next" | "land" | "retry" | "lean";
1616
src/http/server.ts +2 −2
@@ -77,13 +77,13 @@ import {
7777 } from "../web/pages/issues.ts";
7878 import { globalMapPage, mapPage } from "../web/pages/map.ts";
7979 import {
80 appStateFragment,
8180 asksSubmit,
8281 modeSubmit,
8382 saySubmit,
8483 shipSubmit,
8584 tellSubmit,
86 } from "../web/pages/studio.ts";
85 } from "../web/studio/actions.ts";
86 import { appStateFragment } from "../web/studio/previews.ts";
8787 import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts";
8888 import { dependencySubmit } from "../web/pages/thread.ts";
8989 import {
src/web/pages/issues.ts +2 −1
@@ -63,7 +63,8 @@ import {
6363 threadFragment,
6464 } from "./thread.ts";
6565 import { crewActivityCard } from "./runs.ts";
66 import { studioTell, studioTells } from "./studio.ts";
66 import { studioTells } from "../studio/lists.ts";
67 import { studioTell } from "../studio/tell.ts";
6768
6869 const PER_PAGE = 25;
6970
src/web/pages/pull.ts +1 −1
@@ -96,7 +96,7 @@ import { previewCard } from "./apps.ts";
9696 import { lookCard } from "./looks.ts";
9797 import { pullState } from "./pulls.ts";
9898 import { crewActivityCard } from "./runs.ts";
99 import { studioPull } from "./studio.ts";
99 import { studioPull } from "../studio/change.ts";
100100
101101 /** The pull and its repo, or the response that refuses to say why not. */
102102 function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
src/web/pages/pulls.ts +1 −1
@@ -53,7 +53,7 @@ import {
5353 } from "../ui.ts";
5454 import { refSet } from "./refs.ts";
5555 import { threadRow } from "./issues.ts";
56 import { studioChanges } from "./studio.ts";
56 import { studioChanges } from "../studio/lists.ts";
5757 import { originGlyph } from "./thread.ts";
5858
5959 const PER_PAGE = 25;
src/web/pages/repo.ts +1 −1
@@ -37,7 +37,7 @@ import { codeJs } from "../client.ts";
3737 import { highlightBlob } from "../code.ts";
3838 import { liveClientJs, liveMount } from "../live.ts";
3939 import { appStrip } from "./apps.ts";
40 import { studioApp } from "./studio.ts";
40 import { studioApp } from "../studio/app.ts";
4141 import { getAppByRepo } from "../../core/apps.ts";
4242 import { field, readForm } from "../form.ts";
4343 import { html, when, type Html } from "../html.ts";
src/web/pages/runs.ts +2 −1
@@ -37,7 +37,8 @@ import { field, readForm } from "../form.ts";
3737 import { html, when, type Html } from "../html.ts";
3838 import { flashTo, page, redirect } from "../layout.ts";
3939 import { liveClientJs, liveMount } from "../live.ts";
40 import { studioRun, studioRuns } from "./studio.ts";
40 import { studioRuns } from "../studio/lists.ts";
41 import { studioRun } from "../studio/run.ts";
4142 import {
4243 btn,
4344 empty,
src/web/pages/site.ts +1 −1
@@ -39,7 +39,7 @@ import {
3939 } from "../../core/users.ts";
4040 import { isThemeName } from "../../design/themes.ts";
4141 import { crossOrigin, type Ctx } from "../../http/context.ts";
42 import { studioHome } from "./studio.ts";
42 import { studioHome } from "../studio/home.ts";
4343 import { edgeLoginUrl } from "../../http/edge.ts";
4444 import { sshCommand } from "../../ssh/command.ts";
4545 import {
src/web/pages/studio.ts +10 −2762
@@ -1,2765 +1,13 @@
11 /**
2 * The studio: the forge with apps as the unit. A person does three things
3 * to an app here — tell the crew, look at what it made, decide when it
4 * asks — and every word on the page maps to one thing in git that dev mode
5 * shows. Nothing here is a second system: these pages read the same tables
6 * as the rest and act through the same actions a person would take by
7 * hand.
8 *
9 * Words: *tell* is an issue; *working* is a run; *look* is an open pull
10 * with a green check and a preview; *shipped* is a merge production has
11 * caught up to; *asks you* is a pull the crew stopped on, or a green one
12 * waiting for a look. `runWord` and `pullWord` are the one place these are
13 * decided, from facts — never from a run's own summary text.
2 * The studio's old front door. Every page and every word moved to
3 * `web/studio/`, one module per concept — `words` (the vocabulary and its
4 * badge), `asks`/`leans` (what asks a person, and the decider's lean on it),
5 * `previews`, `home`, `app`, `change`, `tell`, `run`, `lists`, `actions` and
6 * `style`. Nothing renders here any more: this file exists so the tests that
7 * prove the move changed no byte of HTML could stay untouched while it
8 * happened. Import the module you need; nothing new belongs here.
149 */
1510
16 import type { Database } from "bun:sqlite";
17 import { mergeBase } from "../../git/merge.ts";
18 import { repoDir } from "../../git/repos.ts";
19 import {
20 addComment,
21 assignIssue,
22 openIssue,
23 setIssueStateThrough,
24 } from "../../actions/issues.ts";
25 import { mergePull } from "../../actions/pulls.ts";
26 import { terminalFor } from "../../actions/apps.ts";
27 import { tellCrew } from "../../actions/crew.ts";
28 import { envPull, previewEnv } from "../../apps/data.ts";
29 import {
30 appsRuntime,
31 type AppsRuntime,
32 type EnvState,
33 type EnvStatus,
34 } from "../../apps/runtime.ts";
35 import { listCrewRoles, type Agent } from "../../core/agents.ts";
36 import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts";
37 import { dependenciesOf } from "../../core/dependencies.ts";
38 import { listEvents } from "../../core/events.ts";
39 import {
40 getIssueById,
41 listComments,
42 listIssues,
43 listOpenAgentPullsVisibleTo,
44 type Issue,
45 } from "../../core/issues.ts";
46 import {
47 getPull,
48 joinMany,
49 listPulls,
50 openPullsClosing,
51 pullForBranch,
52 type Pull,
53 } from "../../core/pulls.ts";
54 import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts";
55 import { listReviews } from "../../core/reviews.ts";
56 import {
57 activeRunFor,
58 automaticRunsSince,
59 checkSummary,
60 isTerminal,
61 latestAgentRunsForRepos,
62 latestRunsForIssues,
63 listActiveRuns,
64 listRuns,
65 type CheckSummary,
66 type Run,
67 } from "../../core/runs.ts";
68 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
69 import { autonomyStatus, bringUpToDate } from "../../crew/autonomy.ts";
70 import {
71 decisionStats,
72 latestDecision,
73 latestLean,
74 recordDecision,
75 type DecisionStat,
76 } from "../../core/decisions.ts";
77 import { routeIssue } from "../../crew/route.ts";
78 import { certain, decideOr, type Decider } from "../../crew/decide.ts";
79 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
80 import type { Ctx } from "../../http/context.ts";
81 import { createLog } from "../../kernel/log.ts";
82 import {
83 crumbsFor,
84 loadRepo,
85 notFoundPage,
86 repoBase,
87 repoHeader,
88 repoPalette,
89 signInFirst,
90 } from "../chrome.ts";
91 import { field, readForm } from "../form.ts";
92 import { html, when, type Html } from "../html.ts";
93 import { asksJs, liveRegionsJs, studioRunJs } from "../client.ts";
94 import { flashOrJson, flashTo, fragment, page, redirect } from "../layout.ts";
95 import { liveClientJs, liveMount } from "../live.ts";
96 import { isMode, modeCookie } from "../mode.ts";
97 import {
98 avatar,
99 badge,
100 btn,
101 card,
102 dot,
103 empty,
104 envActions,
105 envFacts,
106 envPhrase,
107 icon,
108 listCard,
109 originTag,
110 pager,
111 pill,
112 row,
113 seg,
114 sha,
115 stateBadge,
116 terminalLine,
117 time,
118 userLink,
119 userTag,
120 type Tone,
121 } from "../ui.ts";
122 import { looksForApps } from "../../core/looks.ts";
123 import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts";
124 import { onYouCard } from "./site.ts";
125 import { previewCard } from "./apps.ts";
126 import { pullState } from "./pulls.ts";
127 import {
128 actorsFor,
129 costText,
130 durationText,
131 issuesFor,
132 logLine,
133 reasonWords,
134 runTitle,
135 runTitleFrom,
136 } from "./runs.ts";
137 import { composer, conversation, originGlyph } from "./thread.ts";
138
139 // ---- decisions: what asks a person ----
140
141 export type DecisionKind = "stopped" | "look" | "paused";
142
143 /** Dollars as a person reads them: cents when small, whole when not. */
144 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
145
146 export interface Decision {
147 readonly kind: DecisionKind;
148 /** Stable across renders — the kind and what it is about — so a form can
149 * name it and a later request can find it again. */
150 readonly id: string;
151 readonly repo: Repo;
152 readonly pull?: Pull | undefined;
153 readonly who: string;
154 /** Three sentences at most: what, why, what happens next. */
155 readonly says: string;
156 /** The same in one clause, for a row. */
157 readonly short: string;
158 /** The option the decider thinks is right, and how sure; none for a rule. */
159 readonly likely?: { readonly option: string; readonly p: number } | undefined;
160 /** The preview pictured on a phone, when the forge has taken one. */
161 readonly picture?: string | undefined;
162 }
163
164 /** The options a card offers, by name, as the decider sees them. */
165 const CARD_OPTIONS: Record<DecisionKind, Readonly<Record<string, string>>> = {
166 stopped: {
167 say: "Tell the builder what to try; the check can pass with guidance.",
168 close: "Close it; the change is not worth more tries.",
169 look: "A person should read the change before deciding.",
170 },
171 look: {
172 ship: "Ship it; the change is green and small enough to land on its check.",
173 say: "Say what is off; something in it needs a change first.",
174 preview: "Open the preview first; it must be seen before shipping.",
175 },
176 paused: { settings: "Raise the ceiling.", wait: "Wait for midnight." },
177 };
178
179 const log = createLog("studio");
180
181 /** A pull's head oid, or "" for a card with no commit to key a lean on. */
182 const headOf = (d: Decision): string => d.pull?.headOid ?? "";
183
184 /** One background ask per card+head, so a page rendered by many people at
185 * once never fans out past one call per card. Cleared once answered. */
186 const leanInFlight = new Map<string, Promise<void>>();
187
188 /** A pause before asking, long enough that the response for the render
189 * which triggered the ask is on its way out first — the point is that the
190 * decider is never on the request's critical path, not that the ask is
191 * instant. */
192 const LEAN_ASK_DELAY_MS = 50;
193
194 /**
195 * Ask the decider which option on a card is likely right, off the request
196 * path — after this turn of the event loop, never inside it — and remember
197 * the answer keyed to the head it was about. A rule's answer is not worth
198 * remembering: it is what the page would have shown anyway.
199 */
200 function scheduleLean(ctx: Ctx, d: Decision, decider: Decider): void {
201 const head = headOf(d);
202 const key = `${d.id}@${head}`;
203 if (leanInFlight.has(key)) return;
204 const options = Object.keys(CARD_OPTIONS[d.kind]);
205 const state = {
206 card: d.kind,
207 head,
208 repository: `${d.repo.ownerName}/${d.repo.name}`,
209 situation: d.says,
210 ...(d.pull === undefined
211 ? {}
212 : { change: d.pull.title, description: d.pull.body.slice(0, 800) }),
213 };
214 const asked = new Promise<void>((resolve) => {
215 setTimeout(() => {
216 decideOr(
217 decider,
218 state,
219 {
220 option: {
221 options,
222 about: "Which of these a careful owner of the app would pick.",
223 describe: CARD_OPTIONS[d.kind],
224 },
225 },
226 () => ({ option: certain(options, options[0]!) }),
227 )
228 .then((decided) => {
229 if (decided.by === "rule") return;
230 recordDecision(ctx.app.db, {
231 kind: "lean",
232 repoId: d.repo.id,
233 issueId: d.pull?.id,
234 state,
235 answer: decided.answers.option.choice,
236 p: decided.answers.option.p,
237 by: decided.by,
238 ms: decided.ms,
239 });
240 })
241 .catch((error) =>
242 log.warn("lean not recorded", { id: d.id, error: String(error) }),
243 )
244 .finally(() => {
245 leanInFlight.delete(key);
246 resolve();
247 });
248 }, LEAN_ASK_DELAY_MS);
249 });
250 leanInFlight.set(key, asked);
251 }
252
253 /** Test hook: every ask scheduled so far, answered or not. The page itself
254 * never waits on this — only a test that wants the next render to have it. */
255 export function pendingLeans(): Promise<unknown> {
256 return Promise.allSettled([...leanInFlight.values()]);
257 }
258
259 /**
260 * Every card with the lean already known about it, read with no wait; a
261 * card whose lean is unknown, or whose head has moved past the one it was
262 * asked about, is asked for off the request path (`scheduleLean`) so a
263 * later render has it. A forge with no decider does nothing here, exactly
264 * as before.
265 */
266 export function withLikely(
267 ctx: Ctx,
268 decisions: readonly Decision[],
269 ): Decision[] {
270 const decider = ctx.app.config.decider;
271 if (decider === undefined) return [...decisions];
272 return decisions.map((d) => {
273 const lean = latestLean(
274 ctx.app.db,
275 d.repo.id,
276 d.pull?.id ?? null,
277 d.kind,
278 headOf(d),
279 );
280 if (lean === undefined) {
281 scheduleLean(ctx, d, decider);
282 return d;
283 }
284 return { ...d, likely: { option: lean.answer, p: lean.p } };
285 });
286 }
287
288 /** When a person last spoke on a conversation, or its start. */
289 function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string {
290 let latest = issue.createdAt;
291 for (const c of listComments(ctx.app.db, issue.id))
292 if (c.authorName !== agentName && c.createdAt > latest)
293 latest = c.createdAt;
294 for (const r of listReviews(ctx.app.db, issue.id))
295 if (r.authorName !== agentName && r.createdAt > latest)
296 latest = r.createdAt;
297 return latest;
298 }
299
300 /**
301 * The crew's open pulls the viewer may land, sorted into what asks them:
302 * stopped (red past the crew's own limit since a person last spoke), or
303 * green and waiting for a look. Plus a repository paused at its ceiling.
304 */
305 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
306 const db = ctx.app.db;
307 const out: Decision[] = [];
308 const user = ctx.auth.user;
309 if (!user) return out;
310 // Shipping, closing and the day's ceiling are the owner's own decisions —
311 // a reader who cannot write here is never asked to make one, and never
312 // sees the dollar figure a "paused" card would otherwise carry.
313 const repos = allRepos.filter((r) => canSeeOwnerFacts(db, r, user));
314 const byId = new Map(repos.map((r) => [r.id, r]));
315 const pulls = listOpenAgentPullsVisibleTo(db, user, 50).filter((p) =>
316 byId.has(p.repoId),
317 );
318 const checks = latestRunsForIssues(
319 db,
320 pulls.map((p) => p.id),
321 );
322 const joined = new Map(joinMany(db, pulls).map((p) => [p.id, p]));
323 // The pictures, in one read: a green pull's row leads with its preview.
324 const records = appsForRepos(db, [...byId.keys()]);
325 const looks = looksForApps(
326 db,
327 [...records.values()].map((r) => r.id),
328 );
329 const pictureOf = (repo: Repo, pull: Pull): string | undefined => {
330 const record = records.get(repo.id);
331 if (record === undefined) return undefined;
332 const env = previewEnv(pull.number);
333 const look = looks.find(
334 (l) =>
335 l.appId === record.id &&
336 l.env === env &&
337 l.kind === "phone" &&
338 l.error === null,
339 );
340 return look && lookUrl(repoBase(repo), env, look.commitOid, "phone");
341 };
342 for (const issue of pulls) {
343 const repo = byId.get(issue.repoId)!;
344 const pull = joined.get(issue.id);
345 if (!pull) continue;
346 const check = checks.get(issue.id);
347 const who = issue.authorName;
348 // The crew is on it: that is working, not a question for a person.
349 if (activeRunFor(db, "agent", issue.id) !== undefined) continue;
350 if (check?.state === "failed") {
351 const since = lastHumanWord(ctx, issue, who);
352 const tries = automaticRunsSince(db, issue.id, since);
353 const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop";
354 // A repository on its own parks what it cannot finish and moves on;
355 // only an assisted one stops here for a person.
356 if (
357 (tries >= CHECK_FAILED_LIMIT || stopped) &&
358 repo.autonomy !== "autonomous"
359 )
360 out.push({
361 kind: "stopped",
362 id: `stopped:${pull.id}`,
363 repo,
364 pull,
365 who,
366 says: stopped
367 ? `${who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.`
368 : `${who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`,
369 short: stopped
370 ? `the check failed; the decider stopped for you`
371 : `${who} tried ${tries + 1} times; the check still fails`,
372 });
373 } else if (check?.state === "ok" && repo.autonomy !== "autonomous") {
374 // Autonomous repositories look at their own green pulls: the decider,
375 // then the crew's reviewer. Everything else waits for a person.
376 const picture = pictureOf(repo, pull);
377 out.push({
378 kind: "look",
379 id: `look:${pull.id}`,
380 repo,
381 pull,
382 who,
383 says: `${who} finished "${pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`,
384 short: `${who} finished it; the check is green`,
385 ...(picture === undefined ? {} : { picture }),
386 });
387 }
388 }
389 for (const repo of repos) {
390 if (repo.autonomy === "off") continue;
391 const s = autonomyStatus(ctx.app, repo);
392 if (s.paused === "ceiling")
393 out.push({
394 kind: "paused",
395 id: `paused:${repo.id}`,
396 repo,
397 who: "the crew",
398 says: `${repo.name} reached its $${dollars(s.ceilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`,
399 short: `at its $${dollars(s.ceilingUsd)} ceiling until 00:00 UTC`,
400 });
401 }
402 return out;
403 }
404
405 /** The variant a card's button takes: primary when the decider leans to it. */
406 function leanVariant(
407 d: Decision,
408 option: string,
409 fallback: "primary" | undefined,
410 ): { variant?: "primary" } {
411 const variant =
412 d.likely === undefined
413 ? fallback
414 : d.likely.option === option
415 ? "primary"
416 : undefined;
417 return variant === undefined ? {} : { variant };
418 }
419
420 /** A word beside the leaned-to option: how sure, in plain terms. */
421 function leanNote(d: Decision, option: string): Html | null {
422 if (d.likely === undefined || d.likely.option !== option) return null;
423 const sure =
424 d.likely.p >= 0.85 ? "likely" : d.likely.p >= 0.6 ? "probably" : "maybe";
425 return html`<span
426 class="muted small studio-lean"
427 title="${Math.round(d.likely.p * 100)}% by the decider"
428 >${sure}</span
429 >`;
430 }
431
432 // ---- deciding several at once ----
433
434 /**
435 * What a person can decide for several asks in one go: an ask's own landing
436 * button, applied to each. Only what needs no words qualifies — saying
437 * something happens on the change's page. The page shows an action only
438 * while every checked ask takes it; the route skips one that does not.
439 */
440 export type BulkAction = "ship" | "close";
441
442 const BULK: Readonly<
443 Record<
444 BulkAction,
445 {
446 readonly kinds: readonly DecisionKind[];
447 readonly label: string;
448 readonly done: string;
449 }
450 >
451 > = {
452 ship: { kinds: ["look"], label: "Ship", done: "Shipped" },
453 close: { kinds: ["stopped"], label: "Close", done: "Closed" },
454 };
455 const BULK_ACTIONS = Object.keys(BULK) as readonly BulkAction[];
456 const isBulkAction = (v: string): v is BulkAction => Object.hasOwn(BULK, v);
457 const takes = (d: Decision, a: BulkAction): boolean =>
458 BULK[a].kinds.includes(d.kind);
459
460 /** The id of the form a row's own button submits (see `decisionForm`). */
461 const askFormId = (d: Decision): string => `ask-${d.id}`;
462
463 /**
464 * The form behind a row's landing button — its own post, exactly as it was.
465 * It sits after the panel's form, because a form inside a form is not
466 * HTML; the button reaches it by id.
467 */
468 function decisionForm(d: Decision): Html | null {
469 const pull = d.pull;
470 if (pull === undefined) return null;
471 const base = repoBase(d.repo);
472 const own = html`id="${askFormId(d)}" method="post" hidden`;
473 if (d.kind === "stopped")
474 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
475 <input type="hidden" name="state" value="closed" />
476 </form>`;
477 if (d.kind === "look")
478 return html`<form ${own} action="${base}/pulls/${pull.number}/ship">
479 <input type="hidden" name="pull" value="${pull.number}" />
480 </form>`;
481 return null;
482 }
483
484 /**
485 * The actions on one ask, on one line: a button for what lands (submitting
486 * the row's own form, `decisionForm`), links for what needs a look or a
487 * word. Saying something to the crew happens on the change's own page,
488 * where the conversation is — not in a box in a list.
489 */
490 function decisionActions(ctx: Ctx, d: Decision): Html {
491 const base = repoBase(d.repo);
492 const pull = d.pull;
493 const preview =
494 pull === undefined
495 ? undefined
496 : getAppByRepo(ctx.app.db, d.repo.id) === undefined
497 ? undefined
498 : `${base}/app/pull-${pull.number}/open`;
499 if (d.kind === "stopped" && pull)
500 return html`${btn("Tell them", {
501 href: `${base}/pulls/${pull.number}#comment-body`,
502 size: "sm",
503 ...leanVariant(d, "say", "primary"),
504 })}${leanNote(d, "say")}
505 ${btn("Close", {
506 size: "sm",
507 form: askFormId(d),
508 ...leanVariant(d, "close", undefined),
509 })}${leanNote(d, "close")}
510 ${btn("See", {
511 href: `${base}/pulls/${pull.number}`,
512 size: "sm",
513 ...leanVariant(d, "look", undefined),
514 })}${leanNote(d, "look")}`;
515 if (d.kind === "look" && pull)
516 return html`${btn("Ship", {
517 size: "sm",
518 form: askFormId(d),
519 ...leanVariant(d, "ship", "primary"),
520 })}${leanNote(d, "ship")}
521 ${when(
522 preview,
523 (href) =>
524 html`${btn("Preview", {
525 href,
526 size: "sm",
527 ...leanVariant(d, "preview", undefined),
528 })}${leanNote(d, "preview")}`,
529 )}
530 ${btn("Not like this", {
531 href: `${base}/pulls/${pull.number}#comment-body`,
532 size: "sm",
533 ...leanVariant(d, "say", undefined),
534 })}${leanNote(d, "say")}`;
535 return html`${btn("Settings", {
536 href: `${base}/settings/autonomy`,
537 size: "sm",
538 })}`;
539 }
540
541 // ---- studio words for what the crew is doing ----
542
543 /** A merge refusal in a person's words. */
544 function mergeWords(error: unknown): string {
545 if (typeof error === "string") return error.replace(/_/g, " ");
546 if (typeof error === "object" && error !== null && "kind" in error) {
547 const e = error as { kind: string; message?: string };
548 if (e.kind === "git") return `git said ${e.message ?? "no"}`;
549 return e.kind.replace(/_/g, " ");
550 }
551 return String(error);
552 }
553
554 /**
555 * The week's decisions in one line: how many, how many the decider took
556 * rather than the rule, and what came of the ones that are known.
557 * "12 · 11 by typesafe · shipped 5, shown 2, stopped 1"
558 */
559 function decisionWords(stats: readonly DecisionStat[]): Html {
560 const n = stats.reduce((s, k) => s + k.n, 0);
561 const decided = stats.reduce((s, k) => s + k.decided, 0);
562 const by = stats
563 .flatMap((k) => Object.entries(k.outcomes))
564 .reduce<Record<string, number>>((acc, [o, c]) => {
565 acc[o] = (acc[o] ?? 0) + c;
566 return acc;
567 }, {});
568 const outcomes = Object.entries(by)
569 .sort((a, b) => b[1] - a[1])
570 .map(([o, c]) => `${o} ${c}`)
571 .join(", ");
572 return html`${n}
573 <span class="muted"
574 >· ${decided} by the
575 decider${outcomes === "" ? "" : ` · ${outcomes}`}</span
576 >`;
577 }
578
579 /**
580 * The one control a live run has: a person who can write here stops a
581 * running one or takes a queued one back off the board. Nothing for runs
582 * that are over, or for readers.
583 */
584 function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
585 if (run.state !== "running" && run.state !== "queued") return null;
586 if (run.kind !== "agent") return null;
587 if (!canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)) return null;
588 return html`<form
589 method="post"
590 action="${repoBase(repo)}/runs/${run.id}/cancel"
591 >
592 ${btn(run.state === "running" ? "Stop" : "Cancel", {
593 size: "sm",
594 variant: "outline",
595 })}
596 </form>`;
597 }
598
599 type Word =
600 | "working"
601 | "look"
602 | "shipped"
603 | "stopped"
604 | "waiting"
605 | "planned"
606 | "told";
607
608 /**
609 * Everything the word functions below need to see production: the repo's
610 * git directory (an ancestry check reads it), and the runtime plus the
611 * app record, when the repository runs one. Built once per page and
612 * threaded through — never a second way to ask what is serving.
613 */
614 export interface WordCtx {
615 readonly db: Database;
616 readonly dir: string;
617 readonly runtime: AppsRuntime | undefined;
618 readonly record: AppRecord | undefined;
619 }
620
621 /**
622 * The pull an agent run produced, if it produced one — not a foreign key
623 * (a pull is its own issue, unrelated in the schema to the issue it
624 * closes) but the two facts a run leaves behind: a review run's
625 * `issueId` is the pull itself, and a builder run's `branch` is the one
626 * `landWork` (`crew/runner.ts`) already uses to find its own pull again.
627 */
628 function pullFromRun(db: Database, run: Run): Pull | undefined {
629 if (run.issueId !== null) {
630 const about = getIssueById(db, run.issueId);
631 if (about?.kind === "pull") return getPull(db, run.repoId, about.number);
632 }
633 if (run.branch === "") return undefined;
634 return pullForBranch(db, run.repoId, run.branch);
635 }
636
637 /** Whether production is serving this commit, or something later than it. */
638 async function caughtUp(
639 dir: string,
640 production: EnvStatus | undefined,
641 commitOid: string,
642 ): Promise<boolean> {
643 if (production === undefined || production.commitOid === "") return false;
644 if (production.commitOid === commitOid) return true;
645 return (await mergeBase(dir, commitOid, production.commitOid)) === commitOid;
646 }
647
648 /** A merged pull production has not caught up to yet: its own honest word. */
649 function mergedPullWord(
650 production: EnvStatus | undefined,
651 commitOid: string,
652 ): { word: Word; note: string } {
653 if (production?.commitOid === commitOid) {
654 if (production.state === "failed")
655 return {
656 word: "stopped",
657 note: production.error ?? "production's deploy of it failed",
658 };
659 return { word: "working", note: "production is deploying it now" };
660 }
661 return { word: "waiting", note: "merged; waiting for production to redeploy" };
662 }
663
664 /** A single open pull's check, in the studio's own words — never dev's
665 * "Checks passed/failed": the same five words as the rest of the page. */
666 function checkRunWord(check: CheckSummary): { word: Word; note: string } {
667 if (check.state === "ok")
668 return { word: "look", note: "green — ready for a look" };
669 if (check.state === "failed")
670 return check.run?.servingAt != null
671 ? { word: "stopped", note: "serving, but the check failed" }
672 : { word: "stopped", note: "failed before it answered" };
673 if (check.state === "running")
674 return {
675 word: "working",
676 note: check.run?.servingAt != null ? "serving — still checking" : "checking now",
677 };
678 if (check.state === "queued")
679 return { word: "working", note: "queued to check" };
680 if (check.state === "cancelled")
681 return { word: "stopped", note: "the check was cancelled" };
682 return { word: "waiting", note: "no deploy of this head yet" };
683 }
684
685 /**
686 * A pull's word: open goes by its own check, closed without a merge is
687 * simply stopped, and merged is *shipped* only once production is at or
688 * past the commit that merge carried — the one place that fact is asked,
689 * so the app page, the change page, the home and the board never invent
690 * their own answer to it.
691 */
692 export async function pullWord(
693 ctx: WordCtx,
694 pull: Pull,
695 ): Promise<{ word: Word; note: string }> {
696 if (pull.mergedAt !== null) {
697 const commitOid = pull.mergedOid ?? pull.headOid;
698 if (ctx.runtime === undefined || ctx.record === undefined)
699 return { word: "shipped", note: "merged" };
700 const production = ctx.runtime
701 .status(ctx.record)
702 .find((e) => e.env === "production");
703 if (await caughtUp(ctx.dir, production, commitOid))
704 return { word: "shipped", note: "production is serving it" };
705 return mergedPullWord(production, commitOid);
706 }
707 if (pull.state === "closed")
708 return { word: "stopped", note: "closed without shipping" };
709 return checkRunWord(checkSummary(ctx.db, pull.id, pull.headOid));
710 }
711
712 /**
713 * An agent run's word: still going by its state, and once it is over, by
714 * what it produced — a pull, judged the same way `pullWord` judges any
715 * other, or nothing to show, which is *planned* rather than a guess at
716 * *shipped*. Never `run.summary`: that stays the note beside the word.
717 */
718 export async function runWord(
719 ctx: WordCtx,
720 run: Run,
721 ): Promise<{ word: Word; note: string }> {
722 if (run.state === "queued")
723 return run.notBefore === null
724 ? { word: "waiting", note: "queued" }
725 : { word: "waiting", note: "paused, resumes soon" };
726 if (run.state === "running") return { word: "working", note: "now" };
727 if (run.state === "failed" || run.state === "cancelled")
728 return { word: "stopped", note: run.summary || run.state };
729 const pull = pullFromRun(ctx.db, run);
730 if (pull === undefined)
731 return { word: "planned", note: run.summary || "nothing to show yet" };
732 return await pullWord(ctx, pull);
733 }
734
735 const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
736 working: "loader",
737 look: "eye",
738 shipped: "check",
739 stopped: "alert",
740 waiting: "clock",
741 planned: "map",
742 told: "clock",
743 };
744
745 /** One of the five (or six) studio words, as a badge. An id names the one a
746 * live script repaints as a run finishes (`studioRunJs`). */
747 function wordBadge(word: Word, id?: string): Html {
748 const variant =
749 word === "look" || word === "shipped"
750 ? "success"
751 : word === "stopped"
752 ? "danger"
753 : word === "working"
754 ? "accent"
755 : "secondary";
756 return html`<span
757 class="badge"
758 data-variant="${variant}"
759 data-word="${word}"
760 ${id === undefined ? null : html`id="${id}"`}
761 >${word === "working" ? icon("loader") : null}${word}</span
762 >`;
763 }
764
765 // ---- previews: every open pull's preview, one click away ----
766
767 /** The three words the studio uses for a preview, whatever its check says. */
768 function previewBadge(state: EnvState | undefined): Html {
769 if (state === "running")
770 return badge("live", { tone: "success", icon: "play" });
771 if (state === "deploying")
772 return badge("starting", { tone: "attn", icon: "loader" });
773 return badge("not up", {
774 ...(state === "failed" ? { tone: "danger" as const } : {}),
775 icon: "square",
776 });
777 }
778
779 /**
780 * One open pull's preview, as one row: its host is the click, its state is
781 * the badge, and a check still running or a check that failed says so right
782 * there — the row never hides a preview that is serving.
783 */
784 function previewRow(
785 base: string,
786 runtime: AppsRuntime | undefined,
787 record: AppRecord,
788 pull: Pull,
789 env: EnvStatus | undefined,
790 ): Html {
791 const href =
792 runtime === undefined
793 ? undefined
794 : `${base}/app/${previewEnv(pull.number)}/open`;
795 const host =
796 runtime === undefined
797 ? undefined
798 : runtime.urlFor(record, previewEnv(pull.number)).replace(/^https?:\/\//, "");
799 const bits: Html[] = [html`#${pull.number} ${pull.title}`];
800 if (env?.commitOid) bits.push(html`${env.commitOid.slice(0, 7)}`);
801 if (env?.since) bits.push(html`up ${time(env.since)}`);
802 const say = env?.error ?? env?.note;
803 if (say) bits.push(html`${say}`);
804 return row({
805 ...(href === undefined ? {} : { href, nav: true }),
806 lead: icon("rocket"),
807 title:
808 host === undefined
809 ? `#${pull.number} ${pull.title}`
810 : html`<span class="mono">${host}</span>`,
811 meta: bits.reduce((a, b) => html`${a} · ${b}`),
812 trail: previewBadge(env?.state),
813 });
814 }
815
816 /**
817 * Every open pull's preview, one row each, from the runtime's own `status()`
818 * — no per-pull query, so the list costs nothing extra as pulls pile up. A
819 * pull with no environment yet still gets a row, so the list matches the
820 * open pulls exactly.
821 */
822 function previewsPanel(
823 base: string,
824 runtime: AppsRuntime | undefined,
825 record: AppRecord,
826 openPulls: readonly Pull[],
827 envs: readonly EnvStatus[],
828 ): Html | null {
829 if (openPulls.length === 0) return null;
830 const byPull = new Map(
831 envs
832 .filter((e) => e.env !== "production")
833 .map((e) => [envPull(e.env), e] as const),
834 );
835 return listCard(
836 openPulls.map((p) =>
837 previewRow(base, runtime, record, p, byPull.get(p.number)),
838 ),
839 {
840 head: html`${icon("rocket")}
841 <h2>Previews</h2>`,
842 },
843 );
844 }
845
846 /**
847 * The head card and the previews list: what production is doing, and every
848 * open pull's own environment. One function, called for the page's first
849 * paint and for the fragment a deploy event refreshes in place
850 * (`appStateFragment`) — the two never draw two copies of the same markup.
851 */
852 function appPanels(
853 ctx: Ctx,
854 repo: Repo,
855 record: AppRecord,
856 openPulls: readonly Pull[],
857 ): { head: Html; previews: Html | null } {
858 const db = ctx.app.db;
859 const base = repoBase(repo);
860 const runtime = appsRuntime(ctx.app);
861 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
862 const previews = previewsPanel(base, runtime, record, openPulls, envs);
863 const production = envs.find((e) => e.env === "production");
864 const url = runtime?.urlFor(record, "production");
865 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
866 // The state in one word, and under it the same facts the App page shows —
867 // why it is not up, and the deploy whose log says the rest.
868 const live = envPhrase(production);
869 const head = html`<div class="page-head studio-head">
870 <div class="stack stack-tight">
871 <h1>
872 ${repo.name}
873 ${badge(
874 live.word,
875 live.tone === "default"
876 ? { variant: "secondary" }
877 : { tone: live.tone },
878 )}
879 </h1>
880 <p class="muted">
881 ${repo.description || "No description yet."}
882 ${url === undefined
883 ? null
884 : html`·
885 <a href="${base}/app/production/open" class="mono"
886 >${url.replace(/^https?:\/\//, "")}</a
887 >`}
888 </p>
889 ${when(production, (env) => envFacts(base, env))}
890 ${when(writer, () =>
891 terminalLine(
892 terminalFor(ctx.app, record, "production", production),
893 ctx.app.config.sshHostKey,
894 ),
895 )}
896 </div>
897 <span class="spacer"></span>
898 ${lookNow(ctx, repo, record)}
899 ${btn("Open the app", {
900 href: `${base}/app/production/open`,
901 variant: "primary",
902 icon: "external",
903 })}
904 ${when(production, (env) => envActions(base, env, writer))}
905 </div>`;
906 return { head, previews };
907 }
908
909 /**
910 * GET /:owner/:name/app/state — the head card and previews list, bare. The
911 * studio page's own live client (`live.ts`) fetches this on a deploy run
912 * event for the app being viewed and swaps the two in place, so a change
913 * serving in its preview shows up without a reload. Authorized exactly as
914 * the studio page: a viewer who cannot see the app gets the same 404.
915 */
916 export function appStateFragment(ctx: Ctx): Response {
917 const repo = loadRepo(ctx);
918 if (!repo) return fragment(html``, 404);
919 const record = getAppByRepo(ctx.app.db, repo.id);
920 if (!record) return fragment(html``, 404);
921 const openPulls = listPulls(ctx.app.db, repo.id, {
922 state: "open",
923 limit: 10,
924 });
925 const { head, previews } = appPanels(ctx, repo, record, openPulls);
926 return fragment(html`<div id="app-head">${head}</div>
927 <div id="app-previews">${previews}</div>`);
928 }
929
930 // ---- the app in the studio ----
931
932 export async function studioApp(
933 ctx: Ctx,
934 repo: Repo,
935 record: AppRecord,
936 ): Promise<Response> {
937 const db = ctx.app.db;
938 const base = repoBase(repo);
939 // Money, the crew roster, autonomy limits and the decision ledger are the
940 // owner's facts, not the app's — a viewer who cannot write here reads this
941 // page as a stranger, whatever else they can see of the repository.
942 const ownerFacts = canSeeOwnerFacts(db, repo, ctx.auth.user);
943 const wordCtx: WordCtx = {
944 db,
945 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
946 runtime: appsRuntime(ctx.app),
947 record,
948 };
949 const crew = listCrewRoles(db, repo.id);
950 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
951 const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 });
952 const actors = getUsersByIds(
953 db,
954 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
955 );
956 const issues = new Map<string, Issue>();
957 for (const r of runs)
958 if (r.issueId !== null && !issues.has(r.issueId)) {
959 const i = getIssueById(db, r.issueId);
960 if (i) issues.set(r.issueId, i);
961 }
962 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
963 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
964 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
965 const shipped = listPulls(db, repo.id, {
966 state: "closed",
967 limit: 100,
968 }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length;
969 // The rest is spend, worked out only for a viewer who is going to see it.
970 const weekRuns = ownerFacts
971 ? listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
972 (r) => r.createdAt >= week,
973 )
974 : [];
975 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
976 const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : [];
977 const autonomy =
978 !ownerFacts || repo.autonomy === "off"
979 ? undefined
980 : autonomyStatus(ctx.app, repo);
981
982 // ---- the app itself ----
983 const { head, previews } = appPanels(ctx, repo, record, openPulls);
984
985 // ---- the box ----
986 // Telling the crew, and who takes it, is the owner's own front door — a
987 // stranger sees the app, not who is behind it.
988 const tell = !ownerFacts
989 ? null
990 : html`<form
991 class="card comment-form studio-tell"
992 method="post"
993 action="${base}/tell"
994 >
995 <label class="sr-only" for="tell">Tell the crew</label>
996 <textarea
997 class="input"
998 id="tell"
999 name="text"
1000 rows="3"
1001 required
1002 placeholder="${builders.length === 0
1003 ? "Add a crew member under Settings → Crew, then tell them what you want."
1004 : "What should change? One thing at a time works best."}"
1005 ></textarea>
1006 <div class="bar">
1007 <span class="muted small">
1008 ${builders.length === 0
1009 ? html`Nobody on the crew yet.`
1010 : html`${builders
1011 .map((b) => userTag(b.user.username))
1012 .reduce((a, b) => html`${a} ${b}`)}
1013 ${builders.length === 1 ? "takes it" : "share the work"}`}
1014 </span>
1015 <span class="spacer"></span>
1016 ${btn("Tell the crew", { variant: "primary", type: "submit" })}
1017 </div>
1018 </form>`;
1019
1020 // ---- now: one row per thing, in the five words ----
1021 const proposed = new Set(openPulls.map((p) => p.id));
1022 const openByIssue = openPullsClosing(db, repo.id);
1023 const told = listIssues(db, repo.id, {
1024 kind: "issue",
1025 state: "open",
1026 limit: 30,
1027 }).filter((i) => i.assigneeIsAgent && !openByIssue.has(i.number));
1028 const everRan = latestRunsForIssues(
1029 db,
1030 told.map((i) => i.id),
1031 ["agent"],
1032 );
1033 const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5);
1034 const issueNumberById = new Map(told.map((i) => [i.id, i.number]));
1035 const rowsNow = runs
1036 .filter((r) => {
1037 if (r.issueId === null) return true;
1038 if (proposed.has(r.issueId)) return false;
1039 const n = issueNumberById.get(r.issueId) ?? issues.get(r.issueId)?.number;
1040 return n === undefined || !openByIssue.has(n);
1041 })
1042 .filter(
1043 (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx,
1044 )
1045 .slice(0, 6);
1046 // Who is on the crew is an owner's fact same as its roster card — a
1047 // viewer who cannot write here reads "Now" as the app's own story, never
1048 // by the agent's name.
1049 const nameFor = (name: string): string => (ownerFacts ? name : "the crew");
1050 const nowRows: Html[] = [
1051 ...(await Promise.all(
1052 openPulls.map(async (p) => {
1053 const { word, note } = await pullWord(wordCtx, p);
1054 const who = nameFor(p.authorName);
1055 return row({
1056 href: `${base}/pulls/${p.number}`,
1057 nav: true,
1058 lead: avatarOf(who),
1059 title: p.title,
1060 meta: html`${who} · ${note} · ${time(p.createdAt)}`,
1061 trail: wordBadge(word),
1062 });
1063 }),
1064 )),
1065 ...(await Promise.all(
1066 rowsNow.map(async (r) => {
1067 const { word, note } = await runWord(wordCtx, r);
1068 const who = nameFor(
1069 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
1070 );
1071 const about = r.issueId === null ? undefined : issues.get(r.issueId);
1072 const stop = stopForm(ctx, repo, r);
1073 return row({
1074 ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }),
1075 lead: avatarOf(who),
1076 title: stop
1077 ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>`
1078 : (about?.title ?? note),
1079 meta: html`${who} · ${word === "working" ? "working now" : note}
1080 ${word === "working"
1081 ? null
1082 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
1083 trail: stop
1084 ? html`<span class="studio-ask-actions"
1085 >${wordBadge(word)} ${stop}</span
1086 >`
1087 : wordBadge(word),
1088 });
1089 }),
1090 )),
1091 ...waiting.map((i) => {
1092 const who = nameFor(i.assigneeName ?? "crew");
1093 return row({
1094 href: `${base}/issues/${i.number}`,
1095 nav: true,
1096 lead: avatarOf(who),
1097 title: i.title,
1098 meta: html`${who} · told ${time(i.createdAt)}, not picked up
1099 yet`,
1100 trail: wordBadge("waiting"),
1101 });
1102 }),
1103 ];
1104 const now =
1105 nowRows.length === 0
1106 ? card(
1107 html`<p class="muted small">
1108 Nothing yet. Tell the crew something.
1109 </p>`,
1110 {
1111 head: html`${icon("activity")}
1112 <h2>Now</h2>`,
1113 },
1114 )
1115 : listCard(nowRows, {
1116 head: html`${icon("activity")}
1117 <h2>Now</h2>
1118 <span class="spacer"></span>
1119 ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`,
1120 });
1121
1122 const asks = when(decisions.length > 0, () =>
1123 asksPanel(ctx, decisions, base),
1124 );
1125
1126 // ---- the rail ----
1127 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
1128 // write here gets only the shipped count, never the roster, the money or
1129 // the ledger — and the page never asks for the rest on their behalf.
1130 const crewCard = !ownerFacts
1131 ? null
1132 : crew.length === 0
1133 ? card(
1134 html`<p class="muted small">
1135 Nobody yet. <a href="${base}/settings/crew">Add someone.</a>
1136 </p>`,
1137 {
1138 head: html`${icon("bot")}
1139 <h2>Crew</h2>`,
1140 },
1141 )
1142 : listCard(
1143 crew.map((c) =>
1144 row({
1145 lead: avatarOf(c.agent.user.username),
1146 title: c.agent.user.username,
1147 meta: html`${c.role === "builder"
1148 ? "builds"
1149 : c.role === "planner"
1150 ? "plans"
1151 : "reviews"}`,
1152 }),
1153 ),
1154 {
1155 head: html`${icon("bot")}
1156 <h2>Crew</h2>
1157 <span class="spacer"></span>
1158 ${btn("Change", { href: `${base}/settings/crew`, size: "sm" })}`,
1159 },
1160 );
1161 const weekCard = card(
1162 !ownerFacts
1163 ? html`<dl>
1164 <dt>Shipped</dt>
1165 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
1166 </dl>`
1167 : html`<dl>
1168 <dt>Shipped</dt>
1169 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
1170 <dt>Runs</dt>
1171 <dd>${weekRuns.length}</dd>
1172 <dt>Spent</dt>
1173 <dd>
1174 $${spent.toFixed(2)}${shipped > 0
1175 ? html` <span class="muted"
1176 >· $${(spent / shipped).toFixed(2)} per shipped change</span
1177 >`
1178 : null}
1179 </dd>
1180 ${when(
1181 ledgerWeek.length > 0,
1182 () =>
1183 html`<dt>Decided</dt>
1184 <dd>${decisionWords(ledgerWeek)}</dd>`,
1185 )}
1186 </dl>`,
1187 {
1188 head: html`${icon("activity")}
1189 <h2>This week</h2>`,
1190 extra: "about",
1191 },
1192 );
1193 const ownCard = !ownerFacts
1194 ? null
1195 : card(
1196 autonomy === undefined
1197 ? html`<p class="muted small">
1198 Off. <a href="${base}/settings/autonomy">Turn it on</a> and the
1199 crew works what is ready without being asked.
1200 </p>`
1201 : html`<dl>
1202 <dt>Mode</dt>
1203 <dd>
1204 ${repo.autonomy === "autonomous" ? "Autonomous" : "Assisted"}
1205 </dd>
1206 <dt>Today</dt>
1207 <dd>
1208 $${autonomy.spentTodayUsd.toFixed(2)} of
1209 $${autonomy.ceilingUsd.toFixed(0)}
1210 </dd>
1211 <dt>Slots</dt>
1212 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
1213 ${autonomy.paused
1214 ? html`<dt>Paused</dt>
1215 <dd>
1216 ${autonomy.paused === "ceiling"
1217 ? "at the ceiling, until 00:00 UTC"
1218 : autonomy.paused}
1219 </dd>`
1220 : null}
1221 </dl>`,
1222 {
1223 head: html`${icon("play")}
1224 <h2>On its own</h2>
1225 <span class="spacer"></span>
1226 ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`,
1227 extra: "about",
1228 },
1229 );
1230 const more = html`<p class="muted small">
1231 <a href="${base}/map">The map</a> ·
1232 <a href="${base}/issues">Everything told</a> ·
1233 <a href="${base}/pulls">Every change</a>
1234 </p>`;
1235
1236 const body = html`${repoHeader(ctx, repo, "code", {
1237 compact: true,
1238 ownTitle: true,
1239 })}
1240 <div id="app-head">${head}</div>
1241 <div class="dash">
1242 <div class="dash-main stack">
1243 ${tell}
1244 <div
1245 id="live-asks"
1246 data-live="run.*,pull.*,issue.*,check.*"
1247 data-live-repo="${repo.ownerName}/${repo.name}"
1248 >
1249 ${asks}
1250 </div>
1251 <div id="app-previews">${previews}</div>
1252 <div
1253 id="live-now"
1254 data-live="run.*,pull.*,issue.*"
1255 data-live-repo="${repo.ownerName}/${repo.name}"
1256 >
1257 ${now}
1258 </div>
1259 </div>
1260 <aside class="dash-rail stack">
1261 ${crewCard} ${weekCard} ${ownCard} ${more}
1262 </aside>
1263 </div>
1264 ${liveMount({ repo: base, app: true })}`;
1265 return page({
1266 ctx,
1267 title: `${repo.name}`,
1268 crumbs: crumbsFor(repo),
1269 palette: repoPalette(repo),
1270 body,
1271 styleCss: studioCss,
1272 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
1273 });
1274 }
1275
1276 function avatarOf(username: string): Html {
1277 return avatar(username);
1278 }
1279
1280 // ---- working: one run, in studio words ----
1281
1282 /** At most this many of the run's own lines held in the page at once — the
1283 * live script drops the oldest as a new one lands, past this. */
1284 const RUN_TRANSCRIPT_CAP = 400;
1285
1286 /**
1287 * GET /:owner/:name/runs/:id in the studio: who is on it, the tell it came
1288 * from, how long it has gone and what it has cost, the transcript itself,
1289 * and its state in the studio's own word — the page someone sits on while
1290 * waiting for a change to become visible. Steer and Stop are the very same
1291 * forms `runPage` offers, posting to the very same routes with the very
1292 * same checks; `?mode=dev` reads the transcript there, unchanged.
1293 */
1294 export async function studioRun(
1295 ctx: Ctx,
1296 repo: Repo,
1297 run: Run,
1298 lines: readonly string[],
1299 truncated: boolean,
1300 ): Promise<Response> {
1301 const db = ctx.app.db;
1302 const base = repoBase(repo);
1303 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
1304 const actor =
1305 run.actorId === null ? undefined : getUserById(db, run.actorId);
1306 const issue =
1307 run.issueId === null ? undefined : getIssueById(db, run.issueId);
1308 // Who is on the crew is an owner's fact, same as the app's own page — a
1309 // viewer who cannot write here reads the transcript as the crew's own
1310 // work, never by the agent's name (title, every line, the live stream's
1311 // `data-who`, the running pulse). The steer box below is writer-only
1312 // already, so it only ever renders with the real name.
1313 const who = writer ? (actor?.username ?? "an agent") : "the crew";
1314 const title = runTitle(run, writer ? actor?.username : "the crew", issue);
1315 const wordCtx: WordCtx = {
1316 db,
1317 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1318 runtime: appsRuntime(ctx.app),
1319 record: getAppByRepo(db, repo.id),
1320 };
1321 const { word } = await runWord(wordCtx, run);
1322 const unfinished = !isTerminal(run.state);
1323 const duration = durationText(run);
1324 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
1325 const shown = lines.slice(-RUN_TRANSCRIPT_CAP);
1326 const hidden = lines.length - shown.length;
1327
1328 // The pull this run's tell has become, once one exists: the tell itself
1329 // when the run is already a pull's own (feedback, a failed check, a
1330 // conflict fix), or the open pull that closes it while the run is still
1331 // the tell's first build — the case this page matters most for.
1332 const pull =
1333 issue === undefined
1334 ? undefined
1335 : issue.kind === "pull"
1336 ? getPull(db, repo.id, issue.number)
1337 : openPullsClosing(db, repo.id).get(issue.number);
1338
1339 const head = html`<div class="issue-head">
1340 <h1>${title}</h1>
1341 <div class="issue-meta">
1342 ${wordBadge(word, "run-word")}
1343 ${when(
1344 issue,
1345 (i) =>
1346 html`<a
1347 href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}"
1348 >#${i.number} ${i.title}</a
1349 >`,
1350 )}
1351 ${when(
1352 writer,
1353 () =>
1354 html`<span
1355 class="num"
1356 id="run-cost"
1357 ${run.costUsd > 0 ? null : html`hidden`}
1358 >${costText(run.costUsd)} spent</span
1359 >
1360 <span
1361 class="num"
1362 id="run-turns"
1363 ${run.turns > 0 ? null : html`hidden`}
1364 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
1365 >`,
1366 )}
1367 ${when(
1368 duration,
1369 (d) =>
1370 html`<span
1371 class="num"
1372 id="run-elapsed"
1373 data-started="${run.startedAt ?? ""}"
1374 data-finished="${run.finishedAt ?? ""}"
1375 >${run.finishedAt === null ? "running for" : "took"} ${d}</span
1376 >`,
1377 )}
1378 </div>
1379 <p class="run-summary" id="run-summary">${run.summary}</p>
1380 </div>`;
1381
1382 const body = html`${repoHeader(ctx, repo, "app", {
1383 compact: true,
1384 ownTitle: true,
1385 })}
1386 ${head}
1387 ${when(
1388 writer && unfinished && run.kind === "agent" && issue === undefined,
1389 () =>
1390 html`<div class="run-actions" id="run-cancel">
1391 <form method="post" action="${base}/runs/${run.id}/cancel">
1392 ${btn("Stop", { size: "sm", variant: "outline", icon: "square" })}
1393 </form>
1394 </div>`,
1395 )}
1396 ${when(
1397 writer && unfinished && run.kind !== "agent",
1398 () =>
1399 html`<div class="run-actions" id="run-cancel">
1400 <form method="post" action="${base}/runs/${run.id}/cancel">
1401 ${btn("Cancel run", {
1402 size: "sm",
1403 variant: "outline",
1404 icon: "square",
1405 })}
1406 </form>
1407 </div>`,
1408 )}
1409 ${when(pull, (p) => previewCard(ctx, repo, p))}
1410 ${when(
1411 truncated,
1412 () =>
1413 html`<p class="muted small mb">
1414 Showing the last 512 KB of the log; the file on disk has the rest.
1415 </p>`,
1416 )}
1417 ${when(
1418 shown.length === 0,
1419 () =>
1420 html`<p class="muted small" id="run-empty">
1421 Nothing has been logged yet.
1422 </p>`,
1423 )}
1424 <div class="run-live" id="run-live" data-state="${run.state}">
1425 <div
1426 class="run-log"
1427 id="run-log"
1428 data-who="${who}"
1429 data-cap="${RUN_TRANSCRIPT_CAP}"
1430 ${unfinished ? html`data-stream="${stream}"` : null}
1431 >
1432 ${when(
1433 hidden > 0,
1434 () =>
1435 html`<p class="muted small">
1436 Holding the last ${RUN_TRANSCRIPT_CAP} lines here; the run's own
1437 log keeps the rest.
1438 </p>`,
1439 )}
1440 ${shown.map((l) => logLine(l, who))}
1441 ${when(
1442 run.state === "running",
1443 () =>
1444 html`<div class="ln ln-pulse" id="run-pulse">
1445 <span class="who">${who}</span
1446 ><span class="txt"><i></i><i></i><i></i></span>
1447 </div>`,
1448 )}
1449 <button type="button" class="run-jump" id="run-jump" hidden>
1450 newer below
1451 </button>
1452 </div>
1453 ${when(
1454 writer && run.kind === "agent" && issue !== undefined,
1455 () =>
1456 html`<form
1457 class="run-steer"
1458 id="run-steer"
1459 method="post"
1460 action="${base}/runs/${run.id}/steer"
1461 data-api="/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/steer"
1462 data-running="${run.state === "running" ? "1" : "0"}"
1463 >
1464 <label class="sr-only" for="steer-text"
1465 >Say something to ${who}</label
1466 >
1467 <textarea
1468 class="input"
1469 id="steer-text"
1470 name="text"
1471 rows="1"
1472 required
1473 placeholder="${run.state === "running"
1474 ? `Steer ${who} — it hears you at its next step`
1475 : `Tell ${who} for its next run`}"
1476 ></textarea>
1477 <div class="bar">
1478 <span class="muted small" id="run-steer-note"
1479 >${run.state === "running"
1480 ? "Heard after the step it is on. Enter sends; every word is kept in the log."
1481 : "The run is over; this goes on the conversation and starts a run."}</span
1482 >
1483 <span class="spacer"></span>
1484 ${when(
1485 unfinished,
1486 () =>
1487 html`<button
1488 type="submit"
1489 class="btn"
1490 data-size="sm"
1491 data-variant="outline"
1492 id="run-stop"
1493 formaction="${base}/runs/${run.id}/cancel"
1494 formnovalidate
1495 >
1496 ${run.state === "running" ? "Stop" : "Cancel"}
1497 </button>`,
1498 )}
1499 ${btn("Send", { size: "sm", variant: "primary", type: "submit" })}
1500 </div>
1501 </form>`,
1502 )}
1503 </div>
1504 <p class="muted small mt">
1505 <a href="${base}/runs/${run.id}?mode=dev">${icon("code")}Read the transcript</a>
1506 — every tool call, in dev's own words.
1507 </p>`;
1508
1509 return page({
1510 ctx,
1511 title: `${title} · ${repo.name}`,
1512 crumbs: crumbsFor(repo),
1513 palette: repoPalette(repo),
1514 body,
1515 styleCss: studioCss,
1516 scriptJs: studioRunJs,
1517 // The no-script half of a live log: while the run is going, the page
1518 // refreshes itself; the script above removes this the moment it runs.
1519 ...(unfinished ? { refresh: { seconds: 5 } } : {}),
1520 });
1521 }
1522
1523 // ---- one pull, in studio words ----
1524
1525 /**
1526 * GET /:owner/:name/pulls/:number in studio mode: a change is judged by
1527 * what it does, not by its diff. The title and the author's own words (the
1528 * opening post, same as dev), who made it and when, the check and the
1529 * preview in the studio's five words, the look when one has been taken, the
1530 * same decision the asks panel would offer, and the conversation — with the
1531 * diff exactly one click away at `?mode=dev`. No diff is computed here, so
1532 * this never gets slower as a change gets bigger.
1533 */
1534 export async function studioPull(
1535 ctx: Ctx,
1536 repo: Repo,
1537 pull: Pull,
1538 record: AppRecord,
1539 ): Promise<Response> {
1540 const db = ctx.app.db;
1541 const base = repoBase(repo);
1542 const thread = `${base}/pulls/${pull.number}`;
1543 const wordCtx: WordCtx = {
1544 db,
1545 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1546 runtime: appsRuntime(ctx.app),
1547 record,
1548 };
1549 const { word, note } = await pullWord(wordCtx, pull);
1550 // Before a merge the deploy worth linking is the preview's own check;
1551 // after, it is production's — the preview's run may already be torn
1552 // down, and the word above is judged by production regardless.
1553 let deploy: { id: string; commitOid: string } | undefined;
1554 if (pull.mergedAt === null) {
1555 const run = checkSummary(db, pull.id, pull.headOid).run;
1556 if (run) deploy = { id: run.id, commitOid: run.commitOid };
1557 } else {
1558 const production = wordCtx.runtime
1559 ?.status(record)
1560 .find((e) => e.env === "production");
1561 if (production?.runId)
1562 deploy = { id: production.runId, commitOid: production.commitOid };
1563 }
1564 const decision = decisionsFor(ctx, [repo]).find(
1565 (d) => d.pull?.id === pull.id,
1566 );
1567
1568 const head = html`<div class="issue-head">
1569 <h1>${pull.title} <span class="number">#${pull.number}</span></h1>
1570 <div class="issue-meta">
1571 ${stateBadge(pullState(pull))}
1572 <span
1573 >${pull.originAuthor
1574 ? originTag(pull.originAuthor, originGlyph(repo))
1575 : userLink(pull.authorName)}
1576 opened this ${time(pull.createdAt)}</span
1577 >
1578 ${when(
1579 pull.originUrl,
1580 (url) =>
1581 html`<a class="origin-link" href="${url}" rel="noreferrer noopener"
1582 >${icon("external")}view on origin</a
1583 >`,
1584 )}
1585 </div>
1586 </div>`;
1587
1588 const checkLine = card(
1589 html`<p class="studio-pull-check">
1590 ${wordBadge(word)} <span class="muted small">${note}</span>
1591 ${when(
1592 deploy,
1593 (d) =>
1594 html`<span class="where-sep" aria-hidden="true">·</span
1595 ><a href="${base}/runs/${d.id}"
1596 >deploy ${d.commitOid.slice(0, 7)}</a
1597 >`,
1598 )}
1599 </p>`,
1600 );
1601
1602 const decisionSection = when(decision, (d) =>
1603 card(
1604 html`<p>${d.says}</p>
1605 <div class="bar mt">${decisionActions(ctx, d)}</div>`,
1606 {
1607 head: html`${icon("alert")}
1608 <h2>Decide</h2>`,
1609 },
1610 ),
1611 );
1612
1613 const talk = conversation(ctx, repo, pull, { openVerb: "opened this" });
1614
1615 const body = html`${repoHeader(ctx, repo, "pulls", {
1616 compact: true,
1617 ownTitle: true,
1618 })}
1619 ${head} ${checkLine} ${decisionSection}
1620 ${lookCard(ctx, repo, record, pull, pull.headOid)}
1621 <div class="split">
1622 <div>
1623 ${talk.node}
1624 ${composer(ctx, {
1625 action: `${thread}/comments`,
1626 stateAction: `${thread}/state`,
1627 state: pull.state,
1628 locked: pull.mergedAt !== null,
1629 kind: "pull",
1630 })}
1631 </div>
1632 <aside>${previewCard(ctx, repo, pull)}</aside>
1633 </div>
1634 <p class="muted small mt">
1635 <a href="${thread}?mode=dev">${icon("code")}Read the code</a> — every
1636 file it changed, line by line.
1637 </p>
1638 ${decision ? decisionForm(decision) : null}
1639 ${liveMount({
1640 repo: base,
1641 thread: "pull",
1642 number: pull.number,
1643 lastCommentId: talk.lastCommentId,
1644 })}`;
1645
1646 return page({
1647 ctx,
1648 title: `${pull.title} · ${repo.name}`,
1649 crumbs: crumbsFor(repo),
1650 palette: repoPalette(repo),
1651 body,
1652 styleCss: studioCss,
1653 scriptJs: liveClientJs,
1654 });
1655 }
1656
1657 // ---- one tell, in studio words ----
1658
1659 /** The pull whose merge closed this issue, from the event it left behind —
1660 * the same record `stateEvents` (thread.ts) reads for the timeline's own
1661 * "closed this" line, so this never disagrees with what the page already
1662 * shows. Undefined for an issue closed by hand, or still open. */
1663 function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined {
1664 if (issue.state !== "closed") return undefined;
1665 const closed = listEvents(db, { repoId: repo.id, limit: 200 }).find(
1666 (e) =>
1667 e.kind === "issue.closed" &&
1668 Number(e.payload.number) === issue.number &&
1669 e.payload.by === "merge",
1670 );
1671 if (closed === undefined) return undefined;
1672 const number = Number(closed.payload.pull);
1673 return Number.isInteger(number) ? getPull(db, repo.id, number) : undefined;
1674 }
1675
1676 /**
1677 * Where one tell stands, in the studio's own words: the pull that closed
1678 * or closes it — judged by `pullWord`, the one place that fact is decided,
1679 * so a merged tell reads *shipped* only once production is serving it,
1680 * same as everywhere else — else a run in flight, else its last run's own
1681 * word, else *told*: said, and nobody has picked it up yet. Undefined for
1682 * a closed issue nothing merged shut, so the caller falls back to the
1683 * plain state badge dev uses.
1684 */
1685 async function tellWord(
1686 wordCtx: WordCtx,
1687 repo: Repo,
1688 issue: Issue,
1689 closingPull: Pull | undefined,
1690 ): Promise<{ word: Word; note: string } | undefined> {
1691 const db = wordCtx.db;
1692 if (issue.state === "closed") {
1693 const pull = shippedBy(db, repo, issue);
1694 return pull ? await pullWord(wordCtx, pull) : undefined;
1695 }
1696 if (closingPull !== undefined) return await pullWord(wordCtx, closingPull);
1697 if (activeRunFor(db, "agent", issue.id) !== undefined)
1698 return { word: "working", note: "working now" };
1699 const last = listRuns(db, repo.id, {
1700 kind: "agent",
1701 issueId: issue.id,
1702 limit: 1,
1703 })[0];
1704 if (last !== undefined) return await runWord(wordCtx, last);
1705 return { word: "told", note: "not picked up yet" };
1706 }
1707
1708 /** Every open issue this one waits for, and every one that waits for it —
1709 * read-only: the dependency form stays dev's alone (`dependenciesCard`,
1710 * thread.ts). */
1711 function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Html {
1712 const { blockedBy, blocks } = dependenciesOf(db, issue.id);
1713 const list = (items: readonly Issue[]) =>
1714 html`<ul class="dep-list">
1715 ${items.map(
1716 (i) =>
1717 html`<li data-state="${i.state}">
1718 ${icon(i.state === "closed" ? "issueClosed" : "issue")}
1719 <a href="${base}/issues/${i.number}">${i.title}</a>
1720 <span class="muted small">#${i.number}</span>
1721 </li>`,
1722 )}
1723 </ul>`;
1724 if (blockedBy.length === 0 && blocks.length === 0)
1725 return card(
1726 html`<p class="muted small">Waits for nothing, holds up nothing.</p>`,
1727 { head: html`${icon("map")} <h2>Depends on</h2>` },
1728 );
1729 return card(
1730 html`${blockedBy.length === 0
1731 ? html`<p class="muted small">Waits for nothing.</p>`
1732 : html`<p class="small">Waits for ${blockedBy.length}</p>
1733 ${list(blockedBy)}`}
1734 ${blocks.length === 0
1735 ? null
1736 : html`<p class="small mt">Holds up ${blocks.length}</p>
1737 ${list(blocks)}`}`,
1738 { head: html`${icon("map")} <h2>Depends on</h2>` },
1739 );
1740 }
1741
1742 /** Every agent run for this tell, newest first: who, how long, what it
1743 * cost, and its state in the studio's own word — the title opens the
1744 * transcript. Empty when the crew has not touched it yet. */
1745 async function tellRuns(
1746 wordCtx: WordCtx,
1747 repo: Repo,
1748 issue: Issue,
1749 ): Promise<Html | null> {
1750 const runs = listRuns(wordCtx.db, repo.id, {
1751 kind: "agent",
1752 issueId: issue.id,
1753 limit: 10,
1754 });
1755 if (runs.length === 0) return null;
1756 const base = repoBase(repo);
1757 const actors = getUsersByIds(
1758 wordCtx.db,
1759 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
1760 );
1761 const rows = await Promise.all(
1762 runs.map(async (r) => {
1763 const who =
1764 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew");
1765 const { word } = await runWord(wordCtx, r);
1766 const duration = durationText(r);
1767 return row({
1768 href: `${base}/runs/${r.id}`,
1769 nav: true,
1770 lead: avatarOf(who),
1771 title: html`${who} <span class="muted">${reasonWords(r.reason)}</span>`,
1772 meta: html`${duration ? html`${duration} · ` : null}${r.summary ||
1773 "no summary yet"}`,
1774 trail: html`${wordBadge(word)}${when(
1775 r.costUsd > 0,
1776 () =>
1777 html`<span class="num muted small">${costText(r.costUsd)}</span>`,
1778 )}`,
1779 });
1780 }),
1781 );
1782 return listCard(rows, {
1783 head: html`${icon("bot")}
1784 <h2>Runs</h2>`,
1785 });
1786 }
1787
1788 /** The pull that closes this tell, with its check and a way into its
1789 * preview — nothing here that `studioPull`/`previewCard` do not already
1790 * draw. */
1791 async function tellClosingPull(
1792 ctx: Ctx,
1793 wordCtx: WordCtx,
1794 repo: Repo,
1795 pull: Pull,
1796 ): Promise<Html> {
1797 const base = repoBase(repo);
1798 const { word, note } = await pullWord(wordCtx, pull);
1799 return html`${card(
1800 html`<p>
1801 <a href="${base}/pulls/${pull.number}">${pull.title}</a>
1802 ${wordBadge(word)} <span class="muted small">${note}</span>
1803 </p>`,
1804 { head: html`${icon("pull")} <h2>Closes with</h2>` },
1805 )}${previewCard(ctx, repo, pull)}`;
1806 }
1807
1808 /**
1809 * GET /:owner/:name/issues/:number in studio mode: a tell is one sentence
1810 * someone said, and the page answers three questions — who has it, what it
1811 * is waiting on, and what the crew has done with it — nothing a dev-mode
1812 * reader needs (labels, milestones, the dependency form) belongs here.
1813 * `?mode=dev` reads that page, unchanged; the conversation and the reply
1814 * are the very same action as always (`conversation`/`composer`,
1815 * thread.ts).
1816 */
1817 export async function studioTell(
1818 ctx: Ctx,
1819 repo: Repo,
1820 issue: Issue,
1821 ): Promise<Response> {
1822 const db = ctx.app.db;
1823 const base = repoBase(repo);
1824 const thread = `${base}/issues/${issue.number}`;
1825 const direction = repo.directionIssueId === issue.id;
1826 const closingPull = openPullsClosing(db, repo.id).get(issue.number);
1827 const wordCtx: WordCtx = {
1828 db,
1829 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1830 runtime: appsRuntime(ctx.app),
1831 record: getAppByRepo(db, repo.id),
1832 };
1833 const [wordInfo, runsCard, closingCard] = await Promise.all([
1834 direction ? undefined : tellWord(wordCtx, repo, issue, closingPull),
1835 tellRuns(wordCtx, repo, issue),
1836 closingPull ? tellClosingPull(ctx, wordCtx, repo, closingPull) : undefined,
1837 ]);
1838
1839 const head = html`<div class="issue-head">
1840 <h1>${issue.title} <span class="number">#${issue.number}</span></h1>
1841 <div class="issue-meta">
1842 ${direction
1843 ? html`<span class="muted small"
1844 >${icon("map")} the repository's standing direction — always
1845 open, read by the planner directly, never picked up like an
1846 ordinary tell</span
1847 >`
1848 : wordInfo
1849 ? html`${wordBadge(wordInfo.word)}
1850 <span class="muted small">${wordInfo.note}</span>`
1851 : stateBadge(issue.state)}
1852 <span
1853 >${issue.originAuthor
1854 ? originTag(issue.originAuthor, originGlyph(repo))
1855 : userLink(issue.authorName)}
1856 opened this ${time(issue.createdAt)}</span
1857 >
1858 ${when(
1859 issue.originUrl,
1860 (url) =>
1861 html`<a class="origin-link" href="${url}" rel="noreferrer noopener"
1862 >${icon("external")}view on origin</a
1863 >`,
1864 )}
1865 <span
1866 >${issue.assigneeName === null
1867 ? html`<span class="muted">nobody has it yet</span>`
1868 : html`${when(issue.assigneeIsAgent, () => icon("bot"))}
1869 ${userLink(issue.assigneeName)} has it`}</span
1870 >
1871 </div>
1872 </div>`;
1873
1874 const talk = conversation(ctx, repo, issue, { openVerb: "opened this" });
1875
1876 const body = html`${repoHeader(ctx, repo, "issues", {
1877 compact: true,
1878 ownTitle: true,
1879 })}
1880 ${head}
1881 <div class="split">
1882 <div>
1883 ${talk.node}
1884 ${composer(ctx, {
1885 action: `${thread}/comments`,
1886 stateAction: `${thread}/state`,
1887 state: issue.state,
1888 kind: "issue",
1889 })}
1890 </div>
1891 <aside>
1892 ${runsCard}
1893 ${closingCard}
1894 ${tellDependencies(base, issue, db)}
1895 </aside>
1896 </div>
1897 <p class="muted small mt">
1898 <a href="${thread}?mode=dev">${icon("code")}Read the details</a> —
1899 labels, the dependency form and the assignee, dev's own way.
1900 </p>
1901 ${liveMount({
1902 repo: base,
1903 thread: "issue",
1904 number: issue.number,
1905 lastCommentId: talk.lastCommentId,
1906 })}`;
1907
1908 return page({
1909 ctx,
1910 title: `${issue.title} · ${repo.name}`,
1911 crumbs: crumbsFor(repo),
1912 palette: repoPalette(repo),
1913 body,
1914 styleCss: studioCss,
1915 scriptJs: liveClientJs,
1916 });
1917 }
1918
1919 // ---- lists: everything told, every change, every run ----
1920
1921 /** The five words a studio list filters by. `waiting` and `planned` are
1922 * still moving, so they fold into `working` — the one place the seven-word
1923 * vocabulary above buckets down to the five a list links to. */
1924 type WordFilter = "told" | "working" | "look" | "shipped" | "stopped";
1925
1926 const WORD_FILTERS: readonly WordFilter[] = [
1927 "told",
1928 "working",
1929 "look",
1930 "shipped",
1931 "stopped",
1932 ];
1933
1934 function listBucket(word: Word): WordFilter {
1935 return word === "waiting" || word === "planned" ? "working" : word;
1936 }
1937
1938 /** The word named in `?word=`, when it is one of the five. */
1939 function readWordFilter(ctx: Ctx): WordFilter | undefined {
1940 const raw = ctx.url.searchParams.get("word") ?? "";
1941 return (WORD_FILTERS as readonly string[]).includes(raw)
1942 ? (raw as WordFilter)
1943 : undefined;
1944 }
1945
1946 /** Whether a row belongs on the page shown: the exact word the URL asked
1947 * for, else "open and moving" — told, working or look, never shipped or
1948 * stopped. */
1949 function inWordFilter(word: Word, filter: WordFilter | undefined): boolean {
1950 const bucket = listBucket(word);
1951 return filter === undefined
1952 ? bucket === "told" || bucket === "working" || bucket === "look"
1953 : bucket === filter;
1954 }
1955
1956 /** A filter bar of five words, each a link carrying `?word=` — never a
1957 * form, so the filter lives in the address and can be shared. No filter
1958 * reads as "Open": what is open and moving. */
1959 function wordFilterBar(base: string, current: WordFilter | undefined): Html {
1960 return html`<div class="filters mb">
1961 ${seg([
1962 { label: "Open", href: base, current: current === undefined },
1963 ...WORD_FILTERS.map((w) => ({
1964 label: w[0]!.toUpperCase() + w.slice(1),
1965 href: `${base}?word=${w}`,
1966 current: current === w,
1967 })),
1968 ])}
1969 </div>`;
1970 }
1971
1972 const LIST_PAGE = 25;
1973
1974 /** `?word=` carried onto a `cursor` link, so paging keeps the filter. */
1975 function pagedHref(base: string, filter: WordFilter | undefined, cursor: string): string {
1976 return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`;
1977 }
1978
1979 /**
1980 * GET /:owner/:name/issues in the studio: everything told, one row per
1981 * issue in its studio word — who has it, when it last moved, and the tell
1982 * itself as the row's own link. No labels, no milestones. `?word=` narrows
1983 * it; `?mode=dev` reads dev's list, unchanged. Bounded to one page: `tellWord`
1984 * costs a few small reads per row, the trade the pull list already makes
1985 * for its own check summaries (`verdictsFor`, pulls.ts).
1986 */
1987 export async function studioTells(
1988 ctx: Ctx,
1989 repo: Repo,
1990 record: AppRecord,
1991 ): Promise<Response> {
1992 const db = ctx.app.db;
1993 const base = repoBase(repo);
1994 const filter = readWordFilter(ctx);
1995 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
1996 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
1997 const cursor =
1998 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
1999
2000 const fetched = listIssues(db, repo.id, {
2001 kind: "issue",
2002 state,
2003 limit: LIST_PAGE,
2004 ...(cursor !== undefined ? { cursor } : {}),
2005 }).filter((issue) => issue.id !== repo.directionIssueId);
2006 const wordCtx: WordCtx = {
2007 db,
2008 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2009 runtime: appsRuntime(ctx.app),
2010 record,
2011 };
2012 const closingPulls = openPullsClosing(db, repo.id);
2013 const words = await Promise.all(
2014 fetched.map((issue) =>
2015 tellWord(wordCtx, repo, issue, closingPulls.get(issue.number)),
2016 ),
2017 );
2018 const rows = fetched
2019 .map((issue, i) => ({ issue, info: words[i] }))
2020 .filter(
2021 (r): r is { issue: Issue; info: { word: Word; note: string } } =>
2022 r.info !== undefined && inWordFilter(r.info.word, filter),
2023 );
2024
2025 const list =
2026 rows.length === 0
2027 ? empty({
2028 icon: "issue",
2029 title: "Nothing here",
2030 note: "Nothing told matches this filter yet.",
2031 })
2032 : listCard(
2033 rows.map(({ issue, info }) =>
2034 row({
2035 href: `${base}/issues/${issue.number}`,
2036 nav: true,
2037 lead: avatarOf(issue.assigneeName ?? "crew"),
2038 title: issue.title,
2039 meta: html`${issue.assigneeName
2040 ? html`${issue.assigneeName} · `
2041 : null}${info.note} · ${time(issue.updatedAt)}`,
2042 trail: wordBadge(info.word),
2043 }),
2044 ),
2045 );
2046 const lastNumber = fetched[fetched.length - 1]?.number;
2047
2048 const body = html`${repoHeader(ctx, repo, "issues")}
2049 <div class="mt">
2050 ${wordFilterBar(`${base}/issues`, filter)} ${list}
2051 ${pager(
2052 fetched.length === LIST_PAGE && lastNumber !== undefined
2053 ? { next: pagedHref(`${base}/issues`, filter, String(lastNumber)) }
2054 : {},
2055 )}
2056 </div>
2057 <p class="muted small mt">
2058 <a href="${base}/issues?mode=dev">${icon("code")}See dev's list</a> —
2059 labels, milestones and the dependency form, dev's own way.
2060 </p>
2061 ${liveMount({ repo: base })}`;
2062
2063 return page({
2064 ctx,
2065 title: `Everything told · ${repo.name}`,
2066 crumbs: crumbsFor(repo),
2067 palette: repoPalette(repo),
2068 body,
2069 styleCss: studioCss,
2070 scriptJs: liveClientJs,
2071 });
2072 }
2073
2074 /**
2075 * GET /:owner/:name/pulls in the studio: every change, one row per pull in
2076 * its studio word — who made it, when it last moved, and the change itself
2077 * as the row's own link. No branch names, no diff. `?word=` narrows it;
2078 * `?mode=dev` reads dev's list, unchanged.
2079 */
2080 export async function studioChanges(
2081 ctx: Ctx,
2082 repo: Repo,
2083 record: AppRecord,
2084 ): Promise<Response> {
2085 const db = ctx.app.db;
2086 const base = repoBase(repo);
2087 const filter = readWordFilter(ctx);
2088 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
2089 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
2090 const cursor =
2091 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
2092
2093 const fetched = listPulls(db, repo.id, {
2094 state,
2095 limit: LIST_PAGE,
2096 ...(cursor !== undefined ? { cursor } : {}),
2097 });
2098 const wordCtx: WordCtx = {
2099 db,
2100 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2101 runtime: appsRuntime(ctx.app),
2102 record,
2103 };
2104 const words = await Promise.all(
2105 fetched.map((pull) => pullWord(wordCtx, pull)),
2106 );
2107 const rows = fetched
2108 .map((pull, i) => ({ pull, info: words[i]! }))
2109 .filter((r) => inWordFilter(r.info.word, filter));
2110
2111 const list =
2112 rows.length === 0
2113 ? empty({
2114 icon: "pull",
2115 title: "Nothing here",
2116 note: "Nothing matches this filter yet.",
2117 })
2118 : listCard(
2119 rows.map(({ pull, info }) =>
2120 row({
2121 href: `${base}/pulls/${pull.number}`,
2122 nav: true,
2123 lead: avatarOf(pull.authorName),
2124 title: pull.title,
2125 meta: html`${pull.authorName} · ${info.note} ·
2126 ${time(pull.updatedAt)}`,
2127 trail: wordBadge(info.word),
2128 }),
2129 ),
2130 );
2131 const lastNumber = fetched[fetched.length - 1]?.number;
2132
2133 const body = html`${repoHeader(ctx, repo, "pulls")}
2134 <div class="mt">
2135 ${wordFilterBar(`${base}/pulls`, filter)} ${list}
2136 ${pager(
2137 fetched.length === LIST_PAGE && lastNumber !== undefined
2138 ? { next: pagedHref(`${base}/pulls`, filter, String(lastNumber)) }
2139 : {},
2140 )}
2141 </div>
2142 <p class="muted small mt">
2143 <a href="${base}/pulls?mode=dev">${icon("code")}See dev's list</a> —
2144 branch names and the diff, dev's own way.
2145 </p>
2146 ${liveMount({ repo: base })}`;
2147
2148 return page({
2149 ctx,
2150 title: `Every change · ${repo.name}`,
2151 crumbs: crumbsFor(repo),
2152 palette: repoPalette(repo),
2153 body,
2154 styleCss: studioCss,
2155 scriptJs: liveClientJs,
2156 });
2157 }
2158
2159 /**
2160 * GET /:owner/:name/runs in the studio: every run, one row per run in its
2161 * studio word — who ran it, when it last moved, and the transcript as the
2162 * row's own link. `?word=` narrows it; `?mode=dev` reads dev's list,
2163 * unchanged.
2164 */
2165 export async function studioRuns(
2166 ctx: Ctx,
2167 repo: Repo,
2168 record: AppRecord,
2169 ): Promise<Response> {
2170 const db = ctx.app.db;
2171 const base = repoBase(repo);
2172 const filter = readWordFilter(ctx);
2173 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
2174
2175 const fetched = listRuns(db, repo.id, {
2176 kind: "agent",
2177 limit: LIST_PAGE,
2178 ...(cursor ? { cursor } : {}),
2179 });
2180 const wordCtx: WordCtx = {
2181 db,
2182 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2183 runtime: appsRuntime(ctx.app),
2184 record,
2185 };
2186 const actors = actorsFor(ctx, fetched);
2187 const issues = issuesFor(ctx, fetched);
2188 const words = await Promise.all(fetched.map((run) => runWord(wordCtx, run)));
2189 const rows = fetched
2190 .map((run, i) => ({ run, info: words[i]! }))
2191 .filter((r) => inWordFilter(r.info.word, filter));
2192
2193 const list =
2194 rows.length === 0
2195 ? empty({
2196 icon: "bot",
2197 title: "Nothing here",
2198 note: "Nothing matches this filter yet.",
2199 })
2200 : listCard(
2201 rows.map(({ run, info }) => {
2202 const who =
2203 run.actorId === null
2204 ? "crew"
2205 : (actors.get(run.actorId)?.username ?? "crew");
2206 return row({
2207 href: `${base}/runs/${run.id}`,
2208 nav: true,
2209 lead: avatarOf(who),
2210 title: runTitleFrom(run, actors, issues),
2211 meta: html`${who} · ${info.note} ·
2212 ${time(run.finishedAt ?? run.createdAt)}`,
2213 trail: wordBadge(info.word),
2214 });
2215 }),
2216 );
2217 const last = fetched[fetched.length - 1];
2218
2219 const body = html`${repoHeader(ctx, repo, "app", { compact: true })}
2220 <div class="mt">
2221 ${wordFilterBar(`${base}/runs`, filter)} ${list}
2222 ${pager(
2223 fetched.length === LIST_PAGE && last !== undefined
2224 ? { next: pagedHref(`${base}/runs`, filter, last.id) }
2225 : {},
2226 )}
2227 </div>
2228 <p class="muted small mt">
2229 <a href="${base}/runs?mode=dev">${icon("code")}See dev's list</a> —
2230 deploys and checks alongside the crew's runs, dev's own way.
2231 </p>
2232 ${liveMount({ repo: base })}`;
2233
2234 return page({
2235 ctx,
2236 title: `Every run · ${repo.name}`,
2237 crumbs: crumbsFor(repo),
2238 palette: repoPalette(repo),
2239 body,
2240 styleCss: studioCss,
2241 scriptJs: liveClientJs,
2242 });
2243 }
2244
2245 // ---- the studio home ----
2246
2247 /**
2248 * The home in the studio: what asks you first, then every app as one row
2249 * that carries a real signal — live or not, what the crew last did there,
2250 * how many things wait on you — and beside it what is happening right now
2251 * across all of them, and what a person put on you. Built from the same
2252 * rows and cards as the rest of the forge, so it reads as the forge.
2253 */
2254 export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
2255 const db = ctx.app.db;
2256 const { repos: appRepos, records } = appsOf(ctx, user);
2257 const runtime = appsRuntime(ctx.app);
2258 // A public app you can only read appears here too, but who is on its
2259 // crew is its owner's fact, not yours to see — same predicate as the
2260 // app's own page.
2261 const ownerFacts = new Set(
2262 appRepos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id),
2263 );
2264 // One read for the newest run of every app, one for who ran them: the
2265 // home must not get slower as the apps grow.
2266 const latestRuns = latestAgentRunsForRepos(
2267 db,
2268 appRepos.map((r) => r.id),
2269 );
2270 const actors = getUsersByIds(
2271 db,
2272 [...latestRuns.values()]
2273 .map((r) => r.actorId)
2274 .filter((id): id is string => id !== null),
2275 );
2276 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
2277 const asksByRepo = new Map<string, number>();
2278 for (const d of decisions)
2279 asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1);
2280
2281 const appRows = await Promise.all(appRepos.map(async (repo) => {
2282 const record = records.get(repo.id)!;
2283 const envs = runtime === undefined ? [] : runtime.status(record);
2284 const production = envs.find((e) => e.env === "production");
2285 const state = production?.state ?? "stopped";
2286 const latest = latestRuns.get(repo.id);
2287 const word =
2288 latest === undefined
2289 ? undefined
2290 : await runWord(
2291 {
2292 db,
2293 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
2294 runtime,
2295 record,
2296 },
2297 latest,
2298 );
2299 const who =
2300 latest?.actorId == null || !ownerFacts.has(repo.id)
2301 ? undefined
2302 : actors.get(latest.actorId)?.username;
2303 const asks = asksByRepo.get(repo.id) ?? 0;
2304 const tone: Tone =
2305 state === "running"
2306 ? "success"
2307 : state === "failed"
2308 ? "danger"
2309 : "default";
2310 const happening =
2311 latest === undefined || word === undefined
2312 ? html`<span class="muted">quiet — tell the crew something</span>`
2313 : html`<span class="studio-word" data-word="${word.word}"
2314 >${word.word}</span
2315 >
2316 ${who ? html`· ${who}` : null}
2317 ${word.word === "working"
2318 ? html`· now`
2319 : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`;
2320 return row({
2321 href: repoBase(repo),
2322 nav: true,
2323 lead: dot(tone),
2324 title: repo.name,
2325 titleAttr: repo.description || repo.name,
2326 meta: html`${repo.description
2327 ? html`${repo.description} · `
2328 : null}${happening}`,
2329 trail: html`${asks > 0 ? pill(`${asks} asks you`, "accent") : null}
2330 <span class="badge" data-tone="${tone}"
2331 >${state === "running"
2332 ? "live"
2333 : state === "stopped"
2334 ? "not running"
2335 : state}</span
2336 >`,
2337 });
2338 }));
2339
2340 const apps =
2341 appRows.length === 0
2342 ? empty({
2343 icon: "rocket",
2344 title: "No apps yet",
2345 note: "Make one, tell the crew what it is, and watch it come up.",
2346 action: btn("New app", { href: "/apps/new", variant: "primary" }),
2347 })
2348 : listCard(appRows, {
2349 head: html`${icon("rocket")}
2350 <h2>Your apps</h2>
2351 <span class="spacer"></span>
2352 ${btn("Map", { href: "/map", size: "sm", icon: "map" })}
2353 ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}`,
2354 });
2355
2356 const asks = when(decisions.length > 0, () => asksPanel(ctx, decisions, "/"));
2357
2358 // Happening now: every run in flight across the apps, one read.
2359 const running = listActiveRuns(
2360 db,
2361 appRepos.map((r) => r.id),
2362 );
2363 const runActors = getUsersByIds(
2364 db,
2365 running.map((r) => r.actorId).filter((id): id is string => id !== null),
2366 );
2367 const byId = new Map(appRepos.map((r) => [r.id, r]));
2368 // The board: the same runs as a wall for a second monitor.
2369 const nowHead = html`${icon("activity")}
2370 <h2>Happening now</h2>
2371 <span class="spacer"></span>
2372 ${btn("Focus", {
2373 href: "/board",
2374 size: "sm",
2375 title: "The board: a live wall of the crews at work",
2376 })}`;
2377 const now =
2378 running.length === 0
2379 ? card(html`<p class="muted small">Nobody is working right now.</p>`, {
2380 head: nowHead,
2381 })
2382 : listCard(
2383 running.map((r) => {
2384 const repo = byId.get(r.repoId)!;
2385 const actorName =
2386 r.actorId === null
2387 ? "crew"
2388 : (runActors.get(r.actorId)?.username ?? "crew");
2389 const who = ownerFacts.has(repo.id) ? actorName : "the crew";
2390 const stop = stopForm(ctx, repo, r);
2391 return row({
2392 ...(stop
2393 ? {}
2394 : { href: `${repoBase(repo)}/runs/${r.id}`, nav: true }),
2395 lead: icon(r.state === "running" ? "loader" : "clock"),
2396 title: html`<a href="${repoBase(repo)}/runs/${r.id}"
2397 >${who} · ${repo.name}</a
2398 >`,
2399 meta: html`${r.state === "running"
2400 ? "working"
2401 : "waiting"}${r.startedAt
2402 ? html` since ${time(r.startedAt)}`
2403 : null}`,
2404 ...(stop
2405 ? {
2406 trail: html`<span class="studio-ask-actions"
2407 >${stop}</span
2408 >`,
2409 }
2410 : {}),
2411 });
2412 }),
2413 { head: nowHead },
2414 );
2415
2416 const body = html`<div class="dash">
2417 <div class="dash-main stack">
2418 <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*">${asks}</div>
2419 <div
2420 id="live-apps"
2421 data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged"
2422 >
2423 ${apps}
2424 </div>
2425 </div>
2426 <aside class="dash-rail stack">
2427 <div id="live-now" data-live="run.*">${now}</div>
2428 <div
2429 id="live-on-you"
2430 data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged"
2431 >
2432 ${onYouCard(ctx, user) ?? null}
2433 </div>
2434 </aside>
2435 </div>`;
2436 return page({
2437 ctx,
2438 title: "Studio",
2439 body,
2440 styleCss: studioCss,
2441 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
2442 });
2443 }
2444
2445 /** One decision as a row: a box to pick it, what it says, the options in place. */
2446 function decisionRow(ctx: Ctx, d: Decision): Html {
2447 const base = repoBase(d.repo);
2448 const glyph =
2449 d.kind === "stopped" ? "alert" : d.kind === "look" ? "eye" : "clock";
2450 // The title links; the row itself does not, because it holds controls
2451 // and a control inside an anchor is not HTML a browser will keep together.
2452 const href = d.pull ? `${base}/pulls/${d.pull.number}` : base;
2453 const title = html`<a href="${href}">${d.pull?.title ?? d.repo.name}</a>`;
2454 return row({
2455 lead: html`<input
2456 class="studio-ask-pick"
2457 type="checkbox"
2458 name="ask"
2459 value="${d.id}"
2460 data-kind="${d.kind}"
2461 data-repo="${base}"
2462 aria-label="Select: ${d.pull?.title ?? d.repo.name}"
2463 />${d.picture === undefined ? icon(glyph) : lookThumb(href, d.picture)}`,
2464 title,
2465 titleAttr: d.says,
2466 meta: html`${d.repo.name}${d.pull ? html` #${d.pull.number}` : null} ·
2467 ${d.short}`,
2468 trail: html`<span class="studio-ask-actions"
2469 >${decisionActions(ctx, d)}</span
2470 >`,
2471 });
2472 }
2473
2474 /**
2475 * The asks as one form. Every row has a box; check any and a bar appears
2476 * with what fits them all, posting to /studio/asks. A row's own button
2477 * submits that row's form, placed after this one — nothing is nested, and
2478 * one decision is exactly the post it was before.
2479 */
2480 function asksPanel(
2481 ctx: Ctx,
2482 decisions: readonly Decision[],
2483 back: string,
2484 ): Html {
2485 const bar = html`<div class="studio-bulk">
2486 <output class="studio-bulk-count">Selected</output>
2487 ${BULK_ACTIONS.map((a) =>
2488 btn(BULK[a].label, { size: "sm", name: "action", value: a }),
2489 )}
2490 <span class="muted studio-bulk-none">no one action fits them all</span>
2491 </div>`;
2492 return html`<div class="studio-asks">
2493 <form method="post" action="/studio/asks">
2494 <input type="hidden" name="back" value="${back}" />
2495 ${card(
2496 html`${bar}
2497 <ul class="list">
2498 ${decisions.map((d) => decisionRow(ctx, d))}
2499 </ul>`,
2500 {
2501 head: html`${icon("alert")}
2502 <h2>Asks you</h2>
2503 <span class="muted small">decide, and the crew carries on</span>`,
2504 pad: false,
2505 },
2506 )}
2507 </form>
2508 ${decisions.map(decisionForm)}
2509 </div>`;
2510 }
2511
2512 /** The apps a person can see, as repositories, with their records. */
2513 function appsOf(ctx: Ctx, user: User) {
2514 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
2515 const records = appsForRepos(
2516 ctx.app.db,
2517 repos.map((r) => r.id),
2518 );
2519 return { repos: repos.filter((r) => records.has(r.id)), records };
2520 }
2521
2522 // ---- actions ----
2523
2524 /** POST /:owner/:name/tell { text } — a sentence becomes work. */
2525 export async function tellSubmit(ctx: Ctx): Promise<Response> {
2526 const repo = loadRepo(ctx);
2527 if (!repo) return notFoundPage(ctx);
2528 if (!ctx.auth.user) return signInFirst(ctx);
2529 const form = await readForm(ctx, "rw");
2530 if (form instanceof Response) return form;
2531 const base = repoBase(repo);
2532 const text = field(form, "text").trim();
2533 if (text === "")
2534 return flashTo(ctx, base, {
2535 tone: "danger",
2536 message: "Say something first.",
2537 });
2538 const told = await tellCrew(ctx.app, ctx.auth.user, repo, text);
2539 if (!told.ok)
2540 return flashTo(ctx, base, {
2541 tone: "danger",
2542 message: "That could not be told.",
2543 });
2544 const { routed } = told.value;
2545 if (routed === undefined)
2546 return flashTo(ctx, base, {
2547 message: `Told. Nobody is on the crew to take it yet — add someone under Settings → Crew.`,
2548 });
2549 const builder = routed.builder;
2550 return flashTo(ctx, base, {
2551 message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}. Watch it work below.`,
2552 });
2553 }
2554
2555 /** POST /:owner/:name/pulls/:number/ship — land a green change. */
2556 export async function shipSubmit(ctx: Ctx): Promise<Response> {
2557 const repo = loadRepo(ctx);
2558 if (!repo) return notFoundPage(ctx);
2559 if (!ctx.auth.user) return signInFirst(ctx);
2560 const form = await readForm(ctx, "rw");
2561 if (form instanceof Response) return form;
2562 const base = repoBase(repo);
2563 const number = Number(ctx.params.number);
2564 const pull = Number.isInteger(number)
2565 ? getPull(ctx.app.db, repo.id, number)
2566 : undefined;
2567 if (!pull) return notFoundPage(ctx);
2568 const merged = await mergePull(ctx.app, ctx.auth.user, repo, pull, "squash");
2569 // A conflict is never the person's to fix: the pull's agent brings the
2570 // branch up to date, and the pull comes back green, or lands on its own.
2571 if (
2572 !merged.ok &&
2573 typeof merged.error !== "string" &&
2574 merged.error.kind === "conflict"
2575 ) {
2576 const who = bringUpToDate(ctx.app, repo, pull, merged.error.files);
2577 return flashOrJson(ctx, base, {
2578 tone: "danger" as const,
2579 message:
2580 who === undefined
2581 ? `It conflicts with main (${merged.error.files.join(", ")}) and the crew has already tried its limit; say what to do on the pull.`
2582 : `It conflicts with main (${merged.error.files.join(", ")}); ${who} is bringing it up to date${repo.autonomy === "autonomous" ? ", then it lands on its own" : ", then it comes back to you"}.`,
2583 });
2584 }
2585 return flashOrJson(ctx, base, {
2586 ...(merged.ok
2587 ? { message: `Shipped "${pull.title}". Production is redeploying.` }
2588 : {
2589 tone: "danger" as const,
2590 message: `That could not ship: ${mergeWords(merged.error)}.`,
2591 }),
2592 });
2593 }
2594
2595 /** POST /:owner/:name/pulls/:number/say { text } — feedback to the crew. */
2596 export async function saySubmit(ctx: Ctx): Promise<Response> {
2597 const repo = loadRepo(ctx);
2598 if (!repo) return notFoundPage(ctx);
2599 if (!ctx.auth.user) return signInFirst(ctx);
2600 const form = await readForm(ctx, "rw");
2601 if (form instanceof Response) return form;
2602 const base = repoBase(repo);
2603 const number = Number(ctx.params.number);
2604 const pull = Number.isInteger(number)
2605 ? getPull(ctx.app.db, repo.id, number)
2606 : undefined;
2607 if (!pull) return notFoundPage(ctx);
2608 const text = field(form, "text").trim();
2609 if (text === "")
2610 return flashTo(ctx, base, {
2611 tone: "danger",
2612 message: "Say something first.",
2613 });
2614 const said = addComment(ctx.app, ctx.auth.user, repo, pull, text);
2615 return flashOrJson(ctx, base, {
2616 ...(said.ok
2617 ? { message: `Told ${pull.authorName}. It picks the change back up.` }
2618 : { tone: "danger" as const, message: "That could not be said." }),
2619 });
2620 }
2621
2622 /**
2623 * POST /studio/asks { ask*, action, back } — one decision for several asks.
2624 * Each is the same act as its row's own button. An ask that is gone, or
2625 * never took this action, is skipped and counted, never guessed at.
2626 */
2627 export async function asksSubmit(ctx: Ctx): Promise<Response> {
2628 const user = ctx.auth.user;
2629 if (!user) return signInFirst(ctx);
2630 const form = await readForm(ctx, "rw");
2631 if (form instanceof Response) return form;
2632 const back = backTo(ctx, field(form, "back"));
2633 const action = field(form, "action");
2634 const picked = [
2635 ...new Set(
2636 form.getAll("ask").filter((v): v is string => typeof v === "string"),
2637 ),
2638 ];
2639 if (!isBulkAction(action) || picked.length === 0)
2640 return flashOrJson(ctx, back, {
2641 tone: "danger",
2642 message: "Pick some asks, and what to do with them.",
2643 });
2644 // The asks as they stand now, not as the page had them.
2645 const asks = new Map(
2646 decisionsFor(ctx, appsOf(ctx, user).repos).map((d) => [d.id, d]),
2647 );
2648 const done: string[] = [];
2649 const skipped: string[] = [];
2650 const failed: string[] = [];
2651 for (const id of picked) {
2652 const d = asks.get(id);
2653 if (d?.pull === undefined || !takes(d, action)) {
2654 skipped.push(id);
2655 continue;
2656 }
2657 const r =
2658 action === "ship"
2659 ? await mergePull(ctx.app, user, d.repo, d.pull, "squash")
2660 : await setIssueStateThrough(ctx.app, user, d.repo, d.pull, "closed");
2661 (r.ok ? done : failed).push(id);
2662 }
2663 const tally = [`${BULK[action].done} ${done.length}`];
2664 if (skipped.length > 0) tally.push(`skipped ${skipped.length}`);
2665 if (failed.length > 0) tally.push(`could not ${action} ${failed.length}`);
2666 return flashOrJson(
2667 ctx,
2668 back,
2669 {
2670 ...(done.length === 0 ? { tone: "danger" as const } : {}),
2671 message: tally.join(" · "),
2672 },
2673 { done, skipped, failed },
2674 );
2675 }
2676
2677 /** Where a form sends a person back: what it said, else the referer, else
2678 * home — and never off this forge. */
2679 function backTo(ctx: Ctx, said: string): string {
2680 const back = said || ctx.request.headers.get("referer") || "/";
2681 return (back.startsWith("/") && !back.startsWith("//")) ||
2682 back.startsWith(`${ctx.url.origin}/`)
2683 ? back
2684 : "/";
2685 }
2686
2687 /** POST /mode { mode } — studio or dev, for this browser. */
2688 export async function modeSubmit(ctx: Ctx): Promise<Response> {
2689 const form = await readForm(ctx);
2690 if (form instanceof Response) return form;
2691 const choice = field(form, "mode");
2692 const secure = ctx.url.protocol === "https:";
2693 return redirect(
2694 backTo(ctx, field(form, "back")),
2695 modeCookie(isMode(choice) ? choice : undefined, secure),
2696 );
2697 }
2698
2699 /**
2700 * An action in the bar shows only while every checked ask takes it, and a
2701 * note when none does — from the same table the route enforces, so the
2702 * page cannot offer what the server would skip. CSS alone: it holds with
2703 * scripts off.
2704 */
2705 const bulkCss = ((): string => {
2706 const other = (a: BulkAction): string =>
2707 `[name="ask"]${BULK[a].kinds.map((k) => `:not([data-kind="${k}"])`).join("")}:checked`;
2708 return [
2709 ...BULK_ACTIONS.map(
2710 (a) =>
2711 `.studio-asks:has(${other(a)}) [name="action"][value="${a}"] { display: none; }`,
2712 ),
2713 `.studio-asks${BULK_ACTIONS.map((a) => `:has(${other(a)})`).join("")} .studio-bulk-none { display: inline; }`,
2714 ].join("\n");
2715 })();
2716
2717 export const studioCss = /* css */ `
2718 .studio { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 22px; margin-top: 16px; }
2719 @media (max-width: 860px) { .studio { grid-template-columns: 1fr; } }
2720 .studio-main { display: grid; gap: 18px; }
2721 .studio-preview { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--muted); }
2722 .studio-preview-head { display: flex; align-items: center; gap: 12px; }
2723 .studio-live { font-family: var(--mono); font-size: 11.5px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); }
2724 .studio-live[data-state="running"] { color: var(--merged); border-color: var(--merged); }
2725 .studio-live[data-state="failed"] { color: var(--danger, #c33); }
2726 .studio-tell textarea { min-height: 72px; resize: vertical; }
2727 .studio-tell .bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
2728 .studio-head h1 { display: flex; align-items: center; gap: 10px; }
2729 /* The facts under the title take the whole line; the two buttons that act
2730 on production sit with the rest, each in its own POST form. */
2731 .studio-head .env-facts { margin-top: 2px; }
2732 .studio-head form { display: inline-flex; }
2733 .badge[data-variant="accent"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
2734 .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; }
2735 .studio-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
2736 .studio-list li { display: grid; grid-template-columns: 18px 84px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 14px; }
2737 .studio-list li svg { vertical-align: -3px; }
2738 .studio-who { font-weight: 600; }
2739 .studio-word { font-family: var(--mono); font-size: 11.5px; opacity: .8; white-space: nowrap; }
2740 .row-meta .studio-word { font-family: inherit; font-size: inherit; opacity: 1; font-weight: 560; }
2741 .studio-list li[data-word="working"] .studio-word { color: var(--accent); opacity: 1; }
2742 .studio-list li[data-word="look"] .studio-word { color: var(--merged); opacity: 1; }
2743 .studio-list li[data-word="stopped"] .studio-word { color: var(--danger, #c33); opacity: 1; }
2744 .studio-ask-actions { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
2745 .studio-lean { margin-left: 2px; }
2746 .studio-asks .row-lead { gap: 8px; }
2747 .studio-ask-pick { accent-color: var(--accent); margin: 0; width: 13px; height: 13px; opacity: .6; }
2748 .studio-ask-pick:checked, .studio-ask-pick:focus-visible, .row:hover .studio-ask-pick { opacity: 1; }
2749 .studio-bulk { display: none; align-items: center; gap: 8px; padding: 6px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; background: color-mix(in srgb, var(--accent) 7%, var(--card)); }
2750 .studio-asks:has([name="ask"]:checked) .studio-bulk { display: flex; }
2751 .studio-bulk-count { font-weight: 550; font-variant-numeric: tabular-nums; }
2752 .studio-bulk-none { display: none; }
2753 .studio-asks li[data-busy] { opacity: .6; }
2754 .studio-asks li[data-busy] .studio-ask-actions { visibility: hidden; }
2755 .studio-asks li[data-busy] .row-lead svg { display: none; }
2756 .studio-asks li[data-busy] .row-lead::after { content: ""; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
2757 .studio-ask-note { color: var(--danger, #c33); }
2758 ${bulkCss}
2759 .studio-rail { display: grid; gap: 18px; align-content: start; }
2760 .studio-rail h3 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; opacity: .7; margin: 0 0 6px; font-weight: 600; }
2761 .studio-crew { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; font-size: 13.5px; }
2762 .studio-crew li { display: grid; grid-template-columns: 74px 1fr; gap: 8px; }
2763 .studio-crew li b { font-weight: 600; }
2764 .studio-crew li span { opacity: .85; }
2765 `;
11 export { decisionsFor } from "../studio/asks.ts";
12 export { pendingLeans, withLikely } from "../studio/leans.ts";
13 export { pullWord, runWord, type WordCtx } from "../studio/words.ts";
src/web/studio/actions.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * Every POST the studio makes: a sentence told to the crew, a green change
3 * shipped, a word said back, one decision taken for several asks at once,
4 * and the switch between studio and dev. None of it is a second system —
5 * each calls the same action a person would reach by hand (`actions/*`) with
6 * the same access checks, reads its form through `readForm` (the `rw` scope
7 * floor and the same-origin check with it), and answers with a flash on the
8 * page that sent them, or JSON when the asks panel asked in place.
9 */
10
11 import { addComment, setIssueStateThrough } from "../../actions/issues.ts";
12 import { tellCrew } from "../../actions/crew.ts";
13 import { mergePull } from "../../actions/pulls.ts";
14 import { getPull } from "../../core/pulls.ts";
15 import { bringUpToDate } from "../../crew/autonomy.ts";
16 import type { Ctx } from "../../http/context.ts";
17 import {
18 loadRepo,
19 notFoundPage,
20 repoBase,
21 signInFirst,
22 } from "../chrome.ts";
23 import { field, readForm } from "../form.ts";
24 import { flashOrJson, flashTo, redirect } from "../layout.ts";
25 import { isMode, modeCookie } from "../mode.ts";
26 import { BULK, decisionsFor, isBulkAction, takes } from "./asks.ts";
27 import { appsOf } from "./home.ts";
28
29 /** A merge refusal in a person's words. */
30 function mergeWords(error: unknown): string {
31 if (typeof error === "string") return error.replace(/_/g, " ");
32 if (typeof error === "object" && error !== null && "kind" in error) {
33 const e = error as { kind: string; message?: string };
34 if (e.kind === "git") return `git said ${e.message ?? "no"}`;
35 return e.kind.replace(/_/g, " ");
36 }
37 return String(error);
38 }
39
40 /** POST /:owner/:name/tell { text } — a sentence becomes work. */
41 export async function tellSubmit(ctx: Ctx): Promise<Response> {
42 const repo = loadRepo(ctx);
43 if (!repo) return notFoundPage(ctx);
44 if (!ctx.auth.user) return signInFirst(ctx);
45 const form = await readForm(ctx, "rw");
46 if (form instanceof Response) return form;
47 const base = repoBase(repo);
48 const text = field(form, "text").trim();
49 if (text === "")
50 return flashTo(ctx, base, {
51 tone: "danger",
52 message: "Say something first.",
53 });
54 const told = await tellCrew(ctx.app, ctx.auth.user, repo, text);
55 if (!told.ok)
56 return flashTo(ctx, base, {
57 tone: "danger",
58 message: "That could not be told.",
59 });
60 const { routed } = told.value;
61 if (routed === undefined)
62 return flashTo(ctx, base, {
63 message: `Told. Nobody is on the crew to take it yet — add someone under Settings → Crew.`,
64 });
65 const builder = routed.builder;
66 return flashTo(ctx, base, {
67 message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}. Watch it work below.`,
68 });
69 }
70
71 /** POST /:owner/:name/pulls/:number/ship — land a green change. */
72 export async function shipSubmit(ctx: Ctx): Promise<Response> {
73 const repo = loadRepo(ctx);
74 if (!repo) return notFoundPage(ctx);
75 if (!ctx.auth.user) return signInFirst(ctx);
76 const form = await readForm(ctx, "rw");
77 if (form instanceof Response) return form;
78 const base = repoBase(repo);
79 const number = Number(ctx.params.number);
80 const pull = Number.isInteger(number)
81 ? getPull(ctx.app.db, repo.id, number)
82 : undefined;
83 if (!pull) return notFoundPage(ctx);
84 const merged = await mergePull(ctx.app, ctx.auth.user, repo, pull, "squash");
85 // A conflict is never the person's to fix: the pull's agent brings the
86 // branch up to date, and the pull comes back green, or lands on its own.
87 if (
88 !merged.ok &&
89 typeof merged.error !== "string" &&
90 merged.error.kind === "conflict"
91 ) {
92 const who = bringUpToDate(ctx.app, repo, pull, merged.error.files);
93 return flashOrJson(ctx, base, {
94 tone: "danger" as const,
95 message:
96 who === undefined
97 ? `It conflicts with main (${merged.error.files.join(", ")}) and the crew has already tried its limit; say what to do on the pull.`
98 : `It conflicts with main (${merged.error.files.join(", ")}); ${who} is bringing it up to date${repo.autonomy === "autonomous" ? ", then it lands on its own" : ", then it comes back to you"}.`,
99 });
100 }
101 return flashOrJson(ctx, base, {
102 ...(merged.ok
103 ? { message: `Shipped "${pull.title}". Production is redeploying.` }
104 : {
105 tone: "danger" as const,
106 message: `That could not ship: ${mergeWords(merged.error)}.`,
107 }),
108 });
109 }
110
111 /** POST /:owner/:name/pulls/:number/say { text } — feedback to the crew. */
112 export async function saySubmit(ctx: Ctx): Promise<Response> {
113 const repo = loadRepo(ctx);
114 if (!repo) return notFoundPage(ctx);
115 if (!ctx.auth.user) return signInFirst(ctx);
116 const form = await readForm(ctx, "rw");
117 if (form instanceof Response) return form;
118 const base = repoBase(repo);
119 const number = Number(ctx.params.number);
120 const pull = Number.isInteger(number)
121 ? getPull(ctx.app.db, repo.id, number)
122 : undefined;
123 if (!pull) return notFoundPage(ctx);
124 const text = field(form, "text").trim();
125 if (text === "")
126 return flashTo(ctx, base, {
127 tone: "danger",
128 message: "Say something first.",
129 });
130 const said = addComment(ctx.app, ctx.auth.user, repo, pull, text);
131 return flashOrJson(ctx, base, {
132 ...(said.ok
133 ? { message: `Told ${pull.authorName}. It picks the change back up.` }
134 : { tone: "danger" as const, message: "That could not be said." }),
135 });
136 }
137
138 /**
139 * POST /studio/asks { ask*, action, back } — one decision for several asks.
140 * Each is the same act as its row's own button. An ask that is gone, or
141 * never took this action, is skipped and counted, never guessed at.
142 */
143 export async function asksSubmit(ctx: Ctx): Promise<Response> {
144 const user = ctx.auth.user;
145 if (!user) return signInFirst(ctx);
146 const form = await readForm(ctx, "rw");
147 if (form instanceof Response) return form;
148 const back = backTo(ctx, field(form, "back"));
149 const action = field(form, "action");
150 const picked = [
151 ...new Set(
152 form.getAll("ask").filter((v): v is string => typeof v === "string"),
153 ),
154 ];
155 if (!isBulkAction(action) || picked.length === 0)
156 return flashOrJson(ctx, back, {
157 tone: "danger",
158 message: "Pick some asks, and what to do with them.",
159 });
160 // The asks as they stand now, not as the page had them.
161 const asks = new Map(
162 decisionsFor(ctx, appsOf(ctx, user).repos).map((d) => [d.id, d]),
163 );
164 const done: string[] = [];
165 const skipped: string[] = [];
166 const failed: string[] = [];
167 for (const id of picked) {
168 const d = asks.get(id);
169 if (d?.pull === undefined || !takes(d, action)) {
170 skipped.push(id);
171 continue;
172 }
173 const r =
174 action === "ship"
175 ? await mergePull(ctx.app, user, d.repo, d.pull, "squash")
176 : await setIssueStateThrough(ctx.app, user, d.repo, d.pull, "closed");
177 (r.ok ? done : failed).push(id);
178 }
179 const tally = [`${BULK[action].done} ${done.length}`];
180 if (skipped.length > 0) tally.push(`skipped ${skipped.length}`);
181 if (failed.length > 0) tally.push(`could not ${action} ${failed.length}`);
182 return flashOrJson(
183 ctx,
184 back,
185 {
186 ...(done.length === 0 ? { tone: "danger" as const } : {}),
187 message: tally.join(" · "),
188 },
189 { done, skipped, failed },
190 );
191 }
192
193 /** Where a form sends a person back: what it said, else the referer, else
194 * home — and never off this forge. */
195 function backTo(ctx: Ctx, said: string): string {
196 const back = said || ctx.request.headers.get("referer") || "/";
197 return (back.startsWith("/") && !back.startsWith("//")) ||
198 back.startsWith(`${ctx.url.origin}/`)
199 ? back
200 : "/";
201 }
202
203 /** POST /mode { mode } — studio or dev, for this browser. */
204 export async function modeSubmit(ctx: Ctx): Promise<Response> {
205 const form = await readForm(ctx);
206 if (form instanceof Response) return form;
207 const choice = field(form, "mode");
208 const secure = ctx.url.protocol === "https:";
209 return redirect(
210 backTo(ctx, field(form, "back")),
211 modeCookie(isMode(choice) ? choice : undefined, secure),
212 );
213 }
src/web/studio/app.ts added +410
@@ -0,0 +1,410 @@
1 /**
2 * The app in the studio: one page per app, and the front door a person acts
3 * from — the box they tell the crew in, what asks them, and Now, one row per
4 * thing the crew has in hand in the studio's own words. Money, the crew
5 * roster, autonomy and the decision ledger are the owner's facts and render
6 * only for a viewer who can write here; a stranger sees the app.
7 */
8
9 import { appsRuntime } from "../../apps/runtime.ts";
10 import { listCrewRoles } from "../../core/agents.ts";
11 import type { AppRecord } from "../../core/apps.ts";
12 import { decisionStats, type DecisionStat } from "../../core/decisions.ts";
13 import { getIssueById, listIssues, type Issue } from "../../core/issues.ts";
14 import { listPulls, openPullsClosing } from "../../core/pulls.ts";
15 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
16 import { latestRunsForIssues, listRuns } from "../../core/runs.ts";
17 import { getUsersByIds } from "../../core/users.ts";
18 import { autonomyStatus } from "../../crew/autonomy.ts";
19 import { repoDir } from "../../git/repos.ts";
20 import type { Ctx } from "../../http/context.ts";
21 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
22 import { asksJs, liveRegionsJs } from "../client.ts";
23 import { html, when, type Html } from "../html.ts";
24 import { page } from "../layout.ts";
25 import { liveClientJs, liveMount } from "../live.ts";
26 import {
27 avatar,
28 btn,
29 card,
30 icon,
31 listCard,
32 row,
33 time,
34 userTag,
35 } from "../ui.ts";
36 import { asksPanel, decisionsFor } from "./asks.ts";
37 import { withLikely } from "./leans.ts";
38 import { appPanels } from "./previews.ts";
39 import { stopForm } from "./run.ts";
40 import { studioCss } from "./style.ts";
41 import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts";
42
43 /**
44 * The week's decisions in one line: how many, how many the decider took
45 * rather than the rule, and what came of the ones that are known.
46 * "12 · 11 by typesafe · shipped 5, shown 2, stopped 1"
47 */
48 function decisionWords(stats: readonly DecisionStat[]): Html {
49 const n = stats.reduce((s, k) => s + k.n, 0);
50 const decided = stats.reduce((s, k) => s + k.decided, 0);
51 const by = stats
52 .flatMap((k) => Object.entries(k.outcomes))
53 .reduce<Record<string, number>>((acc, [o, c]) => {
54 acc[o] = (acc[o] ?? 0) + c;
55 return acc;
56 }, {});
57 const outcomes = Object.entries(by)
58 .sort((a, b) => b[1] - a[1])
59 .map(([o, c]) => `${o} ${c}`)
60 .join(", ");
61 return html`${n}
62 <span class="muted"
63 >· ${decided} by the
64 decider${outcomes === "" ? "" : ` · ${outcomes}`}</span
65 >`;
66 }
67
68 export async function studioApp(
69 ctx: Ctx,
70 repo: Repo,
71 record: AppRecord,
72 ): Promise<Response> {
73 const db = ctx.app.db;
74 const base = repoBase(repo);
75 // Money, the crew roster, autonomy limits and the decision ledger are the
76 // owner's facts, not the app's — a viewer who cannot write here reads this
77 // page as a stranger, whatever else they can see of the repository.
78 const ownerFacts = canSeeOwnerFacts(db, repo, ctx.auth.user);
79 const wordCtx: WordCtx = {
80 db,
81 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
82 runtime: appsRuntime(ctx.app),
83 record,
84 };
85 const crew = listCrewRoles(db, repo.id);
86 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
87 const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 });
88 const actors = getUsersByIds(
89 db,
90 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
91 );
92 const issues = new Map<string, Issue>();
93 for (const r of runs)
94 if (r.issueId !== null && !issues.has(r.issueId)) {
95 const i = getIssueById(db, r.issueId);
96 if (i) issues.set(r.issueId, i);
97 }
98 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
99 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
100 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
101 const shipped = listPulls(db, repo.id, {
102 state: "closed",
103 limit: 100,
104 }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length;
105 // The rest is spend, worked out only for a viewer who is going to see it.
106 const weekRuns = ownerFacts
107 ? listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
108 (r) => r.createdAt >= week,
109 )
110 : [];
111 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
112 const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : [];
113 const autonomy =
114 !ownerFacts || repo.autonomy === "off"
115 ? undefined
116 : autonomyStatus(ctx.app, repo);
117
118 // ---- the app itself ----
119 const { head, previews } = appPanels(ctx, repo, record, openPulls);
120
121 // ---- the box ----
122 // Telling the crew, and who takes it, is the owner's own front door — a
123 // stranger sees the app, not who is behind it.
124 const tell = !ownerFacts
125 ? null
126 : html`<form
127 class="card comment-form studio-tell"
128 method="post"
129 action="${base}/tell"
130 >
131 <label class="sr-only" for="tell">Tell the crew</label>
132 <textarea
133 class="input"
134 id="tell"
135 name="text"
136 rows="3"
137 required
138 placeholder="${builders.length === 0
139 ? "Add a crew member under Settings → Crew, then tell them what you want."
140 : "What should change? One thing at a time works best."}"
141 ></textarea>
142 <div class="bar">
143 <span class="muted small">
144 ${builders.length === 0
145 ? html`Nobody on the crew yet.`
146 : html`${builders
147 .map((b) => userTag(b.user.username))
148 .reduce((a, b) => html`${a} ${b}`)}
149 ${builders.length === 1 ? "takes it" : "share the work"}`}
150 </span>
151 <span class="spacer"></span>
152 ${btn("Tell the crew", { variant: "primary", type: "submit" })}
153 </div>
154 </form>`;
155
156 // ---- now: one row per thing, in the five words ----
157 const proposed = new Set(openPulls.map((p) => p.id));
158 const openByIssue = openPullsClosing(db, repo.id);
159 const told = listIssues(db, repo.id, {
160 kind: "issue",
161 state: "open",
162 limit: 30,
163 }).filter((i) => i.assigneeIsAgent && !openByIssue.has(i.number));
164 const everRan = latestRunsForIssues(
165 db,
166 told.map((i) => i.id),
167 ["agent"],
168 );
169 const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5);
170 const issueNumberById = new Map(told.map((i) => [i.id, i.number]));
171 const rowsNow = runs
172 .filter((r) => {
173 if (r.issueId === null) return true;
174 if (proposed.has(r.issueId)) return false;
175 const n = issueNumberById.get(r.issueId) ?? issues.get(r.issueId)?.number;
176 return n === undefined || !openByIssue.has(n);
177 })
178 .filter(
179 (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx,
180 )
181 .slice(0, 6);
182 // Who is on the crew is an owner's fact same as its roster card — a
183 // viewer who cannot write here reads "Now" as the app's own story, never
184 // by the agent's name.
185 const nameFor = (name: string): string => (ownerFacts ? name : "the crew");
186 const nowRows: Html[] = [
187 ...(await Promise.all(
188 openPulls.map(async (p) => {
189 const { word, note } = await pullWord(wordCtx, p);
190 const who = nameFor(p.authorName);
191 return row({
192 href: `${base}/pulls/${p.number}`,
193 nav: true,
194 lead: avatar(who),
195 title: p.title,
196 meta: html`${who} · ${note} · ${time(p.createdAt)}`,
197 trail: wordBadge(word),
198 });
199 }),
200 )),
201 ...(await Promise.all(
202 rowsNow.map(async (r) => {
203 const { word, note } = await runWord(wordCtx, r);
204 const who = nameFor(
205 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
206 );
207 const about = r.issueId === null ? undefined : issues.get(r.issueId);
208 const stop = stopForm(ctx, repo, r);
209 return row({
210 ...(stop ? {} : { href: `${base}/runs/${r.id}`, nav: true }),
211 lead: avatar(who),
212 title: stop
213 ? html`<a href="${base}/runs/${r.id}">${about?.title ?? note}</a>`
214 : (about?.title ?? note),
215 meta: html`${who} · ${word === "working" ? "working now" : note}
216 ${word === "working"
217 ? null
218 : html` · ${time(r.finishedAt ?? r.createdAt)}`}`,
219 trail: stop
220 ? html`<span class="studio-ask-actions"
221 >${wordBadge(word)} ${stop}</span
222 >`
223 : wordBadge(word),
224 });
225 }),
226 )),
227 ...waiting.map((i) => {
228 const who = nameFor(i.assigneeName ?? "crew");
229 return row({
230 href: `${base}/issues/${i.number}`,
231 nav: true,
232 lead: avatar(who),
233 title: i.title,
234 meta: html`${who} · told ${time(i.createdAt)}, not picked up
235 yet`,
236 trail: wordBadge("waiting"),
237 });
238 }),
239 ];
240 const now =
241 nowRows.length === 0
242 ? card(
243 html`<p class="muted small">
244 Nothing yet. Tell the crew something.
245 </p>`,
246 {
247 head: html`${icon("activity")}
248 <h2>Now</h2>`,
249 },
250 )
251 : listCard(nowRows, {
252 head: html`${icon("activity")}
253 <h2>Now</h2>
254 <span class="spacer"></span>
255 ${btn("Every run", { href: `${base}/runs`, size: "sm" })}`,
256 });
257
258 const asks = when(decisions.length > 0, () =>
259 asksPanel(ctx, decisions, base),
260 );
261
262 // ---- the rail ----
263 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
264 // write here gets only the shipped count, never the roster, the money or
265 // the ledger — and the page never asks for the rest on their behalf.
266 const crewCard = !ownerFacts
267 ? null
268 : crew.length === 0
269 ? card(
270 html`<p class="muted small">
271 Nobody yet. <a href="${base}/settings/crew">Add someone.</a>
272 </p>`,
273 {
274 head: html`${icon("bot")}
275 <h2>Crew</h2>`,
276 },
277 )
278 : listCard(
279 crew.map((c) =>
280 row({
281 lead: avatar(c.agent.user.username),
282 title: c.agent.user.username,
283 meta: html`${c.role === "builder"
284 ? "builds"
285 : c.role === "planner"
286 ? "plans"
287 : "reviews"}`,
288 }),
289 ),
290 {
291 head: html`${icon("bot")}
292 <h2>Crew</h2>
293 <span class="spacer"></span>
294 ${btn("Change", { href: `${base}/settings/crew`, size: "sm" })}`,
295 },
296 );
297 const weekCard = card(
298 !ownerFacts
299 ? html`<dl>
300 <dt>Shipped</dt>
301 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
302 </dl>`
303 : html`<dl>
304 <dt>Shipped</dt>
305 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
306 <dt>Runs</dt>
307 <dd>${weekRuns.length}</dd>
308 <dt>Spent</dt>
309 <dd>
310 $${spent.toFixed(2)}${shipped > 0
311 ? html` <span class="muted"
312 >· $${(spent / shipped).toFixed(2)} per shipped change</span
313 >`
314 : null}
315 </dd>
316 ${when(
317 ledgerWeek.length > 0,
318 () =>
319 html`<dt>Decided</dt>
320 <dd>${decisionWords(ledgerWeek)}</dd>`,
321 )}
322 </dl>`,
323 {
324 head: html`${icon("activity")}
325 <h2>This week</h2>`,
326 extra: "about",
327 },
328 );
329 const ownCard = !ownerFacts
330 ? null
331 : card(
332 autonomy === undefined
333 ? html`<p class="muted small">
334 Off. <a href="${base}/settings/autonomy">Turn it on</a> and the
335 crew works what is ready without being asked.
336 </p>`
337 : html`<dl>
338 <dt>Mode</dt>
339 <dd>
340 ${repo.autonomy === "autonomous" ? "Autonomous" : "Assisted"}
341 </dd>
342 <dt>Today</dt>
343 <dd>
344 $${autonomy.spentTodayUsd.toFixed(2)} of
345 $${autonomy.ceilingUsd.toFixed(0)}
346 </dd>
347 <dt>Slots</dt>
348 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
349 ${autonomy.paused
350 ? html`<dt>Paused</dt>
351 <dd>
352 ${autonomy.paused === "ceiling"
353 ? "at the ceiling, until 00:00 UTC"
354 : autonomy.paused}
355 </dd>`
356 : null}
357 </dl>`,
358 {
359 head: html`${icon("play")}
360 <h2>On its own</h2>
361 <span class="spacer"></span>
362 ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`,
363 extra: "about",
364 },
365 );
366 const more = html`<p class="muted small">
367 <a href="${base}/map">The map</a> ·
368 <a href="${base}/issues">Everything told</a> ·
369 <a href="${base}/pulls">Every change</a>
370 </p>`;
371
372 const body = html`${repoHeader(ctx, repo, "code", {
373 compact: true,
374 ownTitle: true,
375 })}
376 <div id="app-head">${head}</div>
377 <div class="dash">
378 <div class="dash-main stack">
379 ${tell}
380 <div
381 id="live-asks"
382 data-live="run.*,pull.*,issue.*,check.*"
383 data-live-repo="${repo.ownerName}/${repo.name}"
384 >
385 ${asks}
386 </div>
387 <div id="app-previews">${previews}</div>
388 <div
389 id="live-now"
390 data-live="run.*,pull.*,issue.*"
391 data-live-repo="${repo.ownerName}/${repo.name}"
392 >
393 ${now}
394 </div>
395 </div>
396 <aside class="dash-rail stack">
397 ${crewCard} ${weekCard} ${ownCard} ${more}
398 </aside>
399 </div>
400 ${liveMount({ repo: base, app: true })}`;
401 return page({
402 ctx,
403 title: `${repo.name}`,
404 crumbs: crumbsFor(repo),
405 palette: repoPalette(repo),
406 body,
407 styleCss: studioCss,
408 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
409 });
410 }
src/web/studio/asks.ts added +369
@@ -0,0 +1,369 @@
1 /**
2 * What asks a person, and what they can do about it: a *decision* is one
3 * card — a pull the crew stopped on, a green pull waiting for a look, or a
4 * repository parked at its ceiling — read from the same tables the rest of
5 * the forge reads, with the same access the owner has. This module owns the
6 * card, the row, the panel of them and the bulk bar; every button here posts
7 * to the route a person could have posted to by hand (`actions.ts`).
8 */
9
10 import { previewEnv } from "../../apps/data.ts";
11 import { appsForRepos, getAppByRepo } from "../../core/apps.ts";
12 import { latestDecision } from "../../core/decisions.ts";
13 import {
14 listComments,
15 listOpenAgentPullsVisibleTo,
16 type Issue,
17 } from "../../core/issues.ts";
18 import { looksForApps } from "../../core/looks.ts";
19 import { joinMany, type Pull } from "../../core/pulls.ts";
20 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
21 import { listReviews } from "../../core/reviews.ts";
22 import {
23 activeRunFor,
24 automaticRunsSince,
25 latestRunsForIssues,
26 } from "../../core/runs.ts";
27 import { autonomyStatus } from "../../crew/autonomy.ts";
28 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
29 import type { Ctx } from "../../http/context.ts";
30 import { repoBase } from "../chrome.ts";
31 import { html, when, type Html } from "../html.ts";
32 import { btn, card, icon, row } from "../ui.ts";
33 import { lookThumb, lookUrl } from "../pages/looks.ts";
34 import { leanNote, leanVariant } from "./leans.ts";
35
36
37 export type DecisionKind = "stopped" | "look" | "paused";
38
39 /** Dollars as a person reads them: cents when small, whole when not. */
40 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
41
42 export interface Decision {
43 readonly kind: DecisionKind;
44 /** Stable across renders — the kind and what it is about — so a form can
45 * name it and a later request can find it again. */
46 readonly id: string;
47 readonly repo: Repo;
48 readonly pull?: Pull | undefined;
49 readonly who: string;
50 /** Three sentences at most: what, why, what happens next. */
51 readonly says: string;
52 /** The same in one clause, for a row. */
53 readonly short: string;
54 /** The option the decider thinks is right, and how sure; none for a rule. */
55 readonly likely?: { readonly option: string; readonly p: number } | undefined;
56 /** The preview pictured on a phone, when the forge has taken one. */
57 readonly picture?: string | undefined;
58 }
59
60 /** When a person last spoke on a conversation, or its start. */
61 function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string {
62 let latest = issue.createdAt;
63 for (const c of listComments(ctx.app.db, issue.id))
64 if (c.authorName !== agentName && c.createdAt > latest)
65 latest = c.createdAt;
66 for (const r of listReviews(ctx.app.db, issue.id))
67 if (r.authorName !== agentName && r.createdAt > latest)
68 latest = r.createdAt;
69 return latest;
70 }
71
72 /**
73 * The crew's open pulls the viewer may land, sorted into what asks them:
74 * stopped (red past the crew's own limit since a person last spoke), or
75 * green and waiting for a look. Plus a repository paused at its ceiling.
76 */
77 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
78 const db = ctx.app.db;
79 const out: Decision[] = [];
80 const user = ctx.auth.user;
81 if (!user) return out;
82 // Shipping, closing and the day's ceiling are the owner's own decisions —
83 // a reader who cannot write here is never asked to make one, and never
84 // sees the dollar figure a "paused" card would otherwise carry.
85 const repos = allRepos.filter((r) => canSeeOwnerFacts(db, r, user));
86 const byId = new Map(repos.map((r) => [r.id, r]));
87 const pulls = listOpenAgentPullsVisibleTo(db, user, 50).filter((p) =>
88 byId.has(p.repoId),
89 );
90 const checks = latestRunsForIssues(
91 db,
92 pulls.map((p) => p.id),
93 );
94 const joined = new Map(joinMany(db, pulls).map((p) => [p.id, p]));
95 // The pictures, in one read: a green pull's row leads with its preview.
96 const records = appsForRepos(db, [...byId.keys()]);
97 const looks = looksForApps(
98 db,
99 [...records.values()].map((r) => r.id),
100 );
101 const pictureOf = (repo: Repo, pull: Pull): string | undefined => {
102 const record = records.get(repo.id);
103 if (record === undefined) return undefined;
104 const env = previewEnv(pull.number);
105 const look = looks.find(
106 (l) =>
107 l.appId === record.id &&
108 l.env === env &&
109 l.kind === "phone" &&
110 l.error === null,
111 );
112 return look && lookUrl(repoBase(repo), env, look.commitOid, "phone");
113 };
114 for (const issue of pulls) {
115 const repo = byId.get(issue.repoId)!;
116 const pull = joined.get(issue.id);
117 if (!pull) continue;
118 const check = checks.get(issue.id);
119 const who = issue.authorName;
120 // The crew is on it: that is working, not a question for a person.
121 if (activeRunFor(db, "agent", issue.id) !== undefined) continue;
122 if (check?.state === "failed") {
123 const since = lastHumanWord(ctx, issue, who);
124 const tries = automaticRunsSince(db, issue.id, since);
125 const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop";
126 // A repository on its own parks what it cannot finish and moves on;
127 // only an assisted one stops here for a person.
128 if (
129 (tries >= CHECK_FAILED_LIMIT || stopped) &&
130 repo.autonomy !== "autonomous"
131 )
132 out.push({
133 kind: "stopped",
134 id: `stopped:${pull.id}`,
135 repo,
136 pull,
137 who,
138 says: stopped
139 ? `${who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.`
140 : `${who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`,
141 short: stopped
142 ? `the check failed; the decider stopped for you`
143 : `${who} tried ${tries + 1} times; the check still fails`,
144 });
145 } else if (check?.state === "ok" && repo.autonomy !== "autonomous") {
146 // Autonomous repositories look at their own green pulls: the decider,
147 // then the crew's reviewer. Everything else waits for a person.
148 const picture = pictureOf(repo, pull);
149 out.push({
150 kind: "look",
151 id: `look:${pull.id}`,
152 repo,
153 pull,
154 who,
155 says: `${who} finished "${pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`,
156 short: `${who} finished it; the check is green`,
157 ...(picture === undefined ? {} : { picture }),
158 });
159 }
160 }
161 for (const repo of repos) {
162 if (repo.autonomy === "off") continue;
163 const s = autonomyStatus(ctx.app, repo);
164 if (s.paused === "ceiling")
165 out.push({
166 kind: "paused",
167 id: `paused:${repo.id}`,
168 repo,
169 who: "the crew",
170 says: `${repo.name} reached its $${dollars(s.ceilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`,
171 short: `at its $${dollars(s.ceilingUsd)} ceiling until 00:00 UTC`,
172 });
173 }
174 return out;
175 }
176 // ---- deciding several at once ----
177
178 /**
179 * What a person can decide for several asks in one go: an ask's own landing
180 * button, applied to each. Only what needs no words qualifies — saying
181 * something happens on the change's page. The page shows an action only
182 * while every checked ask takes it; the route skips one that does not.
183 */
184 export type BulkAction = "ship" | "close";
185
186 export const BULK: Readonly<
187 Record<
188 BulkAction,
189 {
190 readonly kinds: readonly DecisionKind[];
191 readonly label: string;
192 readonly done: string;
193 }
194 >
195 > = {
196 ship: { kinds: ["look"], label: "Ship", done: "Shipped" },
197 close: { kinds: ["stopped"], label: "Close", done: "Closed" },
198 };
199 const BULK_ACTIONS = Object.keys(BULK) as readonly BulkAction[];
200 export const isBulkAction = (v: string): v is BulkAction =>
201 Object.hasOwn(BULK, v);
202 export const takes = (d: Decision, a: BulkAction): boolean =>
203 BULK[a].kinds.includes(d.kind);
204
205 /** The id of the form a row's own button submits (see `decisionForm`). */
206 const askFormId = (d: Decision): string => `ask-${d.id}`;
207
208 /**
209 * The form behind a row's landing button — its own post, exactly as it was.
210 * It sits after the panel's form, because a form inside a form is not
211 * HTML; the button reaches it by id.
212 */
213 export function decisionForm(d: Decision): Html | null {
214 const pull = d.pull;
215 if (pull === undefined) return null;
216 const base = repoBase(d.repo);
217 const own = html`id="${askFormId(d)}" method="post" hidden`;
218 if (d.kind === "stopped")
219 return html`<form ${own} action="${base}/pulls/${pull.number}/state">
220 <input type="hidden" name="state" value="closed" />
221 </form>`;
222 if (d.kind === "look")
223 return html`<form ${own} action="${base}/pulls/${pull.number}/ship">
224 <input type="hidden" name="pull" value="${pull.number}" />
225 </form>`;
226 return null;
227 }
228
229 /**
230 * The actions on one ask, on one line: a button for what lands (submitting
231 * the row's own form, `decisionForm`), links for what needs a look or a
232 * word. Saying something to the crew happens on the change's own page,
233 * where the conversation is — not in a box in a list.
234 */
235 export function decisionActions(ctx: Ctx, d: Decision): Html {
236 const base = repoBase(d.repo);
237 const pull = d.pull;
238 const preview =
239 pull === undefined
240 ? undefined
241 : getAppByRepo(ctx.app.db, d.repo.id) === undefined
242 ? undefined
243 : `${base}/app/pull-${pull.number}/open`;
244 if (d.kind === "stopped" && pull)
245 return html`${btn("Tell them", {
246 href: `${base}/pulls/${pull.number}#comment-body`,
247 size: "sm",
248 ...leanVariant(d, "say", "primary"),
249 })}${leanNote(d, "say")}
250 ${btn("Close", {
251 size: "sm",
252 form: askFormId(d),
253 ...leanVariant(d, "close", undefined),
254 })}${leanNote(d, "close")}
255 ${btn("See", {
256 href: `${base}/pulls/${pull.number}`,
257 size: "sm",
258 ...leanVariant(d, "look", undefined),
259 })}${leanNote(d, "look")}`;
260 if (d.kind === "look" && pull)
261 return html`${btn("Ship", {
262 size: "sm",
263 form: askFormId(d),
264 ...leanVariant(d, "ship", "primary"),
265 })}${leanNote(d, "ship")}
266 ${when(
267 preview,
268 (href) =>
269 html`${btn("Preview", {
270 href,
271 size: "sm",
272 ...leanVariant(d, "preview", undefined),
273 })}${leanNote(d, "preview")}`,
274 )}
275 ${btn("Not like this", {
276 href: `${base}/pulls/${pull.number}#comment-body`,
277 size: "sm",
278 ...leanVariant(d, "say", undefined),
279 })}${leanNote(d, "say")}`;
280 return html`${btn("Settings", {
281 href: `${base}/settings/autonomy`,
282 size: "sm",
283 })}`;
284 }
285
286 /** One decision as a row: a box to pick it, what it says, the options in place. */
287 function decisionRow(ctx: Ctx, d: Decision): Html {
288 const base = repoBase(d.repo);
289 const glyph =
290 d.kind === "stopped" ? "alert" : d.kind === "look" ? "eye" : "clock";
291 // The title links; the row itself does not, because it holds controls
292 // and a control inside an anchor is not HTML a browser will keep together.
293 const href = d.pull ? `${base}/pulls/${d.pull.number}` : base;
294 const title = html`<a href="${href}">${d.pull?.title ?? d.repo.name}</a>`;
295 return row({
296 lead: html`<input
297 class="studio-ask-pick"
298 type="checkbox"
299 name="ask"
300 value="${d.id}"
301 data-kind="${d.kind}"
302 data-repo="${base}"
303 aria-label="Select: ${d.pull?.title ?? d.repo.name}"
304 />${d.picture === undefined ? icon(glyph) : lookThumb(href, d.picture)}`,
305 title,
306 titleAttr: d.says,
307 meta: html`${d.repo.name}${d.pull ? html` #${d.pull.number}` : null} ·
308 ${d.short}`,
309 trail: html`<span class="studio-ask-actions"
310 >${decisionActions(ctx, d)}</span
311 >`,
312 });
313 }
314
315 /**
316 * The asks as one form. Every row has a box; check any and a bar appears
317 * with what fits them all, posting to /studio/asks. A row's own button
318 * submits that row's form, placed after this one — nothing is nested, and
319 * one decision is exactly the post it was before.
320 */
321 export function asksPanel(
322 ctx: Ctx,
323 decisions: readonly Decision[],
324 back: string,
325 ): Html {
326 const bar = html`<div class="studio-bulk">
327 <output class="studio-bulk-count">Selected</output>
328 ${BULK_ACTIONS.map((a) =>
329 btn(BULK[a].label, { size: "sm", name: "action", value: a }),
330 )}
331 <span class="muted studio-bulk-none">no one action fits them all</span>
332 </div>`;
333 return html`<div class="studio-asks">
334 <form method="post" action="/studio/asks">
335 <input type="hidden" name="back" value="${back}" />
336 ${card(
337 html`${bar}
338 <ul class="list">
339 ${decisions.map((d) => decisionRow(ctx, d))}
340 </ul>`,
341 {
342 head: html`${icon("alert")}
343 <h2>Asks you</h2>
344 <span class="muted small">decide, and the crew carries on</span>`,
345 pad: false,
346 },
347 )}
348 </form>
349 ${decisions.map(decisionForm)}
350 </div>`;
351 }
352
353 /**
354 * An action in the bar shows only while every checked ask takes it, and a
355 * note when none does — from the same table the route enforces, so the
356 * page cannot offer what the server would skip. CSS alone: it holds with
357 * scripts off.
358 */
359 export const bulkCss = ((): string => {
360 const other = (a: BulkAction): string =>
361 `[name="ask"]${BULK[a].kinds.map((k) => `:not([data-kind="${k}"])`).join("")}:checked`;
362 return [
363 ...BULK_ACTIONS.map(
364 (a) =>
365 `.studio-asks:has(${other(a)}) [name="action"][value="${a}"] { display: none; }`,
366 ),
367 `.studio-asks${BULK_ACTIONS.map((a) => `:has(${other(a)})`).join("")} .studio-bulk-none { display: inline; }`,
368 ].join("\n");
369 })();
src/web/studio/change.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * One change in the studio: a pull is judged by what it does, not by its
3 * diff — the title and the author's own words, the check and the preview in
4 * the studio's five words, the look when one has been taken, the same
5 * decision the asks panel would offer, and the conversation. No diff is
6 * computed here, so the page never gets slower as a change gets bigger;
7 * `?mode=dev` reads the code, unchanged (`pages/pull.ts`).
8 */
9
10 import { appsRuntime } from "../../apps/runtime.ts";
11 import type { AppRecord } from "../../core/apps.ts";
12 import type { Pull } from "../../core/pulls.ts";
13 import type { Repo } from "../../core/repos.ts";
14 import { checkSummary } from "../../core/runs.ts";
15 import { repoDir } from "../../git/repos.ts";
16 import type { Ctx } from "../../http/context.ts";
17 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
18 import { html, when } from "../html.ts";
19 import { page } from "../layout.ts";
20 import { liveClientJs, liveMount } from "../live.ts";
21 import {
22 card,
23 icon,
24 originTag,
25 stateBadge,
26 time,
27 userLink,
28 } from "../ui.ts";
29 import { previewCard } from "../pages/apps.ts";
30 import { lookCard } from "../pages/looks.ts";
31 import { pullState } from "../pages/pulls.ts";
32 import { composer, conversation, originGlyph } from "../pages/thread.ts";
33 import { decisionActions, decisionForm, decisionsFor } from "./asks.ts";
34 import { studioCss } from "./style.ts";
35 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
36
37 /**
38 * GET /:owner/:name/pulls/:number in studio mode: a change is judged by
39 * what it does, not by its diff. The title and the author's own words (the
40 * opening post, same as dev), who made it and when, the check and the
41 * preview in the studio's five words, the look when one has been taken, the
42 * same decision the asks panel would offer, and the conversation — with the
43 * diff exactly one click away at `?mode=dev`. No diff is computed here, so
44 * this never gets slower as a change gets bigger.
45 */
46 export async function studioPull(
47 ctx: Ctx,
48 repo: Repo,
49 pull: Pull,
50 record: AppRecord,
51 ): Promise<Response> {
52 const db = ctx.app.db;
53 const base = repoBase(repo);
54 const thread = `${base}/pulls/${pull.number}`;
55 const wordCtx: WordCtx = {
56 db,
57 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
58 runtime: appsRuntime(ctx.app),
59 record,
60 };
61 const { word, note } = await pullWord(wordCtx, pull);
62 // Before a merge the deploy worth linking is the preview's own check;
63 // after, it is production's — the preview's run may already be torn
64 // down, and the word above is judged by production regardless.
65 let deploy: { id: string; commitOid: string } | undefined;
66 if (pull.mergedAt === null) {
67 const run = checkSummary(db, pull.id, pull.headOid).run;
68 if (run) deploy = { id: run.id, commitOid: run.commitOid };
69 } else {
70 const production = wordCtx.runtime
71 ?.status(record)
72 .find((e) => e.env === "production");
73 if (production?.runId)
74 deploy = { id: production.runId, commitOid: production.commitOid };
75 }
76 const decision = decisionsFor(ctx, [repo]).find(
77 (d) => d.pull?.id === pull.id,
78 );
79
80 const head = html`<div class="issue-head">
81 <h1>${pull.title} <span class="number">#${pull.number}</span></h1>
82 <div class="issue-meta">
83 ${stateBadge(pullState(pull))}
84 <span
85 >${pull.originAuthor
86 ? originTag(pull.originAuthor, originGlyph(repo))
87 : userLink(pull.authorName)}
88 opened this ${time(pull.createdAt)}</span
89 >
90 ${when(
91 pull.originUrl,
92 (url) =>
93 html`<a class="origin-link" href="${url}" rel="noreferrer noopener"
94 >${icon("external")}view on origin</a
95 >`,
96 )}
97 </div>
98 </div>`;
99
100 const checkLine = card(
101 html`<p class="studio-pull-check">
102 ${wordBadge(word)} <span class="muted small">${note}</span>
103 ${when(
104 deploy,
105 (d) =>
106 html`<span class="where-sep" aria-hidden="true">·</span
107 ><a href="${base}/runs/${d.id}"
108 >deploy ${d.commitOid.slice(0, 7)}</a
109 >`,
110 )}
111 </p>`,
112 );
113
114 const decisionSection = when(decision, (d) =>
115 card(
116 html`<p>${d.says}</p>
117 <div class="bar mt">${decisionActions(ctx, d)}</div>`,
118 {
119 head: html`${icon("alert")}
120 <h2>Decide</h2>`,
121 },
122 ),
123 );
124
125 const talk = conversation(ctx, repo, pull, { openVerb: "opened this" });
126
127 const body = html`${repoHeader(ctx, repo, "pulls", {
128 compact: true,
129 ownTitle: true,
130 })}
131 ${head} ${checkLine} ${decisionSection}
132 ${lookCard(ctx, repo, record, pull, pull.headOid)}
133 <div class="split">
134 <div>
135 ${talk.node}
136 ${composer(ctx, {
137 action: `${thread}/comments`,
138 stateAction: `${thread}/state`,
139 state: pull.state,
140 locked: pull.mergedAt !== null,
141 kind: "pull",
142 })}
143 </div>
144 <aside>${previewCard(ctx, repo, pull)}</aside>
145 </div>
146 <p class="muted small mt">
147 <a href="${thread}?mode=dev">${icon("code")}Read the code</a> — every
148 file it changed, line by line.
149 </p>
150 ${decision ? decisionForm(decision) : null}
151 ${liveMount({
152 repo: base,
153 thread: "pull",
154 number: pull.number,
155 lastCommentId: talk.lastCommentId,
156 })}`;
157
158 return page({
159 ctx,
160 title: `${pull.title} · ${repo.name}`,
161 crumbs: crumbsFor(repo),
162 palette: repoPalette(repo),
163 body,
164 styleCss: studioCss,
165 scriptJs: liveClientJs,
166 });
167 }
src/web/studio/home.ts added +247
@@ -0,0 +1,247 @@
1 /**
2 * The home in the studio: what asks you first, then every app as one row
3 * carrying a real signal — live or not, what the crew last did there, how
4 * many things wait on you — and beside it what is happening right now across
5 * all of them, and what a person put on you. Built from the same rows and
6 * cards as the rest of the forge, from grouped reads only, so the page does
7 * not get slower as the apps grow.
8 */
9
10 import { appsRuntime } from "../../apps/runtime.ts";
11 import { appsForRepos } from "../../core/apps.ts";
12 import { canSeeOwnerFacts, listReposVisibleTo } from "../../core/repos.ts";
13 import { latestAgentRunsForRepos, listActiveRuns } from "../../core/runs.ts";
14 import { getUsersByIds, type User } from "../../core/users.ts";
15 import { repoDir } from "../../git/repos.ts";
16 import type { Ctx } from "../../http/context.ts";
17 import { repoBase } from "../chrome.ts";
18 import { asksJs, liveRegionsJs } from "../client.ts";
19 import { html, when } from "../html.ts";
20 import { page } from "../layout.ts";
21 import { liveClientJs } from "../live.ts";
22 import {
23 btn,
24 card,
25 dot,
26 empty,
27 icon,
28 listCard,
29 pill,
30 row,
31 time,
32 type Tone,
33 } from "../ui.ts";
34 import { onYouCard } from "../pages/site.ts";
35 import { asksPanel, decisionsFor } from "./asks.ts";
36 import { withLikely } from "./leans.ts";
37 import { stopForm } from "./run.ts";
38 import { studioCss } from "./style.ts";
39 import { runWord } from "./words.ts";
40
41 /**
42 * The home in the studio: what asks you first, then every app as one row
43 * that carries a real signal — live or not, what the crew last did there,
44 * how many things wait on you — and beside it what is happening right now
45 * across all of them, and what a person put on you. Built from the same
46 * rows and cards as the rest of the forge, so it reads as the forge.
47 */
48 export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
49 const db = ctx.app.db;
50 const { repos: appRepos, records } = appsOf(ctx, user);
51 const runtime = appsRuntime(ctx.app);
52 // A public app you can only read appears here too, but who is on its
53 // crew is its owner's fact, not yours to see — same predicate as the
54 // app's own page.
55 const ownerFacts = new Set(
56 appRepos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id),
57 );
58 // One read for the newest run of every app, one for who ran them: the
59 // home must not get slower as the apps grow.
60 const latestRuns = latestAgentRunsForRepos(
61 db,
62 appRepos.map((r) => r.id),
63 );
64 const actors = getUsersByIds(
65 db,
66 [...latestRuns.values()]
67 .map((r) => r.actorId)
68 .filter((id): id is string => id !== null),
69 );
70 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
71 const asksByRepo = new Map<string, number>();
72 for (const d of decisions)
73 asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1);
74
75 const appRows = await Promise.all(appRepos.map(async (repo) => {
76 const record = records.get(repo.id)!;
77 const envs = runtime === undefined ? [] : runtime.status(record);
78 const production = envs.find((e) => e.env === "production");
79 const state = production?.state ?? "stopped";
80 const latest = latestRuns.get(repo.id);
81 const word =
82 latest === undefined
83 ? undefined
84 : await runWord(
85 {
86 db,
87 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
88 runtime,
89 record,
90 },
91 latest,
92 );
93 const who =
94 latest?.actorId == null || !ownerFacts.has(repo.id)
95 ? undefined
96 : actors.get(latest.actorId)?.username;
97 const asks = asksByRepo.get(repo.id) ?? 0;
98 const tone: Tone =
99 state === "running"
100 ? "success"
101 : state === "failed"
102 ? "danger"
103 : "default";
104 const happening =
105 latest === undefined || word === undefined
106 ? html`<span class="muted">quiet — tell the crew something</span>`
107 : html`<span class="studio-word" data-word="${word.word}"
108 >${word.word}</span
109 >
110 ${who ? html`· ${who}` : null}
111 ${word.word === "working"
112 ? html`· now`
113 : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`;
114 return row({
115 href: repoBase(repo),
116 nav: true,
117 lead: dot(tone),
118 title: repo.name,
119 titleAttr: repo.description || repo.name,
120 meta: html`${repo.description
121 ? html`${repo.description} · `
122 : null}${happening}`,
123 trail: html`${asks > 0 ? pill(`${asks} asks you`, "accent") : null}
124 <span class="badge" data-tone="${tone}"
125 >${state === "running"
126 ? "live"
127 : state === "stopped"
128 ? "not running"
129 : state}</span
130 >`,
131 });
132 }));
133
134 const apps =
135 appRows.length === 0
136 ? empty({
137 icon: "rocket",
138 title: "No apps yet",
139 note: "Make one, tell the crew what it is, and watch it come up.",
140 action: btn("New app", { href: "/apps/new", variant: "primary" }),
141 })
142 : listCard(appRows, {
143 head: html`${icon("rocket")}
144 <h2>Your apps</h2>
145 <span class="spacer"></span>
146 ${btn("Map", { href: "/map", size: "sm", icon: "map" })}
147 ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}`,
148 });
149
150 const asks = when(decisions.length > 0, () => asksPanel(ctx, decisions, "/"));
151
152 // Happening now: every run in flight across the apps, one read.
153 const running = listActiveRuns(
154 db,
155 appRepos.map((r) => r.id),
156 );
157 const runActors = getUsersByIds(
158 db,
159 running.map((r) => r.actorId).filter((id): id is string => id !== null),
160 );
161 const byId = new Map(appRepos.map((r) => [r.id, r]));
162 // The board: the same runs as a wall for a second monitor.
163 const nowHead = html`${icon("activity")}
164 <h2>Happening now</h2>
165 <span class="spacer"></span>
166 ${btn("Focus", {
167 href: "/board",
168 size: "sm",
169 title: "The board: a live wall of the crews at work",
170 })}`;
171 const now =
172 running.length === 0
173 ? card(html`<p class="muted small">Nobody is working right now.</p>`, {
174 head: nowHead,
175 })
176 : listCard(
177 running.map((r) => {
178 const repo = byId.get(r.repoId)!;
179 const actorName =
180 r.actorId === null
181 ? "crew"
182 : (runActors.get(r.actorId)?.username ?? "crew");
183 const who = ownerFacts.has(repo.id) ? actorName : "the crew";
184 const stop = stopForm(ctx, repo, r);
185 return row({
186 ...(stop
187 ? {}
188 : { href: `${repoBase(repo)}/runs/${r.id}`, nav: true }),
189 lead: icon(r.state === "running" ? "loader" : "clock"),
190 title: html`<a href="${repoBase(repo)}/runs/${r.id}"
191 >${who} · ${repo.name}</a
192 >`,
193 meta: html`${r.state === "running"
194 ? "working"
195 : "waiting"}${r.startedAt
196 ? html` since ${time(r.startedAt)}`
197 : null}`,
198 ...(stop
199 ? {
200 trail: html`<span class="studio-ask-actions"
201 >${stop}</span
202 >`,
203 }
204 : {}),
205 });
206 }),
207 { head: nowHead },
208 );
209
210 const body = html`<div class="dash">
211 <div class="dash-main stack">
212 <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*">${asks}</div>
213 <div
214 id="live-apps"
215 data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged"
216 >
217 ${apps}
218 </div>
219 </div>
220 <aside class="dash-rail stack">
221 <div id="live-now" data-live="run.*">${now}</div>
222 <div
223 id="live-on-you"
224 data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged"
225 >
226 ${onYouCard(ctx, user) ?? null}
227 </div>
228 </aside>
229 </div>`;
230 return page({
231 ctx,
232 title: "Studio",
233 body,
234 styleCss: studioCss,
235 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
236 });
237 }
238
239 /** The apps a person can see, as repositories, with their records. */
240 export function appsOf(ctx: Ctx, user: User) {
241 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
242 const records = appsForRepos(
243 ctx.app.db,
244 repos.map((r) => r.id),
245 );
246 return { repos: repos.filter((r) => records.has(r.id)), records };
247 }
src/web/studio/leans.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * The decider's lean on an ask: which option a careful owner would pick,
3 * asked off the request path and remembered against the head it was about
4 * (`core/decisions.ts`, kind `lean`), so a page reads it with no wait and a
5 * forge with no decider does nothing at all. The lean only ever decorates an
6 * ask — it never makes the decision, and it never holds up a render.
7 */
8
9 import { latestLean, recordDecision } from "../../core/decisions.ts";
10 import { certain, decideOr, type Decider } from "../../crew/decide.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { createLog } from "../../kernel/log.ts";
13 import { html, type Html } from "../html.ts";
14 import type { Decision, DecisionKind } from "./asks.ts";
15
16 /** The options a card offers, by name, as the decider sees them. */
17 const CARD_OPTIONS: Record<DecisionKind, Readonly<Record<string, string>>> = {
18 stopped: {
19 say: "Tell the builder what to try; the check can pass with guidance.",
20 close: "Close it; the change is not worth more tries.",
21 look: "A person should read the change before deciding.",
22 },
23 look: {
24 ship: "Ship it; the change is green and small enough to land on its check.",
25 say: "Say what is off; something in it needs a change first.",
26 preview: "Open the preview first; it must be seen before shipping.",
27 },
28 paused: { settings: "Raise the ceiling.", wait: "Wait for midnight." },
29 };
30
31 const log = createLog("studio");
32
33 /** A pull's head oid, or "" for a card with no commit to key a lean on. */
34 const headOf = (d: Decision): string => d.pull?.headOid ?? "";
35
36 /** One background ask per card+head, so a page rendered by many people at
37 * once never fans out past one call per card. Cleared once answered. */
38 const leanInFlight = new Map<string, Promise<void>>();
39
40 /** A pause before asking, long enough that the response for the render
41 * which triggered the ask is on its way out first — the point is that the
42 * decider is never on the request's critical path, not that the ask is
43 * instant. */
44 const LEAN_ASK_DELAY_MS = 50;
45
46 /**
47 * Ask the decider which option on a card is likely right, off the request
48 * path — after this turn of the event loop, never inside it — and remember
49 * the answer keyed to the head it was about. A rule's answer is not worth
50 * remembering: it is what the page would have shown anyway.
51 */
52 function scheduleLean(ctx: Ctx, d: Decision, decider: Decider): void {
53 const head = headOf(d);
54 const key = `${d.id}@${head}`;
55 if (leanInFlight.has(key)) return;
56 const options = Object.keys(CARD_OPTIONS[d.kind]);
57 const state = {
58 card: d.kind,
59 head,
60 repository: `${d.repo.ownerName}/${d.repo.name}`,
61 situation: d.says,
62 ...(d.pull === undefined
63 ? {}
64 : { change: d.pull.title, description: d.pull.body.slice(0, 800) }),
65 };
66 const asked = new Promise<void>((resolve) => {
67 setTimeout(() => {
68 decideOr(
69 decider,
70 state,
71 {
72 option: {
73 options,
74 about: "Which of these a careful owner of the app would pick.",
75 describe: CARD_OPTIONS[d.kind],
76 },
77 },
78 () => ({ option: certain(options, options[0]!) }),
79 )
80 .then((decided) => {
81 if (decided.by === "rule") return;
82 recordDecision(ctx.app.db, {
83 kind: "lean",
84 repoId: d.repo.id,
85 issueId: d.pull?.id,
86 state,
87 answer: decided.answers.option.choice,
88 p: decided.answers.option.p,
89 by: decided.by,
90 ms: decided.ms,
91 });
92 })
93 .catch((error) =>
94 log.warn("lean not recorded", { id: d.id, error: String(error) }),
95 )
96 .finally(() => {
97 leanInFlight.delete(key);
98 resolve();
99 });
100 }, LEAN_ASK_DELAY_MS);
101 });
102 leanInFlight.set(key, asked);
103 }
104
105 /** Test hook: every ask scheduled so far, answered or not. The page itself
106 * never waits on this — only a test that wants the next render to have it. */
107 export function pendingLeans(): Promise<unknown> {
108 return Promise.allSettled([...leanInFlight.values()]);
109 }
110
111 /**
112 * Every card with the lean already known about it, read with no wait; a
113 * card whose lean is unknown, or whose head has moved past the one it was
114 * asked about, is asked for off the request path (`scheduleLean`) so a
115 * later render has it. A forge with no decider does nothing here, exactly
116 * as before.
117 */
118 export function withLikely(
119 ctx: Ctx,
120 decisions: readonly Decision[],
121 ): Decision[] {
122 const decider = ctx.app.config.decider;
123 if (decider === undefined) return [...decisions];
124 return decisions.map((d) => {
125 const lean = latestLean(
126 ctx.app.db,
127 d.repo.id,
128 d.pull?.id ?? null,
129 d.kind,
130 headOf(d),
131 );
132 if (lean === undefined) {
133 scheduleLean(ctx, d, decider);
134 return d;
135 }
136 return { ...d, likely: { option: lean.answer, p: lean.p } };
137 });
138 }
139 /** The variant a card's button takes: primary when the decider leans to it. */
140 export function leanVariant(
141 d: Decision,
142 option: string,
143 fallback: "primary" | undefined,
144 ): { variant?: "primary" } {
145 const variant =
146 d.likely === undefined
147 ? fallback
148 : d.likely.option === option
149 ? "primary"
150 : undefined;
151 return variant === undefined ? {} : { variant };
152 }
153
154 /** A word beside the leaned-to option: how sure, in plain terms. */
155 export function leanNote(d: Decision, option: string): Html | null {
156 if (d.likely === undefined || d.likely.option !== option) return null;
157 const sure =
158 d.likely.p >= 0.85 ? "likely" : d.likely.p >= 0.6 ? "probably" : "maybe";
159 return html`<span
160 class="muted small studio-lean"
161 title="${Math.round(d.likely.p * 100)}% by the decider"
162 >${sure}</span
163 >`;
164 }
src/web/studio/lists.ts added +365
@@ -0,0 +1,365 @@
1 /**
2 * The studio's three lists — everything told, every change, every run — and
3 * the one filter bar they share: five words, each a link carrying `?word=`,
4 * so what a person is looking at lives in the address and can be shared. A
5 * row is the same word, note and time the thing's own page shows, because
6 * every one of them is read through `tellWord`/`pullWord`/`runWord`. Each
7 * list is bounded to one page; `?mode=dev` reads dev's list, unchanged.
8 */
9
10 import { appsRuntime } from "../../apps/runtime.ts";
11 import type { AppRecord } from "../../core/apps.ts";
12 import { listIssues, type Issue } from "../../core/issues.ts";
13 import { listPulls, openPullsClosing } from "../../core/pulls.ts";
14 import type { Repo } from "../../core/repos.ts";
15 import { listRuns } from "../../core/runs.ts";
16 import { repoDir } from "../../git/repos.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
19 import { html, type Html } from "../html.ts";
20 import { page } from "../layout.ts";
21 import { liveClientJs, liveMount } from "../live.ts";
22 import {
23 avatar,
24 empty,
25 icon,
26 listCard,
27 pager,
28 row,
29 seg,
30 time,
31 } from "../ui.ts";
32 import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts";
33 import { studioCss } from "./style.ts";
34 import { tellWord } from "./tell.ts";
35 import {
36 pullWord,
37 runWord,
38 wordBadge,
39 type Word,
40 type WordCtx,
41 } from "./words.ts";
42
43 /** The five words a studio list filters by. `waiting` and `planned` are
44 * still moving, so they fold into `working` — the one place the seven-word
45 * vocabulary above buckets down to the five a list links to. */
46 type WordFilter = "told" | "working" | "look" | "shipped" | "stopped";
47
48 const WORD_FILTERS: readonly WordFilter[] = [
49 "told",
50 "working",
51 "look",
52 "shipped",
53 "stopped",
54 ];
55
56 function listBucket(word: Word): WordFilter {
57 return word === "waiting" || word === "planned" ? "working" : word;
58 }
59
60 /** The word named in `?word=`, when it is one of the five. */
61 function readWordFilter(ctx: Ctx): WordFilter | undefined {
62 const raw = ctx.url.searchParams.get("word") ?? "";
63 return (WORD_FILTERS as readonly string[]).includes(raw)
64 ? (raw as WordFilter)
65 : undefined;
66 }
67
68 /** Whether a row belongs on the page shown: the exact word the URL asked
69 * for, else "open and moving" — told, working or look, never shipped or
70 * stopped. */
71 function inWordFilter(word: Word, filter: WordFilter | undefined): boolean {
72 const bucket = listBucket(word);
73 return filter === undefined
74 ? bucket === "told" || bucket === "working" || bucket === "look"
75 : bucket === filter;
76 }
77
78 /** A filter bar of five words, each a link carrying `?word=` — never a
79 * form, so the filter lives in the address and can be shared. No filter
80 * reads as "Open": what is open and moving. */
81 function wordFilterBar(base: string, current: WordFilter | undefined): Html {
82 return html`<div class="filters mb">
83 ${seg([
84 { label: "Open", href: base, current: current === undefined },
85 ...WORD_FILTERS.map((w) => ({
86 label: w[0]!.toUpperCase() + w.slice(1),
87 href: `${base}?word=${w}`,
88 current: current === w,
89 })),
90 ])}
91 </div>`;
92 }
93
94 const LIST_PAGE = 25;
95
96 /** `?word=` carried onto a `cursor` link, so paging keeps the filter. */
97 function pagedHref(base: string, filter: WordFilter | undefined, cursor: string): string {
98 return `${base}?${filter ? `word=${filter}&` : ""}cursor=${cursor}`;
99 }
100
101 /**
102 * GET /:owner/:name/issues in the studio: everything told, one row per
103 * issue in its studio word — who has it, when it last moved, and the tell
104 * itself as the row's own link. No labels, no milestones. `?word=` narrows
105 * it; `?mode=dev` reads dev's list, unchanged. Bounded to one page: `tellWord`
106 * costs a few small reads per row, the trade the pull list already makes
107 * for its own check summaries (`verdictsFor`, pulls.ts).
108 */
109 export async function studioTells(
110 ctx: Ctx,
111 repo: Repo,
112 record: AppRecord,
113 ): Promise<Response> {
114 const db = ctx.app.db;
115 const base = repoBase(repo);
116 const filter = readWordFilter(ctx);
117 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
118 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
119 const cursor =
120 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
121
122 const fetched = listIssues(db, repo.id, {
123 kind: "issue",
124 state,
125 limit: LIST_PAGE,
126 ...(cursor !== undefined ? { cursor } : {}),
127 }).filter((issue) => issue.id !== repo.directionIssueId);
128 const wordCtx: WordCtx = {
129 db,
130 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
131 runtime: appsRuntime(ctx.app),
132 record,
133 };
134 const closingPulls = openPullsClosing(db, repo.id);
135 const words = await Promise.all(
136 fetched.map((issue) =>
137 tellWord(wordCtx, repo, issue, closingPulls.get(issue.number)),
138 ),
139 );
140 const rows = fetched
141 .map((issue, i) => ({ issue, info: words[i] }))
142 .filter(
143 (r): r is { issue: Issue; info: { word: Word; note: string } } =>
144 r.info !== undefined && inWordFilter(r.info.word, filter),
145 );
146
147 const list =
148 rows.length === 0
149 ? empty({
150 icon: "issue",
151 title: "Nothing here",
152 note: "Nothing told matches this filter yet.",
153 })
154 : listCard(
155 rows.map(({ issue, info }) =>
156 row({
157 href: `${base}/issues/${issue.number}`,
158 nav: true,
159 lead: avatar(issue.assigneeName ?? "crew"),
160 title: issue.title,
161 meta: html`${issue.assigneeName
162 ? html`${issue.assigneeName} · `
163 : null}${info.note} · ${time(issue.updatedAt)}`,
164 trail: wordBadge(info.word),
165 }),
166 ),
167 );
168 const lastNumber = fetched[fetched.length - 1]?.number;
169
170 const body = html`${repoHeader(ctx, repo, "issues")}
171 <div class="mt">
172 ${wordFilterBar(`${base}/issues`, filter)} ${list}
173 ${pager(
174 fetched.length === LIST_PAGE && lastNumber !== undefined
175 ? { next: pagedHref(`${base}/issues`, filter, String(lastNumber)) }
176 : {},
177 )}
178 </div>
179 <p class="muted small mt">
180 <a href="${base}/issues?mode=dev">${icon("code")}See dev's list</a> —
181 labels, milestones and the dependency form, dev's own way.
182 </p>
183 ${liveMount({ repo: base })}`;
184
185 return page({
186 ctx,
187 title: `Everything told · ${repo.name}`,
188 crumbs: crumbsFor(repo),
189 palette: repoPalette(repo),
190 body,
191 styleCss: studioCss,
192 scriptJs: liveClientJs,
193 });
194 }
195
196 /**
197 * GET /:owner/:name/pulls in the studio: every change, one row per pull in
198 * its studio word — who made it, when it last moved, and the change itself
199 * as the row's own link. No branch names, no diff. `?word=` narrows it;
200 * `?mode=dev` reads dev's list, unchanged.
201 */
202 export async function studioChanges(
203 ctx: Ctx,
204 repo: Repo,
205 record: AppRecord,
206 ): Promise<Response> {
207 const db = ctx.app.db;
208 const base = repoBase(repo);
209 const filter = readWordFilter(ctx);
210 const state = filter === "shipped" || filter === "stopped" ? "closed" : "open";
211 const rawCursor = Number(ctx.url.searchParams.get("cursor"));
212 const cursor =
213 Number.isSafeInteger(rawCursor) && rawCursor > 0 ? rawCursor : undefined;
214
215 const fetched = listPulls(db, repo.id, {
216 state,
217 limit: LIST_PAGE,
218 ...(cursor !== undefined ? { cursor } : {}),
219 });
220 const wordCtx: WordCtx = {
221 db,
222 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
223 runtime: appsRuntime(ctx.app),
224 record,
225 };
226 const words = await Promise.all(
227 fetched.map((pull) => pullWord(wordCtx, pull)),
228 );
229 const rows = fetched
230 .map((pull, i) => ({ pull, info: words[i]! }))
231 .filter((r) => inWordFilter(r.info.word, filter));
232
233 const list =
234 rows.length === 0
235 ? empty({
236 icon: "pull",
237 title: "Nothing here",
238 note: "Nothing matches this filter yet.",
239 })
240 : listCard(
241 rows.map(({ pull, info }) =>
242 row({
243 href: `${base}/pulls/${pull.number}`,
244 nav: true,
245 lead: avatar(pull.authorName),
246 title: pull.title,
247 meta: html`${pull.authorName} · ${info.note} ·
248 ${time(pull.updatedAt)}`,
249 trail: wordBadge(info.word),
250 }),
251 ),
252 );
253 const lastNumber = fetched[fetched.length - 1]?.number;
254
255 const body = html`${repoHeader(ctx, repo, "pulls")}
256 <div class="mt">
257 ${wordFilterBar(`${base}/pulls`, filter)} ${list}
258 ${pager(
259 fetched.length === LIST_PAGE && lastNumber !== undefined
260 ? { next: pagedHref(`${base}/pulls`, filter, String(lastNumber)) }
261 : {},
262 )}
263 </div>
264 <p class="muted small mt">
265 <a href="${base}/pulls?mode=dev">${icon("code")}See dev's list</a> —
266 branch names and the diff, dev's own way.
267 </p>
268 ${liveMount({ repo: base })}`;
269
270 return page({
271 ctx,
272 title: `Every change · ${repo.name}`,
273 crumbs: crumbsFor(repo),
274 palette: repoPalette(repo),
275 body,
276 styleCss: studioCss,
277 scriptJs: liveClientJs,
278 });
279 }
280
281 /**
282 * GET /:owner/:name/runs in the studio: every run, one row per run in its
283 * studio word — who ran it, when it last moved, and the transcript as the
284 * row's own link. `?word=` narrows it; `?mode=dev` reads dev's list,
285 * unchanged.
286 */
287 export async function studioRuns(
288 ctx: Ctx,
289 repo: Repo,
290 record: AppRecord,
291 ): Promise<Response> {
292 const db = ctx.app.db;
293 const base = repoBase(repo);
294 const filter = readWordFilter(ctx);
295 const cursor = ctx.url.searchParams.get("cursor") ?? undefined;
296
297 const fetched = listRuns(db, repo.id, {
298 kind: "agent",
299 limit: LIST_PAGE,
300 ...(cursor ? { cursor } : {}),
301 });
302 const wordCtx: WordCtx = {
303 db,
304 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
305 runtime: appsRuntime(ctx.app),
306 record,
307 };
308 const actors = actorsFor(ctx, fetched);
309 const issues = issuesFor(ctx, fetched);
310 const words = await Promise.all(fetched.map((run) => runWord(wordCtx, run)));
311 const rows = fetched
312 .map((run, i) => ({ run, info: words[i]! }))
313 .filter((r) => inWordFilter(r.info.word, filter));
314
315 const list =
316 rows.length === 0
317 ? empty({
318 icon: "bot",
319 title: "Nothing here",
320 note: "Nothing matches this filter yet.",
321 })
322 : listCard(
323 rows.map(({ run, info }) => {
324 const who =
325 run.actorId === null
326 ? "crew"
327 : (actors.get(run.actorId)?.username ?? "crew");
328 return row({
329 href: `${base}/runs/${run.id}`,
330 nav: true,
331 lead: avatar(who),
332 title: runTitleFrom(run, actors, issues),
333 meta: html`${who} · ${info.note} ·
334 ${time(run.finishedAt ?? run.createdAt)}`,
335 trail: wordBadge(info.word),
336 });
337 }),
338 );
339 const last = fetched[fetched.length - 1];
340
341 const body = html`${repoHeader(ctx, repo, "app", { compact: true })}
342 <div class="mt">
343 ${wordFilterBar(`${base}/runs`, filter)} ${list}
344 ${pager(
345 fetched.length === LIST_PAGE && last !== undefined
346 ? { next: pagedHref(`${base}/runs`, filter, last.id) }
347 : {},
348 )}
349 </div>
350 <p class="muted small mt">
351 <a href="${base}/runs?mode=dev">${icon("code")}See dev's list</a> —
352 deploys and checks alongside the crew's runs, dev's own way.
353 </p>
354 ${liveMount({ repo: base })}`;
355
356 return page({
357 ctx,
358 title: `Every run · ${repo.name}`,
359 crumbs: crumbsFor(repo),
360 palette: repoPalette(repo),
361 body,
362 styleCss: studioCss,
363 scriptJs: liveClientJs,
364 });
365 }
src/web/studio/previews.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * Every environment an app has, as the studio draws them: the head card for
3 * what production is doing, and one row per open pull for the preview a
4 * change can be looked at in. Both come from the runtime's own `status()` in
5 * one read, so the list costs nothing extra as pulls pile up, and both are
6 * drawn by the one function the page's first paint and the live fragment a
7 * deploy event refreshes (`appStateFragment`) share.
8 */
9
10 import { terminalFor } from "../../actions/apps.ts";
11 import { envPull, previewEnv } from "../../apps/data.ts";
12 import {
13 appsRuntime,
14 type AppsRuntime,
15 type EnvState,
16 type EnvStatus,
17 } from "../../apps/runtime.ts";
18 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
19 import { listPulls, type Pull } from "../../core/pulls.ts";
20 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
21 import type { Ctx } from "../../http/context.ts";
22 import { loadRepo, repoBase } from "../chrome.ts";
23 import { html, when, type Html } from "../html.ts";
24 import { fragment } from "../layout.ts";
25 import {
26 badge,
27 btn,
28 envActions,
29 envFacts,
30 envPhrase,
31 icon,
32 listCard,
33 row,
34 terminalLine,
35 time,
36 } from "../ui.ts";
37 import { lookNow } from "../pages/looks.ts";
38
39 /** The three words the studio uses for a preview, whatever its check says. */
40 function previewBadge(state: EnvState | undefined): Html {
41 if (state === "running")
42 return badge("live", { tone: "success", icon: "play" });
43 if (state === "deploying")
44 return badge("starting", { tone: "attn", icon: "loader" });
45 return badge("not up", {
46 ...(state === "failed" ? { tone: "danger" as const } : {}),
47 icon: "square",
48 });
49 }
50
51 /**
52 * One open pull's preview, as one row: its host is the click, its state is
53 * the badge, and a check still running or a check that failed says so right
54 * there — the row never hides a preview that is serving.
55 */
56 function previewRow(
57 base: string,
58 runtime: AppsRuntime | undefined,
59 record: AppRecord,
60 pull: Pull,
61 env: EnvStatus | undefined,
62 ): Html {
63 const href =
64 runtime === undefined
65 ? undefined
66 : `${base}/app/${previewEnv(pull.number)}/open`;
67 const host =
68 runtime === undefined
69 ? undefined
70 : runtime.urlFor(record, previewEnv(pull.number)).replace(/^https?:\/\//, "");
71 const bits: Html[] = [html`#${pull.number} ${pull.title}`];
72 if (env?.commitOid) bits.push(html`${env.commitOid.slice(0, 7)}`);
73 if (env?.since) bits.push(html`up ${time(env.since)}`);
74 const say = env?.error ?? env?.note;
75 if (say) bits.push(html`${say}`);
76 return row({
77 ...(href === undefined ? {} : { href, nav: true }),
78 lead: icon("rocket"),
79 title:
80 host === undefined
81 ? `#${pull.number} ${pull.title}`
82 : html`<span class="mono">${host}</span>`,
83 meta: bits.reduce((a, b) => html`${a} · ${b}`),
84 trail: previewBadge(env?.state),
85 });
86 }
87
88 /**
89 * Every open pull's preview, one row each, from the runtime's own `status()`
90 * — no per-pull query, so the list costs nothing extra as pulls pile up. A
91 * pull with no environment yet still gets a row, so the list matches the
92 * open pulls exactly.
93 */
94 function previewsPanel(
95 base: string,
96 runtime: AppsRuntime | undefined,
97 record: AppRecord,
98 openPulls: readonly Pull[],
99 envs: readonly EnvStatus[],
100 ): Html | null {
101 if (openPulls.length === 0) return null;
102 const byPull = new Map(
103 envs
104 .filter((e) => e.env !== "production")
105 .map((e) => [envPull(e.env), e] as const),
106 );
107 return listCard(
108 openPulls.map((p) =>
109 previewRow(base, runtime, record, p, byPull.get(p.number)),
110 ),
111 {
112 head: html`${icon("rocket")}
113 <h2>Previews</h2>`,
114 },
115 );
116 }
117
118 /**
119 * The head card and the previews list: what production is doing, and every
120 * open pull's own environment. One function, called for the page's first
121 * paint and for the fragment a deploy event refreshes in place
122 * (`appStateFragment`) — the two never draw two copies of the same markup.
123 */
124 export function appPanels(
125 ctx: Ctx,
126 repo: Repo,
127 record: AppRecord,
128 openPulls: readonly Pull[],
129 ): { head: Html; previews: Html | null } {
130 const db = ctx.app.db;
131 const base = repoBase(repo);
132 const runtime = appsRuntime(ctx.app);
133 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
134 const previews = previewsPanel(base, runtime, record, openPulls, envs);
135 const production = envs.find((e) => e.env === "production");
136 const url = runtime?.urlFor(record, "production");
137 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
138 // The state in one word, and under it the same facts the App page shows —
139 // why it is not up, and the deploy whose log says the rest.
140 const live = envPhrase(production);
141 const head = html`<div class="page-head studio-head">
142 <div class="stack stack-tight">
143 <h1>
144 ${repo.name}
145 ${badge(
146 live.word,
147 live.tone === "default"
148 ? { variant: "secondary" }
149 : { tone: live.tone },
150 )}
151 </h1>
152 <p class="muted">
153 ${repo.description || "No description yet."}
154 ${url === undefined
155 ? null
156 : html`·
157 <a href="${base}/app/production/open" class="mono"
158 >${url.replace(/^https?:\/\//, "")}</a
159 >`}
160 </p>
161 ${when(production, (env) => envFacts(base, env))}
162 ${when(writer, () =>
163 terminalLine(
164 terminalFor(ctx.app, record, "production", production),
165 ctx.app.config.sshHostKey,
166 ),
167 )}
168 </div>
169 <span class="spacer"></span>
170 ${lookNow(ctx, repo, record)}
171 ${btn("Open the app", {
172 href: `${base}/app/production/open`,
173 variant: "primary",
174 icon: "external",
175 })}
176 ${when(production, (env) => envActions(base, env, writer))}
177 </div>`;
178 return { head, previews };
179 }
180
181 /**
182 * GET /:owner/:name/app/state — the head card and previews list, bare. The
183 * studio page's own live client (`live.ts`) fetches this on a deploy run
184 * event for the app being viewed and swaps the two in place, so a change
185 * serving in its preview shows up without a reload. Authorized exactly as
186 * the studio page: a viewer who cannot see the app gets the same 404.
187 */
188 export function appStateFragment(ctx: Ctx): Response {
189 const repo = loadRepo(ctx);
190 if (!repo) return fragment(html``, 404);
191 const record = getAppByRepo(ctx.app.db, repo.id);
192 if (!record) return fragment(html``, 404);
193 const openPulls = listPulls(ctx.app.db, repo.id, {
194 state: "open",
195 limit: 10,
196 });
197 const { head, previews } = appPanels(ctx, repo, record, openPulls);
198 return fragment(html`<div id="app-head">${head}</div>
199 <div id="app-previews">${previews}</div>`);
200 }
src/web/studio/run.ts added +294
@@ -0,0 +1,294 @@
1 /**
2 * One run in the studio: the transcript a person sits on while waiting for a
3 * change to become visible — who is on it, the tell it came from, what it
4 * has cost, its own lines as they land — and the one control a live run has,
5 * Stop, which the home and the app page put on a row of their own. Steer and
6 * Stop post to the very same routes with the very same checks as `runPage`
7 * (`pages/runs.ts`); `?mode=dev` reads the same log there, unchanged.
8 */
9
10 import { appsRuntime } from "../../apps/runtime.ts";
11 import { getAppByRepo } from "../../core/apps.ts";
12 import { getIssueById } from "../../core/issues.ts";
13 import { getPull, openPullsClosing } from "../../core/pulls.ts";
14 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
15 import { isTerminal, type Run } from "../../core/runs.ts";
16 import { getUserById } from "../../core/users.ts";
17 import { repoDir } from "../../git/repos.ts";
18 import type { Ctx } from "../../http/context.ts";
19 import {
20 crumbsFor,
21 repoBase,
22 repoHeader,
23 repoPalette,
24 } from "../chrome.ts";
25 import { studioRunJs } from "../client.ts";
26 import { html, when, type Html } from "../html.ts";
27 import { page } from "../layout.ts";
28 import { btn, icon } from "../ui.ts";
29 import { previewCard } from "../pages/apps.ts";
30 import { costText, durationText, logLine, runTitle } from "../pages/runs.ts";
31 import { studioCss } from "./style.ts";
32 import { runWord, wordBadge, type WordCtx } from "./words.ts";
33
34 /**
35 * The one control a live run has: a person who can write here stops a
36 * running one or takes a queued one back off the board. Nothing for runs
37 * that are over, or for readers.
38 */
39 export function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
40 if (run.state !== "running" && run.state !== "queued") return null;
41 if (run.kind !== "agent") return null;
42 if (!canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)) return null;
43 return html`<form
44 method="post"
45 action="${repoBase(repo)}/runs/${run.id}/cancel"
46 >
47 ${btn(run.state === "running" ? "Stop" : "Cancel", {
48 size: "sm",
49 variant: "outline",
50 })}
51 </form>`;
52 }
53
54
55 /** At most this many of the run's own lines held in the page at once — the
56 * live script drops the oldest as a new one lands, past this. */
57 const RUN_TRANSCRIPT_CAP = 400;
58
59 /**
60 * GET /:owner/:name/runs/:id in the studio: who is on it, the tell it came
61 * from, how long it has gone and what it has cost, the transcript itself,
62 * and its state in the studio's own word — the page someone sits on while
63 * waiting for a change to become visible. Steer and Stop are the very same
64 * forms `runPage` offers, posting to the very same routes with the very
65 * same checks; `?mode=dev` reads the transcript there, unchanged.
66 */
67 export async function studioRun(
68 ctx: Ctx,
69 repo: Repo,
70 run: Run,
71 lines: readonly string[],
72 truncated: boolean,
73 ): Promise<Response> {
74 const db = ctx.app.db;
75 const base = repoBase(repo);
76 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
77 const actor =
78 run.actorId === null ? undefined : getUserById(db, run.actorId);
79 const issue =
80 run.issueId === null ? undefined : getIssueById(db, run.issueId);
81 // Who is on the crew is an owner's fact, same as the app's own page — a
82 // viewer who cannot write here reads the transcript as the crew's own
83 // work, never by the agent's name (title, every line, the live stream's
84 // `data-who`, the running pulse). The steer box below is writer-only
85 // already, so it only ever renders with the real name.
86 const who = writer ? (actor?.username ?? "an agent") : "the crew";
87 const title = runTitle(run, writer ? actor?.username : "the crew", issue);
88 const wordCtx: WordCtx = {
89 db,
90 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
91 runtime: appsRuntime(ctx.app),
92 record: getAppByRepo(db, repo.id),
93 };
94 const { word } = await runWord(wordCtx, run);
95 const unfinished = !isTerminal(run.state);
96 const duration = durationText(run);
97 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
98 const shown = lines.slice(-RUN_TRANSCRIPT_CAP);
99 const hidden = lines.length - shown.length;
100
101 // The pull this run's tell has become, once one exists: the tell itself
102 // when the run is already a pull's own (feedback, a failed check, a
103 // conflict fix), or the open pull that closes it while the run is still
104 // the tell's first build — the case this page matters most for.
105 const pull =
106 issue === undefined
107 ? undefined
108 : issue.kind === "pull"
109 ? getPull(db, repo.id, issue.number)
110 : openPullsClosing(db, repo.id).get(issue.number);
111
112 const head = html`<div class="issue-head">
113 <h1>${title}</h1>
114 <div class="issue-meta">
115 ${wordBadge(word, "run-word")}
116 ${when(
117 issue,
118 (i) =>
119 html`<a
120 href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}"
121 >#${i.number} ${i.title}</a
122 >`,
123 )}
124 ${when(
125 writer,
126 () =>
127 html`<span
128 class="num"
129 id="run-cost"
130 ${run.costUsd > 0 ? null : html`hidden`}
131 >${costText(run.costUsd)} spent</span
132 >
133 <span
134 class="num"
135 id="run-turns"
136 ${run.turns > 0 ? null : html`hidden`}
137 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
138 >`,
139 )}
140 ${when(
141 duration,
142 (d) =>
143 html`<span
144 class="num"
145 id="run-elapsed"
146 data-started="${run.startedAt ?? ""}"
147 data-finished="${run.finishedAt ?? ""}"
148 >${run.finishedAt === null ? "running for" : "took"} ${d}</span
149 >`,
150 )}
151 </div>
152 <p class="run-summary" id="run-summary">${run.summary}</p>
153 </div>`;
154
155 const body = html`${repoHeader(ctx, repo, "app", {
156 compact: true,
157 ownTitle: true,
158 })}
159 ${head}
160 ${when(
161 writer && unfinished && run.kind === "agent" && issue === undefined,
162 () =>
163 html`<div class="run-actions" id="run-cancel">
164 <form method="post" action="${base}/runs/${run.id}/cancel">
165 ${btn("Stop", { size: "sm", variant: "outline", icon: "square" })}
166 </form>
167 </div>`,
168 )}
169 ${when(
170 writer && unfinished && run.kind !== "agent",
171 () =>
172 html`<div class="run-actions" id="run-cancel">
173 <form method="post" action="${base}/runs/${run.id}/cancel">
174 ${btn("Cancel run", {
175 size: "sm",
176 variant: "outline",
177 icon: "square",
178 })}
179 </form>
180 </div>`,
181 )}
182 ${when(pull, (p) => previewCard(ctx, repo, p))}
183 ${when(
184 truncated,
185 () =>
186 html`<p class="muted small mb">
187 Showing the last 512 KB of the log; the file on disk has the rest.
188 </p>`,
189 )}
190 ${when(
191 shown.length === 0,
192 () =>
193 html`<p class="muted small" id="run-empty">
194 Nothing has been logged yet.
195 </p>`,
196 )}
197 <div class="run-live" id="run-live" data-state="${run.state}">
198 <div
199 class="run-log"
200 id="run-log"
201 data-who="${who}"
202 data-cap="${RUN_TRANSCRIPT_CAP}"
203 ${unfinished ? html`data-stream="${stream}"` : null}
204 >
205 ${when(
206 hidden > 0,
207 () =>
208 html`<p class="muted small">
209 Holding the last ${RUN_TRANSCRIPT_CAP} lines here; the run's own
210 log keeps the rest.
211 </p>`,
212 )}
213 ${shown.map((l) => logLine(l, who))}
214 ${when(
215 run.state === "running",
216 () =>
217 html`<div class="ln ln-pulse" id="run-pulse">
218 <span class="who">${who}</span
219 ><span class="txt"><i></i><i></i><i></i></span>
220 </div>`,
221 )}
222 <button type="button" class="run-jump" id="run-jump" hidden>
223 newer below
224 </button>
225 </div>
226 ${when(
227 writer && run.kind === "agent" && issue !== undefined,
228 () =>
229 html`<form
230 class="run-steer"
231 id="run-steer"
232 method="post"
233 action="${base}/runs/${run.id}/steer"
234 data-api="/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/steer"
235 data-running="${run.state === "running" ? "1" : "0"}"
236 >
237 <label class="sr-only" for="steer-text"
238 >Say something to ${who}</label
239 >
240 <textarea
241 class="input"
242 id="steer-text"
243 name="text"
244 rows="1"
245 required
246 placeholder="${run.state === "running"
247 ? `Steer ${who} — it hears you at its next step`
248 : `Tell ${who} for its next run`}"
249 ></textarea>
250 <div class="bar">
251 <span class="muted small" id="run-steer-note"
252 >${run.state === "running"
253 ? "Heard after the step it is on. Enter sends; every word is kept in the log."
254 : "The run is over; this goes on the conversation and starts a run."}</span
255 >
256 <span class="spacer"></span>
257 ${when(
258 unfinished,
259 () =>
260 html`<button
261 type="submit"
262 class="btn"
263 data-size="sm"
264 data-variant="outline"
265 id="run-stop"
266 formaction="${base}/runs/${run.id}/cancel"
267 formnovalidate
268 >
269 ${run.state === "running" ? "Stop" : "Cancel"}
270 </button>`,
271 )}
272 ${btn("Send", { size: "sm", variant: "primary", type: "submit" })}
273 </div>
274 </form>`,
275 )}
276 </div>
277 <p class="muted small mt">
278 <a href="${base}/runs/${run.id}?mode=dev">${icon("code")}Read the transcript</a>
279 — every tool call, in dev's own words.
280 </p>`;
281
282 return page({
283 ctx,
284 title: `${title} · ${repo.name}`,
285 crumbs: crumbsFor(repo),
286 palette: repoPalette(repo),
287 body,
288 styleCss: studioCss,
289 scriptJs: studioRunJs,
290 // The no-script half of a live log: while the run is going, the page
291 // refreshes itself; the script above removes this the moment it runs.
292 ...(unfinished ? { refresh: { seconds: 5 } } : {}),
293 });
294 }
src/web/studio/style.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The studio's page stylesheet: the one `styleCss` block every studio page
3 * hands `page()`, assembled from named parts the way `web/style.ts` assembles
4 * the served sheet from `style/*`. Each part is named for the concept whose
5 * markup it styles, and they are joined in the order they were served when
6 * this was one block at the bottom of `pages/studio.ts`, so the sheet is
7 * byte-for-byte the string it has always been. `bulkCss` is not written here
8 * at all: it is derived from the same table the bulk route enforces
9 * (`asks.ts`).
10 *
11 * `shell` and `rail` hold rules nothing in this directory draws any more,
12 * and `rows` all but two of them. They are kept exactly as they were because
13 * this is a pure move — deleting a rule changes what is served, and that is
14 * its own issue.
15 */
16
17 import { bulkCss } from "./asks.ts";
18
19 /** A part as written: the newline that opens the template belongs to the
20 * sheet, the one that closes it does not. */
21 const part = (css: string): string => css.slice(0, -1);
22
23 /** The first studio's own two-column shell, and the preview box it drew. */
24 const shell = part(/* css */ `
25 .studio { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 22px; margin-top: 16px; }
26 @media (max-width: 860px) { .studio { grid-template-columns: 1fr; } }
27 .studio-main { display: grid; gap: 18px; }
28 .studio-preview { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--muted); }
29 .studio-preview-head { display: flex; align-items: center; gap: 12px; }
30 .studio-live { font-family: var(--mono); font-size: 11.5px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); }
31 .studio-live[data-state="running"] { color: var(--merged); border-color: var(--merged); }
32 .studio-live[data-state="failed"] { color: var(--danger, #c33); }
33 `);
34
35 /** The box a person tells the crew from (`app.ts`). */
36 const tellBox = part(/* css */ `
37 .studio-tell textarea { min-height: 72px; resize: vertical; }
38 .studio-tell .bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
39 `);
40
41 /** The app's head card: what production is doing (`previews.ts`). */
42 const appHead = part(/* css */ `
43 .studio-head h1 { display: flex; align-items: center; gap: 10px; }
44 /* The facts under the title take the whole line; the two buttons that act
45 on production sit with the rest, each in its own POST form. */
46 .studio-head .env-facts { margin-top: 2px; }
47 .studio-head form { display: inline-flex; }
48 `);
49
50 /** One of the five words, as a badge (`words.ts`). */
51 const wordBadges = part(/* css */ `
52 .badge[data-variant="accent"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
53 .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; }
54 `);
55
56 /** The first studio's word rows, and the word itself where a row's meta
57 * line still carries one (`home.ts`). */
58 const rows = part(/* css */ `
59 .studio-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
60 .studio-list li { display: grid; grid-template-columns: 18px 84px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 14px; }
61 .studio-list li svg { vertical-align: -3px; }
62 .studio-who { font-weight: 600; }
63 .studio-word { font-family: var(--mono); font-size: 11.5px; opacity: .8; white-space: nowrap; }
64 .row-meta .studio-word { font-family: inherit; font-size: inherit; opacity: 1; font-weight: 560; }
65 .studio-list li[data-word="working"] .studio-word { color: var(--accent); opacity: 1; }
66 .studio-list li[data-word="look"] .studio-word { color: var(--merged); opacity: 1; }
67 .studio-list li[data-word="stopped"] .studio-word { color: var(--danger, #c33); opacity: 1; }
68 `);
69
70 /** The asks panel: a row's controls, the picker, the bulk bar (`asks.ts`). */
71 const asks = part(/* css */ `
72 .studio-ask-actions { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
73 .studio-lean { margin-left: 2px; }
74 .studio-asks .row-lead { gap: 8px; }
75 .studio-ask-pick { accent-color: var(--accent); margin: 0; width: 13px; height: 13px; opacity: .6; }
76 .studio-ask-pick:checked, .studio-ask-pick:focus-visible, .row:hover .studio-ask-pick { opacity: 1; }
77 .studio-bulk { display: none; align-items: center; gap: 8px; padding: 6px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; background: color-mix(in srgb, var(--accent) 7%, var(--card)); }
78 .studio-asks:has([name="ask"]:checked) .studio-bulk { display: flex; }
79 .studio-bulk-count { font-weight: 550; font-variant-numeric: tabular-nums; }
80 .studio-bulk-none { display: none; }
81 .studio-asks li[data-busy] { opacity: .6; }
82 .studio-asks li[data-busy] .studio-ask-actions { visibility: hidden; }
83 .studio-asks li[data-busy] .row-lead svg { display: none; }
84 .studio-asks li[data-busy] .row-lead::after { content: ""; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
85 .studio-ask-note { color: var(--danger, #c33); }
86 `);
87
88 /** The first studio's rail, and the crew list it held. */
89 const rail = part(/* css */ `
90 .studio-rail { display: grid; gap: 18px; align-content: start; }
91 .studio-rail h3 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; opacity: .7; margin: 0 0 6px; font-weight: 600; }
92 .studio-crew { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; font-size: 13.5px; }
93 .studio-crew li { display: grid; grid-template-columns: 74px 1fr; gap: 8px; }
94 .studio-crew li b { font-weight: 600; }
95 .studio-crew li span { opacity: .85; }
96 `);
97
98 export const studioCss = `${[
99 shell,
100 tellBox,
101 appHead,
102 wordBadges,
103 rows,
104 asks,
105 `\n${bulkCss}`,
106 rail,
107 ].join("")}\n`;
src/web/studio/tell.ts added +307
@@ -0,0 +1,307 @@
1 /**
2 * One tell in the studio: a tell is one sentence someone said, and the page
3 * answers three questions — who has it, what it is waiting on, and what the
4 * crew has done with it. Where it stands is `tellWord`, which every studio
5 * list of tells reads too, so a tell says the same thing in a row as it does
6 * on its own page. Nothing a dev-mode reader needs (labels, milestones, the
7 * dependency form) belongs here; `?mode=dev` reads that page, unchanged.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { appsRuntime } from "../../apps/runtime.ts";
12 import { getAppByRepo } from "../../core/apps.ts";
13 import { dependenciesOf } from "../../core/dependencies.ts";
14 import { listEvents } from "../../core/events.ts";
15 import type { Issue } from "../../core/issues.ts";
16 import { getPull, openPullsClosing, type Pull } from "../../core/pulls.ts";
17 import type { Repo } from "../../core/repos.ts";
18 import { activeRunFor, listRuns } from "../../core/runs.ts";
19 import { getUsersByIds } from "../../core/users.ts";
20 import { repoDir } from "../../git/repos.ts";
21 import type { Ctx } from "../../http/context.ts";
22 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
23 import { html, when, type Html } from "../html.ts";
24 import { page } from "../layout.ts";
25 import { liveClientJs, liveMount } from "../live.ts";
26 import {
27 avatar,
28 card,
29 icon,
30 listCard,
31 originTag,
32 row,
33 stateBadge,
34 time,
35 userLink,
36 } from "../ui.ts";
37 import { previewCard } from "../pages/apps.ts";
38 import { costText, durationText, reasonWords } from "../pages/runs.ts";
39 import { composer, conversation, originGlyph } from "../pages/thread.ts";
40 import { studioCss } from "./style.ts";
41 import {
42 pullWord,
43 runWord,
44 wordBadge,
45 type Word,
46 type WordCtx,
47 } from "./words.ts";
48
49 /** The pull whose merge closed this issue, from the event it left behind —
50 * the same record `stateEvents` (thread.ts) reads for the timeline's own
51 * "closed this" line, so this never disagrees with what the page already
52 * shows. Undefined for an issue closed by hand, or still open. */
53 function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined {
54 if (issue.state !== "closed") return undefined;
55 const closed = listEvents(db, { repoId: repo.id, limit: 200 }).find(
56 (e) =>
57 e.kind === "issue.closed" &&
58 Number(e.payload.number) === issue.number &&
59 e.payload.by === "merge",
60 );
61 if (closed === undefined) return undefined;
62 const number = Number(closed.payload.pull);
63 return Number.isInteger(number) ? getPull(db, repo.id, number) : undefined;
64 }
65
66 /**
67 * Where one tell stands, in the studio's own words: the pull that closed
68 * or closes it — judged by `pullWord`, the one place that fact is decided,
69 * so a merged tell reads *shipped* only once production is serving it,
70 * same as everywhere else — else a run in flight, else its last run's own
71 * word, else *told*: said, and nobody has picked it up yet. Undefined for
72 * a closed issue nothing merged shut, so the caller falls back to the
73 * plain state badge dev uses.
74 */
75 export async function tellWord(
76 wordCtx: WordCtx,
77 repo: Repo,
78 issue: Issue,
79 closingPull: Pull | undefined,
80 ): Promise<{ word: Word; note: string } | undefined> {
81 const db = wordCtx.db;
82 if (issue.state === "closed") {
83 const pull = shippedBy(db, repo, issue);
84 return pull ? await pullWord(wordCtx, pull) : undefined;
85 }
86 if (closingPull !== undefined) return await pullWord(wordCtx, closingPull);
87 if (activeRunFor(db, "agent", issue.id) !== undefined)
88 return { word: "working", note: "working now" };
89 const last = listRuns(db, repo.id, {
90 kind: "agent",
91 issueId: issue.id,
92 limit: 1,
93 })[0];
94 if (last !== undefined) return await runWord(wordCtx, last);
95 return { word: "told", note: "not picked up yet" };
96 }
97
98 /** Every open issue this one waits for, and every one that waits for it —
99 * read-only: the dependency form stays dev's alone (`dependenciesCard`,
100 * thread.ts). */
101 function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Html {
102 const { blockedBy, blocks } = dependenciesOf(db, issue.id);
103 const list = (items: readonly Issue[]) =>
104 html`<ul class="dep-list">
105 ${items.map(
106 (i) =>
107 html`<li data-state="${i.state}">
108 ${icon(i.state === "closed" ? "issueClosed" : "issue")}
109 <a href="${base}/issues/${i.number}">${i.title}</a>
110 <span class="muted small">#${i.number}</span>
111 </li>`,
112 )}
113 </ul>`;
114 if (blockedBy.length === 0 && blocks.length === 0)
115 return card(
116 html`<p class="muted small">Waits for nothing, holds up nothing.</p>`,
117 { head: html`${icon("map")} <h2>Depends on</h2>` },
118 );
119 return card(
120 html`${blockedBy.length === 0
121 ? html`<p class="muted small">Waits for nothing.</p>`
122 : html`<p class="small">Waits for ${blockedBy.length}</p>
123 ${list(blockedBy)}`}
124 ${blocks.length === 0
125 ? null
126 : html`<p class="small mt">Holds up ${blocks.length}</p>
127 ${list(blocks)}`}`,
128 { head: html`${icon("map")} <h2>Depends on</h2>` },
129 );
130 }
131
132 /** Every agent run for this tell, newest first: who, how long, what it
133 * cost, and its state in the studio's own word — the title opens the
134 * transcript. Empty when the crew has not touched it yet. */
135 async function tellRuns(
136 wordCtx: WordCtx,
137 repo: Repo,
138 issue: Issue,
139 ): Promise<Html | null> {
140 const runs = listRuns(wordCtx.db, repo.id, {
141 kind: "agent",
142 issueId: issue.id,
143 limit: 10,
144 });
145 if (runs.length === 0) return null;
146 const base = repoBase(repo);
147 const actors = getUsersByIds(
148 wordCtx.db,
149 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
150 );
151 const rows = await Promise.all(
152 runs.map(async (r) => {
153 const who =
154 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew");
155 const { word } = await runWord(wordCtx, r);
156 const duration = durationText(r);
157 return row({
158 href: `${base}/runs/${r.id}`,
159 nav: true,
160 lead: avatar(who),
161 title: html`${who} <span class="muted">${reasonWords(r.reason)}</span>`,
162 meta: html`${duration ? html`${duration} · ` : null}${r.summary ||
163 "no summary yet"}`,
164 trail: html`${wordBadge(word)}${when(
165 r.costUsd > 0,
166 () =>
167 html`<span class="num muted small">${costText(r.costUsd)}</span>`,
168 )}`,
169 });
170 }),
171 );
172 return listCard(rows, {
173 head: html`${icon("bot")}
174 <h2>Runs</h2>`,
175 });
176 }
177
178 /** The pull that closes this tell, with its check and a way into its
179 * preview — nothing here that `studioPull`/`previewCard` do not already
180 * draw. */
181 async function tellClosingPull(
182 ctx: Ctx,
183 wordCtx: WordCtx,
184 repo: Repo,
185 pull: Pull,
186 ): Promise<Html> {
187 const base = repoBase(repo);
188 const { word, note } = await pullWord(wordCtx, pull);
189 return html`${card(
190 html`<p>
191 <a href="${base}/pulls/${pull.number}">${pull.title}</a>
192 ${wordBadge(word)} <span class="muted small">${note}</span>
193 </p>`,
194 { head: html`${icon("pull")} <h2>Closes with</h2>` },
195 )}${previewCard(ctx, repo, pull)}`;
196 }
197
198 /**
199 * GET /:owner/:name/issues/:number in studio mode: a tell is one sentence
200 * someone said, and the page answers three questions — who has it, what it
201 * is waiting on, and what the crew has done with it — nothing a dev-mode
202 * reader needs (labels, milestones, the dependency form) belongs here.
203 * `?mode=dev` reads that page, unchanged; the conversation and the reply
204 * are the very same action as always (`conversation`/`composer`,
205 * thread.ts).
206 */
207 export async function studioTell(
208 ctx: Ctx,
209 repo: Repo,
210 issue: Issue,
211 ): Promise<Response> {
212 const db = ctx.app.db;
213 const base = repoBase(repo);
214 const thread = `${base}/issues/${issue.number}`;
215 const direction = repo.directionIssueId === issue.id;
216 const closingPull = openPullsClosing(db, repo.id).get(issue.number);
217 const wordCtx: WordCtx = {
218 db,
219 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
220 runtime: appsRuntime(ctx.app),
221 record: getAppByRepo(db, repo.id),
222 };
223 const [wordInfo, runsCard, closingCard] = await Promise.all([
224 direction ? undefined : tellWord(wordCtx, repo, issue, closingPull),
225 tellRuns(wordCtx, repo, issue),
226 closingPull ? tellClosingPull(ctx, wordCtx, repo, closingPull) : undefined,
227 ]);
228
229 const head = html`<div class="issue-head">
230 <h1>${issue.title} <span class="number">#${issue.number}</span></h1>
231 <div class="issue-meta">
232 ${direction
233 ? html`<span class="muted small"
234 >${icon("map")} the repository's standing direction — always
235 open, read by the planner directly, never picked up like an
236 ordinary tell</span
237 >`
238 : wordInfo
239 ? html`${wordBadge(wordInfo.word)}
240 <span class="muted small">${wordInfo.note}</span>`
241 : stateBadge(issue.state)}
242 <span
243 >${issue.originAuthor
244 ? originTag(issue.originAuthor, originGlyph(repo))
245 : userLink(issue.authorName)}
246 opened this ${time(issue.createdAt)}</span
247 >
248 ${when(
249 issue.originUrl,
250 (url) =>
251 html`<a class="origin-link" href="${url}" rel="noreferrer noopener"
252 >${icon("external")}view on origin</a
253 >`,
254 )}
255 <span
256 >${issue.assigneeName === null
257 ? html`<span class="muted">nobody has it yet</span>`
258 : html`${when(issue.assigneeIsAgent, () => icon("bot"))}
259 ${userLink(issue.assigneeName)} has it`}</span
260 >
261 </div>
262 </div>`;
263
264 const talk = conversation(ctx, repo, issue, { openVerb: "opened this" });
265
266 const body = html`${repoHeader(ctx, repo, "issues", {
267 compact: true,
268 ownTitle: true,
269 })}
270 ${head}
271 <div class="split">
272 <div>
273 ${talk.node}
274 ${composer(ctx, {
275 action: `${thread}/comments`,
276 stateAction: `${thread}/state`,
277 state: issue.state,
278 kind: "issue",
279 })}
280 </div>
281 <aside>
282 ${runsCard}
283 ${closingCard}
284 ${tellDependencies(base, issue, db)}
285 </aside>
286 </div>
287 <p class="muted small mt">
288 <a href="${thread}?mode=dev">${icon("code")}Read the details</a> —
289 labels, the dependency form and the assignee, dev's own way.
290 </p>
291 ${liveMount({
292 repo: base,
293 thread: "issue",
294 number: issue.number,
295 lastCommentId: talk.lastCommentId,
296 })}`;
297
298 return page({
299 ctx,
300 title: `${issue.title} · ${repo.name}`,
301 crumbs: crumbsFor(repo),
302 palette: repoPalette(repo),
303 body,
304 styleCss: studioCss,
305 scriptJs: liveClientJs,
306 });
307 }
src/web/studio/words.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * The studio's vocabulary: the five words (and the two that only ever fold
3 * into them) a person reads on every studio page, and the badge that draws
4 * one. `runWord` and `pullWord` are the one place a word is decided, from
5 * facts in git and the tables — never from a run's own summary text — so the
6 * home, the app, a change, a tell and every list say the same thing about
7 * the same commit. Nothing here knows about a page.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { mergeBase } from "../../git/merge.ts";
12 import type { AppsRuntime, EnvStatus } from "../../apps/runtime.ts";
13 import type { AppRecord } from "../../core/apps.ts";
14 import { getIssueById } from "../../core/issues.ts";
15 import { getPull, pullForBranch, type Pull } from "../../core/pulls.ts";
16 import {
17 checkSummary,
18 type CheckSummary,
19 type Run,
20 } from "../../core/runs.ts";
21 import { html, type Html } from "../html.ts";
22 import { icon } from "../ui.ts";
23
24 export type Word =
25 | "working"
26 | "look"
27 | "shipped"
28 | "stopped"
29 | "waiting"
30 | "planned"
31 | "told";
32
33 /**
34 * Everything the word functions below need to see production: the repo's
35 * git directory (an ancestry check reads it), and the runtime plus the
36 * app record, when the repository runs one. Built once per page and
37 * threaded through — never a second way to ask what is serving.
38 */
39 export interface WordCtx {
40 readonly db: Database;
41 readonly dir: string;
42 readonly runtime: AppsRuntime | undefined;
43 readonly record: AppRecord | undefined;
44 }
45
46 /**
47 * The pull an agent run produced, if it produced one — not a foreign key
48 * (a pull is its own issue, unrelated in the schema to the issue it
49 * closes) but the two facts a run leaves behind: a review run's
50 * `issueId` is the pull itself, and a builder run's `branch` is the one
51 * `landWork` (`crew/runner.ts`) already uses to find its own pull again.
52 */
53 function pullFromRun(db: Database, run: Run): Pull | undefined {
54 if (run.issueId !== null) {
55 const about = getIssueById(db, run.issueId);
56 if (about?.kind === "pull") return getPull(db, run.repoId, about.number);
57 }
58 if (run.branch === "") return undefined;
59 return pullForBranch(db, run.repoId, run.branch);
60 }
61
62 /** Whether production is serving this commit, or something later than it. */
63 async function caughtUp(
64 dir: string,
65 production: EnvStatus | undefined,
66 commitOid: string,
67 ): Promise<boolean> {
68 if (production === undefined || production.commitOid === "") return false;
69 if (production.commitOid === commitOid) return true;
70 return (await mergeBase(dir, commitOid, production.commitOid)) === commitOid;
71 }
72
73 /** A merged pull production has not caught up to yet: its own honest word. */
74 function mergedPullWord(
75 production: EnvStatus | undefined,
76 commitOid: string,
77 ): { word: Word; note: string } {
78 if (production?.commitOid === commitOid) {
79 if (production.state === "failed")
80 return {
81 word: "stopped",
82 note: production.error ?? "production's deploy of it failed",
83 };
84 return { word: "working", note: "production is deploying it now" };
85 }
86 return { word: "waiting", note: "merged; waiting for production to redeploy" };
87 }
88
89 /** A single open pull's check, in the studio's own words — never dev's
90 * "Checks passed/failed": the same five words as the rest of the page. */
91 function checkRunWord(check: CheckSummary): { word: Word; note: string } {
92 if (check.state === "ok")
93 return { word: "look", note: "green — ready for a look" };
94 if (check.state === "failed")
95 return check.run?.servingAt != null
96 ? { word: "stopped", note: "serving, but the check failed" }
97 : { word: "stopped", note: "failed before it answered" };
98 if (check.state === "running")
99 return {
100 word: "working",
101 note: check.run?.servingAt != null ? "serving — still checking" : "checking now",
102 };
103 if (check.state === "queued")
104 return { word: "working", note: "queued to check" };
105 if (check.state === "cancelled")
106 return { word: "stopped", note: "the check was cancelled" };
107 return { word: "waiting", note: "no deploy of this head yet" };
108 }
109
110 /**
111 * A pull's word: open goes by its own check, closed without a merge is
112 * simply stopped, and merged is *shipped* only once production is at or
113 * past the commit that merge carried — the one place that fact is asked,
114 * so the app page, the change page, the home and the board never invent
115 * their own answer to it.
116 */
117 export async function pullWord(
118 ctx: WordCtx,
119 pull: Pull,
120 ): Promise<{ word: Word; note: string }> {
121 if (pull.mergedAt !== null) {
122 const commitOid = pull.mergedOid ?? pull.headOid;
123 if (ctx.runtime === undefined || ctx.record === undefined)
124 return { word: "shipped", note: "merged" };
125 const production = ctx.runtime
126 .status(ctx.record)
127 .find((e) => e.env === "production");
128 if (await caughtUp(ctx.dir, production, commitOid))
129 return { word: "shipped", note: "production is serving it" };
130 return mergedPullWord(production, commitOid);
131 }
132 if (pull.state === "closed")
133 return { word: "stopped", note: "closed without shipping" };
134 return checkRunWord(checkSummary(ctx.db, pull.id, pull.headOid));
135 }
136
137 /**
138 * An agent run's word: still going by its state, and once it is over, by
139 * what it produced — a pull, judged the same way `pullWord` judges any
140 * other, or nothing to show, which is *planned* rather than a guess at
141 * *shipped*. Never `run.summary`: that stays the note beside the word.
142 */
143 export async function runWord(
144 ctx: WordCtx,
145 run: Run,
146 ): Promise<{ word: Word; note: string }> {
147 if (run.state === "queued")
148 return run.notBefore === null
149 ? { word: "waiting", note: "queued" }
150 : { word: "waiting", note: "paused, resumes soon" };
151 if (run.state === "running") return { word: "working", note: "now" };
152 if (run.state === "failed" || run.state === "cancelled")
153 return { word: "stopped", note: run.summary || run.state };
154 const pull = pullFromRun(ctx.db, run);
155 if (pull === undefined)
156 return { word: "planned", note: run.summary || "nothing to show yet" };
157 return await pullWord(ctx, pull);
158 }
159
160 const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
161 working: "loader",
162 look: "eye",
163 shipped: "check",
164 stopped: "alert",
165 waiting: "clock",
166 planned: "map",
167 told: "clock",
168 };
169
170 /** One of the five (or six) studio words, as a badge. An id names the one a
171 * live script repaints as a run finishes (`studioRunJs`). */
172 export function wordBadge(word: Word, id?: string): Html {
173 const variant =
174 word === "look" || word === "shipped"
175 ? "success"
176 : word === "stopped"
177 ? "danger"
178 : word === "working"
179 ? "accent"
180 : "secondary";
181 return html`<span
182 class="badge"
183 data-variant="${variant}"
184 data-word="${word}"
185 ${id === undefined ? null : html`id="${id}"`}
186 >${word === "working" ? icon("loader") : null}${word}</span
187 >`;
188 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs