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

Absorb: show a feature's feed items #9901

Merged opened this

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

Merged

scout/forge:scout/9884-absorb-show-a-feature-s-feed-items → main 8 commits fork 5 threads · 5 unresolved

  • Merged. scout/9884-absorb-show-a-feature-s-feed-items was merged into main as 58c3691.

Review

Sign in to review this change.
13 files changed +254 −10
docs/features/absorb.md +5 −1
@@ -51,6 +51,10 @@ The forge reads the declaration from the commit it starts. A preview of a pull t
5151 - A preview of a guest's pull that no writer has approved gets no `FORGE_SETTING_*` and no `FORGE_FEATURE`, in the same way it gets no secrets.
5252 - Only a production start updates the menu's name and page. A pull's `forge.json` cannot relabel the forge's menu.
5353
54 ## Its feed items
55
56 What the feed holds about an app's repository (runs, pulls, issues, looks) shows on its page, Settings › Features › the app, under "In the feed". The same items are `GET /api/v1/features/:slug/feed` and the `features` MCP tool with `slug` and `feed: true`. They are the viewer's own feed items for that repository, so nobody sees an item they could not see in the feed; a muted app still shows its items here. In the terminal, "Feature feeds" in the palette lists the absorbed apps you may read; `enter` opens one's items. Nothing is added to the feed: an app does not post items of its own.
57
5458 ## Without the crew
5559
5660 Absorbing, settings, the menu entry and the page use no model and start no agent run. They work with the crew off and no model credential on the forge. `test/apps-features.test.ts` runs the whole flow that way. The forge never hands an app the crew's credential. A feature's schedule is the app's own: the app reads `FORGE_SETTING_*` and runs its own timer. An app that wants a model's judgment on top, such as an issue written from what it found, must do its work without one and treat the model as optional.
@@ -76,7 +80,7 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu
7680
7781 ## Not done yet
7882
79 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883), show its feed items (#9884) or keep `secret` settings (#9886).
83 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883) or keep `secret` settings (#9886).
8084
8185 ## MCP tools
8286
src/actions/features.ts +21 −1
@@ -32,8 +32,10 @@ import {
3232 setFeatureSettings as keepSettings,
3333 unabsorb,
3434 } from "../core/features.ts";
35 import { canRead, getRepoById, type Repo } from "../core/repos.ts";
35 import { canRead, getRepo, getRepoById, type Repo } from "../core/repos.ts";
3636 import type { User } from "../core/users.ts";
37 import { readFeedPage } from "../feed/feed.ts";
38 import type { FeedItem } from "../feed/rank.ts";
3739 import { resolveOid } from "../git/merge.ts";
3840 import { commitLog } from "../git/read.ts";
3941 import { repoDir } from "../git/repos.ts";
@@ -321,6 +323,24 @@ export async function getFeature(app: App, actor: User | undefined, slug: string
321323 return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance));
322324 }
323325
326 /**
327 * An app's own feed items, for a feature `getFeature` already returned (so
328 * its reader was checked there, once): what the viewer's feed holds about its
329 * repository, ranked as the feed ranks them. A guest has no feed, so none.
330 */
331 export function feedOf(app: App, actor: User | undefined, view: FeatureView, limit = 20): FeedItem[] {
332 if (actor === undefined) return [];
333 const [owner = "", name = ""] = view.repo.split("/");
334 const repo = getRepo(app.db, owner, name);
335 return repo === undefined ? [] : readFeedPage(app.db, actor, { limit, repoId: repo.id }).items;
336 }
outdated

featureFeed repeats getFeature and then readable(), and featurePage has already called getFeature. That is the declaration read and the access check done twice per page view, against the under-50ms rule. Resolve the repo once and pass it in, or have the page call one function that returns both.

