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

Feed model and ranker: a per-viewer read model of forge events, ranked, each item with a one-sentence reason #364

Merged opened this

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

Merged

scout/forge:scout/360-feed-model-and-ranker-a-per-viewer-read → main 1 commit fork

  • Merged. scout/360-feed-model-and-ranker-a-per-viewer-read was merged into main as c0eddaa.

Review

Sign in to review this change.
16 files changed +1304 −5
CLAUDE.md +3
@@ -136,6 +136,9 @@ src/
136136 page says it in), asks, leans, previews, shelf (an app as one
137137 row, for any page that lists apps), home, app, change, tell,
138138 run, lists, actions, style (the page sheet, assembled)
139 feed/ a viewer's feed (docs/FEED.md): items (candidates read through
140 the access every page uses), affinity (decayed counts), rank,
141 feed (filter, page, cursor)
139142 ssh/ the SSH server: transport (kex/ciphers), auth (publickey),
140143 channels (session only), keys, wire — zero deps, no forwarding
141144 tui/ the terminal UI. term/ is the transport boundary (`Terminal`);
docs/FEED.md added +43
@@ -0,0 +1,43 @@
1 # The feed
2
3 A per-viewer read model of what happened on the forge, ranked, each item with
4 one sentence saying why it is there. Data only: there is no page yet.
5
6 `GET /api/v1/feed?limit=&cursor=` → `{ items, next_cursor }`, for the signed-in
7 viewer only. An item is `{ id, kind, app, actor, at, link, reason, score }`;
8 `kind` is `run | ask | pull | decision | map | cost`. Strings are plain text,
9 not HTML: whatever renders them escapes them.
10
11 `POST /api/v1/feed/interactions` `{ item, kind, app? }` records what the viewer
12 did (`opened dwelled steered told swiped shipped shared dismissed muted`).
13 Same-origin checked for browser sessions, `rw` for tokens. An `event:` item
14 names its own repo and actor; any other item names its app as `owner/name`.
15 Anything the viewer cannot read is a 404.
16
17 ## Where items come from (`src/feed/items.ts`)
18
19 - Events through `listEventsVisibleTo` then `audienceFilter`: `run.finished`
20 (a deploy, check or agent run; a costly agent run is a `cost` item, shown
21 only to those who may see owner facts), `pull.opened|merged|reviewed`,
22 `look.taken` (new: a full set of preview pictures), `app.serving`,
23 `issue.blocked|unblocked` (map changes).
24 - Pending asks (`askLinesFor`), only for what the viewer may decide.
25 - Decision-ledger outcomes, only for repositories the viewer may write.
26
27 Runs already announce themselves on the outbox (`run.*`); looks did not, so
28 `look.taken` was added.
29
30 ## Ranking (`src/feed/rank.ts`, `affinity.ts`)
31
32 `score = urgency + 1.5·affinity + recency − repeats`. Urgency: asks 6–10, a
33 failed run 4. Recency halves every 6 hours. Affinity is a decayed count
34 (half-life 7 days) per app and per actor, seeded from ownership, pins, crew,
35 the viewer's own merges/proposals/tells and ledger outcomes, then moved by
36 interactions; a dismissal is −4, a mute −8 and hides the app. Each item
37 already placed from the same app costs 1.2, from the same actor 0.6.
38 The reason is the sentence for the largest term.
39
40 Muting hides an app's items except asks: what waits on a person is never
41 muted away. Dismissing hides one item. The cursor is `score|id` of the last
42 item; the feed is recomputed per request and resumes by id (or by score if
43 that item has gone).
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/core/decisions.ts +18
@@ -282,3 +282,21 @@ export function decisionStats(
282282 ...k,
283283 }));
284284 }
285
286 /** A decision whose outcome is known, newest outcome first, for these repositories. */
287 export function listDecidedIn(
288 db: Database,
289 repoIds: readonly string[],
290 limit = 50,
291 ): Decision[] {
292 if (repoIds.length === 0) return [];
293 const marks = repoIds.map((_, n) => `?${n + 2}`).join(",");
294 return db
295 .query<Row, (string | number)[]>(
296 `SELECT * FROM decision
297 WHERE outcome IS NOT NULL AND kind != 'lean' AND repo_id IN (${marks})
298 ORDER BY outcome_at DESC, id DESC LIMIT ?1`,
299 )
300 .all(limit, ...repoIds)
301 .map(toDecision);
302 }
src/core/events.ts +8
@@ -140,6 +140,14 @@ const toEvent = (r: EventRow): ForgeEvent => ({
140140 createdAt: r.created_at,
141141 });
142142
143 /** One event by id, or undefined. No access check: the caller owes one. */
144 export function getEvent(db: Database, eventId: string): ForgeEvent | undefined {
145 const row = db
146 .query<EventRow, [string]>("SELECT * FROM event WHERE id = ?1")
147 .get(eventId);
148 return row ? toEvent(row) : undefined;
149 }
150
143151 export function listEvents(
144152 db: Database,
145153 options: ListEventsOptions = {},
src/core/interactions.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * What a viewer did with a feed item. One row per act, never edited; the
3 * feed's affinity is these rows counted with a decay (`feed/affinity.ts`).
4 * Pure data: the route that records one owes the access check.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { id } from "../kernel/id.ts";
9
10 export const INTERACTION_KINDS = [
11 "opened",
12 "dwelled",
13 "steered",
14 "told",
15 "swiped",
16 "shipped",
17 "shared",
18 "dismissed",
19 "muted",
20 ] as const;
21 export type InteractionKind = (typeof INTERACTION_KINDS)[number];
22
23 export const isInteractionKind = (v: unknown): v is InteractionKind =>
24 typeof v === "string" && (INTERACTION_KINDS as readonly string[]).includes(v);
25
26 /** A feed item id: a namespace, then what it names — `event:01J…`, `ask:look:…`. */
27 export const ITEM_ID = /^[a-z]{2,12}:[A-Za-z0-9_.:-]{1,160}$/;
28
29 export interface Interaction {
30 readonly id: string;
31 readonly viewerId: string;
32 readonly itemId: string;
33 readonly kind: InteractionKind;
34 readonly repoId: string | null;
35 readonly actorId: string | null;
36 readonly createdAt: string;
37 }
38
39 interface Row {
40 id: string;
41 viewer_id: string;
42 item_id: string;
43 kind: InteractionKind;
44 repo_id: string | null;
45 actor_id: string | null;
46 created_at: string;
47 }
48
49 const toInteraction = (r: Row): Interaction => ({
50 id: r.id,
51 viewerId: r.viewer_id,
52 itemId: r.item_id,
53 kind: r.kind,
54 repoId: r.repo_id,
55 actorId: r.actor_id,
56 createdAt: r.created_at,
57 });
58
59 export function recordInteraction(
60 db: Database,
61 input: {
62 viewerId: string;
63 itemId: string;
64 kind: InteractionKind;
65 repoId: string | null;
66 actorId: string | null;
67 at?: string;
68 },
69 ): Interaction {
70 const row: Row = {
71 id: id(),
72 viewer_id: input.viewerId,
73 item_id: input.itemId,
74 kind: input.kind,
75 repo_id: input.repoId,
76 actor_id: input.actorId,
77 created_at: input.at ?? new Date().toISOString(),
78 };
79 db.query(
80 `INSERT INTO interaction (id, viewer_id, item_id, kind, repo_id, actor_id, created_at)
81 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
82 ).run(
83 row.id,
84 row.viewer_id,
85 row.item_id,
86 row.kind,
87 row.repo_id,
88 row.actor_id,
89 row.created_at,
90 );
91 return toInteraction(row);
92 }
93
94 /** A viewer's newest interactions, newest first — the window affinity reads. */
95 export function listInteractions(
96 db: Database,
97 viewerId: string,
98 limit = 1000,
99 ): Interaction[] {
100 return db
101 .query<Row, [string, number]>(
102 "SELECT * FROM interaction WHERE viewer_id = ?1 ORDER BY id DESC LIMIT ?2",
103 )
104 .all(viewerId, limit)
105 .map(toInteraction);
106 }
107
108 /** The repositories this viewer muted. Mute does not decay. */
109 export function mutedRepos(db: Database, viewerId: string): Set<string> {
110 return new Set(
111 db
112 .query<{ repo_id: string }, [string]>(
113 "SELECT DISTINCT repo_id FROM interaction WHERE viewer_id = ?1 AND kind = 'muted' AND repo_id IS NOT NULL",
114 )
115 .all(viewerId)
116 .map((r) => r.repo_id),
117 );
118 }
119
120 /** Items this viewer dismissed. */
121 export function dismissedItems(db: Database, viewerId: string): Set<string> {
122 return new Set(
123 db
124 .query<{ item_id: string }, [string]>(
125 "SELECT DISTINCT item_id FROM interaction WHERE viewer_id = ?1 AND kind = 'dismissed'",
126 )
127 .all(viewerId)
128 .map((r) => r.item_id),
129 );
130 }
src/core/schema.ts +19
@@ -937,4 +937,23 @@ export const migrations: readonly string[] = [
937937 PRIMARY KEY (name, scope, scope_id)
938938 );
939939 `,
940
941 // 35 — what a viewer did with a feed item: opened it, dwelled on it,
942 // dismissed it, muted its app. One row per act; the feed's affinity is
943 // these, counted with a decay. `item_id` is the feed's own id, and
944 // `repo_id`/`actor_id` are what the act says it was about, so a row
945 // outlives the event it named.
946 `
947 CREATE TABLE interaction (
948 id TEXT PRIMARY KEY,
949 viewer_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
950 item_id TEXT NOT NULL,
951 kind TEXT NOT NULL CHECK (kind IN
952 ('opened','dwelled','steered','told','swiped','shipped','shared','dismissed','muted')),
953 repo_id TEXT REFERENCES repo(id) ON DELETE CASCADE,
954 actor_id TEXT REFERENCES user(id) ON DELETE SET NULL,
955 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
956 );
957 CREATE INDEX interaction_viewer ON interaction(viewer_id, id);
958 `,
940959 ];
src/feed/affinity.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * What a viewer cares about, as decayed counts per app and per actor. No
3 * model: a seed from what they own, pin and have on their crew, plus every
4 * act they have done — merging, opening, telling, and what they did with
5 * feed items — each weighed and halved every week. The strongest part of a
6 * score is kept by name, because the ranker's reason is made from it.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { listDecidedIn } from "../core/decisions.ts";
11 import type { InteractionKind } from "../core/interactions.ts";
12 import { listInteractions } from "../core/interactions.ts";
13 import { getPull } from "../core/pulls.ts";
14 import { listReposWritableTo, type Repo } from "../core/repos.ts";
15 import type { User } from "../core/users.ts";
16
17 export const HALF_LIFE_MS = 7 * 24 * 3600 * 1000;
18 export const decay = (ageMs: number, halfLife = HALF_LIFE_MS): number =>
19 0.5 ** (Math.max(0, ageMs) / halfLife);
20
21 /** Why a part exists — the word the reason sentence is built around. */
22 export type Why =
23 | "owns"
24 | "works on"
25 | "pinned"
26 | "crew"
27 | "shipped"
28 | "proposed"
29 | "told"
30 | "decided"
31 | InteractionKind;
32
33 /** What each interaction is worth. Negatives outweigh the positives. */
34 export const INTERACTION_WEIGHT: Readonly<Record<InteractionKind, number>> = {
35 opened: 1,
36 dwelled: 1.5,
37 steered: 3,
38 told: 3,
39 swiped: 0.5,
40 shipped: 4,
41 shared: 2,
42 dismissed: -4,
43 muted: -8,
44 };
45
46 export interface Weighted {
47 readonly score: number;
48 /** The part with the largest size, and when it last counted. */
49 readonly why: Why | undefined;
50 readonly at: number | undefined;
51 /** What that part was about, when it was one thing ("Fix the nav"). */
52 readonly label: string | undefined;
53 }
54
55 const NONE: Weighted = {
56 score: 0,
57 why: undefined,
58 at: undefined,
59 label: undefined,
60 };
61
62 interface Part {
63 sum: number;
64 at: number;
65 label: string | undefined;
66 }
67
68 export class Affinity {
69 private readonly parts = new Map<string, Map<Why, Part>>();
70
71 add(
72 key: string,
73 why: Why,
74 weight: number,
75 at: number,
76 label?: string | undefined,
77 ): void {
78 let byWhy = this.parts.get(key);
79 if (!byWhy) this.parts.set(key, (byWhy = new Map()));
80 const part = byWhy.get(why);
81 if (!part) byWhy.set(why, { sum: weight, at, label });
82 else {
83 part.sum += weight;
84 if (at >= part.at) {
85 part.at = at;
86 if (label !== undefined) part.label = label;
87 }
88 }
89 }
90
91 get(key: string): Weighted {
92 const byWhy = this.parts.get(key);
93 if (!byWhy) return NONE;
94 let score = 0;
95 let top: { why: Why; part: Part } | undefined;
96 for (const [why, part] of byWhy) {
97 score += part.sum;
98 if (!top || Math.abs(part.sum) > Math.abs(top.part.sum))
99 top = { why, part };
100 }
101 return {
102 score,
103 why: top?.why,
104 at: top?.part.at,
105 label: top?.part.label,
106 };
107 }
108
109 app = (repoId: string): Weighted => this.get(`app:${repoId}`);
110 actor = (userId: string): Weighted => this.get(`actor:${userId}`);
111 }
112
113 /** What the viewer's own past acts on the outbox count for. */
114 const OWN_ACTS: Readonly<Record<string, { why: Why; weight: number }>> = {
115 "pull.merged": { why: "shipped", weight: 3 },
116 "pull.opened": { why: "proposed", weight: 1.5 },
117 "issue.opened": { why: "told", weight: 2 },
118 "prompt.told": { why: "told", weight: 2 },
119 };
120
121 export function affinityFor(
122 db: Database,
123 viewer: User,
124 now: number,
125 ): Affinity {
126 const aff = new Affinity();
127 const mine = listReposWritableTo(db, viewer, { limit: 200 });
128 for (const repo of mine)
129 aff.add(
130 `app:${repo.id}`,
131 repo.ownerId === viewer.id ? "owns" : "works on",
132 2,
133 now,
134 );
135 for (const row of db
136 .query<{ repo_id: string }, [string]>(
137 "SELECT repo_id FROM pin WHERE user_id = ?1",
138 )
139 .all(viewer.id))
140 aff.add(`app:${row.repo_id}`, "pinned", 1, now);
141 seedCrew(db, aff, mine, now);
142 seedOwnActs(db, aff, viewer, now);
143 for (const d of listDecidedIn(
144 db,
145 mine.map((r) => r.id),
146 )) {
147 const at = Date.parse(d.outcomeAt ?? d.createdAt);
148 if (d.outcome === "shipped")
149 aff.add(`app:${d.repoId}`, "decided", decay(now - at), at);
150 }
151 for (const i of listInteractions(db, viewer.id)) {
152 const at = Date.parse(i.createdAt);
153 const w = INTERACTION_WEIGHT[i.kind] * decay(now - at);
154 if (i.repoId !== null) aff.add(`app:${i.repoId}`, i.kind, w, at);
155 if (i.actorId !== null) aff.add(`actor:${i.actorId}`, i.kind, w, at);
156 }
157 return aff;
158 }
159
160 function seedCrew(
161 db: Database,
162 aff: Affinity,
163 repos: readonly Repo[],
164 now: number,
165 ): void {
166 if (repos.length === 0) return;
167 const marks = repos.map((_, n) => `?${n + 1}`).join(",");
168 for (const row of db
169 .query<{ user_id: string }, string[]>(
170 `SELECT DISTINCT user_id FROM repo_crew WHERE repo_id IN (${marks})`,
171 )
172 .all(...repos.map((r) => r.id)))
173 aff.add(`actor:${row.user_id}`, "crew", 1, now);
174 }
175
176 function seedOwnActs(
177 db: Database,
178 aff: Affinity,
179 viewer: User,
180 now: number,
181 ): void {
182 const kinds = Object.keys(OWN_ACTS);
183 const rows = db
184 .query<
185 { kind: string; repo_id: string; payload: string; created_at: string },
186 string[]
187 >(
188 `SELECT kind, repo_id, payload, created_at FROM event
189 WHERE actor_id = ?1 AND repo_id IS NOT NULL
190 AND kind IN (${kinds.map((_, n) => `?${n + 2}`).join(",")})
191 ORDER BY id DESC LIMIT 200`,
192 )
193 .all(viewer.id, ...kinds);
194 for (const row of rows) {
195 const act = OWN_ACTS[row.kind]!;
196 const at = Date.parse(row.created_at);
197 let label: string | undefined;
198 try {
199 const number = (JSON.parse(row.payload) as { number?: unknown }).number;
200 if (typeof number === "number" && row.kind.startsWith("pull."))
201 label = getPull(db, row.repo_id, number)?.title;
202 } catch {
203 // A payload that is not JSON names nothing.
204 }
205 aff.add(
206 `app:${row.repo_id}`,
207 act.why,
208 act.weight * decay(now - at),
209 at,
210 label,
211 );
212 }
213 }
src/feed/feed.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * One viewer's feed: candidates the viewer may see, less what they muted or
3 * dismissed, ranked, then a page of it. A muted app goes quiet except for
4 * asks — something that waits on a person is never muted away. The cursor
5 * is the last item's `score|id`; the feed is recomputed each time, and the
6 * cursor finds its place by id, or by score when that item is gone.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { dismissedItems, mutedRepos } from "../core/interactions.ts";
11 import type { User } from "../core/users.ts";
12 import { affinityFor } from "./affinity.ts";
13 import { candidatesFor } from "./items.ts";
14 import { rank, type FeedItem } from "./rank.ts";
15
16 export const FEED_LIMIT_DEFAULT = 20;
17 export const FEED_LIMIT_MAX = 50;
18
19 const CURSOR = /^(-?\d{1,6}(?:\.\d{1,6})?)\|([a-z]{2,12}:[A-Za-z0-9_.:-]{1,160})$/;
20
21 export const cursorOf = (item: FeedItem): string => `${item.score}|${item.id}`;
22
23 /** A cursor as it was issued, or undefined for anything else. */
24 export function parseCursor(
25 raw: string,
26 ): { score: number; id: string } | undefined {
27 const m = CURSOR.exec(raw);
28 return m ? { score: Number(m[1]), id: m[2]! } : undefined;
29 }
30
31 export function readFeed(
32 db: Database,
33 viewer: User,
34 options: { limit?: number; cursor?: { score: number; id: string }; now?: number } = {},
35 ): { items: FeedItem[]; next_cursor: string | null } {
36 const now = options.now ?? Date.now();
37 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
38 const muted = mutedRepos(db, viewer.id);
39 const dismissed = dismissedItems(db, viewer.id);
40 const candidates = candidatesFor(db, viewer, now).filter(
41 (c) => !dismissed.has(c.id) && (c.kind === "ask" || !muted.has(c.repoId)),
42 );
43 let ranked = rank(candidates, affinityFor(db, viewer, now), now);
44 const cursor = options.cursor;
45 if (cursor) {
46 const at = ranked.findIndex((i) => i.id === cursor.id);
47 ranked =
48 at >= 0
49 ? ranked.slice(at + 1)
50 : ranked.filter((i) => i.score < cursor.score);
51 }
52 const items = ranked.slice(0, limit);
53 const last = items[items.length - 1];
54 return {
55 items,
56 next_cursor:
57 ranked.length > limit && last !== undefined ? cursorOf(last) : null,
58 };
59 }
src/feed/items.ts added +202
@@ -0,0 +1,202 @@
1 /**
2 * The feed's candidates: what happened that a viewer may be told about, as
3 * plain data. Every source reads through the access the rest of the forge
4 * uses — events by repository visibility and then `audienceFilter`, asks by
5 * what the viewer may decide, the ledger and money by what an owner may see
6 * — so the feed cannot say what a page would not.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { listDecidedIn } from "../core/decisions.ts";
11 import { audienceFilter } from "../core/event_audience.ts";
12 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
13 import { getPull } from "../core/pulls.ts";
14 import {
15 canSeeOwnerFacts,
16 getReposByIds,
17 listReposWritableTo,
18 type Repo,
19 } from "../core/repos.ts";
20 import { getUserById, type User } from "../core/users.ts";
21 import { askLinesFor } from "../web/studio/asks.ts";
22
23 export type FeedKind = "run" | "ask" | "pull" | "decision" | "map" | "cost";
24
25 /** What a candidate is, before it is ranked and worded. */
26 export interface Candidate {
27 readonly id: string;
28 readonly kind: FeedKind;
29 readonly repoId: string;
30 /** `owner/name`. */
31 readonly app: string;
32 readonly actorId: string | null;
33 /** The actor's username. */
34 readonly actor: string | null;
35 readonly at: string;
36 readonly link: string;
37 /** What happened, as a clause: the reason when recency or urgency leads. */
38 readonly what: string;
39 /** 0 for news; an ask or a failure is more. */
40 readonly urgency: number;
41 }
42
43 export const EVENT_WINDOW = 300;
44 const COST_FLOOR_USD = 1;
45 const clip = (s: string, n = 120): string =>
46 s.length > n ? `${s.slice(0, n - 1)}…` : s;
47
48 const slug = (r: Repo): string => `${r.ownerName}/${r.name}`;
49 const base = (r: Repo): string => `/${r.ownerName}/${r.name}`;
50
51 const URGENCY = { stopped: 10, look: 8, paused: 6 } as const;
52
53 export function candidatesFor(
54 db: Database,
55 viewer: User,
56 now: number,
57 ): Candidate[] {
58 const events = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }).filter(
59 audienceFilter(db, viewer),
60 );
61 const writable = listReposWritableTo(db, viewer, { limit: 200 });
62 const asks = askLinesFor(db, viewer);
63 const decided = listDecidedIn(
64 db,
65 writable.map((r) => r.id),
66 );
67 const repos = getReposByIds(db, [
68 ...events.map((e) => e.repoId!),
69 ...asks.map((a) => a.repoId),
70 ]);
71 for (const r of writable) repos.set(r.id, r);
72
73 const names = new Map<string, string | null>();
74 const nameOf = (id: string | null): string | null => {
75 if (id === null) return null;
76 if (!names.has(id)) names.set(id, getUserById(db, id)?.username ?? null);
77 return names.get(id) ?? null;
78 };
79 const owner = new Map<string, boolean>();
80 const owns = (repo: Repo): boolean => {
81 if (!owner.has(repo.id))
82 owner.set(repo.id, canSeeOwnerFacts(db, repo, viewer));
83 return owner.get(repo.id)!;
84 };
85
86 const out: Candidate[] = [];
87 for (const e of events) {
88 const repo = repos.get(e.repoId!);
89 if (!repo) continue;
90 const c = fromEvent(db, e, repo, owns(repo));
91 if (c) out.push({ ...c, actor: nameOf(e.actorId) });
92 }
93 for (const a of asks) {
94 const repo = repos.get(a.repoId);
95 if (!repo) continue;
96 out.push({
97 id: `ask:${a.id}`,
98 kind: "ask",
99 repoId: repo.id,
100 app: slug(repo),
101 actorId: null,
102 actor: null,
103 at: new Date(now).toISOString(),
104 link:
105 a.number === undefined
106 ? `${base(repo)}/settings/autonomy`
107 : `${base(repo)}/changes/${a.number}`,
108 what: a.line,
109 urgency: URGENCY[a.kind],
110 });
111 }
112 for (const d of decided) {
113 const repo = repos.get(d.repoId);
114 if (!repo) continue;
115 out.push({
116 id: `decision:${d.id}`,
117 kind: "decision",
118 repoId: repo.id,
119 app: slug(repo),
120 actorId: null,
121 actor: null,
122 at: d.outcomeAt ?? d.createdAt,
123 link: `${base(repo)}/pulls`,
124 what: clip(`${d.by} chose “${clip(d.answer, 40)}” on a ${d.kind} and it ended ${d.outcome}`),
125 urgency: 0,
126 });
127 }
128 return out;
129 }
130
131 type Partial = Omit<Candidate, "actor">;
132
133 function fromEvent(
134 db: Database,
135 e: ForgeEvent,
136 repo: Repo,
137 ownerFacts: boolean,
138 ): Partial | undefined {
139 const common = {
140 id: `event:${e.id}`,
141 repoId: repo.id,
142 app: slug(repo),
143 actorId: e.actorId,
144 at: e.createdAt,
145 urgency: 0,
146 };
147 const p = e.payload;
148 const number = typeof p["number"] === "number" ? p["number"] : undefined;
149 const title =
150 number === undefined ? "" : (getPull(db, repo.id, number)?.title ?? "");
151 const change = number === undefined ? base(repo) : `${base(repo)}/changes/${number}`;
152 switch (e.kind) {
153 case "run.finished": {
154 const state = p["state"] === "failed" ? "failed" : "ok";
155 const kind = typeof p["kind"] === "string" ? p["kind"] : "run";
156 const cost = typeof p["costUsd"] === "number" ? p["costUsd"] : 0;
157 const link = `${base(repo)}/runs/${String(p["runId"] ?? "")}`;
158 const summary = clip(typeof p["summary"] === "string" ? p["summary"] : "");
159 if (kind === "agent" && cost >= COST_FLOOR_USD && ownerFacts)
160 return {
161 ...common,
162 kind: "cost",
163 link,
164 what: `a run cost $${cost.toFixed(2)}${summary ? `: ${summary}` : ""}`,
165 };
166 return {
167 ...common,
168 kind: "run",
169 link,
170 urgency: state === "failed" ? 4 : 0,
171 what: `a ${kind} run ${state === "failed" ? "failed" : "finished"}${summary ? `: ${summary}` : ""}`,
172 };
173 }
174 case "pull.opened":
175 return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}` };
176 case "pull.merged":
177 return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}` };
178 case "pull.reviewed":
179 return { ...common, kind: "pull", link: change, what: `a change was reviewed${title ? `: ${clip(title)}` : ""}` };
180 case "look.taken":
181 case "app.serving":
182 return {
183 ...common,
184 kind: "pull",
185 link: base(repo),
186 what: e.kind === "look.taken" ? "a new preview picture is ready" : "a new version is serving",
187 };
188 case "issue.blocked":
189 case "issue.unblocked":
190 return {
191 ...common,
192 kind: "map",
193 link: `${base(repo)}/map`,
194 what:
195 e.kind === "issue.blocked"
196 ? `#${String(number)} now waits for #${String(p["blockedBy"])}`
197 : `#${String(number)} no longer waits for #${String(p["blockedBy"])}`,
198 };
199 default:
200 return undefined;
201 }
202 }
src/feed/rank.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The ranker. A score is three terms — urgency, affinity, recency — less a
3 * decay for each item already placed from the same app or the same actor,
4 * so one noisy source cannot fill the feed. Negatives weigh strongly: the
5 * affinity term goes as low as it goes high ×2. The reason is the sentence
6 * for the term that led, so it is never a different story than the order.
7 */
8
9 import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts";
10 import type { Candidate } from "./items.ts";
11
12 export interface FeedItem {
13 readonly id: string;
14 readonly kind: Candidate["kind"];
15 /** `owner/name`. */
16 readonly app: string;
17 readonly actor: string | null;
18 readonly at: string;
19 readonly link: string;
20 /** One sentence: why this, and why here. */
21 readonly reason: string;
22 readonly score: number;
23 }
24
25 const RECENCY_MAX = 4;
26 const RECENCY_HALF_LIFE_MS = 6 * 3600 * 1000;
27 const AFFINITY_WEIGHT = 1.5;
28 const AFFINITY_MAX = 9;
29 const AFFINITY_MIN = -18;
30 export const REPEAT_APP = 1.2;
31 export const REPEAT_ACTOR = 0.6;
32
33 const clamp = (n: number, lo: number, hi: number): number =>
34 Math.min(hi, Math.max(lo, n));
35
36 /** `just now`, `3 hours ago`, `yesterday`, `5 days ago`. */
37 export function ago(at: number, now: number): string {
38 const min = Math.floor((now - at) / 60_000);
39 if (min < 2) return "just now";
40 if (min < 60) return `${min} minutes ago`;
41 const hours = Math.floor(min / 60);
42 if (hours < 24) return hours === 1 ? "an hour ago" : `${hours} hours ago`;
43 const days = Math.floor(hours / 24);
44 return days === 1 ? "yesterday" : `${days} days ago`;
45 }
46
47 const VERB: Partial<Record<Why, string>> = {
48 shipped: "shipped",
49 proposed: "proposed",
50 told: "told",
51 decided: "decided on",
52 opened: "opened",
53 dwelled: "lingered on",
54 steered: "steered",
55 swiped: "swiped through",
56 shared: "shared",
57 };
58
59 function affinityReason(c: Candidate, app: Weighted, actor: Weighted, now: number): string {
60 const lead = app.score >= actor.score ? app : actor;
61 const why = lead.why;
62 const when = lead.at === undefined ? "" : ` ${ago(lead.at, now)}`;
63 if (lead === actor)
64 return why === "crew"
65 ? `because ${c.actor ?? "they"} is on your crew`
66 : `because you ${VERB[why as Why] ?? "worked with"} ${c.actor ?? "them"}${when}`;
67 if (why === "owns") return `because you own ${c.app}`;
68 if (why === "works on") return `because you work on ${c.app}`;
69 if (why === "pinned") return `because you pinned ${c.app}`;
70 const thing = lead.label !== undefined ? `“${lead.label}”` : c.app;
71 return `because you ${VERB[why as Why] ?? "worked on"} ${thing}${when}`;
72 }
73
74 interface Scored {
75 readonly c: Candidate;
76 readonly base: number;
77 readonly reason: string;
78 }
79
80 export function rank(
81 candidates: readonly Candidate[],
82 aff: Affinity,
83 now: number,
84 ): FeedItem[] {
85 const scored: Scored[] = candidates.map((c) => {
86 const app = aff.app(c.repoId);
87 const actor = c.actorId === null ? aff.get("actor:") : aff.actor(c.actorId);
88 const raw = AFFINITY_WEIGHT * (app.score + 0.5 * actor.score);
89 const affinity = clamp(raw, AFFINITY_MIN, AFFINITY_MAX);
90 const recency =
91 RECENCY_MAX * decay(now - Date.parse(c.at), RECENCY_HALF_LIFE_MS);
92 const urgency = c.urgency;
93 const base = urgency + affinity + recency;
94 const reason =
95 urgency >= Math.max(affinity, recency)
96 ? c.what
97 : affinity >= recency
98 ? affinityReason(c, app, actor, now)
99 : `${c.what}, ${ago(Date.parse(c.at), now)}`;
100 return { c, base, reason };
101 });
102
103 // Greedy: place the best remaining, then charge its source against the
104 // rest. Placed scores never rise, which is what lets a cursor resume.
105 const out: FeedItem[] = [];
106 const perApp = new Map<string, number>();
107 const perActor = new Map<string, number>();
108 const rest = [...scored];
109 while (rest.length > 0) {
110 let best = 0;
111 let bestScore = -Infinity;
112 for (let i = 0; i < rest.length; i++) {
113 const s = rest[i]!;
114 const adj =
115 s.base -
116 REPEAT_APP * (perApp.get(s.c.repoId) ?? 0) -
117 REPEAT_ACTOR * (s.c.actorId === null ? 0 : (perActor.get(s.c.actorId) ?? 0));
118 const better =
119 adj > bestScore || (adj === bestScore && s.c.id > rest[best]!.c.id);
120 if (better) {
121 best = i;
122 bestScore = adj;
123 }
124 }
125 const [s] = rest.splice(best, 1);
126 perApp.set(s!.c.repoId, (perApp.get(s!.c.repoId) ?? 0) + 1);
127 if (s!.c.actorId !== null)
128 perActor.set(s!.c.actorId, (perActor.get(s!.c.actorId) ?? 0) + 1);
129 out.push({
130 id: s!.c.id,
131 kind: s!.c.kind,
132 app: s!.c.app,
133 actor: s!.c.actor,
134 at: s!.c.at,
135 link: s!.c.link,
136 reason: s!.reason,
137 score: Math.round(bestScore * 1e6) / 1e6,
138 });
139 }
140 return out;
141 }
src/http/feed.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * The feed's two routes: read it, and say what you did with an item. Both
3 * are the signed-in viewer's own — there is no way to ask for another's.
4 */
5
6 import { audienceFilter } from "../core/event_audience.ts";
7 import { getEvent } from "../core/events.ts";
8 import {
9 ITEM_ID,
10 isInteractionKind,
11 recordInteraction,
12 } from "../core/interactions.ts";
13 import { canRead, getRepo, getRepoById } from "../core/repos.ts";
14 import {
15 FEED_LIMIT_MAX,
16 parseCursor,
17 readFeed,
18 } from "../feed/feed.ts";
19 import type { Handler } from "./context.ts";
20 import type { Router } from "./router.ts";
21 import {
22 apiError,
23 body,
24 json,
25 notFound,
26 requireScope,
27 str,
28 unauthorized,
29 } from "./respond.ts";
30
31 export function feedRoutes(router: Router<Handler>): void {
32 router.add("GET", "/api/v1/feed", (ctx) => {
33 const viewer = ctx.auth.user;
34 if (!viewer) return unauthorized();
35 const q = ctx.url.searchParams;
36 const rawLimit = q.get("limit");
37 const limit = rawLimit === null ? undefined : Number(rawLimit);
38 if (
39 limit !== undefined &&
40 (!Number.isInteger(limit) || limit < 1 || limit > FEED_LIMIT_MAX)
41 )
42 return apiError("invalid_limit", 422, `limit is 1 to ${FEED_LIMIT_MAX}`);
43 const rawCursor = q.get("cursor");
44 const cursor = rawCursor === null ? undefined : parseCursor(rawCursor);
45 if (rawCursor !== null && cursor === undefined)
46 return apiError("invalid_cursor", 422, "that cursor was not issued here");
47 return json(
48 readFeed(ctx.app.db, viewer, {
49 ...(limit === undefined ? {} : { limit }),
50 ...(cursor === undefined ? {} : { cursor }),
51 }),
52 200,
53 { "cache-control": "private, no-store" },
54 );
55 });
56
57 router.add("POST", "/api/v1/feed/interactions", async (ctx) => {
58 const denied = requireScope(ctx, "rw");
59 if (denied) return denied;
60 const viewer = ctx.auth.user!;
61 const db = ctx.app.db;
62 const input = await body(ctx.request);
63 const itemId = str(input.item);
64 const kind = input.kind;
65 if (!ITEM_ID.test(itemId))
66 return apiError("invalid_item", 422, "item is a feed item id");
67 if (!isInteractionKind(kind))
68 return apiError("invalid_kind", 422, "kind is not one the feed knows");
69
70 // What the act is about is read from the item when the item is an event
71 // — through the same visibility the feed used — and from the named
72 // repository otherwise. Anything the viewer cannot read is not found.
73 let repoId: string | null = null;
74 let actorId: string | null = null;
75 if (itemId.startsWith("event:")) {
76 const event = getEvent(db, itemId.slice("event:".length));
77 const repo = event?.repoId ? getRepoById(db, event.repoId) : undefined;
78 if (!event || !repo || !canRead(db, repo, viewer)) return notFound();
79 if (!audienceFilter(db, viewer)(event)) return notFound();
80 repoId = repo.id;
81 actorId = event.actorId;
82 } else {
83 const [owner, name, extra] = str(input.app).split("/");
84 const repo =
85 owner && name && extra === undefined
86 ? getRepo(db, owner, name)
87 : undefined;
88 if (!repo || !canRead(db, repo, viewer)) return notFound();
89 repoId = repo.id;
90 }
91 const made = recordInteraction(db, {
92 viewerId: viewer.id,
93 itemId,
94 kind,
95 repoId,
96 actorId,
97 });
98 return json({ id: made.id }, 201);
99 });
100 }
src/http/server.ts +2
@@ -254,6 +254,7 @@ import { pushThrough } from "../origins/push-through.ts";
254254 import { notFoundPage } from "../web/chrome.ts";
255255 import { targetForShortCode } from "../origins/oauth.ts";
256256 import { apiRoutes } from "./api.ts";
257 import { feedRoutes } from "./feed.ts";
257258 import { mcpRoutes } from "./mcp.ts";
258259 import {
259260 cookieValue,
@@ -871,6 +872,7 @@ export function buildRouter(): Router<Handler> {
871872 export function buildApiRouter(): Router<Handler> {
872873 const router = new Router<Handler>();
873874 apiRoutes(router);
875 feedRoutes(router);
874876 return router;
875877 }
876878
src/looks/service.ts +6
@@ -360,6 +360,12 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
360360 // of heads before it have nothing left to say.
361361 if (allOk)
362362 remove(deleteLooks(app.db, item.appId, item.env, item.commitOid));
363 if (allOk)
364 app.bus.append({
365 kind: "look.taken",
366 repoId: record.repoId,
367 payload: { env: item.env, commitOid: item.commitOid },
368 });
363369 }
364370
365371 /** An environment is gone: so are its pictures, waiting or taken. */
src/web/studio/asks.ts +27 −4
@@ -233,16 +233,39 @@ function askCandidatesFor(
233233 export function askLinesFor(
234234 db: Database,
235235 user: User,
236 ): { id: string; line: string }[] {
236 ): {
237 id: string;
238 line: string;
239 kind: DecisionKind;
240 repoId: string;
241 /** The pull's number, for the asks that are about one. */
242 number?: number;
243 }[] {
237244 const repos = listReposVisibleTo(db, user, { limit: 200 });
238245 return askCandidatesFor(db, user, repos).map((c) => {
239246 const repo = `${c.repo.name}`;
247 const about = { kind: c.kind, repoId: c.repo.id };
240248 if (c.kind === "paused")
241 return { id: `paused:${c.repo.id}`, line: `${repo} is paused at its ceiling` };
249 return {
250 ...about,
251 id: `paused:${c.repo.id}`,
252 line: `${repo} is paused at its ceiling`,
253 };
242254 const title = c.pull?.title ?? "";
255 const number = c.pull!.number;
243256 return c.kind === "stopped"
244 ? { id: `stopped:${c.pull!.id}`, line: `${repo}: the crew stopped on "${title}"` }
245 : { id: `look:${c.pull!.id}`, line: `${repo}: "${title}" is green, waiting for your look` };
257 ? {
258 ...about,
259 number,
260 id: `stopped:${c.pull!.id}`,
261 line: `${repo}: the crew stopped on "${title}"`,
262 }
263 : {
264 ...about,
265 number,
266 id: `look:${c.pull!.id}`,
267 line: `${repo}: "${title}" is green, waiting for your look`,
268 };
246269 });
247270 }
248271
test/feed.test.ts added +332
@@ -0,0 +1,332 @@
1 /**
2 * The feed: a per-viewer read of the outbox, asks and the ledger, ranked,
3 * each item with the sentence that says why. Proven on the ranker's own
4 * terms (pure), and end to end on who may see what.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { recordInteraction } from "../src/core/interactions.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { Affinity, affinityFor } from "../src/feed/affinity.ts";
19 import type { Candidate } from "../src/feed/items.ts";
20 import { readFeed } from "../src/feed/feed.ts";
21 import { rank } from "../src/feed/rank.ts";
22
23 const NOW = Date.parse("2026-10-02T12:00:00Z");
24 const HOUR = 3600_000;
25 const DAY = 24 * HOUR;
26
27 const cand = (id: string, over: Partial<Candidate> = {}): Candidate => ({
28 id: `event:${id}`,
29 kind: "pull",
30 repoId: "r1",
31 app: "alice/one",
32 actorId: null,
33 actor: null,
34 at: new Date(NOW - HOUR).toISOString(),
35 link: "/alice/one",
36 what: "a change shipped",
37 urgency: 0,
38 ...over,
39 });
40
41 describe("the ranker", () => {
42 test("urgency beats affinity beats recency, and says so", () => {
43 const aff = new Affinity();
44 aff.add("app:liked", "owns", 3, NOW);
45 const items = rank(
46 [
47 cand("fresh", { repoId: "plain", at: new Date(NOW).toISOString() }),
48 cand("liked", { repoId: "liked", at: new Date(NOW - 20 * HOUR).toISOString() }),
49 cand("ask", {
50 repoId: "other",
51 kind: "ask",
52 urgency: 8,
53 what: "one: “x” is green, waiting for your look",
54 at: new Date(NOW - 3 * DAY).toISOString(),
55 }),
56 ],
57 aff,
58 NOW,
59 );
60 expect(items.map((i) => i.id)).toEqual(["event:ask", "event:liked", "event:fresh"]);
61 expect(items[0]!.reason).toBe("one: “x” is green, waiting for your look");
62 expect(items[1]!.reason).toBe("because you own alice/one");
63 expect(items[2]!.reason).toBe("a change shipped, just now");
64 });
65
66 test("the reason names what you shipped, and when", () => {
67 const aff = new Affinity();
68 aff.add("app:r1", "shipped", 3, NOW - DAY - HOUR, "Fix the nav");
69 const [item] = rank([cand("a")], aff, NOW);
70 expect(item!.reason).toBe("because you shipped “Fix the nav” yesterday");
71 });
72
73 test("a crew member's news says whose crew", () => {
74 const aff = new Affinity();
75 aff.add("actor:u1", "crew", 6, NOW);
76 const [item] = rank([cand("a", { actorId: "u1", actor: "scout" })], aff, NOW);
77 expect(item!.reason).toBe("because scout is on your crew");
78 });
79
80 test("one source cannot fill the feed: repeats decay", () => {
81 const same = [1, 2, 3, 4].map((n) =>
82 cand(`a${n}`, { at: new Date(NOW - n * 60_000).toISOString() }),
83 );
84 const other = cand("b", {
85 repoId: "r2",
86 app: "bob/two",
87 at: new Date(NOW - 20 * 60_000).toISOString(),
88 });
89 const ids = rank([...same, other], new Affinity(), NOW).map((i) => i.id);
90 expect(ids.indexOf("event:b")).toBeLessThan(ids.indexOf("event:a3"));
91 expect(ids[0]).toBe("event:a1");
92 });
93
94 test("placed scores never rise, so a cursor can resume", () => {
95 const items = rank(
96 [1, 2, 3, 4, 5].map((n) => cand(`x${n}`, { at: new Date(NOW - n * HOUR).toISOString() })),
97 new Affinity(),
98 NOW,
99 );
100 for (let i = 1; i < items.length; i++)
101 expect(items[i]!.score).toBeLessThanOrEqual(items[i - 1]!.score);
102 });
103
104 test("a dismissal weighs more than an opening: negatives are strong", () => {
105 const liked = new Affinity();
106 liked.add("app:r1", "opened", 1, NOW);
107 const burned = new Affinity();
108 burned.add("app:r1", "opened", 1, NOW);
109 burned.add("app:r1", "dismissed", -4, NOW);
110 expect(burned.app("r1").score).toBeLessThan(0);
111 const [a] = rank([cand("a")], liked, NOW);
112 const [b] = rank([cand("a")], burned, NOW);
113 expect(b!.score).toBeLessThan(a!.score);
114 });
115 });
116
117 describe("the feed of a forge", () => {
118 let dataDir: string;
119 let app: App;
120 let servers: Servers;
121 let alice: User;
122 let bob: User;
123 let aliceToken: string;
124 let bobToken: string;
125 let secretRepo: Repo;
126 let openRepo: Repo;
127 const emit = (repo: Repo, kind: string, actor: User, payload = {}) =>
128 app.bus.append({ kind, repoId: repo.id, actorId: actor.id, payload });
129
130 beforeAll(async () => {
131 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-"));
132 app = createApp({ dataDir, port: 0 });
133 servers = await serve(app);
134 const mk = async (username: string, first = false) => {
135 const r = await createUser(app.db, {
136 username,
137 email: `${username}@forge.local`,
138 password: "a-good-passphrase",
139 adminIfFirst: first,
140 });
141 if (!r.ok) throw new Error(r.error);
142 return r.value;
143 };
144 // The first account is a site admin, who sees everything; neither of
145 // the two people under test is.
146 await mk("root", true);
147 alice = await mk("alice");
148 bob = await mk("bob");
149 aliceToken = createToken(app.db, alice.id, "t", "rw").token;
150 bobToken = createToken(app.db, bob.id, "t", "rw").token;
151 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
152 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
153 if (!s.ok || !o.ok) throw new Error("repos");
154 secretRepo = s.value;
155 openRepo = o.value;
156 emit(secretRepo, "run.finished", alice, { kind: "deploy", state: "failed", summary: "boom", runId: "r" });
157 emit(openRepo, "run.finished", alice, { kind: "deploy", state: "ok", summary: "up", runId: "r2" });
158 emit(openRepo, "webhook.created", alice, { url: "https://hooks.example.test/s3cret" });
159 emit(openRepo, "issue.blocked", alice, { number: 2, blockedBy: 1 });
160 });
161
162 afterAll(async () => {
163 servers.stop();
164 app.close();
165 await rm(dataDir, { recursive: true, force: true });
166 });
167
168 const api = (token: string, path: string, init: RequestInit = {}) =>
169 fetch(`${servers.url}${path}`, {
170 ...init,
171 headers: { authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
172 });
173
174 test("no leak across viewers: a private repo and configuration stay out", () => {
175 const mine = readFeed(app.db, alice).items;
176 expect(mine.some((i) => i.app === "alice/secret")).toBe(true);
177 const theirs = readFeed(app.db, bob).items;
178 expect(theirs.length).toBeGreaterThan(0);
179 expect(theirs.every((i) => i.app === "alice/open")).toBe(true);
180 expect(JSON.stringify(theirs)).not.toContain("s3cret");
181 expect(theirs.map((i) => i.kind).sort()).toEqual(["map", "run"]);
182 });
183
184 test("items are plain data with a reason and a link", () => {
185 for (const i of readFeed(app.db, alice).items) {
186 expect(i.id).toMatch(/^[a-z]+:/);
187 expect(i.link.startsWith("/alice/")).toBe(true);
188 expect(i.reason.length).toBeGreaterThan(5);
189 expect(Date.parse(i.at)).not.toBeNaN();
190 }
191 });
192
193 test("GET /api/v1/feed needs a viewer, validates, and pages by cursor", async () => {
194 expect((await fetch(`${servers.url}/api/v1/feed`)).status).toBe(401);
195 expect((await api(aliceToken, "/api/v1/feed?limit=0")).status).toBe(422);
196 expect((await api(aliceToken, "/api/v1/feed?cursor=x")).status).toBe(422);
197 const first = (await (await api(aliceToken, "/api/v1/feed?limit=1")).json()) as {
198 items: { id: string }[];
199 next_cursor: string | null;
200 };
201 expect(first.items).toHaveLength(1);
202 expect(first.next_cursor).not.toBeNull();
203 const seen = [first.items[0]!.id];
204 let cursor = first.next_cursor;
205 while (cursor) {
206 const page = (await (
207 await api(aliceToken, `/api/v1/feed?limit=1&cursor=${encodeURIComponent(cursor)}`)
208 ).json()) as typeof first;
209 seen.push(...page.items.map((i) => i.id));
210 cursor = page.next_cursor;
211 }
212 expect(new Set(seen).size).toBe(seen.length);
213 expect(seen).toEqual(readFeed(app.db, alice).items.map((i) => i.id));
214 });
215
216 test("an interaction is validated, and cannot name what the viewer cannot see", async () => {
217 const post = (token: string, data: unknown, headers = {}) =>
218 api(token, "/api/v1/feed/interactions", {
219 method: "POST",
220 headers: { "content-type": "application/json", ...headers },
221 body: JSON.stringify(data),
222 });
223 const secretEvent = readFeed(app.db, alice).items.find((i) => i.app === "alice/secret")!;
224 expect((await post(bobToken, { item: secretEvent.id, kind: "opened" })).status).toBe(404);
225 expect((await post(aliceToken, { item: secretEvent.id, kind: "bogus" })).status).toBe(422);
226 expect((await post(aliceToken, { item: "../x", kind: "opened" })).status).toBe(422);
227 expect((await post(aliceToken, { item: secretEvent.id, kind: "opened" })).status).toBe(201);
228 // Configuration events are the admins' even where the repo is public.
229 const hook = app.db
230 .query<{ id: string }, []>("SELECT id FROM event WHERE kind = 'webhook.created'")
231 .get()!;
232 expect((await post(bobToken, { item: `event:${hook.id}`, kind: "opened" })).status).toBe(404);
233 // An item that is not an event names its app, which must be readable.
234 expect((await post(bobToken, { item: "ask:look:x", kind: "opened", app: "alice/secret" })).status).toBe(404);
235 expect((await post(bobToken, { item: "ask:look:x", kind: "opened" })).status).toBe(404);
236 });
237
238 test("a browser session's cross-origin interaction is refused", async () => {
239 const session = createSession(app.db, alice.id);
240 const res = await fetch(`${servers.url}/api/v1/feed/interactions`, {
241 method: "POST",
242 headers: {
243 origin: "http://evil.example",
244 cookie: `forge_session=${session.token}`,
245 "content-type": "application/json",
246 },
247 body: JSON.stringify({ item: "ask:look:x", kind: "muted", app: "alice/open" }),
248 });
249 expect(res.status).toBe(403);
250 });
251
252 test("mute silences an app; dismissal hides the one item", () => {
253 const carol = bob;
254 const before = readFeed(app.db, carol).items;
255 const run = before.find((i) => i.kind === "run")!;
256 recordInteraction(app.db, {
257 viewerId: carol.id,
258 itemId: run.id,
259 kind: "dismissed",
260 repoId: openRepo.id,
261 actorId: alice.id,
262 });
263 const after = readFeed(app.db, carol).items;
264 expect(after.some((i) => i.id === run.id)).toBe(false);
265 expect(after.some((i) => i.kind === "map")).toBe(true);
266 recordInteraction(app.db, {
267 viewerId: carol.id,
268 itemId: "event:whatever",
269 kind: "muted",
270 repoId: openRepo.id,
271 actorId: null,
272 });
273 expect(readFeed(app.db, carol).items).toEqual([]);
274 // Muting is the viewer's own: alice still hears from the app.
275 expect(readFeed(app.db, alice).items.some((i) => i.app === "alice/open")).toBe(true);
276 });
277 });
278
279 describe("affinity", () => {
280 let dataDir: string;
281 let app: App;
282 let user: User;
283 let repo: Repo;
284 beforeAll(async () => {
285 dataDir = await mkdtemp(join(tmpdir(), "forge-affinity-"));
286 app = createApp({ dataDir, port: 0 });
287 const u = await createUser(app.db, {
288 username: "dana",
289 email: "[email protected]",
290 password: "a-good-passphrase",
291 });
292 if (!u.ok) throw new Error(u.error);
293 user = u.value;
294 const r = await createRepo(app, user, { name: "mine", visibility: "public" });
295 if (!r.ok) throw new Error("repo");
296 repo = r.value;
297 });
298 afterAll(async () => {
299 app.close();
300 await rm(dataDir, { recursive: true, force: true });
301 });
302
303 const at = (ms: number) => new Date(NOW - ms).toISOString();
304 const act = (kind: "opened" | "dismissed", ms: number) =>
305 recordInteraction(app.db, {
306 viewerId: user.id,
307 itemId: "event:x",
308 kind,
309 repoId: repo.id,
310 actorId: null,
311 at: at(ms),
312 });
313
314 test("seeded from ownership, then moved by what was done, with decay", () => {
315 const seed = affinityFor(app.db, user, NOW).app(repo.id);
316 expect(seed.score).toBe(2);
317 expect(seed.why).toBe("owns");
318 act("opened", 0);
319 const fresh = affinityFor(app.db, user, NOW).app(repo.id).score;
320 expect(fresh).toBeCloseTo(3, 5);
321 // Two weeks later the opening has lost three quarters of its weight.
322 const later = affinityFor(app.db, user, NOW + 14 * DAY).app(repo.id).score;
323 expect(later).toBeCloseTo(2.25, 5);
324 });
325
326 test("a dismissal outweighs the opening and leads the reason", () => {
327 act("dismissed", 0);
328 const w = affinityFor(app.db, user, NOW).app(repo.id);
329 expect(w.score).toBeCloseTo(2 + 1 - 4, 5);
330 expect(w.why).toBe("dismissed");
331 });
332 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs