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

A tell opens in studio words: an app's issue page #72

Merged opened this

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

Merged

scout/forge:scout/49-a-tell-opens-in-studio-words-an-app-s-is → main 2 commits fork

  • Merged. scout/49-a-tell-opens-in-studio-words-an-app-s-is was merged into main as 31a94c6.

Review

Sign in to review this change.
4 files changed +423 −5
docs/STUDIO.md +22
@@ -102,6 +102,28 @@ A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone
102102 who can write here; its title opens the transcript, where the agent can be
103103 steered mid-run (see `docs/APPS.md`, _Runs_).
104104
105 ## A tell, on its own page
106
107 `/:owner/:name/issues/:number` on an app, in the studio, is a tell — one
108 sentence someone said to the crew — not dev's labels, milestones and
109 dependency form. It answers three questions: who has it (or that nobody
110 does yet), what it is waiting on and what waits on it (`dependenciesOf`,
111 `src/core/dependencies.ts`, each a link, read-only here), and what the
112 crew has done with it — every agent run, newest first, who ran it, how
113 long it went, what it cost and its state in the studio's own word
114 (`tellWord`, `src/web/pages/studio.ts`), its title opening the transcript
115 — and the pull that closes it, with its check and a link into its
116 preview, when one exists. The sentence itself, and the reply, are the
117 conversation dev's page shows too (`conversation`/`composer`,
118 `src/web/pages/thread.ts`): the same action, the same same-origin check,
119 told once. The repository's own standing direction is not an ordinary
120 tell — it never gets picked up on its own — and the page says so rather
121 than showing it as waiting forever. *Read the details* is one link away,
122 `?mode=dev` on the same URL, where labels, the dependency form and the
123 assignee control stay the only place those exist; an issue on a
124 repository that is not an app, or any issue in dev mode, renders exactly
125 what it always has.
126
105127 ## Working, on its own page
106128
107129 `/:owner/:name/runs/:id` on an app, in the studio, is a run: who is on it,
src/web/pages/issues.ts +11 −1
@@ -5,6 +5,7 @@
55 */
66
77 import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts";
8 import { getAppByRepo } from "../../core/apps.ts";
89 import {
910 issueCounts,
1011 labelsFor,
@@ -62,6 +63,7 @@ import {
6263 threadFragment,
6364 } from "./thread.ts";
6465 import { crewActivityCard } from "./runs.ts";
66 import { studioTell } from "./studio.ts";
6567
6668 const PER_PAGE = 25;
6769
@@ -464,10 +466,18 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> {
464466 // ---- one issue ----
465467
466468 // GET /:owner/:name/issues/:number
467 export function issuePage(ctx: Ctx): Response {
469 export async function issuePage(ctx: Ctx): Promise<Response> {
468470 const found = threadContext(ctx, "issue");
469471 if (found instanceof Response) return found;
470472 const { repo, issue } = found;
473 // In the studio a tell is one sentence someone said, not dev's labels and
474 // dependency form — the same rule `pullPage` follows for a change.
475 // `?mode=dev` is the one-off way to dev's page without switching the
476 // browser, same as there.
477 const studio =
478 ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev";
479 if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined)
480 return await studioTell(ctx, repo, issue);
471481 const base = repoBase(repo);
472482 const thread = `${base}/issues/${issue.number}`;
473483
src/web/pages/studio.ts +280 −2
@@ -33,6 +33,8 @@ import {
3333 } from "../../apps/runtime.ts";
3434 import { listCrewRoles, type Agent } from "../../core/agents.ts";
3535 import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts";
36 import { dependenciesOf } from "../../core/dependencies.ts";
37 import { listEvents } from "../../core/events.ts";
3638 import {
3739 getIssueById,
3840 listComments,
@@ -123,7 +125,13 @@ import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts";
123125 import { onYouCard } from "./site.ts";
124126 import { previewCard } from "./apps.ts";
125127 import { pullState } from "./pulls.ts";
126 import { costText, durationText, logLine, runTitle } from "./runs.ts";
128 import {
129 costText,
130 durationText,
131 logLine,
132 reasonWords,
133 runTitle,
134 } from "./runs.ts";
127135 import { composer, conversation, originGlyph } from "./thread.ts";
128136
129137 // ---- decisions: what asks a person ----
@@ -582,7 +590,14 @@ function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
582590 </form>`;
583591 }
584592
585 type Word = "working" | "look" | "shipped" | "stopped" | "waiting" | "planned";
593 type Word =
594 | "working"
595 | "look"
596 | "shipped"
597 | "stopped"
598 | "waiting"
599 | "planned"
600 | "told";
586601
587602 /**
588603 * Everything the word functions below need to see production: the repo's
@@ -718,6 +733,7 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = {
718733 stopped: "alert",
719734 waiting: "clock",
720735 planned: "map",
736 told: "clock",
721737 };
722738
723739 /** One of the five (or six) studio words, as a badge. An id names the one a
@@ -1591,6 +1607,268 @@ export async function studioPull(
15911607 });
15921608 }
15931609
1610 // ---- one tell, in studio words ----
1611
1612 /** The pull whose merge closed this issue, from the event it left behind —
1613 * the same record `stateEvents` (thread.ts) reads for the timeline's own
1614 * "closed this" line, so this never disagrees with what the page already
1615 * shows. Undefined for an issue closed by hand, or still open. */
1616 function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined {
1617 if (issue.state !== "closed") return undefined;
1618 const closed = listEvents(db, { repoId: repo.id, limit: 200 }).find(
1619 (e) =>
1620 e.kind === "issue.closed" &&
1621 Number(e.payload.number) === issue.number &&
1622 e.payload.by === "merge",
1623 );
1624 if (closed === undefined) return undefined;
1625 const number = Number(closed.payload.pull);
1626 return Number.isInteger(number) ? getPull(db, repo.id, number) : undefined;
1627 }
1628
1629 /**
1630 * Where one tell stands, in the studio's own words: the pull that closed
1631 * or closes it — judged by `pullWord`, the one place that fact is decided,
1632 * so a merged tell reads *shipped* only once production is serving it,
1633 * same as everywhere else — else a run in flight, else its last run's own
1634 * word, else *told*: said, and nobody has picked it up yet. Undefined for
1635 * a closed issue nothing merged shut, so the caller falls back to the
1636 * plain state badge dev uses.
1637 */
1638 async function tellWord(
1639 wordCtx: WordCtx,
1640 repo: Repo,
1641 issue: Issue,
1642 closingPull: Pull | undefined,
1643 ): Promise<{ word: Word; note: string } | undefined> {
1644 const db = wordCtx.db;
1645 if (issue.state === "closed") {
1646 const pull = shippedBy(db, repo, issue);
1647 return pull ? await pullWord(wordCtx, pull) : undefined;
1648 }
1649 if (closingPull !== undefined) return await pullWord(wordCtx, closingPull);
1650 if (activeRunFor(db, "agent", issue.id) !== undefined)
1651 return { word: "working", note: "working now" };
1652 const last = listRuns(db, repo.id, {
1653 kind: "agent",
1654 issueId: issue.id,
1655 limit: 1,
1656 })[0];
1657 if (last !== undefined) return await runWord(wordCtx, last);
1658 return { word: "told", note: "not picked up yet" };
1659 }
1660
1661 /** Every open issue this one waits for, and every one that waits for it —
1662 * read-only: the dependency form stays dev's alone (`dependenciesCard`,
1663 * thread.ts). */
1664 function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Html {
1665 const { blockedBy, blocks } = dependenciesOf(db, issue.id);
1666 const list = (items: readonly Issue[]) =>
1667 html`<ul class="dep-list">
1668 ${items.map(
1669 (i) =>
1670 html`<li data-state="${i.state}">
1671 ${icon(i.state === "closed" ? "issueClosed" : "issue")}
1672 <a href="${base}/issues/${i.number}">${i.title}</a>
1673 <span class="muted small">#${i.number}</span>
1674 </li>`,
1675 )}
1676 </ul>`;
1677 if (blockedBy.length === 0 && blocks.length === 0)
1678 return card(
1679 html`<p class="muted small">Waits for nothing, holds up nothing.</p>`,
1680 { head: html`${icon("map")} <h2>Depends on</h2>` },
1681 );
1682 return card(
1683 html`${blockedBy.length === 0
1684 ? html`<p class="muted small">Waits for nothing.</p>`
1685 : html`<p class="small">Waits for ${blockedBy.length}</p>
1686 ${list(blockedBy)}`}
1687 ${blocks.length === 0
1688 ? null
1689 : html`<p class="small mt">Holds up ${blocks.length}</p>
1690 ${list(blocks)}`}`,
1691 { head: html`${icon("map")} <h2>Depends on</h2>` },
1692 );
1693 }
1694
1695 /** Every agent run for this tell, newest first: who, how long, what it
1696 * cost, and its state in the studio's own word — the title opens the
1697 * transcript. Empty when the crew has not touched it yet. */
1698 async function tellRuns(
1699 wordCtx: WordCtx,
1700 repo: Repo,
1701 issue: Issue,
1702 ): Promise<Html | null> {
1703 const runs = listRuns(wordCtx.db, repo.id, {
1704 kind: "agent",
1705 issueId: issue.id,
1706 limit: 10,
1707 });
1708 if (runs.length === 0) return null;
1709 const base = repoBase(repo);
1710 const actors = getUsersByIds(
1711 wordCtx.db,
1712 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
1713 );
1714 const rows = await Promise.all(
1715 runs.map(async (r) => {
1716 const who =
1717 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew");
1718 const { word } = await runWord(wordCtx, r);
1719 const duration = durationText(r);
1720 return row({
1721 href: `${base}/runs/${r.id}`,
1722 nav: true,
1723 lead: avatarOf(who),
1724 title: html`${who} <span class="muted">${reasonWords(r.reason)}</span>`,
1725 meta: html`${duration ? html`${duration} · ` : null}${r.summary ||
1726 "no summary yet"}`,
1727 trail: html`${wordBadge(word)}${when(
1728 r.costUsd > 0,
1729 () =>
1730 html`<span class="num muted small">${costText(r.costUsd)}</span>`,
1731 )}`,
1732 });
1733 }),
1734 );
1735 return listCard(rows, {
1736 head: html`${icon("bot")}
1737 <h2>Runs</h2>`,
1738 });
1739 }
1740
1741 /** The pull that closes this tell, with its check and a way into its
1742 * preview — nothing here that `studioPull`/`previewCard` do not already
1743 * draw. */
1744 async function tellClosingPull(
1745 ctx: Ctx,
1746 wordCtx: WordCtx,
1747 repo: Repo,
1748 pull: Pull,
1749 ): Promise<Html> {
1750 const base = repoBase(repo);
1751 const { word, note } = await pullWord(wordCtx, pull);
1752 return html`${card(
1753 html`<p>
1754 <a href="${base}/pulls/${pull.number}">${pull.title}</a>
1755 ${wordBadge(word)} <span class="muted small">${note}</span>
1756 </p>`,
1757 { head: html`${icon("pull")} <h2>Closes with</h2>` },
1758 )}${previewCard(ctx, repo, pull)}`;
1759 }
1760
1761 /**
1762 * GET /:owner/:name/issues/:number in studio mode: a tell is one sentence
1763 * someone said, and the page answers three questions — who has it, what it
1764 * is waiting on, and what the crew has done with it — nothing a dev-mode
1765 * reader needs (labels, milestones, the dependency form) belongs here.
1766 * `?mode=dev` reads that page, unchanged; the conversation and the reply
1767 * are the very same action as always (`conversation`/`composer`,
1768 * thread.ts).
1769 */
1770 export async function studioTell(
1771 ctx: Ctx,
1772 repo: Repo,
1773 issue: Issue,
1774 ): Promise<Response> {
1775 const db = ctx.app.db;
1776 const base = repoBase(repo);
1777 const thread = `${base}/issues/${issue.number}`;
1778 const direction = repo.directionIssueId === issue.id;
1779 const closingPull = openPullsClosing(db, repo.id).get(issue.number);
1780 const wordCtx: WordCtx = {
1781 db,
1782 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1783 runtime: appsRuntime(ctx.app),
1784 record: getAppByRepo(db, repo.id),
1785 };
1786 const [wordInfo, runsCard, closingCard] = await Promise.all([
1787 direction ? undefined : tellWord(wordCtx, repo, issue, closingPull),
1788 tellRuns(wordCtx, repo, issue),
1789 closingPull ? tellClosingPull(ctx, wordCtx, repo, closingPull) : undefined,
1790 ]);
1791
1792 const head = html`<div class="issue-head">
1793 <h1>${issue.title} <span class="number">#${issue.number}</span></h1>
1794 <div class="issue-meta">
1795 ${direction
1796 ? html`<span class="muted small"
1797 >${icon("map")} the repository's standing direction — always
1798 open, read by the planner directly, never picked up like an
1799 ordinary tell</span
1800 >`
1801 : wordInfo
1802 ? html`${wordBadge(wordInfo.word)}
1803 <span class="muted small">${wordInfo.note}</span>`
1804 : stateBadge(issue.state)}
1805 <span
1806 >${issue.originAuthor
1807 ? originTag(issue.originAuthor, originGlyph(repo))
1808 : userLink(issue.authorName)}
1809 opened this ${time(issue.createdAt)}</span
1810 >
1811 ${when(
1812 issue.originUrl,
1813 (url) =>
1814 html`<a class="origin-link" href="${url}" rel="noreferrer noopener"
1815 >${icon("external")}view on origin</a
1816 >`,
1817 )}
1818 <span
1819 >${issue.assigneeName === null
1820 ? html`<span class="muted">nobody has it yet</span>`
1821 : html`${when(issue.assigneeIsAgent, () => icon("bot"))}
1822 ${userLink(issue.assigneeName)} has it`}</span
1823 >
1824 </div>
1825 </div>`;
1826
1827 const talk = conversation(ctx, repo, issue, { openVerb: "opened this" });
1828
1829 const body = html`${repoHeader(ctx, repo, "issues", {
1830 compact: true,
1831 ownTitle: true,
1832 })}
1833 ${head}
1834 <div class="split">
1835 <div>
1836 ${talk.node}
1837 ${composer(ctx, {
1838 action: `${thread}/comments`,
1839 stateAction: `${thread}/state`,
1840 state: issue.state,
1841 kind: "issue",
1842 })}
1843 </div>
1844 <aside>
1845 ${runsCard}
1846 ${closingCard}
1847 ${tellDependencies(base, issue, db)}
1848 </aside>
1849 </div>
1850 <p class="muted small mt">
1851 <a href="${thread}?mode=dev">${icon("code")}Read the details</a> —
1852 labels, the dependency form and the assignee, dev's own way.
1853 </p>
1854 ${liveMount({
1855 repo: base,
1856 thread: "issue",
1857 number: issue.number,
1858 lastCommentId: talk.lastCommentId,
1859 })}`;
1860
1861 return page({
1862 ctx,
1863 title: `${issue.title} · ${repo.name}`,
1864 crumbs: crumbsFor(repo),
1865 palette: repoPalette(repo),
1866 body,
1867 styleCss: studioCss,
1868 scriptJs: liveClientJs,
1869 });
1870 }
1871
15941872 // ---- the studio home ----
15951873
15961874 /**
test/studio.test.ts +110 −2
@@ -13,7 +13,12 @@ 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 { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts";
16 import { linkIssue } from "../src/actions/dependencies.ts";
17 import {
18 assignIssue,
19 openIssue,
20 setIssueState,
21 } from "../src/actions/issues.ts";
1722 import { mergePull, openPull } from "../src/actions/pulls.ts";
1823 import {
1924 commitToBranch,
@@ -24,7 +29,7 @@ import { setCrewRole } from "../src/core/agents.ts";
2429 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
2530 import { recordLook } from "../src/core/looks.ts";
2631 import { getPull, type Pull } from "../src/core/pulls.ts";
27 import type { Repo } from "../src/core/repos.ts";
32 import { setDirectionIssue, type Repo } from "../src/core/repos.ts";
2833 import {
2934 claimRun,
3035 createRun,
@@ -337,6 +342,109 @@ describe("the studio", () => {
337342 expect(studioCookie).toContain("Merge pull request");
338343 });
339344
345 test("a tell on an app renders as a studio page — who has it, its runs, what blocks it, and no label form; ?mode=dev still renders today's page", async () => {
346 const blocker = openIssue(app, alice, repo, {
347 title: "Ground work",
348 body: "Do this first.",
349 });
350 if (!blocker.ok) throw new Error(String(blocker.error));
351 const told = openIssue(app, alice, repo, {
352 title: "Make the export button work",
353 body: "It does nothing right now.",
354 });
355 if (!told.ok) throw new Error(String(told.error));
356 const linked = linkIssue(app, alice, repo, told.value, blocker.value);
357 expect(linked.ok).toBe(true);
358 const scout = getUserByName(app.db, "scout")!;
359 const assigned = assignIssue(app, alice, repo, told.value, scout);
360 expect(assigned.ok).toBe(true);
361 const issue = getIssue(app.db, repo.id, told.value.number)!;
362 const run = createRun(app.db, {
363 repoId: repo.id,
364 kind: "agent",
365 issueId: issue.id,
366 actorId: scout.id,
367 reason: "assigned",
368 });
369 claimRun(app.db, run.id);
370 noteRunProgress(app.db, run.id, { costUsd: 0.12 });
371
372 const studio = await (
373 await get(`/alice/notes/issues/${issue.number}`)
374 ).text();
375 expect(studio).toContain("Make the export button work");
376 expect(studio).toContain("scout");
377 expect(studio).toContain("has it");
378 expect(studio).toContain("Ground work");
379 expect(studio).toContain(`href="/alice/notes/issues/${blocker.value.number}"`);
380 expect(studio).toContain('data-word="working"');
381 expect(studio).toContain("$0.12");
382 expect(studio).toContain('id="comment-body"');
383 expect(studio).not.toContain("<h2>Labels</h2>");
384 expect(studio).not.toContain("Apply labels");
385 expect(studio).toContain(
386 `href="/alice/notes/issues/${issue.number}?mode=dev"`,
387 );
388
389 const dev = await (
390 await get(`/alice/notes/issues/${issue.number}`, {
391 cookie: `${cookie}; forge_mode=dev`,
392 })
393 ).text();
394 expect(dev).toContain("<h2>Labels</h2>");
395 expect(dev).toContain("<h2>Assignee</h2>");
396 expect(dev).toContain("<h2>Depends on</h2>");
397 });
398
399 test("the repository's direction never reads as waiting forever", async () => {
400 const direction = openIssue(app, alice, repo, {
401 title: "Direction",
402 body: "Ship faster.",
403 });
404 if (!direction.ok) throw new Error(String(direction.error));
405 setDirectionIssue(app.db, repo.id, direction.value.id);
406
407 const studio = await (
408 await get(`/alice/notes/issues/${direction.value.number}`)
409 ).text();
410 expect(studio).toContain("standing direction");
411 expect(studio).not.toContain('data-word="told"');
412
413 // Leave the repository as the other tests in this suite found it.
414 setDirectionIssue(app.db, repo.id, null);
415 });
416
417 test("a tell on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => {
418 const plain = await createRepo(app, alice, {
419 name: "plaintell",
420 visibility: "private",
421 description: "no app here",
422 });
423 if (!plain.ok) throw new Error(String(plain.error));
424 const told = openIssue(app, alice, plain.value, {
425 title: "Plain issue",
426 body: "Nothing special.",
427 });
428 if (!told.ok) throw new Error(String(told.error));
429
430 const studioCookie = await (
431 await get(`/alice/plaintell/issues/${told.value.number}`)
432 ).text();
433 expect(studioCookie).toContain("<h2>Labels</h2>");
434 expect(studioCookie).toContain("<h2>Assignee</h2>");
435 expect(studioCookie).toContain("<h2>Depends on</h2>");
436 });
437
438 test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => {
439 const told = openIssue(app, alice, repo, { title: "Private work" });
440 if (!told.ok) throw new Error(String(told.error));
441 const anon = await fetch(
442 `${base}/alice/notes/issues/${told.value.number}`,
443 { headers: { cookie: "forge_mode=studio" }, redirect: "manual" },
444 );
445 expect(anon.status).toBe(404);
446 });
447
340448 test("several asks, one decision: a box per ask, one bar, one post; in place when asked for JSON", async () => {
341449 const scout = getUserByName(app.db, "scout")!;
342450 // The crew proposes: a pull by scout on its own branch.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs