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

Flow: the week's budget as pipes I can see and turn, and the system does exactly that #8768

Merged opened this in answer to

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

Merged

mason/forge:mason/8698-flow-the-week-s-budget-as-pipes-i-can-se → main 12 commits fork 2 threads · 2 unresolved

  • Merged. mason/8698-flow-the-week-s-budget-as-pipes-i-can-se was merged into main as ed024f9.

Review

Sign in to review this change.
31 files changed +1299 −18
docs/design/areas/flow-valves.md added +3
@@ -0,0 +1,3 @@
1 # Flow valves
2
3 `/flow` draws the week's budget as pipes (`src/web/flow_pipes.ts`): `.pipes` is the SVG, `.pipe` a pipe whose stroke width is the week's spend, `.pipe-out` the pipe from a repository to shipped, `.pipe-valve` the valve on a repository (`.pipe-valve-open`, `.pipe-valve-share`, `.pipe-valve-shut` say how far it is turned), `.pipe-label` and `.pipe-repo` its text. `.valve-form` is the row of a valve's slider, its words and its Set button, and `.valve-range` the slider itself, which submits on release.
docs/features/flow-valves.md added +33
@@ -0,0 +1,33 @@
1 # Flow: the week's budget as pipes
2
3 `/flow` (a forge admin; linked from Boards) draws where the week's money goes: your notes, the planner, the builders and reviews on the left, a **valve** per repository in the middle, shipped on the right. Line width is the real spend of the last 7 days (`flow/pipes.ts`).
4
5 ## A valve
6
7 One stored number per repository, 0 to 100 (`flow_valve`): **shut** (0), **a share** of the week's agent starts (1 to 99), or **open** (100, and what a repository without a valve is). Drag the slider and release: that is `POST /flow/valve`, and the next dispatcher tick obeys it (`flow/valve_gate.ts`, called by `pump` in `crew/dispatch.ts`).
8
9 The rule is `throughValves` in `flow/valve.ts`, plain data in and out, so the same valves and the same queue decide the same way (`test/flow-valves.test.ts`):
10
11 - shut: a queued run waits, and its log says why; a run carrying on after a pause is never held;
12 - a share: runs start in turn while the repository holds no more than its share (rounded up) of the week's starts, then yield to the other repositories. A share is a ceiling in turn, not a quota: when nothing else wants the slot, the yielding run still starts;
13 - open: unchanged.
14
15 Only a person who is a forge admin turns a valve (`actions/valves.ts`); an agent, whatever it was granted, is told `not_found`. Each turn is kept (`flow_valve_turn`) and announced as `valve.turned`.
16
17 ## Before and after
18
19 Beside each valve the page says what the next tick will do from the queue as it stands ("of 3 queued: 1 starts in turn, 1 yields, 1 stays queued, held by the valve"), and, once turned, who turned it, from what to what, and how many runs have started since.
20
21 ## Suggestions
22
23 Looking at a repository's page as an admin is counted per day (`flow_look`). When your share of looks differs from a valve by 10 points or more, and there are at least 10 looks, the valve shows "You looked here N times this week: 40% would match" with one button. Taking it is a turn you make (`POST /flow/suggestion`; the level is worked out again on the server). Looks never move a valve.
24
25 ## For an agent
26
27 The MCP tool `valves` reads the same pipes; `repo` and `level` plan a turn (`dryRun` is true unless `false`) and apply it with an admin-scope token held by a person.
28
29 ## In the terminal
30
31 The palette's **Valves** (`tui/screens/valves.ts`, a forge admin who is a person; a guest, a member and an agent are offered nothing) lists the same `readPipes` rows. `h` and `l` close and open the valve under the cursor by ten, `x` shuts it, `o` opens it, `t` takes the suggestion; each goes through `turnValveFor` / `acceptSuggestion`, so it is kept and announced as `valve.turned` like a turn on the page. The screen refreshes on that event, so a turn made on the page shows there and the other way round (`test/tui-valves.test.ts`).
32
33 `valve.turned` names the repository, so it is for that repository's admins (`core/event_audience.ts`), never for a reader who could not open it.
forge.json +1 −1
@@ -3,7 +3,7 @@
33 "api.anthropic.com"
44 ],
55 "app": {
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"],
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/actions/valves.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * Turning a valve (`flow/valve.ts`): the one writer of a level. A forge
3 * admin who is a person turns it; an agent is told `not_found`, whatever it
4 * was granted, because a valve is how the owner directs the crew and the
5 * crew must not direct itself. A turn is kept, announced as `valve.turned`,
6 * and read by the next tick. Accepting a suggestion is a turn the person
7 * makes: the level is worked out again here, never taken from the caller.
8 */
9
10 import type { App } from "../app.ts";
11 import { getRepo, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { levelWords, parseLevel, suggestedLevel } from "../flow/valve.ts";
15 import { looksThisWeek, readLevels, turnValve } from "../flow/valve_store.ts";
16
17 export type ValveError = "not_found" | "invalid_level";
18
19 function person(actor: User | undefined): Result<User, "not_found"> {
20 return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found");
21 }
22
23 function repoOf(app: App, slug: string): Repo | undefined {
24 const [owner, name, ...rest] = slug.split("/");
25 if (owner === undefined || name === undefined || rest.length > 0) return undefined;
26 return getRepo(app.db, owner, name);
27 }
28
29 export interface Turned {
30 readonly repo: string;
31 readonly from: number;
32 readonly to: number;
33 readonly says: string;
34 }
35
36 function turn(app: App, who: User, repo: Repo, level: number): Turned {
37 const { from, to } = turnValve(app.db, repo.id, level, who.id);
38 const slug = `${repo.ownerName}/${repo.name}`;
39 if (from !== to)
40 app.bus.append({
41 kind: "valve.turned",
42 actorId: who.id,
43 repoId: repo.id,
44 payload: { repo: slug, from, to },
45 });
46 return { repo: slug, from, to, says: `${slug}: ${levelWords(to)} From the next tick on.` };
47 }
48
49 /** Set a repository's valve to `level` (0 shut, 1 to 99 a share, 100 open). */
50 export function turnValveFor(app: App, actor: User | undefined, slug: string, level: unknown): Result<Turned, ValveError> {
51 const who = person(actor);
52 if (!who.ok) return who;
53 const repo = repoOf(app, slug);
54 if (repo === undefined) return err("not_found");
55 const n = parseLevel(level);
56 if (n === undefined) return err("invalid_level");
57 return ok(turn(app, who.value, repo, n));
58 }
59
60 /** Take the suggestion beside a valve, as it stands now; nothing when there is none. */
61 export function acceptSuggestion(app: App, actor: User | undefined, slug: string): Result<Turned, ValveError> {
62 const who = person(actor);
63 if (!who.ok) return who;
64 const repo = repoOf(app, slug);
65 if (repo === undefined) return err("not_found");
66 const current = readLevels(app.db).get(repo.id) ?? 100;
67 const level = suggestedLevel(looksThisWeek(app.db, who.value.id), repo.id, current);
68 if (level === undefined) return err("invalid_level");
69 return ok(turn(app, who.value, repo, level));
70 }
src/core/event_audience.ts +2
@@ -187,6 +187,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
187187 kind === "repo.upstream_follow" ||
188188 kind === "repo.upstream_checked" ||
189189 kind === "repo.crew_outsiders" ||
190 // A valve turn names a repository and the owner's budget: for those who administer it (`actions/valves.ts`).
191 kind === "valve.turned" ||
190192 // A clog starting names the forge's own pile-up: the owner's to hear (#6524).
191193 kind.startsWith("clog.") ||
192194 // The release keeper's steps name images and probe numbers: the forge owner's (`keeper/keeper.ts`, #7557).
src/core/migration-pins/20261102200000-flow-valves.pin added +1
@@ -0,0 +1,1 @@
1 0f041a8900a4af6b058889a66684634ca66dc7da70b990ea50c7351e806bad89
src/core/migrations/20261102200000-flow-valves.ts added +29
@@ -0,0 +1,29 @@
1 // The week's budget as valves (#8698). `flow_valve` is one stored value per
2 // repository: the share of the week's agent starts it may hold, 0 (shut) to
3 // 100 (open); no row is open. Only a person writes it. `flow_valve_turn`
4 // keeps every turn, so the page can say what a turn did. `flow_look` counts
5 // where a person looks, per day, to suggest a valve and never to set one.
6 export default `
7 CREATE TABLE IF NOT EXISTS flow_valve (
8 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
9 level INTEGER NOT NULL CHECK (level BETWEEN 0 AND 100),
10 set_by TEXT REFERENCES user(id) ON DELETE SET NULL,
11 set_at TEXT NOT NULL
12 );
13 CREATE TABLE IF NOT EXISTS flow_valve_turn (
14 id INTEGER PRIMARY KEY AUTOINCREMENT,
15 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
16 from_level INTEGER NOT NULL,
17 to_level INTEGER NOT NULL,
18 by_id TEXT REFERENCES user(id) ON DELETE SET NULL,
19 at TEXT NOT NULL
20 );
21 CREATE INDEX IF NOT EXISTS flow_valve_turn_at ON flow_valve_turn (at);
22 CREATE TABLE IF NOT EXISTS flow_look (
23 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
24 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
25 day TEXT NOT NULL,
26 n INTEGER NOT NULL DEFAULT 0,
27 PRIMARY KEY (user_id, repo_id, day)
28 );
29 `;
src/crew/dispatch.ts +10 −2
@@ -43,6 +43,7 @@ import { runningLimitSentence, runningRuns } from "../settings/quotas.ts";
4343 import { appendRunLog } from "../runs/log.ts";
4444 import { decideAndRecord } from "./ledger.ts";
4545 import { byDials } from "./team_share.ts";
46 import { gateQueue } from "../flow/valve_gate.ts";
4647 import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts";
4748 import { listReviews } from "../core/reviews.ts";
4849 import {
@@ -1414,8 +1415,15 @@ function pump(
14141415 const started: string[] = [];
14151416 const noted = notedBy.get(app) ?? new Set<string>();
14161417 notedBy.set(app, noted);
1417 // Builder starts go to teams in proportion to their dials (`team_share.ts`).
1418 for (const queued of byDials(app.db, listQueued(app.db, "agent", 50))) {
1418 // Builder starts go to teams in proportion to their dials (`team_share.ts`),
1419 // then through the valves a person turned (`flow/valve_gate.ts`).
1420 const gated = gateQueue(app.db, byDials(app.db, listQueued(app.db, "agent", 50)));
1421 for (const shut of gated.held) {
1422 if (noted.has(shut.id)) continue;
1423 noted.add(shut.id);
1424 appendRunLog(app.config.dataDir, shut.id, "queued, not started: its repository's valve is shut (/flow)");
1425 }
1426 for (const queued of gated.order) {
14191427 if (queued.issueId !== null && runningIssues.has(queued.issueId)) continue;
14201428 const fresh = getRun(app.db, queued.id);
14211429 if (!fresh || fresh.state !== "queued") continue;
src/flow/pipes.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * The week's budget as pipes: sources (your notes, the planner, the
3 * builders, reviews) flowing through a valve per repository to shipped,
4 * with real spend as the width. Plain data out for the page, the terminal
5 * and the MCP. The preview is the valves applied to the queue as it stands,
6 * by the same rule the next tick uses (`valve_gate.ts`); what a turn did is
7 * the starts since it.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { listQueued } from "../core/runs.ts";
12 import { getRepoById } from "../core/repos.ts";
13 import { byDials } from "../crew/team_share.ts";
14 import { modeOf, suggestedLevel, type Mode } from "./valve.ts";
15 import { gateQueue } from "./valve_gate.ts";
16 import {
17 lastTurns,
18 looksThisWeek,
19 notesThisWeek,
20 readLevels,
21 shippedThisWeek,
22 SOURCES,
23 spendThisWeek,
24 startsSince,
25 type Source,
26 } from "./valve_store.ts";
27
28 const MAX_VALVES = 24;
29
30 export interface ValveRow {
31 readonly repoId: string;
32 readonly slug: string;
33 readonly level: number;
34 readonly mode: Mode;
35 readonly usd: number;
36 readonly runs: number;
37 readonly notes: number;
38 readonly shipped: number;
39 /** The next tick, from the queue now: starting in turn, yielding to others, held shut. */
40 readonly next: { readonly queued: number; readonly goes: number; readonly yields: number; readonly held: number };
41 /** The latest turn and what has happened since it. */
42 readonly turned?: { readonly by: string; readonly at: string; readonly from: number; readonly to: number; readonly started: number };
43 /** What where you look suggests, when it differs enough from the valve. */
44 readonly suggestion?: number;
45 readonly looks: number;
46 }
47
48 export interface Pipes {
49 readonly usd: number;
50 readonly notes: number;
51 readonly shipped: number;
52 readonly sources: readonly { readonly source: Source | "notes"; readonly usd: number; readonly runs: number }[];
53 /** Each pipe from a source into a repository, in dollars (notes carry none, only a count of runs). */
54 readonly flows: readonly { readonly source: Source | "notes"; readonly repoId: string; readonly usd: number; readonly runs: number }[];
55 readonly valves: readonly ValveRow[];
56 }
57
58 export function readPipes(db: Database, viewerId: string, now = Date.now()): Pipes {
59 const spend = spendThisWeek(db, now);
60 const notes = notesThisWeek(db, now);
61 const shipped = shippedThisWeek(db, now);
62 const levels = readLevels(db);
63 const turns = lastTurns(db);
64 const looks = looksThisWeek(db, viewerId, now);
65 const gated = gateQueue(db, byDials(db, listQueued(db, "agent", 50)), now);
66 const yielding = new Set(gated.yielding.map((r) => r.id));
67 const queue = new Map<string, { queued: number; goes: number; yields: number; held: number }>();
68 const slot = (id: string) => {
69 let s = queue.get(id);
70 if (s === undefined) queue.set(id, (s = { queued: 0, goes: 0, yields: 0, held: 0 }));
71 return s;
72 };
73 for (const r of gated.order) {
74 const s = slot(r.repoId);
75 s.queued++;
76 if (yielding.has(r.id)) s.yields++;
77 else s.goes++;
78 }
79 for (const r of gated.held) {
80 const s = slot(r.repoId);
81 s.queued++;
82 s.held++;
83 }
84
85 const ids = new Set<string>([
86 ...levels.keys(),
87 ...queue.keys(),
88 ...notes.keys(),
89 ...shipped.keys(),
90 ...spend.map((s) => s.repoId),
91 ]);
92 const usdOf = (id: string) => spend.filter((s) => s.repoId === id).reduce((a, s) => a + s.usd, 0);
93 const rows: ValveRow[] = [];
94 for (const id of ids) {
95 const repo = getRepoById(db, id);
96 if (repo === undefined) continue;
97 const level = levels.get(id) ?? 100;
98 const turn = turns.get(id);
99 const suggestion = suggestedLevel(looks, id, level);
100 rows.push({
101 repoId: id,
102 slug: `${repo.ownerName}/${repo.name}`,
103 level,
104 mode: modeOf(level),
105 usd: usdOf(id),
106 runs: spend.filter((s) => s.repoId === id).reduce((a, s) => a + s.runs, 0),
107 notes: notes.get(id) ?? 0,
108 shipped: shipped.get(id) ?? 0,
109 next: queue.get(id) ?? { queued: 0, goes: 0, yields: 0, held: 0 },
110 ...(turn === undefined ? {} : { turned: { by: turn.by, at: turn.at, from: turn.from, to: turn.to, started: startsSince(db, id, turn.at) } }),
111 ...(suggestion === undefined ? {} : { suggestion }),
112 looks: looks.find((l) => l.repoId === id)?.n ?? 0,
113 });
114 }
115 rows.sort((a, b) => b.usd - a.usd || a.slug.localeCompare(b.slug));
116 rows.length = Math.min(rows.length, MAX_VALVES);
117 const shown = new Set(rows.map((r) => r.repoId));
118 const sources = [
119 { source: "notes" as const, usd: 0, runs: [...notes.values()].reduce((a, n) => a + n, 0) },
120 ...SOURCES.map((source) => ({
121 source,
122 usd: spend.filter((s) => s.source === source).reduce((a, s) => a + s.usd, 0),
123 runs: spend.filter((s) => s.source === source).reduce((a, s) => a + s.runs, 0),
124 })),
125 ];
126 return {
127 usd: spend.reduce((a, s) => a + s.usd, 0),
128 notes: sources[0]!.runs,
129 shipped: [...shipped.values()].reduce((a, n) => a + n, 0),
130 sources,
131 flows: [
132 ...[...notes].map(([repoId, runs]) => ({ source: "notes" as const, repoId, usd: 0, runs })),
133 ...spend,
134 ].filter((f) => shown.has(f.repoId)),
135 valves: rows,
136 };
137 }
src/flow/valve.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * A valve is one stored number per repository, 0 to 100: shut, a share of
3 * the week's agent starts, or open (no valve at all). This file is the whole
4 * rule, plain data in and plain data out, so the same valves and the same
5 * queue always decide the same way:
6 *
7 * - shut: a run that has not started yet stays queued;
8 * - a share: a run goes in its turn while the repository holds no more than
9 * its share of the week's starts, and yields to the others after that (it
10 * still starts when nothing else wants the slot);
11 * - open: no change.
12 *
13 * A run carrying on after a pause (`resumed`) is already going and is never
14 * held. Nothing here reads a clock or a table; `valve_store.ts` does.
15 */
16
17 export const OPEN = 100;
18 export const SHUT = 0;
19
20 /** A valve level from the wire: a whole number 0 to 100, else nothing. */
21 export function parseLevel(raw: unknown): number | undefined {
22 const n = typeof raw === "string" && /^\d{1,3}$/.test(raw.trim()) ? Number(raw) : raw;
23 return typeof n === "number" && Number.isInteger(n) && n >= SHUT && n <= OPEN ? n : undefined;
24 }
25
26 export type Mode = "open" | "share" | "shut";
27
28 export const modeOf = (level: number): Mode => (level >= OPEN ? "open" : level <= SHUT ? "shut" : "share");
29
30 /** One sentence for a level, shared by the page, the terminal and the MCP. */
31 export function levelWords(level: number): string {
32 if (level >= OPEN) return "Open: runs start as the crew picks them.";
33 if (level <= SHUT) return "Shut: nothing new starts here until you open it.";
34 return `${level}% of the week: runs start in turn up to that share, then yield to the other repositories.`;
35 }
36
37 export interface Waiting {
38 readonly repoId: string;
39 /** Carrying on after a pause or a restart: already going, so never held. */
40 readonly resumed: boolean;
41 }
42
43 export interface Gated<T> {
44 /** In the order they should start: those within their share first, then those that yield. */
45 readonly order: T[];
46 /** Shut out this tick. */
47 readonly held: T[];
48 /** In `order`, the ones that yield: over their share, started only if a slot is left. */
49 readonly yielding: T[];
50 }
51
52 /**
53 * Put the queue through the valves. `starts` is each repository's agent
54 * starts so far this week; `levels` is the valves, a repository without one
55 * being open. The result depends on nothing else.
56 */
57 export function throughValves<T>(
58 queued: readonly T[],
59 waiting: (t: T) => Waiting,
60 levels: ReadonlyMap<string, number>,
61 starts: ReadonlyMap<string, number>,
62 ): Gated<T> {
63 const counts = new Map(starts);
64 let total = 0;
65 for (const n of counts.values()) total += n;
66 const within: T[] = [];
67 const yielding: T[] = [];
68 const held: T[] = [];
69 for (const t of queued) {
70 const { repoId, resumed } = waiting(t);
71 const level = resumed ? OPEN : (levels.get(repoId) ?? OPEN);
72 if (level <= SHUT) {
73 held.push(t);
74 continue;
75 }
76 const mine = counts.get(repoId) ?? 0;
77 if (level < OPEN && mine + 1 > Math.ceil((level / 100) * (total + 1))) {
78 yielding.push(t);
79 continue;
80 }
81 counts.set(repoId, mine + 1);
82 total++;
83 within.push(t);
84 }
85 return { order: [...within, ...yielding], held, yielding };
86 }
87
88 export interface Looks {
89 readonly repoId: string;
90 readonly n: number;
91 }
92
93 /** Fewer looks than this say nothing yet. */
94 export const LOOKS_MIN = 10;
95 /** A suggestion nearer than this to the valve is not worth a tap. */
96 const NEAR = 10;
97
98 /**
99 * The level the looks suggest for a repository: its share of all looks,
100 * rounded to the nearest 5 and kept within 5 to 95, so a suggestion is
101 * never shut and never open. Nothing when there are too few looks, the
102 * repository has none, or the valve is already near it.
103 */
104 export function suggestedLevel(
105 looks: readonly Looks[],
106 repoId: string,
107 current: number,
108 ): number | undefined {
109 let total = 0;
110 let mine = 0;
111 for (const l of looks) {
112 total += l.n;
113 if (l.repoId === repoId) mine += l.n;
114 }
115 if (total < LOOKS_MIN || mine === 0) return undefined;
116 const level = Math.min(95, Math.max(5, Math.round(((mine / total) * 100) / 5) * 5));
117 return Math.abs(level - current) < NEAR ? undefined : level;
118 }
src/flow/valve_gate.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * The valves applied to the queue: the one place the crew's next start and
3 * the page's preview both ask, so what the page says it will do is what the
4 * next tick does. Reads the stored valves and this week's starts, nothing else.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { Run } from "../core/runs.ts";
9 import { throughValves, type Gated } from "./valve.ts";
10 import { readLevels, startsThisWeek } from "./valve_store.ts";
11
12 /** Queued runs through the valves; with no valve turned, the queue as it came. */
13 export function gateQueue(db: Database, queued: readonly Run[], now = Date.now()): Gated<Run> {
14 const levels = readLevels(db);
15 if (levels.size === 0) return { order: [...queued], held: [], yielding: [] };
16 return throughValves(
17 queued,
18 (r) => ({ repoId: r.repoId, resumed: r.attempt > 0 }),
19 levels,
20 startsThisWeek(db, now),
21 );
22 }
src/flow/valve_store.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The valves and what they read, in the database: the stored levels, the
3 * turns of them, the week's starts and spend, and where a person looks.
4 * Rules live in `valve.ts`; the only writer of a level is `turnValve`, and
5 * it is called only by `actions/valves.ts` for a person.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { Looks } from "./valve.ts";
10
11 export const WEEK_MS = 7 * 86_400_000;
12
13 const since = (now: number): string => new Date(now - WEEK_MS).toISOString();
14
15 /** Every stored valve; a repository without one is open. */
16 export function readLevels(db: Database): Map<string, number> {
17 const rows = db.query<{ repo_id: string; level: number }, []>("SELECT repo_id, level FROM flow_valve").all();
18 return new Map(rows.map((r) => [r.repo_id, r.level]));
19 }
20
21 /** Agent runs started in the last week, per repository. */
22 export function startsThisWeek(db: Database, now = Date.now()): Map<string, number> {
23 const rows = db
24 .query<{ repo_id: string; n: number }, [string]>(
25 "SELECT repo_id, COUNT(*) AS n FROM run WHERE kind = 'agent' AND started_at >= ?1 GROUP BY repo_id",
26 )
27 .all(since(now));
28 return new Map(rows.map((r) => [r.repo_id, r.n]));
29 }
30
31 export type Source = "planner" | "builders" | "reviews";
32 export const SOURCES: readonly Source[] = ["planner", "builders", "reviews"];
33
34 export interface Spend {
35 readonly source: Source;
36 readonly repoId: string;
37 readonly runs: number;
38 readonly usd: number;
39 }
40
41 /** The week's agent runs and what they cost, by who started them and where they went. */
42 export function spendThisWeek(db: Database, now = Date.now()): Spend[] {
43 const rows = db
44 .query<{ repo_id: string; role: string | null; reason: string; n: number; usd: number }, [string]>(
45 `SELECT r.repo_id, c.role, r.reason, COUNT(*) AS n, COALESCE(SUM(r.cost_usd), 0) AS usd
46 FROM run r LEFT JOIN repo_crew c ON c.repo_id = r.repo_id AND c.user_id = r.actor_id
47 WHERE r.kind = 'agent' AND r.started_at >= ?1
48 GROUP BY r.repo_id, c.role, r.reason`,
49 )
50 .all(since(now));
51 const by = new Map<string, Spend>();
52 for (const r of rows) {
53 const source: Source =
54 r.reason === "review" || r.role === "reviewer" ? "reviews" : r.role === "planner" ? "planner" : "builders";
55 const key = `${source}\u0000${r.repo_id}`;
56 const was = by.get(key);
57 by.set(key, {
58 source,
59 repoId: r.repo_id,
60 runs: (was?.runs ?? 0) + r.n,
61 usd: (was?.usd ?? 0) + r.usd,
62 });
63 }
64 return [...by.values()];
65 }
66
67 /** Issues a person (not an agent) opened in the last week, per repository: the notes. */
68 export function notesThisWeek(db: Database, now = Date.now()): Map<string, number> {
69 const rows = db
70 .query<{ repo_id: string; n: number }, [string]>(
71 `SELECT i.repo_id, COUNT(*) AS n FROM issue i JOIN user u ON u.id = i.author_id
72 WHERE i.created_at >= ?1 AND i.kind = 'issue' AND u.kind = 'user' AND u.is_agent = 0 GROUP BY i.repo_id`,
73 )
74 .all(since(now));
75 return new Map(rows.map((r) => [r.repo_id, r.n]));
76 }
77
78 export interface Turn {
79 readonly repoId: string;
80 readonly from: number;
81 readonly to: number;
82 readonly by: string;
83 readonly at: string;
84 }
85
86 /** Set a valve and keep the turn; nothing happens when it already reads so. */
87 export function turnValve(db: Database, repoId: string, level: number, byId: string, now = Date.now()): { from: number; to: number } {
88 const at = new Date(now).toISOString();
89 return db.transaction(() => {
90 const was = db.query<{ level: number }, [string]>("SELECT level FROM flow_valve WHERE repo_id = ?1").get(repoId);
91 const from = was?.level ?? 100;
92 if (from === level && (was !== null || level === 100)) return { from, to: level };
93 db.query(
94 `INSERT INTO flow_valve (repo_id, level, set_by, set_at) VALUES (?1, ?2, ?3, ?4)
95 ON CONFLICT (repo_id) DO UPDATE SET level = excluded.level, set_by = excluded.set_by, set_at = excluded.set_at`,
96 ).run(repoId, level, byId, at);
97 db.query("INSERT INTO flow_valve_turn (repo_id, from_level, to_level, by_id, at) VALUES (?1, ?2, ?3, ?4, ?5)").run(
98 repoId,
99 from,
100 level,
101 byId,
102 at,
103 );
104 return { from, to: level };
105 })();
106 }
107
108 /** The latest turn of each repository's valve. */
109 export function lastTurns(db: Database): Map<string, Turn> {
110 const rows = db
111 .query<{ repo_id: string; from_level: number; to_level: number; username: string | null; at: string }, []>(
112 `SELECT t.repo_id, t.from_level, t.to_level, u.username, t.at
113 FROM flow_valve_turn t LEFT JOIN user u ON u.id = t.by_id
114 WHERE t.id IN (SELECT MAX(id) FROM flow_valve_turn GROUP BY repo_id)`,
115 )
116 .all();
117 return new Map(
118 rows.map((r) => [r.repo_id, { repoId: r.repo_id, from: r.from_level, to: r.to_level, by: r.username ?? "someone", at: r.at }]),
119 );
120 }
121
122 /** Agent runs a repository started since `at`. */
123 export function startsSince(db: Database, repoId: string, at: string): number {
124 return db
125 .query<{ n: number }, [string, string]>(
126 "SELECT COUNT(*) AS n FROM run WHERE repo_id = ?1 AND kind = 'agent' AND started_at >= ?2",
127 )
128 .get(repoId, at)!.n;
129 }
130
131 /** Note that `userId` looked at a repository, today. */
132 export function recordLook(db: Database, userId: string, repoId: string, now = Date.now()): void {
133 db.query(
134 `INSERT INTO flow_look (user_id, repo_id, day, n) VALUES (?1, ?2, ?3, 1)
135 ON CONFLICT (user_id, repo_id, day) DO UPDATE SET n = n + 1`,
136 ).run(userId, repoId, new Date(now).toISOString().slice(0, 10));
137 }
138
139 /** Where a person looked in the last week. */
140 export function looksThisWeek(db: Database, userId: string, now = Date.now()): Looks[] {
141 const rows = db
142 .query<{ repo_id: string; n: number }, [string, string]>(
143 "SELECT repo_id, SUM(n) AS n FROM flow_look WHERE user_id = ?1 AND day >= ?2 GROUP BY repo_id",
144 )
145 .all(userId, since(now).slice(0, 10));
146 return rows.map((r) => ({ repoId: r.repo_id, n: r.n }));
147 }
148
149 /** Pulls merged in the last week, per repository: what reached shipped. */
150 export function shippedThisWeek(db: Database, now = Date.now()): Map<string, number> {
151 const rows = db
152 .query<{ repo_id: string; n: number }, [string]>(
153 `SELECT i.repo_id, COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id
154 WHERE p.merged_at >= ?1 GROUP BY i.repo_id`,
155 )
156 .all(since(now));
157 return new Map(rows.map((r) => [r.repo_id, r.n]));
158 }
src/http/mcp/tools/index.ts +1
@@ -164,6 +164,7 @@ export { tool as tokens } from "./tokens.ts";
164164 export { tool as trail } from "./trail.ts";
165165 export { tool as upstream } from "./upstream.ts";
166166 export { tool as usage } from "./usage.ts";
167 export { tool as valves } from "./valves.ts";
167168 export { tool as variant } from "./variant.ts";
168169 export { tool as vision } from "./vision.ts";
169170 export { tool as wait_for } from "./wait_for.ts";
src/http/mcp/tools/valves.ts added +39
@@ -0,0 +1,39 @@
1 import { turnValveFor } from "../../../actions/valves.ts";
2 import { readPipes } from "../../../flow/pipes.ts";
3 import { levelWords, parseLevel } from "../../../flow/valve.ts";
4 import { scopeAllows } from "../../../core/tokens.ts";
5 import { good, bad, type Tool } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "valves",
9 doc: "`{ repo?, level?, dryRun? }` (a forge admin who is a person; an agent is told no such tool): the week's budget as pipes, the same page as /flow: `{ usd, shipped, sources, valves: [{ slug, level, mode, usd, runs, shipped, next: { queued, goes, yields, held }, turned?, suggestion? }] }`; with `repo` (`owner/name`) and `level` (0 shut, 1 to 99 the share of the week's starts, 100 open), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token and takes effect on the next tick. A valve is the owner's alone, so no agent turns one; `suggestion` is where the owner looks, and moves nothing",
10 description: "The week's pipes and valves; with `repo` and `level`, turns a valve (plans first).",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: { type: "string", maxLength: 200 },
15 level: { type: "integer", minimum: 0, maximum: 100 },
16 dryRun: { type: "boolean" },
17 },
18 },
19 writes: true,
20 dryByDefault: true,
21 run(ctx, user, args) {
22 if (user?.isAdmin !== true || user.kind === "agent") return bad("not_found", "no such tool here");
23 const pipes = readPipes(ctx.app.db, user.id);
24 const repo = typeof args["repo"] === "string" ? args["repo"] : undefined;
25 if (repo === undefined) return good(pipes);
26 const level = parseLevel(args["level"]);
27 if (level === undefined) return bad("invalid_level", "level is a whole number from 0 (shut) to 100 (open)");
28 const now = pipes.valves.find((v) => v.slug === repo);
29 if (args["dryRun"] !== false)
30 return good({
31 says: `Would set the valve of ${repo} from ${now?.level ?? 100} to ${level}. ${levelWords(level)} Send dryRun: false to turn it.`,
32 });
33 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
34 return bad("forbidden", "turning a valve needs an admin-scope token");
35 const turned = turnValveFor(ctx.app, user, repo, level);
36 if (!turned.ok) return bad(turned.error, turned.error === "not_found" ? "no such repository" : "level is a whole number from 0 to 100");
37 return good(turned.value);
38 },
39 };
src/http/server.ts +5 −1
@@ -266,6 +266,7 @@ import { appNowFragment } from "../web/studio/now.ts";
266266 import { appStateFragment } from "../web/studio/previews.ts";
267267 import { changesList, tellsList } from "../web/studio/lists.ts";
268268 import { dependencySubmit } from "../web/pages/thread.ts";
269 import { flowPage, suggestionTake, valveSet } from "../web/pages/flow.ts";
269270 import { heldApply, heldPage, liftHoldSubmit } from "../web/pages/gate.ts";
270271 import {
271272 assigneeSubmit,
@@ -775,7 +776,10 @@ export function buildRouter(): Router<Handler> {
775776 // Every app on one canvas; a literal route, so it must sit above /:owner.
776777 router.add("GET", "/map", globalMapPage);
777778 // The pipeline moved to a panel on /boards (#2774); the old address follows it. Literal, above /:owner.
778 router.add("GET", "/flow", (ctx) => new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } }));
779 // /flow is the week's valves (#8698); a query string is an old pipeline drill, which went to /boards.
780 router.add("GET", "/flow", flowPage);
781 router.add("POST", "/flow/valve", valveSet);
782 router.add("POST", "/flow/suggestion", suggestionTake);
779783 router.add("GET", "/system", systemPage);
780784 router.add("GET", "/system/stream", systemStream);
781785 router.add("GET", "/contribute", contributePage);
src/tui/screens/entries.ts +2
@@ -40,6 +40,7 @@ import { entry as subscriptions } from "./subscriptions.ts";
4040 import { entry as system } from "./system.ts";
4141 import { entry as teams } from "./teams.ts";
4242 import { entry as telemetry } from "./telemetry.ts";
43 import { entry as valves } from "./valves.ts";
4344
4445 export interface PaletteEntry {
4546 readonly id: string;
@@ -63,6 +64,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
6364 system,
6465 telemetry,
6566 resilience,
67 valves,
6668 access,
6769 audit,
6870 secrets,
src/tui/screens/valves.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The week's valves in the terminal, a forge admin's who is a person: one row
3 * per repository with its valve and what the next tick will do, as /flow
4 * draws it from the same `readPipes`. `h`/`l` close or open a valve by ten,
5 * `x` shuts it, `o` opens it, `t` takes the suggestion beside it. A turn goes
6 * through `actions/valves.ts`, so it is kept, announced as `valve.turned` and
7 * reaches every open page and screen; the screen refreshes on that event.
8 */
9
10 import { acceptSuggestion, turnValveFor } from "../../actions/valves.ts";
11 import { readPipes, type Pipes, type ValveRow } from "../../flow/pipes.ts";
12 import { levelWords } from "../../flow/valve.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import type { Session } from "../session.ts";
16 import { style } from "../term/style.ts";
17 import { truncate } from "../term/width.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import type { PaletteEntry } from "./entries.ts";
20 import { ListState, drawList, live, type ListRow } from "./common.ts";
21 import { mayCapacity } from "./capacity.ts";
22
23 const STEP = 10;
24
25 function rowOf(v: ValveRow): ListRow {
26 const turned = v.turned === undefined ? "" : ` · ${v.turned.by} turned it ${String(v.turned.from)} → ${String(v.turned.to)}`;
27 const suggestion = v.suggestion === undefined ? "" : ` · t takes ${String(v.suggestion)}%`;
28 return {
29 title: `${v.slug} ${String(v.level)}%`,
30 meta: `$${v.usd.toFixed(2)} · ${String(v.runs)} runs · ${String(v.shipped)} shipped`,
31 note: `${levelWords(v.level)} ${String(v.next.queued)} queued: ${String(v.next.goes)} start, ${String(v.next.yields)} yield, ${String(v.next.held)} held${turned}${suggestion}`,
32 };
33 }
34
35 export function valvesScreen(f: Session, ctx: Ctx): Screen {
36 const state = new ListState();
37 let pipes: Pipes | undefined;
38 const reload = (): void => {
39 pipes = mayCapacity(f) && f.user !== undefined ? readPipes(f.db, f.user.id) : undefined;
40 ctx.invalidate();
41 };
42 const valves = (): readonly ValveRow[] => pipes?.valves ?? [];
43 const here = (): ValveRow | undefined => valves()[state.index];
44 const turn = (to: (v: ValveRow) => number): void => {
45 const v = here();
46 if (v === undefined) return;
47 const r = turnValveFor(f.app, f.user, v.slug, to(v));
48 ctx.toast(r.ok ? r.value.says : "that is not a valve", r.ok ? undefined : "attn");
49 reload();
50 };
51 const screen: Screen = {
52 title: "Valves",
53 keys: [
54 binding(["j", "k"], "move cursor"),
55 binding(["h", "l"], "close / open by ten"),
56 binding(["x"], "shut"),
57 binding(["o"], "open"),
58 binding(["t"], "take the suggestion"),
59 ],
60 load(signal) {
61 live(f, signal, { kinds: ["valve.turned"] }, reload);
62 reload();
63 return Promise.resolve();
64 },
65 render(area, s) {
66 const c = ctx.theme.c;
67 const [head, list] = split(area, "v", [len(1), fill(1)]);
68 const spent = pipes === undefined ? "" : `$${pipes.usd.toFixed(2)} spent in 7 days, ${String(pipes.shipped)} shipped; `;
69 s.text(head!.x, head!.y, truncate(`${spent}the crew does what the valves say, from the next tick`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
70 drawList(s, list!, c, state, valves().map(rowOf));
71 },
72 key(e) {
73 if (e.key === "h") turn((v) => Math.max(0, v.level - STEP));
74 else if (e.key === "l") turn((v) => Math.min(100, v.level + STEP));
75 else if (e.key === "x") turn(() => 0);
76 else if (e.key === "o") turn(() => 100);
77 else if (e.key === "t") {
78 const v = here();
79 if (v === undefined) return true;
80 if (v.suggestion === undefined) ctx.toast("no suggestion here", "attn");
81 else {
82 const r = acceptSuggestion(f.app, f.user, v.slug);
83 ctx.toast(r.ok ? r.value.says : "no suggestion here", r.ok ? undefined : "attn");
84 reload();
85 }
86 return true;
87 } else return state.key(e, valves().length);
88 return true;
89 },
90 };
91 return screen;
92 }
93
94 /** The week's valves, as /flow turns them: a forge admin's who is a person. */
95 export const entry: PaletteEntry = {
96 id: "valves",
97 title: "Valves",
98 hint: "the week's budget as a valve per repository; turn one and the crew obeys",
99 after: ["flow"],
100 offered: mayCapacity,
101 run: (session, ctx) => ctx.push(valvesScreen(session, ctx)),
102 };
src/web/flow_pipes.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The week's pipes drawn: sources on the left, a valve per repository in the
3 * middle, shipped on the right, line width the real spend of the week —
4 * SVG written here on the server, no client drawing. A pipe's width is its
5 * dollars over the widest pipe's, up to `MAX_W` units; a pipe with no spend
6 * (a note, a run that cost nothing) keeps a hairline so it can be seen. A
7 * shut valve draws its outgoing pipe dashed and a share draws a half-open
8 * gate. Colours are classes (`fg-sN`, `pipe-valve-*`), set in the stylesheet.
9 */
10
11 import type { Pipes } from "../flow/pipes.ts";
12 import { html, type Html } from "./html.ts";
13
14 const W = 720;
15 const SOURCE_X = 150;
16 const VALVE_X = 380;
17 const SHIPPED_X = 600;
18 const ROW = 46;
19 const MAX_W = 26;
20 const HAIR = 2;
21
22 const money = (usd: number): string => (usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`);
23
24 const curve = (x1: number, y1: number, x2: number, y2: number): string => {
25 const mid = (x1 + x2) / 2;
26 return `M${x1} ${y1} C${mid} ${y1} ${mid} ${y2} ${x2} ${y2}`;
27 };
28
29 export function pipesSvg(p: Pipes): Html {
30 const sources = p.sources;
31 const rows = Math.max(sources.length * 1.4, p.valves.length);
32 const H = Math.round(rows * ROW) + 24;
33 const widest = Math.max(0.01, ...p.flows.map((f) => f.usd), ...p.valves.map((v) => v.usd));
34 const width = (usd: number): number => Math.max(HAIR, (usd / widest) * MAX_W);
35 const sy = (i: number): number => 20 + ((H - 40) * (i + 0.5)) / sources.length;
36 const vy = (i: number): number => 20 + ((H - 40) * (i + 0.5)) / Math.max(1, p.valves.length);
37 const shippedY = H / 2;
38 const at = new Map(p.valves.map((v, i) => [v.repoId, i]));
39 const index = new Map(sources.map((s, i) => [s.source, i]));
40 const pipes = p.flows.map((f) => {
41 const i = index.get(f.source);
42 const j = at.get(f.repoId);
43 if (i === undefined || j === undefined) return null;
44 return html`<path class="pipe fg-s${String(i)}" d="${curve(SOURCE_X, sy(i), VALVE_X - 14, vy(j))}" stroke-width="${width(f.usd).toFixed(1)}"><title>${f.source} to ${p.valves[j]!.slug}: ${money(f.usd)}, ${String(f.runs)} ${f.source === "notes" ? "notes" : "runs"}</title></path>`;
45 });
46 const out = p.valves.map(
47 (v, j) => html`<path class="pipe pipe-out pipe-valve-${v.mode}" d="${curve(VALVE_X + 14, vy(j), SHIPPED_X - 10, shippedY)}" stroke-width="${width(v.usd).toFixed(1)}"><title>${v.slug} to shipped: ${money(v.usd)}, ${String(v.shipped)} shipped</title></path>`,
48 );
49 const labels = sources.map(
50 (s, i) => html`<text class="pipe-label" x="${String(SOURCE_X - 10)}" y="${String(sy(i) + 4)}" text-anchor="end">${s.source === "notes" ? "your notes" : s.source} ${s.source === "notes" ? String(s.runs) : money(s.usd)}</text>`,
51 );
52 const valves = p.valves.map(
53 (v, j) => html`<g class="pipe-valve pipe-valve-${v.mode}">
54 <rect x="${String(VALVE_X - 14)}" y="${String(vy(j) - 12)}" width="28" height="24" rx="4" />
55 <text class="pipe-label" x="${String(VALVE_X)}" y="${String(vy(j) + 4)}" text-anchor="middle">${v.mode === "open" ? "open" : v.mode === "shut" ? "shut" : `${String(v.level)}%`}</text>
56 <text class="pipe-label pipe-repo" x="${String(VALVE_X)}" y="${String(vy(j) - 16)}" text-anchor="middle">${v.slug}</text>
57 </g>`,
58 );
59 return html`<svg class="pipes" viewBox="0 0 ${String(W)} ${String(H)}" role="img" aria-label="The week's budget: ${money(p.usd)} spent by ${String(p.valves.length)} repositories, ${String(p.shipped)} pulls shipped. The table below has the same numbers.">
60 ${pipes}${out}${valves}${labels}
61 <text class="pipe-label" x="${String(SHIPPED_X + 10)}" y="${String(shippedY - 4)}">shipped</text>
62 <text class="pipe-label" x="${String(SHIPPED_X + 10)}" y="${String(shippedY + 14)}">${String(p.shipped)} pulls</text>
63 </svg>`;
64 }
src/web/pages/boards-view.ts +1
@@ -116,6 +116,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
116116 ctx,
117117 title: "Boards",
118118 body: html`<div class="page-head"><h1>Boards</h1>
119 ${btn("Flow", { href: "/flow", variant: "ghost" })}
119120 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
120121 ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))}
121122 ${card(make)} ${body} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`,
src/web/pages/flow.ts added +122
@@ -0,0 +1,122 @@
1 /**

No src/tui/ screen mirrors this page. Add one with the same admin gate, and make a terminal turn reach web viewers live.

2 * /flow: the week's budget as pipes a forge admin can turn. The first paint
3 * is the whole page (`flow_pipes.ts` draws it, a form per valve turns it);
4 * the little script only submits a drag. A turn is `POST /flow/valve`, the
5 * suggestion beside a valve is `POST /flow/suggestion`, and both end back
6 * here with one sentence of what the next tick will do. The terminal door is
7 * `tui/screens/valves.ts`, on the same `readPipes` and the same actions.
8 */
9
10 import { acceptSuggestion, turnValveFor, type ValveError } from "../../actions/valves.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { readPipes, type ValveRow } from "../../flow/pipes.ts";
13 import { levelWords } from "../../flow/valve.ts";
14 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
15 import { notFoundPage, signInFirst } from "../chrome.ts";
16 import { pipesSvg } from "../flow_pipes.ts";
17 import { field, readForm } from "../form.ts";
18 import { html, when } from "../html.ts";
19 import { flashTo, page } from "../layout.ts";
20 import { btn, card, icon, row, time } from "../ui.ts";
21
22 const money = (usd: number): string => `$${usd.toFixed(2)}`;
23 const plural = (n: number, one: string, many = `${one}s`): string => `${String(n)} ${n === 1 ? one : many}`;
24
25 /** The script: a drag submits its form, and the words follow the handle. */
26 export const valveJs = /* js */ `
27 (function () {
28 var words = function (n) {
29 return n >= 100 ? "Open: runs start as the crew picks them." : n <= 0 ? "Shut: nothing new starts here until you open it." : n + "% of the week: runs start in turn up to that share, then yield to the other repositories.";
30 };
31 document.querySelectorAll("form.valve-form").forEach(function (form) {
32 var range = form.querySelector("input[type=range]");
33 var out = form.querySelector("output");
34 if (!range || !out) return;
35 range.addEventListener("input", function () { out.textContent = words(Number(range.value)); });
36 range.addEventListener("change", function () { form.requestSubmit(); });
37 });
38 })();
39 `;
40
41 function nextWords(v: ValveRow): string {
42 if (v.next.queued === 0) return "Nothing queued, so the next tick starts nothing here.";
43 const parts = [`${plural(v.next.goes, "run")} start${v.next.goes === 1 ? "s" : ""} in turn`];
44 if (v.next.yields > 0) parts.push(`${String(v.next.yields)} yield${v.next.yields === 1 ? "s" : ""} to other repositories`);
45 if (v.next.held > 0) parts.push(`${String(v.next.held)} stay${v.next.held === 1 ? "s" : ""} queued, held by the valve`);
46 return `Next tick, of ${String(v.next.queued)} queued: ${parts.join(", ")}.`;
47 }
48
49 function valveRow(v: ValveRow) {
50 const turned = v.turned;
51 return row({
52 lead: icon("activity"),
53 title: html`<a href="/${v.slug}">${v.slug}</a>
54 <span class="muted small">${money(v.usd)} this week · ${plural(v.runs, "run")} · ${plural(v.shipped, "pull")} shipped${v.notes > 0 ? html` · ${plural(v.notes, "note")}` : ""}</span>`,
55 meta: html`<form class="valve-form" method="post" action="/flow/valve">
56 <input type="hidden" name="repo" value="${v.slug}" />
57 <input class="valve-range" type="range" name="level" min="0" max="100" step="5" value="${String(v.level)}" aria-label="Valve for ${v.slug}: 0 shut, 100 open" />
58 <output class="muted small">${levelWords(v.level)}</output>
59 ${btn("Set", { size: "sm", label: `Set the valve for ${v.slug}` })}
60 </form>
61 <p class="muted small">${nextWords(v)}</p>
62 ${when(
63 turned !== undefined,
64 () => html`<p class="muted small">${turned!.by} turned it ${String(turned!.from)} → ${String(turned!.to)} ${time(turned!.at)}; since, ${plural(turned!.started, "run")} started.</p>`,
65 )}
66 ${when(
67 v.suggestion !== undefined,
68 () => html`<form class="valve-form" method="post" action="/flow/suggestion">
69 <input type="hidden" name="repo" value="${v.slug}" />
70 <span class="small">You looked here ${plural(v.looks, "time")} this week: ${String(v.suggestion)}% would match. It moves nothing until you tap.</span>
71 ${btn(`Take ${String(v.suggestion)}%`, { size: "sm", label: `Set the valve for ${v.slug} to ${String(v.suggestion)}%` })}
72 </form>`,
73 )}`,
74 });
75 }
76
77 // GET /flow
78 export function flowPage(ctx: Ctx): Response {
79 if (ctx.url.search !== "") return new Response(null, { status: 301, headers: { location: `/boards${ctx.url.search}` } });
80 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
81 const user = ctx.auth.user;
82 if (user === undefined) return signInFirst(ctx);
83 if (!user.isAdmin || user.kind === "agent") return notFoundPage(ctx);
84 const p = readPipes(ctx.app.db, user.id);
85 const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div>
86 <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p>
87 ${card(html`<div class="card-body">${pipesSvg(p)}
88 <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p></div>`)}
89 ${p.valves.length === 0
90 ? card(html`<div class="card-body"><p class="muted">No repository has spent, queued or shipped anything this week, so there is no pipe to turn yet.</p></div>`)
91 : card(html`<ul class="list">${p.valves.map(valveRow)}</ul>`, { pad: false, head: html`${icon("activity")}<h2>Valves</h2>` })}`;
92 const rendering = performance.now();
93 const response = page({ ctx, title: "Flow", body, scriptJs: valveJs });
94 notePhase(ctx.request, "render", performance.now() - rendering);
95 return response;
96 }
97
98 const ERRORS: Record<ValveError, string> = {
99 not_found: "No such repository.",
100 invalid_level: "A valve is a whole number from 0 (shut) to 100 (open).",
101 };
102
103 function done(ctx: Ctx, result: ReturnType<typeof turnValveFor>): Response {
104 if (!result.ok && result.error === "not_found") return notFoundPage(ctx);
105 return flashTo(ctx, "/flow", result.ok ? { message: result.value.from === result.value.to ? `${result.value.repo} already reads so.` : result.value.says } : { tone: "danger", message: ERRORS[result.error] });
106 }
107
108 // POST /flow/valve
109 export async function valveSet(ctx: Ctx): Promise<Response> {
110 if (!ctx.auth.user) return signInFirst(ctx);
111 const form = await readForm(ctx, "admin");
112 if (form instanceof Response) return form;
113 return done(ctx, turnValveFor(ctx.app, ctx.auth.user, field(form, "repo"), field(form, "level")));
114 }
115
116 // POST /flow/suggestion
117 export async function suggestionTake(ctx: Ctx): Promise<Response> {
118 if (!ctx.auth.user) return signInFirst(ctx);
119 const form = await readForm(ctx, "admin");
120 if (form instanceof Response) return form;
121 return done(ctx, acceptSuggestion(ctx.app, ctx.auth.user, field(form, "repo")));
122 }
src/web/pages/repo.ts +8
@@ -8,6 +8,7 @@
88 * path segment and can never smuggle a path of its own.
99 */
1010
11 import { recordLook } from "../../flow/valve_store.ts";
1112 import { immutableWhenAddressed } from "../../http/addressed.ts";
1213 import { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts";
1314 import { behindLine, followingLine } from "./upstream-follow.ts";
@@ -1179,6 +1180,12 @@ export function rawResponse(
11791180 return new Response(blob.body, { headers });
11801181 }
11811182
1183 /** A forge admin looking at a repository is counted, to suggest a valve on /flow and never to set one. */
1184 function noteLook(ctx: Ctx, repoId: string): void {
1185 const user = ctx.auth.user;
1186 if (user?.isAdmin === true && user.kind !== "agent") recordLook(ctx.app.db, user.id, repoId);
1187 }
1188
11821189 // GET /:owner/:name — the repo home. An app's home is the app, for every
11831190 // viewer: what it serves, who is on it, what it asks; its code is one tab
11841191 // deeper, at /files. Any other repository's home is its code. An older
@@ -1186,6 +1193,7 @@ export function rawResponse(
11861193 export async function repoHome(ctx: Ctx): Promise<Response> {
11871194 const repo = loadRepo(ctx);
11881195 if (!repo) return notFoundPage(ctx);
1196 noteLook(ctx, repo.id);
11891197 const record = getAppByRepo(ctx.app.db, repo.id);
11901198 if (record === undefined) return await codeHome(ctx, repo);
11911199 if (ctx.url.searchParams.has("ref"))
src/web/style/content.ts +13
@@ -849,6 +849,19 @@ textarea.editor:focus-visible { outline: none; }
849849 #needs-you #asks-phone-more > .stack { margin-top: 0; }
850850 }
851851 }
852 /* Flow (#8698): the week's pipes. Width is spend; a valve is drawn open,
853 half-open (a share) or shut, in tokens. */
854 .pipes { width: 100%; height: auto; display: block; }
855 .pipe { fill: none; stroke: currentColor; opacity: .5; }
856 .pipe-out { color: var(--series-3); }
857 .pipe-out.pipe-valve-shut { stroke-dasharray: 4 4; opacity: .35; }
858 .pipe-valve rect { fill: var(--bg); stroke: var(--border); stroke-width: 1.5; }
859 .pipe-valve-share rect { stroke: var(--series-1); }
860 .pipe-valve-shut rect { stroke: var(--series-5); fill: var(--bg); }
861 .pipe-label { font-size: 12.5px; fill: var(--fg); }
862 .pipe-repo { font-size: 12.5px; fill: var(--muted-fg); }
863 .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; }
864 .valve-range { flex: 1 1 12rem; min-width: 8rem; }
852865 /* The config form (#7336): nested objects as quiet fieldsets, scalar
853866 arrays as a wrapping row of chips, object arrays as cards. */
854867 .config-group { border-left: 2px solid var(--border); padding-left: 12px; }
test/conversation-confirm.test.ts +1 −1
@@ -121,7 +121,7 @@ describe("the one list of what asks first", () => {
121121 // settings and can be put back. A new `writes: true` tool is on neither list until someone decides.
122122 const NOT_ASKED = new Set([
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "cloud_accounts", "close", "confirm_wait",
124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
127127 "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
test/flow-pipeline.test.ts +3 −3
@@ -252,12 +252,12 @@ describe("/flow: the forge shows its own bottleneck", () => {
252252 expect(await (await get("/", cookie)).text()).not.toContain('href="/flow"');
253253 });
254254
255 test("/flow is a permanent redirect to /boards, and /boards is for a forge admin", async () => {
255 test("/flow with a query is the old drill, a permanent redirect to /boards; /boards is for a forge admin", async () => {
256256 const bob = await login("bob");
257257 for (const cookie of [bob, ""]) {
258 const res = await get("/flow", cookie);
258 const res = await get("/flow?out=approved", cookie);
259259 expect(res.status).toBe(301);
260 expect(res.headers.get("location")).toBe("/boards");
260 expect(res.headers.get("location")).toBe("/boards?out=approved");
261261 }
262262 expect((await get("/boards", bob)).status).toBe(404);
263263 expect(await (await get("/", bob)).text()).not.toContain('href="/flow"');
test/flow-valves.test.ts added +244
@@ -0,0 +1,244 @@
1 /**
2 * /flow (#8698): the week's budget as pipes with a valve per repository.
3 * A valve is a stored value only a person changes; the same valves and the
4 * same queue always decide the same way; where a person looks is a
5 * suggestion beside a valve and never moves it.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { acceptSuggestion, turnValveFor } from "../src/actions/valves.ts";
15 import { createRun, listQueued } from "../src/core/runs.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { throughValves, parseLevel, suggestedLevel } from "../src/flow/valve.ts";
18 import { gateQueue } from "../src/flow/valve_gate.ts";
19 import { readPipes } from "../src/flow/pipes.ts";
20 import { readLevels, recordLook } from "../src/flow/valve_store.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22
23 type Item = { id: string; repoId: string; resumed: boolean };
24 const item = (id: string, repoId: string, resumed = false): Item => ({ id, repoId, resumed });
25 const waiting = (t: Item) => t;
26 const ids = (xs: readonly Item[]) => xs.map((x) => x.id);
27
28 describe("the valve rule", () => {
29 test("with no valve the queue goes as it came", () => {
30 const q = [item("1", "a"), item("2", "b"), item("3", "a")];
31 const out = throughValves(q, waiting, new Map(), new Map());
32 expect(ids(out.order)).toEqual(["1", "2", "3"]);
33 expect(out.held).toEqual([]);
34 });
35
36 test("a shut valve holds its runs, and a resumed run is never held", () => {
37 const q = [item("1", "a"), item("2", "b"), item("3", "a", true)];
38 const out = throughValves(q, waiting, new Map([["a", 0]]), new Map());
39 expect(ids(out.order)).toEqual(["2", "3"]);
40 expect(ids(out.held)).toEqual(["1"]);
41 });
42
43 test("a share lets a repository start in turn up to it, then it yields but is not dropped", () => {
44 const q = [item("1", "a"), item("2", "a"), item("3", "b"), item("4", "a")];
45 // a may hold 10% of the week; b has started 3 already.
46 const out = throughValves(q, waiting, new Map([["a", 10]]), new Map([["b", 3]]));
47 expect(ids(out.order)).toEqual(["1", "3", "2", "4"]);
48 expect(ids(out.yielding)).toEqual(["2", "4"]);
49 expect(out.held).toEqual([]);
50 });
51
52 test("the same valves and the same queue decide the same way, every time", () => {
53 const q = Array.from({ length: 12 }, (_, i) => item(String(i), ["a", "b", "c"][i % 3]!));
54 const levels = new Map([["a", 30], ["b", 0], ["c", 60]]);
55 const starts = new Map([["a", 4], ["c", 1]]);
56 const first = JSON.stringify(throughValves(q, waiting, levels, starts));
57 for (let n = 0; n < 5; n++) expect(JSON.stringify(throughValves(q, waiting, levels, starts))).toBe(first);
58 });
59
60 test("a level is a whole number 0 to 100", () => {
61 expect(parseLevel("40")).toBe(40);
62 expect(parseLevel(0)).toBe(0);
63 for (const bad of ["101", "-1", "4.5", "x", "", null, undefined, 1.5, "1e2"]) expect(parseLevel(bad)).toBeUndefined();
64 });
65
66 test("looks suggest a share, only with enough of them and only when it differs", () => {
67 const looks = [{ repoId: "a", n: 8 }, { repoId: "b", n: 2 }];
68 expect(suggestedLevel(looks, "a", 100)).toBe(80);
69 expect(suggestedLevel(looks, "a", 75)).toBeUndefined();
70 expect(suggestedLevel(looks, "c", 100)).toBeUndefined();
71 expect(suggestedLevel([{ repoId: "a", n: 3 }], "a", 100)).toBeUndefined();
72 });
73 });
74
75 describe("/flow", () => {
76 let dir: string;
77 let app: App;
78 let servers: Servers;
79 let alice: User;
80 let bob: User;
81 let shop = "";
82 let blog = "";
83 let shopId = "";
84 let blogId = "";
85
86 const login = async (username: string): Promise<string> => {
87 const res = await fetch(`${servers.url}/signin`, {
88 method: "POST",
89 redirect: "manual",
90 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
91 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
92 });
93 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
94 };
95 const get = (path: string, cookie: string) => fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" });
96 const post = (path: string, cookie: string, body: Record<string, string>, origin = servers.url) =>
97 fetch(`${servers.url}${path}`, {
98 method: "POST",
99 redirect: "manual",
100 headers: { cookie, origin, "content-type": "application/x-www-form-urlencoded" },
101 body: new URLSearchParams(body),
102 });
103 const level = (repoId: string) => readLevels(app.db).get(repoId);
104
105 beforeAll(async () => {
106 dir = await mkdtemp(join(tmpdir(), "forge-valves-"));
107 app = createApp({ dataDir: dir, port: 0 });
108 servers = await serve(app);
109 const make = async (username: string, adminIfFirst: boolean) => {
110 const u = await createUser(app.db, {
111 origin: "seed",
112 trusted: true,
113 username,
114 email: `${username}@forge.local`,
115 password: "a-good-passphrase",
116 adminIfFirst,
117 });
118 if (!u.ok) throw new Error(u.error);
119 return u.value;
120 };
121 alice = await make("alice", true);
122 bob = await make("bob", false);
123 for (const name of ["shop", "blog"]) {
124 const made = await createRepo(app, alice, { name });
125 if (!made.ok) throw new Error(String(made.error));
126 if (name === "shop") shopId = made.value.id;
127 else blogId = made.value.id;
128 }
129 shop = "alice/shop";
130 blog = "alice/blog";
131 });
132 afterAll(async () => {
133 servers.stop();
134 await rm(dir, { recursive: true, force: true });
135 });
136
137 test("an admin sees the pipes and a valve for each repository; anyone else does not see the page", async () => {
138 createRun(app.db, { repoId: shopId, kind: "agent", reason: "assigned" });
139 const cookie = await login("alice");
140 const res = await get("/flow", cookie);
141 expect(res.status).toBe(200);
142 const text = await res.text();
143 expect(text).toContain('class="pipes');
144 expect(text).toContain(`value="${shop}"`);
145 expect(text).toContain('type="range"');
146 expect(text).toContain("Next tick, of 1 queued");
147 expect((await get("/flow", await login("bob"))).status).toBe(404);
148 expect((await get("/flow", "")).status).toBe(303);
149 // The old pipeline drill links still go to the boards.
150 expect((await get("/flow?out=approved", cookie)).headers.get("location")).toBe("/boards?out=approved");
151 });
152
153 test("turning a valve is one POST, stored, and the next tick's queue obeys it", async () => {
154 const cookie = await login("alice");
155 const q1 = createRun(app.db, { repoId: blogId, kind: "agent", reason: "assigned" });
156 const before = gateQueue(app.db, listQueued(app.db, "agent", 50));
157 expect(ids2(before.order)).toContain(q1.id);
158 const res = await post("/flow/valve", cookie, { repo: blog, level: "0" });
159 expect(res.status).toBe(303);
160 expect(level(blogId)).toBe(0);
161 const after = gateQueue(app.db, listQueued(app.db, "agent", 50));
162 expect(ids2(after.held)).toEqual([q1.id]);
163 expect(ids2(after.order)).not.toContain(q1.id);
164 // The page says what it did: the turn, and what is held.
165 const text = await (await get("/flow", cookie)).text();
166 expect(text).toContain("1 stays queued, held by the valve");
167 expect(text).toContain("alice turned it 100 → 0");
168 // Open again.
169 await post("/flow/valve", cookie, { repo: blog, level: "100" });
170 expect(ids2(gateQueue(app.db, listQueued(app.db, "agent", 50)).held)).toEqual([]);
171 });
172
173 test("a bad level, a stranger, a cross-origin post and an agent change nothing", async () => {
174 const cookie = await login("alice");
175 await post("/flow/valve", cookie, { repo: shop, level: "70" });
176 expect(level(shopId)).toBe(70);
177 await post("/flow/valve", cookie, { repo: shop, level: "250" });
178 await post("/flow/valve", cookie, { repo: shop, level: "abc" });
179 expect((await post("/flow/valve", cookie, { repo: shop, level: "0" }, "https://evil.example")).status).toBe(403);
180 expect((await post("/flow/valve", await login("bob"), { repo: shop, level: "0" })).status).toBe(404);
181 expect(level(shopId)).toBe(70);
182 const agent = { ...alice, kind: "agent" as const };
183 expect(turnValveFor(app, agent, shop, 0)).toEqual({ ok: false, error: "not_found" });
184 expect(turnValveFor(app, bob, shop, 0)).toEqual({ ok: false, error: "not_found" });
185 expect(turnValveFor(app, alice, "alice/missing", 0)).toEqual({ ok: false, error: "not_found" });
186 expect(turnValveFor(app, alice, shop, 101)).toEqual({ ok: false, error: "invalid_level" });
187 expect(level(shopId)).toBe(70);
188 });
189
190 test("where a person looks is a suggestion beside a valve, taken with one tap and never by itself", async () => {
191 const cookie = await login("alice");
192 await post("/flow/valve", cookie, { repo: shop, level: "100" });
193 // Twelve looks at the shop and one at the blog, recorded by visiting the repository page.
194 for (let n = 0; n < 12; n++) recordLook(app.db, alice.id, shopId);
195 await get(`/${blog}`, cookie);
196 expect(level(shopId)).toBe(100);
197 const page = await (await get("/flow", cookie)).text();
198 expect(page).toContain("would match");
199 expect(page).toContain("Take 90%");
200 expect(level(shopId)).toBe(100);
201 // Bob, not an admin, takes nothing.
202 expect(acceptSuggestion(app, bob, shop)).toEqual({ ok: false, error: "not_found" });
203 const res = await post("/flow/suggestion", cookie, { repo: shop, level: "5" });
204 expect(res.status).toBe(303);
205 expect(level(shopId)).toBe(90);
206 // Nothing suggested now: the valve is where the looks are.
207 expect(acceptSuggestion(app, alice, shop)).toEqual({ ok: false, error: "invalid_level" });
208 });
209
210 test("the pipes carry this week's real numbers", () => {
211 const run = createRun(app.db, { repoId: shopId, kind: "agent", reason: "assigned" });
212 app.db.query("UPDATE run SET state = 'ok', started_at = ?1, cost_usd = 2.5 WHERE id = ?2").run(new Date().toISOString(), run.id);
213 const pipes = readPipes(app.db, alice.id);
214 const row = pipes.valves.find((v) => v.slug === shop)!;
215 expect(row.usd).toBeCloseTo(2.5);
216 expect(row.runs).toBe(1);
217 expect(pipes.sources.find((s) => s.source === "builders")!.usd).toBeCloseTo(2.5);
218 expect(pipes.usd).toBeCloseTo(2.5);
219 });
220 });
221
222 const ids2 = (runs: readonly { id: string }[]) => runs.map((r) => r.id);
223
224 describe("the week's reads walk an index", () => {
225 test("starts, notes and shipped are never a scan of a growing table", async () => {
226 const dir = await mkdtemp(join(tmpdir(), "forge-valves-plan-"));
227 const app = createApp({ dataDir: dir, port: 0 });
228 try {
229 const sqls = [
230 "SELECT repo_id, COUNT(*) AS n FROM run WHERE kind = 'agent' AND started_at >= ?1 GROUP BY repo_id",
231 `SELECT i.repo_id, COUNT(*) AS n FROM issue i JOIN user u ON u.id = i.author_id
232 WHERE i.created_at >= ?1 AND i.kind = 'issue' AND u.kind = 'user' AND u.is_agent = 0 GROUP BY i.repo_id`,
233 `SELECT i.repo_id, COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id
234 WHERE p.merged_at >= ?1 GROUP BY i.repo_id`,
235 ];
236 for (const sql of sqls) {
237 const plan = app.db.query(`EXPLAIN QUERY PLAN ${sql}`).all("x").map((r) => (r as { detail: string }).detail);
238 for (const line of plan) expect(line).not.toMatch(/^SCAN (run|issue|pull)\b(?! USING)/);
239 }
240 } finally {
241 await rm(dir, { recursive: true, force: true });
242 }
243 });
244 });
test/forge-looks.test.ts +1 −1
@@ -43,7 +43,7 @@ const NOT_A_PAGE: Record<string, string> = {
4343 "/tell": "answers 404 to a GET",
4444 "/asks": "redirects to /#needs-you",
4545 "/big": "redirects to /board?wall",
46 "/flow": "redirects to /boards",
46 "/pipeline": "four static words on how a change travels; /flow is the page with numbers",
4747 "/login": "redirects to /signin",
4848 "/sign-in": "redirects to /signin",
4949 "/mcp": "a protocol endpoint, answers 405 to a GET",
test/mcp.test.ts +1
@@ -340,6 +340,7 @@ describe("the forge over MCP", () => {
340340 "scorecard",
341341 "flags",
342342 "flow",
343 "valves",
343344 "system",
344345 "backups",
345346 "telemetry_history",
test/palette-roster.test.ts +1
@@ -77,6 +77,7 @@ test("a site-admin person is offered the whole roster", () => {
7777 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
7878 ["flow", "Flow", "the pipeline's bottleneck and throughput"],
7979 ["system", "System", "the machine now: CPU, memory, disks, processes"],
80 ["valves", "Valves", "the week's budget as a valve per repository; turn one and the crew obeys"],
8081 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
8182 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
8283 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
test/server-timing-sweep.test.ts −1
@@ -44,7 +44,6 @@ const REDIRECTS = new Set([
4444 "/alice/forge/tells",
4545 "/alice/forge/tells/new",
4646 "/big",
47 "/flow",
4847 "/login",
4948 "/openapi.json",
5049 "/settings/origins/callback",
test/system-telemetry.test.ts +4 −8
@@ -400,14 +400,10 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el
400400 expect(flowCommand()).toBeUndefined();
401401 });
402402
403 test("GET /flow is a permanent redirect to /boards, for anyone", async () => {
404 for (const token of [undefined, tokens["alice"]]) {
405 const res = await fetch(`${servers.url}/flow`, { redirect: "manual", headers: token ? { authorization: `Bearer ${token}` } : {} });
406 expect(res.status).toBe(301);
407 expect(res.headers.get("location")).toBe("/boards");
408 }
409 const drill = await fetch(`${servers.url}/flow?out=approved&hour=3`, { redirect: "manual" });
410 expect(drill.headers.get("location")).toBe("/boards?out=approved&hour=3");
403 test("GET /flow is the pipes page, no longer a redirect to /boards", async () => {
404 const res = await fetch(`${servers.url}/flow`, { redirect: "manual", headers: { authorization: `Bearer ${tokens["alice"]}` } });
405 expect(res.status).toBe(200);
406 expect(res.headers.get("location")).toBeNull();
411407 });
412408
413409 test("in the terminal, the owner is offered System and Telemetry; nobody else is", () => {
test/tui-valves.test.ts added +103
@@ -0,0 +1,103 @@
1 /** #8698: the valves in the terminal, a forge admin's who is a person; a turn made there reaches everyone live, and only those who may hear it. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { turnValveFor } from "../src/actions/valves.ts";
9 import { audienceFilter } from "../src/core/event_audience.ts";
10 import { listEvents } from "../src/core/events.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { readLevels } from "../src/flow/valve_store.ts";
13 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp, settleUntil } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let app: App;
22 let alice: User;
23 let bob: User;
24 let repoId: string;
25 const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-tui-valves-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 const mk = async (username: string, adminIfFirst: boolean) => {
31 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
32 if (!r.ok) throw new Error(r.error);
33 return r.value;
34 };
35 alice = await mk("alice", true);
36 bob = await mk("bob", false);
37 const made = await createRepo(app, alice, { name: "vault", visibility: "private" });
38 if (!made.ok) throw new Error(String(made.error));
39 repoId = made.value.id;
40 turnValveFor(app, alice, "alice/vault", 50);
41 }, 30_000);
42
43 afterAll(async () => {
44 app.close();
45 await rm(dir, { recursive: true, force: true });
46 });
47
48 const door = (u: User) => {
49 const { app: tui, session } = createForgeApp(app, identity(u), { terminal: headlessTerminal(140, 24), exitHooks: false });
50 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
51 tui.stop?.();
52 return ids;
53 };
54
55 test("only an admin who is a person is offered Valves", () => {
56 expect(door(alice)).toContain("valves");
57 expect(door(bob)).not.toContain("valves");
58 });
59
60 test("a turn made in the terminal is kept, announced, and shown", async () => {
61 const { app: tui, session } = createForgeApp(app, identity(alice), { terminal: headlessTerminal(140, 24), exitHooks: false });
62 tui.start();
63 const press = async (k: string) => {
64 tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
65 await settleApp(tui);
66 };
67 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
68 for (const ch of "Valves") tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
69 await settleApp(tui);
70 await press("enter");
71 await settleUntil(tui, "alice/vault", { timeoutMs: 4_000 });
72 expect(tui.screen.toText()).toContain("50%");
73 await press("h");
74 expect(readLevels(app.db).get(repoId)).toBe(40);
75 await settleUntil(tui, "40%", { timeoutMs: 4_000 });
76 await press("x");
77 expect(readLevels(app.db).get(repoId)).toBe(0);
78 const turned = listEvents(app.db, { repoId }).filter((e) => e.kind === "valve.turned");
79 expect(turned.length).toBeGreaterThanOrEqual(3);
80 tui.stop?.();
81 expect(session.user?.id).toBe(alice.id);
82 });
83
84 test("a turn elsewhere reaches an open screen live", async () => {
85 const { app: tui } = createForgeApp(app, identity(alice), { terminal: headlessTerminal(140, 24), exitHooks: false });
86 tui.start();
87 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
88 for (const ch of "Valves") tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
89 await settleApp(tui);
90 tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false });
91 await settleUntil(tui, "alice/vault 0%", { timeoutMs: 4_000 });
92 turnValveFor(app, alice, "alice/vault", 70);
93 await settleUntil(tui, "alice/vault 70%", { timeoutMs: 4_000 });
94 tui.stop?.();
95 });
96
97 test("valve.turned names a repository, so only those who administer it hear it", () => {
98 const all = listEvents(app.db, { repoId }).filter((e) => e.kind === "valve.turned");
99 expect(all.length).toBeGreaterThan(0);
100 expect(all.filter(audienceFilter(app.db, bob))).toEqual([]);
101 expect(all.filter(audienceFilter(app.db, undefined))).toEqual([]);
102 expect(all.filter(audienceFilter(app.db, alice)).length).toBe(all.length);
103 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs