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

Absorb: Settings > Features and /api/v1/features read git for every app on every request #9650

Merged opened this

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

Merged

scout/forge:scout/9543-absorb-settings-features-and-api-v1-feat → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9543-absorb-settings-features-and-api-v1-feat was merged into main as bf421c3.

Review

Sign in to review this change.
3 files changed +57 −7
src/actions/features.ts +20 −1
@@ -80,11 +80,28 @@ export interface FeatureView {
8080 const siteAdmin = (actor: User | undefined): actor is User =>
8181 actor !== undefined && actor.isAdmin && actor.kind !== "agent";
8282
83 /**
84 * The parsed declaration by commit oid. A commit never changes, so what its
85 * forge.json declares never does: only an app whose tip moved reads git again.
86 * Bounded by clearing whole when full; a refill costs one read per app.
87 */
88 const declared = new Map<string, Result<FeatureSpec | undefined, string>>();
89 const DECLARED_MAX = 2000;
90
8391 /** The feature an app's default branch declares, as `forge.json` says it at the tip. */
8492 export async function declaredFeature(app: App, repo: Repo): Promise<Result<FeatureSpec | undefined, string>> {
8593 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
8694 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
8795 if (tip === undefined) return ok(undefined);
96 const known = declared.get(tip);

Next: the cached parsed result is shared by reference with every caller. Freeze it or hand out a copy so a mutation cannot corrupt the cache.

97 if (known !== undefined) return known;
98 const read = await readDeclaration(dir, tip);
99 if (declared.size >= DECLARED_MAX) declared.clear();
100 declared.set(tip, read);
101 return read;
102 }
103
104 async function readDeclaration(dir: string, tip: string): Promise<Result<FeatureSpec | undefined, string>> {
88105 const text = await readFileAt(dir, tip, "forge.json");
89106 if (text === undefined) return ok(undefined);
90107 let parsed: unknown;
@@ -165,6 +182,8 @@ export interface NavFeature {
165182 readonly name: string;
166183 /** The app path to open: the feature's page. */
167184 readonly page: string;
185 /** The app record already read, so the menu does not fetch it again. */
186 readonly record: AppRecord;
168187 }
169188
170189 /** Absorbed apps the actor may read, for the forge's nav. Cheap enough for every page: no git reads. */
@@ -175,7 +194,7 @@ export function absorbedForNav(app: App, actor: User | undefined): NavFeature[]
175194 if (record === undefined) continue;
176195 const repo = getRepoById(app.db, record.repoId);
177196 if (repo === undefined || !canRead(app.db, repo, actor)) continue;
178 out.push({ appId: record.id, slug: record.slug, repo: `${repo.ownerName}/${repo.name}`, name: row.name, page: row.page });
197 out.push({ appId: record.id, slug: record.slug, repo: `${repo.ownerName}/${repo.name}`, name: row.name, page: row.page, record });
179198 }
180199 return out;
181200 }
src/web/layout.ts +2 −4
@@ -16,7 +16,6 @@ import { appsForRepos } from "../core/apps.ts";
1616 import { foundryHref } from "../actions/foundry_door.ts";
1717 import { appsRuntime } from "../apps/runtime.ts";
1818 import { absorbedForNav } from "../actions/features.ts";
19 import { getAppById } from "../core/apps.ts";
2019 import {
2120 enterableApps,
2221 getRepo,
@@ -273,12 +272,11 @@ function newMenu(ctx: Ctx): Html {
273272 function featureItems(ctx: Ctx, user: User): MenuItem[] {
274273 const runtime = appsRuntime(ctx.app);
275274 return absorbedForNav(ctx.app, user).map((f) => {
276 const record = getAppById(ctx.app.db, f.appId);
277275 // Without a runtime there is no host to open: the app's own page instead.
278276 const href =
279 runtime === undefined || record === undefined
277 runtime === undefined
280278 ? `/${f.repo}`
281 : `/apps/open?host=${encodeURIComponent(new URL(runtime.urlFor(record, "production")).host)}&to=${encodeURIComponent(f.page)}`;
279 : `/apps/open?host=${encodeURIComponent(new URL(runtime.urlFor(f.record, "production")).host)}&to=${encodeURIComponent(f.page)}`;
282280 return { label: f.name, href, icon: "rocket" as const };
283281 });
284282 }
test/features.test.ts +35 −2
@@ -12,15 +12,17 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1212 import { mkdtemp, rm } from "node:fs/promises";
1313 import { tmpdir } from "node:os";
1414 import { join } from "node:path";
15 import { statements } from "./helpers/statements.ts";
16 import { expectWork } from "./helpers/work.ts";
1517 import { createApp, type App } from "../src/app.ts";
16 import { absorbApp, absorbedForNav, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts";
18 import { absorbApp, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts";
1719 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1820 import { checkSetting, featureEnv, parseFeature, settingEnvName } from "../src/apps/feature.ts";
1921 import { featureVarsAt } from "../src/apps/feature_env.ts";
2022 import { appEnv } from "../src/apps/process.ts";
2123 import { createApp as insertApp } from "../src/core/apps.ts";
2224 import { listEvents } from "../src/core/events.ts";
23 import { featureSettings, getAbsorbed } from "../src/core/features.ts";
25 import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts";
2426 import type { Repo } from "../src/core/repos.ts";
2527 import { createToken } from "../src/core/tokens.ts";
2628 import { createUser, type User } from "../src/core/users.ts";
@@ -330,4 +332,35 @@ describe("absorbing an app", () => {
330332 const stranger = await mcpTool(servers.url, bobToken, "features", { slug: "radar" });
331333 expect(stranger.error).toBeDefined();
332334 });
335
336 test("the declaration is cached by tip: the same tip is one read, a moved tip reads again", async () => {
337 const first = await declaredFeature(app, repo);
338 expect(await declaredFeature(app, repo)).toBe(first); // the very same parsed result
339 const moved = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: { ...FEATURE, name: "Radar 2" } });
340 const done = await commitToBranch(app, cy, repo, { message: "rename", files: [{ path: "forge.json", content: moved }] });
341 if (!done.ok) throw new Error(JSON.stringify(done.error));
342 const after = await declaredFeature(app, repo);
343 expect(after).not.toBe(first);
344 expect(after.ok && after.value?.name).toBe("Radar 2");
345 });
346
347 test("with 20 more absorbed apps the menu is built from one pass and renders within budget", async () => {
348 for (let i = 0; i < 20; i++) {
349 const r = await createRepo(app, cy, { name: `many-${i}`, visibility: "public", description: "" });
350 if (!r.ok) throw new Error(String(r.error));
351 const row = insertApp(app.db, { repoId: r.value.id, slug: `many-${i}`, access: "public" });
352 if (!row.ok) throw new Error(row.error);
353 absorb(app.db, row.value.id, ada.id, `Many ${i}`, "/");
354 }
355 let nav = absorbedForNav(app, ada);
356 expect(nav.length).toBeGreaterThanOrEqual(20);
357 expect(nav.every((f) => f.record.id === f.appId)).toBe(true); // the record rides along
358 const n = nav.length;
359 const used = await statements(app.db, async () => {
360 nav = absorbedForNav(app, ada);
361 });
362 expectWork(used, { max: 2 + 3 * n }); // one list, then at most three reads per absorbed app

Next: the bound 2+3n sits one statement above the measured 64 for 21 apps, so it is brittle. Also, it measures absorbedForNav rather than the featureItems render.

363 const page = await (await web(adaCookie, "/settings")).text();
364 expect(page).toContain("Many 19");
365 });
333366 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs