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

The app's studio page shows a new deployment arriving without a reload #53

Merged opened this

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

Merged

scout/forge:scout/35-the-app-s-studio-page-shows-a-new-deploy → main 1 commit fork

  • Merged. scout/35-the-app-s-studio-page-shows-a-new-deploy was merged into main as b4d9cd1.

Review

Sign in to review this change.
7 files changed +348 −46
docs/STUDIO.md +4 −1
@@ -32,7 +32,10 @@ An app's home in the studio (`/:owner/:name`, when the repository is an
3232 app) shows, top to bottom: what it serves and where; the box; what asks
3333 you; what is happening now. Beside it, who is on the crew and what each is
3434 for, this week's numbers (shipped, runs, spend, spend per shipped change),
35 and how the repository runs itself, when it does.
35 and how the repository runs itself, when it does. The page keeps itself
36 current about deployments: a deploy run for the app being viewed refetches
37 the head card and previews list in place, so a change serving in its
38 preview shows up without a reload.
3639
3740 **The box** is the front door. A sentence becomes an issue — the first line
3841 its title, the rest its body — and the routing decision hands it to a
src/http/server.ts +4
@@ -77,6 +77,7 @@ import {
7777 } from "../web/pages/issues.ts";
7878 import { globalMapPage, mapPage } from "../web/pages/map.ts";
7979 import {
80 appStateFragment,
8081 asksSubmit,
8182 modeSubmit,
8283 saySubmit,
@@ -597,6 +598,9 @@ export function buildRouter(): Router<Handler> {
597598 // The app a repository runs as, and the runs it produced. Opening an
598599 // environment goes through the forge so a private app gets its grant.
599600 router.add("GET", "/:owner/:name/app", appPage);
601 // The studio page's own live refresh: the head card and previews list,
602 // bare, fetched on a deploy run event for the app being viewed.
603 router.add("GET", "/:owner/:name/app/state", appStateFragment);
600604 router.add("GET", "/:owner/:name/app/:env/open", appOpen);
601605 // Where a private app sends a person who arrived by its link.
602606 router.add("GET", "/apps/open", appsOpen);
src/web/layout.ts +2
@@ -521,6 +521,8 @@ export function fragment(body: Html, status = 200): Response {
521521 "content-type": "text/html; charset=utf-8",
522522 "content-security-policy":
523523 "default-src 'none'; style-src 'none'; sandbox",
524 "referrer-policy": "same-origin",
525 "x-content-type-options": "nosniff",
524526 "cache-control": "no-store",
525527 },
526528 });
src/web/live.ts +41
@@ -34,6 +34,12 @@ export interface LiveConfig {
3434 number?: number;
3535 /** Id of the last comment already on the page (for the ?after= fetch). */
3636 lastCommentId?: string;
37 /**
38 * The studio app page: a deploy run event for this repo fetches
39 * `<repo>/app/state` and swaps the head card and previews list in place,
40 * rather than only offering the pill.
41 */
42 app?: boolean;
3743 }
3844
3945 /** The config element, the polite status line, and the (hidden) activity pill. */
@@ -46,6 +52,7 @@ export function liveMount(config: LiveConfig): Html {
4652 data-thread="${config.thread ?? ""}"
4753 data-number="${config.number ?? ""}"
4854 data-last="${config.lastCommentId ?? ""}"
55 data-app="${config.app ? "1" : ""}"
4956 ></div>
5057 <p
5158 class="live-status"
@@ -66,6 +73,7 @@ export const liveClientJs = /* js */ `
6673 var thread = cfg.getAttribute('data-thread');
6774 var number = cfg.getAttribute('data-number');
6875 var lastId = cfg.getAttribute('data-last') || '';
76 var isApp = cfg.getAttribute('data-app') === '1';
6977 var pill = document.getElementById('live-pill');
7078 var status = document.getElementById('live-status');
7179 var conv = document.getElementById('conversation');
@@ -117,9 +125,42 @@ export const liveClientJs = /* js */ `
117125 .catch(function () {});
118126 }
119127
128 // A deploy run for the app this page is about: refetch the app's own
129 // fragment and swap the head card and previews list in place, so a change
130 // serving in its preview shows up without a reload. At most one fetch in
131 // flight — a burst of queued/started/finished for the same push collapses
132 // to whatever the last one finds.
133 var appBusy = false, appQueued = false;
134 function isDeploy(data) {
135 return isApp && data && data.kind && data.kind.indexOf('run.') === 0 &&
136 data.payload && data.payload.kind === 'deploy';
137 }
138 function refreshApp() {
139 if (appBusy) { appQueued = true; return; }
140 appBusy = true;
141 fetch(repo + '/app/state', { headers: { accept: 'text/html' } })
142 .then(function (r) { return r.ok ? r.text() : ''; })
143 .then(function (text) {
144 if (!text) return;
145 var holder = document.createElement('div');
146 holder.innerHTML = text;
147 ['app-head', 'app-previews'].forEach(function (id) {
148 var mine = document.getElementById(id);
149 var theirs = holder.querySelector('#' + id);
150 if (mine && theirs) mine.replaceWith(theirs);
151 });
152 })
153 .catch(function () {})
154 .then(function () {
155 appBusy = false;
156 if (appQueued) { appQueued = false; refreshApp(); }
157 });
158 }
159
120160 function onEvent(e) {
121161 var data;
122162 try { data = JSON.parse(e.data); } catch (_) { return; }
163 if (isDeploy(data)) { refreshApp(); return; }
123164 if (relevant(data) && conv && (data.kind === thread + '.commented')) {
124165 appendNew();
125166 } else if (relevant(data) || !thread) {
src/web/pages/studio.ts +80 −44
@@ -79,7 +79,7 @@ import {
7979 import { field, readForm } from "../form.ts";
8080 import { html, when, type Html } from "../html.ts";
8181 import { asksJs, liveRegionsJs } from "../client.ts";
82 import { flashOrJson, flashTo, page, redirect } from "../layout.ts";
82 import { flashOrJson, flashTo, fragment, page, redirect } from "../layout.ts";
8383 import { liveClientJs, liveMount } from "../live.ts";
8484 import { isMode, modeCookie } from "../mode.ts";
8585 import {
@@ -668,51 +668,20 @@ function previewsPanel(
668668 );
669669 }
670670
671 // ---- the app in the studio ----
672
673 export async function studioApp(
671 /**
672 * The head card and the previews list: what production is doing, and every
673 * open pull's own environment. One function, called for the page's first
674 * paint and for the fragment a deploy event refreshes in place
675 * (`appStateFragment`) — the two never draw two copies of the same markup.
676 */
677 function appPanels(
674678 ctx: Ctx,
675679 repo: Repo,
676680 record: AppRecord,
677 ): Promise<Response> {
681 openPulls: readonly Pull[],
682 ): { head: Html; previews: Html | null } {
678683 const db = ctx.app.db;
679684 const base = repoBase(repo);
680 const crew = listCrewRoles(db, repo.id);
681 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
682 const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 });
683 const actors = getUsersByIds(
684 db,
685 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
686 );
687 const issues = new Map<string, Issue>();
688 for (const r of runs)
689 if (r.issueId !== null && !issues.has(r.issueId)) {
690 const i = getIssueById(db, r.issueId);
691 if (i) issues.set(r.issueId, i);
692 }
693 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
694 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
695 const pullChecks = latestRunsForIssues(
696 db,
697 openPulls.map((p) => p.id),
698 );
699 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
700 const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
701 (r) => r.createdAt >= week,
702 );
703 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
704 const shipped = listPulls(db, repo.id, {
705 state: "closed",
706 limit: 100,
707 }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length;
708 const ledgerWeek = decisionStats(db, repo.id, week);
709 const autonomy =
710 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
711 const moving = runs.some(
712 (r) => r.state === "running" || r.state === "queued",
713 );
714
715 // ---- the app itself ----
716685 const runtime = appsRuntime(ctx.app);
717686 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
718687 const previews = previewsPanel(base, runtime, record, openPulls, envs);
@@ -753,6 +722,73 @@ export async function studioApp(
753722 })}
754723 ${when(production, (env) => envActions(base, env, writer))}
755724 </div>`;
725 return { head, previews };
726 }
727
728 /**
729 * GET /:owner/:name/app/state — the head card and previews list, bare. The
730 * studio page's own live client (`live.ts`) fetches this on a deploy run
731 * event for the app being viewed and swaps the two in place, so a change
732 * serving in its preview shows up without a reload. Authorized exactly as
733 * the studio page: a viewer who cannot see the app gets the same 404.
734 */
735 export function appStateFragment(ctx: Ctx): Response {
736 const repo = loadRepo(ctx);
737 if (!repo) return fragment(html``, 404);
738 const record = getAppByRepo(ctx.app.db, repo.id);
739 if (!record) return fragment(html``, 404);
740 const openPulls = listPulls(ctx.app.db, repo.id, {
741 state: "open",
742 limit: 10,
743 });
744 const { head, previews } = appPanels(ctx, repo, record, openPulls);
745 return fragment(html`<div id="app-head">${head}</div>
746 <div id="app-previews">${previews}</div>`);
747 }
748
749 // ---- the app in the studio ----
750
751 export async function studioApp(
752 ctx: Ctx,
753 repo: Repo,
754 record: AppRecord,
755 ): Promise<Response> {
756 const db = ctx.app.db;
757 const base = repoBase(repo);
758 const crew = listCrewRoles(db, repo.id);
759 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
760 const runs = listRuns(db, repo.id, { kind: "agent", limit: 8 });
761 const actors = getUsersByIds(
762 db,
763 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
764 );
765 const issues = new Map<string, Issue>();
766 for (const r of runs)
767 if (r.issueId !== null && !issues.has(r.issueId)) {
768 const i = getIssueById(db, r.issueId);
769 if (i) issues.set(r.issueId, i);
770 }
771 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
772 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
773 const pullChecks = latestRunsForIssues(
774 db,
775 openPulls.map((p) => p.id),
776 );
777 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
778 const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
779 (r) => r.createdAt >= week,
780 );
781 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
782 const shipped = listPulls(db, repo.id, {
783 state: "closed",
784 limit: 100,
785 }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length;
786 const ledgerWeek = decisionStats(db, repo.id, week);
787 const autonomy =
788 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
789
790 // ---- the app itself ----
791 const { head, previews } = appPanels(ctx, repo, record, openPulls);
756792
757793 // ---- the box ----
758794 const tell = html`<form
@@ -1011,7 +1047,7 @@ export async function studioApp(
10111047 compact: true,
10121048 ownTitle: true,
10131049 })}
1014 ${head}
1050 <div id="app-head">${head}</div>
10151051 <div class="dash">
10161052 <div class="dash-main stack">
10171053 ${tell}
@@ -1022,7 +1058,7 @@ export async function studioApp(
10221058 >
10231059 ${asks}
10241060 </div>
1025 ${previews}
1061 <div id="app-previews">${previews}</div>
10261062 <div
10271063 id="live-now"
10281064 data-live="run.*,pull.*,issue.*"
@@ -1035,7 +1071,7 @@ export async function studioApp(
10351071 ${crewCard} ${weekCard} ${ownCard} ${more}
10361072 </aside>
10371073 </div>
1038 ${when(moving, () => liveMount({ repo: base }))}`;
1074 ${liveMount({ repo: base, app: true })}`;
10391075 return page({
10401076 ctx,
10411077 title: `${repo.name}`,
test/live-pages.test.ts +35
@@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
13 import { enableApp } from "../src/actions/apps.ts";
1314 import { editFiles } from "../src/actions/contents.ts";
1415 import { createApp, type App } from "../src/app.ts";
1516 import { createRepo } from "../src/core/repos.ts";
@@ -70,4 +71,38 @@ describe("live browsing pages", () => {
7071 expect(html).toContain('id="live"');
7172 expect(html).toContain("new EventSource");
7273 });
74
75 test("the studio app page wires up the live client with data-app, so a deploy run event refetches the app's own fragment", async () => {
76 const user = await createUser(app.db, {
77 username: "nia",
78 email: "[email protected]",
79 password: "live-pages-pass",
80 adminIfFirst: true,
81 });
82 if (!user.ok) throw new Error("user");
83 const repo = await createRepo(app.db, app.reposRoot, {
84 ownerId: user.value.id,
85 name: "runner",
86 visibility: "public",
87 });
88 if (!repo.ok) throw new Error("repo");
89 const committed = await editFiles(app, user.value, repo.value, {
90 files: [{ path: "README.md", content: "# runner\n" }],
91 message: "first commit",
92 });
93 if (!committed.ok) throw new Error("commit");
94 const enabled = await enableApp(app, user.value, repo.value, {
95 slug: "runner",
96 });
97 if (!enabled.ok) throw new Error(String(enabled.error));
98
99 const html = await (
100 await fetch(`${base}/nia/runner`, {
101 headers: { cookie: "forge_mode=studio" },
102 })
103 ).text();
104 expect(html).toContain('id="live"');
105 expect(html).toContain('data-app="1"');
106 expect(html).toContain("new EventSource");
107 });
73108 });
test/studio.test.ts +182 −1
@@ -9,7 +9,7 @@ import { tmpdir } from "node:os";
99 import { dirname, join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { enableApp } from "../src/actions/apps.ts";
12 import type { EnvName } from "../src/apps/data.ts";
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";
@@ -915,3 +915,184 @@ describe("the studio head says why the app is not up", () => {
915915 expect(mine).toContain("serving · tests failed: 1 of 3 tests failed");
916916 });
917917 });
918
919 /**
920 * GET /:owner/:name/app/state — the fragment the studio page's live client
921 * fetches on a deploy run event, and swaps into `#app-head`/`#app-previews`
922 * in place of a reload. It is authorized exactly as the studio page, and
923 * renders from the same `appPanels` function the page's first paint does.
924 */
925 describe("the app's live fragment: /:owner/:name/app/state", () => {
926 let dataDir: string;
927 let app: App;
928 let servers: Servers;
929 let base: string;
930 let runtime: AppsRuntime;
931 let repo: Repo;
932 let record: AppRecord;
933 let owner: string;
934 let stranger: string;
935
936 interface PreviewSlot {
937 readonly app: AppRecord;
938 readonly env: EnvName;
939 readonly chain: Promise<void>;
940 readonly restarts: number[];
941 deploying?: unknown;
942 live?: unknown;
943 lastError?: string;
944 lastRunId?: string;
945 }
946 const putEnv = (
947 env: EnvName,
948 slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">,
949 ): void => {
950 (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set(
951 `${record.id}/${env}`,
952 { app: record, env, chain: Promise.resolve(), restarts: [], ...slot },
953 );
954 };
955
956 const signIn = async (username: string): Promise<string> =>
957 (
958 (
959 await fetch(`${base}/signin`, {
960 method: "POST",
961 body: new URLSearchParams({
962 username,
963 password: "a-good-passphrase",
964 }),
965 redirect: "manual",
966 })
967 ).headers.get("set-cookie") ?? ""
968 ).split(";")[0]!;
969
970 const get = (path: string, cookie: string) =>
971 fetch(`${base}${path}`, {
972 headers: { cookie: `${cookie}; forge_mode=studio` },
973 redirect: "manual",
974 });
975
976 beforeAll(async () => {
977 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-state-"));
978 app = createApp({ dataDir, port: 0 });
979 servers = await serve(app);
980 base = servers.url;
981 runtime = new AppsRuntime(app, {
982 domain: "state.test",
983 scheme: "http",
984 publicPort: servers.port,
985 forgeUrl: servers.url,
986 concurrency: 1,
987 });
988 const a = await createUser(app.db, {
989 username: "alice",
990 email: "[email protected]",
991 password: "a-good-passphrase",
992 adminIfFirst: true,
993 });
994 if (!a.ok) throw new Error(a.error);
995 const c = await createUser(app.db, {
996 username: "carol",
997 email: "[email protected]",
998 password: "a-good-passphrase",
999 });
1000 if (!c.ok) throw new Error(c.error);
1001 owner = await signIn("alice");
1002 stranger = await signIn("carol");
1003 const made = await createRepo(app, a.value, {
1004 name: "vault",
1005 visibility: "private",
1006 description: "",
1007 });
1008 if (!made.ok) throw new Error(String(made.error));
1009 repo = made.value;
1010 const seeded = await commitToBranch(app, a.value, repo, {
1011 message: "Start",
1012 files: [{ path: "README.md", content: "v\n" }],
1013 });
1014 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1015 const enabled = await enableApp(app, a.value, repo, { slug: "vault" });
1016 if (!enabled.ok) throw new Error(String(enabled.error));
1017 record = getAppByRepo(app.db, repo.id)!;
1018 });
1019
1020 afterAll(async () => {
1021 servers.stop();
1022 app.close();
1023 await rm(dataDir, { recursive: true, force: true });
1024 });
1025
1026 test("renders the head card for a viewer who may see it, with the security floor's headers; refuses one who may not, same as the page", async () => {
1027 const fragment = await get("/alice/vault/app/state", owner);
1028 expect(fragment.status).toBe(200);
1029 expect(fragment.headers.get("x-content-type-options")).toBe("nosniff");
1030 expect(fragment.headers.get("referrer-policy")).toBe("same-origin");
1031 const text = await fragment.text();
1032 expect(text).toContain('id="app-head"');
1033 expect(text).toContain('id="app-previews"');
1034 expect(text).toContain("not running");
1035
1036 const page = await (await get("/alice/vault", owner)).text();
1037 expect(page).toContain("not running");
1038
1039 const refusedFragment = await get("/alice/vault/app/state", stranger);
1040 expect(refusedFragment.status).toBe(404);
1041 const refusedPage = await get("/alice/vault", stranger);
1042 expect(refusedPage.status).toBe(404);
1043 });
1044
1045 test("a preview flips from deploying to live between two calls, and the link stays correct", async () => {
1046 const alice = getUserByName(app.db, "alice")!;
1047 const branch = await commitToBranch(app, alice, repo, {
1048 branch: "feature/x",
1049 base: repo.defaultBranch,
1050 message: "Add X",
1051 files: [{ path: "X.md", content: "x\n" }],
1052 });
1053 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1054 const opened = await openPull(app, alice, repo, {
1055 title: "Add X",
1056 body: "",
1057 baseBranch: repo.defaultBranch,
1058 headBranch: "feature/x",
1059 });
1060 if (!opened.ok) throw new Error(String(opened.error));
1061 const pull = opened.value;
1062 const env = previewEnv(pull.number);
1063 const link = `href="/alice/vault/app/${env}/open"`;
1064
1065 const deployRun = createRun(app.db, {
1066 repoId: repo.id,
1067 kind: "deploy",
1068 env,
1069 commitOid: pull.headOid,
1070 reason: "pull",
1071 });
1072 claimRun(app.db, deployRun.id);
1073 putEnv(env, {
1074 deploying: {
1075 run: { id: deployRun.id, commitOid: pull.headOid },
1076 abort: new AbortController(),
1077 },
1078 });
1079 const starting = await (await get("/alice/vault/app/state", owner)).text();
1080 expect(starting).toContain("starting");
1081 expect(starting).not.toContain(">live<");
1082 expect(starting).toContain(link);
1083
1084 finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" });
1085 putEnv(env, {
1086 live: {
1087 commitOid: pull.headOid,
1088 handle: { port: 4321 },
1089 runId: deployRun.id,
1090 since: new Date().toISOString(),
1091 },
1092 });
1093 const live = await (await get("/alice/vault/app/state", owner)).text();
1094 expect(live).not.toContain("starting");
1095 expect(live).toContain(">live<");
1096 expect(live).toContain(link);
1097 });
1098 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs