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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3400-each-app-on-a-client-s-first-screen-open → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 2059 | 2059 | through the same `tell()` and answer "Sent. The agents will pick it up."; the |
| 2060 | 2060 | web returns to `/` with that line. |
| 2061 | 2061 | |
| 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 | ||
| 2062 | 2069 | `GET /metrics` (site admin only; repository names are in the labels) serves |
| 2063 | 2070 | `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` |
| 2064 | 2071 | per `repo` and `destination` as Prometheus text, so a leaked upstream socket |
src/core/client-apps.ts +13 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { ago } from "../feed/rank.ts"; |
| 10 | import { appsRuntime, envsFor } from "../apps/runtime.ts"; | |
| 11 | import { appsForRepos } from "./apps.ts"; | |
| 10 | 12 | import { spawnedRepos } from "./next-steps.ts"; |
| 11 | 13 | import { withoutArchived, type Repo } from "./repos.ts"; |
| 12 | 14 | import type { User } from "./users.ts"; |
| @@ -17,6 +19,8 @@ export interface ClientApp { | ||
| 17 | 19 | readonly working: number; |
| 18 | 20 | /** Agent runs lined up, none going yet. */ |
| 19 | 21 | 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; | |
| 20 | 24 | } |
| 21 | 25 | |
| 22 | 26 | /** 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( | ||
| 34 | 38 | GROUP BY repo_id, state`, |
| 35 | 39 | ) |
| 36 | 40 | .all(...repos.map((r) => r.id)); |
| 41 | const runtime = appsRuntime(app); | |
| 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 | }; | |
| 37 | 49 | return repos.map((repo) => { |
| 38 | 50 | const own = rows.filter((r) => r.repo_id === repo.id); |
| 39 | 51 | const n = (state: string): number => |
| 40 | 52 | 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) }; | |
| 42 | 54 | }); |
| 43 | 55 | } |
| 44 | 56 |
src/tui/screens/dashboard.ts +14 −1
| @@ -86,6 +86,7 @@ import { | ||
| 86 | 86 | openOnWeb, |
| 87 | 87 | promptScreen, |
| 88 | 88 | plural, |
| 89 | linkScreen, | |
| 89 | 90 | } from "./common.ts"; |
| 90 | 91 | import { readKpis } from "../../flow/kpis.ts"; |
| 91 | 92 | import { kpiLines } from "../../flow/kpi_words.ts"; |
| @@ -166,6 +167,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 166 | 167 | let clientLines: string[] = []; |
| 167 | 168 | /** The ids of those apps, so the key that asks for something stays on them. */ |
| 168 | 169 | 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>(); | |
| 169 | 172 | /** The check lane, as the board's strip says it; null when nothing is queued. */ |
| 170 | 173 | let lane: string | null = null; |
| 171 | 174 | /** 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 { | ||
| 190 | 193 | figures = []; |
| 191 | 194 | const apps = await clientAppRows(f.app, f.user); |
| 192 | 195 | 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]))); | |
| 193 | 197 | const now = new Date(); |
| 194 | 198 | const doings = clientDoings(f.app.db, apps.map((a) => a.repo.id), now); |
| 195 | 199 | clientLines = |
| @@ -197,7 +201,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 197 | 201 | ? ["Your apps will appear here."] |
| 198 | 202 | : apps.map((a) => { |
| 199 | 203 | 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)}`; | |
| 201 | 206 | return doing === "" ? line : `${line} ${doing}`; |
| 202 | 207 | }); |
| 203 | 208 | ctx.invalidate(); |
| @@ -746,6 +751,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 746 | 751 | }), |
| 747 | 752 | binding(["C"], "decider", { disabled: !f.user }), |
| 748 | 753 | ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []), |
| 754 | ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []), | |
| 749 | 755 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 750 | 756 | binding(["M"], "migrate", { disabled: !f.signedIn }), |
| 751 | 757 | binding(["A"], "account", { disabled: !f.signedIn }), |
| @@ -952,6 +958,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 952 | 958 | if (repo && clientRepoIds.has(repo.id)) askForSomething(f, ctx, () => screen, repo); |
| 953 | 959 | return true; |
| 954 | 960 | } |
| 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 | } | |
| 955 | 968 | if (e.key === "n" && f.signedIn) { |
| 956 | 969 | ctx.push(newRepoScreen(f, ctx)); |
| 957 | 970 | return true; |
src/web/studio/client_home.ts +9 −2
| @@ -34,7 +34,7 @@ export async function clientApps(ctx: Ctx, user: User): Promise<Html> { | ||
| 34 | 34 | clientDoings(ctx.app.db, apps.map((a) => a.repo.id), now), |
| 35 | 35 | ); |
| 36 | 36 | const rendering = performance.now(); |
| 37 | const rows = apps.map(({ repo, working, queued }) => { | |
| 37 | const rows = apps.map(({ repo, working, queued, live }) => { | |
| 38 | 38 | const doing = doingLine(doings.get(repo.id), now.getTime()); |
| 39 | 39 | const about = repo.description !== "" ? repo.description : "An app of yours."; |
| 40 | 40 | // 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> { | ||
| 44 | 44 | variant: "outline", |
| 45 | 45 | }) |
| 46 | 46 | : 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" }); | |
| 47 | 52 | 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> | |
| 49 | 56 | <span class="client-app-about">${about}</span> |
| 50 | 57 | <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}" |
| 51 | 58 | >${agentsLine(working, queued)}</span |
test/client-home.test.ts +65
| @@ -12,6 +12,8 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 14 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 15 | 17 | import { clientApps } from "../src/web/studio/client_home.ts"; |
| 16 | 18 | import type { Ctx } from "../src/http/context.ts"; |
| 17 | 19 | import { clientAppRows, cutTitle, doingLine } from "../src/core/client-apps.ts"; |
| @@ -237,6 +239,69 @@ describe("a client's first screen", () => { | ||
| 237 | 239 | } |
| 238 | 240 | }); |
| 239 | 241 | |
| 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 | ||
| 240 | 305 | test("the terminal's first screen asks for something with t, in the web's words", async () => { |
| 241 | 306 | const maria = getUserByName(child.db, "maria")!; |
| 242 | 307 | const terminal = headlessTerminal(120, 30); |
new above
offline
This is read per request for every app via envsFor; fine, but the terminal must consume
livetoo (dashboard.ts clientLines currently ignores it), so both doors offer the same thing.