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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/360-feed-model-and-ranker-a-per-viewer-read → main 1 commit fork
16 files
changed +1304
−5
CLAUDE.md +3
| @@ -136,6 +136,9 @@ src/ | ||
| 136 | 136 | page says it in), asks, leans, previews, shelf (an app as one |
| 137 | 137 | row, for any page that lists apps), home, app, change, tell, |
| 138 | 138 | 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) | |
| 139 | 142 | ssh/ the SSH server: transport (kex/ciphers), auth (publickey), |
| 140 | 143 | channels (session only), keys, wire — zero deps, no forwarding |
| 141 | 144 | 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 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "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", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/core/decisions.ts +18
| @@ -282,3 +282,21 @@ export function decisionStats( | ||
| 282 | 282 | ...k, |
| 283 | 283 | })); |
| 284 | 284 | } |
| 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 => ({ | ||
| 140 | 140 | createdAt: r.created_at, |
| 141 | 141 | }); |
| 142 | 142 | |
| 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 | ||
| 143 | 151 | export function listEvents( |
| 144 | 152 | db: Database, |
| 145 | 153 | 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[] = [ | ||
| 937 | 937 | PRIMARY KEY (name, scope, scope_id) |
| 938 | 938 | ); |
| 939 | 939 | `, |
| 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 | `, | |
| 940 | 959 | ]; |
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"; | ||
| 254 | 254 | import { notFoundPage } from "../web/chrome.ts"; |
| 255 | 255 | import { targetForShortCode } from "../origins/oauth.ts"; |
| 256 | 256 | import { apiRoutes } from "./api.ts"; |
| 257 | import { feedRoutes } from "./feed.ts"; | |
| 257 | 258 | import { mcpRoutes } from "./mcp.ts"; |
| 258 | 259 | import { |
| 259 | 260 | cookieValue, |
| @@ -871,6 +872,7 @@ export function buildRouter(): Router<Handler> { | ||
| 871 | 872 | export function buildApiRouter(): Router<Handler> { |
| 872 | 873 | const router = new Router<Handler>(); |
| 873 | 874 | apiRoutes(router); |
| 875 | feedRoutes(router); | |
| 874 | 876 | return router; |
| 875 | 877 | } |
| 876 | 878 |
src/looks/service.ts +6
| @@ -360,6 +360,12 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 360 | 360 | // of heads before it have nothing left to say. |
| 361 | 361 | if (allOk) |
| 362 | 362 | 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 | }); | |
| 363 | 369 | } |
| 364 | 370 | |
| 365 | 371 | /** An environment is gone: so are its pictures, waiting or taken. */ |
src/web/studio/asks.ts +27 −4
| @@ -233,16 +233,39 @@ function askCandidatesFor( | ||
| 233 | 233 | export function askLinesFor( |
| 234 | 234 | db: Database, |
| 235 | 235 | 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 | }[] { | |
| 237 | 244 | const repos = listReposVisibleTo(db, user, { limit: 200 }); |
| 238 | 245 | return askCandidatesFor(db, user, repos).map((c) => { |
| 239 | 246 | const repo = `${c.repo.name}`; |
| 247 | const about = { kind: c.kind, repoId: c.repo.id }; | |
| 240 | 248 | 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 | }; | |
| 242 | 254 | const title = c.pull?.title ?? ""; |
| 255 | const number = c.pull!.number; | |
| 243 | 256 | 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 | }; | |
| 246 | 269 | }); |
| 247 | 270 | } |
| 248 | 271 |
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 | }); |
new above
offline