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

The studio says how to get into the running app: the terminal, verbatim #99

Merged opened this

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

Merged

scout/forge:scout/57-the-studio-says-how-to-get-into-the-runn → main 1 commit fork

  • Merged. scout/57-the-studio-says-how-to-get-into-the-runn was merged into main as 2f0f6ab.

Review

Sign in to review this change.
8 files changed +228 −7
docs/STUDIO.md +11
@@ -70,6 +70,17 @@ coming up — but by "the crew" rather than an agent's own name, and never
7070 with a run's cost, turns or steer box. A private repository is unaffected:
7171 it already answers only to people who can read it at all.
7272
73 **The terminal** is the same operator's door, so it sits beside the same
74 facts: a writer sees the exact `ssh …` command production answers to,
75 copyable, with the forge's host key fingerprint beside it, or one line
76 saying the terminal is off — no SSH listener on this forge, or this
77 app's `forge.json` declares no `tty` (`docs/APPS.md`, _A terminal for
78 every app_). A pull's own preview card shows its own command the same
79 way, since a preview answers to a different login than production's. A
80 reader sees none of this. Both surfaces call the one function that knows
81 the fact, `terminalFor` (`src/actions/apps.ts`), so the dev App page and
82 the studio page never carry two ideas of what the command is.
83
7384 **The box** is the front door. A sentence becomes an issue — the first line
7485 its title, the rest its body — and the routing decision hands it to a
7586 builder: the builder with the least in flight, then by name. That is one
src/actions/apps.ts +17 −3
@@ -361,8 +361,6 @@ export interface AppView {
361361 readonly environments: EnvStatus[];
362362 /** Whether a runtime is serving at all (`FORGE_APPS=0` means no). */
363363 readonly serving: boolean;
364 /** `ssh -p 2222 notes@host` — how to reach production's terminal, when SSH is on. */
365 readonly ssh: string | undefined;
366364 }
367365
368366 /**
@@ -379,6 +377,23 @@ export function sshCommandFor(
379377 return appsRuntime(app)?.sshFor(record, env);
380378 }
381379
380 /**
381 * The one fact behind every "Terminal" line a page draws: the exact command,
382 * or undefined when there is nothing to show — SSH is off on this forge, or
383 * this environment's app declares no `tty`. A status that has never been
384 * live does not know its `tty` yet, so the command still shows; the
385 * environment's own state already says it is not running.
386 */
387 export function terminalFor(
388 app: App,
389 record: AppRecord,
390 env: EnvName,
391 status: EnvStatus | undefined,
392 ): string | undefined {
393 if (status?.tty === null) return undefined;
394 return sshCommandFor(app, record, env);
395 }
396
382397 /** What is running, for anyone who can see the repository. */
383398 export function appView(
384399 app: App,
@@ -394,7 +409,6 @@ export function appView(
394409 app: record,
395410 environments: runtime === undefined ? [] : runtime.status(record),
396411 serving: runtime !== undefined,
397 ssh: sshCommandFor(app, record, "production"),
398412 });
399413 }
400414
src/apps/runtime.ts +8
@@ -110,6 +110,12 @@ export interface EnvStatus {
110110 * already answers.
111111 */
112112 readonly note?: string | undefined;
113 /**
114 * The app's own `tty` command, once known live — `null` when its
115 * `forge.json` declares none. Unset while never live, since a checkout
116 * that has not started has not said either way.
117 */
118 readonly tty?: string | null | undefined;
113119 }
114120
115121 interface Live {
@@ -284,6 +290,7 @@ export class AppsRuntime {
284290 since: state.live.since,
285291 servingAt: state.live.servingAt,
286292 createdAt: state.live.createdAt,
293 tty: state.live.manifest.tty,
287294 ...(state.live.deployStartedAt === null
288295 ? {}
289296 : { deployStartedAt: state.live.deployStartedAt }),
@@ -302,6 +309,7 @@ export class AppsRuntime {
302309 since: state.live.since,
303310 servingAt: state.live.servingAt,
304311 createdAt: state.live.createdAt,
312 tty: state.live.manifest.tty,
305313 ...(state.live.deployStartedAt === null
306314 ? {}
307315 : { deployStartedAt: state.live.deployStartedAt }),
src/web/pages/apps.ts +17 −4
@@ -16,8 +16,8 @@ import {
1616 newApp,
1717 openEnvUrl,
1818 redeployEnv,
19 sshCommandFor,
2019 stopEnv,
20 terminalFor,
2121 } from "../../actions/apps.ts";
2222 import { listAllAgents, listCrew } from "../../actions/crew.ts";
2323 import { planningAvailable } from "../../crew/plan.ts";
@@ -69,6 +69,7 @@ import {
6969 row,
7070 selectField,
7171 sha,
72 terminalLine,
7273 time,
7374 userLink,
7475 field as formField,
@@ -557,7 +558,10 @@ export function appPage(ctx: Ctx): Response {
557558 const runs = listed.ok ? listed.value : [];
558559 const crew = listCrew(ctx.app, ctx.auth.user, repo);
559560 const url = appsRuntime(ctx.app)?.urlFor(record, "production");
560 const ssh = sshCommandFor(ctx.app, record, "production");
561 const production = envs.find((env) => env.env === "production");
562 const ssh = writer
563 ? terminalFor(ctx.app, record, "production", production)
564 : undefined;
561565 // The same rows the runs page draws, from the same two grouped reads: a
562566 // table's first column folds into a sliver at phone width, a row does not.
563567 const runsHead = html`${icon("activity")}
@@ -616,10 +620,10 @@ export function appPage(ctx: Ctx): Response {
616620 : html`<a href="${base}/app/production/open" class="mono">${url}</a>`}
617621 </dd>
618622 ${when(
619 ssh !== undefined,
623 writer,
620624 () =>
621625 html`<dt>Terminal</dt>
622 <dd><code class="mono">${ssh ?? ""}</code></dd>`,
626 <dd>${terminalLine(ssh, ctx.app.config.sshHostKey)}</dd>`,
623627 )}
624628 <dt>Access</dt>
625629 <dd>
@@ -718,8 +722,17 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
718722 : "No preview yet — one starts when this pull request's head deploys.";
719723 return card(html`<p class="muted small">${why}</p>`, { head });
720724 }
725 // A preview's terminal is a different login from production's — its own
726 // command, shown only to a writer, same as the app page's.
727 const writer = canWrite(ctx.app.db, repo, ctx.auth.user);
721728 return card(
722729 html`${envFacts(base, env)}
730 ${when(writer, () =>
731 terminalLine(
732 terminalFor(ctx.app, view.value.app, env.env, env),
733 ctx.app.config.sshHostKey,
734 ),
735 )}
723736 <div class="bar mt">
724737 ${btn("Open preview", {
725738 href: `${base}/app/${env.env}/open`,
src/web/pages/studio.ts +8
@@ -23,6 +23,7 @@ import {
2323 setIssueStateThrough,
2424 } from "../../actions/issues.ts";
2525 import { mergePull } from "../../actions/pulls.ts";
26 import { terminalFor } from "../../actions/apps.ts";
2627 import { tellCrew } from "../../actions/crew.ts";
2728 import { envPull, previewEnv } from "../../apps/data.ts";
2829 import {
@@ -112,6 +113,7 @@ import {
112113 seg,
113114 sha,
114115 stateBadge,
116 terminalLine,
115117 time,
116118 userLink,
117119 userTag,
@@ -885,6 +887,12 @@ function appPanels(
885887 >`}
886888 </p>
887889 ${when(production, (env) => envFacts(base, env))}
890 ${when(writer, () =>
891 terminalLine(
892 terminalFor(ctx.app, record, "production", production),
893 ctx.app.config.sshHostKey,
894 ),
895 )}
888896 </div>
889897 <span class="spacer"></span>
890898 ${lookNow(ctx, repo, record)}
src/web/ui.ts +22
@@ -376,6 +376,28 @@ export function envFacts(
376376 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`;
377377 }
378378
379 /**
380 * An environment's terminal, as copyable monospace text with the host key
381 * fingerprint beside it — so a first connection can be trusted deliberately
382 * — or one line saying the terminal is off, when there is no command to
383 * show. Every page that names a way in draws this one, never its own words
384 * for "off".
385 */
386 export function terminalLine(
387 command: string | undefined,
388 fingerprint?: string | undefined,
389 ): Html {
390 if (command === undefined)
391 return html`<p class="muted small">The terminal is off.</p>`;
392 return html`<p class="terminal-line">
393 <code class="mono">${command}</code>
394 ${when(
395 fingerprint,
396 (fp) => html`<span class="muted small">host key ${fp}</span>`,
397 )}
398 </p>`;
399 }
400
379401 /**
380402 * The two things a writer can do to an environment. Both are POST forms —
381403 * the routes they post to make the same-origin check — and a reader is
test/studio-words.test.ts +2
@@ -264,6 +264,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
264264 handle: { port: 4321 },
265265 runId: check.id,
266266 since: new Date().toISOString(),
267 manifest: { tty: null },
267268 });
268269
269270 // A merged change, production caught up to it — the studio's *shipped*.
@@ -314,6 +315,7 @@ describe("dev's vocabulary never reaches a studio page", () => {
314315 handle: { port: 4321 },
315316 runId: deploy.id,
316317 since: new Date().toISOString(),
318 manifest: { tty: null },
317319 });
318320 });
319321
test/studio.test.ts +143
@@ -1440,6 +1440,7 @@ describe("the studio head says why the app is not up", () => {
14401440 runId: deploy,
14411441 since: new Date(Date.now() - 60_000).toISOString(),
14421442 testsFailed: "1 of 3 tests failed",
1443 manifest: { tty: null },
14431444 },
14441445 });
14451446 const mine = await head(writer);
@@ -1449,6 +1450,143 @@ describe("the studio head says why the app is not up", () => {
14491450 });
14501451 });
14511452
1453 /**
1454 * The studio app page's Terminal — the one door an operator has into what
1455 * is running. `terminalFor` (`src/actions/apps.ts`) is the single fact both
1456 * this page and the dev App page read; this only checks it is drawn (or
1457 * withheld) correctly, not recomputed.
1458 */
1459 describe("the studio app page's terminal", () => {
1460 let dataDir: string;
1461 let app: App;
1462 let servers: Servers;
1463 let base: string;
1464 let repo: Repo;
1465 let record: AppRecord;
1466 let runtime: AppsRuntime;
1467 let writer: string;
1468 let reader: string;
1469
1470 interface Slot {
1471 readonly app: AppRecord;
1472 readonly env: EnvName;
1473 readonly chain: Promise<void>;
1474 readonly restarts: number[];
1475 readonly live?: unknown;
1476 }
1477 const putLive = (tty: string | null): void => {
1478 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
1479 `${record.id}/production`,
1480 {
1481 app: record,
1482 env: "production",
1483 chain: Promise.resolve(),
1484 restarts: [],
1485 live: {
1486 commitOid: "a".repeat(40),
1487 handle: { port: 4321 },
1488 runId: "fake",
1489 since: new Date().toISOString(),
1490 manifest: { tty },
1491 },
1492 },
1493 );
1494 };
1495
1496 const signIn = async (username: string): Promise<string> =>
1497 (
1498 (
1499 await fetch(`${base}/signin`, {
1500 method: "POST",
1501 body: new URLSearchParams({
1502 username,
1503 password: "a-good-passphrase",
1504 }),
1505 redirect: "manual",
1506 })
1507 ).headers.get("set-cookie") ?? ""
1508 ).split(";")[0]!;
1509
1510 const appPage = (cookie: string) =>
1511 fetch(`${base}/alice/beacon`, {
1512 headers: { cookie: `${cookie}; forge_mode=studio` },
1513 redirect: "manual",
1514 }).then((r) => r.text());
1515
1516 beforeAll(async () => {
1517 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-terminal-"));
1518 app = createApp({ dataDir, port: 0, host: "forge.example" });
1519 app.config.sshPort = 2222;
1520 app.config.sshHostKey = "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
1521 servers = await serve(app);
1522 base = servers.url;
1523 runtime = new AppsRuntime(app, {
1524 domain: "apps.test",
1525 scheme: "http",
1526 publicPort: servers.port,
1527 forgeUrl: servers.url,
1528 concurrency: 1,
1529 });
1530 const a = await createUser(app.db, {
1531 username: "alice",
1532 email: "[email protected]",
1533 password: "a-good-passphrase",
1534 adminIfFirst: true,
1535 });
1536 if (!a.ok) throw new Error(a.error);
1537 const b = await createUser(app.db, {
1538 username: "bob",
1539 email: "[email protected]",
1540 password: "a-good-passphrase",
1541 });
1542 if (!b.ok) throw new Error(b.error);
1543 writer = await signIn("alice");
1544 reader = await signIn("bob");
1545 const made = await createRepo(app, a.value, {
1546 name: "beacon",
1547 visibility: "public",
1548 description: "a light that stays on",
1549 });
1550 if (!made.ok) throw new Error(String(made.error));
1551 repo = made.value;
1552 const seeded = await commitToBranch(app, a.value, repo, {
1553 message: "Start",
1554 files: [{ path: "README.md", content: "beacon\n" }],
1555 });
1556 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1557 const enabled = await enableApp(app, a.value, repo, { slug: "beacon" });
1558 if (!enabled.ok) throw new Error(String(enabled.error));
1559 record = getAppByRepo(app.db, repo.id)!;
1560 });
1561
1562 afterAll(async () => {
1563 servers.stop();
1564 app.close();
1565 await rm(dataDir, { recursive: true, force: true });
1566 });
1567
1568 test("a writer sees the exact command and the host key; a reader sees neither", async () => {
1569 putLive("bun run src/tty.ts");
1570 const mine = await appPage(writer);
1571 expect(mine).toContain("ssh -p 2222 [email protected]");
1572 expect(mine).toContain(
1573 "host key SHA256:abcdefghijklmnopqrstuvwxyz",
1574 );
1575 const theirs = await appPage(reader);
1576 expect(theirs).not.toContain("ssh -p 2222 [email protected]");
1577 expect(theirs).not.toContain("The terminal is off");
1578 });
1579
1580 test("an app with no tty says the terminal is off, to a writer only", async () => {
1581 putLive(null);
1582 const mine = await appPage(writer);
1583 expect(mine).toContain("The terminal is off");
1584 expect(mine).not.toContain("ssh -p 2222 [email protected]");
1585 const theirs = await appPage(reader);
1586 expect(theirs).not.toContain("The terminal is off");
1587 });
1588 });
1589
14521590 /**
14531591 * GET /:owner/:name/app/state — the fragment the studio page's live client
14541592 * fetches on a deploy run event, and swaps into `#app-head`/`#app-previews`
@@ -1621,6 +1759,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16211759 handle: { port: 4321 },
16221760 runId: deployRun.id,
16231761 since: new Date().toISOString(),
1762 manifest: { tty: null },
16241763 },
16251764 });
16261765 const live = await (await get("/alice/vault/app/state", owner)).text();
@@ -1651,6 +1790,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16511790 servingAt,
16521791 createdAt,
16531792 deployStartedAt,
1793 manifest: { tty: null },
16541794 },
16551795 });
16561796
@@ -1669,6 +1809,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16691809 servingAt,
16701810 createdAt: deployStartedAt,
16711811 deployStartedAt,
1812 manifest: { tty: null },
16721813 },
16731814 });
16741815 const prompt = await (await get("/alice/vault/app/state", owner)).text();
@@ -1685,6 +1826,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
16851826 since: servingAt,
16861827 servingAt,
16871828 deployStartedAt,
1829 manifest: { tty: null },
16881830 },
16891831 });
16901832 const pressed = await (await get("/alice/vault/app/state", owner)).text();
@@ -1895,6 +2037,7 @@ describe("words come from facts, not from run.summary", () => {
18952037 handle: { port: 4321 },
18962038 runId: "fake",
18972039 since: new Date().toISOString(),
2040 manifest: { tty: null },
18982041 },
18992042 }
19002043 : {}),

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs