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

The app page's Adapter section has Pull now, as the API and MCP already do #10144

Merged opened this

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

Merged

mason/forge:mason/10125-the-app-page-s-adapter-section-has-pull → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/10125-the-app-page-s-adapter-section-has-pull was merged into main as 55743e9.

Review

Sign in to review this change.
5 files changed +172 −11
docs/features/adapters-card.md +7
@@ -12,6 +12,13 @@ when), the **next pull** and the **cursor** of the last good pull.
1212 rows. A writer presses `p` there to pull now (`pullNow`): the rows are
1313 read again, and a refusal shows its `PULL_NOW_REFUSALS` sentence in the
1414 status line. Anyone else is shown no key and `p` does nothing.
15 - **Pull now:** a writer (not a reader) also gets a **Pull now** button
16 under the rows. It posts same-origin to `POST /<owner>/<repo>/app/pull`
17 (`adapterPull`, `src/web/studio/adapter.ts`), which calls `pullNow`, the
18 function the API and MCP use, and redirects back to the app page, where
19 Last pull shows the new run. A refusal comes back as `?pull=<code>` and
20 shows its sentence from `PULL_NOW_REFUSALS` in one muted line. Anyone who
21 may not write gets 404 and no button.
1522 - Both list the **last ten pull runs** under the facts (`pullRunLines`,
1623 limit 10): when, ok or failed, written/skipped/failed and, for a failed
1724 run, its error sentence. With no runs, one muted line says "No pull runs
src/http/server.ts +2
@@ -219,6 +219,7 @@ import {
219219 newIssueAnywhere,
220220 newIssueSubmit,
221221 } from "../web/pages/issues.ts";
222 import { adapterPull } from "../web/studio/adapter.ts";
222223 import { noteModerate } from "../web/studio/notes.ts";
223224 import { tellPage } from "../web/studio/tell.ts";
224225 import { issuePicture } from "../web/studio/picture.ts";
@@ -1288,6 +1289,7 @@ export function buildRouter(): Router<Handler> {
12881289 // Where a private app sends a person who arrived by its link.
12891290 router.add("GET", "/apps/open", appsOpen);
12901291 router.add("POST", "/:owner/:name/app/notes", noteModerate);
1292 router.add("POST", "/:owner/:name/app/pull", adapterPull);
12911293 router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy);
12921294 router.add("POST", "/:owner/:name/app/:env/stop", appStop);
12931295 router.add("GET", "/:owner/:name/events", eventsList);
src/web/studio/adapter.ts +52 −10
@@ -8,11 +8,24 @@
88 * `adapterFactRows`, shared with the terminal; this module adds only markup.
99 */
1010 import type { PullRunLine } from "../../apps/pull.ts";
11 import type { App } from "../../app.ts";
11 import type { Repo } from "../../core/repos.ts";
1212 import type { AppRecord } from "../../core/apps.ts";
1313 import { NO_RUNS_WORDS, adapterFactRows, pullRunWords, adapterFactsOf, everyWords, type AdapterFacts, type FactPart } from "../../apps/adapter_facts.ts";
14 import { pullNow, PULL_NOW_REFUSALS, type PullNowError } from "../../actions/app_pull.ts";
15 import { requireWriter } from "../../actions/access.ts";
16 import { getAppByRepo } from "../../core/apps.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { loadRepo, notFoundPage, repoBase } from "../chrome.ts";
19 import { readForm } from "../form.ts";
1420 import { html, type Html } from "../html.ts";
15 import { badge, time } from "../ui.ts";
21 import { redirect } from "../layout.ts";
22 import { badge, btn, time } from "../ui.ts";
23
24 /** What a writer is offered under the rows: the form's address, and the sentence of a refused pull, if the last one was. */
25 export interface PullOffer {
26 readonly action: string;
27 readonly refusal?: string | undefined;
28 }
1629
1730 export { everyWords, type AdapterFacts };
1831
@@ -32,17 +45,52 @@ function part(p: FactPart): Html {
3245 }
3346
3447 /** The adapter's facts as plain dl rows under an h2 (principles 6 and 7: a fact is a row, not a card). */
35 export function adapterCardView(facts: AdapterFacts, now: number = Date.now()): Html {
48 export function adapterCardView(facts: AdapterFacts, now: number = Date.now(), pull?: PullOffer): Html {
3649 return html`<section class="adapter">
3750 <h2>Adapter</h2>
3851 <dl>
3952 ${adapterFactRows(facts, now).map((row) => html`<dt>${row.label}</dt>
4053 <dd>${row.parts.map(part)}</dd>`)}
41 </dl>
54 </dl>${pull === undefined
55 ? null
56 : html`
57 <form method="post" action="${pull.action}">${btn("Pull now", { size: "sm" })}</form>${pull.refusal === undefined
58 ? null
59 : html`
60 <p class="muted small">${pull.refusal}</p>`}`}
4261 ${runsList(facts.runs ?? [])}
4362 </section>`;
4463 }
4564
65 /** The refusal a `?pull=` code names, or none: only the codes `pullNow` can refuse with are believed. */
66 export function pullRefusalOf(code: string | null): string | undefined {
67 return code !== null && Object.hasOwn(PULL_NOW_REFUSALS, code) ? PULL_NOW_REFUSALS[code as PullNowError] : undefined;
68 }
69
70 /** The section for an app that declares an adapter on its default branch; null for any other. A writer is offered Pull now. */
71 export async function adapterCard(ctx: Ctx, record: AppRecord, repo: Repo): Promise<Html | null> {
72 const facts = await adapterFactsOf(ctx.app, record);
73 if (facts === undefined) return null;
74 const base = repoBase(repo);
75 const writer = requireWriter(ctx.app, ctx.auth.user, repo).ok;
76 return adapterCardView(
77 facts,
78 Date.now(),
79 writer ? { action: `${base}/app/pull`, refusal: pullRefusalOf(ctx.url.searchParams.get("pull")) } : undefined,
80 );
81 }
82
83 // POST /:owner/:name/app/pull — pull now, then back to the app page; a refusal rides as `?pull=<code>`.
84 export async function adapterPull(ctx: Ctx): Promise<Response> {
85 const repo = loadRepo(ctx);
86 if (!repo || !requireWriter(ctx.app, ctx.auth.user, repo).ok || !getAppByRepo(ctx.app.db, repo.id)) return notFoundPage(ctx);
87 const form = await readForm(ctx, "rw");
outdated

Both doors: the page can now pull but the terminal Adapter screen cannot. Add the same writer-only action in src/tui/screens/adapter.ts calling pullNow.

88 if (form instanceof Response) return form;
89 const pulled = await pullNow(ctx.app, ctx.auth.user, repo);
90 const back = repoBase(repo); // /app answers 302 to here; the query would be lost
91 return redirect(pulled.ok ? back : `${back}?pull=${pulled.error}`, { url: ctx.url, forgeUrl: ctx.app.config.url });
92 }
93
4694 /** Whether the sync is healthy over time: the heading is always there, the runs or one muted line under it. */
4795 function runsList(runs: readonly PullRunLine[]): Html {
4896 return html`<h3>Pull runs</h3>
@@ -52,9 +100,3 @@ function runsList(runs: readonly PullRunLine[]): Html {
52100 ${runs.map((r) => html`<li class="break">${time(r.at)} ${badge(r.state, { variant: "outline" })} ${pullRunWords(r)}</li>`)}
53101 </ul>`}`;
54102 }
55
56 /** The section for an app that declares an adapter on its default branch; null for any other. */
57 export async function adapterCard(app: App, record: AppRecord): Promise<Html | null> {
58 const facts = await adapterFactsOf(app, record);
59 return facts === undefined ? null : adapterCardView(facts);
60 }
src/web/studio/app.ts +1 −1
@@ -493,7 +493,7 @@ export async function studioApp(
493493 policyLine(ctx, repo, record),
494494 );
495495 const adapter = ownerFacts
496 ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx.app, record))
496 ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo))
497497 : null;
498498 const rendering = performance.now();
499499 const body = html`${repoHeader(ctx, repo, "app", {
test/adapter-pull-button.test.ts added +110
@@ -0,0 +1,110 @@
1 /** The app page's Pull now button (#10125): a writer's same-origin POST runs `pullNow`; anyone else gets 404; cross-origin is refused. */
2 import { afterAll, beforeAll, expect, mock, spyOn, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { newApp } from "../src/actions/apps.ts";
8 import { PULL_NOW_REFUSALS } from "../src/actions/app_pull.ts";
9 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { commitFiles } from "../src/git/write.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { listPullRuns } from "../src/core/runs.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17 import { promote } from "./helpers/members.ts";
18 import { tokenFor } from "./helpers/tokens.ts";
19
20 let dataDir: string, app: App, servers: Servers, runtime: AppsRuntime, adapterApp: ReturnType<typeof Bun.serve>;
21 let appId: string, aliceRw: string, bobRw: string, awake = true;
22
23 const send = (token: string, origin: string | undefined) =>
24 fetch(`${servers.url}/alice/hello/app/pull`, {
25 method: "POST",
26 redirect: "manual",
27 headers: { authorization: `Bearer ${token}`, ...(origin ? { origin } : {}) },
28 });
29 const page = (token: string, qs = "") =>
30 fetch(`${servers.url}/alice/hello${qs}`, { headers: { authorization: `Bearer ${token}` } }).then((r) => r.text());
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-adapter-pull-button-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
37 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
38 if (!b.ok) throw new Error(b.error);
39 promote(app.db, b.value);
40 const made = await newApp(app, getUserByName(app.db, "alice")!, { name: "hello", visibility: "public" } as never);
41 if (!made.ok) throw new Error(String(made.error));
42 const repo = getRepo(app.db, "alice", "hello")!;
43 appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id;
44 aliceRw = tokenFor(app, "alice", "rw");
45 bobRw = tokenFor(app, "bob", "rw");
46 adapterApp = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({ pulled: 2, skipped: 1, cursor: "r1", errors: 0 }) });
47 runtime = new AppsRuntime(app, { domain: "pull.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url });
48 spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
49 (): EnvStatus =>
50 (awake
51 ? { env: "production", state: "running", commitOid: "0".repeat(40), url: "", port: adapterApp.port }
52 : { env: "production", state: "stopped", commitOid: "0".repeat(40), url: "" }) as EnvStatus,
53 );
54 const c = await commitFiles(repoDir(app.reposRoot, "alice", "hello"), {
55 branch: repo.defaultBranch,
56 message: "forge.json",
57 files: [{ path: "forge.json", content: JSON.stringify({ run: "bun run src/server.ts", adapter: { schedule: { every: 3600 } } }) }],
58 author: { name: "T", email: "[email protected]" },
59 });
60 if (!c.ok) throw new Error(JSON.stringify(c.error));
61 }, 30_000);
62
63 afterAll(async () => {
64 mock.restore();
65 await runtime.stop();
66 adapterApp.stop(true);
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 test("only a writer is offered the button", async () => {
73 expect(await page(aliceRw)).toContain('action="/alice/hello/app/pull"');
74 expect(await page(bobRw)).not.toContain("/app/pull");
75 });
76
77 test("a writer's same-origin POST records a run and returns to the app page", async () => {
78 const res = await send(aliceRw, servers.url);
79 expect(res.status).toBe(303);
80 expect(res.headers.get("location")).toContain("/alice/hello");
81 const runs = listPullRuns(app.db, appId);
82 expect(runs.length).toBe(1);
83 expect(runs[0]).toMatchObject({ state: "ok", written: 2, cursor: "r1" });
84 });
85
86 test("a reader gets 404 and nothing is recorded", async () => {
87 expect((await send(bobRw, servers.url)).status).toBe(404);
88 expect(listPullRuns(app.db, appId).length).toBe(1);
89 });
90
91 test("a cross-origin POST is refused", async () => {
92 const res = await send(aliceRw, "http://evil.example");
93 expect(res.status).toBe(403);
94 expect(listPullRuns(app.db, appId).length).toBe(1);
95 });
96
97 test("a refusal shows its sentence in one muted line, and an unknown code shows nothing", async () => {
98 awake = false;
99 const res = await send(aliceRw, servers.url);
100 expect(res.headers.get("location")).toContain("pull=asleep");
101 const shown = await page(aliceRw, "?pull=asleep");
102 expect(shown).toContain(`<p class="muted small">${PULL_NOW_REFUSALS.asleep.replace("'", "&#39;")}</p>`);
103 const section = async (qs: string) => {

Trailing whitespace after the semicolon; also no terminal test exists.

104 const text = await page(aliceRw, qs);
105 return text.slice(text.indexOf("<h2>Adapter"), text.indexOf("</section>", text.indexOf("<h2>Adapter")));
106 };
107 expect(await section("?pull=%3Cb%3E")).not.toContain("muted small");
108 expect(await section("?pull=constructor")).not.toContain("muted small");
109 awake = true;
110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs