Public: anyone can read this; the crew can change it.
A deploy's own page says an agent said it, and calls the live deploy "planned" #210
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/195-a-deploy-s-own-page-says-an-agent-said-i → main 1 commit fork
8 files
changed +148
−59
src/web/client.ts +32 −27
| @@ -264,6 +264,36 @@ export const mirrorJs = /* js */ ` | ||
| 264 | 264 | * \`appTailJs\` so a fourth live log never grows a fourth copy of it — |
| 265 | 265 | * interpolated inline, so \`who\` closes over whichever script uses it. |
| 266 | 266 | */ |
| 267 | /** | |
| 268 | * The clock beside a run's own log: elapsed time ticks while it goes, and | |
| 269 | * stops at "took" — under ten seconds in tenths, matching \`durationText\` | |
| 270 | * (\`pages/runs.ts\`) exactly, since the two paint the very same number | |
| 271 | * first server-side, then live; a mismatched rounding here would flicker | |
| 272 | * the instant this script takes over. Shared by \`runLogJs\` and | |
| 273 | * \`studioRunJs\` so the two can never drift apart. | |
| 274 | */ | |
| 275 | const elapsedTickJs = /* js */ ` | |
| 276 | function tick() { | |
| 277 | var elapsed = document.getElementById('run-elapsed'); | |
| 278 | if (!elapsed) return; | |
| 279 | var started = Date.parse(elapsed.getAttribute('data-started') || ''); | |
| 280 | if (!started) return; | |
| 281 | var finished = Date.parse(elapsed.getAttribute('data-finished') || ''); | |
| 282 | var end = finished || Date.now(); | |
| 283 | var ms = Math.max(0, end - started); | |
| 284 | var text; | |
| 285 | if (ms < 10000) { | |
| 286 | text = (ms / 1000).toFixed(1) + 's'; | |
| 287 | } else { | |
| 288 | var s = Math.round(ms / 1000); | |
| 289 | text = s < 60 ? s + 's' : s < 3600 ? Math.floor(s / 60) + 'm ' + (s % 60) + 's' : Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; | |
| 290 | } | |
| 291 | elapsed.textContent = (finished ? 'took ' : 'running for ') + text; | |
| 292 | } | |
| 293 | var clock = window.setInterval(tick, 100); | |
| 294 | tick(); | |
| 295 | `; | |
| 296 | ||
| 267 | 297 | const logLineJs = /* js */ ` |
| 268 | 298 | var FORGE = /^(claude:|done:|failed:|paused|declined:|cloning |branch |forked |pushing |push refused|clone kept|nothing to propose|opened pull|updated pull|committed the|could not|checking out|deploy |data: |\\$ |session |the session|interrupted|passed in|install |test |start |ready |process )/; |
| 269 | 299 | function line(kind, speaker, fill) { |
| @@ -371,20 +401,7 @@ export const runLogJs = /* js */ ` | ||
| 371 | 401 | if (follow) toEnd(); else if (jump) jump.hidden = false; |
| 372 | 402 | } |
| 373 | 403 | |
| 374 | // The clock: elapsed time ticks while the run goes, and stops at "took". | |
| 375 | function tick() { | |
| 376 | var elapsed = document.getElementById('run-elapsed'); | |
| 377 | if (!elapsed) return; | |
| 378 | var started = Date.parse(elapsed.getAttribute('data-started') || ''); | |
| 379 | if (!started) return; | |
| 380 | var finished = Date.parse(elapsed.getAttribute('data-finished') || ''); | |
| 381 | var end = finished || Date.now(); | |
| 382 | var s = Math.max(0, Math.round((end - started) / 1000)); | |
| 383 | var text = s < 60 ? s + 's' : s < 3600 ? Math.floor(s / 60) + 'm ' + (s % 60) + 's' : Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; | |
| 384 | elapsed.textContent = (finished ? 'took ' : 'running for ') + text; | |
| 385 | } | |
| 386 | var clock = window.setInterval(tick, 1000); | |
| 387 | tick(); | |
| 404 | ${elapsedTickJs} | |
| 388 | 405 | |
| 389 | 406 | // Steering without a reload: Enter sends, Shift+Enter breaks a line, the |
| 390 | 407 | // words go to the API, and the line the forge writes for them arrives on |
| @@ -539,19 +556,7 @@ export const studioRunJs = /* js */ ` | ||
| 539 | 556 | if (follow) toEnd(); else if (jump) jump.hidden = false; |
| 540 | 557 | } |
| 541 | 558 | |
| 542 | function tick() { | |
| 543 | var elapsed = document.getElementById('run-elapsed'); | |
| 544 | if (!elapsed) return; | |
| 545 | var started = Date.parse(elapsed.getAttribute('data-started') || ''); | |
| 546 | if (!started) return; | |
| 547 | var finished = Date.parse(elapsed.getAttribute('data-finished') || ''); | |
| 548 | var end = finished || Date.now(); | |
| 549 | var s = Math.max(0, Math.round((end - started) / 1000)); | |
| 550 | var text = s < 60 ? s + 's' : s < 3600 ? Math.floor(s / 60) + 'm ' + (s % 60) + 's' : Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; | |
| 551 | elapsed.textContent = (finished ? 'took ' : 'running for ') + text; | |
| 552 | } | |
| 553 | var clock = window.setInterval(tick, 1000); | |
| 554 | tick(); | |
| 559 | ${elapsedTickJs} | |
| 555 | 560 | |
| 556 | 561 | if (steer && window.fetch) { |
| 557 | 562 | var text = steer.querySelector('textarea'); |
src/web/pages/runs.ts +9 −5
| @@ -103,7 +103,6 @@ export const reasonWords = (reason: string): string => | ||
| 103 | 103 | /** Dollars, two places — a run's spend is a small number read at a glance. */ |
| 104 | 104 | export const costText = (usd: number): string => `$${usd.toFixed(2)}`; |
| 105 | 105 | |
| 106 | /** `12s`, `3m 4s`, `1h 2m` — how long a run took, or has taken so far. */ | |
| 107 | 106 | /** |
| 108 | 107 | * What kind of line a run wrote: a tool the agent reached for (`→ Bash …`), |
| 109 | 108 | * a word from the forge itself (cloning, pushing, what the model cost), or |
| @@ -147,13 +146,18 @@ export function logLine(line: string, who = "agent"): Html { | ||
| 147 | 146 | </div>`; |
| 148 | 147 | } |
| 149 | 148 | |
| 149 | /** | |
| 150 | * `0.3s`, `12s`, `3m 4s`, `1h 2m` — how long a run took, or has taken so | |
| 151 | * far. Under ten seconds it names tenths, the way `servedPhrase` (`ui.ts`) | |
| 152 | * already does for a deploy's own arrival — whole seconds there would round | |
| 153 | * a deploy the head card calls "live 0.3s after the push" down to "took 0s". | |
| 154 | */ | |
| 150 | 155 | export function durationText(run: Run): string | undefined { |
| 151 | 156 | if (run.startedAt === null) return undefined; |
| 152 | 157 | const end = run.finishedAt === null ? Date.now() : Date.parse(run.finishedAt); |
| 153 | const seconds = Math.max( | |
| 154 | 0, | |
| 155 | Math.round((end - Date.parse(run.startedAt)) / 1000), | |
| 156 | ); | |
| 158 | const ms = Math.max(0, end - Date.parse(run.startedAt)); | |
| 159 | if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`; | |
| 160 | const seconds = Math.round(ms / 1000); | |
| 157 | 161 | if (seconds < 60) return `${seconds}s`; |
| 158 | 162 | const minutes = Math.floor(seconds / 60); |
| 159 | 163 | if (minutes < 60) return `${minutes}m ${seconds % 60}s`; |
src/web/studio/lists.ts +7 −3
| @@ -48,8 +48,10 @@ import { | ||
| 48 | 48 | } from "./words.ts"; |
| 49 | 49 | |
| 50 | 50 | /** The five words a studio list filters by. `waiting` and `planned` are |
| 51 | * still moving, so they fold into `working` — the one place the seven-word | |
| 52 | * vocabulary above buckets down to the five a list links to. */ | |
| 51 | * still moving, so they fold into `working`; `done` is a deploy or a check | |
| 52 | * that finished with nothing else to show, so it folds into `shipped` — the | |
| 53 | * one place the eight-word vocabulary above buckets down to the five a list | |
| 54 | * links to. */ | |
| 53 | 55 | type WordFilter = "told" | "working" | "look" | "shipped" | "stopped"; |
| 54 | 56 | |
| 55 | 57 | const WORD_FILTERS: readonly WordFilter[] = [ |
| @@ -61,7 +63,9 @@ const WORD_FILTERS: readonly WordFilter[] = [ | ||
| 61 | 63 | ]; |
| 62 | 64 | |
| 63 | 65 | function listBucket(word: Word): WordFilter { |
| 64 | return word === "waiting" || word === "planned" ? "working" : word; | |
| 66 | if (word === "waiting" || word === "planned") return "working"; | |
| 67 | if (word === "done") return "shipped"; | |
| 68 | return word; | |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | 71 | /** The word named in `?word=`, when it is one of the five. */ |
src/web/studio/log.ts +19
| @@ -6,9 +6,28 @@ | ||
| 6 | 6 | * second copy of it. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import type { RunKind } from "../../core/runs.ts"; | |
| 9 | 10 | import { logLine } from "../pages/runs.ts"; |
| 10 | 11 | import { html, when, type Html } from "../html.ts"; |
| 11 | 12 | |
| 13 | /** | |
| 14 | * Who speaks a run's own transcript: a deploy or a check speaks for itself | |
| 15 | * — never "an agent", which is a name for nobody — and only an agent's run | |
| 16 | * is named for the agent on it, or "the crew" for a reader who cannot see | |
| 17 | * who that is. The one place this is decided, so a run's own page and the | |
| 18 | * tail beside a running row (`studio/previews.ts`) never choose two voices | |
| 19 | * for the same lines. | |
| 20 | */ | |
| 21 | export function runVoice( | |
| 22 | kind: RunKind, | |
| 23 | writer: boolean, | |
| 24 | actorName: string | undefined, | |
| 25 | ): string { | |
| 26 | if (kind === "deploy") return "the deploy"; | |
| 27 | if (kind === "check") return "the check"; | |
| 28 | return writer ? (actorName ?? "an agent") : "the crew"; | |
| 29 | } | |
| 30 | ||
| 12 | 31 | /** At most this many of a run's own lines held in a full transcript at |
| 13 | 32 | * once — the live script drops the oldest as a new one lands, past this. */ |
| 14 | 33 | export const RUN_TRANSCRIPT_CAP = 400; |
src/web/studio/previews.ts +2 −2
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | time, |
| 38 | 38 | } from "../ui.ts"; |
| 39 | 39 | import { lookNow } from "../pages/looks.ts"; |
| 40 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; | |
| 40 | import { LOG_TAIL_CAP, logBox, runVoice } from "./log.ts"; | |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | 43 | * The primary button, offered only once production answers — a preview |
| @@ -171,7 +171,7 @@ function deployTail( | ||
| 171 | 171 | if (agentBusy) return undefined; |
| 172 | 172 | return logBox({ |
| 173 | 173 | id: "app-run-tail", |
| 174 | who: "the deploy", | |
| 174 | who: runVoice("deploy", true, undefined), | |
| 175 | 175 | cap: LOG_TAIL_CAP, |
| 176 | 176 | lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP) |
| 177 | 177 | .lines, |
src/web/studio/run.ts +10 −7
| @@ -28,7 +28,7 @@ import { page } from "../layout.ts"; | ||
| 28 | 28 | import { btn, icon } from "../ui.ts"; |
| 29 | 29 | import { previewCard } from "../pages/apps.ts"; |
| 30 | 30 | import { costText, durationText, runTitle } from "../pages/runs.ts"; |
| 31 | import { logBox, RUN_TRANSCRIPT_CAP } from "./log.ts"; | |
| 31 | import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts"; | |
| 32 | 32 | import { changeHref, tellHref } from "./nouns.ts"; |
| 33 | 33 | import { runWord, wordBadge, type WordCtx } from "./words.ts"; |
| 34 | 34 | |
| @@ -75,12 +75,15 @@ export async function studioRun( | ||
| 75 | 75 | run.actorId === null ? undefined : getUserById(db, run.actorId); |
| 76 | 76 | const issue = |
| 77 | 77 | run.issueId === null ? undefined : getIssueById(db, run.issueId); |
| 78 | // Who is on the crew is an owner's fact, same as the app's own page — a | |
| 79 | // viewer who cannot write here reads the transcript as the crew's own | |
| 80 | // work, never by the agent's name (title, every line, the live stream's | |
| 81 | // `data-who`, the running pulse). The steer box below is writer-only | |
| 82 | // already, so it only ever renders with the real name. | |
| 83 | const who = writer ? (actor?.username ?? "an agent") : "the crew"; | |
| 78 | // A deploy or a check speaks for itself (`runVoice`, shared with the tail | |
| 79 | // `studio/previews.ts` opens for the same lines while production comes | |
| 80 | // up) — only an agent run is named for who is on it, and who is on the | |
| 81 | // crew is an owner's fact, same as the app's own page: a viewer who | |
| 82 | // cannot write here reads the transcript as the crew's own work, never by | |
| 83 | // the agent's name (title, every line, the live stream's `data-who`, the | |
| 84 | // running pulse). The steer box below is writer-only already, so it only | |
| 85 | // ever renders with the real name. | |
| 86 | const who = runVoice(run.kind, writer, actor?.username); | |
| 84 | 87 | const title = runTitle(run, writer ? actor?.username : "the crew", issue); |
| 85 | 88 | const wordCtx: WordCtx = { |
| 86 | 89 | db, |
src/web/studio/words.ts +25 −15
| @@ -1,10 +1,11 @@ | ||
| 1 | 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. | |
| 2 | * The studio's vocabulary: the five words (and the three that only ever fold | |
| 3 | * into them — `waiting`, `planned` into `working`, `done` into `shipped`) a | |
| 4 | * person reads on every studio page, and the badge that draws one. `runWord` | |
| 5 | * and `pullWord` are the one place a word is decided, from facts in git and | |
| 6 | * the tables — never from a run's own summary text — so the home, the app, a | |
| 7 | * change, a tell and every list say the same thing about the same commit. | |
| 8 | * Nothing here knows about a page. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import type { Database } from "bun:sqlite"; |
| @@ -29,7 +30,8 @@ export type Word = | ||
| 29 | 30 | | "stopped" |
| 30 | 31 | | "waiting" |
| 31 | 32 | | "planned" |
| 32 | | "told"; | |
| 33 | | "told" | |
| 34 | | "done"; | |
| 33 | 35 | |
| 34 | 36 | /** |
| 35 | 37 | * Everything the word functions below need to see production: the repo's |
| @@ -155,10 +157,13 @@ export async function pullWord( | ||
| 155 | 157 | } |
| 156 | 158 | |
| 157 | 159 | /** |
| 158 | * An agent run's word: still going by its state, and once it is over, by | |
| 159 | * what it produced — a pull, judged the same way `pullWord` judges any | |
| 160 | * other, or nothing to show, which is *planned* rather than a guess at | |
| 161 | * *shipped*. Never `run.summary`: that stays the note beside the word. | |
| 160 | * A run's word: still going by its state, and once it is over, by what it | |
| 161 | * produced. A pull, when there is one, is judged the same way `pullWord` | |
| 162 | * judges any other. Short of that, an agent run that shipped nothing is | |
| 163 | * *planned* rather than a guess at *shipped* — but a deploy or a check | |
| 164 | * never produces a pull of its own, so the same absence there just means | |
| 165 | * it finished: *done*, not *planned*. Never `run.summary`: that stays the | |
| 166 | * note beside the word. | |
| 162 | 167 | */ |
| 163 | 168 | export async function runWord( |
| 164 | 169 | ctx: WordCtx, |
| @@ -172,9 +177,13 @@ export async function runWord( | ||
| 172 | 177 | if (run.state === "failed" || run.state === "cancelled") |
| 173 | 178 | return { word: "stopped", note: run.summary || run.state }; |
| 174 | 179 | const pull = pullFromRun(ctx.db, run); |
| 175 | if (pull === undefined) | |
| 176 | return { word: "planned", note: run.summary || "nothing to show yet" }; | |
| 177 | return await pullWord(ctx, pull); | |
| 180 | if (pull !== undefined) return await pullWord(ctx, pull); | |
| 181 | if (run.kind !== "agent") | |
| 182 | return { | |
| 183 | word: "done", | |
| 184 | note: run.summary || (run.kind === "deploy" ? "deployed" : "checked"), | |
| 185 | }; | |
| 186 | return { word: "planned", note: run.summary || "nothing to show yet" }; | |
| 178 | 187 | } |
| 179 | 188 | |
| 180 | 189 | const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { |
| @@ -185,13 +194,14 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 185 | 194 | waiting: "clock", |
| 186 | 195 | planned: "map", |
| 187 | 196 | told: "clock", |
| 197 | done: "check", | |
| 188 | 198 | }; |
| 189 | 199 | |
| 190 | 200 | /** One of the five (or six) studio words, as a badge. An id names the one a |
| 191 | 201 | * live script repaints as a run finishes (`studioRunJs`). */ |
| 192 | 202 | export function wordBadge(word: Word, id?: string): Html { |
| 193 | 203 | const variant = |
| 194 | word === "look" || word === "shipped" | |
| 204 | word === "look" || word === "shipped" || word === "done" | |
| 195 | 205 | ? "success" |
| 196 | 206 | : word === "stopped" |
| 197 | 207 | ? "danger" |
test/studio.test.ts +44
| @@ -3179,6 +3179,8 @@ describe("working: a run's own page in the studio", () => { | ||
| 3179 | 3179 | expect(page).toContain(`href="/alice/engine/tells/${tell.value.number}"`); |
| 3180 | 3180 | expect(page).toContain("$0.42 spent"); |
| 3181 | 3181 | expect(page).toContain('data-word="working"'); |
| 3182 | // An agent's own run still speaks in the agent's name (#195). | |
| 3183 | expect(page).toContain('data-who="scout"'); | |
| 3182 | 3184 | expect(page).toContain('id="run-steer"'); |
| 3183 | 3185 | expect(page).toContain("Steer scout"); |
| 3184 | 3186 | expect(page).toContain('id="run-stop"'); |
| @@ -3254,6 +3256,48 @@ describe("working: a run's own page in the studio", () => { | ||
| 3254 | 3256 | expect(page).not.toContain(">Stop<"); |
| 3255 | 3257 | expect(page).not.toContain(">Cancel run<"); |
| 3256 | 3258 | }); |
| 3259 | ||
| 3260 | test("a deploy's own run page speaks as the deploy, reads as done rather than planned, and prints a tenth for a sub-second run (#195)", async () => { | |
| 3261 | const run = createRun(app.db, { | |
| 3262 | repoId: repo.id, | |
| 3263 | kind: "deploy", | |
| 3264 | env: "production", | |
| 3265 | commitOid: "c740479", | |
| 3266 | reason: "boot", | |
| 3267 | }); | |
| 3268 | claimRun(app.db, run.id); | |
| 3269 | appendRunLog(app.config.dataDir, run.id, "$ bun test"); | |
| 3270 | appendRunLog(app.config.dataDir, run.id, "bun test v1.3.14 (0d9b296a)"); | |
| 3271 | appendRunLog( | |
| 3272 | app.config.dataDir, | |
| 3273 | run.id, | |
| 3274 | "(pass) the page knows who you are and offers the right door [3.61ms]", | |
| 3275 | ); | |
| 3276 | finishRun(app.db, run.id, { state: "ok", summary: "deploy" }); | |
| 3277 | // A run that took 300ms — today's whole-second rounding reads "took 0s". | |
| 3278 | app.db | |
| 3279 | .query("UPDATE run SET started_at = ?2, finished_at = ?3 WHERE id = ?1") | |
| 3280 | .run( | |
| 3281 | run.id, | |
| 3282 | "2024-01-01T00:00:00.000Z", | |
| 3283 | "2024-01-01T00:00:00.300Z", | |
| 3284 | ); | |
| 3285 | ||
| 3286 | const page = await ( | |
| 3287 | await get(`/alice/engine/runs/${run.id}`, writerCookie) | |
| 3288 | ).text(); | |
| 3289 | // The deploy speaks for itself: the box, and every line in it — never | |
| 3290 | // "an agent", which is nobody that was actually there. | |
| 3291 | expect(page).toContain('data-who="the deploy"'); | |
| 3292 | expect(page).toContain('class="who">the deploy<'); | |
| 3293 | expect(page).not.toContain("an agent"); | |
| 3294 | // A deploy that finished with nothing else to show reads as done, not | |
| 3295 | // as a run that has not started yet. | |
| 3296 | expect(page).toContain('data-word="done"'); | |
| 3297 | expect(page).not.toContain('data-word="planned"'); | |
| 3298 | // Sub-second, in tenths — not rounded away to "took 0s". | |
| 3299 | expect(page).toContain(">took 0.3s<"); | |
| 3300 | }); | |
| 3257 | 3301 | }); |
| 3258 | 3302 | |
| 3259 | 3303 | /** |
new above
offline