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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/51-working-opens-a-transcript-that-reads-as → main 2 commits fork
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 | ||
| 86 | 86 | who can write here; its title opens the transcript, where the agent can be |
| 87 | 87 | steered mid-run (see `docs/APPS.md`, _Runs_). |
| 88 | 88 | |
| 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 | ||
| 89 | 105 | ## The look, on its own page |
| 90 | 106 | |
| 91 | 107 | `/: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 */ ` | ||
| 447 | 447 | })(); |
| 448 | 448 | `; |
| 449 | 449 | |
| 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 | ||
| 450 | 638 | /** |
| 451 | 639 | * The code browser: the explorer column, the preview it paints beside itself, |
| 452 | 640 | * and the fuzzy file finder. |
src/web/pages/runs.ts +10
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | runLog, |
| 18 | 18 | steerRepoRun, |
| 19 | 19 | } from "../../actions/runs.ts"; |
| 20 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 20 | 21 | import { getIssueById, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 21 | 22 | import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; |
| 22 | 23 | import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts"; |
| @@ -36,6 +37,7 @@ import { field, readForm } from "../form.ts"; | ||
| 36 | 37 | import { html, when, type Html } from "../html.ts"; |
| 37 | 38 | import { flashTo, page, redirect } from "../layout.ts"; |
| 38 | 39 | import { liveClientJs, liveMount } from "../live.ts"; |
| 40 | import { studioRun } from "./studio.ts"; | |
| 39 | 41 | import { |
| 40 | 42 | btn, |
| 41 | 43 | empty, |
| @@ -337,6 +339,14 @@ export function runPage(ctx: Ctx): Response { | ||
| 337 | 339 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| 338 | 340 | if (!found.ok) return notFoundPage(ctx); |
| 339 | 341 | 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); | |
| 340 | 350 | const base = repoBase(repo); |
| 341 | 351 | const actor = |
| 342 | 352 | run.actorId === null ? undefined : getUserById(ctx.app.db, run.actorId); |
src/web/pages/studio.ts +233 −4
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | activeRunFor, |
| 54 | 54 | automaticRunsSince, |
| 55 | 55 | checkSummary, |
| 56 | isTerminal, | |
| 56 | 57 | latestAgentRunsForRepos, |
| 57 | 58 | latestRunsForIssues, |
| 58 | 59 | listActiveRuns, |
| @@ -85,7 +86,7 @@ import { | ||
| 85 | 86 | } from "../chrome.ts"; |
| 86 | 87 | import { field, readForm } from "../form.ts"; |
| 87 | 88 | import { html, when, type Html } from "../html.ts"; |
| 88 | import { asksJs, liveRegionsJs } from "../client.ts"; | |
| 89 | import { asksJs, liveRegionsJs, studioRunJs } from "../client.ts"; | |
| 89 | 90 | import { flashOrJson, flashTo, fragment, page, redirect } from "../layout.ts"; |
| 90 | 91 | import { liveClientJs, liveMount } from "../live.ts"; |
| 91 | 92 | import { isMode, modeCookie } from "../mode.ts"; |
| @@ -116,6 +117,7 @@ import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts"; | ||
| 116 | 117 | import { onYouCard } from "./site.ts"; |
| 117 | 118 | import { previewCard } from "./apps.ts"; |
| 118 | 119 | import { pullState } from "./pulls.ts"; |
| 120 | import { costText, durationText, logLine, runTitle } from "./runs.ts"; | |
| 119 | 121 | import { composer, conversation, originGlyph } from "./thread.ts"; |
| 120 | 122 | |
| 121 | 123 | // ---- decisions: what asks a person ---- |
| @@ -601,8 +603,9 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 601 | 603 | planned: "map", |
| 602 | 604 | }; |
| 603 | 605 | |
| 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 { | |
| 606 | 609 | const variant = |
| 607 | 610 | word === "look" || word === "shipped" |
| 608 | 611 | ? "success" |
| @@ -611,7 +614,11 @@ function wordBadge(word: Word): Html { | ||
| 611 | 614 | : word === "working" |
| 612 | 615 | ? "accent" |
| 613 | 616 | : "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}"`} | |
| 615 | 622 | >${word === "working" ? icon("loader") : null}${word}</span |
| 616 | 623 | >`; |
| 617 | 624 | } |
| @@ -1116,6 +1123,228 @@ function avatarOf(username: string): Html { | ||
| 1116 | 1123 | return avatar(username); |
| 1117 | 1124 | } |
| 1118 | 1125 | |
| 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 | ||
| 1119 | 1348 | // ---- one pull, in studio words ---- |
| 1120 | 1349 | |
| 1121 | 1350 | /** |
test/studio.test.ts +165 −2
| @@ -13,8 +13,8 @@ import { previewEnv, type EnvName } from "../src/apps/data.ts"; | ||
| 13 | 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 17 | 16 | import { openIssue } from "../src/actions/issues.ts"; |
| 17 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 18 | 18 | import { |
| 19 | 19 | commitToBranch, |
| 20 | 20 | createRepo, |
| @@ -25,7 +25,12 @@ import { getIssue, listIssues } from "../src/core/issues.ts"; | ||
| 25 | 25 | import { recordLook } from "../src/core/looks.ts"; |
| 26 | 26 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 27 | 27 | 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"; | |
| 29 | 34 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 30 | 35 | import type { Decider } from "../src/crew/decide.ts"; |
| 31 | 36 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; |
| @@ -1313,3 +1318,161 @@ describe("the studio's slot count", () => { | ||
| 1313 | 1318 | expect(page).toContain("Plan the next cloth"); |
| 1314 | 1319 | }); |
| 1315 | 1320 | }); |
| 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 | }); |
new above
offline