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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8698-flow-the-week-s-budget-as-pipes-i-can-se → main 12 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "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"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "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 { | ||
| 187 | 187 | kind === "repo.upstream_follow" || |
| 188 | 188 | kind === "repo.upstream_checked" || |
| 189 | 189 | 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" || | |
| 190 | 192 | // A clog starting names the forge's own pile-up: the owner's to hear (#6524). |
| 191 | 193 | kind.startsWith("clog.") || |
| 192 | 194 | // 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"; | ||
| 43 | 43 | import { appendRunLog } from "../runs/log.ts"; |
| 44 | 44 | import { decideAndRecord } from "./ledger.ts"; |
| 45 | 45 | import { byDials } from "./team_share.ts"; |
| 46 | import { gateQueue } from "../flow/valve_gate.ts"; | |
| 46 | 47 | import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts"; |
| 47 | 48 | import { listReviews } from "../core/reviews.ts"; |
| 48 | 49 | import { |
| @@ -1414,8 +1415,15 @@ function pump( | ||
| 1414 | 1415 | const started: string[] = []; |
| 1415 | 1416 | const noted = notedBy.get(app) ?? new Set<string>(); |
| 1416 | 1417 | 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) { | |
| 1419 | 1427 | if (queued.issueId !== null && runningIssues.has(queued.issueId)) continue; |
| 1420 | 1428 | const fresh = getRun(app.db, queued.id); |
| 1421 | 1429 | 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"; | ||
| 164 | 164 | export { tool as trail } from "./trail.ts"; |
| 165 | 165 | export { tool as upstream } from "./upstream.ts"; |
| 166 | 166 | export { tool as usage } from "./usage.ts"; |
| 167 | export { tool as valves } from "./valves.ts"; | |
| 167 | 168 | export { tool as variant } from "./variant.ts"; |
| 168 | 169 | export { tool as vision } from "./vision.ts"; |
| 169 | 170 | 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"; | ||
| 266 | 266 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 267 | 267 | import { changesList, tellsList } from "../web/studio/lists.ts"; |
| 268 | 268 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 269 | import { flowPage, suggestionTake, valveSet } from "../web/pages/flow.ts"; | |
| 269 | 270 | import { heldApply, heldPage, liftHoldSubmit } from "../web/pages/gate.ts"; |
| 270 | 271 | import { |
| 271 | 272 | assigneeSubmit, |
| @@ -775,7 +776,10 @@ export function buildRouter(): Router<Handler> { | ||
| 775 | 776 | // Every app on one canvas; a literal route, so it must sit above /:owner. |
| 776 | 777 | router.add("GET", "/map", globalMapPage); |
| 777 | 778 | // 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); | |
| 779 | 783 | router.add("GET", "/system", systemPage); |
| 780 | 784 | router.add("GET", "/system/stream", systemStream); |
| 781 | 785 | router.add("GET", "/contribute", contributePage); |
src/tui/screens/entries.ts +2
| @@ -40,6 +40,7 @@ import { entry as subscriptions } from "./subscriptions.ts"; | ||
| 40 | 40 | import { entry as system } from "./system.ts"; |
| 41 | 41 | import { entry as teams } from "./teams.ts"; |
| 42 | 42 | import { entry as telemetry } from "./telemetry.ts"; |
| 43 | import { entry as valves } from "./valves.ts"; | |
| 43 | 44 | |
| 44 | 45 | export interface PaletteEntry { |
| 45 | 46 | readonly id: string; |
| @@ -63,6 +64,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 63 | 64 | system, |
| 64 | 65 | telemetry, |
| 65 | 66 | resilience, |
| 67 | valves, | |
| 66 | 68 | access, |
| 67 | 69 | audit, |
| 68 | 70 | 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> { | ||
| 116 | 116 | ctx, |
| 117 | 117 | title: "Boards", |
| 118 | 118 | body: html`<div class="page-head"><h1>Boards</h1> |
| 119 | ${btn("Flow", { href: "/flow", variant: "ghost" })} | |
| 119 | 120 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 120 | 121 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))} |
| 121 | 122 | ${card(make)} ${body} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, |
src/web/pages/flow.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 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 @@ | ||
| 8 | 8 | * path segment and can never smuggle a path of its own. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { recordLook } from "../../flow/valve_store.ts"; | |
| 11 | 12 | import { immutableWhenAddressed } from "../../http/addressed.ts"; |
| 12 | 13 | import { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts"; |
| 13 | 14 | import { behindLine, followingLine } from "./upstream-follow.ts"; |
| @@ -1179,6 +1180,12 @@ export function rawResponse( | ||
| 1179 | 1180 | return new Response(blob.body, { headers }); |
| 1180 | 1181 | } |
| 1181 | 1182 | |
| 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 | ||
| 1182 | 1189 | // GET /:owner/:name — the repo home. An app's home is the app, for every |
| 1183 | 1190 | // viewer: what it serves, who is on it, what it asks; its code is one tab |
| 1184 | 1191 | // deeper, at /files. Any other repository's home is its code. An older |
| @@ -1186,6 +1193,7 @@ export function rawResponse( | ||
| 1186 | 1193 | export async function repoHome(ctx: Ctx): Promise<Response> { |
| 1187 | 1194 | const repo = loadRepo(ctx); |
| 1188 | 1195 | if (!repo) return notFoundPage(ctx); |
| 1196 | noteLook(ctx, repo.id); | |
| 1189 | 1197 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 1190 | 1198 | if (record === undefined) return await codeHome(ctx, repo); |
| 1191 | 1199 | if (ctx.url.searchParams.has("ref")) |
src/web/style/content.ts +13
| @@ -849,6 +849,19 @@ textarea.editor:focus-visible { outline: none; } | ||
| 849 | 849 | #needs-you #asks-phone-more > .stack { margin-top: 0; } |
| 850 | 850 | } |
| 851 | 851 | } |
| 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; } | |
| 852 | 865 | /* The config form (#7336): nested objects as quiet fieldsets, scalar |
| 853 | 866 | arrays as a wrapping row of chips, object arrays as cards. */ |
| 854 | 867 | .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", () => { | ||
| 121 | 121 | // settings and can be put back. A new `writes: true` tool is on neither list until someone decides. |
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "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", | |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "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", () => { | ||
| 252 | 252 | expect(await (await get("/", cookie)).text()).not.toContain('href="/flow"'); |
| 253 | 253 | }); |
| 254 | 254 | |
| 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 () => { | |
| 256 | 256 | const bob = await login("bob"); |
| 257 | 257 | for (const cookie of [bob, ""]) { |
| 258 | const res = await get("/flow", cookie); | |
| 258 | const res = await get("/flow?out=approved", cookie); | |
| 259 | 259 | expect(res.status).toBe(301); |
| 260 | expect(res.headers.get("location")).toBe("/boards"); | |
| 260 | expect(res.headers.get("location")).toBe("/boards?out=approved"); | |
| 261 | 261 | } |
| 262 | 262 | expect((await get("/boards", bob)).status).toBe(404); |
| 263 | 263 | 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> = { | ||
| 43 | 43 | "/tell": "answers 404 to a GET", |
| 44 | 44 | "/asks": "redirects to /#needs-you", |
| 45 | 45 | "/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", | |
| 47 | 47 | "/login": "redirects to /signin", |
| 48 | 48 | "/sign-in": "redirects to /signin", |
| 49 | 49 | "/mcp": "a protocol endpoint, answers 405 to a GET", |
test/mcp.test.ts +1
| @@ -340,6 +340,7 @@ describe("the forge over MCP", () => { | ||
| 340 | 340 | "scorecard", |
| 341 | 341 | "flags", |
| 342 | 342 | "flow", |
| 343 | "valves", | |
| 343 | 344 | "system", |
| 344 | 345 | "backups", |
| 345 | 346 | "telemetry_history", |
test/palette-roster.test.ts +1
| @@ -77,6 +77,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 77 | 77 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 78 | 78 | ["flow", "Flow", "the pipeline's bottleneck and throughput"], |
| 79 | 79 | ["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"], | |
| 80 | 81 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 81 | 82 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 82 | 83 | ["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([ | ||
| 44 | 44 | "/alice/forge/tells", |
| 45 | 45 | "/alice/forge/tells/new", |
| 46 | 46 | "/big", |
| 47 | "/flow", | |
| 48 | 47 | "/login", |
| 49 | 48 | "/openapi.json", |
| 50 | 49 | "/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 | ||
| 400 | 400 | expect(flowCommand()).toBeUndefined(); |
| 401 | 401 | }); |
| 402 | 402 | |
| 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(); | |
| 411 | 407 | }); |
| 412 | 408 | |
| 413 | 409 | 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 | }); |
new above
offline
No src/tui/ screen mirrors this page. Add one with the same admin gate, and make a terminal turn reach web viewers live.