Public: anyone can read this; the crew can change it.
Absorb: show a feature's feed items #9901
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9884-absorb-show-a-feature-s-feed-items → main 8 commits fork 5 threads · 5 unresolved
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 | ||
| 51 | 51 | - 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. |
| 52 | 52 | - Only a production start updates the menu's name and page. A pull's `forge.json` cannot relabel the forge's menu. |
| 53 | 53 | |
| 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 | ||
| 54 | 58 | ## Without the crew |
| 55 | 59 | |
| 56 | 60 | 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 | ||
| 76 | 80 | |
| 77 | 81 | ## Not done yet |
| 78 | 82 | |
| 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). | |
| 80 | 84 | |
| 81 | 85 | ## MCP tools |
| 82 | 86 |
src/actions/features.ts +21 −1
| @@ -32,8 +32,10 @@ import { | ||
| 32 | 32 | setFeatureSettings as keepSettings, |
| 33 | 33 | unabsorb, |
| 34 | 34 | } 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"; | |
| 36 | 36 | import type { User } from "../core/users.ts"; |
| 37 | import { readFeedPage } from "../feed/feed.ts"; | |
| 38 | import type { FeedItem } from "../feed/rank.ts"; | |
| 37 | 39 | import { resolveOid } from "../git/merge.ts"; |
| 38 | 40 | import { commitLog } from "../git/read.ts"; |
| 39 | 41 | import { repoDir } from "../git/repos.ts"; |
| @@ -321,6 +323,24 @@ export async function getFeature(app: App, actor: User | undefined, slug: string | ||
| 321 | 323 | return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance)); |
| 322 | 324 | } |
| 323 | 325 | |
| 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 | } | |
| 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 | ||
| 324 | 344 | async function redeployAll(app: App, record: AppRecord, repo: Repo): Promise<void> { |
| 325 | 345 | await appsRuntime(app)?.reconcileApp(record, repo, "redeploy"); |
| 326 | 346 | } |
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"; | ||
| 72 | 72 | export { route as getApiV1Family } from "./getApiV1Family.ts"; |
| 73 | 73 | export { route as getApiV1Features } from "./getApiV1Features.ts"; |
| 74 | 74 | export { route as getApiV1FeaturesSlug } from "./getApiV1FeaturesSlug.ts"; |
| 75 | export { route as getApiV1FeaturesSlugFeed } from "./getApiV1FeaturesSlugFeed.ts"; | |
| 75 | 76 | export { route as getApiV1Feed } from "./getApiV1Feed.ts"; |
| 76 | 77 | export { route as getApiV1FeedChips } from "./getApiV1FeedChips.ts"; |
| 77 | 78 | export { route as getApiV1Flags } from "./getApiV1Flags.ts"; |
src/feed/feed.ts +4
| @@ -138,6 +138,8 @@ type FeedOptions = { | ||
| 138 | 138 | chips?: Chips; |
| 139 | 139 | /** The home pins asks above the feed: they must not fill its page. */ |
| 140 | 140 | withoutAsks?: boolean; |
| 141 | /** Only this repository's items (an app's own feed); its mute does not hide them. */ | |
| 142 | repoId?: string; | |
| 141 | 143 | /** Told how long the settled-state read took, for Server-Timing. */ |
| 142 | 144 | phase?: (name: string, ms: number) => void; |
| 143 | 145 | }; |
| @@ -166,6 +168,7 @@ export function readFeedPage( | ||
| 166 | 168 | ...shared, |
| 167 | 169 | ] |
| 168 | 170 | // The chips: the one filter. An ask was filtered by it in `askLinesFor`. |
| 171 | .filter((c) => options.repoId === undefined || c.repoId === options.repoId) | |
| 169 | 172 | .filter((c) => c.kind === "ask" || !chipHides(chips, c)) |
| 170 | 173 | .filter((c) => |
| 171 | 174 | // An ask is never muted away. Its own dismissal was already read by |
| @@ -177,6 +180,7 @@ export function readFeedPage( | ||
| 177 | 180 | (c.kind === "ask" || |
| 178 | 181 | c.kind === "milestone" || |
| 179 | 182 | c.kind === "choose" || |
| 183 | options.repoId !== undefined || | |
| 180 | 184 | !muted.has(c.repoId)), |
| 181 | 185 | ); |
| 182 | 186 | // After every filter, so a burst folds only what the viewer would see. |
src/http/api/features.ts +9
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * |
| 5 | 5 | * GET /api/v1/features every app that declares one (admin) |
| 6 | 6 | * 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) | |
| 7 | 8 | * PUT /api/v1/features/:slug absorb it (admin scope) |
| 8 | 9 | * DELETE /api/v1/features/:slug un-absorb it; settings are kept (admin scope) |
| 9 | 10 | * PATCH /api/v1/features/:slug/settings `{ settings: { key: value } }` (admin scope) |
| @@ -12,6 +13,7 @@ | ||
| 12 | 13 | import { |
| 13 | 14 | absorbApp, |
| 14 | 15 | changeFeatureSettings, |
| 16 | featureFeed, | |
| 15 | 17 | getFeature, |
| 16 | 18 | listFeatures, |
| 17 | 19 | unabsorbApp, |
| @@ -49,6 +51,13 @@ export function featureRoutes(router: Router<Handler>): void { | ||
| 49 | 51 | return json(found.value); |
| 50 | 52 | }); |
| 51 | 53 | |
| 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 | ||
| 52 | 61 | router.add("PUT", "/api/v1/features/:slug", async (ctx) => { |
| 53 | 62 | const denied = requireScope(ctx, "admin"); |
| 54 | 63 | 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 ( |
||
| 2 | 2 | import { callFeatureTool } from "../../../actions/feature_tools.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | 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", | |
| 9 | 9 | description: "Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings.", |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| 13 | 13 | slug: { type: "string", maxLength: 100 }, |
| 14 | feed: { type: "boolean" }, | |
| 14 | 15 | absorb: { type: "boolean" }, |
| 15 | 16 | settings: { type: "object" }, |
| 16 | 17 | tool: { type: "string", maxLength: 40 }, |
| @@ -30,6 +31,7 @@ export const tool: Tool = { | ||
| 30 | 31 | if (!found.ok) return bad(found.error, found.error === "not_a_feature" ? "that app declares no feature" : "no such app"); |
| 31 | 32 | const tool = typeof args["tool"] === "string" ? args["tool"] : undefined; |
| 32 | 33 | if (tool !== undefined) { |
| 34 | if (args["feed"] === true) return bad("invalid", "feed reads; a tool call acts: send them apart"); | |
| 33 | 35 | const spec = found.value.tools.find((t) => t.name === tool); |
| 34 | 36 | const given = typeof args["args"] === "object" && args["args"] !== null && !Array.isArray(args["args"]) ? (args["args"] as Record<string, unknown>) : {}; |
| 35 | 37 | 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 = { | ||
| 41 | 43 | const settings = typeof args["settings"] === "object" && args["settings"] !== null && !Array.isArray(args["settings"]) |
| 42 | 44 | ? (args["settings"] as Record<string, unknown>) |
| 43 | 45 | : 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 | } | |
| 44 | 50 | if (absorb === undefined && settings === undefined) return good(found.value); |
| 45 | 51 | if (args["dryRun"] !== false) { |
| 46 | 52 | const would = [ |
src/tui/screens/entries.ts +2
| @@ -27,6 +27,7 @@ import { entry as edgeOnly } from "./edgeonly.ts"; | ||
| 27 | 27 | import { entry as traffic } from "./trafficview.ts"; |
| 28 | 28 | import { entry as featureReview } from "./features.ts"; |
| 29 | 29 | import { entry as feed } from "./feed.ts"; |
| 30 | import { entry as featureFeed } from "./featurefeed.ts"; | |
| 30 | 31 | import { entry as features } from "./featureswitch.ts"; |
| 31 | 32 | import { entry as forgeView } from "./forgeview.ts"; |
| 32 | 33 | import { entry as flowsAll } from "./flowsall.ts"; |
| @@ -120,6 +121,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 120 | 121 | domains, |
| 121 | 122 | feed, |
| 122 | 123 | featureReview, |
| 124 | featureFeed, | |
| 123 | 125 | features, |
| 124 | 126 | front, |
| 125 | 127 | 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 { | ||
| 10 | 10 | absorbApp, |
| 11 | 11 | acceptDeclaration, |
| 12 | 12 | changeFeatureSettings, |
| 13 | feedOf, | |
| 13 | 14 | getFeature, |
| 14 | 15 | listFeatures, |
| 15 | 16 | unabsorbApp, |
| @@ -18,13 +19,14 @@ import { | ||
| 18 | 19 | provenanceFacts, |
| 19 | 20 | } from "../../actions/features.ts"; |
| 20 | 21 | import { DAYS } from "../../apps/feature.ts"; |
| 22 | import type { FeedItem } from "../../feed/rank.ts"; | |
| 21 | 23 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 24 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 23 | 25 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 24 | 26 | import { readForm, type Form } from "../form.ts"; |
| 25 | 27 | import { html, raw, when } from "../html.ts"; |
| 26 | 28 | 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"; | |
| 28 | 30 | import { accountSections } from "./origins.ts"; |
| 29 | 31 | |
| 30 | 32 | function requireAdmin(ctx: Ctx): Response | undefined { |
| @@ -155,7 +157,7 @@ export async function featurePage(ctx: Ctx): Promise<Response> { | ||
| 155 | 157 | const found = await timePhase(ctx.request, "feature", () => getFeature(ctx.app, ctx.auth.user, ctx.params["slug"] ?? "")); |
| 156 | 158 | if (!found.ok) return notFoundPage(ctx); |
| 157 | 159 | 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)); | |
| 159 | 161 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 160 | 162 | return response; |
| 161 | 163 | } |
| @@ -176,7 +178,7 @@ function typedValue(s: SettingView, raw: unknown): string { | ||
| 176 | 178 | return text; |
| 177 | 179 | } |
| 178 | 180 | |
| 179 | function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response { | |
| 181 | function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonly FeedItem[] = []): Response { | |
| 180 | 182 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 181 | 183 | const settings = f.settings.map((shown) => { |
| 182 | 184 | const bad = refused?.key === shown.key; |
| @@ -205,6 +207,16 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response { | ||
| 205 | 207 | </p> |
| 206 | 208 | </form>`} |
| 207 | 209 | </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 |
||
| 219 | </section> | |
| 208 | 220 | <p class="muted small"> |
| 209 | 221 | Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right. |
| 210 | 222 | <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"; | ||
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { absorbApp } from "../src/actions/features.ts"; | |
| 11 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 12 | 13 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 13 | 14 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -32,6 +33,7 @@ describe("features in the terminal", () => { | ||
| 32 | 33 | let ada: User; |
| 33 | 34 | let eve: User; |
| 34 | 35 | let appId: string; |
| 36 | let repoId: string; | |
| 35 | 37 | |
| 36 | 38 | function boot(user: User) { |
| 37 | 39 | const terminal = headlessTerminal(120, 32); |
| @@ -48,11 +50,11 @@ describe("features in the terminal", () => { | ||
| 48 | 50 | for (const ch of text) key(ch); |
| 49 | 51 | await settleApp(app); |
| 50 | 52 | }; |
| 51 | const palette = async () => { | |
| 53 | const palette = async (query = "Features") => { | |
| 52 | 54 | await settleApp(app); |
| 53 | 55 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); |
| 54 | 56 | await settleApp(app); |
| 55 | await type("Features"); | |
| 57 | await type(query); | |
| 56 | 58 | }; |
| 57 | 59 | return { text: () => app.screen.toText(), press, type, palette, stop: () => app.stop() }; |
| 58 | 60 | } |
| @@ -74,6 +76,7 @@ describe("features in the terminal", () => { | ||
| 74 | 76 | const row = insertApp(forge.db, { repoId: repo.value.id, slug: "radar", access: "public" }); |
| 75 | 77 | if (!row.ok) throw new Error(row.error); |
| 76 | 78 | appId = row.value.id; |
| 79 | repoId = repo.value.id; | |
| 77 | 80 | }, 60_000); |
| 78 | 81 | afterAll(async () => { |
| 79 | 82 | forge?.close(); |
| @@ -112,4 +115,25 @@ describe("features in the terminal", () => { | ||
| 112 | 115 | expect(getAbsorbed(forge.db, appId)).toBeUndefined(); |
| 113 | 116 | t.stop(); |
| 114 | 117 | }); |
| 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 | }); | |
| 115 | 139 | }); |
test/features.test.ts +71 −1
| @@ -15,7 +15,7 @@ import { join } from "node:path"; | ||
| 15 | 15 | import { statements } from "./helpers/statements.ts"; |
| 16 | 16 | import { expectWork } from "./helpers/work.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { forkRepo } from "../src/core/repos.ts"; |
| 21 | 21 | import { resolveOid } from "../src/git/merge.ts"; |
| @@ -26,6 +26,8 @@ import { featureVarsAt } from "../src/apps/feature_env.ts"; | ||
| 26 | 26 | import { appEnv } from "../src/apps/process.ts"; |
| 27 | 27 | import { createApp as insertApp, deleteApp } from "../src/core/apps.ts"; |
| 28 | 28 | import { listEvents } from "../src/core/events.ts"; |
| 29 | import { muteRepo } from "../src/core/interactions.ts"; | |
| 30 | import { readFeedPage } from "../src/feed/feed.ts"; | |
| 29 | 31 | import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts"; |
| 30 | 32 | import type { Repo } from "../src/core/repos.ts"; |
| 31 | 33 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -584,6 +586,74 @@ describe("absorbing an app", () => { | ||
| 584 | 586 | expect(stranger.error).toBeDefined(); |
| 585 | 587 | }); |
| 586 | 588 | |
| 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 | ||
| 587 | 657 | test("the MCP tool lists the app's tools, plans a write, and refuses what is not declared or not running", async () => { |
| 588 | 658 | const listed = await mcpTool(servers.url, adaToken, "features", { slug: "radar" }); |
| 589 | 659 | 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", () => { | ||
| 143 | 143 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 144 | 144 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 145 | 145 | ["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"], | |
| 146 | 147 | ["features", "Features", "apps that declare a feature: absorb one, change its settings"], |
| 147 | 148 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 148 | 149 | ["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", | ||
| 211 | 212 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 212 | 213 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
| 213 | 214 | ["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"], | |
| 214 | 216 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 215 | 217 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 216 | 218 | ["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" | ||
| 281 | 283 | ["bets", "Bets", "back, stop and finish a repository\'s bets"], |
| 282 | 284 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 283 | 285 | ["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"], | |
| 284 | 287 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 285 | 288 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 286 | 289 | ["profile", "Your profile", "display name and email"], |
featureFeedrepeatsgetFeatureand thenreadable(), andfeaturePagehas already calledgetFeature. 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.