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

Each app on a client's first screen opens the running app in one tap #4113

Merged opened this

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

Merged

scout/forge:scout/3400-each-app-on-a-client-s-first-screen-open → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/3400-each-app-on-a-client-s-first-screen-open was merged into main as d9ebb7c.

Review

Sign in to review this change.
5 files changed +108 −4
README.md +7
@@ -2059,6 +2059,13 @@ script), in the terminal the `t` key on the app under the cursor. Both go
20592059 through the same `tell()` and answer "Sent. The agents will pick it up."; the
20602060 web returns to `/` with that line.
20612061
2062 An app that is running also shows "Open" (web: a link to
2063 `/:owner/:name/app/production/open`, the apps page's own) and, in the terminal,
2064 a "(running, o opens it)" marker with `o` showing its live address to open or
2065 copy. Both read `live` on the row from `clientAppRows`, which is set only while
2066 production runs; an app that is not running offers neither and says nothing
2067 about why.
2068
20622069 `GET /metrics` (site admin only; repository names are in the labels) serves
20632070 `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out`
20642071 per `repo` and `destination` as Prometheus text, so a leaked upstream socket
src/core/client-apps.ts +13 −1
@@ -7,6 +7,8 @@
77 import type { Database } from "bun:sqlite";
88 import type { App } from "../app.ts";
99 import { ago } from "../feed/rank.ts";
10 import { appsRuntime, envsFor } from "../apps/runtime.ts";
11 import { appsForRepos } from "./apps.ts";
1012 import { spawnedRepos } from "./next-steps.ts";
1113 import { withoutArchived, type Repo } from "./repos.ts";
1214 import type { User } from "./users.ts";
@@ -17,6 +19,8 @@ export interface ClientApp {
1719 readonly working: number;
1820 /** Agent runs lined up, none going yet. */
1921 readonly queued: number;
22 /** The address the running app answers at, the apps page's; undefined unless it is running. */
23 readonly live: string | undefined;
2024 }
2125
2226 /** The apps `user` can see on a spawned forge, each with its agent counts; archived ones are put away, not shown. */
@@ -34,11 +38,19 @@ export async function clientAppRows(
3438 GROUP BY repo_id, state`,
3539 )
3640 .all(...repos.map((r) => r.id));
41 const runtime = appsRuntime(app);

This is read per request for every app via envsFor; fine, but the terminal must consume live too (dashboard.ts clientLines currently ignores it), so both doors offer the same thing.

42 const records = appsForRepos(app.db, repos.map((r) => r.id));
43 const liveOf = (repo: Repo): string | undefined => {
44 const record = records.get(repo.id);
45 if (record === undefined || runtime === undefined) return undefined;
46 const up = envsFor(app, user, record).find((e) => e.env === "production")?.state === "running";
47 return up ? runtime.urlFor(record, "production") : undefined;

envsFor runs per repo on every render of / and on each dashboard refresh. Measure with ~20 apps; if status is not a cheap in-memory read, compute it once per request.

48 };
3749 return repos.map((repo) => {
3850 const own = rows.filter((r) => r.repo_id === repo.id);
3951 const n = (state: string): number =>
4052 own.find((r) => r.state === state)?.n ?? 0;
41 return { repo, working: n("running"), queued: n("queued") };
53 return { repo, working: n("running"), queued: n("queued"), live: liveOf(repo) };
4254 });
4355 }
4456
src/tui/screens/dashboard.ts +14 −1
@@ -86,6 +86,7 @@ import {
8686 openOnWeb,
8787 promptScreen,
8888 plural,
89 linkScreen,
8990 } from "./common.ts";
9091 import { readKpis } from "../../flow/kpis.ts";
9192 import { kpiLines } from "../../flow/kpi_words.ts";
@@ -166,6 +167,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
166167 let clientLines: string[] = [];
167168 /** The ids of those apps, so the key that asks for something stays on them. */
168169 let clientRepoIds = new Set<string>();
170 /** The live address of each of those apps that is running, by repo id; `o` shows it. */
171 let clientLive = new Map<string, string>();
169172 /** The check lane, as the board's strip says it; null when nothing is queued. */
170173 let lane: string | null = null;
171174 /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */
@@ -190,6 +193,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
190193 figures = [];
191194 const apps = await clientAppRows(f.app, f.user);
192195 clientRepoIds = new Set(apps.map((a) => a.repo.id));
196 clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const])));
193197 const now = new Date();
194198 const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now);
195199 clientLines =
@@ -197,7 +201,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
197201 ? ["Your apps will appear here."]
198202 : apps.map((a) => {
199203 const doing = doingLine(doings.get(a.repo.id), now.getTime());
200 const line = `${a.repo.name}: ${agentsLine(a.working, a.queued)}`;
204 const mark = a.live === undefined ? "" : " (running, o opens it)";
205 const line = `${a.repo.name}${mark}: ${agentsLine(a.working, a.queued)}`;
201206 return doing === "" ? line : `${line} ${doing}`;
202207 });
203208 ctx.invalidate();
@@ -746,6 +751,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
746751 }),
747752 binding(["C"], "decider", { disabled: !f.user }),
748753 ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []),
754 ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []),
749755 binding(["n"], "new repo", { disabled: !f.signedIn }),
750756 binding(["M"], "migrate", { disabled: !f.signedIn }),
751757 binding(["A"], "account", { disabled: !f.signedIn }),
@@ -952,6 +958,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
952958 if (repo && clientRepoIds.has(repo.id)) askForSomething(f, ctx, () => screen, repo);
953959 return true;
954960 }
961 if (e.key === "o" && f.user && clientLive.size > 0) {
962 // The running app under the cursor, as a link the person can open or copy.
963 const repo = shown()[at("repos").index];
964 const live = repo && clientLive.get(repo.id);
965 if (live) ctx.push(linkScreen(ctx, live, screen));
966 return true;
967 }
955968 if (e.key === "n" && f.signedIn) {
956969 ctx.push(newRepoScreen(f, ctx));
957970 return true;
src/web/studio/client_home.ts +9 −2
@@ -34,7 +34,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
3434 clientDoings(ctx.app.db, apps.map((a) => a.repo.id), now),
3535 );
3636 const rendering = performance.now();
37 const rows = apps.map(({ repo, working, queued }) => {
37 const rows = apps.map(({ repo, working, queued, live }) => {
3838 const doing = doingLine(doings.get(repo.id), now.getTime());
3939 const about = repo.description !== "" ? repo.description : "An app of yours.";
4040 // The tell form with this app chosen; it sends the person back to `/`.
@@ -44,8 +44,15 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
4444 variant: "outline",
4545 })
4646 : null;
47 // The running app, by the way the apps page opens it; an app that is not up offers nothing.
48 const open =
49 live === undefined
50 ? null
51 : btn("Open", { href: `${repoBase(repo)}/app/production/open`, variant: "outline" });
4752 return html`<li class="client-app">
48 <a href="${repoBase(repo)}"><b>${repo.name}</b></a> ${ask}
53 <div class="bar">
54 <a href="${repoBase(repo)}"><b>${repo.name}</b></a> ${open} ${ask}
55 </div>
4956 <span class="client-app-about">${about}</span>
5057 <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}"
5158 >${agentsLine(working, queued)}</span
test/client-home.test.ts +65
@@ -12,6 +12,8 @@ import { join } from "node:path";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
1414 import { createApp, type App } from "../src/app.ts";
15 import { AppsRuntime } from "../src/apps/runtime.ts";
16 import { getAppByRepo } from "../src/core/apps.ts";
1517 import { clientApps } from "../src/web/studio/client_home.ts";
1618 import type { Ctx } from "../src/http/context.ts";
1719 import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts";
@@ -237,6 +239,69 @@ describe("a client's first screen", () => {
237239 }
238240 });
239241
242 test("a running app offers Open to its live address; one that is not offers nothing", async () => {
243 const maria = getUserByName(child.db, "maria")!;
244 const repo = child.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'").get()!;
245 const runtime = new AppsRuntime(child, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test" });
246 try {
247 const record = getAppByRepo(child.db, repo.id);
248 if (record === undefined) throw new Error("no app record");
249 let state = "stopped";
250 runtime.status = () => [{ env: "production", state }] as never;
251 const ctx = { app: child, request: new Request("https://x.test/"), auth: { user: maria } } as unknown as Ctx;
252 const text = (h: string): string => h.replace(/<[^>]*>/g, " ");
253 const opens = async () => String(await clientApps(ctx, maria));
254
255 const down = await opens();
256 expect(down).not.toContain(">Open<");
257 expect(down).not.toContain("/app/production/open");
258 expect(down).toContain('href="/marias/bakery"');
259
260 state = "running";
261 const up = await opens();
262 expect(up).toContain('href="/marias/bakery/app/production/open"');
263 expect(up).toContain(">Open<");
264 expect(up).toContain('href="/marias/bakery"');
265 expect(runtime.urlFor(record, "production")).toStartWith("https://");
266 for (const shown of [up, down])
267 for (const word of ["deploy", "process", "port"])
268 expect(text(shown)).not.toMatch(new RegExp(`\\b${word}`, "i"));
269
270 // The terminal offers the same: a marker on the running app and `o` for its address.
271 const terminal = headlessTerminal(120, 30);
272 const { app } = createForgeApp(
273 child,
274 { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria },
275 { terminal, exitHooks: false },
276 );
277 app.start();
278 await settleApp(app);
279 expect(app.screen.toText()).toContain("bakery (running, o opens it): ");
280 terminal.feed("o");
281 await settleApp(app);
282 expect(app.screen.toText()).toContain(runtime.urlFor(record, "production"));
283 app.stop();
284
285 state = "stopped";
286 const idle = headlessTerminal(120, 30);
287 const stopped = createForgeApp(
288 child,
289 { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria },
290 { terminal: idle, exitHooks: false },
291 );
292 stopped.app.start();
293 await settleApp(stopped.app);
294 expect(stopped.app.screen.toText()).toContain("bakery: Its agents");
295 expect(stopped.app.screen.toText()).not.toContain("running");
296 idle.feed("o");
297 await settleApp(stopped.app);
298 expect(stopped.app.screen.toText()).not.toContain("https://");
299 stopped.app.stop();
300 } finally {
301 await runtime.stop();
302 }
303 });
304
240305 test("the terminal's first screen asks for something with t, in the web's words", async () => {
241306 const maria = getUserByName(child.db, "maria")!;
242307 const terminal = headlessTerminal(120, 30);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs