Public: anyone can read this; the crew can change it.
Split src/web/pages/studio.ts into modules named for one concept each #100
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/63-split-src-web-pages-studio-ts-into-modul → main 3 commits fork
24 files
changed +3061
−2777
CLAUDE.md +4 −1
| @@ -118,7 +118,10 @@ src/ | ||
| 118 | 118 | string), style/{base,components,content} assembled by style.ts, |
| 119 | 119 | markdown, highlight, code, worddiff, diff-view, live, and |
| 120 | 120 | 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) | |
| 122 | 125 | ssh/ the SSH server: transport (kex/ciphers), auth (publickey), |
| 123 | 126 | channels (session only), keys, wire — zero deps, no forwarding |
| 124 | 127 | 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. | ||
| 252 | 252 | whether the change, the environment or the base branch failed. When it |
| 253 | 253 | is the environment's fault, at 70% or better, the check runs again once |
| 254 | 254 | 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 | |
| 256 | 256 | likely right, so the card leads with it — _likely_, _probably_ or _maybe_ |
| 257 | 257 | beside the button, the probability on hover. The person still decides. |
| 258 | 258 |
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. | ||
| 20 | 20 | | **asks you** | a pull the crew stopped on, or a green one for a look | |
| 21 | 21 | |
| 22 | 22 | 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 | |
| 24 | 24 | text, which stays the note beside the word. A run's word comes from its |
| 25 | 25 | state, and once it is over, from what it produced: a pull, judged by |
| 26 | 26 | `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_). | ||
| 137 | 137 | issue numbers, labels, milestones and branch names replaced by the same |
| 138 | 138 | five words the rest of the studio speaks. Each is a page of rows, newest |
| 139 | 139 | 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 | |
| 141 | 141 | one link — the row itself — to the tell, the change or the transcript. The |
| 142 | 142 | app page's own footer (`the map · everything told · every change`, and |
| 143 | 143 | *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`, | ||
| 167 | 167 | `src/core/dependencies.ts`, each a link, read-only here), and what the |
| 168 | 168 | crew has done with it — every agent run, newest first, who ran it, how |
| 169 | 169 | 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 | |
| 171 | 171 | — and the pull that closes it, with its check and a link into its |
| 172 | 172 | preview, when one exists. The sentence itself, and the reply, are the |
| 173 | 173 | conversation dev's page shows too (`conversation`/`composer`, |
| @@ -201,7 +201,7 @@ mode, renders exactly what it always has. | ||
| 201 | 201 | `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself — |
| 202 | 202 | never its diff. The title and the opening post in the author's own words, |
| 203 | 203 | 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 | |
| 205 | 205 | state with the link that opens it (`previewCard`, the same card the aside |
| 206 | 206 | carries in dev), and the look at phone and desktop width when one has been |
| 207 | 207 | 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"; | ||
| 10 | 10 | import { id as newId } from "../kernel/id.ts"; |
| 11 | 11 | |
| 12 | 12 | /** `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 | |
| 14 | 14 | * `latestLean` can tell a fresh answer from one about a head that moved. */ |
| 15 | 15 | export type DecisionKind = "next" | "land" | "retry" | "lean"; |
| 16 | 16 |
src/http/server.ts +2 −2
| @@ -77,13 +77,13 @@ import { | ||
| 77 | 77 | } from "../web/pages/issues.ts"; |
| 78 | 78 | import { globalMapPage, mapPage } from "../web/pages/map.ts"; |
| 79 | 79 | import { |
| 80 | appStateFragment, | |
| 81 | 80 | asksSubmit, |
| 82 | 81 | modeSubmit, |
| 83 | 82 | saySubmit, |
| 84 | 83 | shipSubmit, |
| 85 | 84 | tellSubmit, |
| 86 | } from "../web/pages/studio.ts"; | |
| 85 | } from "../web/studio/actions.ts"; | |
| 86 | import { appStateFragment } from "../web/studio/previews.ts"; | |
| 87 | 87 | import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts"; |
| 88 | 88 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 89 | 89 | import { |
src/web/pages/issues.ts +2 −1
| @@ -63,7 +63,8 @@ import { | ||
| 63 | 63 | threadFragment, |
| 64 | 64 | } from "./thread.ts"; |
| 65 | 65 | 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"; | |
| 67 | 68 | |
| 68 | 69 | const PER_PAGE = 25; |
| 69 | 70 |
src/web/pages/pull.ts +1 −1
| @@ -96,7 +96,7 @@ import { previewCard } from "./apps.ts"; | ||
| 96 | 96 | import { lookCard } from "./looks.ts"; |
| 97 | 97 | import { pullState } from "./pulls.ts"; |
| 98 | 98 | import { crewActivityCard } from "./runs.ts"; |
| 99 | import { studioPull } from "./studio.ts"; | |
| 99 | import { studioPull } from "../studio/change.ts"; | |
| 100 | 100 | |
| 101 | 101 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 102 | 102 | function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
src/web/pages/pulls.ts +1 −1
| @@ -53,7 +53,7 @@ import { | ||
| 53 | 53 | } from "../ui.ts"; |
| 54 | 54 | import { refSet } from "./refs.ts"; |
| 55 | 55 | import { threadRow } from "./issues.ts"; |
| 56 | import { studioChanges } from "./studio.ts"; | |
| 56 | import { studioChanges } from "../studio/lists.ts"; | |
| 57 | 57 | import { originGlyph } from "./thread.ts"; |
| 58 | 58 | |
| 59 | 59 | const PER_PAGE = 25; |
src/web/pages/repo.ts +1 −1
| @@ -37,7 +37,7 @@ import { codeJs } from "../client.ts"; | ||
| 37 | 37 | import { highlightBlob } from "../code.ts"; |
| 38 | 38 | import { liveClientJs, liveMount } from "../live.ts"; |
| 39 | 39 | import { appStrip } from "./apps.ts"; |
| 40 | import { studioApp } from "./studio.ts"; | |
| 40 | import { studioApp } from "../studio/app.ts"; | |
| 41 | 41 | import { getAppByRepo } from "../../core/apps.ts"; |
| 42 | 42 | import { field, readForm } from "../form.ts"; |
| 43 | 43 | 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"; | ||
| 37 | 37 | import { html, when, type Html } from "../html.ts"; |
| 38 | 38 | import { flashTo, page, redirect } from "../layout.ts"; |
| 39 | 39 | 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"; | |
| 41 | 42 | import { |
| 42 | 43 | btn, |
| 43 | 44 | empty, |
src/web/pages/site.ts +1 −1
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | } from "../../core/users.ts"; |
| 40 | 40 | import { isThemeName } from "../../design/themes.ts"; |
| 41 | 41 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 42 | import { studioHome } from "./studio.ts"; | |
| 42 | import { studioHome } from "../studio/home.ts"; | |
| 43 | 43 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 44 | 44 | import { sshCommand } from "../../ssh/command.ts"; |
| 45 | 45 | import { |
src/web/pages/studio.ts +10 −2762
| @@ -1,2765 +1,13 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 14 | 9 | */ |
| 15 | 10 | |
| 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}-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}-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}-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}-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 | } |
new above
offline