337
338 /** The same, from a slug, for the door that has not read the feature yet. */
339 export async function featureFeed(app: App, actor: User | undefined, slug: string, limit = 20): Promise<Result<readonly FeedItem[], FeatureError>> {
340 const found = await getFeature(app, actor, slug);
341 return found.ok ? ok(feedOf(app, actor, found.value, limit)) : found;
342 }
343
324344 async function redeployAll(app: App, record: AppRecord, repo: Repo): Promise<void> {
325345 await appsRuntime(app)?.reconcileApp(record, repo, "redeploy");
326346 }
src/contract/openapi/routes/getApiV1FeaturesSlugFeed.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/features/{slug}/feed",
6 summary: "The feed items about one app's feature, newest first, as {items, next_cursor} (for whoever may read the app)",
7 auth: false,
8 refusals: { not_found: "no such app, or one you may not read", not_a_feature: "the app's forge.json declares no feature (422)" },
9 };
src/contract/openapi/routes/index.ts +1
@@ -72,6 +72,7 @@ export { route as getApiV1Explore } from "./getApiV1Explore.ts";
7272 export { route as getApiV1Family } from "./getApiV1Family.ts";
7373 export { route as getApiV1Features } from "./getApiV1Features.ts";
7474 export { route as getApiV1FeaturesSlug } from "./getApiV1FeaturesSlug.ts";
75 export { route as getApiV1FeaturesSlugFeed } from "./getApiV1FeaturesSlugFeed.ts";
7576 export { route as getApiV1Feed } from "./getApiV1Feed.ts";
7677 export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts";
7778 export { route as getApiV1Flags } from "./getApiV1Flags.ts";
src/feed/feed.ts +4
@@ -138,6 +138,8 @@ type FeedOptions = {
138138 chips?: Chips;
139139 /** The home pins asks above the feed: they must not fill its page. */
140140 withoutAsks?: boolean;
141 /** Only this repository's items (an app's own feed); its mute does not hide them. */
142 repoId?: string;
141143 /** Told how long the settled-state read took, for Server-Timing. */
142144 phase?: (name: string, ms: number) => void;
143145 };
@@ -166,6 +168,7 @@ export function readFeedPage(
166168 ...shared,
167169 ]
168170 // The chips: the one filter. An ask was filtered by it in `askLinesFor`.
171 .filter((c) => options.repoId === undefined || c.repoId === options.repoId)
169172 .filter((c) => c.kind === "ask" || !chipHides(chips, c))
170173 .filter((c) =>
171174 // An ask is never muted away. Its own dismissal was already read by
@@ -177,6 +180,7 @@ export function readFeedPage(
177180 (c.kind === "ask" ||
178181 c.kind === "milestone" ||
179182 c.kind === "choose" ||
183 options.repoId !== undefined ||
180184 !muted.has(c.repoId)),
181185 );
182186 // After every filter, so a burst folds only what the viewer would see.
src/http/api/features.ts +9
@@ -4,6 +4,7 @@
44 *
55 * GET /api/v1/features every app that declares one (admin)
66 * GET /api/v1/features/:slug one, with its settings (its readers)
7 * GET /api/v1/features/:slug/feed its feed items: what the feed holds about the app (its readers)
78 * PUT /api/v1/features/:slug absorb it (admin scope)
89 * DELETE /api/v1/features/:slug un-absorb it; settings are kept (admin scope)
910 * PATCH /api/v1/features/:slug/settings `{ settings: { key: value } }` (admin scope)
@@ -12,6 +13,7 @@
1213 import {
1314 absorbApp,
1415 changeFeatureSettings,
16 featureFeed,
1517 getFeature,
1618 listFeatures,
1719 unabsorbApp,
@@ -49,6 +51,13 @@ export function featureRoutes(router: Router<Handler>): void {
4951 return json(found.value);
5052 });
5153
54 router.add("GET", "/api/v1/features/:slug/feed", async (ctx) => {
55 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
56 const items = await timePhase(ctx.request, "feature", () => featureFeed(ctx.app, ctx.auth.user, ctx.params.slug ?? ""));
57 if (!items.ok) return refused(items.error);
58 return json({ items: items.value, next_cursor: null });
59 });
60
5261 router.add("PUT", "/api/v1/features/:slug", async (ctx) => {
5362 const denied = requireScope(ctx, "admin");
5463 if (denied) return denied;
src/http/mcp/tools/features.ts +8 −2
@@ -1,16 +1,17 @@
1 import { absorbApp, changeFeatureSettings, getFeature, listFeatures, unabsorbApp } from "../../../actions/features.ts";
1 import { absorbApp, changeFeatureSettings, feedOf, getFeature, listFeatures, unabsorbApp } from "../../../actions/features.ts";
outdated

Imports are out of order (featureFeed between absorbApp and changeFeatureSettings). The same applies to src/web/pages/features.ts and src/actions/features.ts. Also, feed: true combined with absorb or settings silently ignores the writes. Refuse that combination so a write is never dropped without an error.

22 import { callFeatureTool } from "../../../actions/feature_tools.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "features",
8 doc: "`{ slug?, absorb?, settings?, tool?, args?, dryRun? }`: apps absorbed as the forge's own features. With nothing, every app whose forge.json declares a `feature`, absorbed or not (forge admin); with `slug`, that one and its typed settings (its readers); `absorb: true|false` absorbs or un-absorbs it, `settings: { name: value }` changes an absorbed one's settings (each checked against its declaration, the app restarts with them as `FORGE_SETTING_*`); `tool` calls one of the absorbed app's declared tools (listed as `tools` with `slug`) with `args`: a read tool answers at once, one that writes is for a repo writer and planned first; absorbing and settings changes are planned first (`dryRun` is true unless `false`) and need an admin-scope token",
8 doc: "`{ slug?, feed?, absorb?, settings?, tool?, args?, dryRun? }`: apps absorbed as the forge's own features. With nothing, every app whose forge.json declares a `feature`, absorbed or not (forge admin); with `slug`, that one and its typed settings (its readers), or with `feed: true` its feed items; `absorb: true|false` absorbs or un-absorbs it, `settings: { name: value }` changes an absorbed one's settings (each checked against its declaration, the app restarts with them as `FORGE_SETTING_*`); `tool` calls one of the absorbed app's declared tools (listed as `tools` with `slug`) with `args`: a read tool answers at once, one that writes is for a repo writer and planned first; absorbing and settings changes are planned first (`dryRun` is true unless `false`) and need an admin-scope token",
99 description: "Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
1313 slug: { type: "string", maxLength: 100 },
14 feed: { type: "boolean" },
1415 absorb: { type: "boolean" },
1516 settings: { type: "object" },
1617 tool: { type: "string", maxLength: 40 },
@@ -30,6 +31,7 @@ export const tool: Tool = {
3031 if (!found.ok) return bad(found.error, found.error === "not_a_feature" ? "that app declares no feature" : "no such app");
3132 const tool = typeof args["tool"] === "string" ? args["tool"] : undefined;
3233 if (tool !== undefined) {
34 if (args["feed"] === true) return bad("invalid", "feed reads; a tool call acts: send them apart");
3335 const spec = found.value.tools.find((t) => t.name === tool);
3436 const given = typeof args["args"] === "object" && args["args"] !== null && !Array.isArray(args["args"]) ? (args["args"] as Record<string, unknown>) : {};
3537 if (spec?.writes === true && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) return bad("forbidden", "a tool that writes needs an rw token");
@@ -41,6 +43,10 @@ export const tool: Tool = {
4143 const settings = typeof args["settings"] === "object" && args["settings"] !== null && !Array.isArray(args["settings"])
4244 ? (args["settings"] as Record<string, unknown>)
4345 : undefined;
46 if (args["feed"] === true) {
47 if (absorb !== undefined || settings !== undefined) return bad("invalid", "feed reads; absorb and settings write: send them apart");
48 return good({ items: feedOf(ctx.app, user, found.value) });
49 }
4450 if (absorb === undefined && settings === undefined) return good(found.value);
4551 if (args["dryRun"] !== false) {
4652 const would = [
src/tui/screens/entries.ts +2
@@ -27,6 +27,7 @@ import { entry as edgeOnly } from "./edgeonly.ts";
2727 import { entry as traffic } from "./trafficview.ts";
2828 import { entry as featureReview } from "./features.ts";
2929 import { entry as feed } from "./feed.ts";
30 import { entry as featureFeed } from "./featurefeed.ts";
3031 import { entry as features } from "./featureswitch.ts";
3132 import { entry as forgeView } from "./forgeview.ts";
3233 import { entry as flowsAll } from "./flowsall.ts";
@@ -120,6 +121,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
120121 domains,
121122 feed,
122123 featureReview,
124 featureFeed,
123125 features,
124126 front,
125127 owner,
src/tui/screens/featurefeed.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * A feature's feed items in the terminal: the page's "In the feed" under
3 * Settings › Features. The list is the apps absorbed as features that this
4 * person may read (`absorbedForNav`, the menu's own read); `enter` opens one
5 * and shows what `feedOf` gives, as the page and `GET /api/v1/features/:slug/feed`
6 * do: the viewer's own feed items about that repository, muted or not.
7 */
8
9 import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts";
10 import type { FeedItem } from "../../feed/rank.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { emptyState } from "../ui/widgets.ts";
15 import { ListState, ago, drawList, type ListRow } from "./common.ts";
16 import type { PaletteEntry } from "./entries.ts";
17
18 const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ");
19
20 /** One feature's items. A feature that cannot be read, or a guest, has none. */
21 export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Screen {
22 const state = new ListState();
23 let items: readonly FeedItem[] = [];
24 void getFeature(f.app, f.user, feature.slug).then((found) => {
25 items = found.ok ? feedOf(f.app, f.user, found.value) : [];
26 ctx.invalidate();
27 });
28 return {
29 title: `In the feed: ${feature.name}`,
30 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
31 render(area, s) {
32 const c = ctx.theme.c;
33 if (items.length === 0) {
34 emptyState(s, area, c, { title: "Nothing yet." });
35 return;
36 }
37 const rows: ListRow[] = items.map((i) => ({ title: oneLine(i.what), secondary: oneLine(i.actor ?? i.app), meta: ago(i.at) }));
38 drawList(s, area, c, state, rows);
39 },
40 key(e) {
41 if (e.key === "esc" || e.key === "q") ctx.pop();
42 else state.key(e, items.length);
43 return true;
44 },
45 };
46 }
47
48 export function featureFeedPicker(f: Session, ctx: Ctx): Screen {
49 const state = new ListState();
50 const features = absorbedForNav(f.app, f.user);
51 return {
52 title: "Feature feeds",
53 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc", "q"], "back")],
54 render(area, s) {
55 const c = ctx.theme.c;
56 if (features.length === 0) {
57 emptyState(s, area, c, { title: "No absorbed app to show." });
58 return;
59 }
60 drawList(s, area, c, state, features.map((x): ListRow => ({ title: x.name, secondary: x.repo, meta: "" })));
61 },
62 key(e) {
63 if (e.key === "esc" || e.key === "q") ctx.pop();
64 else if (e.key === "enter") {
65 const at = features[state.index];
66 if (at !== undefined) ctx.push(featureFeedScreen(f, ctx, at));
67 } else state.key(e, features.length);
68 return true;
69 },
70 };
71 }
72
73 export const entry: PaletteEntry = {
74 id: "feature-feed",
75 title: "Feature feeds",
76 hint: "the feed items about an absorbed app, as Settings › Features shows them",
77 after: ["feature-review"],
78 offered: (session) => session.user !== undefined,
79 run: (session, ctx) => ctx.push(featureFeedPicker(session, ctx)),
80 };
src/web/pages/features.ts +15 −3
@@ -10,6 +10,7 @@ import {
1010 absorbApp,
1111 acceptDeclaration,
1212 changeFeatureSettings,
13 feedOf,
1314 getFeature,
1415 listFeatures,
1516 unabsorbApp,
@@ -18,13 +19,14 @@ import {
1819 provenanceFacts,
1920 } from "../../actions/features.ts";
2021 import { DAYS } from "../../apps/feature.ts";
22 import type { FeedItem } from "../../feed/rank.ts";
2123 import type { Ctx } from "../../http/context.ts";
2224 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2325 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2426 import { readForm, type Form } from "../form.ts";
2527 import { html, raw, when } from "../html.ts";
2628 import { flashTo, page } from "../layout.ts";
27 import { badge, btn, empty, row } from "../ui.ts";
29 import { badge, btn, empty, row, time } from "../ui.ts";
2830 import { accountSections } from "./origins.ts";
2931
3032 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -155,7 +157,7 @@ export async function featurePage(ctx: Ctx): Promise<Response> {
155157 const found = await timePhase(ctx.request, "feature", () => getFeature(ctx.app, ctx.auth.user, ctx.params["slug"] ?? ""));
156158 if (!found.ok) return notFoundPage(ctx);
157159 const rendering = performance.now();
158 const response = featureView(ctx, found.value);
160 const response = featureView(ctx, found.value, undefined, feedOf(ctx.app, ctx.auth.user, found.value, 10));
159161 notePhase(ctx.request, "render", performance.now() - rendering);
160162 return response;
161163 }
@@ -176,7 +178,7 @@ function typedValue(s: SettingView, raw: unknown): string {
176178 return text;
177179 }
178180
179 function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response {
181 function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonly FeedItem[] = []): Response {
180182 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
181183 const settings = f.settings.map((shown) => {
182184 const bad = refused?.key === shown.key;
@@ -205,6 +207,16 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response {
205207 </p>
206208 </form>`}
207209 </section>
210 <section class="settings-block">
211 <h3>In the feed</h3>
212 ${items.length === 0
outdated

Principle 1: the page already says what the settings are for. Check that this heading and the empty sentence below don't explain the same thing twice. 'Nothing about X in your feed yet.' should be one plain line, per docs/WRITING.md.

213 ? html`<p class="muted small">Nothing yet.</p>`
214 : html`<ul class="list">${items.map((i) => row({
215 ...(i.link.startsWith("/") && !i.link.startsWith("//") ? { href: i.link } : {}),
216 title: i.what,
217 meta: html`${i.actor ?? i.app} · ${time(i.at)}`,
218 }))}</ul>`}

Principles 1 and 8: the meta line mixes the actor, an ISO date slice and the raw i.reason, which is feed-ranking jargon and not plain words. Show the actor and the date and drop the reason. The "the crew" fallback is also invented copy. Reuse the feed's own row and time formatting so the rows look the same as on the home feed. Check that i.link can only be an internal path.

219 </section>
208220 <p class="muted small">
209221 Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right.
210222 <a href="/settings/features">All features</a>
test/features-tui.test.ts +26 −2
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { absorbApp } from "../src/actions/features.ts";
1112 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1213 import { createApp, type App as Forge } from "../src/app.ts";
1314 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -32,6 +33,7 @@ describe("features in the terminal", () => {
3233 let ada: User;
3334 let eve: User;
3435 let appId: string;
36 let repoId: string;
3537
3638 function boot(user: User) {
3739 const terminal = headlessTerminal(120, 32);
@@ -48,11 +50,11 @@ describe("features in the terminal", () => {
4850 for (const ch of text) key(ch);
4951 await settleApp(app);
5052 };
51 const palette = async () => {
53 const palette = async (query = "Features") => {
5254 await settleApp(app);
5355 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
5456 await settleApp(app);
55 await type("Features");
57 await type(query);
5658 };
5759 return { text: () => app.screen.toText(), press, type, palette, stop: () => app.stop() };
5860 }
@@ -74,6 +76,7 @@ describe("features in the terminal", () => {
7476 const row = insertApp(forge.db, { repoId: repo.value.id, slug: "radar", access: "public" });
7577 if (!row.ok) throw new Error(row.error);
7678 appId = row.value.id;
79 repoId = repo.value.id;
7780 }, 60_000);
7881 afterAll(async () => {
7982 forge?.close();
@@ -112,4 +115,25 @@ describe("features in the terminal", () => {
112115 expect(getAbsorbed(forge.db, appId)).toBeUndefined();
113116 t.stop();
114117 });
118
119 test("a reader opens an absorbed app's feed items from the palette", async () => {
120 const absorbed = await absorbApp(forge, ada, "radar");
121 if (!absorbed.ok) throw new Error(absorbed.error);
122 forge.bus.append({ kind: "run.finished", repoId, actorId: ada.id, payload: { kind: "agent", state: "ok", summary: "Radar misses Tuesday", runId: "r1" } });
123 const t = boot(ada);
124 await t.palette("Feature feeds");
125 await t.press("enter");
126 expect(t.text()).toContain("Radar");
127 await t.press("enter");
128 expect(t.text()).toContain("Radar misses Tuesday");
129 t.stop();
130 });
131
132 test("any signed-in reader is offered it, for the apps they may read", async () => {
133 const t = boot(eve);
134 await t.palette("Feature feeds");
135 await t.press("enter");
136 expect(t.text()).toContain("Radar");
137 t.stop();
138 });
115139 });
test/features.test.ts +71 −1
@@ -15,7 +15,7 @@ import { join } from "node:path";
1515 import { statements } from "./helpers/statements.ts";
1616 import { expectWork } from "./helpers/work.ts";
1717 import { createApp, type App } from "../src/app.ts";
18 import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, lastChange, provenanceFacts, unabsorbApp } from "../src/actions/features.ts";
18 import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, featureFeed, getFeature, lastChange, provenanceFacts, unabsorbApp } from "../src/actions/features.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2020 import { forkRepo } from "../src/core/repos.ts";
2121 import { resolveOid } from "../src/git/merge.ts";
@@ -26,6 +26,8 @@ import { featureVarsAt } from "../src/apps/feature_env.ts";
2626 import { appEnv } from "../src/apps/process.ts";
2727 import { createApp as insertApp, deleteApp } from "../src/core/apps.ts";
2828 import { listEvents } from "../src/core/events.ts";
29 import { muteRepo } from "../src/core/interactions.ts";
30 import { readFeedPage } from "../src/feed/feed.ts";
2931 import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts";
3032 import type { Repo } from "../src/core/repos.ts";
3133 import { createToken } from "../src/core/tokens.ts";
@@ -584,6 +586,74 @@ describe("absorbing an app", () => {
584586 expect(stranger.error).toBeDefined();
585587 });
586588
589 const post = (summary: string, repoId = repo.id) =>
590 app.bus.append({ kind: "run.finished", repoId, actorId: cy.id, payload: { kind: "agent", state: "ok", summary, runId: summary } });
591 const feedOfApi = async (token: string, slug = "radar") =>
592 ((await (await api("GET", token, `/${slug}/feed`)).json()) as { items: { what: string; app: string }[] }).items;
593
594 test("its feed items are the repository's own, not another's, through the API and MCP", async () => {
595 post("Radar misses Tuesday");
596 const other = await createRepo(app, cy, { name: "elsewhere", visibility: "private", description: "" });
597 if (!other.ok) throw new Error(String(other.error));
598 post("Not about radar", other.value.id);
599 const items = await feedOfApi(adaToken);
600 expect(items.length).toBeGreaterThan(0);
601 expect(items.every((i) => i.app === `${repo.ownerName}/${repo.name}`)).toBe(true);
602 expect(JSON.stringify(items)).toContain("Radar misses Tuesday");
603 expect(JSON.stringify(items)).not.toContain("Not about radar");
604 const viaMcp = await mcpTool(servers.url, adaToken, "features", { slug: "radar", feed: true });
605 expect(JSON.stringify(viaMcp.value)).toContain("Radar misses Tuesday");
606 });
607
608 test("a muted app's items still show in its own feed, though the home feed hides them", async () => {
609 post("Radar after the mute");
610 muteRepo(app.db, ada.id, repo.id);
611 expect(JSON.stringify(readFeedPage(app.db, ada, { limit: 50 }).items)).not.toContain("Radar after the mute");
612 expect(JSON.stringify(await feedOfApi(adaToken))).toContain("Radar after the mute");
613 });
614
615 test("the page shows them, and a stranger gets no page and no items", async () => {
616 post("Radar on the page");
617 const page = await (await web(adaCookie, "/settings/features/radar")).text();
618 expect(page).toContain("In the feed");
619 expect(page).toContain("Radar on the page");
620 expect((await api("GET", bobToken, "/radar/feed")).status).toBe(404);
621 expect((await fetch(`${servers.url}/api/v1/features/radar/feed`)).status).toBe(404);
622 });
623
624 test("a guest reading a public feature has no feed, so gets []", async () => {
625 const pub = await createRepo(app, cy, { name: "pubradar", visibility: "public", description: "" });
626 if (!pub.ok) throw new Error(String(pub.error));
627 const done = await commitToBranch(app, cy, pub.value, { message: "feature", files: [{ path: "forge.json", content: FORGE_JSON }] });
628 if (!done.ok) throw new Error(JSON.stringify(done.error));
629 const row = insertApp(app.db, { repoId: pub.value.id, slug: "pubradar", access: "public" });
630 if (!row.ok) throw new Error(row.error);
631 post("Public news", pub.value.id);
632 const guest = await featureFeed(app, undefined, "pubradar");
633 expect(guest).toEqual({ ok: true, value: [] });
634 const viaHttp = await fetch(`${servers.url}/api/v1/features/pubradar/feed`);
635 expect(await viaHttp.json()).toEqual({ items: [], next_cursor: null });
636 });
637
638 test("an app that declares no feature has no feed: 422 not_a_feature", async () => {
639 const plain = await createRepo(app, cy, { name: "plainapp", visibility: "private", description: "" });
640 if (!plain.ok) throw new Error(String(plain.error));
641 const done = await commitToBranch(app, cy, plain.value, { message: "app", files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "bun run src/server.ts" } }) }] });
642 if (!done.ok) throw new Error(JSON.stringify(done.error));
643 const row = insertApp(app.db, { repoId: plain.value.id, slug: "plainapp", access: "private" });
644 if (!row.ok) throw new Error(row.error);
645 const res = await api("GET", adaToken, "/plainapp/feed");
646 expect(res.status).toBe(422);
647 expect(((await res.json()) as { error: string }).error).toBe("not_a_feature");
648 });
649
650 test("MCP refuses feed together with a write rather than dropping the write", async () => {
651 const before = getAbsorbed(app.db, appId) !== undefined;
652 const mixed = await mcpTool(servers.url, adaToken, "features", { slug: "radar", feed: true, absorb: !before, dryRun: false });
653 expect(JSON.stringify(mixed)).toContain("send them apart");
654 expect(getAbsorbed(app.db, appId) !== undefined).toBe(before);
655 });
656
587657 test("the MCP tool lists the app's tools, plans a write, and refuses what is not declared or not running", async () => {
588658 const listed = await mcpTool(servers.url, adaToken, "features", { slug: "radar" });
589659 expect(listed.value?.tools.map((t: { name: string }) => t.name)).toEqual(["scan", "latest"]);
test/palette-roster.test.ts +3
@@ -143,6 +143,7 @@ test("a site-admin person is offered the whole roster", () => {
143143 ["domains", "Failure domains", "failure domains and their last drill"],
144144 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
145145 ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"],
146 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
146147 ["features", "Features", "apps that declare a feature: absorb one, change its settings"],
147148 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
148149 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
@@ -211,6 +212,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
211212 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
212213 ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"],
213214 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
215 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
214216 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
215217 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
216218 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
@@ -281,6 +283,7 @@ test("a member person is offered the signed-in roster without the admin entries"
281283 ["bets", "Bets", "back, stop and finish a repository\'s bets"],
282284 ["origins", "Connected origins", "the hosts your mirrors come from"],
283285 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
286 ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"],
284287 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
285288 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
286289 ["profile", "Your profile", "display name and email"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs