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

One share verb: share(item, to?) routes to who needs to know, with budgets against noise #370

Merged opened this

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

Merged

scout/forge:scout/362-one-share-verb-share-item-to-routes-to-w → main 3 commits fork

  • Merged. scout/362-one-share-verb-share-item-to-routes-to-w was merged into main as 02d3306.

Review

Sign in to review this change.
11 files changed +863 −5
docs/FEED.md +28
@@ -75,3 +75,31 @@ a card, and paging past the first 20.
7575 - Not done: steering a run offline (a plain form post, needs the network),
7676 updating the kept `/` from live swaps (it is the page as last navigated),
7777 swipes (no swipe UI yet), push, and anything native.
78
79 ## Sharing (`src/feed/share.ts`, `core/shares.ts`)
80
81 `POST /api/v1/share` `{ item, to? }` (`rw`, same-origin for sessions) and the
82 MCP tool `share` are one verb. `to` is up to 10 usernames. The sender must
83 see the item (it is looked up in the sender's own feed sources); the answer
84 is `{ sent: [{ to, outcome }] }`, outcome one of `delivered queued steered
85 told duplicate skipped unreachable`. A name that does not exist and one who
86 may not see the item are both `unreachable`.
87
88 - **No target:** the app's crew and owners (an org's writers and admins);
89 only if none of those is someone else, the people who pinned the app
90 (inside the org when an org owns it). A named target is always added.
91 - **No widening:** a recipient must `canRead` the repository and have the
92 very item among their own candidates, or they are unreachable.
93 - **An agent** is steered (a comment on the change, when the item is one) or
94 told (an issue routed to a builder) through the existing actions, as the
95 sender, so the sender's own rights apply.
96 - **A person** gets a `share:` feed item with the reason "shared by X: …",
97 boosted by `3 × sender weight`; it replaces the plain item. Their mute of
98 the app makes the share `skipped`; dismissing the item hides the share.
99 - **Budget:** 5 delivered a day per person. More are queued, delivered
100 best-weighted first as room frees (checked on each feed read), and dropped
101 after 48 hours; a waiting share's worth falls linearly with its age.
102 - **Dedupe:** one share per item per recipient, ever.
103 - **Sender weight:** `2·(opens+1)/(shares+2)` over 90 days of delivered
104 shares to people, clamped 0.2–2; an open is the recipient's `opened` of the
105 item or its share. A sender nobody opens sinks, a trusted one rises.
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 test/feed.test.ts test/feed-page.test.ts test/offline.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 test/feed-page.test.ts test/offline.test.ts test/share.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/core/schema.ts +24
@@ -956,4 +956,28 @@ export const migrations: readonly string[] = [
956956 );
957957 CREATE INDEX interaction_viewer ON interaction(viewer_id, id);
958958 `,
959
960 // 36 — a share: one item, one sender, one recipient. A share is delivered
961 // (it shows in the recipient's feed, boosted), queued (the recipient's
962 // daily budget is spent; it waits and decays) or dropped (it decayed).
963 // One row per item and recipient, ever: that is the dedupe. What the
964 // item was is kept as words, so the row outlives the event it named.
965 `
966 CREATE TABLE share (
967 id TEXT PRIMARY KEY,
968 item_id TEXT NOT NULL,
969 sender_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
970 recipient_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
971 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
972 kind TEXT NOT NULL,
973 link TEXT NOT NULL,
974 what TEXT NOT NULL,
975 state TEXT NOT NULL CHECK (state IN ('delivered','queued','dropped')),
976 created_at TEXT NOT NULL,
977 delivered_at TEXT,
978 UNIQUE (item_id, recipient_id)
979 );
980 CREATE INDEX share_recipient ON share(recipient_id, state);
981 CREATE INDEX share_sender ON share(sender_id, state);
982 `,
959983 ];
src/core/shares.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Shares as data: one row per item and recipient, ever (the dedupe). A
3 * share is delivered, queued behind the recipient's daily budget, or
4 * dropped once it has waited too long. Pure data: the verb that makes one
5 * (`feed/share.ts`) owes the access check.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { id } from "../kernel/id.ts";
10
11 export const DAILY_BUDGET = 5;
12 export const QUEUE_TTL_MS = 48 * 3600 * 1000;
13 const DAY_MS = 24 * 3600 * 1000;
14 const WEIGHT_WINDOW_MS = 90 * DAY_MS;
15 export const WEIGHT_MIN = 0.2;
16 export const WEIGHT_MAX = 2;
17
18 export type ShareState = "delivered" | "queued" | "dropped";
19
20 export interface Share {
21 readonly id: string;
22 readonly itemId: string;
23 readonly senderId: string;
24 readonly recipientId: string;
25 readonly repoId: string;
26 readonly kind: string;
27 readonly link: string;
28 readonly what: string;
29 readonly state: ShareState;
30 readonly createdAt: string;
31 readonly deliveredAt: string | null;
32 }
33
34 interface Row {
35 id: string;
36 item_id: string;
37 sender_id: string;
38 recipient_id: string;
39 repo_id: string;
40 kind: string;
41 link: string;
42 what: string;
43 state: ShareState;
44 created_at: string;
45 delivered_at: string | null;
46 }
47
48 const toShare = (r: Row): Share => ({
49 id: r.id,
50 itemId: r.item_id,
51 senderId: r.sender_id,
52 recipientId: r.recipient_id,
53 repoId: r.repo_id,
54 kind: r.kind,
55 link: r.link,
56 what: r.what,
57 state: r.state,
58 createdAt: r.created_at,
59 deliveredAt: r.delivered_at,
60 });
61
62 const iso = (ms: number): string => new Date(ms).toISOString();
63
64 /** Whether this item was ever shared with this recipient, in any state. */
65 export function wasShared(
66 db: Database,
67 itemId: string,
68 recipientId: string,
69 ): boolean {
70 return (
71 db
72 .query("SELECT 1 FROM share WHERE item_id = ?1 AND recipient_id = ?2")
73 .get(itemId, recipientId) !== null
74 );
75 }
76
77 /** Shares delivered to this recipient in the last day. */
78 export function deliveredToday(
79 db: Database,
80 recipientId: string,
81 now: number,
82 ): number {
83 return (
84 db
85 .query<{ n: number }, [string, string]>(
86 "SELECT COUNT(*) AS n FROM share WHERE recipient_id = ?1 AND state = 'delivered' AND delivered_at > ?2",
87 )
88 .get(recipientId, iso(now - DAY_MS))?.n ?? 0
89 );
90 }
91
92 /**
93 * A sender's weight from their past shares: the share-to-open rate, with a
94 * prior of one open in two, so a newcomer is 1 and a sender nobody opens
95 * sinks toward `WEIGHT_MIN` while one who is always opened rises to `WEIGHT_MAX`.
96 */
97 export function senderWeight(
98 db: Database,
99 senderId: string,
100 now: number,
101 ): number {
102 const row = db
103 .query<{ n: number; opened: number }, [string, string]>(
104 `SELECT COUNT(*) AS n, COALESCE(SUM(EXISTS (
105 SELECT 1 FROM interaction i
106 WHERE i.viewer_id = share.recipient_id AND i.kind = 'opened'
107 AND i.item_id IN (share.item_id, 'share:' || share.id))), 0) AS opened
108 FROM share
109 WHERE sender_id = ?1 AND state = 'delivered' AND delivered_at > ?2
110 AND recipient_id NOT IN (SELECT user_id FROM agent)`,
111 )
112 .get(senderId, iso(now - WEIGHT_WINDOW_MS));
113 const n = row?.n ?? 0;
114 const opened = row?.opened ?? 0;
115 const w = (2 * (opened + 1)) / (n + 2);
116 return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, w));
117 }
118
119 export function addShare(
120 db: Database,
121 input: Omit<Share, "id" | "state" | "createdAt" | "deliveredAt"> & {
122 state: "delivered" | "queued";
123 at: number;
124 },
125 ): Share {
126 const row: Row = {
127 id: id(),
128 item_id: input.itemId,
129 sender_id: input.senderId,
130 recipient_id: input.recipientId,
131 repo_id: input.repoId,
132 kind: input.kind,
133 link: input.link,
134 what: input.what,
135 state: input.state,
136 created_at: iso(input.at),
137 delivered_at: input.state === "delivered" ? iso(input.at) : null,
138 };
139 db.query(
140 `INSERT INTO share (id, item_id, sender_id, recipient_id, repo_id, kind, link, what, state, created_at, delivered_at)
141 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)`,
142 ).run(
143 row.id,
144 row.item_id,
145 row.sender_id,
146 row.recipient_id,
147 row.repo_id,
148 row.kind,
149 row.link,
150 row.what,
151 row.state,
152 row.created_at,
153 row.delivered_at,
154 );
155 return toShare(row);
156 }
157
158 /** Delivered shares for a recipient, newest first, within a week. */
159 export function deliveredFor(
160 db: Database,
161 recipientId: string,
162 now: number,
163 ): Share[] {
164 return db
165 .query<Row, [string, string]>(
166 `SELECT * FROM share WHERE recipient_id = ?1 AND state = 'delivered'
167 AND delivered_at > ?2 ORDER BY delivered_at DESC, id DESC LIMIT 50`,
168 )
169 .all(recipientId, iso(now - 7 * DAY_MS))
170 .map(toShare);
171 }
172
173 /**
174 * Let the queue move: what waited past its time is dropped, and while the
175 * day's budget has room the best-weighted waiting share is delivered, the
176 * older first among equals. Returns how many were delivered.
177 */
178 export function drainQueue(
179 db: Database,
180 recipientId: string,
181 now: number,
182 ): number {
183 db.query(
184 "UPDATE share SET state = 'dropped' WHERE recipient_id = ?1 AND state = 'queued' AND created_at < ?2",
185 ).run(recipientId, iso(now - QUEUE_TTL_MS));
186 const waiting = db
187 .query<Row, [string]>(
188 "SELECT * FROM share WHERE recipient_id = ?1 AND state = 'queued' ORDER BY created_at, id",
189 )
190 .all(recipientId);
191 if (waiting.length === 0) return 0;
192 let room = DAILY_BUDGET - deliveredToday(db, recipientId, now);
193 const weights = new Map<string, number>();
194 const w = (s: string): number => {
195 if (!weights.has(s)) weights.set(s, senderWeight(db, s, now));
196 return weights.get(s)!;
197 };
198 // A waiting share has decayed by its age: weight × (1 − age / ttl).
199 const worth = (r: Row): number =>
200 w(r.sender_id) * (1 - (now - Date.parse(r.created_at)) / QUEUE_TTL_MS);
201 waiting.sort((a, b) => worth(b) - worth(a));
202 let moved = 0;
203 for (const r of waiting) {
204 if (room <= 0) break;
205 db.query(
206 "UPDATE share SET state = 'delivered', delivered_at = ?2 WHERE id = ?1",
207 ).run(r.id, iso(now));
208 room--;
209 moved++;
210 }
211 return moved;
212 }
src/feed/feed.ts +48 −3
@@ -8,9 +8,11 @@
88
99 import type { Database } from "bun:sqlite";
1010 import { dismissedItems, mutedRepos } from "../core/interactions.ts";
11 import type { User } from "../core/users.ts";
1211 import { affinityFor } from "./affinity.ts";
13 import { candidatesFor } from "./items.ts";
12 import { canRead, getRepoById } from "../core/repos.ts";
13 import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts";
14 import { getUserById, type User } from "../core/users.ts";
15 import { candidatesFor, type Candidate } from "./items.ts";
1416 import { rank, type FeedItem } from "./rank.ts";
1517
1618 export const FEED_LIMIT_DEFAULT = 20;
@@ -37,7 +39,15 @@ export function readFeed(
3739 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
3840 const muted = mutedRepos(db, viewer.id);
3941 const dismissed = dismissedItems(db, viewer.id);
40 const candidates = candidatesFor(db, viewer, now).filter(
42 drainQueue(db, viewer.id, now);
43 const shared = sharedCandidates(db, viewer, now).filter(
44 (c) => !dismissed.has(c.itemId),
45 );
46 const boosted = new Set(shared.map((c) => c.itemId));
47 const candidates = [
48 ...candidatesFor(db, viewer, now).filter((c) => !boosted.has(c.id)),
49 ...shared,
50 ].filter(
4151 (c) => !dismissed.has(c.id) && (c.kind === "ask" || !muted.has(c.repoId)),
4252 );
4353 let ranked = rank(candidates, affinityFor(db, viewer, now), now);
@@ -57,3 +67,38 @@ export function readFeed(
5767 ranked.length > limit && last !== undefined ? cursorOf(last) : null,
5868 };
5969 }
70
71 export const SHARE_BOOST = 3;
72
73 /**
74 * What others shared with the viewer, as candidates that carry the item's
75 * own words, boosted by the sender's weight. A repository the viewer can
76 * no longer read says nothing. The shared item replaces the plain one.
77 */
78 function sharedCandidates(
79 db: Database,
80 viewer: User,
81 now: number,
82 ): (Candidate & { itemId: string })[] {
83 const out: (Candidate & { itemId: string })[] = [];
84 for (const s of deliveredFor(db, viewer.id, now)) {
85 const repo = getRepoById(db, s.repoId);
86 const sender = getUserById(db, s.senderId);
87 if (!repo || !sender || !canRead(db, repo, viewer)) continue;
88 out.push({
89 id: `share:${s.id}`,
90 itemId: s.itemId,
91 kind: s.kind as Candidate["kind"],
92 repoId: repo.id,
93 app: `${repo.ownerName}/${repo.name}`,
94 actorId: sender.id,
95 actor: sender.username,
96 at: s.deliveredAt ?? s.createdAt,
97 link: s.link,
98 what: s.what,
99 urgency: SHARE_BOOST * senderWeight(db, sender.id, now),
100 said: `shared by ${sender.username}: ${s.what}`,
101 });
102 }
103 return out;
104 }
src/feed/items.ts +2
@@ -38,6 +38,8 @@ export interface Candidate {
3838 readonly what: string;
3939 /** 0 for news; an ask or a failure is more. */
4040 readonly urgency: number;
41 /** When set, the sentence as it is: a share says who shared it. */
42 readonly said?: string;
4143 }
4244
4345 export const EVENT_WINDOW = 300;
src/feed/rank.ts +3 −1
@@ -92,7 +92,9 @@ export function rank(
9292 const urgency = c.urgency;
9393 const base = urgency + affinity + recency;
9494 const reason =
95 urgency >= Math.max(affinity, recency)
95 c.said !== undefined
96 ? c.said
97 : urgency >= Math.max(affinity, recency)
9698 ? c.what
9799 : affinity >= recency
98100 ? affinityReason(c, app, actor, now)
src/feed/share.ts added +226
@@ -0,0 +1,226 @@
1 /**
2 * The one share verb: `share(item, to?)`. It says who needs to know and
3 * tells them the way that fits — an agent is steered or told through the
4 * routes that already exist, a person gets the item boosted in their feed
5 * with a "shared by" reason. Three rules keep it from being noise:
6 * a share never widens access (the recipient must read the repository and
7 * be able to see that very item, by the feed's own sources); an item goes
8 * to a recipient once; and a person's day has a budget, past which shares
9 * wait in a queue and decay (`core/shares.ts`).
10 *
11 * With no target the route is the app's crew and owners, and only when
12 * there is none of those, the people who pinned the app (inside the org,
13 * when an org owns it). A named target is always added.
14 */
15
16 import type { App } from "../app.ts";
17 import { addCommentThrough } from "../actions/issues.ts";
18 import { tell } from "../actions/tell.ts";
19 import { listCrew } from "../core/agents.ts";
20 import { getIssue } from "../core/issues.ts";
21 import { mutedRepos, recordInteraction } from "../core/interactions.ts";
22 import { isOrgMember } from "../core/orgs.ts";
23 import { canRead, getRepoById } from "../core/repos.ts";
24 import {
25 DAILY_BUDGET,
26 addShare,
27 deliveredToday,
28 drainQueue,
29 wasShared,
30 } from "../core/shares.ts";
31 import {
32 getUserById,
33 getUserByName,
34 type User,
35 } from "../core/users.ts";
36 import { candidatesFor, type Candidate } from "./items.ts";
37
38 export const RECIPIENTS_MAX = 10;
39
40 export type Outcome =
41 | "delivered"
42 | "queued"
43 | "steered"
44 | "told"
45 | "duplicate"
46 | "skipped"
47 | "unreachable";
48
49 export interface Sent {
50 readonly to: string;
51 readonly outcome: Outcome;
52 }
53
54 export type ShareError = "not_found" | "invalid_target";
55
56 /** Owners who need to know: the person, or an org's writers and admins. */
57 function ownersOf(app: App, ownerId: string): User[] {
58 const owner = getUserById(app.db, ownerId);
59 if (owner === undefined) return [];
60 if (owner.kind !== "org") return [owner];
61 return app.db
62 .query<{ user_id: string }, [string]>(
63 `SELECT DISTINCT team_member.user_id FROM team_member
64 JOIN team ON team.id = team_member.team_id
65 WHERE team.org_id = ?1 AND team.permission IN ('write','admin')`,
66 )
67 .all(ownerId)
68 .map((r) => getUserById(app.db, r.user_id))
69 .filter((u): u is User => u !== undefined);
70 }
71
72 function followersOf(app: App, repoId: string, ownerId: string): User[] {
73 const org = getUserById(app.db, ownerId)?.kind === "org";
74 return app.db
75 .query<{ user_id: string }, [string]>(
76 "SELECT user_id FROM pin WHERE repo_id = ?1",
77 )
78 .all(repoId)
79 .filter((r) => !org || isOrgMember(app.db, ownerId, r.user_id))
80 .map((r) => getUserById(app.db, r.user_id))
81 .filter((u): u is User => u !== undefined);
82 }
83
84 const words = (c: Candidate): string => c.what.replace(/\s+/g, " ").slice(0, 200);
85
86 export async function share(
87 app: App,
88 sender: User,
89 itemId: string,
90 to: readonly string[] = [],
91 now = Date.now(),
92 ): Promise<
93 | { ok: true; value: { sent: Sent[]; item: Candidate } }
94 | { ok: false; error: ShareError }
95 > {
96 const db = app.db;
97 // The sender must see the item, by the same sources the feed reads.
98 const item = candidatesFor(db, sender, now).find((c) => c.id === itemId);
99 const repo = item && getRepoById(db, item.repoId);
100 if (!item || !repo || !canRead(db, repo, sender))
101 return { ok: false, error: "not_found" };
102
103 const named: (User | string)[] = [];
104 for (const name of to) {
105 const u = getUserByName(db, name);
106 named.push(u && u.kind !== "org" ? u : name);
107 }
108 let routed: User[] = [];
109 if (named.length === 0) {
110 const first = [
111 ...listCrew(db, repo.id).map((a) => a.user),
112 ...ownersOf(app, repo.ownerId),
113 ];
114 routed = first.some((u) => u.id !== sender.id)
115 ? first
116 : followersOf(app, repo.id, repo.ownerId);
117 }
118 const wanted: (User | string)[] = [...named, ...routed];
119
120 const seen = new Set<string>([sender.id]);
121 const sent: Sent[] = [];
122 const muted = new Map<string, Set<string>>();
123 for (const w of wanted) {
124 if (sent.length >= RECIPIENTS_MAX) break;
125 if (typeof w === "string") {
126 // No such person and not yours to know about look the same.
127 sent.push({ to: w.slice(0, 40), outcome: "unreachable" });
128 continue;
129 }
130 if (seen.has(w.id)) continue;
131 seen.add(w.id);
132 const mine = (outcome: Outcome): void => {
133 sent.push({ to: w.username, outcome });
134 };
135 // No widening: the recipient reads the repository and sees this item.
136 if (
137 !canRead(db, repo, w) ||
138 !candidatesFor(db, w, now).some((c) => c.id === item.id)
139 ) {
140 mine("unreachable");
141 continue;
142 }
143 if (wasShared(db, item.id, w.id)) {
144 mine("duplicate");
145 continue;
146 }
147 if (w.kind === "agent") {
148 mine(await steer(app, sender, w, repo, item));
149 continue;
150 }
151 if (!muted.has(w.id)) muted.set(w.id, mutedRepos(db, w.id));
152 if (muted.get(w.id)!.has(repo.id)) {
153 mine("skipped");
154 continue;
155 }
156 drainQueue(db, w.id, now);
157 const room = deliveredToday(db, w.id, now) < DAILY_BUDGET;
158 addShare(db, {
159 itemId: item.id,
160 senderId: sender.id,
161 recipientId: w.id,
162 repoId: repo.id,
163 kind: item.kind,
164 link: item.link,
165 what: words(item),
166 state: room ? "delivered" : "queued",
167 at: now,
168 });
169 mine(room ? "delivered" : "queued");
170 }
171 if (sent.some((s) => s.outcome !== "unreachable" && s.outcome !== "duplicate"))
172 recordInteraction(db, {
173 viewerId: sender.id,
174 itemId: item.id,
175 kind: "shared",
176 repoId: repo.id,
177 actorId: item.actorId,
178 at: new Date(now).toISOString(),
179 });
180 return { ok: true, value: { sent, item } };
181 }
182
183 /**
184 * An agent hears it the way it hears anything: said on the change when the
185 * item is one (a builder at work takes it as its next step), told to the
186 * crew otherwise. A sender who may not speak there is turned away.
187 */
188 async function steer(
189 app: App,
190 sender: User,
191 agent: User,
192 repo: NonNullable<ReturnType<typeof getRepoById>>,
193 item: Candidate,
194 ): Promise<Outcome> {
195 const text = `Shared by ${sender.username} with ${agent.username}: ${words(item)} (${item.link})`;
196 const number = /\/changes\/(\d+)$/.exec(item.link)?.[1];
197 const issue =
198 number === undefined ? undefined : getIssue(app.db, repo.id, Number(number));
199 if (issue !== undefined) {
200 const said = await addCommentThrough(app, sender, repo, issue, text);
201 if (said.ok) {
202 markSent(app, item, agent, sender);
203 return "steered";
204 }
205 return "unreachable";
206 }
207 const told = await tell(app, sender, repo, text);
208 if (!told.ok) return "unreachable";
209 markSent(app, item, agent, sender);
210 return "told";
211 }
212
213 /** An agent has no feed; the row only keeps it from being steered twice. */
214 function markSent(app: App, item: Candidate, agent: User, sender: User): void {
215 addShare(app.db, {
216 itemId: item.id,
217 senderId: sender.id,
218 recipientId: agent.id,
219 repoId: item.repoId,
220 kind: item.kind,
221 link: item.link,
222 what: words(item),
223 state: "delivered",
224 at: Date.now(),
225 });
226 }
src/http/feed.ts +35
@@ -11,6 +11,7 @@ import {
1111 isInteractionKind,
1212 recordInteraction,
1313 } from "../core/interactions.ts";
14 import { share, RECIPIENTS_MAX } from "../feed/share.ts";
1415 import { canRead, getRepo, getRepoById } from "../core/repos.ts";
1516 import {
1617 FEED_LIMIT_MAX,
@@ -119,4 +120,38 @@ export function feedRoutes(router: Router<Handler>): void {
119120 return apiError("refused", told.error === "unauthorized" ? 403 : 422, "that could not be told");
120121 return json({ issue: told.value.issue.number }, 201);
121122 });
123
124 router.add("POST", "/api/v1/share", async (ctx) => {
125 const denied = requireScope(ctx, "rw");
126 if (denied) return denied;
127 const input = await body(ctx.request);
128 const itemId = str(input.item);
129 if (!ITEM_ID.test(itemId))
130 return apiError("invalid_item", 422, "item is a feed item id");
131 const to = parseTargets(input.to);
132 if (to === undefined)
133 return apiError(
134 "invalid_target",
135 422,
136 `to is up to ${RECIPIENTS_MAX} usernames`,
137 );
138 const made = await share(ctx.app, ctx.auth.user!, itemId, to);
139 if (!made.ok) return notFound();
140 return json({ sent: made.value.sent }, 201);
141 });
142 }
143
144 /** `to` as a list of usernames: absent, one, or a few; anything else is no. */
145 export function parseTargets(raw: unknown): string[] | undefined {
146 if (raw === undefined || raw === null) return [];
147 const list = Array.isArray(raw) ? raw : [raw];
148 if (list.length > RECIPIENTS_MAX) return undefined;
149 const out: string[] = [];
150 for (const v of list) {
151 if (typeof v !== "string") return undefined;
152 const name = v.trim().replace(/^@/, "");
153 if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,38}$/.test(name)) return undefined;
154 out.push(name);
155 }
156 return out;
122157 }
src/http/mcp.ts +31
@@ -27,6 +27,9 @@ import {
2727 import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
2828 import { cancelRepoRun } from "../actions/runs.ts";
2929 import { tell, toldWords } from "../actions/tell.ts";
30 import { ITEM_ID } from "../core/interactions.ts";
31 import { share } from "../feed/share.ts";
32 import { parseTargets } from "./feed.ts";
3033 import { appsRuntime } from "../apps/runtime.ts";
3134 import { getIssue, getIssueById } from "../core/issues.ts";
3235 import { repoGraph } from "../core/dependencies.ts";
@@ -174,6 +177,34 @@ const TOOLS: readonly Tool[] = [
174177 });
175178 },
176179 },
180 {
181 name: "share",
182 description:
183 "Share a feed item with whoever needs to know. With no `to` it goes to the app's crew and owners, else to those who follow the app. Nobody is shown what they could not already see; an agent is told or steered, a person gets it boosted in their feed, and each person has a daily budget.",
184 inputSchema: {
185 type: "object",
186 properties: {
187 item: { type: "string", description: "A feed item id, like event:01J…" },
188 to: {
189 type: "array",
190 items: { type: "string" },
191 maxItems: 10,
192 description: "Usernames, people or agents.",
193 },
194 },
195 required: ["item"],
196 },
197 writes: true,
198 async run(ctx, user, args) {
199 const item = str(args["item"]);
200 const to = parseTargets(args["to"]);
201 if (!ITEM_ID.test(item) || to === undefined)
202 return bad("invalid", "item is a feed item id; to is up to 10 usernames");
203 const made = await share(ctx.app, user, item, to);
204 if (!made.ok) return bad("not_found", "no such item");
205 return good({ sent: made.value.sent });
206 },
207 },
177208 {
178209 name: "say",
179210 description:
test/share.test.ts added +253
@@ -0,0 +1,253 @@
1 /**
2 * share(item, to?): who needs to know, without widening access or adding
3 * noise. Proven end to end on a real app: no widening, the daily budget and
4 * its queue, dedupe, agent routing, sender weight, and the routes.
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { listComments } from "../src/core/issues.ts";
15 import { recordInteraction } from "../src/core/interactions.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { pin } from "../src/core/pins.ts";
18 import { DAILY_BUDGET, QUEUE_TTL_MS, senderWeight } from "../src/core/shares.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { readFeed } from "../src/feed/feed.ts";
22 import { share } from "../src/feed/share.ts";
23 import { parseTargets } from "../src/http/feed.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25
26 const HOUR = 3600_000;
27
28 describe("share", () => {
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let bob: User;
34 let carol: User;
35 let bot: User;
36 let aliceToken: string;
37 let open: Repo;
38 let secret: Repo;
39 let n = 0;
40 const emit = (repo: Repo, kind = "run.finished", payload: object = {}) =>
41 `event:${app.bus.append({
42 kind,
43 repoId: repo.id,
44 actorId: alice.id,
45 payload: { kind: "deploy", state: "ok", summary: `run ${++n}`, runId: `r${n}`, ...payload },
46 }).id}`;
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-share-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 const mk = async (username: string, first = false) => {
53 const r = await createUser(app.db, {
54 username,
55 email: `${username}@forge.local`,
56 password: "a-good-passphrase",
57 adminIfFirst: first,
58 });
59 if (!r.ok) throw new Error(r.error);
60 return r.value;
61 };
62 const root = await mk("root", true);
63 alice = await mk("alice");
64 bob = await mk("bob");
65 carol = await mk("carol");
66 const made = await createAgent(app, root, { username: "bot", brief: "Be brief." });
67 if (!made.ok) throw new Error(String(made.error));
68 bot = made.value.user;
69 aliceToken = createToken(app.db, alice.id, "t", "rw").token;
70 const o = await createRepo(app, alice, { name: "open", visibility: "public" });
71 const s = await createRepo(app, alice, { name: "secret", visibility: "private" });
72 if (!o.ok || !s.ok) throw new Error("repos");
73 open = o.value;
74 secret = s.value;
75 expect(addToCrew(app, root, open, "bot").ok).toBe(true);
76 });
77
78 afterAll(async () => {
79 servers.stop();
80 app.close();
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 const sent = async (item: string, to: string[], who = alice, now?: number) => {
85 const r = await share(app, who, item, to, now);
86 if (!r.ok) throw new Error(r.error);
87 return r.value.sent;
88 };
89
90 test("a share never widens access", async () => {
91 const item = emit(secret);
92 // Bob cannot read the private app: he is unreachable, and sees nothing.
93 expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "unreachable" }]);
94 expect(readFeed(app.db, bob).items.some((i) => i.app === "alice/secret")).toBe(false);
95 // A name that does not exist looks the same.
96 expect(await sent(item, ["nobody"])).toEqual([{ to: "nobody", outcome: "unreachable" }]);
97 // Nor can bob share what he cannot see.
98 const r = await share(app, bob, item, ["carol"]);
99 expect(r).toEqual({ ok: false, error: "not_found" });
100 });
101
102 test("a person gets a boosted item that says who shared it, once", async () => {
103 const item = emit(open);
104 expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "delivered" }]);
105 const items = readFeed(app.db, bob).items;
106 const hit = items.find((i) => i.id.startsWith("share:"));
107 expect(hit?.reason).toStartWith("shared by alice: ");
108 expect(items.some((i) => i.id === item)).toBe(false);
109 expect(items[0]?.id).toBe(hit!.id);
110 // Dedupe: the same item to the same person again is not sent.
111 expect(await sent(item, ["bob"])).toEqual([{ to: "bob", outcome: "duplicate" }]);
112 expect(readFeed(app.db, bob).items.filter((i) => i.id.startsWith("share:")).length).toBe(1);
113 });
114
115 test("the daily budget queues the rest, and the queue decays", async () => {
116 const t0 = Date.now();
117 const items = Array.from({ length: DAILY_BUDGET + 2 }, () => emit(open));
118 const outcomes: string[] = [];
119 for (const it of items) outcomes.push((await sent(it, ["carol"], alice, t0))[0]!.outcome);
120 expect(outcomes.filter((o) => o === "delivered").length).toBe(DAILY_BUDGET);
121 expect(outcomes.filter((o) => o === "queued").length).toBe(2);
122 const shares = (now: number) =>
123 readFeed(app.db, carol, { now, limit: 50 }).items.filter((i) => i.id.startsWith("share:")).length;
124 expect(shares(t0 + HOUR)).toBe(DAILY_BUDGET);
125 // A day on, the budget is back and the queue moves.
126 expect(shares(t0 + 25 * HOUR)).toBe(DAILY_BUDGET + 2);
127 });
128
129 test("a queued share that waits too long is dropped", async () => {
130 const t0 = Date.now();
131 const dave = await createUser(app.db, {
132 username: "dave",
133 email: "[email protected]",
134 password: "a-good-passphrase",
135 });
136 if (!dave.ok) throw new Error(dave.error);
137 // Spend dave's day with shares from carol-less alice, then queue one.
138 for (let i = 0; i < DAILY_BUDGET; i++) await sent(emit(open), ["dave"], alice, t0 + 40 * HOUR);
139 const late = await sent(emit(open), ["dave"], alice, t0 + 40 * HOUR);
140 expect(late[0]!.outcome).toBe("queued");
141 // Past the ttl with the budget spent again by fresh shares, it is gone.
142 const way = t0 + 40 * HOUR + QUEUE_TTL_MS + HOUR;
143 const feed = readFeed(app.db, dave.value, { now: way, limit: 50 });
144 expect(feed.items.filter((i) => i.id.startsWith("share:")).length).toBe(DAILY_BUDGET);
145 const state = app.db
146 .query<{ state: string }, [string]>("SELECT state FROM share WHERE recipient_id = ?1 AND state = 'dropped'")
147 .all(dave.value.id);
148 expect(state.length).toBe(1);
149 });
150
151 test("muting the app is a strong no", async () => {
152 recordInteraction(app.db, {
153 viewerId: bob.id,
154 itemId: "app:x",
155 kind: "muted",
156 repoId: open.id,
157 actorId: null,
158 });
159 expect(await sent(emit(open), ["bob"])).toEqual([{ to: "bob", outcome: "skipped" }]);
160 });
161
162 test("an agent is steered on a change, told otherwise", async () => {
163 const issue = openIssue(app, alice, open, { title: "Plant tulips", body: "" });
164 if (!issue.ok) throw new Error(String(issue.error));
165 const change = emit(open, "pull.opened", { number: issue.value.number });
166 expect(await sent(change, ["bot"])).toEqual([{ to: "bot", outcome: "steered" }]);
167 const said = listComments(app.db, issue.value.id).map((c) => c.body);
168 expect(said.some((b) => b.startsWith("Shared by alice with bot:"))).toBe(true);
169 // Not twice.
170 expect(await sent(change, ["bot"])).toEqual([{ to: "bot", outcome: "duplicate" }]);
171 const plain = emit(open);
172 expect(await sent(plain, ["bot"])).toEqual([{ to: "bot", outcome: "told" }]);
173 });
174
175 test("with no target it goes to crew and owners, then followers", async () => {
176 const item = emit(open);
177 // Alice owns it and bot is on the crew: she is the sender, so bot only.
178 expect((await sent(item, [])).map((s) => s.to)).toEqual(["bot"]);
179 // Bob (not an owner) shares: crew and the owner are both reached.
180 const item2 = emit(open);
181 expect((await sent(item2, [], bob)).map((s) => s.to).sort()).toEqual(["alice", "bot"]);
182 // When only the sender is left, the followers hear it.
183 const lone = await createRepo(app, alice, { name: "lone", visibility: "public" });
184 if (!lone.ok) throw new Error("repo");
185 const erin = await createUser(app.db, { username: "erin", email: "[email protected]", password: "a-good-passphrase" });
186 if (!erin.ok) throw new Error("user");
187 pin(app.db, erin.value.id, lone.value.id);
188 const item3 = emit(lone.value);
189 expect(await sent(item3, [])).toEqual([{ to: "erin", outcome: "delivered" }]);
190 });
191
192 test("a sender who is opened rises, one who is not sinks", async () => {
193 const mk = async (username: string) => {
194 const r = await createUser(app.db, { username, email: `${username}@forge.local`, password: "a-good-passphrase" });
195 if (!r.ok) throw new Error("user");
196 return r.value;
197 };
198 const sender = await mk("sender");
199 const reader = await mk("reader");
200 expect(senderWeight(app.db, sender.id, Date.now())).toBe(1);
201 const items = [] as string[];
202 for (let i = 0; i < 4; i++) {
203 items.push(emit(open));
204 await sent(items[i]!, ["reader"], sender);
205 }
206 const ignored = senderWeight(app.db, sender.id, Date.now());
207 expect(ignored).toBeLessThan(0.5);
208 for (const it of items)
209 recordInteraction(app.db, { viewerId: reader.id, itemId: it, kind: "opened", repoId: open.id, actorId: null });
210 expect(senderWeight(app.db, sender.id, Date.now())).toBeGreaterThan(1.5);
211 });
212
213 test("the route validates, checks scope and answers", async () => {
214 const item = emit(open);
215 const post = (token: string | undefined, payload: unknown) =>
216 fetch(`${servers.url}/api/v1/share`, {
217 method: "POST",
218 headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json" },
219 body: JSON.stringify(payload),
220 });
221 expect((await post(undefined, { item })).status).toBe(401);
222 expect((await post(aliceToken, { item: "nope" })).status).toBe(422);
223 expect((await post(aliceToken, { item, to: [1] })).status).toBe(422);
224 expect((await post(aliceToken, { item, to: "bad name!" })).status).toBe(422);
225 expect((await post(aliceToken, { item: "event:missing" })).status).toBe(404);
226 const ok = await post(aliceToken, { item, to: ["carol"] });
227 expect(ok.status).toBe(201);
228 expect(await ok.json()).toEqual({ sent: [{ to: "carol", outcome: expect.any(String) }] });
229 const ro = createToken(app.db, alice.id, "ro", "ro").token;
230 expect((await post(ro, { item, to: ["carol"] })).status).toBe(403);
231 });
232
233 test("parseTargets accepts names and refuses the rest", () => {
234 expect(parseTargets(undefined)).toEqual([]);
235 expect(parseTargets("@bob")).toEqual(["bob"]);
236 expect(parseTargets(["a", "b"])).toEqual(["a", "b"]);
237 expect(parseTargets({})).toBeUndefined();
238 expect(parseTargets(Array(11).fill("a"))).toBeUndefined();
239 });
240
241 test("it is a tool on the MCP door", async () => {
242 const item = emit(open);
243 const call = (name: string, args: object) =>
244 fetch(`${servers.url}/mcp`, {
245 method: "POST",
246 headers: { authorization: `Bearer ${aliceToken}`, "content-type": "application/json", accept: "application/json" },
247 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
248 }).then((r) => r.json() as Promise<{ result: { isError?: boolean; content: { text: string }[] } }>);
249 const r = await call("share", { item, to: ["carol"] });
250 expect(r.result.isError).toBeFalsy();
251 expect(r.result.content[0]!.text).toContain("carol");
252 });
253 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs