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

Merged opened this

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

Merged

scout/forge:scout/195-a-deploy-s-own-page-says-an-agent-said-i → main 1 commit fork

  • Merged. scout/195-a-deploy-s-own-page-says-an-agent-said-i was merged into main as 0b0656b.

Review

Sign in to review this change.
8 files changed +148 −59
src/web/client.ts +32 −27
@@ -264,6 +264,36 @@ export const mirrorJs = /* js */ `
264264 * \`appTailJs\` so a fourth live log never grows a fourth copy of it —
265265 * interpolated inline, so \`who\` closes over whichever script uses it.
266266 */
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
267297 const logLineJs = /* js */ `
268298 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 )/;
269299 function line(kind, speaker, fill) {
@@ -371,20 +401,7 @@ export const runLogJs = /* js */ `
371401 if (follow) toEnd(); else if (jump) jump.hidden = false;
372402 }
373403
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}
388405
389406 // Steering without a reload: Enter sends, Shift+Enter breaks a line, the
390407 // words go to the API, and the line the forge writes for them arrives on
@@ -539,19 +556,7 @@ export const studioRunJs = /* js */ `
539556 if (follow) toEnd(); else if (jump) jump.hidden = false;
540557 }
541558
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}
555560
556561 if (steer && window.fetch) {
557562 var text = steer.querySelector('textarea');
src/web/pages/runs.ts +9 −5
@@ -103,7 +103,6 @@ export const reasonWords = (reason: string): string =>
103103 /** Dollars, two places — a run's spend is a small number read at a glance. */
104104 export const costText = (usd: number): string => `$${usd.toFixed(2)}`;
105105
106 /** `12s`, `3m 4s`, `1h 2m` — how long a run took, or has taken so far. */
107106 /**
108107 * What kind of line a run wrote: a tool the agent reached for (`→ Bash …`),
109108 * 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 {
147146 </div>`;
148147 }
149148
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 */
150155 export function durationText(run: Run): string | undefined {
151156 if (run.startedAt === null) return undefined;
152157 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);
157161 if (seconds < 60) return `${seconds}s`;
158162 const minutes = Math.floor(seconds / 60);
159163 if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
src/web/studio/lists.ts +7 −3
@@ -48,8 +48,10 @@ import {
4848 } from "./words.ts";
4949
5050 /** 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. */
5355 type WordFilter = "told" | "working" | "look" | "shipped" | "stopped";
5456
5557 const WORD_FILTERS: readonly WordFilter[] = [
@@ -61,7 +63,9 @@ const WORD_FILTERS: readonly WordFilter[] = [
6163 ];
6264
6365 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;
6569 }
6670
6771 /** The word named in `?word=`, when it is one of the five. */
src/web/studio/log.ts +19
@@ -6,9 +6,28 @@
66 * second copy of it.
77 */
88
9 import type { RunKind } from "../../core/runs.ts";
910 import { logLine } from "../pages/runs.ts";
1011 import { html, when, type Html } from "../html.ts";
1112
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
1231 /** At most this many of a run's own lines held in a full transcript at
1332 * once — the live script drops the oldest as a new one lands, past this. */
1433 export const RUN_TRANSCRIPT_CAP = 400;
src/web/studio/previews.ts +2 −2
@@ -37,7 +37,7 @@ import {
3737 time,
3838 } from "../ui.ts";
3939 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";
4141
4242 /**
4343 * The primary button, offered only once production answers — a preview
@@ -171,7 +171,7 @@ function deployTail(
171171 if (agentBusy) return undefined;
172172 return logBox({
173173 id: "app-run-tail",
174 who: "the deploy",
174 who: runVoice("deploy", true, undefined),
175175 cap: LOG_TAIL_CAP,
176176 lines: readRunLogTail(ctx.app.config.dataDir, production.runId, LOG_TAIL_CAP)
177177 .lines,
src/web/studio/run.ts +10 −7
@@ -28,7 +28,7 @@ import { page } from "../layout.ts";
2828 import { btn, icon } from "../ui.ts";
2929 import { previewCard } from "../pages/apps.ts";
3030 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";
3232 import { changeHref, tellHref } from "./nouns.ts";
3333 import { runWord, wordBadge, type WordCtx } from "./words.ts";
3434
@@ -75,12 +75,15 @@ export async function studioRun(
7575 run.actorId === null ? undefined : getUserById(db, run.actorId);
7676 const issue =
7777 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);
8487 const title = runTitle(run, writer ? actor?.username : "the crew", issue);
8588 const wordCtx: WordCtx = {
8689 db,
src/web/studio/words.ts +25 −15
@@ -1,10 +1,11 @@
11 /**
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.
89 */
910
1011 import type { Database } from "bun:sqlite";
@@ -29,7 +30,8 @@ export type Word =
2930 | "stopped"
3031 | "waiting"
3132 | "planned"
32 | "told";
33 | "told"
34 | "done";
3335
3436 /**
3537 * Everything the word functions below need to see production: the repo's
@@ -155,10 +157,13 @@ export async function pullWord(
155157 }
156158
157159 /**
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.
162167 */
163168 export async function runWord(
164169 ctx: WordCtx,
@@ -172,9 +177,13 @@ export async function runWord(
172177 if (run.state === "failed" || run.state === "cancelled")
173178 return { word: "stopped", note: run.summary || run.state };
174179 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" };
178187 }
179188
180189 const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
@@ -185,13 +194,14 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
185194 waiting: "clock",
186195 planned: "map",
187196 told: "clock",
197 done: "check",
188198 };
189199
190200 /** One of the five (or six) studio words, as a badge. An id names the one a
191201 * live script repaints as a run finishes (`studioRunJs`). */
192202 export function wordBadge(word: Word, id?: string): Html {
193203 const variant =
194 word === "look" || word === "shipped"
204 word === "look" || word === "shipped" || word === "done"
195205 ? "success"
196206 : word === "stopped"
197207 ? "danger"
test/studio.test.ts +44
@@ -3179,6 +3179,8 @@ describe("working: a run's own page in the studio", () => {
31793179 expect(page).toContain(`href="/alice/engine/tells/${tell.value.number}"`);
31803180 expect(page).toContain("$0.42 spent");
31813181 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"');
31823184 expect(page).toContain('id="run-steer"');
31833185 expect(page).toContain("Steer scout");
31843186 expect(page).toContain('id="run-stop"');
@@ -3254,6 +3256,48 @@ describe("working: a run's own page in the studio", () => {
32543256 expect(page).not.toContain(">Stop<");
32553257 expect(page).not.toContain(">Cancel run<");
32563258 });
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 });
32573301 });
32583302
32593303 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs