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

Working opens a transcript that reads as the studio: what the crew is doing, live #59

Merged opened this

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

Merged

scout/forge:scout/51-working-opens-a-transcript-that-reads-as → main 2 commits fork

  • Merged. scout/51-working-opens-a-transcript-that-reads-as was merged into main as 26566f7.

Review

Sign in to review this change.
5 files changed +612 −6
docs/STUDIO.md +16
@@ -86,6 +86,22 @@ A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone
8686 who can write here; its title opens the transcript, where the agent can be
8787 steered mid-run (see `docs/APPS.md`, _Runs_).
8888
89 ## Working, on its own page
90
91 `/:owner/:name/runs/:id` on an app, in the studio, is a run: who is on it,
92 the tell it came from (linking to it), how long it has gone and what it has
93 cost, and its state in the studio's own word. The transcript is the page's
94 body, tailing live with a fixed-height scrolling area and a cap on how many
95 lines it holds — a long agent run never grows the page without bound; with
96 JavaScript off the page still shows what was logged and refreshes itself
97 while the run is in flight. Once the tell has become a pull, its preview is
98 on the page as soon as it exists — this is the page someone sits on while
99 waiting for a change to become visible. Steer and Stop are here for anyone
100 who can write, through the very same routes and checks dev's page uses.
101 _Read the transcript_ links to dev's page (`?mode=dev` on the same URL),
102 unchanged; a run on a repository that is not an app, or any run in dev
103 mode, renders exactly what it always has.
104
89105 ## The look, on its own page
90106
91107 `/:owner/:name/pulls/:n` on an app, in the studio, is the change itself —
src/web/client.ts +188
@@ -447,6 +447,194 @@ export const runLogJs = /* js */ `
447447 })();
448448 `;
449449
450 /**
451 * The same live transcript as \`runLogJs\`, painted in the studio's own five
452 * words instead of dev's queued/running/passed/failed — the one thing that
453 * cannot be shared, since the two pages speak different vocabularies for the
454 * same run. \`data-cap\` on \`#run-log\` bounds how many lines a long-running
455 * agent can pile into the DOM: past it, the oldest line is dropped as a new
456 * one arrives, so an hour-long run never grows without bound.
457 */
458 export const studioRunJs = /* js */ `
459 (function () {
460 var log = document.getElementById('run-log');
461 if (!log) return;
462 var url = log.getAttribute('data-stream');
463 var who = log.getAttribute('data-who') || 'agent';
464 var cap = parseInt(log.getAttribute('data-cap') || '0', 10) || 0;
465 var pulse = document.getElementById('run-pulse');
466 var jump = document.getElementById('run-jump');
467 var word = document.getElementById('run-word');
468 var summary = document.getElementById('run-summary');
469 var cancel = document.getElementById('run-cancel');
470 var stop = document.getElementById('run-stop');
471 var steer = document.getElementById('run-steer');
472 var live = document.getElementById('run-live');
473
474 var follow = true;
475 function atEnd() { return log.scrollTop + log.clientHeight >= log.scrollHeight - 12; }
476 function toEnd() { log.scrollTop = log.scrollHeight; follow = true; if (jump) jump.hidden = true; }
477 log.addEventListener('scroll', function () { follow = atEnd(); if (follow && jump) jump.hidden = true; });
478 if (jump) jump.addEventListener('click', toEnd);
479 function fit() {
480 var room = window.innerHeight - log.getBoundingClientRect().top - (steer ? steer.offsetHeight : 0) - 16;
481 if (room > 240) log.style.maxHeight = room + 'px';
482 if (follow) toEnd();
483 }
484 window.addEventListener('resize', fit);
485 fit();
486
487 function studioWord(run) {
488 if (run.state === 'queued') return 'waiting';
489 if (run.state === 'running') return 'working';
490 if (run.state === 'failed' || run.state === 'cancelled') return 'stopped';
491 var s = run.summary || '';
492 if (/opened pull|updated pull/.test(s)) return 'look';
493 if (/planned|no changes pushed/.test(s)) return 'planned';
494 return 'shipped';
495 }
496 function variantFor(w) {
497 return w === 'look' || w === 'shipped' ? 'success' : w === 'stopped' ? 'danger' : w === 'working' ? 'accent' : 'secondary';
498 }
499 function money(n) { return n < 0.01 ? '<$0.01' : '$' + n.toFixed(2); }
500 function paint(run) {
501 if (word) {
502 var w = studioWord(run);
503 word.setAttribute('data-word', w);
504 word.setAttribute('data-variant', variantFor(w));
505 word.textContent = w;
506 }
507 if (live) live.setAttribute('data-state', run.state);
508 if (summary && run.summary) summary.textContent = run.summary;
509 var cost = document.getElementById('run-cost');
510 if (cost && typeof run.costUsd === 'number' && run.costUsd > 0) { cost.textContent = money(run.costUsd) + ' spent'; cost.hidden = false; }
511 var turns = document.getElementById('run-turns');
512 if (turns && typeof run.turns === 'number' && run.turns > 0) { turns.textContent = run.turns + (run.turns === 1 ? ' turn' : ' turns'); turns.hidden = false; }
513 if (cancel) cancel.hidden = true;
514 if (stop) stop.hidden = true;
515 if (pulse && pulse.parentNode) pulse.parentNode.removeChild(pulse);
516 var elapsed = document.getElementById('run-elapsed');
517 if (elapsed && !elapsed.getAttribute('data-finished')) elapsed.setAttribute('data-finished', new Date().toISOString());
518 }
519
520 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 )/;
521 function line(kind, speaker, fill) {
522 var el = document.createElement('div');
523 el.className = 'ln ln-' + kind;
524 var w = document.createElement('span');
525 w.className = 'who';
526 w.textContent = speaker;
527 var t = document.createElement('span');
528 t.className = 'txt';
529 fill(t);
530 el.appendChild(w);
531 el.appendChild(t);
532 return el;
533 }
534 function draw(text) {
535 if (text.indexOf('you: ') === 0) return line('you', 'you', function (t) { t.textContent = text.slice(5); });
536 if (text.indexOf('\\u2192 ') === 0) {
537 var rest = text.slice(2), at = rest.indexOf(' ');
538 return line('tool', '', function (t) {
539 var b = document.createElement('b');
540 b.textContent = at === -1 ? rest : rest.slice(0, at);
541 t.appendChild(b);
542 t.appendChild(document.createTextNode(at === -1 ? '' : ' ' + rest.slice(at + 1)));
543 });
544 }
545 if (FORGE.test(text)) return line('forge', 'forge', function (t) { t.textContent = text; });
546 return line('say', who, function (t) { t.textContent = text; });
547 }
548 function add(el) {
549 log.insertBefore(el, pulse && pulse.parentNode === log ? pulse : jump);
550 if (cap) {
551 var kept = log.querySelectorAll('.ln:not(#run-pulse)');
552 while (kept.length > cap) { log.removeChild(kept[0]); kept = log.querySelectorAll('.ln:not(#run-pulse)'); }
553 }
554 if (follow) toEnd(); else if (jump) jump.hidden = false;
555 }
556
557 function tick() {
558 var elapsed = document.getElementById('run-elapsed');
559 if (!elapsed) return;
560 var started = Date.parse(elapsed.getAttribute('data-started') || '');
561 if (!started) return;
562 var finished = Date.parse(elapsed.getAttribute('data-finished') || '');
563 var end = finished || Date.now();
564 var s = Math.max(0, Math.round((end - started) / 1000));
565 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';
566 elapsed.textContent = (finished ? 'took ' : 'running for ') + text;
567 }
568 var clock = window.setInterval(tick, 1000);
569 tick();
570
571 if (steer && window.fetch) {
572 var text = steer.querySelector('textarea');
573 function size() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(160, text.scrollHeight) + 'px'; fit(); }
574 if (text) {
575 text.addEventListener('input', size);
576 text.addEventListener('keydown', function (ev) {
577 if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); if (steer.requestSubmit) steer.requestSubmit(); else steer.submit(); }
578 });
579 size();
580 }
581 steer.addEventListener('submit', function (ev) {
582 if (ev.submitter && ev.submitter.id === 'run-stop') return;
583 var api = steer.getAttribute('data-api');
584 if (!text || !api || steer.getAttribute('data-running') !== '1') return;
585 ev.preventDefault();
586 var said = text.value.trim();
587 if (!said) return;
588 text.disabled = true;
589 fetch(api, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text: said }) })
590 .then(function (r) { return r.ok ? r.json() : Promise.reject(r); })
591 .then(function (out) {
592 text.value = '';
593 size();
594 var note = document.getElementById('run-steer-note');
595 if (note) note.textContent = out.outcome === 'sent' ? 'Heard at its next step. Every word is kept in the log.' : 'Nobody was listening; it went on the conversation.';
596 })
597 .catch(function () { steer.submit(); })
598 .then(function () { text.disabled = false; text.focus(); });
599 });
600 }
601
602 if (!url || typeof EventSource === 'undefined') return;
603 var meta = document.querySelector('meta[http-equiv="refresh"]');
604 if (meta && !/url=/i.test(meta.getAttribute('content') || '')) meta.remove();
605 var source = new EventSource(url);
606 var empty = document.getElementById('run-empty');
607 var seeded = false;
608 source.addEventListener('line', function (event) {
609 if (!seeded) {
610 if (event.lastEventId === '1') {
611 Array.prototype.slice.call(log.querySelectorAll('.ln:not(#run-pulse)')).forEach(function (el) { log.removeChild(el); });
612 }
613 seeded = true;
614 if (empty) empty.hidden = true;
615 }
616 var text = event.data;
617 try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {}
618 add(draw(text));
619 });
620 source.addEventListener('done', function (event) {
621 source.close();
622 var run;
623 try { run = JSON.parse(event.data); } catch (err) { return; }
624 paint(run);
625 if (steer) {
626 steer.setAttribute('data-running', '0');
627 var n = document.getElementById('run-steer-note');
628 if (n) n.textContent = 'The run is over; this goes on the conversation and starts a run.';
629 var t = steer.querySelector('textarea');
630 if (t) t.placeholder = 'Tell ' + who + ' for its next run';
631 }
632 window.clearInterval(clock);
633 tick();
634 });
635 })();
636 `;
637
450638 /**
451639 * The code browser: the explorer column, the preview it paints beside itself,
452640 * and the fuzzy file finder.
src/web/pages/runs.ts +10
@@ -17,6 +17,7 @@ import {
1717 runLog,
1818 steerRepoRun,
1919 } from "../../actions/runs.ts";
20 import { getAppByRepo } from "../../core/apps.ts";
2021 import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts";
2122 import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts";
2223 import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts";
@@ -36,6 +37,7 @@ import { field, readForm } from "../form.ts";
3637 import { html, when, type Html } from "../html.ts";
3738 import { flashTo, page, redirect } from "../layout.ts";
3839 import { liveClientJs, liveMount } from "../live.ts";
40 import { studioRun } from "./studio.ts";
3941 import {
4042 btn,
4143 empty,
@@ -337,6 +339,14 @@ export function runPage(ctx: Ctx): Response {
337339 const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
338340 if (!found.ok) return notFoundPage(ctx);
339341 const { run, lines, truncated } = found.value;
342 // In the studio, working is a run: who is on it, the tell it came from,
343 // what it has cost, and the box to steer it — the same rule `repoHome`
344 // and `pullPage` follow. `?mode=dev` is the one-off way to the transcript
345 // dev already renders, without switching the browser.
346 const studio =
347 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
348 if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined)
349 return studioRun(ctx, repo, run, lines, truncated);
340350 const base = repoBase(repo);
341351 const actor =
342352 run.actorId === null ? undefined : getUserById(ctx.app.db, run.actorId);
src/web/pages/studio.ts +233 −4
@@ -53,6 +53,7 @@ import {
5353 activeRunFor,
5454 automaticRunsSince,
5555 checkSummary,
56 isTerminal,
5657 latestAgentRunsForRepos,
5758 latestRunsForIssues,
5859 listActiveRuns,
@@ -85,7 +86,7 @@ import {
8586 } from "../chrome.ts";
8687 import { field, readForm } from "../form.ts";
8788 import { html, when, type Html } from "../html.ts";
88 import { asksJs, liveRegionsJs } from "../client.ts";
89 import { asksJs, liveRegionsJs, studioRunJs } from "../client.ts";
8990 import { flashOrJson, flashTo, fragment, page, redirect } from "../layout.ts";
9091 import { liveClientJs, liveMount } from "../live.ts";
9192 import { isMode, modeCookie } from "../mode.ts";
@@ -116,6 +117,7 @@ import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts";
116117 import { onYouCard } from "./site.ts";
117118 import { previewCard } from "./apps.ts";
118119 import { pullState } from "./pulls.ts";
120 import { costText, durationText, logLine, runTitle } from "./runs.ts";
119121 import { composer, conversation, originGlyph } from "./thread.ts";
120122
121123 // ---- decisions: what asks a person ----
@@ -601,8 +603,9 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
601603 planned: "map",
602604 };
603605
604 /** One of the five (or six) studio words, as a badge. */
605 function wordBadge(word: Word): Html {
606 /** One of the five (or six) studio words, as a badge. An id names the one a
607 * live script repaints as a run finishes (`studioRunJs`). */
608 function wordBadge(word: Word, id?: string): Html {
606609 const variant =
607610 word === "look" || word === "shipped"
608611 ? "success"
@@ -611,7 +614,11 @@ function wordBadge(word: Word): Html {
611614 : word === "working"
612615 ? "accent"
613616 : "secondary";
614 return html`<span class="badge" data-variant="${variant}" data-word="${word}"
617 return html`<span
618 class="badge"
619 data-variant="${variant}"
620 data-word="${word}"
621 ${id === undefined ? null : html`id="${id}"`}
615622 >${word === "working" ? icon("loader") : null}${word}</span
616623 >`;
617624 }
@@ -1116,6 +1123,228 @@ function avatarOf(username: string): Html {
11161123 return avatar(username);
11171124 }
11181125
1126 // ---- working: one run, in studio words ----
1127
1128 /** At most this many of the run's own lines held in the page at once — the
1129 * live script drops the oldest as a new one lands, past this. */
1130 const RUN_TRANSCRIPT_CAP = 400;
1131
1132 /**
1133 * GET /:owner/:name/runs/:id in the studio: who is on it, the tell it came
1134 * from, how long it has gone and what it has cost, the transcript itself,
1135 * and its state in the studio's own word — the page someone sits on while
1136 * waiting for a change to become visible. Steer and Stop are the very same
1137 * forms `runPage` offers, posting to the very same routes with the very
1138 * same checks; `?mode=dev` reads the transcript there, unchanged.
1139 */
1140 export function studioRun(
1141 ctx: Ctx,
1142 repo: Repo,
1143 run: Run,
1144 lines: readonly string[],
1145 truncated: boolean,
1146 ): Response {
1147 const db = ctx.app.db;
1148 const base = repoBase(repo);
1149 const actor =
1150 run.actorId === null ? undefined : getUserById(db, run.actorId);
1151 const issue =
1152 run.issueId === null ? undefined : getIssueById(db, run.issueId);
1153 const who = actor?.username ?? "an agent";
1154 const title = runTitle(run, actor?.username, issue);
1155 const { word } = wordFor(run);
1156 const unfinished = !isTerminal(run.state);
1157 const writer = canWrite(db, repo, ctx.auth.user);
1158 const duration = durationText(run);
1159 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
1160 const shown = lines.slice(-RUN_TRANSCRIPT_CAP);
1161 const hidden = lines.length - shown.length;
1162
1163 // The pull this run's tell has become, once one exists: the tell itself
1164 // when the run is already a pull's own (feedback, a failed check, a
1165 // conflict fix), or the open pull that closes it while the run is still
1166 // the tell's first build — the case this page matters most for.
1167 const pull =
1168 issue === undefined
1169 ? undefined
1170 : issue.kind === "pull"
1171 ? getPull(db, repo.id, issue.number)
1172 : openPullsClosing(db, repo.id).get(issue.number);
1173
1174 const head = html`<div class="issue-head">
1175 <h1>${title}</h1>
1176 <div class="issue-meta">
1177 ${wordBadge(word, "run-word")}
1178 ${when(
1179 issue,
1180 (i) =>
1181 html`<a
1182 href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}"
1183 >#${i.number} ${i.title}</a
1184 >`,
1185 )}
1186 <span class="num" id="run-cost" ${run.costUsd > 0 ? null : html`hidden`}
1187 >${costText(run.costUsd)} spent</span
1188 >
1189 <span class="num" id="run-turns" ${run.turns > 0 ? null : html`hidden`}
1190 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
1191 >
1192 ${when(
1193 duration,
1194 (d) =>
1195 html`<span
1196 class="num"
1197 id="run-elapsed"
1198 data-started="${run.startedAt ?? ""}"
1199 data-finished="${run.finishedAt ?? ""}"
1200 >${run.finishedAt === null ? "running for" : "took"} ${d}</span
1201 >`,
1202 )}
1203 </div>
1204 <p class="run-summary" id="run-summary">${run.summary}</p>
1205 </div>`;
1206
1207 const body = html`${repoHeader(ctx, repo, "app", {
1208 compact: true,
1209 ownTitle: true,
1210 })}
1211 ${head}
1212 ${when(
1213 writer && unfinished && run.kind === "agent" && issue === undefined,
1214 () =>
1215 html`<div class="run-actions" id="run-cancel">
1216 <form method="post" action="${base}/runs/${run.id}/cancel">
1217 ${btn("Stop", { size: "sm", variant: "outline", icon: "square" })}
1218 </form>
1219 </div>`,
1220 )}
1221 ${when(
1222 writer && unfinished && run.kind !== "agent",
1223 () =>
1224 html`<div class="run-actions" id="run-cancel">
1225 <form method="post" action="${base}/runs/${run.id}/cancel">
1226 ${btn("Cancel run", {
1227 size: "sm",
1228 variant: "outline",
1229 icon: "square",
1230 })}
1231 </form>
1232 </div>`,
1233 )}
1234 ${when(pull, (p) => previewCard(ctx, repo, p))}
1235 ${when(
1236 truncated,
1237 () =>
1238 html`<p class="muted small mb">
1239 Showing the last 512 KB of the log; the file on disk has the rest.
1240 </p>`,
1241 )}
1242 ${when(
1243 shown.length === 0,
1244 () =>
1245 html`<p class="muted small" id="run-empty">
1246 Nothing has been logged yet.
1247 </p>`,
1248 )}
1249 <div class="run-live" id="run-live" data-state="${run.state}">
1250 <div
1251 class="run-log"
1252 id="run-log"
1253 data-who="${who}"
1254 data-cap="${RUN_TRANSCRIPT_CAP}"
1255 ${unfinished ? html`data-stream="${stream}"` : null}
1256 >
1257 ${when(
1258 hidden > 0,
1259 () =>
1260 html`<p class="muted small">
1261 Holding the last ${RUN_TRANSCRIPT_CAP} lines here; the run's own
1262 log keeps the rest.
1263 </p>`,
1264 )}
1265 ${shown.map((l) => logLine(l, who))}
1266 ${when(
1267 run.state === "running",
1268 () =>
1269 html`<div class="ln ln-pulse" id="run-pulse">
1270 <span class="who">${who}</span
1271 ><span class="txt"><i></i><i></i><i></i></span>
1272 </div>`,
1273 )}
1274 <button type="button" class="run-jump" id="run-jump" hidden>
1275 newer below
1276 </button>
1277 </div>
1278 ${when(
1279 writer && run.kind === "agent" && issue !== undefined,
1280 () =>
1281 html`<form
1282 class="run-steer"
1283 id="run-steer"
1284 method="post"
1285 action="${base}/runs/${run.id}/steer"
1286 data-api="/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/steer"
1287 data-running="${run.state === "running" ? "1" : "0"}"
1288 >
1289 <label class="sr-only" for="steer-text"
1290 >Say something to ${who}</label
1291 >
1292 <textarea
1293 class="input"
1294 id="steer-text"
1295 name="text"
1296 rows="1"
1297 required
1298 placeholder="${run.state === "running"
1299 ? `Steer ${who} — it hears you at its next step`
1300 : `Tell ${who} for its next run`}"
1301 ></textarea>
1302 <div class="bar">
1303 <span class="muted small" id="run-steer-note"
1304 >${run.state === "running"
1305 ? "Heard after the step it is on. Enter sends; every word is kept in the log."
1306 : "The run is over; this goes on the conversation and starts a run."}</span
1307 >
1308 <span class="spacer"></span>
1309 ${when(
1310 unfinished,
1311 () =>
1312 html`<button
1313 type="submit"
1314 class="btn"
1315 data-size="sm"
1316 data-variant="outline"
1317 id="run-stop"
1318 formaction="${base}/runs/${run.id}/cancel"
1319 formnovalidate
1320 >
1321 ${run.state === "running" ? "Stop" : "Cancel"}
1322 </button>`,
1323 )}
1324 ${btn("Send", { size: "sm", variant: "primary", type: "submit" })}
1325 </div>
1326 </form>`,
1327 )}
1328 </div>
1329 <p class="muted small mt">
1330 <a href="${base}/runs/${run.id}?mode=dev">${icon("code")}Read the transcript</a>
1331 — every tool call, in dev's own words.
1332 </p>`;
1333
1334 return page({
1335 ctx,
1336 title: `${title} · ${repo.name}`,
1337 crumbs: crumbsFor(repo),
1338 palette: repoPalette(repo),
1339 body,
1340 styleCss: studioCss,
1341 scriptJs: studioRunJs,
1342 // The no-script half of a live log: while the run is going, the page
1343 // refreshes itself; the script above removes this the moment it runs.
1344 ...(unfinished ? { refresh: { seconds: 5 } } : {}),
1345 });
1346 }
1347
11191348 // ---- one pull, in studio words ----
11201349
11211350 /**
test/studio.test.ts +165 −2
@@ -13,8 +13,8 @@ import { previewEnv, type EnvName } from "../src/apps/data.ts";
1313 import { AppsRuntime } from "../src/apps/runtime.ts";
1414 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1515 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { mergePull, openPull } from "../src/actions/pulls.ts";
1716 import { openIssue } from "../src/actions/issues.ts";
17 import { mergePull, openPull } from "../src/actions/pulls.ts";
1818 import {
1919 commitToBranch,
2020 createRepo,
@@ -25,7 +25,12 @@ import { getIssue, listIssues } from "../src/core/issues.ts";
2525 import { recordLook } from "../src/core/looks.ts";
2626 import { getPull, type Pull } from "../src/core/pulls.ts";
2727 import type { Repo } from "../src/core/repos.ts";
28 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
28 import {
29 claimRun,
30 createRun,
31 finishRun,
32 noteRunProgress,
33 } from "../src/core/runs.ts";
2934 import { createUser, getUserByName, type User } from "../src/core/users.ts";
3035 import type { Decider } from "../src/crew/decide.ts";
3136 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
@@ -1313,3 +1318,161 @@ describe("the studio's slot count", () => {
13131318 expect(page).toContain("Plan the next cloth");
13141319 });
13151320 });
1321
1322 /**
1323 * Working, on its own page: `/:owner/:name/runs/:id` in the studio names
1324 * the tell, the cost, and offers the steer box — same routes and checks as
1325 * dev's transcript, one click away at `?mode=dev`.
1326 */
1327 describe("working: a run's own page in the studio", () => {
1328 let dataDir: string;
1329 let app: App;
1330 let servers: Servers;
1331 let base: string;
1332 let repo: Repo;
1333 let alice: User;
1334 let scoutId: string;
1335 let writerCookie: string;
1336 let readerCookie: string;
1337
1338 const signIn = async (username: string): Promise<string> =>
1339 (
1340 (
1341 await fetch(`${base}/signin`, {
1342 method: "POST",
1343 body: new URLSearchParams({
1344 username,
1345 password: "a-good-passphrase",
1346 }),
1347 redirect: "manual",
1348 })
1349 ).headers.get("set-cookie") ?? ""
1350 ).split(";")[0]!;
1351
1352 const get = (path: string, cookie: string) =>
1353 fetch(`${base}${path}`, {
1354 headers: { cookie: `${cookie}; forge_mode=studio` },
1355 redirect: "manual",
1356 });
1357
1358 beforeAll(async () => {
1359 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-run-"));
1360 app = createApp({ dataDir, port: 0 });
1361 servers = await serve(app);
1362 base = servers.url;
1363 const a = await createUser(app.db, {
1364 username: "alice",
1365 email: "[email protected]",
1366 password: "a-good-passphrase",
1367 adminIfFirst: true,
1368 });
1369 if (!a.ok) throw new Error(a.error);
1370 alice = a.value;
1371 const b = await createUser(app.db, {
1372 username: "bob",
1373 email: "[email protected]",
1374 password: "a-good-passphrase",
1375 });
1376 if (!b.ok) throw new Error(b.error);
1377 writerCookie = await signIn("alice");
1378 readerCookie = await signIn("bob");
1379 const made = await createRepo(app, a.value, {
1380 name: "engine",
1381 visibility: "private",
1382 description: "an app that builds",
1383 });
1384 if (!made.ok) throw new Error(String(made.error));
1385 repo = made.value;
1386 const seeded = await commitToBranch(app, a.value, repo, {
1387 message: "Start",
1388 files: [{ path: "README.md", content: "engine\n" }],
1389 });
1390 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1391 const enabled = await enableApp(app, a.value, repo, { slug: "engine" });
1392 if (!enabled.ok) throw new Error(String(enabled.error));
1393 const scout = await createAgent(app, a.value, { username: "scout" });
1394 if (!scout.ok) throw new Error(String(scout.error));
1395 scoutId = scout.value.user.id;
1396 expect(addToCrew(app, a.value, repo, "scout").ok).toBe(true);
1397 });
1398
1399 afterAll(async () => {
1400 servers.stop();
1401 app.close();
1402 await rm(dataDir, { recursive: true, force: true });
1403 });
1404
1405 test("the studio transcript names the tell, the cost, and the steer box", async () => {
1406 const tell = openIssue(app, alice, repo, {
1407 title: "Fix the flicker on load",
1408 body: "It flashes white before the page renders.",
1409 });
1410 if (!tell.ok) throw new Error(String(tell.error));
1411 const run = createRun(app.db, {
1412 repoId: repo.id,
1413 kind: "agent",
1414 issueId: tell.value.id,
1415 actorId: scoutId,
1416 reason: "assigned",
1417 });
1418 claimRun(app.db, run.id);
1419 noteRunProgress(app.db, run.id, { costUsd: 0.42 });
1420
1421 const page = await (
1422 await get(`/alice/engine/runs/${run.id}`, writerCookie)
1423 ).text();
1424 expect(page).toContain("Fix the flicker on load");
1425 expect(page).toContain(`href="/alice/engine/issues/${tell.value.number}"`);
1426 expect(page).toContain("$0.42 spent");
1427 expect(page).toContain('data-word="working"');
1428 expect(page).toContain('id="run-steer"');
1429 expect(page).toContain("Steer scout");
1430 expect(page).toContain('id="run-stop"');
1431 // The transcript is on the page, and dev's own reading is one click away.
1432 expect(page).toContain('id="run-log"');
1433 expect(page).toContain(`href="/alice/engine/runs/${run.id}?mode=dev"`);
1434 });
1435
1436 test("a reader without access gets the forge's not-found, exactly as dev", async () => {
1437 const tell = openIssue(app, alice, repo, { title: "Private work" });
1438 if (!tell.ok) throw new Error(String(tell.error));
1439 const run = createRun(app.db, {
1440 repoId: repo.id,
1441 kind: "agent",
1442 issueId: tell.value.id,
1443 actorId: scoutId,
1444 reason: "assigned",
1445 });
1446 claimRun(app.db, run.id);
1447
1448 const asReader = await get(`/alice/engine/runs/${run.id}`, readerCookie);
1449 expect(asReader.status).toBe(404);
1450 });
1451
1452 test("a finished run renders in the studio's own word, with no Stop", async () => {
1453 const tell = openIssue(app, alice, repo, { title: "Tidy the footer" });
1454 if (!tell.ok) throw new Error(String(tell.error));
1455 const run = createRun(app.db, {
1456 repoId: repo.id,
1457 kind: "agent",
1458 issueId: tell.value.id,
1459 actorId: scoutId,
1460 reason: "assigned",
1461 });
1462 claimRun(app.db, run.id);
1463 finishRun(app.db, run.id, {
1464 state: "ok",
1465 summary: "opened pull #9",
1466 costUsd: 0.1,
1467 turns: 4,
1468 });
1469
1470 const page = await (
1471 await get(`/alice/engine/runs/${run.id}`, writerCookie)
1472 ).text();
1473 expect(page).toContain('data-word="look"');
1474 expect(page).not.toContain('id="run-stop"');
1475 expect(page).not.toContain(">Stop<");
1476 expect(page).not.toContain(">Cancel run<");
1477 });
1478 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs